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
/// 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.4';
const String kStudioVersion = '0.51.5';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -455,205 +455,213 @@ class _Sidebar extends StatefulWidget {
State<_Sidebar> createState() => _SidebarState();
}
class _SidebarState extends State<_Sidebar> {
// Default-collapsed (icons only); expands on hover. Width
// anim mirrors NavigationRail's spec: 72 collapsed, 220
// expanded.
class _SidebarState extends State<_Sidebar>
with SingleTickerProviderStateMixin {
// Default-collapsed (icons only); expands on hover. 72 / 220
// mirrors NavigationRail's spec.
static const double _collapsedWidth = 72;
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
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mode = widget.connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle;
final expanded = _hovered;
final width = expanded ? _expandedWidth : _collapsedWidth;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: FaiMotion.fast,
width: width,
color: theme.colorScheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
child: ClipRect(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Brand row fixed-width icon column (kCollapsedWidth)
// on the left, label/version stacked beside it when
// 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(
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,
color: theme.colorScheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
child: ClipRect(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _collapsedWidth,
child: Center(
child: FaiDeltaMark(
color: theme.colorScheme.primary,
mode: mode,
_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,
),
),
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,
labelsInteractive: labelsInteractive,
),
],
const SizedBox(height: FaiSpace.xxl),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
for (var i = 0; i < widget.pages.length; i++)
_SidebarItem(
page: widget.pages[i],
selected: i == widget.selectedIndex,
t: t,
labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth,
onTap: () => widget.onSelect(i),
),
],
),
),
if (expanded)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: 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,
),
),
],
),
),
),
],
),
const SizedBox(height: FaiSpace.md),
// Connection row same pattern. Mini-dot sits
// centered in the 72-col; the full _ConnectionPill
// takes the remaining width when expanded.
Row(
children: [
SizedBox(
width: _collapsedWidth,
child: Center(
child: Tooltip(
message: widget.connected == true
? 'Connected · ${widget.endpointLabel}'
: widget.connected == false
? 'Disconnected · ${widget.endpointLabel}'
: 'Connecting · ${widget.endpointLabel}',
child: _CollapsedConnectionDot(
connected: widget.connected,
),
),
),
_Footer(
t: t,
labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth,
onOpenSettings: () => _openSettings(context),
),
if (expanded)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: _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()}',
),
),
);
},
),
),
),
],
),
if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm),
Row(
children: [
SizedBox(
width: _collapsedWidth,
child: Center(
child: Tooltip(
message: widget.activeChannel!,
child: _CollapsedChannelChip(
channel: widget.activeChannel!,
),
),
),
),
if (expanded)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: _ChannelPill(
channel: widget.activeChannel!,
),
),
),
],
),
],
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(
child: ListView(
padding: EdgeInsets.zero,
children: [
for (var i = 0; i < widget.pages.length; i++)
_SidebarItem(
page: widget.pages[i],
selected: i == widget.selectedIndex,
expanded: expanded,
iconColumnWidth: _collapsedWidth,
onTap: () => widget.onSelect(i),
),
],
),
),
// Footer: theme toggle + clock + settings when
// expanded; just the settings icon when collapsed.
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
child: expanded
? Row(
children: [
_ThemeToggle(),
_LanguageToggle(),
const Expanded(
child: Center(child: _SidebarClock()),
),
IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Settings (⌘;)',
visualDensity: VisualDensity.compact,
onPressed: () => _openSettings(context),
),
],
)
: IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Settings (⌘;)',
visualDensity: VisualDensity.compact,
onPressed: () => _openSettings(context),
),
),
],
),
),
),
);
},
),
);
}
/// 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: [
SizedBox(
width: _collapsedWidth,
child: Center(child: leading),
),
Expanded(
child: t > 0
? IgnorePointer(
ignoring: !labelsInteractive,
child: Opacity(
opacity: t,
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: label,
),
),
)
: const SizedBox.shrink(),
),
],
);
}
Future<void> _openSettings(BuildContext context) async {
final saved = await FaiSettingsDialog.show(context);
if (saved && context.mounted) {
@ -930,14 +938,20 @@ class _ConnectionPill extends StatelessWidget {
class _SidebarItem extends StatefulWidget {
final _NavPage page;
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 VoidCallback onTap;
const _SidebarItem({
required this.page,
required this.selected,
required this.expanded,
required this.t,
required this.labelsInteractive,
required this.iconColumnWidth,
required this.onTap,
});
@ -969,38 +983,41 @@ class _SidebarItemState extends State<_SidebarItem> {
size: 18,
color: color,
);
// Fixed-width icon column on the left + optional label slot
// on the right. The icon's pixel-X is anchored to the rail's
// literal left edge (no ListView padding, no AC h-padding)
// and the icon column width matches the rail's collapsed
// width, so the icon sits at the same x-pixel center of
// the 72-px slot regardless of whether the rail is
// currently 72 or 220 wide. The label appears as an
// Expanded sibling to the icon column only when expanded.
// Label widget is ALWAYS in the tree opacity = t makes
// it fade in/out in lockstep with the rail's width
// animation. Expanded's flex space goes from 0 (at t=0,
// rail width = 72) to (rail width 72) at t=1, so the
// label has natural space to fill without any conditional
// tree mutation.
final content = Row(
children: [
SizedBox(
width: widget.iconColumnWidth,
child: Center(child: icon),
),
if (widget.expanded)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Text(
label,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
),
),
Expanded(
child: widget.t > 0
? IgnorePointer(
ignoring: !widget.labelsInteractive,
child: Opacity(
opacity: widget.t,
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Text(
label,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
),
),
)
: const SizedBox.shrink(),
),
],
);
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),
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
@ -1011,9 +1028,9 @@ class _SidebarItemState extends State<_SidebarItem> {
onTap: widget.onTap,
child: AnimatedContainer(
duration: FaiMotion.fast,
// Only vertical padding horizontal is owned by the
// icon column. Background highlight spans the full
// rail width, which is the typical nav-rail UX.
// No horizontal padding the icon column anchors
// the left. The bg highlight spans the rail's
// current width (typical nav-rail behaviour).
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
decoration: BoxDecoration(color: bg),
child: content,
@ -1022,9 +1039,9 @@ class _SidebarItemState extends State<_SidebarItem> {
),
);
// Tooltip only when collapsed when the rail is expanded
// the label is visible inline + tooltips become noisy.
if (widget.expanded) return inner;
// Tooltip only while the label is hidden once the label
// is visible inline the tooltip becomes redundant noise.
if (widget.t > 0.5) return inner;
return Tooltip(
message: label,
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 {
/// Stable id used for the Cmd+K palette and the sidebar
/// shortcut keys; never shown to the operator. The visible