pin collapse toggles to the status-bar screen edges + flip chevron live (T-294)

Two fixes to the just-shipped collapse toggles:
- They lived inside the centre StatusbarHost, so they shifted when a pane
  collapsed and the bar resized. Move them to the OUTER status-bar row ends
  (screen edges) so they stay put. StatusbarHost reverts to its prior layout.
- The toggle was a const widget that read isCollapsed in its own build; Flutter
  skips identical const children on rebuild, so the chevron never flipped. Pass
  collapsed/visible in from the parent's arrangement listener so it rebuilds and
  the chevron tracks the action direction. A 'flips live' test now guards it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 18:54:46 +02:00
co-authored by Claude Opus 4.8
parent c35bb50c0c
commit 548e3e17e8
5 changed files with 95 additions and 68 deletions
+53 -46
View File
@@ -1,6 +1,6 @@
/// Status-bar collapse toggles (T-294): a fixed cell bookends each end of the
/// status bar, flips a caret-line chevron per arrangement.isCollapsed, and fires
/// the existing sidebar.collapse / context.collapse commands.
/// Status-bar collapse toggle (T-294): a fixed cell pinned to a screen edge of
/// the bar, flipping a caret-line chevron to reflect the direction of the
/// action and firing the existing collapse commands.
library;
import 'package:clide/app.dart';
@@ -15,18 +15,15 @@ import 'helpers/kernel_fixture.dart';
Finder _icon(PhosphorIconPainter p) => find.byWidgetPredicate((w) => w is ClideIcon && w.painter == p);
Widget _bar(KernelFixture f) => Directionality(
Widget _host(KernelFixture f, Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: const MediaQuery(
data: MediaQueryData(size: Size(800, 200)),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 800, height: 26, child: StatusbarHost()),
),
child: MediaQuery(
data: const MediaQueryData(size: Size(800, 200)),
child: Align(alignment: Alignment.topLeft, child: SizedBox(height: 26, child: child)),
),
),
),
@@ -34,58 +31,68 @@ Widget _bar(KernelFixture f) => Directionality(
void main() {
late KernelFixture f;
setUp(() async {
f = await KernelFixture.create();
f.services.arrangement.applyPreset(const LayoutPresetContribution(
id: 'test',
displayName: 'test',
slots: [
LayoutSlot(slot: Slots.sidebar, position: SlotPosition.left, visible: true),
LayoutSlot(slot: Slots.contextPanel, position: SlotPosition.right, visible: true),
],
));
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activate('builtin.default-layout');
});
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
testWidgets('both toggles show, chevrons point inward when panes are open', (tester) async {
await tester.pumpWidget(_bar(f));
testWidgets('chevron points the direction of the action (collapse vs expand)', (tester) async {
// Sidebar open → action is collapse (leftward) → caret-line-left.
await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: false, visible: true)));
await tester.pump();
// Sidebar (left end) collapses leftward → caret-line-left; context (right
// end) collapses rightward → caret-line-right.
expect(_icon(PhosphorIcons.caretLineLeft), findsOneWidget);
// Sidebar collapsed → action is expand (rightward) → caret-line-right.
await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: true, visible: true)));
await tester.pump();
expect(_icon(PhosphorIcons.caretLineRight), findsOneWidget);
// The context toggle mirrors it: open → collapse rightward, collapsed → expand leftward.
await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.contextPanel, collapsed: false, visible: true)));
await tester.pump();
expect(_icon(PhosphorIcons.caretLineRight), findsOneWidget);
});
testWidgets('a collapsed pane flips its chevron outward (expand affordance)', (tester) async {
f.services.arrangement.setCollapsed(Slots.sidebar, true);
await tester.pumpWidget(_bar(f));
testWidgets('the chevron flips live when the collapsed state changes', (tester) async {
var collapsed = false;
late StateSetter setOuter;
await tester.pumpWidget(_host(
f,
StatefulBuilder(builder: (ctx, setState) {
setOuter = setState;
return StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: collapsed, visible: true);
}),
));
await tester.pump();
// Sidebar now collapsed → its chevron points right (expand); context still
// open → right. So two right-pointing, none left.
expect(_icon(PhosphorIcons.caretLineRight), findsNWidgets(2));
expect(_icon(PhosphorIcons.caretLineLeft), findsOneWidget);
setOuter(() => collapsed = true);
await tester.pump();
expect(_icon(PhosphorIcons.caretLineRight), findsOneWidget, reason: 'rebuilds and flips on state change');
expect(_icon(PhosphorIcons.caretLineLeft), findsNothing);
});
testWidgets('tapping the sidebar toggle fires sidebar.collapse', (tester) async {
await tester.pumpWidget(_bar(f));
testWidgets('a hidden pane reserves the cell but shows no chevron', (tester) async {
await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: false, visible: false)));
await tester.pump();
expect(_icon(PhosphorIcons.caretLineLeft), findsNothing);
expect(_icon(PhosphorIcons.caretLineRight), findsNothing);
});
testWidgets('tapping fires the matching collapse command', (tester) async {
f.services.arrangement.applyPreset(const LayoutPresetContribution(
id: 'test',
displayName: 'test',
slots: [LayoutSlot(slot: Slots.sidebar, position: SlotPosition.left, visible: true)],
));
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activate('builtin.default-layout');
await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: false, visible: true)));
await tester.pump();
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isFalse);
await tester.tap(_icon(PhosphorIcons.caretLineLeft)); // the open-sidebar toggle
await tester.tap(_icon(PhosphorIcons.caretLineLeft));
await tester.pump();
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isTrue);
});
testWidgets('a hidden pane reserves the cell but shows no toggle', (tester) async {
f.services.arrangement.setVisible(Slots.contextPanel, false);
await tester.pumpWidget(_bar(f));
await tester.pump();
// Sidebar toggle present (open → left); context hidden → no right chevron.
expect(_icon(PhosphorIcons.caretLineLeft), findsOneWidget);
expect(_icon(PhosphorIcons.caretLineRight), findsNothing);
});
}
+5 -7
View File
@@ -69,11 +69,10 @@ void main() {
for (final width in [600.0, 3440.0]) {
await pumpAt(tester, width);
expect(tester.takeException(), isNull, reason: 'width=$width');
// StatusbarHost pads 4px each side and reserves a fixed 24px collapse-
// toggle cell at each end (T-294), so status items sit 28px inside both
// edges: right item's right edge width - 28, left item's left ≈ 28.
expect(tester.getTopRight(find.text('RIGHT')).dx, closeTo(width - 28, 1.0), reason: 'right group not at edge at width=$width');
expect(tester.getTopLeft(find.text('LEFT')).dx, closeTo(28, 1.0), reason: 'left not at start at width=$width');
// StatusbarHost pads 8px each side → right item's right edge ≈ width - 8.
// (Collapse toggles live at the bar's screen edges, outside this host.)
expect(tester.getTopRight(find.text('RIGHT')).dx, closeTo(width - 8, 1.0), reason: 'right group not at edge at width=$width');
expect(tester.getTopLeft(find.text('LEFT')).dx, closeTo(8, 1.0), reason: 'left not at start at width=$width');
}
});
@@ -85,7 +84,6 @@ void main() {
));
await pumpAt(tester, 3440.0);
expect(tester.takeException(), isNull);
// 4px pad + 24px reserved toggle cell at the right end (T-294).
expect(tester.getTopRight(find.text('R2')).dx, closeTo(3440 - 28, 1.0));
expect(tester.getTopRight(find.text('R2')).dx, closeTo(3440 - 8, 1.0));
});
}