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
/// 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<void> 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<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
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<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
/// 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<void> 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,
),
),
),
),
],
],
),
),
],
);
}
}

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