add status-bar collapse toggles for sidebar + context pane (T-294)
Fixed ~24px caret-line cells bookend the status bar (left=sidebar, right=context); chevron flips per arrangement.isCollapsed (inward=collapse, outward=expand) and fires the existing sidebar.collapse / context.collapse commands — the mouse affordance for the already keyboard/CLI-addressable action (D-6). Visual only; no new collapse logic. Status items shift 28px inward for the reserved cells. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Collapse toggles in the status bar.** A small caret-line button bookends
|
||||||
|
each end of the bottom status bar — left collapses/expands the sidebar, right
|
||||||
|
the context pane. The chevron points inward to collapse, outward to expand,
|
||||||
|
and fires the existing `sidebar.collapse` / `context.collapse` commands
|
||||||
|
(`Ctrl+Shift+1` / `Ctrl+Shift+3`), so it's the mouse affordance for an
|
||||||
|
already keyboard/CLI-addressable action. (T-294)
|
||||||
- **Pasted images render inline in the Claude conversation.** A pasted-image
|
- **Pasted images render inline in the Claude conversation.** A pasted-image
|
||||||
`@<path>` reference now shows as a bounded thumbnail in the message instead of
|
`@<path>` reference now shows as a bounded thumbnail in the message instead of
|
||||||
the raw path; clicking it (or pressing Enter when focused) opens the full
|
the raw path; clicking it (or pressing Enter when focused) opens the full
|
||||||
|
|||||||
+49
-4
@@ -1091,6 +1091,46 @@ class _BottomRail extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fixed-position collapse/expand toggle bookending the status bar (T-294).
|
||||||
|
/// The left cell controls the sidebar, the right cell the context pane; both
|
||||||
|
/// fire the existing `sidebar.collapse` / `context.collapse` commands and flip a
|
||||||
|
/// caret-line chevron per `arrangement.isCollapsed` (outward = expand, inward =
|
||||||
|
/// collapse). The collapse behaviour itself lives in the commands (D-51/D-54);
|
||||||
|
/// this is the mouse affordance for the keyboard/CLI-addressable action (D-6).
|
||||||
|
class _CollapseToggle extends StatelessWidget {
|
||||||
|
const _CollapseToggle({required this.slot});
|
||||||
|
|
||||||
|
final SlotId slot;
|
||||||
|
|
||||||
|
bool get _isSidebar => slot == Slots.sidebar;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final kernel = ClideKernel.of(context);
|
||||||
|
final tokens = ClideTheme.of(context).surface;
|
||||||
|
if (!kernel.arrangement.isVisible(slot)) return const SizedBox(width: 24);
|
||||||
|
final collapsed = kernel.arrangement.isCollapsed(slot);
|
||||||
|
// Mirror + flip: the chevron points OUT (toward the edge the pane lives on)
|
||||||
|
// to expand a collapsed pane, and IN to collapse an open one.
|
||||||
|
final icon = _isSidebar
|
||||||
|
? (collapsed ? PhosphorIcons.caretLineRight : PhosphorIcons.caretLineLeft)
|
||||||
|
: (collapsed ? PhosphorIcons.caretLineLeft : PhosphorIcons.caretLineRight);
|
||||||
|
final what = _isSidebar ? 'sidebar' : 'context panel';
|
||||||
|
return SizedBox(
|
||||||
|
width: 24,
|
||||||
|
child: ClideTappable(
|
||||||
|
onTap: () => kernel.commands.execute(_isSidebar ? 'sidebar.collapse' : 'context.collapse'),
|
||||||
|
tooltip: collapsed ? 'Show $what' : 'Hide $what',
|
||||||
|
builder: (ctx, hovered, focused) => Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
color: (hovered || focused) ? tokens.listItemHoverBackground : null,
|
||||||
|
child: ClideIcon(icon, size: 13, color: tokens.statusBarForeground),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class StatusbarHost extends StatelessWidget {
|
class StatusbarHost extends StatelessWidget {
|
||||||
const StatusbarHost({super.key});
|
const StatusbarHost({super.key});
|
||||||
|
|
||||||
@@ -1099,7 +1139,9 @@ class StatusbarHost extends StatelessWidget {
|
|||||||
final kernel = ClideKernel.of(context);
|
final kernel = ClideKernel.of(context);
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: kernel.panels,
|
// Also listen to the arrangement so the toggle chevrons flip with the
|
||||||
|
// collapsed state (T-294).
|
||||||
|
listenable: Listenable.merge([kernel.panels, kernel.arrangement]),
|
||||||
builder: (ctx, _) {
|
builder: (ctx, _) {
|
||||||
final items = kernel.panels.contributionsFor(Slots.statusbar).whereType<StatusItemContribution>().toList();
|
final items = kernel.panels.contributionsFor(Slots.statusbar).whereType<StatusItemContribution>().toList();
|
||||||
final left = items.where((i) => i.priority < 100).toList();
|
final left = items.where((i) => i.priority < 100).toList();
|
||||||
@@ -1110,13 +1152,15 @@ class StatusbarHost extends StatelessWidget {
|
|||||||
// trails it at intrinsic width — so it hugs the workspace block's
|
// trails it at intrinsic width — so it hugs the workspace block's
|
||||||
// right edge by construction, no Spacer to fight a flex item (T-239).
|
// right edge by construction, no Spacer to fight a flex item (T-239).
|
||||||
// Left items with flex > 0 wrap in Flexible(loose) so they yield width
|
// Left items with flex > 0 wrap in Flexible(loose) so they yield width
|
||||||
// when tight (T-160).
|
// when tight (T-160). The collapse toggles bookend both ends at a fixed
|
||||||
|
// ~24px each; the status items sit between them (T-294).
|
||||||
return Container(
|
return Container(
|
||||||
color: tokens.chromeBackground,
|
color: tokens.chromeBackground,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
const _CollapseToggle(slot: Slots.sidebar),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
@@ -1127,6 +1171,7 @@ class StatusbarHost extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final item in right) item.build(ctx),
|
for (final item in right) item.build(ctx),
|
||||||
|
const _CollapseToggle(slot: Slots.contextPanel),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ abstract class PhosphorIcons {
|
|||||||
static const caretRight = PhosphorIconPainter(0xe13a);
|
static const caretRight = PhosphorIconPainter(0xe13a);
|
||||||
static const caretDown = PhosphorIconPainter(0xe136);
|
static const caretDown = PhosphorIconPainter(0xe136);
|
||||||
static const caretUp = PhosphorIconPainter(0xe13c);
|
static const caretUp = PhosphorIconPainter(0xe13c);
|
||||||
|
// Chevron-with-edge-line: reads as "collapse to / expand from the edge" (T-294).
|
||||||
|
static const caretLineLeft = PhosphorIconPainter(0xe132);
|
||||||
|
static const caretLineRight = PhosphorIconPainter(0xe130);
|
||||||
static const graph = PhosphorIconPainter(0xeb58);
|
static const graph = PhosphorIconPainter(0xeb58);
|
||||||
static const treeStructure = PhosphorIconPainter(0xe67c);
|
static const treeStructure = PhosphorIconPainter(0xe67c);
|
||||||
static const image = PhosphorIconPainter(0xe2ca);
|
static const image = PhosphorIconPainter(0xe2ca);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/// 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.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/app.dart';
|
||||||
|
import 'package:clide/builtin/default_layout/default_layout.dart';
|
||||||
|
import 'package:clide/extension/extension.dart';
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'helpers/kernel_fixture.dart';
|
||||||
|
|
||||||
|
Finder _icon(PhosphorIconPainter p) => find.byWidgetPredicate((w) => w is ClideIcon && w.painter == p);
|
||||||
|
|
||||||
|
Widget _bar(KernelFixture f) => 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()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
tearDown(() => f.dispose());
|
||||||
|
|
||||||
|
testWidgets('both toggles show, chevrons point inward when panes are open', (tester) async {
|
||||||
|
await tester.pumpWidget(_bar(f));
|
||||||
|
await tester.pump();
|
||||||
|
// Sidebar (left end) collapses leftward → caret-line-left; context (right
|
||||||
|
// end) collapses rightward → caret-line-right.
|
||||||
|
expect(_icon(PhosphorIcons.caretLineLeft), findsOneWidget);
|
||||||
|
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));
|
||||||
|
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), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping the sidebar toggle fires sidebar.collapse', (tester) async {
|
||||||
|
await tester.pumpWidget(_bar(f));
|
||||||
|
await tester.pump();
|
||||||
|
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isFalse);
|
||||||
|
|
||||||
|
await tester.tap(_icon(PhosphorIcons.caretLineLeft)); // the open-sidebar toggle
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -69,9 +69,11 @@ void main() {
|
|||||||
for (final width in [600.0, 3440.0]) {
|
for (final width in [600.0, 3440.0]) {
|
||||||
await pumpAt(tester, width);
|
await pumpAt(tester, width);
|
||||||
expect(tester.takeException(), isNull, reason: 'width=$width');
|
expect(tester.takeException(), isNull, reason: 'width=$width');
|
||||||
// StatusbarHost pads 8px each side → right item's right edge ≈ width - 8.
|
// StatusbarHost pads 4px each side and reserves a fixed 24px collapse-
|
||||||
expect(tester.getTopRight(find.text('RIGHT')).dx, closeTo(width - 8, 1.0), reason: 'right group not at edge at width=$width');
|
// toggle cell at each end (T-294), so status items sit 28px inside both
|
||||||
expect(tester.getTopLeft(find.text('LEFT')).dx, closeTo(8, 1.0), reason: 'left not at start at width=$width');
|
// 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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ void main() {
|
|||||||
));
|
));
|
||||||
await pumpAt(tester, 3440.0);
|
await pumpAt(tester, 3440.0);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
expect(tester.getTopRight(find.text('R2')).dx, closeTo(3440 - 8, 1.0));
|
// 4px pad + 24px reserved toggle cell at the right end (T-294).
|
||||||
|
expect(tester.getTopRight(find.text('R2')).dx, closeTo(3440 - 28, 1.0));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user