From dbcff6f7a60080e7e9cbadf037052b6f01dad989 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 31 May 2026 23:12:58 +0200 Subject: [PATCH] =?UTF-8?q?fix(studio):=20sidebar=20=E2=80=94=20fixed-heig?= =?UTF-8?q?ht=20header=20rows,=20no=20more=20Y=20jump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier "single AnimationController for width + opacity" commit (0.51.5) made the *visual* expand smooth but the underlying layout still jumped because the rows themselves changed height between states: collapsed expanded ───────── ───────── triangle (36 px) triangle + 2-line title (44 px) dot (10 px) dot + multi-line pill with optional "Start hub" button (60-80 px when disconnected) letter (16 px) letter + accented chip (28 px) So everything BELOW the header — destinations list, footer — slid down ~50 px every time the rail opened. That's the "Versatz" Stefan kept seeing. Fading the labels in didn't help; the row geometry was the wrong source of truth. Structural fix: every header row is now a SizedBox with a fixed pixel height. Collapsed-state and expanded-state content both fit inside the same height: _brandRowH = 48 // FaiDeltaMark (36 px) centered _connRowH = 44 // single conceptual block, two single- // line texts, no multi-line pill _channelRowH = 28 // chip + single-line channel name _rowGap = 8 The total header block is therefore a constant pixel sum. Items below it (the destinations ListView, the footer) sit at the same Y in both states by construction — not by lucky math, not by animation tricks. The width animation only moves the rail's RIGHT edge; the left + top + bottom edges of every row are immovable. To make the connection row fit in 44 px we drop the inline "Start hub" tonal button. The same affordance is preserved by making the whole row tap-handled when the daemon is unreachable: tap the red dot (or anywhere on the row) to fire `fai daemon start`. The tooltip is updated to spell this out ("Disconnected · tap to start · …"). The channel row is now ALWAYS reserved (28 px placeholder when no channel is active) so that flipping the operator config from local→dev at runtime doesn't shift the destinations list either. The placeholder is invisible. Side-effects: - _ConnectionPill is removed (nothing references it). - New _BrandLabel + _ConnectionLabel widgets, both trivially Column(MainAxisAlignment.center, …) so their content sits visually centered inside the fixed row. Version 0.51.6 → 0.51.7. Signed-off-by: flemming-it --- lib/main.dart | 348 +++++++++++++++++++++++++------------------------- pubspec.yaml | 2 +- 2 files changed, 178 insertions(+), 172 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 12c30a8..f9bcc6d 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.6'; +const String kStudioVersion = '0.51.7'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -461,11 +461,18 @@ class _SidebarState extends State<_Sidebar> // mirrors NavigationRail's spec. static const double _collapsedWidth = 72; static const double _expandedWidth = 220; - // 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. + // Fixed pixel heights for every header row so the layout + // is the SAME total height in collapsed and expanded + // states. Items below the header (destinations list, + // footer) sit at a mathematically constant Y — they + // cannot move when the rail expands because nothing + // above them grows. This is the structural fix; the + // earlier opacity-tied animation only made the broken + // height transition smoother to the eye. + static const double _brandRowH = 48; + static const double _connRowH = 44; + static const double _channelRowH = 28; + static const double _rowGap = 8; late final AnimationController _ctrl; @override @@ -484,27 +491,34 @@ class _SidebarState extends State<_Sidebar> super.dispose(); } + Future _startDaemon(BuildContext context) 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()}', + ), + ), + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final mode = widget.connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle; + final hasChannel = + widget.activeChannel != null && widget.activeChannel!.isNotEmpty; return MouseRegion( 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, @@ -514,81 +528,73 @@ class _SidebarState extends State<_Sidebar> child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _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, + // Brand row — fixed 48px regardless of state. + SizedBox( + height: _brandRowH, + child: _sidebarRow( + leading: FaiDeltaMark( + color: theme.colorScheme.primary, + mode: mode, ), - ), - 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!), + label: _BrandLabel(), t: t, labelsInteractive: labelsInteractive, ), - ], - const SizedBox(height: FaiSpace.xxl), + ), + const SizedBox(height: _rowGap), + // Connection row — fixed 44px. Tapping the row + // (anywhere in either icon col or label) when + // disconnected fires the daemon-start action, + // so we can drop the inline "Start hub" button + // that used to make this row's height balloon. + SizedBox( + height: _connRowH, + child: _sidebarRow( + leading: Tooltip( + message: widget.connected == true + ? 'Connected · ${widget.endpointLabel}' + : widget.connected == false + ? 'Disconnected · tap to start · ${widget.endpointLabel}' + : 'Connecting · ${widget.endpointLabel}', + child: _CollapsedConnectionDot( + connected: widget.connected, + ), + ), + label: _ConnectionLabel( + connected: widget.connected, + endpoint: widget.endpointLabel, + ), + t: t, + labelsInteractive: labelsInteractive, + onTap: widget.connected == false + ? () => _startDaemon(context) + : null, + ), + ), + const SizedBox(height: _rowGap), + // Channel row — fixed 28px. Always reserved + // (with empty content when there's no channel + // selected) so toggling channels at runtime + // doesn't shift the destinations list below. + SizedBox( + height: _channelRowH, + child: hasChannel + ? _sidebarRow( + leading: Tooltip( + message: widget.activeChannel!, + child: _CollapsedChannelChip( + channel: widget.activeChannel!, + ), + ), + label: _ChannelPill( + channel: widget.activeChannel!, + ), + t: t, + labelsInteractive: labelsInteractive, + ) + : const SizedBox.shrink(), + ), + const SizedBox(height: FaiSpace.md), Expanded( child: ListView( padding: EdgeInsets.zero, @@ -636,8 +642,9 @@ class _SidebarState extends State<_Sidebar> required Widget label, required double t, required bool labelsInteractive, + VoidCallback? onTap, }) { - return Row( + final body = Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( @@ -647,7 +654,11 @@ class _SidebarState extends State<_Sidebar> Expanded( child: t > 0 ? IgnorePointer( - ignoring: !labelsInteractive, + // The whole row is interactive when onTap is set + // (we don't want the label sub-widgets eating + // the tap), so ignore pointer events on the + // label sub-tree even when the rail is expanded. + ignoring: onTap != null || !labelsInteractive, child: Opacity( opacity: t, child: Padding( @@ -660,6 +671,15 @@ class _SidebarState extends State<_Sidebar> ), ], ); + if (onTap == null) return body; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: body, + ), + ); } Future _openSettings(BuildContext context) async { @@ -753,7 +773,7 @@ class _ChannelPill extends StatelessWidget { } } -/// Mini-version of [_ConnectionPill] used when the sidebar is +/// Mini-version of [_ConnectionLabel] used when the sidebar is /// collapsed. A 10px dot in the same green/red/amber tonality /// the FaiDeltaMark uses for live / idle / unknown. class _CollapsedConnectionDot extends StatelessWidget { @@ -838,99 +858,85 @@ class _CollapsedChannelChip extends StatelessWidget { } } -class _ConnectionPill extends StatelessWidget { - final bool? connected; - final String label; - /// Triggered by the inline "Start hub" button when the - /// daemon is unreachable. Runs `fai daemon start` server-side - /// (well, locally — Studio shells out). The shell-level - /// health poll picks the daemon up on its next tick. - final Future Function() onStartRequested; +/// Two-line brand label shown in the rail's expanded state. +/// Fits inside a 48-px fixed-height row by design: 16-px title +/// + 2-px gap + 10-px monospaced version stamp = 28-px content +/// with 10 px of vertical breathing room top + bottom. +class _BrandLabel extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'F∆I Studio', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + 'v$kStudioVersion', + style: FaiTheme.mono(size: 10, color: theme.colorScheme.primary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } +} - const _ConnectionPill({ - required this.connected, - required this.label, - required this.onStartRequested, - }); +/// Single-conceptual-block connection label shown in the +/// rail's expanded state. Two single-line rows: a caption +/// (`connected` / `tap to start` / `connecting…`) and the +/// endpoint label below in mono. Fits inside the 44-px fixed +/// connection-row height — see `_connRowH` in `_SidebarState`. +/// The old inline "Start hub" button is gone; the parent row +/// is tappable when `connected == false` and fires the same +/// daemon-start action. +class _ConnectionLabel extends StatelessWidget { + final bool? connected; + final String endpoint; + + const _ConnectionLabel({required this.connected, required this.endpoint}); @override Widget build(BuildContext context) { final theme = Theme.of(context); - final dotColor = connected == true - ? FaiColors.success - : connected == false - ? FaiColors.danger - : FaiColors.muted; + final captionColor = connected == false + ? theme.colorScheme.error + : theme.colorScheme.onSurface; final caption = connected == true ? 'connected' : connected == false - ? 'unreachable' + ? 'tap to start' : 'connecting…'; - return Container( - padding: const EdgeInsets.symmetric( - horizontal: FaiSpace.sm, - vertical: 6, - ), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - FaiStatusDot(color: dotColor, pulsing: connected == true), - const SizedBox(width: 6), - Text( - caption, - style: theme.textTheme.labelSmall?.copyWith( - color: dotColor == FaiColors.danger - ? theme.colorScheme.error - : theme.colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - ), - ], + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + caption, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith( + color: captionColor, + fontWeight: FontWeight.w500, ), - const SizedBox(height: 2), - Text( - label, - style: FaiTheme.mono( - size: 10, - color: theme.colorScheme.onSurfaceVariant, - ), + ), + const SizedBox(height: 2), + Text( + endpoint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: FaiTheme.mono( + size: 10, + color: theme.colorScheme.onSurfaceVariant, ), - // Inline "Start hub" affordance when the daemon - // isn't there. Resolves the chicken-and-egg for - // Windows operators who can't run `fai daemon start` - // from a shell. - if (connected == false) ...[ - const SizedBox(height: FaiSpace.sm), - SizedBox( - width: double.infinity, - child: FilledButton.tonalIcon( - onPressed: onStartRequested, - icon: const Icon(Icons.play_arrow, size: 14), - label: const Text( - 'Start hub', - style: TextStyle(fontSize: 11), - ), - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric( - horizontal: FaiSpace.sm, - vertical: 4, - ), - ), - ), - ), - ], - ], - ), + ), + ], ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 0406ee2..9629fd9 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.6 +version: 0.51.7 environment: sdk: ^3.11.0-200.1.beta