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
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:
@@ -8,6 +8,7 @@ import 'package:clide/kernel/src/keymap/key_chord.dart';
|
||||
import 'package:clide/kernel/src/keymap/keymap_service.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show ActivateIntent, DismissIntent;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
@@ -136,8 +137,6 @@ void main() {
|
||||
expect(intent, isA<InvokeCommandIntent>());
|
||||
final invoke = intent as InvokeCommandIntent;
|
||||
expect(invoke.commandId, 'git.commit');
|
||||
// `id` carries the command suffix for round-trip identification.
|
||||
expect(invoke.id, 'command:git.commit');
|
||||
});
|
||||
|
||||
test('honours a when-clause on the contribution', () async {
|
||||
|
||||
@@ -4,6 +4,7 @@ library;
|
||||
import 'package:clide/kernel/src/keymap/intents.dart';
|
||||
import 'package:clide/kernel/src/keymap/key_chord.dart';
|
||||
import 'package:clide/kernel/src/keymap/keymap.dart';
|
||||
import 'package:flutter/widgets.dart' show ActivateIntent, DismissIntent;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/// Widget tests for the keyboard-operable [ClideTappable] (T-100).
|
||||
///
|
||||
/// The pre-T-100 widget was mouse-only — these tests assert it now
|
||||
/// accepts focus, that Enter / Space invoke onTap via the
|
||||
/// [ActivateIntent] action, and that the focus ring appears when
|
||||
/// the widget has focus.
|
||||
library;
|
||||
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
group('ClideTappable — keyboard', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() async => f.dispose());
|
||||
|
||||
testWidgets('accepts Tab focus when onTap is provided', (tester) async {
|
||||
var tapped = 0;
|
||||
final node = FocusNode();
|
||||
addTearDown(node.dispose);
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
Center(
|
||||
child: ClideTappable(
|
||||
focusNode: node,
|
||||
onTap: () => tapped++,
|
||||
builder: (_, hovered, pressed) => const SizedBox(width: 60, height: 24),
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
expect(node.hasFocus, isFalse);
|
||||
node.requestFocus();
|
||||
await tester.pump();
|
||||
expect(node.hasFocus, isTrue);
|
||||
// Tap count unchanged — focus alone doesn't invoke.
|
||||
expect(tapped, 0);
|
||||
});
|
||||
|
||||
testWidgets('Enter on a focused tappable invokes onTap via ActivateIntent', (tester) async {
|
||||
var tapped = 0;
|
||||
final node = FocusNode();
|
||||
addTearDown(node.dispose);
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
Center(
|
||||
child: ClideTappable(
|
||||
focusNode: node,
|
||||
autofocus: true,
|
||||
onTap: () => tapped++,
|
||||
builder: (_, hovered, pressed) => const SizedBox(width: 60, height: 24),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
expect(node.hasFocus, isTrue);
|
||||
// Dispatch ActivateIntent directly against the focused context
|
||||
// — mirrors what KeymapService.resolveEvent → Actions.maybeInvoke
|
||||
// would do on Enter / Space.
|
||||
Actions.invoke(node.context!, const ActivateIntent());
|
||||
await tester.pump();
|
||||
expect(tapped, 1);
|
||||
});
|
||||
|
||||
testWidgets('disabled tappable (onTap == null) cannot receive focus', (tester) async {
|
||||
final node = FocusNode();
|
||||
addTearDown(node.dispose);
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
Center(
|
||||
child: ClideTappable(
|
||||
focusNode: node,
|
||||
onTap: null,
|
||||
builder: (_, hovered, pressed) => const SizedBox(width: 60, height: 24),
|
||||
),
|
||||
),
|
||||
));
|
||||
node.requestFocus();
|
||||
await tester.pump();
|
||||
expect(node.hasFocus, isFalse, reason: 'canRequestFocus is false when onTap is null');
|
||||
});
|
||||
|
||||
testWidgets('focus ring appears when focused, gone when unfocused', (tester) async {
|
||||
final node = FocusNode();
|
||||
addTearDown(node.dispose);
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
Center(
|
||||
child: ClideTappable(
|
||||
focusNode: node,
|
||||
onTap: () {},
|
||||
builder: (_, hovered, pressed) => const SizedBox(width: 60, height: 24),
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
Iterable<Color> ringColors() => tester
|
||||
.widgetList<DecoratedBox>(find.descendant(of: find.byType(ClideTappable), matching: find.byType(DecoratedBox)))
|
||||
.map((d) => ((d.decoration as BoxDecoration).border?.top.color ?? const Color(0x00000000)));
|
||||
|
||||
// Unfocused: no DecoratedBox descendant carries a non-transparent border.
|
||||
expect(ringColors().any((c) => c.a > 0), isFalse);
|
||||
|
||||
node.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
expect(node.hasFocus, isTrue);
|
||||
expect(ringColors().any((c) => c.a > 0), isTrue, reason: 'focus ring border should become opaque on focus');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user