From 5830198566449aad5230e0df35e7f2bda4dd76ad Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 30 May 2026 16:15:03 +0200 Subject: [PATCH] =?UTF-8?q?fix(studio):=20sidebar=20=E2=80=94=20fixed-widt?= =?UTF-8?q?h=20icon=20column=20eliminates=20icon=20shift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous "left-anchor everything via padding" approach made the math work on paper but the perceived shift persisted. The root cause was structural, not padding-based: ListView gave each item a tight cross-axis constraint equal to the rail's current width (48 collapsed, 196 expanded). Inside the item, the AnimatedContainer filled that constraint and laid out a mainAxisSize.min Row at the start of the AC's content area. The icon's pixel-X depended on the AC's padding _and_ the constraint; even with equal padding the rendered layout shifted across the expand animation because Flutter re-resolved alignment under a moving constraint. Replace the entire pattern with a fixed-width icon column that doesn't care about the parent constraint: ListView (padding 0) → Item Row SizedBox(width: 72) → Center → icon ← anchored to literal x if expanded → Expanded → label text ← grows into remaining space Same pattern for the brand/connection/channel rows above. The 72 px matches the rail's collapsed width exactly, so collapsed rail = one icon column + nothing; expanded rail = same icon column + label slot. Icon's pixel-X is now provably constant. Removes the AC's horizontal padding entirely — the icon column owns the left anchor. The background highlight still spans the full row width (typical nav-rail UX), and the rail expand animation only moves the rail's right edge, never any icon-bearing element. Also passes an `iconColumnWidth: 72` to _SidebarItem instead of hard-coding the magic number in two places. Version 0.51.3 → 0.51.4. Signed-off-by: flemming-it --- lib/main.dart | 305 +++++++++++++++++++++++++------------------------- pubspec.yaml | 2 +- 2 files changed, 153 insertions(+), 154 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index e1d0450..0662c11 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.3'; +const String kStudioVersion = '0.51.4'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -479,150 +479,142 @@ class _SidebarState extends State<_Sidebar> { padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl), child: ClipRect( child: Column( - // Left-align every column child so brand mark + top - // indicators sit at the same X-pixel as the icons - // below. With the default `center`, the brand mark - // re-positioned itself horizontally on every expand - // animation (centered in 72 vs centered in 220), - // which the user perceived as the whole nav rail - // shifting even though the destination icons stayed - // put. Anchoring everything to the start eliminates - // the perceived shift. crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Brand mark — always visible. Pad it to match the - // icon's left offset (ListView padding + AC padding - // = FaiSpace.md + FaiSpace.md), so the triangle's - // left edge aligns with the destination icons' - // left edges. - Padding( - padding: const EdgeInsets.only( - left: FaiSpace.md + FaiSpace.md, - ), - child: - FaiDeltaMark(color: theme.colorScheme.primary, mode: mode), - ), - if (expanded) ...[ - const SizedBox(height: FaiSpace.md), - const Padding( - padding: EdgeInsets.only(left: FaiSpace.md + FaiSpace.md), - child: Text( - 'F∆I Studio', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - ), - const SizedBox(height: 2), - Padding( - padding: const EdgeInsets.only( - left: FaiSpace.md + FaiSpace.md, - ), - child: Text( - 'v$kStudioVersion', - style: FaiTheme.mono( - size: 10, - color: theme.colorScheme.primary, - ), - ), - ), - const SizedBox(height: FaiSpace.md), - // Connection pill — left-aligned to the same - // x-anchor as the icons. - Padding( - padding: const EdgeInsets.only( - left: FaiSpace.md + FaiSpace.md, - 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), - Padding( - padding: const EdgeInsets.only( - left: FaiSpace.md + FaiSpace.md, - right: FaiSpace.md, - ), - child: _ChannelPill(channel: widget.activeChannel!), - ), - ], - ] else ...[ - // Collapsed top: keep the brand mark and the - // most glance-able status indicators (version, - // connection dot, channel letter) so the rail - // still reads as F∆I Studio at-a-glance without - // hovering to expand. All left-padded by the - // same offset as the destination icons ( - // ListView.padding + AC.padding = 12 + 12) - // so the column reads as one stable left edge. - const SizedBox(height: FaiSpace.sm), - Padding( - padding: const EdgeInsets.only( - left: FaiSpace.md + FaiSpace.md, - ), - child: Text( - 'v$kStudioVersion'.replaceFirst(RegExp(r'-.*'), ''), - style: FaiTheme.mono( - size: 9, - color: theme.colorScheme.primary, - ), - ), - ), - const SizedBox(height: FaiSpace.md), - Padding( - padding: const EdgeInsets.only( - left: FaiSpace.md + FaiSpace.md, - ), - child: Tooltip( - message: widget.connected == true - ? 'Connected · ${widget.endpointLabel}' - : widget.connected == false - ? 'Disconnected · ${widget.endpointLabel}' - : 'Connecting · ${widget.endpointLabel}', - child: - _CollapsedConnectionDot(connected: widget.connected), - ), - ), - if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[ - const SizedBox(height: FaiSpace.sm), - Padding( - padding: const EdgeInsets.only( - left: FaiSpace.md + FaiSpace.md, - ), - child: Tooltip( - message: widget.activeChannel!, - child: _CollapsedChannelChip( - channel: widget.activeChannel!, + // 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( + children: [ + SizedBox( + width: _collapsedWidth, + child: Center( + child: FaiDeltaMark( + color: theme.colorScheme.primary, + mode: mode, ), ), ), + 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, + ), + ), + ), + ), + 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. + // 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: const EdgeInsets.symmetric(horizontal: FaiSpace.md), + 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), ), ], @@ -939,12 +931,14 @@ class _SidebarItem extends StatefulWidget { final _NavPage page; final bool selected; final bool expanded; + final double iconColumnWidth; final VoidCallback onTap; const _SidebarItem({ required this.page, required this.selected, required this.expanded, + required this.iconColumnWidth, required this.onTap, }); @@ -975,30 +969,38 @@ class _SidebarItemState extends State<_SidebarItem> { size: 18, color: color, ); - // Same Row + same outer padding in both states so the icon - // stays at the exact same X-pixel before and after expand - // (operators don't have to re-aim at a destination they - // already pointed at). The label slot is conditional, the - // icon's position is not. + // 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. final content = Row( - mainAxisSize: MainAxisSize.min, children: [ - icon, - if (widget.expanded) ...[ - const SizedBox(width: FaiSpace.md), - Flexible( - child: Text( - label, - overflow: TextOverflow.fade, - softWrap: false, - style: theme.textTheme.labelMedium?.copyWith(color: color), + 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), + ), ), ), - ], ], ); 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), @@ -1009,14 +1011,11 @@ class _SidebarItemState extends State<_SidebarItem> { onTap: widget.onTap, child: AnimatedContainer( duration: FaiMotion.fast, - padding: const EdgeInsets.symmetric( - horizontal: FaiSpace.md, - vertical: FaiSpace.md, - ), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(FaiRadius.sm), - ), + // Only vertical padding — horizontal is owned by the + // icon column. Background highlight spans the full + // rail width, which is the typical nav-rail UX. + padding: const EdgeInsets.symmetric(vertical: FaiSpace.md), + decoration: BoxDecoration(color: bg), child: content, ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 85de955..0642aaf 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.3 +version: 0.51.4 environment: sdk: ^3.11.0-200.1.beta