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
/// 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<void> 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 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) ...[
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,
),
),

View file

@ -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