fix(studio): sidebar — fixed-width icon column eliminates icon shift
Some checks failed
Security / Security check (push) Failing after 1s

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-30 16:15:03 +02:00
parent 1295675bd9
commit 5830198566
2 changed files with 153 additions and 154 deletions

View file

@ -27,7 +27,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.51.3'; const String kStudioVersion = '0.51.4';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -479,64 +479,84 @@ class _SidebarState extends State<_Sidebar> {
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl), padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
child: ClipRect( child: ClipRect(
child: Column( 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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Brand mark always visible. Pad it to match the // Brand row fixed-width icon column (kCollapsedWidth)
// icon's left offset (ListView padding + AC padding // on the left, label/version stacked beside it when
// = FaiSpace.md + FaiSpace.md), so the triangle's // expanded. The 72-px column means the triangle's
// left edge aligns with the destination icons' // pixel-X is identical in both states; only the
// left edges. // rail's RIGHT edge moves on expand, never the
Padding( // left-anchored content.
padding: const EdgeInsets.only( Row(
left: FaiSpace.md + FaiSpace.md, children: [
SizedBox(
width: _collapsedWidth,
child: Center(
child: FaiDeltaMark(
color: theme.colorScheme.primary,
mode: mode,
), ),
child:
FaiDeltaMark(color: theme.colorScheme.primary, mode: mode),
), ),
if (expanded) ...[ ),
const SizedBox(height: FaiSpace.md), if (expanded)
const Padding( Expanded(
padding: EdgeInsets.only(left: FaiSpace.md + FaiSpace.md), child: Padding(
child: Text( padding: const EdgeInsets.only(right: FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'F∆I Studio', 'F∆I Studio',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Padding( Text(
padding: const EdgeInsets.only(
left: FaiSpace.md + FaiSpace.md,
),
child: Text(
'v$kStudioVersion', 'v$kStudioVersion',
style: FaiTheme.mono( style: FaiTheme.mono(
size: 10, size: 10,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
), ),
],
),
),
),
],
), ),
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
// Connection pill left-aligned to the same // Connection row same pattern. Mini-dot sits
// x-anchor as the icons. // centered in the 72-col; the full _ConnectionPill
Padding( // takes the remaining width when expanded.
padding: const EdgeInsets.only( Row(
left: FaiSpace.md + FaiSpace.md, children: [
right: FaiSpace.md, 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( child: _ConnectionPill(
connected: widget.connected, connected: widget.connected,
label: widget.endpointLabel, label: widget.endpointLabel,
onStartRequested: () async { onStartRequested: () async {
final r = await SystemActions.faiDaemon(['start']); final r =
await SystemActions.faiDaemon(['start']);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@ -550,59 +570,16 @@ class _SidebarState extends State<_Sidebar> {
}, },
), ),
), ),
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 FI 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) ...[ if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Padding( Row(
padding: const EdgeInsets.only( children: [
left: FaiSpace.md + FaiSpace.md, SizedBox(
), width: _collapsedWidth,
child: Center(
child: Tooltip( child: Tooltip(
message: widget.activeChannel!, message: widget.activeChannel!,
child: _CollapsedChannelChip( child: _CollapsedChannelChip(
@ -610,19 +587,34 @@ class _SidebarState extends State<_Sidebar> {
), ),
), ),
), ),
),
if (expanded)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: _ChannelPill(
channel: widget.activeChannel!,
),
),
),
], ],
),
], ],
const SizedBox(height: FaiSpace.xxl), 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( Expanded(
child: ListView( child: ListView(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md), padding: EdgeInsets.zero,
children: [ children: [
for (var i = 0; i < widget.pages.length; i++) for (var i = 0; i < widget.pages.length; i++)
_SidebarItem( _SidebarItem(
page: widget.pages[i], page: widget.pages[i],
selected: i == widget.selectedIndex, selected: i == widget.selectedIndex,
expanded: expanded, expanded: expanded,
iconColumnWidth: _collapsedWidth,
onTap: () => widget.onSelect(i), onTap: () => widget.onSelect(i),
), ),
], ],
@ -939,12 +931,14 @@ class _SidebarItem extends StatefulWidget {
final _NavPage page; final _NavPage page;
final bool selected; final bool selected;
final bool expanded; final bool expanded;
final double iconColumnWidth;
final VoidCallback onTap; final VoidCallback onTap;
const _SidebarItem({ const _SidebarItem({
required this.page, required this.page,
required this.selected, required this.selected,
required this.expanded, required this.expanded,
required this.iconColumnWidth,
required this.onTap, required this.onTap,
}); });
@ -975,18 +969,24 @@ class _SidebarItemState extends State<_SidebarItem> {
size: 18, size: 18,
color: color, color: color,
); );
// Same Row + same outer padding in both states so the icon // Fixed-width icon column on the left + optional label slot
// stays at the exact same X-pixel before and after expand // on the right. The icon's pixel-X is anchored to the rail's
// (operators don't have to re-aim at a destination they // literal left edge (no ListView padding, no AC h-padding)
// already pointed at). The label slot is conditional, the // and the icon column width matches the rail's collapsed
// icon's position is not. // 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( final content = Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
icon, SizedBox(
if (widget.expanded) ...[ width: widget.iconColumnWidth,
const SizedBox(width: FaiSpace.md), child: Center(child: icon),
Flexible( ),
if (widget.expanded)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Text( child: Text(
label, label,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
@ -994,11 +994,13 @@ class _SidebarItemState extends State<_SidebarItem> {
style: theme.textTheme.labelMedium?.copyWith(color: color), style: theme.textTheme.labelMedium?.copyWith(color: color),
), ),
), ),
], ),
], ],
); );
final inner = Padding( 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), padding: const EdgeInsets.symmetric(vertical: 2),
child: MouseRegion( child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => setState(() => _hovered = true),
@ -1009,14 +1011,11 @@ class _SidebarItemState extends State<_SidebarItem> {
onTap: widget.onTap, onTap: widget.onTap,
child: AnimatedContainer( child: AnimatedContainer(
duration: FaiMotion.fast, duration: FaiMotion.fast,
padding: const EdgeInsets.symmetric( // Only vertical padding horizontal is owned by the
horizontal: FaiSpace.md, // icon column. Background highlight spans the full
vertical: FaiSpace.md, // rail width, which is the typical nav-rail UX.
), padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
decoration: BoxDecoration( decoration: BoxDecoration(color: bg),
color: bg,
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
child: content, child: content,
), ),
), ),

View file

@ -1,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.51.3 version: 0.51.4
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta