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
|
||||
/// 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.3';
|
||||
const String kStudioVersion = '0.51.4';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
@ -479,150 +479,142 @@ class _SidebarState extends State<_Sidebar> {
|
|||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
|
||||
child: ClipRect(
|
||||
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,
|
||||
children: [
|
||||
// Brand mark — always visible. Pad it to match the
|
||||
// icon's left offset (ListView padding + AC padding
|
||||
// = FaiSpace.md + FaiSpace.md), so the triangle's
|
||||
// left edge aligns with the destination icons'
|
||||
// left edges.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: FaiSpace.md + FaiSpace.md,
|
||||
),
|
||||
child:
|
||||
FaiDeltaMark(color: theme.colorScheme.primary, mode: mode),
|
||||
),
|
||||
if (expanded) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
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!,
|
||||
// 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(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _collapsedWidth,
|
||||
child: Center(
|
||||
child: FaiDeltaMark(
|
||||
color: theme.colorScheme.primary,
|
||||
mode: mode,
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
// 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(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
||||
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),
|
||||
),
|
||||
],
|
||||
|
|
@ -939,12 +931,14 @@ class _SidebarItem extends StatefulWidget {
|
|||
final _NavPage page;
|
||||
final bool selected;
|
||||
final bool expanded;
|
||||
final double iconColumnWidth;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SidebarItem({
|
||||
required this.page,
|
||||
required this.selected,
|
||||
required this.expanded,
|
||||
required this.iconColumnWidth,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
|
|
@ -975,30 +969,38 @@ class _SidebarItemState extends State<_SidebarItem> {
|
|||
size: 18,
|
||||
color: color,
|
||||
);
|
||||
// Same Row + same outer padding in both states so the icon
|
||||
// stays at the exact same X-pixel before and after expand
|
||||
// (operators don't have to re-aim at a destination they
|
||||
// already pointed at). The label slot is conditional, the
|
||||
// icon's position is not.
|
||||
// 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.
|
||||
final content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
icon,
|
||||
if (widget.expanded) ...[
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
style: theme.textTheme.labelMedium?.copyWith(color: color),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
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),
|
||||
|
|
@ -1009,14 +1011,11 @@ class _SidebarItemState extends State<_SidebarItem> {
|
|||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: FaiMotion.fast,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.md,
|
||||
vertical: FaiSpace.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
),
|
||||
// Only vertical padding — horizontal is owned by the
|
||||
// icon column. Background highlight spans the full
|
||||
// rail width, which is the typical nav-rail UX.
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
|
||||
decoration: BoxDecoration(color: bg),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue