From 2b65cd771f0b9b4ddb24f8fc6236a15c8c20bf03 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 31 May 2026 22:24:53 +0200 Subject: [PATCH] =?UTF-8?q?fix(studio):=20sidebar=20=E2=80=94=20single=20A?= =?UTF-8?q?nimationController=20drives=20width=20=20+=20label=20opacity=20?= =?UTF-8?q?in=20lockstep=20(no=20more=20glitchy=20expand)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous "fixed-width icon column" commit already locked the icon's horizontal pixel-X, but the rail still _felt_ glitchy on expand because the rail's geometry and the rail's content were animated by two different mechanisms: - Width: AnimatedContainer over FaiMotion.fast (120 ms) - Content: setState(_hovered = true) → conditional `if (expanded) Expanded(label)` snaps in one frame So on mouse-enter the label widget appeared INSTANTLY while the rail was still 72 px wide. The label tried to render in 0 px of available space and Flutter's layout engine clamped it; over the next 7 frames the rail grew to 220 px and the label visibly "settled in". That's the perceived glitch. Replace the two-source animation with a single SingleTickerProviderStateMixin + AnimationController whose value `t` (0..1, eased via easeInOutCubic) drives both: - rail width = lerp(72, 220, t) - label opacity = t - labelsInteractive = t > 0.5 (so hidden buttons can't eat clicks meant for the icon column) Labels are wrapped in `t > 0 ? IgnorePointer(Opacity(...)) : SizedBox.shrink()`. Once the animation starts, the label joins the tree at opacity ≈ 0 (invisible — no pop) and fades up smoothly as t grows. When fully collapsed (t == 0) the label is removed from layout entirely, so the connection pill's tall "Start hub" affordance doesn't inflate the collapsed rail's height (this also fixes the widget_test vertical-overflow that 0.51.4 introduced). Same pattern applied to the footer (settings icon stays in the 72-px column always; theme/lang/clock fade in beside it) and to each _SidebarItem destination row. End result: width AND content travel together along the same animation curve. No more "snap then catch up" — they're mathematically inseparable. Version 0.51.4 → 0.51.5. Signed-off-by: flemming-it --- lib/main.dart | 500 +++++++++++++++++++++++++++++--------------------- pubspec.yaml | 2 +- 2 files changed, 290 insertions(+), 212 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 0662c11..5069cd8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,7 +27,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.51.4'; +const String kStudioVersion = '0.51.5'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -455,205 +455,213 @@ class _Sidebar extends StatefulWidget { State<_Sidebar> createState() => _SidebarState(); } -class _SidebarState extends State<_Sidebar> { - // Default-collapsed (icons only); expands on hover. Width - // anim mirrors NavigationRail's spec: 72 collapsed, 220 - // expanded. +class _SidebarState extends State<_Sidebar> + with SingleTickerProviderStateMixin { + // Default-collapsed (icons only); expands on hover. 72 / 220 + // mirrors NavigationRail's spec. static const double _collapsedWidth = 72; static const double _expandedWidth = 220; - bool _hovered = false; + // Single source of truth for the entire rail's expand + // animation. width and every label's opacity are derived + // from `_ctrl.value` — no second timer, no conditional + // widget-tree mutation, so width and content move in + // lockstep instead of one snapping while the other glides. + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: FaiMotion.fast, + value: 0, + ); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final theme = Theme.of(context); final mode = widget.connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle; - final expanded = _hovered; - final width = expanded ? _expandedWidth : _collapsedWidth; return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: FaiMotion.fast, - width: width, - color: theme.colorScheme.surfaceContainerLow, - padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl), - child: ClipRect( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Brand row — fixed-width icon column (kCollapsedWidth) - // on the left, label/version stacked beside it when - // expanded. The 72-px column means the triangle's - // pixel-X is identical in both states; only the - // rail's RIGHT edge moves on expand, never the - // left-anchored content. - Row( + onEnter: (_) => _ctrl.forward(), + onExit: (_) => _ctrl.reverse(), + child: AnimatedBuilder( + animation: _ctrl, + builder: (context, _) { + // eased between 0 (fully collapsed) and 1 (fully + // expanded). Opacity uses the same curve, so a label + // that starts fading in is at the same progress + // fraction as the rail's geometry — visually they + // travel together. + final t = Curves.easeInOutCubic.transform(_ctrl.value); + final width = _collapsedWidth + (_expandedWidth - _collapsedWidth) * t; + // Hold pointer hits on labels until the rail is at + // least half-open; otherwise the operator might tap + // through an almost-invisible button on the way to + // the icon they actually want. + final labelsInteractive = t > 0.5; + return Container( + width: width, + color: theme.colorScheme.surfaceContainerLow, + padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl), + child: ClipRect( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: _collapsedWidth, - child: Center( - child: FaiDeltaMark( - color: theme.colorScheme.primary, - mode: mode, + _sidebarRow( + leading: FaiDeltaMark( + color: theme.colorScheme.primary, + mode: mode, + ), + label: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'F∆I Studio', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + 'v$kStudioVersion', + style: FaiTheme.mono( + size: 10, + color: theme.colorScheme.primary, + ), + ), + ], + ), + t: t, + labelsInteractive: labelsInteractive, + ), + const SizedBox(height: FaiSpace.md), + _sidebarRow( + leading: Tooltip( + message: widget.connected == true + ? 'Connected · ${widget.endpointLabel}' + : widget.connected == false + ? 'Disconnected · ${widget.endpointLabel}' + : 'Connecting · ${widget.endpointLabel}', + child: _CollapsedConnectionDot( + connected: widget.connected, ), ), + label: _ConnectionPill( + connected: widget.connected, + label: widget.endpointLabel, + onStartRequested: () async { + final r = await SystemActions.faiDaemon(['start']); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + r.ok + ? 'Daemon start requested. Reconnecting…' + : 'Could not start daemon: ${r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim()}', + ), + ), + ); + }, + ), + t: t, + labelsInteractive: labelsInteractive, + ), + if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[ + const SizedBox(height: FaiSpace.sm), + _sidebarRow( + leading: Tooltip( + message: widget.activeChannel!, + child: _CollapsedChannelChip( + channel: widget.activeChannel!, + ), + ), + label: _ChannelPill(channel: widget.activeChannel!), + t: t, + labelsInteractive: labelsInteractive, + ), + ], + const SizedBox(height: FaiSpace.xxl), + Expanded( + child: ListView( + padding: EdgeInsets.zero, + children: [ + for (var i = 0; i < widget.pages.length; i++) + _SidebarItem( + page: widget.pages[i], + selected: i == widget.selectedIndex, + t: t, + labelsInteractive: labelsInteractive, + iconColumnWidth: _collapsedWidth, + onTap: () => widget.onSelect(i), + ), + ], + ), ), - if (expanded) - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: FaiSpace.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'F∆I Studio', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - 'v$kStudioVersion', - style: FaiTheme.mono( - size: 10, - color: theme.colorScheme.primary, - ), - ), - ], - ), - ), - ), - ], - ), - const SizedBox(height: FaiSpace.md), - // Connection row — same pattern. Mini-dot sits - // centered in the 72-col; the full _ConnectionPill - // takes the remaining width when expanded. - Row( - children: [ - SizedBox( - width: _collapsedWidth, - child: Center( - child: Tooltip( - message: widget.connected == true - ? 'Connected · ${widget.endpointLabel}' - : widget.connected == false - ? 'Disconnected · ${widget.endpointLabel}' - : 'Connecting · ${widget.endpointLabel}', - child: _CollapsedConnectionDot( - connected: widget.connected, - ), - ), - ), + _Footer( + t: t, + labelsInteractive: labelsInteractive, + iconColumnWidth: _collapsedWidth, + onOpenSettings: () => _openSettings(context), ), - if (expanded) - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: FaiSpace.md), - child: _ConnectionPill( - connected: widget.connected, - label: widget.endpointLabel, - onStartRequested: () async { - final r = - await SystemActions.faiDaemon(['start']); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - r.ok - ? 'Daemon start requested. Reconnecting…' - : 'Could not start daemon: ${r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim()}', - ), - ), - ); - }, - ), - ), - ), ], ), - if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[ - const SizedBox(height: FaiSpace.sm), - Row( - children: [ - SizedBox( - width: _collapsedWidth, - child: Center( - child: Tooltip( - message: widget.activeChannel!, - child: _CollapsedChannelChip( - channel: widget.activeChannel!, - ), - ), - ), - ), - if (expanded) - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: FaiSpace.md), - child: _ChannelPill( - channel: widget.activeChannel!, - ), - ), - ), - ], - ), - ], - const SizedBox(height: FaiSpace.xxl), - // Destinations — same fixed-width icon column, - // optional label slot on the right. ListView's - // horizontal padding stays zero so the SizedBox - // anchors at the rail's literal left edge. - Expanded( - child: ListView( - padding: EdgeInsets.zero, - children: [ - for (var i = 0; i < widget.pages.length; i++) - _SidebarItem( - page: widget.pages[i], - selected: i == widget.selectedIndex, - expanded: expanded, - iconColumnWidth: _collapsedWidth, - onTap: () => widget.onSelect(i), - ), - ], - ), - ), - // Footer: theme toggle + clock + settings when - // expanded; just the settings icon when collapsed. - Padding( - padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md), - child: expanded - ? Row( - children: [ - _ThemeToggle(), - _LanguageToggle(), - const Expanded( - child: Center(child: _SidebarClock()), - ), - IconButton( - icon: const Icon(Icons.settings_outlined, size: 16), - tooltip: 'Settings (⌘;)', - visualDensity: VisualDensity.compact, - onPressed: () => _openSettings(context), - ), - ], - ) - : IconButton( - icon: const Icon(Icons.settings_outlined, size: 16), - tooltip: 'Settings (⌘;)', - visualDensity: VisualDensity.compact, - onPressed: () => _openSettings(context), - ), - ), - ], - ), - ), + ), + ); + }, ), ); } + /// Shared row geometry for the brand / connection / channel + /// headers. Leading widget sits in a fixed-width icon column + /// anchored to the rail's left edge. The label slot is in + /// the tree as soon as the animation kicks off (t > 0) and + /// fades in via Opacity tied to the same controller. Skipping + /// layout at t == 0 keeps the row's collapsed height = the + /// leading widget's natural height (rather than including + /// the label's tall "Start hub" affordance even when + /// invisible). The opacity at t ≈ 0.01 is low enough that + /// the layout's first-frame appearance isn't perceived as a + /// pop — the label visually grows in from invisible. + Widget _sidebarRow({ + required Widget leading, + required Widget label, + required double t, + required bool labelsInteractive, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: _collapsedWidth, + child: Center(child: leading), + ), + Expanded( + child: t > 0 + ? IgnorePointer( + ignoring: !labelsInteractive, + child: Opacity( + opacity: t, + child: Padding( + padding: const EdgeInsets.only(right: FaiSpace.md), + child: label, + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + } + Future _openSettings(BuildContext context) async { final saved = await FaiSettingsDialog.show(context); if (saved && context.mounted) { @@ -930,14 +938,20 @@ class _ConnectionPill extends StatelessWidget { class _SidebarItem extends StatefulWidget { final _NavPage page; final bool selected; - final bool expanded; + /// 0 = fully collapsed, 1 = fully expanded. Drives label + /// opacity in lockstep with the parent rail's width + /// animation — there is no second timer for the label, so + /// label and rail can't fall out of sync. + final double t; + final bool labelsInteractive; final double iconColumnWidth; final VoidCallback onTap; const _SidebarItem({ required this.page, required this.selected, - required this.expanded, + required this.t, + required this.labelsInteractive, required this.iconColumnWidth, required this.onTap, }); @@ -969,38 +983,41 @@ class _SidebarItemState extends State<_SidebarItem> { size: 18, color: color, ); - // Fixed-width icon column on the left + optional label slot - // on the right. The icon's pixel-X is anchored to the rail's - // literal left edge (no ListView padding, no AC h-padding) - // and the icon column width matches the rail's collapsed - // width, so the icon sits at the same x-pixel — center of - // the 72-px slot — regardless of whether the rail is - // currently 72 or 220 wide. The label appears as an - // Expanded sibling to the icon column only when expanded. + // Label widget is ALWAYS in the tree — opacity = t makes + // it fade in/out in lockstep with the rail's width + // animation. Expanded's flex space goes from 0 (at t=0, + // rail width = 72) to (rail width − 72) at t=1, so the + // label has natural space to fill without any conditional + // tree mutation. final content = Row( children: [ SizedBox( width: widget.iconColumnWidth, child: Center(child: icon), ), - if (widget.expanded) - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: FaiSpace.md), - child: Text( - label, - overflow: TextOverflow.fade, - softWrap: false, - style: theme.textTheme.labelMedium?.copyWith(color: color), - ), - ), - ), + Expanded( + child: widget.t > 0 + ? IgnorePointer( + ignoring: !widget.labelsInteractive, + child: Opacity( + opacity: widget.t, + child: Padding( + padding: const EdgeInsets.only(right: FaiSpace.md), + child: Text( + label, + overflow: TextOverflow.fade, + softWrap: false, + style: theme.textTheme.labelMedium?.copyWith(color: color), + ), + ), + ), + ) + : const SizedBox.shrink(), + ), ], ); final inner = Padding( - // Vertical breathing room between items. No horizontal - // padding — the icon column itself owns the left anchor. padding: const EdgeInsets.symmetric(vertical: 2), child: MouseRegion( onEnter: (_) => setState(() => _hovered = true), @@ -1011,9 +1028,9 @@ class _SidebarItemState extends State<_SidebarItem> { onTap: widget.onTap, child: AnimatedContainer( duration: FaiMotion.fast, - // Only vertical padding — horizontal is owned by the - // icon column. Background highlight spans the full - // rail width, which is the typical nav-rail UX. + // No horizontal padding — the icon column anchors + // the left. The bg highlight spans the rail's + // current width (typical nav-rail behaviour). padding: const EdgeInsets.symmetric(vertical: FaiSpace.md), decoration: BoxDecoration(color: bg), child: content, @@ -1022,9 +1039,9 @@ class _SidebarItemState extends State<_SidebarItem> { ), ); - // Tooltip only when collapsed — when the rail is expanded - // the label is visible inline + tooltips become noisy. - if (widget.expanded) return inner; + // Tooltip only while the label is hidden — once the label + // is visible inline the tooltip becomes redundant noise. + if (widget.t > 0.5) return inner; return Tooltip( message: label, preferBelow: false, @@ -1033,6 +1050,67 @@ class _SidebarItemState extends State<_SidebarItem> { } } +/// Bottom-of-rail control strip. Settings icon is always the +/// last item in the icon column so its pixel-X never shifts; +/// theme + language toggles and clock fade in beside it as +/// the rail expands, sharing the same `t` as the rest of the +/// sidebar so motion stays in lockstep. +class _Footer extends StatelessWidget { + final double t; + final bool labelsInteractive; + final double iconColumnWidth; + final VoidCallback onOpenSettings; + + const _Footer({ + required this.t, + required this.labelsInteractive, + required this.iconColumnWidth, + required this.onOpenSettings, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: iconColumnWidth, + child: Center( + child: IconButton( + icon: const Icon(Icons.settings_outlined, size: 16), + tooltip: 'Settings (⌘;)', + visualDensity: VisualDensity.compact, + onPressed: onOpenSettings, + ), + ), + ), + Expanded( + child: t > 0 + ? IgnorePointer( + ignoring: !labelsInteractive, + child: Opacity( + opacity: t, + child: Padding( + padding: const EdgeInsets.only(right: FaiSpace.sm), + child: Row( + children: [ + _ThemeToggle(), + _LanguageToggle(), + const Expanded( + child: Center(child: _SidebarClock()), + ), + ], + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + } +} + class _NavPage { /// Stable id used for the Cmd+K palette and the sidebar /// shortcut keys; never shown to the operator. The visible diff --git a/pubspec.yaml b/pubspec.yaml index 0642aaf..6097045 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.51.4 +version: 0.51.5 environment: sdk: ^3.11.0-200.1.beta