fix(studio): sidebar — single AnimationController drives width
Some checks failed
Security / Security check (push) Failing after 2s
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:
parent
5830198566
commit
2b65cd771f
2 changed files with 290 additions and 212 deletions
500
lib/main.dart
500
lib/main.dart
|
|
@ -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,205 +455,213 @@ 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,
|
||||||
width: width,
|
builder: (context, _) {
|
||||||
color: theme.colorScheme.surfaceContainerLow,
|
// eased between 0 (fully collapsed) and 1 (fully
|
||||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
|
// expanded). Opacity uses the same curve, so a label
|
||||||
child: ClipRect(
|
// that starts fading in is at the same progress
|
||||||
child: Column(
|
// fraction as the rail's geometry — visually they
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
// travel together.
|
||||||
children: [
|
final t = Curves.easeInOutCubic.transform(_ctrl.value);
|
||||||
// Brand row — fixed-width icon column (kCollapsedWidth)
|
final width = _collapsedWidth + (_expandedWidth - _collapsedWidth) * t;
|
||||||
// on the left, label/version stacked beside it when
|
// Hold pointer hits on labels until the rail is at
|
||||||
// expanded. The 72-px column means the triangle's
|
// least half-open; otherwise the operator might tap
|
||||||
// pixel-X is identical in both states; only the
|
// through an almost-invisible button on the way to
|
||||||
// rail's RIGHT edge moves on expand, never the
|
// the icon they actually want.
|
||||||
// left-anchored content.
|
final labelsInteractive = t > 0.5;
|
||||||
Row(
|
return Container(
|
||||||
|
width: width,
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
|
||||||
|
child: ClipRect(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
_sidebarRow(
|
||||||
width: _collapsedWidth,
|
leading: FaiDeltaMark(
|
||||||
child: Center(
|
color: theme.colorScheme.primary,
|
||||||
child: FaiDeltaMark(
|
mode: mode,
|
||||||
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)
|
_Footer(
|
||||||
Expanded(
|
t: t,
|
||||||
child: Padding(
|
labelsInteractive: labelsInteractive,
|
||||||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
iconColumnWidth: _collapsedWidth,
|
||||||
child: Column(
|
onOpenSettings: () => _openSettings(context),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
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 {
|
Future<void> _openSettings(BuildContext context) async {
|
||||||
final saved = await FaiSettingsDialog.show(context);
|
final saved = await FaiSettingsDialog.show(context);
|
||||||
if (saved && context.mounted) {
|
if (saved && context.mounted) {
|
||||||
|
|
@ -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,38 +983,41 @@ 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
|
||||||
child: Padding(
|
? IgnorePointer(
|
||||||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
ignoring: !widget.labelsInteractive,
|
||||||
child: Text(
|
child: Opacity(
|
||||||
label,
|
opacity: widget.t,
|
||||||
overflow: TextOverflow.fade,
|
child: Padding(
|
||||||
softWrap: false,
|
padding: const EdgeInsets.only(right: FaiSpace.md),
|
||||||
style: theme.textTheme.labelMedium?.copyWith(color: color),
|
child: Text(
|
||||||
),
|
label,
|
||||||
),
|
overflow: TextOverflow.fade,
|
||||||
),
|
softWrap: false,
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(color: color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue