fix(studio): sidebar — fixed-height header rows, no more Y jump
Some checks failed
Security / Security check (push) Failing after 1s

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-31 23:12:58 +02:00
parent ba1a1a4f06
commit dbcff6f7a6
2 changed files with 178 additions and 172 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.6'; const String kStudioVersion = '0.51.7';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -461,11 +461,18 @@ class _SidebarState extends State<_Sidebar>
// mirrors NavigationRail's spec. // mirrors NavigationRail's spec.
static const double _collapsedWidth = 72; static const double _collapsedWidth = 72;
static const double _expandedWidth = 220; static const double _expandedWidth = 220;
// Single source of truth for the entire rail's expand // Fixed pixel heights for every header row so the layout
// animation. width and every label's opacity are derived // is the SAME total height in collapsed and expanded
// from `_ctrl.value` no second timer, no conditional // states. Items below the header (destinations list,
// widget-tree mutation, so width and content move in // footer) sit at a mathematically constant Y they
// lockstep instead of one snapping while the other glides. // 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; late final AnimationController _ctrl;
@override @override
@ -484,27 +491,34 @@ class _SidebarState extends State<_Sidebar>
super.dispose(); super.dispose();
} }
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final mode = widget.connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle; final mode = widget.connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle;
final hasChannel =
widget.activeChannel != null && widget.activeChannel!.isNotEmpty;
return MouseRegion( return MouseRegion(
onEnter: (_) => _ctrl.forward(), onEnter: (_) => _ctrl.forward(),
onExit: (_) => _ctrl.reverse(), onExit: (_) => _ctrl.reverse(),
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _ctrl, animation: _ctrl,
builder: (context, _) { 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 t = Curves.easeInOutCubic.transform(_ctrl.value);
final width = _collapsedWidth + (_expandedWidth - _collapsedWidth) * t; 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; final labelsInteractive = t > 0.5;
return Container( return Container(
width: width, width: width,
@ -514,81 +528,73 @@ class _SidebarState extends State<_Sidebar>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_sidebarRow( // Brand row fixed 48px regardless of state.
leading: FaiDeltaMark( SizedBox(
color: theme.colorScheme.primary, height: _brandRowH,
mode: mode, child: _sidebarRow(
), leading: FaiDeltaMark(
label: Column( color: theme.colorScheme.primary,
crossAxisAlignment: CrossAxisAlignment.start, mode: mode,
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: _BrandLabel(),
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, t: t,
labelsInteractive: labelsInteractive, 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( Expanded(
child: ListView( child: ListView(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@ -636,8 +642,9 @@ class _SidebarState extends State<_Sidebar>
required Widget label, required Widget label,
required double t, required double t,
required bool labelsInteractive, required bool labelsInteractive,
VoidCallback? onTap,
}) { }) {
return Row( final body = Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
SizedBox( SizedBox(
@ -647,7 +654,11 @@ class _SidebarState extends State<_Sidebar>
Expanded( Expanded(
child: t > 0 child: t > 0
? IgnorePointer( ? 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( child: Opacity(
opacity: t, opacity: t,
child: Padding( 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<void> _openSettings(BuildContext context) async { Future<void> _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 /// collapsed. A 10px dot in the same green/red/amber tonality
/// the FaiDeltaMark uses for live / idle / unknown. /// the FaiDeltaMark uses for live / idle / unknown.
class _CollapsedConnectionDot extends StatelessWidget { class _CollapsedConnectionDot extends StatelessWidget {
@ -838,99 +858,85 @@ class _CollapsedChannelChip extends StatelessWidget {
} }
} }
class _ConnectionPill extends StatelessWidget { /// Two-line brand label shown in the rail's expanded state.
final bool? connected; /// Fits inside a 48-px fixed-height row by design: 16-px title
final String label; /// + 2-px gap + 10-px monospaced version stamp = 28-px content
/// Triggered by the inline "Start hub" button when the /// with 10 px of vertical breathing room top + bottom.
/// daemon is unreachable. Runs `fai daemon start` server-side class _BrandLabel extends StatelessWidget {
/// (well, locally Studio shells out). The shell-level @override
/// health poll picks the daemon up on its next tick. Widget build(BuildContext context) {
final Future<void> Function() onStartRequested; 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({ /// Single-conceptual-block connection label shown in the
required this.connected, /// rail's expanded state. Two single-line rows: a caption
required this.label, /// (`connected` / `tap to start` / `connecting`) and the
required this.onStartRequested, /// 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final dotColor = connected == true final captionColor = connected == false
? FaiColors.success ? theme.colorScheme.error
: connected == false : theme.colorScheme.onSurface;
? FaiColors.danger
: FaiColors.muted;
final caption = connected == true final caption = connected == true
? 'connected' ? 'connected'
: connected == false : connected == false
? 'unreachable' ? 'tap to start'
: 'connecting…'; : 'connecting…';
return Container( return Column(
padding: const EdgeInsets.symmetric( mainAxisAlignment: MainAxisAlignment.center,
horizontal: FaiSpace.sm, crossAxisAlignment: CrossAxisAlignment.start,
vertical: 6, children: [
), Text(
decoration: BoxDecoration( caption,
color: theme.colorScheme.surfaceContainerHigh, maxLines: 1,
borderRadius: BorderRadius.circular(FaiRadius.sm), overflow: TextOverflow.ellipsis,
border: Border.all(color: theme.colorScheme.outlineVariant), style: theme.textTheme.labelMedium?.copyWith(
), color: captionColor,
child: Column( fontWeight: FontWeight.w500,
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,
),
),
],
), ),
const SizedBox(height: 2), ),
Text( const SizedBox(height: 2),
label, Text(
style: FaiTheme.mono( endpoint,
size: 10, maxLines: 1,
color: theme.colorScheme.onSurfaceVariant, 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,
),
),
),
),
],
],
),
); );
} }
} }

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.6 version: 0.51.7
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta