fix(studio): sidebar — single AnimationController drives width
Some checks failed
Security / Security check (push) Failing after 2s

+ label opacity in lockstep (no more glitchy expand)

The previous "fixed-width icon column" commit already locked
the icon's horizontal pixel-X, but the rail still _felt_
glitchy on expand because the rail's geometry and the rail's
content were animated by two different mechanisms:

 - Width: AnimatedContainer over FaiMotion.fast (120 ms)
 - Content: setState(_hovered = true) → conditional
   `if (expanded) Expanded(label)` snaps in one frame

So on mouse-enter the label widget appeared INSTANTLY while
the rail was still 72 px wide. The label tried to render in
0 px of available space and Flutter's layout engine clamped
it; over the next 7 frames the rail grew to 220 px and the
label visibly "settled in". That's the perceived glitch.

Replace the two-source animation with a single
SingleTickerProviderStateMixin + AnimationController whose
value `t` (0..1, eased via easeInOutCubic) drives both:

 - rail width = lerp(72, 220, t)
 - label opacity = t
 - labelsInteractive = t > 0.5 (so hidden buttons can't
   eat clicks meant for the icon column)

Labels are wrapped in `t > 0 ? IgnorePointer(Opacity(...))
: SizedBox.shrink()`. Once the animation starts, the label
joins the tree at opacity ≈ 0 (invisible — no pop) and
fades up smoothly as t grows. When fully collapsed (t == 0)
the label is removed from layout entirely, so the connection
pill's tall "Start hub" affordance doesn't inflate the
collapsed rail's height (this also fixes the widget_test
vertical-overflow that 0.51.4 introduced).

Same pattern applied to the footer (settings icon stays in
the 72-px column always; theme/lang/clock fade in beside it)
and to each _SidebarItem destination row.

End result: width AND content travel together along the same
animation curve. No more "snap then catch up" — they're
mathematically inseparable.

Version 0.51.4 → 0.51.5.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-31 22:24:53 +02:00
parent 5830198566
commit 2b65cd771f
2 changed files with 290 additions and 212 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.4'; const String kStudioVersion = '0.51.5';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -455,25 +455,58 @@ class _Sidebar extends StatefulWidget {
State<_Sidebar> createState() => _SidebarState(); State<_Sidebar> createState() => _SidebarState();
} }
class _SidebarState extends State<_Sidebar> { class _SidebarState extends State<_Sidebar>
// Default-collapsed (icons only); expands on hover. Width with SingleTickerProviderStateMixin {
// anim mirrors NavigationRail's spec: 72 collapsed, 220 // Default-collapsed (icons only); expands on hover. 72 / 220
// expanded. // mirrors NavigationRail's spec.
static const double _collapsedWidth = 72; static const double _collapsedWidth = 72;
static const double _expandedWidth = 220; static const double _expandedWidth = 220;
bool _hovered = false; // 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.
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: FaiMotion.fast,
value: 0,
);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@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 expanded = _hovered;
final width = expanded ? _expandedWidth : _collapsedWidth;
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => _ctrl.forward(),
onExit: (_) => setState(() => _hovered = false), onExit: (_) => _ctrl.reverse(),
child: AnimatedContainer( child: AnimatedBuilder(
duration: FaiMotion.fast, 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, width: width,
color: theme.colorScheme.surfaceContainerLow, color: theme.colorScheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl), padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
@ -481,28 +514,12 @@ class _SidebarState extends State<_Sidebar> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Brand row fixed-width icon column (kCollapsedWidth) _sidebarRow(
// on the left, label/version stacked beside it when leading: FaiDeltaMark(
// 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, color: theme.colorScheme.primary,
mode: mode, mode: mode,
), ),
), label: Column(
),
if (expanded)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
@ -522,20 +539,12 @@ class _SidebarState extends State<_Sidebar> {
), ),
], ],
), ),
), t: t,
), labelsInteractive: labelsInteractive,
],
), ),
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
// Connection row same pattern. Mini-dot sits _sidebarRow(
// centered in the 72-col; the full _ConnectionPill leading: Tooltip(
// takes the remaining width when expanded.
Row(
children: [
SizedBox(
width: _collapsedWidth,
child: Center(
child: Tooltip(
message: widget.connected == true message: widget.connected == true
? 'Connected · ${widget.endpointLabel}' ? 'Connected · ${widget.endpointLabel}'
: widget.connected == false : widget.connected == false
@ -545,18 +554,11 @@ class _SidebarState extends State<_Sidebar> {
connected: widget.connected, connected: widget.connected,
), ),
), ),
), label: _ConnectionPill(
),
if (expanded)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: _ConnectionPill(
connected: widget.connected, connected: widget.connected,
label: widget.endpointLabel, label: widget.endpointLabel,
onStartRequested: () async { onStartRequested: () async {
final r = final r = await SystemActions.faiDaemon(['start']);
await SystemActions.faiDaemon(['start']);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@ -569,42 +571,24 @@ class _SidebarState extends State<_Sidebar> {
); );
}, },
), ),
), t: t,
), labelsInteractive: labelsInteractive,
],
), ),
if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[ if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Row( _sidebarRow(
children: [ leading: Tooltip(
SizedBox(
width: _collapsedWidth,
child: Center(
child: Tooltip(
message: widget.activeChannel!, message: widget.activeChannel!,
child: _CollapsedChannelChip( child: _CollapsedChannelChip(
channel: widget.activeChannel!, channel: widget.activeChannel!,
), ),
), ),
), label: _ChannelPill(channel: widget.activeChannel!),
), t: t,
if (expanded) labelsInteractive: labelsInteractive,
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: _ChannelPill(
channel: widget.activeChannel!,
),
),
),
],
), ),
], ],
const SizedBox(height: FaiSpace.xxl), const SizedBox(height: FaiSpace.xxl),
// 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( Expanded(
child: ListView( child: ListView(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@ -613,44 +597,68 @@ class _SidebarState extends State<_Sidebar> {
_SidebarItem( _SidebarItem(
page: widget.pages[i], page: widget.pages[i],
selected: i == widget.selectedIndex, selected: i == widget.selectedIndex,
expanded: expanded, t: t,
labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth, iconColumnWidth: _collapsedWidth,
onTap: () => widget.onSelect(i), onTap: () => widget.onSelect(i),
), ),
], ],
), ),
), ),
// Footer: theme toggle + clock + settings when _Footer(
// expanded; just the settings icon when collapsed. t: t,
Padding( labelsInteractive: labelsInteractive,
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md), iconColumnWidth: _collapsedWidth,
child: expanded onOpenSettings: () => _openSettings(context),
? Row( ),
],
),
),
);
},
),
);
}
/// Shared row geometry for the brand / connection / channel
/// headers. Leading widget sits in a fixed-width icon column
/// anchored to the rail's left edge. The label slot is in
/// the tree as soon as the animation kicks off (t > 0) and
/// fades in via Opacity tied to the same controller. Skipping
/// layout at t == 0 keeps the row's collapsed height = the
/// leading widget's natural height (rather than including
/// the label's tall "Start hub" affordance even when
/// invisible). The opacity at t 0.01 is low enough that
/// the layout's first-frame appearance isn't perceived as a
/// pop the label visually grows in from invisible.
Widget _sidebarRow({
required Widget leading,
required Widget label,
required double t,
required bool labelsInteractive,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
_ThemeToggle(), SizedBox(
_LanguageToggle(), width: _collapsedWidth,
const Expanded( child: Center(child: leading),
child: Center(child: _SidebarClock()), ),
Expanded(
child: t > 0
? IgnorePointer(
ignoring: !labelsInteractive,
child: Opacity(
opacity: t,
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: label,
), ),
IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Settings (⌘;)',
visualDensity: VisualDensity.compact,
onPressed: () => _openSettings(context),
), ),
],
) )
: IconButton( : const SizedBox.shrink(),
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Settings (⌘;)',
visualDensity: VisualDensity.compact,
onPressed: () => _openSettings(context),
),
), ),
], ],
),
),
),
); );
} }
@ -930,14 +938,20 @@ class _ConnectionPill extends StatelessWidget {
class _SidebarItem extends StatefulWidget { class _SidebarItem extends StatefulWidget {
final _NavPage page; final _NavPage page;
final bool selected; final bool selected;
final bool expanded; /// 0 = fully collapsed, 1 = fully expanded. Drives label
/// opacity in lockstep with the parent rail's width
/// animation there is no second timer for the label, so
/// label and rail can't fall out of sync.
final double t;
final bool labelsInteractive;
final double iconColumnWidth; final double iconColumnWidth;
final VoidCallback onTap; final VoidCallback onTap;
const _SidebarItem({ const _SidebarItem({
required this.page, required this.page,
required this.selected, required this.selected,
required this.expanded, required this.t,
required this.labelsInteractive,
required this.iconColumnWidth, required this.iconColumnWidth,
required this.onTap, required this.onTap,
}); });
@ -969,22 +983,24 @@ class _SidebarItemState extends State<_SidebarItem> {
size: 18, size: 18,
color: color, color: color,
); );
// Fixed-width icon column on the left + optional label slot // Label widget is ALWAYS in the tree opacity = t makes
// on the right. The icon's pixel-X is anchored to the rail's // it fade in/out in lockstep with the rail's width
// literal left edge (no ListView padding, no AC h-padding) // animation. Expanded's flex space goes from 0 (at t=0,
// and the icon column width matches the rail's collapsed // rail width = 72) to (rail width 72) at t=1, so the
// width, so the icon sits at the same x-pixel center of // label has natural space to fill without any conditional
// the 72-px slot regardless of whether the rail is // tree mutation.
// currently 72 or 220 wide. The label appears as an
// Expanded sibling to the icon column only when expanded.
final content = Row( final content = Row(
children: [ children: [
SizedBox( SizedBox(
width: widget.iconColumnWidth, width: widget.iconColumnWidth,
child: Center(child: icon), child: Center(child: icon),
), ),
if (widget.expanded)
Expanded( Expanded(
child: widget.t > 0
? IgnorePointer(
ignoring: !widget.labelsInteractive,
child: Opacity(
opacity: widget.t,
child: Padding( child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md), padding: const EdgeInsets.only(right: FaiSpace.md),
child: Text( child: Text(
@ -995,12 +1011,13 @@ class _SidebarItemState extends State<_SidebarItem> {
), ),
), ),
), ),
)
: const SizedBox.shrink(),
),
], ],
); );
final inner = Padding( 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), padding: const EdgeInsets.symmetric(vertical: 2),
child: MouseRegion( child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => setState(() => _hovered = true),
@ -1011,9 +1028,9 @@ class _SidebarItemState extends State<_SidebarItem> {
onTap: widget.onTap, onTap: widget.onTap,
child: AnimatedContainer( child: AnimatedContainer(
duration: FaiMotion.fast, duration: FaiMotion.fast,
// Only vertical padding horizontal is owned by the // No horizontal padding the icon column anchors
// icon column. Background highlight spans the full // the left. The bg highlight spans the rail's
// rail width, which is the typical nav-rail UX. // current width (typical nav-rail behaviour).
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md), padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
decoration: BoxDecoration(color: bg), decoration: BoxDecoration(color: bg),
child: content, child: content,
@ -1022,9 +1039,9 @@ class _SidebarItemState extends State<_SidebarItem> {
), ),
); );
// Tooltip only when collapsed when the rail is expanded // Tooltip only while the label is hidden once the label
// the label is visible inline + tooltips become noisy. // is visible inline the tooltip becomes redundant noise.
if (widget.expanded) return inner; if (widget.t > 0.5) return inner;
return Tooltip( return Tooltip(
message: label, message: label,
preferBelow: false, preferBelow: false,
@ -1033,6 +1050,67 @@ class _SidebarItemState extends State<_SidebarItem> {
} }
} }
/// Bottom-of-rail control strip. Settings icon is always the
/// last item in the icon column so its pixel-X never shifts;
/// theme + language toggles and clock fade in beside it as
/// the rail expands, sharing the same `t` as the rest of the
/// sidebar so motion stays in lockstep.
class _Footer extends StatelessWidget {
final double t;
final bool labelsInteractive;
final double iconColumnWidth;
final VoidCallback onOpenSettings;
const _Footer({
required this.t,
required this.labelsInteractive,
required this.iconColumnWidth,
required this.onOpenSettings,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: iconColumnWidth,
child: Center(
child: IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Settings (⌘;)',
visualDensity: VisualDensity.compact,
onPressed: onOpenSettings,
),
),
),
Expanded(
child: t > 0
? IgnorePointer(
ignoring: !labelsInteractive,
child: Opacity(
opacity: t,
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.sm),
child: Row(
children: [
_ThemeToggle(),
_LanguageToggle(),
const Expanded(
child: Center(child: _SidebarClock()),
),
],
),
),
),
)
: const SizedBox.shrink(),
),
],
);
}
}
class _NavPage { class _NavPage {
/// Stable id used for the Cmd+K palette and the sidebar /// Stable id used for the Cmd+K palette and the sidebar
/// shortcut keys; never shown to the operator. The visible /// shortcut keys; never shown to the operator. The visible

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