keyboard-operable ClideTappable + palette nav (T-100)
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m5s

Plug widgets into the keymap layer landed in T-117.

ClideTappable:
  - Wrap in `Actions(ActivateIntent → onTap)` outside a `Focus` so
    dispatch from the focused context walks up and hits the action.
  - Add a focus ring via `tokens.globalFocus` (DecoratedBox foreground
    overlay, transparent border when unfocused, no layout shift).
  - Disabled (`onTap == null`) skips focus traversal and shows the
    forbidden cursor.

ClidePalette:
  - Register Actions for the four palette intents
    (selectNext / selectPrev / accept / dismiss).
  - Publish `palette.open` scope flag via `KeymapService.setScopeFlag`
    so when-clauses can scope future bindings to "palette only".
  - Highlight the selected row with `listItemSelectedBackground`;
    scroll it into view on nav.
  - `PaletteController` grows `selectedIndex` + `selectNext` /
    `selectPrevious` / `acceptSelected`; index resets on open /
    filter change.

Intents.dart drops the `ClideIntent` base — `ActivateIntent` and
`DismissIntent` come from Flutter; clide owns the palette and text-
scale and command-bridge subclasses. `parseIntentId('activate')` →
Flutter's class; same for dismiss. Widget code uses the canonical
Flutter Intent types where they fit.

App root grows a PaletteOpenIntent action that calls
`services.palette.open()`, completing the ctrl/cmd+shift+p path
end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:48:03 +02:00
co-authored by Claude Opus 4.7
parent 798ba524f1
commit 12e0509fa3
15 changed files with 531 additions and 160 deletions
@@ -145,6 +145,91 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Hoverable'), findsOneWidget);
});
testWidgets('arrow keys move the highlighted command (T-100)', (tester) async {
for (final id in ['a', 'b', 'c']) {
f.services.commands.register(CommandContribution(
id: id,
command: 'cmd.$id',
title: 'Item $id',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
));
}
f.services.palette.open();
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
await tester.pumpAndSettle();
// selectedIndex starts at 0.
expect(f.services.palette.selectedIndex, 0);
// Dispatch palette intents directly against the palette's
// focused context — what the keymap would do for ↓ / ↑.
final ctx = f.services.palette.isOpen ? FocusManager.instance.primaryFocus?.context : null;
Actions.invoke(ctx!, const PaletteSelectNextIntent());
expect(f.services.palette.selectedIndex, 1);
Actions.invoke(ctx, const PaletteSelectNextIntent());
expect(f.services.palette.selectedIndex, 2);
// Wraps at end.
Actions.invoke(ctx, const PaletteSelectNextIntent());
expect(f.services.palette.selectedIndex, 0);
// Wraps backwards at start.
Actions.invoke(ctx, const PaletteSelectPreviousIntent());
expect(f.services.palette.selectedIndex, 2);
});
testWidgets('PaletteAcceptIntent invokes the highlighted command (T-100)', (tester) async {
var which = '';
for (final id in ['a', 'b', 'c']) {
f.services.commands.register(CommandContribution(
id: id,
command: 'cmd.$id',
title: 'Item $id',
run: (_) async {
which = id;
return IpcResponse.ok(id: '', data: const {});
},
));
}
f.services.palette.open();
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
await tester.pumpAndSettle();
final ctx = FocusManager.instance.primaryFocus!.context!;
Actions.invoke(ctx, const PaletteSelectNextIntent());
Actions.invoke(ctx, const PaletteAcceptIntent());
await tester.pumpAndSettle();
expect(which, 'b');
expect(f.services.palette.isOpen, isFalse, reason: 'acceptSelected closes the palette');
});
testWidgets('DismissIntent closes the palette (T-100)', (tester) async {
f.services.commands.register(CommandContribution(
id: 'c1',
command: 'thing',
title: 'Thing',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
));
f.services.palette.open();
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
await tester.pumpAndSettle();
final ctx = FocusManager.instance.primaryFocus!.context!;
Actions.invoke(ctx, const DismissIntent());
await tester.pumpAndSettle();
expect(f.services.palette.isOpen, isFalse);
});
testWidgets('opening the palette publishes palette.open scope flag (T-100)', (tester) async {
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
await tester.pumpAndSettle();
expect(f.services.keymap.scope['palette.open'] ?? false, isFalse);
f.services.palette.open();
await tester.pumpAndSettle();
expect(f.services.keymap.scope['palette.open'], isTrue);
f.services.palette.close();
await tester.pumpAndSettle();
expect(f.services.keymap.scope['palette.open'], isFalse);
});
});
group('ClideFilterBox', () {