fix(studio): sidebar — fixed-width icon column eliminates icon shift
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
Previous "left-anchor everything via padding" approach made
the math work on paper but the perceived shift persisted. The
root cause was structural, not padding-based: ListView gave
each item a tight cross-axis constraint equal to the rail's
current width (48 collapsed, 196 expanded). Inside the item,
the AnimatedContainer filled that constraint and laid out a
mainAxisSize.min Row at the start of the AC's content area.
The icon's pixel-X depended on the AC's padding _and_ the
constraint; even with equal padding the rendered layout
shifted across the expand animation because Flutter
re-resolved alignment under a moving constraint.
Replace the entire pattern with a fixed-width icon column
that doesn't care about the parent constraint:
ListView (padding 0) → Item Row
SizedBox(width: 72) → Center → icon ← anchored to literal x
if expanded → Expanded → label text ← grows into remaining space
Same pattern for the brand/connection/channel rows above. The
72 px matches the rail's collapsed width exactly, so collapsed
rail = one icon column + nothing; expanded rail = same icon
column + label slot. Icon's pixel-X is now provably constant.
Removes the AC's horizontal padding entirely — the icon
column owns the left anchor. The background highlight still
spans the full row width (typical nav-rail UX), and the rail
expand animation only moves the rail's right edge, never any
icon-bearing element.
Also passes an `iconColumnWidth: 72` to _SidebarItem instead
of hard-coding the magic number in two places.
Version 0.51.3 → 0.51.4.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
1295675bd9
commit
5830198566
2 changed files with 153 additions and 154 deletions
305
lib/main.dart
305
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.3';
|
const String kStudioVersion = '0.51.4';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -479,150 +479,142 @@ class _SidebarState extends State<_Sidebar> {
|
||||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
|
||||||
child: ClipRect(
|
child: ClipRect(
|
||||||
child: Column(
|
child: Column(
|
||||||
// Left-align every column child so brand mark + top
|
|
||||||
// indicators sit at the same X-pixel as the icons
|
|
||||||
// below. With the default `center`, the brand mark
|
|
||||||
// re-positioned itself horizontally on every expand
|
|
||||||
// animation (centered in 72 vs centered in 220),
|
|
||||||
// which the user perceived as the whole nav rail
|
|
||||||
// shifting even though the destination icons stayed
|
|
||||||
// put. Anchoring everything to the start eliminates
|
|
||||||
// the perceived shift.
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Brand mark — always visible. Pad it to match the
|
// Brand row — fixed-width icon column (kCollapsedWidth)
|
||||||
// icon's left offset (ListView padding + AC padding
|
// on the left, label/version stacked beside it when
|
||||||
// = FaiSpace.md + FaiSpace.md), so the triangle's
|
// expanded. The 72-px column means the triangle's
|
||||||
// left edge aligns with the destination icons'
|
// pixel-X is identical in both states; only the
|
||||||
// left edges.
|
// rail's RIGHT edge moves on expand, never the
|
||||||
Padding(
|
// left-anchored content.
|
||||||
padding: const EdgeInsets.only(
|
Row(
|
||||||
left: FaiSpace.md + FaiSpace.md,
|
children: [
|
||||||
),
|
SizedBox(
|
||||||
child:
|
width: _collapsedWidth,
|
||||||
FaiDeltaMark(color: theme.colorScheme.primary, mode: mode),
|
child: Center(
|
||||||
),
|
child: FaiDeltaMark(
|
||||||
if (expanded) ...[
|
color: theme.colorScheme.primary,
|
||||||
const SizedBox(height: FaiSpace.md),
|
mode: mode,
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(left: FaiSpace.md + FaiSpace.md),
|
|
||||||
child: Text(
|
|
||||||
'F∆I Studio',
|
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
left: FaiSpace.md + FaiSpace.md,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'v$kStudioVersion',
|
|
||||||
style: FaiTheme.mono(
|
|
||||||
size: 10,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: FaiSpace.md),
|
|
||||||
// Connection pill — left-aligned to the same
|
|
||||||
// x-anchor as the icons.
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
left: FaiSpace.md + FaiSpace.md,
|
|
||||||
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),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
left: FaiSpace.md + FaiSpace.md,
|
|
||||||
right: FaiSpace.md,
|
|
||||||
),
|
|
||||||
child: _ChannelPill(channel: widget.activeChannel!),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
] else ...[
|
|
||||||
// Collapsed top: keep the brand mark and the
|
|
||||||
// most glance-able status indicators (version,
|
|
||||||
// connection dot, channel letter) so the rail
|
|
||||||
// still reads as F∆I Studio at-a-glance without
|
|
||||||
// hovering to expand. All left-padded by the
|
|
||||||
// same offset as the destination icons (
|
|
||||||
// ListView.padding + AC.padding = 12 + 12)
|
|
||||||
// so the column reads as one stable left edge.
|
|
||||||
const SizedBox(height: FaiSpace.sm),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
left: FaiSpace.md + FaiSpace.md,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'v$kStudioVersion'.replaceFirst(RegExp(r'-.*'), ''),
|
|
||||||
style: FaiTheme.mono(
|
|
||||||
size: 9,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: FaiSpace.md),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
left: FaiSpace.md + FaiSpace.md,
|
|
||||||
),
|
|
||||||
child: Tooltip(
|
|
||||||
message: widget.connected == true
|
|
||||||
? 'Connected · ${widget.endpointLabel}'
|
|
||||||
: widget.connected == false
|
|
||||||
? 'Disconnected · ${widget.endpointLabel}'
|
|
||||||
: 'Connecting · ${widget.endpointLabel}',
|
|
||||||
child:
|
|
||||||
_CollapsedConnectionDot(connected: widget.connected),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: FaiSpace.sm),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
left: FaiSpace.md + FaiSpace.md,
|
|
||||||
),
|
|
||||||
child: Tooltip(
|
|
||||||
message: widget.activeChannel!,
|
|
||||||
child: _CollapsedChannelChip(
|
|
||||||
channel: widget.activeChannel!,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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),
|
const SizedBox(height: FaiSpace.xxl),
|
||||||
// Destinations.
|
// 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: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
padding: EdgeInsets.zero,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < widget.pages.length; i++)
|
for (var i = 0; i < widget.pages.length; i++)
|
||||||
_SidebarItem(
|
_SidebarItem(
|
||||||
page: widget.pages[i],
|
page: widget.pages[i],
|
||||||
selected: i == widget.selectedIndex,
|
selected: i == widget.selectedIndex,
|
||||||
expanded: expanded,
|
expanded: expanded,
|
||||||
|
iconColumnWidth: _collapsedWidth,
|
||||||
onTap: () => widget.onSelect(i),
|
onTap: () => widget.onSelect(i),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -939,12 +931,14 @@ class _SidebarItem extends StatefulWidget {
|
||||||
final _NavPage page;
|
final _NavPage page;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
final bool expanded;
|
final bool expanded;
|
||||||
|
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.expanded,
|
||||||
|
required this.iconColumnWidth,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -975,30 +969,38 @@ class _SidebarItemState extends State<_SidebarItem> {
|
||||||
size: 18,
|
size: 18,
|
||||||
color: color,
|
color: color,
|
||||||
);
|
);
|
||||||
// Same Row + same outer padding in both states so the icon
|
// Fixed-width icon column on the left + optional label slot
|
||||||
// stays at the exact same X-pixel before and after expand
|
// on the right. The icon's pixel-X is anchored to the rail's
|
||||||
// (operators don't have to re-aim at a destination they
|
// literal left edge (no ListView padding, no AC h-padding)
|
||||||
// already pointed at). The label slot is conditional, the
|
// and the icon column width matches the rail's collapsed
|
||||||
// icon's position is not.
|
// 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.
|
||||||
final content = Row(
|
final content = Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
icon,
|
SizedBox(
|
||||||
if (widget.expanded) ...[
|
width: widget.iconColumnWidth,
|
||||||
const SizedBox(width: FaiSpace.md),
|
child: Center(child: icon),
|
||||||
Flexible(
|
),
|
||||||
child: Text(
|
if (widget.expanded)
|
||||||
label,
|
Expanded(
|
||||||
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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
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),
|
||||||
|
|
@ -1009,14 +1011,11 @@ class _SidebarItemState extends State<_SidebarItem> {
|
||||||
onTap: widget.onTap,
|
onTap: widget.onTap,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: FaiMotion.fast,
|
duration: FaiMotion.fast,
|
||||||
padding: const EdgeInsets.symmetric(
|
// Only vertical padding — horizontal is owned by the
|
||||||
horizontal: FaiSpace.md,
|
// icon column. Background highlight spans the full
|
||||||
vertical: FaiSpace.md,
|
// rail width, which is the typical nav-rail UX.
|
||||||
),
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(color: bg),
|
||||||
color: bg,
|
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
||||||
),
|
|
||||||
child: content,
|
child: content,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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.3
|
version: 0.51.4
|
||||||
|
|
||||||
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