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