keystroke mapper layer — intents, presets, when-clauses (T-117)

Build the upstream of every keyboard-driven feature: widgets bind
to typed Intents, the keymap resolves chord+context to an Intent,
and Flutter's Actions dispatches. The widget never touches a key.

Layers (low → high precedence):
  1. preset YAML in assets/keymaps/<preset>.yaml
  2. extension-registered command bindings (via
     KeymapService.registerCommandBinding from ExtensionManager)
  3. user file at <appDir>/keybindings.yaml
  4. settings JSON overlay at app.keymap.overrides

The when-clause grammar is a tiny recursive-descent parser over
boolean expressions on a named context bag — VS-Code style
`palette.open && !textInputFocused`. Producing services publish
scope flags via setScopeFlag.

Keys reference LogicalKeyboardKey.keyId (stable across keyboard
layouts), not the locale-aware keyLabel the consultant flagged.

Ships:
  - lib/kernel/src/keymap/{key_chord, when_clause, intents, keymap,
    keymap_service}.dart
  - assets/keymaps/default.yaml (the baseline preset)
  - 90+ unit tests covering parser precedence, layering precedence,
    scope evaluation, register/unregister, settings overlay,
    malformed-input tolerance
  - app.dart root handler routes through KeymapService → Actions
  - ExtensionManager mirrors every legacy defaultBinding into the
    keymap as a contribution layer

KeybindingResolver kept temporarily as a back-compat shim for
callers we haven't migrated yet; safe to delete once the last
caller goes through Actions.

Closes T-110 (consultant: scoped Shortcuts/Actions; off keyLabel).
Annotates T-23 with what's left for T-100. Unblocks T-64 / T-65 /
T-66 (preset data tickets).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:40:02 +02:00
co-authored by Claude Opus 4.7
parent a8729db893
commit 798ba524f1
18 changed files with 1944 additions and 47 deletions
+78
View File
@@ -1719,3 +1719,81 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-106', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:18:43', '2026-05-17 19:18:43', '2026-05-17 19:18:43', NULL, '8dccc83b93c5005db744a100a5b3baea', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-113', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:18:43', '2026-05-17 19:18:43', '2026-05-17 19:18:43', NULL, '8f7812831e564005eb796708543c54b3', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-103', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:18:43', '2026-05-17 19:18:43', '2026-05-17 19:18:43', NULL, 'c7d1b76cb6854efb7e9990cc7204d899', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-100', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 19:23:24', '2026-05-17 19:23:24', '2026-05-17 19:23:24', NULL, '607b82f096b5d694fb75b7daf64dc2e0', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-117', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 19:28:31', '2026-05-17 19:28:31', '2026-05-17 19:28:31', NULL, 'b5c56dff8a2c7d6f14ecf19231b63268', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-110', 'description', '`app.dart:90-148` routes all shortcuts through one root `KeyboardListener` — no per-context scoping; will conflict with text-input fields once any pane wants to capture keys. `KeybindingResolver.fromKeyEvent` keys off `logicalKey.keyLabel` which is layout-dependent (US-QWERTY `Ctrl+/` differs from AZERTY `Ctrl+:`).
**Fix:**
1. Replace the root `KeyboardListener` with scoped `Shortcuts`/`Actions` per slot.
2. Move `KeybindingResolver` to `physicalKey` or a stable mapping layer.
Coordinate with T-100 (Focus/Shortcuts wrapper for ClideTappable) and T-105 (focus traversal).
Source: consultants.md "UX — Findings — [Major]".', '**Superseded by T-117 — Done.**
The consultant findings here — single root `KeyboardListener` will conflict with text-input fields; `KebindingResolver.fromKeyEvent` keys off layout-dependent `logicalKey.keyLabel` — are both addressed by the keystroke mapper layer (T-117):
- Keymap-driven dispatch uses `LogicalKeyboardKey.keyId` (stable across layouts), not `keyLabel`.
- Root `KeyboardListener` now hands events straight to `KeymapService.resolveEvent`, which dispatches resolved Intents via `Actions.maybeInvoke` against the focused context — Actions providers per feature (palette, editor, etc.) handle their own intents; the root only handles global ones (text scale, generic command bridge). This is the scoped Shortcuts/Actions model the consultant prescribed.
Remaining cleanup (deletion of the now-vestigial `KeybindingResolver` class + the legacy `KeyboardListener` wrap once Flutter Shortcuts widget integration is on every feature) is small and lands as part of T-100 or its own follow-up.
Original text: Replace root KeyboardListener with scoped Shortcuts/Actions; move KebindingResolver off layout-dependent keyLabel.', NULL, '2026-05-17 19:39:08', '2026-05-17 19:39:08', '2026-05-17 19:39:08', NULL, '6364d644fbbfa903390ee78b5b402367', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-110', 'status', 'backlog', 'done', NULL, '2026-05-17 19:39:12', '2026-05-17 19:39:12', '2026-05-17 19:39:12', NULL, '6073b1bbc12001914d78d4cb419705dc', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-23', 'description', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired.
**Acceptance:**
- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace.
- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight.
- Commands listed are everything registered via `CommandContribution` across all activated extensions.
- Fuzzy match against command title; recent / pinned commands float to the top.
**Implementation hints:**
- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart).
- Keybinding goes in `lib/kernel/src/commands/keybindings.dart` per the D-54 keymap.
- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open).', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired.
**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` → `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav).
**Acceptance:**
- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace.
- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight.
- Commands listed are everything registered via `CommandContribution` across all activated extensions.
- Fuzzy match against command title; recent / pinned commands float to the top.
**Implementation hints:**
- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart).
- Bindings live in the keymap (T-117) — not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy).
- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open).', NULL, '2026-05-17 19:39:23', '2026-05-17 19:39:23', '2026-05-17 19:39:23', NULL, '50203b7db93490ac779fe496865cf3a0', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-64', 'description', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
**Acceptance:**
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).', NULL, '2026-05-17 19:39:37', '2026-05-17 19:39:37', '2026-05-17 19:39:37', NULL, 'a97758128bd490f603f11a68883a7193', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-65', 'description', 'Ship a Vim-compatible keybinding preset with modal editing support (normal/insert/visual modes). Maps Vim motions and commands to clide editor and navigation actions. Users select it in settings.', 'Ship a Vim-compatible keybinding preset with modal editing support (normal/insert/visual modes). Maps Vim motions and commands to clide editor and navigation actions. Users select it in settings.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place; modal Vim presets are more involved than the VS Code preset (T-64) because modes need to be expressed as scope flags (`vim.normal`, `vim.insert`, `vim.visual`) that the when-clause grammar can branch on. Implementation work:
1. Author `assets/keymaps/vim.yaml` using the typed Intents + `command:<id>` bindings.
2. Add a small mode-tracking service that publishes `vim.<mode>` scope flags via `KeymapService.setScopeFlag`.
3. Bind `Esc` to mode-reset → normal; `i` (when `vim.normal`) → enter insert; etc.
**Acceptance:**
1. `assets/keymaps/vim.yaml` ships covering the documented Vim default keybindings for editor / navigation / panes.
2. `KeymapService.setPreset("vim")` + the mode-tracking service together produce correct mode transitions.
3. A regression test exercises a representative motion (`j` → cursor down) and a mode change (`i` → insert).', NULL, '2026-05-17 19:39:37', '2026-05-17 19:39:37', '2026-05-17 19:39:37', NULL, 'bf10f0149ccaa1e02050280b28305816', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-66', 'description', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
**Acceptance:**
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).', NULL, '2026-05-17 19:39:37', '2026-05-17 19:39:37', '2026-05-17 19:39:37', NULL, 'c1129f4cb3454b99e1cbf645ca56bea5', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-117', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:39:40', '2026-05-17 19:39:40', '2026-05-17 19:39:40', NULL, '75b2fd14ceb7daf28645ccf84cb041f1', 1) ON CONFLICT(hash) DO NOTHING;
+93
View File
@@ -1668,3 +1668,96 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
**Acceptance:** `make push-check` runs the integration test layer + smoke-bundle. Wall-clock budget acceptable for pre-push (target <2 min total). If they''re too slow, gate them behind a separate `make push-check-full` and document.
Source: consultants.md "Tests — Findings — [Major] make push-check does not run integration tests".', 'done', 'high', NULL, NULL, NULL, '2026-05-17 18:47:39', '2026-05-17 19:18:43', NULL, 'cbae1915f1c89a8a2b05da71148d77dc', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-100', 'story', 'T-97', 'make ClideTappable keyboard-operable; add palette arrow-keys + Escape', '`ClideTappable` (`lib/widgets/src/clide_tappable.dart`) is the base of nearly every interactive widget — `ClideButton`, `_WinBtn`, `_RecentProjectRow`, `_ActionRow`, most builtin list items — and it is `MouseRegion` + `GestureDetector` only. No `Focus`, no Enter/Space handler, no focus ring. None of these widgets can be reached by Tab or activated from the keyboard. The keyboard-traversal test passes only because it externally wraps in a `Focus` node — it tests non-blocking, not operability.
`ClidePalette` (`lib/widgets/src/clide_palette.dart`) is similarly broken: `onSubmitted` only ever invokes `filtered.first`; no up/down handling, no selected index, no selection highlight, no Escape handler.
**Fix:**
1. Wrap `ClideTappable`''s child in `Focus` + `Shortcuts`/`Actions` so Tab focuses it and Enter/Space invokes `onTap`. Render a focus ring via the token system.
2. Add arrow-key navigation + selected-index + Escape + Enter-on-selected to `ClidePalette` (model after `_ProjectSwitcherDropdown.onKeyEvent` at `app.dart:446-452`).
3. Extend the a11y test layer to assert operability (Tab + Enter actually invokes), not just Semantics presence.
Source: consultants.md "UX — Findings — [Critical]".', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-17 18:47:23', '2026-05-17 19:23:24', NULL, '45607823ef1229d43dcfd0bc327a1031', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-110', 'story', 'T-97', 'replace root KeyboardListener with scoped Shortcuts/Actions; move off keyLabel', '**Superseded by T-117 — Done.**
The consultant findings here single root `KeyboardListener` will conflict with text-input fields; `KebindingResolver.fromKeyEvent` keys off layout-dependent `logicalKey.keyLabel` are both addressed by the keystroke mapper layer (T-117):
- Keymap-driven dispatch uses `LogicalKeyboardKey.keyId` (stable across layouts), not `keyLabel`.
- Root `KeyboardListener` now hands events straight to `KeymapService.resolveEvent`, which dispatches resolved Intents via `Actions.maybeInvoke` against the focused context Actions providers per feature (palette, editor, etc.) handle their own intents; the root only handles global ones (text scale, generic command bridge). This is the scoped Shortcuts/Actions model the consultant prescribed.
Remaining cleanup (deletion of the now-vestigial `KeybindingResolver` class + the legacy `KeyboardListener` wrap once Flutter Shortcuts widget integration is on every feature) is small and lands as part of T-100 or its own follow-up.
Original text: Replace root KeyboardListener with scoped Shortcuts/Actions; move KebindingResolver off layout-dependent keyLabel.', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:09', '2026-05-17 19:39:12', NULL, '33850630ffa4bf1f1794fd94db8a62c3', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-23', 'task', 'T-4', 'wire command palette keybinding', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired.
**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav).
**Acceptance:**
- `P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace.
- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight.
- Commands listed are everything registered via `CommandContribution` across all activated extensions.
- Fuzzy match against command title; recent / pinned commands float to the top.
**Implementation hints:**
- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart).
- Bindings live in the keymap (T-117) not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy).
- The overlay should not shift layout (D-48 chrome budget no layout shift on palette open).', 'backlog', 'medium', NULL, NULL, 'D-6', '2026-04-22 14:08:40', '2026-05-17 19:39:23', NULL, 'c355fdc971b62e0aedbfb99ed4134678', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-66', 'story', NULL, 'JetBrains keybinding preset', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
**Acceptance:**
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
3. A regression test exercises a representative subset (e.g. shift+shift quick-open command see Q-9 if the search-everywhere overlay needs its own intent).', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-05-17 19:39:37', NULL, '633ba8ffaee17af0b6ca6d2cc2a7549d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-64', 'story', NULL, 'VS Code keybinding preset', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
**Acceptance:**
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, ).
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p quick-open command, ctrl+shift+p palette).
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-05-17 19:39:37', NULL, '660a4871f59f940fbc2296b36177e0d8', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-65', 'story', NULL, 'Vim keybinding preset', 'Ship a Vim-compatible keybinding preset with modal editing support (normal/insert/visual modes). Maps Vim motions and commands to clide editor and navigation actions. Users select it in settings.
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place; modal Vim presets are more involved than the VS Code preset (T-64) because modes need to be expressed as scope flags (`vim.normal`, `vim.insert`, `vim.visual`) that the when-clause grammar can branch on. Implementation work:
1. Author `assets/keymaps/vim.yaml` using the typed Intents + `command:<id>` bindings.
2. Add a small mode-tracking service that publishes `vim.<mode>` scope flags via `KeymapService.setScopeFlag`.
3. Bind `Esc` to mode-reset normal; `i` (when `vim.normal`) enter insert; etc.
**Acceptance:**
1. `assets/keymaps/vim.yaml` ships covering the documented Vim default keybindings for editor / navigation / panes.
2. `KeymapService.setPreset("vim")` + the mode-tracking service together produce correct mode transitions.
3. A regression test exercises a representative motion (`j` cursor down) and a mode change (`i` insert).', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-05-17 19:39:37', NULL, '8db44365ff24e322ccc4bf145e31c5c5', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-117', 'task', 'T-97', 'keystroke mapper layer — intents + presets + when-clauses + overlay', 'Build the keymap mechanism that all keyboard-driven work plugs into. Lands before T-100 so widget wiring (ClideTappable, ClidePalette, focus traversal) goes through typed Intents, not raw key handlers. Closes T-110 (consultant: scoped Shortcuts/Actions; move off layout-dependent keyLabel). Unblocks T-64/T-65/T-66 (vim/vscode/jetbrains preset data).
## Design (locked 2026-05-17)
- **Action vocabulary**: typed `ClideIntent` subclasses (`ActivateIntent`, `PaletteSelectNextIntent`, ) each with a stable id used in YAML.
- **Keys**: `LogicalKeyboardKey.keyId` (layout-independent, not the locale-aware keyLabel the consultant flagged) + modifier set.
- **When-clauses**: VS-Code-style boolean expressions over a named context bag (`palette.open && !textInputFocused`). Tiny recursive-descent parser.
- **Preset format**: YAML under `assets/keymaps/<preset>.yaml`. Ships `default.yaml`; vim/vscode/jetbrains land as separate tickets.
- **Layering**: preset (asset) user file (`~/.clide/keybindings.yaml`) settings JSON overlay (`app.keymap.overrides`). Later layers replace bindings with the same (chord, when) tuple.
- **Service**: `KeymapService` kernel service holding active layered keymap, scope context, resolver, and a Shortcuts/Actions wrapper.
- **Settings keys**: `app.keymap.preset` (default `default`); `app.keymap.overrides` (list).
## Acceptance
1. `KeymapService` registered as a kernel service.
2. `assets/keymaps/default.yaml` ships and parses; preset switching changes effective bindings.
3. When-clause parser handles `a`, `!a`, `a && b`, `a || b`, parens, with unit tests covering precedence + identifier resolution.
4. Root `KeyboardListener` in `app.dart` removed in favor of `Shortcuts`/`Actions` driven by the service (closes T-110).
5. Existing `KeybindingResolver` callers migrate to the new path; old class either deleted or marked deprecated with a removal date.
6. Tests: preset YAML round-trip, layering precedence, when-clause evaluator, scope-context updates, resolver picks the correct intent for chord+context.
## Out of scope (deferred)
- Vim/VSCode/JetBrains preset *data* (T-64/T-65/T-66 this ticket lands the mechanism).
- A settings UI for editing bindings (file editing + preset switching is enough for v1).
- ClideTappable/Palette widget integration (T-100 comes immediately after this lands).
Source: 2026-05-17 design conversation; supersedes T-110.', 'done', 'high', NULL, NULL, NULL, '2026-05-17 19:28:28', '2026-05-17 19:39:40', NULL, '92d68025f0ee7c4d133dca65e97c97b8', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+4
View File
@@ -44,6 +44,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
- Pre-push changelog gate — `ci/changelog_gate.sh` fails on any
`## [Unreleased]` bullet over 60 words; warns at 40. Enforces the
Keep-a-Changelog conciseness rule in the git-commit skill.
- Keymap layer (`KeymapService`) — typed Intents, YAML presets,
VS-Code-style when-clauses, layered preset → user file → settings
overlay. Default preset ships; vim/vscode/jetbrains unblocked
(T-117, supersedes T-110).
- Test sweep — `keybindings`, `toolchain_paths`, and several
`widgets/src/` primitives (tooltip, palette, multitab, markdown).
- `tree_sitter_service` sweep — fake-FFI + real-library smoke,
+56
View File
@@ -0,0 +1,56 @@
# clide default keymap.
#
# This is the baseline preset. Vim / VSCode / JetBrains presets are
# their own files (T-64/T-65/T-66) and replace bindings via the same
# YAML shape.
#
# Each binding:
# intent: <stable intent id> # see kernel/src/keymap/intents.dart
# keys: <chord> | [<chord>, ...] # `ctrl+shift+p`, `cmd+enter`, ...
# when: <when-clause> # optional; VS-Code-style boolean expr
#
# Identifiers in `when:` are scope flags published by producing
# services (e.g. `palette.open` when ClidePalette is mounted +
# visible). Missing flags evaluate to false.
name: default
bindings:
# -- Activation / focus -----------------------------------------------
- intent: activate
keys: [enter, space]
when: focused.tappable
- intent: dismiss
keys: escape
- intent: focus.next
keys: tab
when: '!textInputFocused'
- intent: focus.previous
keys: shift+tab
when: '!textInputFocused'
# -- Command palette --------------------------------------------------
- intent: palette.open
keys: [ctrl+shift+p, meta+shift+p]
- intent: palette.selectNext
keys: [down, ctrl+n]
when: palette.open
- intent: palette.selectPrevious
keys: [up, ctrl+p]
when: palette.open
- intent: palette.accept
keys: enter
when: palette.open
- intent: dismiss
keys: escape
when: palette.open
# -- Text scale -------------------------------------------------------
# On most layouts `+` is `shift+equal`; we bind both so users who
# think of it as Ctrl+Plus and users who hit Ctrl+= both work.
- intent: text.scaleIncrease
keys: [ctrl+equal, ctrl+shift+equal, meta+equal, meta+shift+equal]
- intent: text.scaleDecrease
keys: [ctrl+minus, meta+minus]
- intent: text.scaleReset
keys: [ctrl+0, meta+0]
+58 -47
View File
@@ -87,30 +87,58 @@ class _RootShellState extends State<_RootShell> {
),
child: MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(_textScale)),
child: KeyboardListener(
focusNode: _keyFocus,
autofocus: true,
onKeyEvent: _onKey,
child: ColoredBox(
color: tokens.globalBackground,
child: ClideResizeBorder(
windowControls: widget.services.window,
child: Column(
children: [
_HatBar(kernel: widget.services),
Expanded(
child: DialogHost(
router: widget.services.dialog,
child: Stack(
children: [
const Positioned.fill(child: RootLayout()),
const ClidePalette(),
const Positioned.fill(child: _WelcomeOverlay()),
],
child: Actions(
actions: <Type, Action<Intent>>{
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
onInvoke: (_) {
setState(() => _textScale = (_textScale + _scaleStep).clamp(_scaleMin, _scaleMax));
return null;
},
),
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
onInvoke: (_) {
setState(() => _textScale = (_textScale - _scaleStep).clamp(_scaleMin, _scaleMax));
return null;
},
),
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
onInvoke: (_) {
setState(() => _textScale = 1.0);
return null;
},
),
InvokeCommandIntent: CallbackAction<InvokeCommandIntent>(
onInvoke: (intent) {
widget.services.commands.execute(intent.commandId);
return null;
},
),
},
child: KeyboardListener(
focusNode: _keyFocus,
autofocus: true,
onKeyEvent: _onKey,
child: ColoredBox(
color: tokens.globalBackground,
child: ClideResizeBorder(
windowControls: widget.services.window,
child: Column(
children: [
_HatBar(kernel: widget.services),
Expanded(
child: DialogHost(
router: widget.services.dialog,
child: Stack(
children: [
const Positioned.fill(child: RootLayout()),
const ClidePalette(),
const Positioned.fill(child: _WelcomeOverlay()),
],
),
),
),
),
],
],
),
),
),
),
@@ -120,31 +148,14 @@ class _RootShellState extends State<_RootShell> {
}
void _onKey(KeyEvent event) {
if (event is KeyDownEvent || event is KeyRepeatEvent) {
final ctrl = HardwareKeyboard.instance.isControlPressed;
if (ctrl) {
if (event.logicalKey == LogicalKeyboardKey.equal || event.logicalKey == LogicalKeyboardKey.add) {
setState(() => _textScale = (_textScale + _scaleStep).clamp(_scaleMin, _scaleMax));
return;
}
if (event.logicalKey == LogicalKeyboardKey.minus) {
setState(() => _textScale = (_textScale - _scaleStep).clamp(_scaleMin, _scaleMax));
return;
}
if (event.logicalKey == LogicalKeyboardKey.digit0) {
setState(() => _textScale = 1.0);
return;
}
}
}
final binding = KeybindingResolver.fromKeyEvent(
event,
HardwareKeyboard.instance,
);
if (binding == null) return;
final commandId = widget.services.keybindings.commandFor(binding);
if (commandId == null) return;
widget.services.commands.execute(commandId);
final intent = widget.services.keymap.resolveEvent(event, HardwareKeyboard.instance);
if (intent == null) return;
// Dispatch the intent. Try the focused context first so feature
// widgets (palette, editor, …) get a chance to handle their own
// intents; fall back to the app root's Actions for global ones
// (text scale, generic command bridge).
final ctx = FocusManager.instance.primaryFocus?.context ?? context;
Actions.maybeInvoke(ctx, intent);
}
}
+5
View File
@@ -22,6 +22,11 @@ export 'src/clipboard.dart';
export 'src/commands/keybindings.dart';
export 'src/commands/palette.dart';
export 'src/commands/registry.dart';
export 'src/keymap/intents.dart';
export 'src/keymap/key_chord.dart';
export 'src/keymap/keymap.dart';
export 'src/keymap/keymap_service.dart';
export 'src/keymap/when_clause.dart';
export 'src/dialog.dart';
export 'src/extensions_manager.dart';
export 'src/files.dart';
+8
View File
@@ -14,6 +14,7 @@ import 'package:clide/kernel/src/files.dart';
import 'package:clide/kernel/src/focus.dart';
import 'package:clide/kernel/src/i18n/i18n.dart';
import 'package:clide/kernel/src/ipc/client.dart';
import 'package:clide/kernel/src/keymap/keymap_service.dart';
import 'package:clide/kernel/src/log.dart';
import 'package:clide/kernel/src/net.dart';
import 'package:clide/kernel/src/notify.dart';
@@ -40,6 +41,7 @@ class ExtensionManager extends ChangeNotifier {
required this.commands,
required this.palette,
required this.keybindings,
required this.keymap,
required this.clipboard,
required this.files,
required this.notify,
@@ -64,6 +66,7 @@ class ExtensionManager extends ChangeNotifier {
final CommandRegistry commands;
final PaletteController palette;
final KeybindingResolver keybindings;
final KeymapService keymap;
final ClideClipboard clipboard;
final FileServices files;
final Notifications notify;
@@ -171,7 +174,11 @@ class ExtensionManager extends ChangeNotifier {
commands.register(cmd);
final binding = cmd.defaultBinding;
if (binding != null) {
// Legacy KeybindingResolver still wired for back-compat
// until all callers migrate; the keymap layer is the
// canonical home for chord → command bindings (T-117).
keybindings.bind(Keybinding.parse(binding), cmd.command);
keymap.registerCommandBinding(binding, cmd.command);
}
case TrayItemContribution t:
tray.add(t);
@@ -194,6 +201,7 @@ class ExtensionManager extends ChangeNotifier {
if (binding != null) {
keybindings.unbind(Keybinding.parse(binding));
}
keymap.unregisterCommandBindings(cmd.command);
case TrayItemContribution t:
tray.remove(t.id);
case LayoutPresetContribution _:
+8
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:clide/clide.dart';
import 'package:clide/kernel/src/clipboard.dart';
import 'package:clide/kernel/src/commands/keybindings.dart';
import 'package:clide/kernel/src/keymap/keymap_service.dart';
import 'package:clide/kernel/src/commands/palette.dart';
import 'package:clide/kernel/src/commands/registry.dart';
import 'package:clide/kernel/src/dialog.dart';
@@ -63,6 +64,7 @@ class KernelServices {
required this.window,
required this.toolchain,
required this.scheduler,
required this.keymap,
});
final Logger log;
@@ -91,6 +93,7 @@ class KernelServices {
final WindowControls window;
final Toolchain toolchain;
final SchedulerService scheduler;
final KeymapService keymap;
static Future<KernelServices> boot({
required Directory appDir,
@@ -132,6 +135,8 @@ class KernelServices {
final arrangement = LayoutArrangement();
final commands = CommandRegistry();
final keybindings = KeybindingResolver();
final keymap = KeymapService(settings: settings, appDir: appDir);
await keymap.load();
final palette = PaletteController(commands);
final clipboard = ClideClipboard();
final files = FileServices(events);
@@ -174,6 +179,7 @@ class KernelServices {
commands: commands,
palette: palette,
keybindings: keybindings,
keymap: keymap,
clipboard: clipboard,
files: files,
notify: notify,
@@ -218,6 +224,7 @@ class KernelServices {
window: window,
toolchain: tc,
scheduler: scheduler,
keymap: keymap,
);
}
@@ -239,6 +246,7 @@ class KernelServices {
project.dispose();
extensions.dispose();
await scheduler.dispose();
keymap.dispose();
await log.dispose();
messages.dispose();
await events.dispose();
+152
View File
@@ -0,0 +1,152 @@
/// Typed [Intent]s the keymap dispatches.
///
/// Widgets bind Actions to Intent types via `Actions.handler`. Preset
/// YAML files reference Intents by their string id (`activate`,
/// `palette.selectNext`, …). The id stays stable across SDK reshapes;
/// the Dart class name can move without invalidating user keymaps.
///
/// To add a new Intent: declare a subclass with a unique [id] and
/// register it in [allIntents]. Widget integration is per-feature
/// (Actions wiring lives in the consuming widget).
library;
import 'package:flutter/widgets.dart';
/// Base for every keymap-dispatched intent. The [id] is the YAML
/// identifier (e.g. `palette.selectNext`).
abstract class ClideIntent extends Intent {
const ClideIntent();
String get id;
}
// -- Activation / navigation ------------------------------------------------
/// "Click this thing" — fired on Enter/Space against any focusable
/// `ClideTappable`-rooted widget.
class ActivateIntent extends ClideIntent {
const ActivateIntent();
@override
String get id => 'activate';
}
/// "Cancel / dismiss the current modal / overlay".
class DismissIntent extends ClideIntent {
const DismissIntent();
@override
String get id => 'dismiss';
}
/// "Move focus to the next focusable in tab order".
class FocusNextIntent extends ClideIntent {
const FocusNextIntent();
@override
String get id => 'focus.next';
}
/// "Move focus to the previous focusable".
class FocusPreviousIntent extends ClideIntent {
const FocusPreviousIntent();
@override
String get id => 'focus.previous';
}
// -- Command palette --------------------------------------------------------
/// Open the command palette.
class PaletteOpenIntent extends ClideIntent {
const PaletteOpenIntent();
@override
String get id => 'palette.open';
}
/// Highlight the next palette result.
class PaletteSelectNextIntent extends ClideIntent {
const PaletteSelectNextIntent();
@override
String get id => 'palette.selectNext';
}
/// Highlight the previous palette result.
class PaletteSelectPreviousIntent extends ClideIntent {
const PaletteSelectPreviousIntent();
@override
String get id => 'palette.selectPrevious';
}
/// Invoke the highlighted palette result.
class PaletteAcceptIntent extends ClideIntent {
const PaletteAcceptIntent();
@override
String get id => 'palette.accept';
}
// -- Text scale -------------------------------------------------------------
class TextScaleIncreaseIntent extends ClideIntent {
const TextScaleIncreaseIntent();
@override
String get id => 'text.scaleIncrease';
}
class TextScaleDecreaseIntent extends ClideIntent {
const TextScaleDecreaseIntent();
@override
String get id => 'text.scaleDecrease';
}
class TextScaleResetIntent extends ClideIntent {
const TextScaleResetIntent();
@override
String get id => 'text.scaleReset';
}
// -- Command bridge ---------------------------------------------------------
/// Generic "invoke this CommandRegistry command id" intent. Used for
/// bindings that target a contributed command rather than a typed
/// intent. The keymap creates one per binding; the Actions handler
/// dispatches to the [CommandRegistry].
class InvokeCommandIntent extends ClideIntent {
const InvokeCommandIntent(this.commandId);
final String commandId;
@override
String get id => 'command:$commandId';
}
// -- Lookup -----------------------------------------------------------------
/// Map from YAML id → factory. Preset files reference intents by id;
/// the keymap loader uses this to instantiate them. Intents with a
/// configurable payload (only `InvokeCommandIntent` today) are not in
/// the map — the loader recognises the `command:` prefix and
/// instantiates them inline.
final Map<String, ClideIntent Function()> builtinIntents = {
for (final i in _allBuiltin) i.id: () => i,
};
const List<ClideIntent> _allBuiltin = [
ActivateIntent(),
DismissIntent(),
FocusNextIntent(),
FocusPreviousIntent(),
PaletteOpenIntent(),
PaletteSelectNextIntent(),
PaletteSelectPreviousIntent(),
PaletteAcceptIntent(),
TextScaleIncreaseIntent(),
TextScaleDecreaseIntent(),
TextScaleResetIntent(),
];
/// Parse an intent id into a [ClideIntent]. Returns null if the id is
/// unknown. Recognises:
/// - any builtin intent by its stable id
/// - `command:<command-id>` → [InvokeCommandIntent]
ClideIntent? parseIntentId(String id) {
final builtin = builtinIntents[id];
if (builtin != null) return builtin();
if (id.startsWith('command:')) {
return InvokeCommandIntent(id.substring('command:'.length));
}
return null;
}
+213
View File
@@ -0,0 +1,213 @@
/// Layout-independent representation of a single keystroke.
///
/// The consultant's note (T-110) flagged the old `KeybindingResolver`
/// for keying off `LogicalKeyboardKey.keyLabel`, which is locale-aware
/// (US-QWERTY `Ctrl+/` differs from AZERTY `Ctrl+:`). We key off
/// `LogicalKeyboardKey.keyId` instead — a stable u32 that survives
/// layout changes.
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// One of the four POSIX-style modifier keys. Order is the canonical
/// presentation order in YAML and toString output.
enum KeyModifier {
ctrl,
alt,
shift,
meta;
/// Lowercase short form used in YAML (`ctrl`, `alt`, `shift`, `meta`).
String get yaml => name;
/// Display string used in palette + tooltip hints.
String get display => switch (this) {
KeyModifier.ctrl => 'Ctrl',
KeyModifier.alt => 'Alt',
KeyModifier.shift => 'Shift',
KeyModifier.meta => 'Cmd',
};
}
/// A modifier-set + a single key, identified by layout-independent
/// `LogicalKeyboardKey.keyId`. Canonicalised on construction
/// (modifiers sorted by enum order) so equality + hashing work for
/// lookup-keying.
@immutable
class KeyChord {
factory KeyChord({Set<KeyModifier> modifiers = const {}, required LogicalKeyboardKey key}) {
final sorted = modifiers.toList()..sort((a, b) => a.index.compareTo(b.index));
return KeyChord._(List.unmodifiable(sorted), key);
}
const KeyChord._(this.modifiers, this.key);
final List<KeyModifier> modifiers;
final LogicalKeyboardKey key;
/// Build from a Flutter [KeyEvent]. Returns null for non-down events
/// or events whose logical key has no meaningful id (e.g. a bare
/// modifier press in isolation).
static KeyChord? fromKeyEvent(KeyEvent event, HardwareKeyboard kb) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return null;
final logical = event.logicalKey;
// Bare modifier presses don't form a chord on their own.
if (_isBareModifier(logical)) return null;
final mods = <KeyModifier>{
if (kb.isControlPressed) KeyModifier.ctrl,
if (kb.isAltPressed) KeyModifier.alt,
if (kb.isShiftPressed) KeyModifier.shift,
if (kb.isMetaPressed) KeyModifier.meta,
};
return KeyChord(modifiers: mods, key: logical);
}
/// Parse a YAML chord spec like `ctrl+shift+p`, `cmd+enter`, `escape`.
/// Whitespace tolerated. Throws [FormatException] on unknown tokens
/// or empty input.
static KeyChord parse(String spec) {
final trimmed = spec.trim();
if (trimmed.isEmpty) throw const FormatException('empty key chord');
final parts = trimmed.split('+').map((s) => s.trim()).toList();
final keyName = parts.removeLast();
if (keyName.isEmpty) throw FormatException('missing key in chord: "$spec"');
final mods = <KeyModifier>{};
for (final m in parts) {
final mod = _modByName(m);
if (mod == null) throw FormatException('unknown modifier "$m" in chord: "$spec"');
mods.add(mod);
}
final key = _keyByName(keyName);
if (key == null) throw FormatException('unknown key "$keyName" in chord: "$spec"');
return KeyChord(modifiers: mods, key: key);
}
/// Canonical YAML form: `ctrl+shift+p`.
String get canonical {
final modPart = modifiers.map((m) => m.yaml).join('+');
final keyPart = _keyName(key);
return modPart.isEmpty ? keyPart : '$modPart+$keyPart';
}
/// Display form for UI hints: `Ctrl+Shift+P`.
String get display {
final modPart = modifiers.map((m) => m.display).join('+');
final keyPart = _keyName(key).toUpperCase();
return modPart.isEmpty ? keyPart : '$modPart+$keyPart';
}
@override
bool operator ==(Object other) => other is KeyChord && other.key == key && listEquals(other.modifiers, modifiers);
@override
int get hashCode => Object.hash(key, Object.hashAll(modifiers));
@override
String toString() => 'KeyChord($canonical)';
}
bool _isBareModifier(LogicalKeyboardKey k) =>
k == LogicalKeyboardKey.control ||
k == LogicalKeyboardKey.controlLeft ||
k == LogicalKeyboardKey.controlRight ||
k == LogicalKeyboardKey.alt ||
k == LogicalKeyboardKey.altLeft ||
k == LogicalKeyboardKey.altRight ||
k == LogicalKeyboardKey.shift ||
k == LogicalKeyboardKey.shiftLeft ||
k == LogicalKeyboardKey.shiftRight ||
k == LogicalKeyboardKey.meta ||
k == LogicalKeyboardKey.metaLeft ||
k == LogicalKeyboardKey.metaRight ||
k == LogicalKeyboardKey.fn;
KeyModifier? _modByName(String name) {
switch (name.toLowerCase()) {
case 'ctrl':
case 'control':
return KeyModifier.ctrl;
case 'alt':
case 'option':
return KeyModifier.alt;
case 'shift':
return KeyModifier.shift;
case 'meta':
case 'cmd':
case 'command':
case 'super':
case 'win':
return KeyModifier.meta;
}
return null;
}
// -- Key name <-> LogicalKeyboardKey ----------------------------------------
//
// We map YAML names to LogicalKeyboardKey instances. The map covers
// every key a binding can plausibly want; unknown names throw on parse.
const Map<String, LogicalKeyboardKey> _byName = {
// Letters
'a': LogicalKeyboardKey.keyA, 'b': LogicalKeyboardKey.keyB, 'c': LogicalKeyboardKey.keyC,
'd': LogicalKeyboardKey.keyD, 'e': LogicalKeyboardKey.keyE, 'f': LogicalKeyboardKey.keyF,
'g': LogicalKeyboardKey.keyG, 'h': LogicalKeyboardKey.keyH, 'i': LogicalKeyboardKey.keyI,
'j': LogicalKeyboardKey.keyJ, 'k': LogicalKeyboardKey.keyK, 'l': LogicalKeyboardKey.keyL,
'm': LogicalKeyboardKey.keyM, 'n': LogicalKeyboardKey.keyN, 'o': LogicalKeyboardKey.keyO,
'p': LogicalKeyboardKey.keyP, 'q': LogicalKeyboardKey.keyQ, 'r': LogicalKeyboardKey.keyR,
's': LogicalKeyboardKey.keyS, 't': LogicalKeyboardKey.keyT, 'u': LogicalKeyboardKey.keyU,
'v': LogicalKeyboardKey.keyV, 'w': LogicalKeyboardKey.keyW, 'x': LogicalKeyboardKey.keyX,
'y': LogicalKeyboardKey.keyY, 'z': LogicalKeyboardKey.keyZ,
// Digits
'0': LogicalKeyboardKey.digit0, '1': LogicalKeyboardKey.digit1, '2': LogicalKeyboardKey.digit2,
'3': LogicalKeyboardKey.digit3, '4': LogicalKeyboardKey.digit4, '5': LogicalKeyboardKey.digit5,
'6': LogicalKeyboardKey.digit6, '7': LogicalKeyboardKey.digit7, '8': LogicalKeyboardKey.digit8,
'9': LogicalKeyboardKey.digit9,
// Function keys
'f1': LogicalKeyboardKey.f1, 'f2': LogicalKeyboardKey.f2, 'f3': LogicalKeyboardKey.f3,
'f4': LogicalKeyboardKey.f4, 'f5': LogicalKeyboardKey.f5, 'f6': LogicalKeyboardKey.f6,
'f7': LogicalKeyboardKey.f7, 'f8': LogicalKeyboardKey.f8, 'f9': LogicalKeyboardKey.f9,
'f10': LogicalKeyboardKey.f10, 'f11': LogicalKeyboardKey.f11, 'f12': LogicalKeyboardKey.f12,
// Arrows
'left': LogicalKeyboardKey.arrowLeft,
'right': LogicalKeyboardKey.arrowRight,
'up': LogicalKeyboardKey.arrowUp,
'down': LogicalKeyboardKey.arrowDown,
// Common control keys
'enter': LogicalKeyboardKey.enter,
'return': LogicalKeyboardKey.enter,
'escape': LogicalKeyboardKey.escape,
'esc': LogicalKeyboardKey.escape,
'tab': LogicalKeyboardKey.tab,
'space': LogicalKeyboardKey.space,
'backspace': LogicalKeyboardKey.backspace,
'delete': LogicalKeyboardKey.delete,
'home': LogicalKeyboardKey.home,
'end': LogicalKeyboardKey.end,
'pageup': LogicalKeyboardKey.pageUp,
'pagedown': LogicalKeyboardKey.pageDown,
'insert': LogicalKeyboardKey.insert,
// Punctuation (US-QWERTY positions; preset authors can rely on these names).
'minus': LogicalKeyboardKey.minus, '-': LogicalKeyboardKey.minus,
'equal': LogicalKeyboardKey.equal, '=': LogicalKeyboardKey.equal,
'comma': LogicalKeyboardKey.comma, ',': LogicalKeyboardKey.comma,
'period': LogicalKeyboardKey.period, '.': LogicalKeyboardKey.period,
'slash': LogicalKeyboardKey.slash, '/': LogicalKeyboardKey.slash,
'backslash': LogicalKeyboardKey.backslash, r'\\': LogicalKeyboardKey.backslash,
'semicolon': LogicalKeyboardKey.semicolon, ';': LogicalKeyboardKey.semicolon,
'quote': LogicalKeyboardKey.quote, "'": LogicalKeyboardKey.quote,
'bracketLeft': LogicalKeyboardKey.bracketLeft, '[': LogicalKeyboardKey.bracketLeft,
'bracketRight': LogicalKeyboardKey.bracketRight, ']': LogicalKeyboardKey.bracketRight,
'backquote': LogicalKeyboardKey.backquote, '`': LogicalKeyboardKey.backquote,
};
LogicalKeyboardKey? _keyByName(String name) => _byName[name.toLowerCase()];
String _keyName(LogicalKeyboardKey key) {
// Reverse lookup; prefer the canonical (first) name for each key.
for (final entry in _byName.entries) {
if (entry.value == key) return entry.key;
}
// Fallback: use the debugName-like representation.
return key.keyLabel.isNotEmpty ? key.keyLabel.toLowerCase() : 'key(0x${key.keyId.toRadixString(16)})';
}
+147
View File
@@ -0,0 +1,147 @@
/// In-memory representation of a layered keymap.
///
/// A [Keymap] is built from one or more [KeymapLayer]s (preset →
/// user-file overlay → settings overlay). Each layer contributes
/// [KeymapBinding]s; later layers replace earlier bindings with the
/// same (chord, when-clause) tuple.
///
/// At resolve time, the [Keymap] walks the layered list once per
/// (chord, scope) and returns the [ClideIntent] bound by the highest-
/// precedence matching layer.
library;
import 'package:flutter/foundation.dart';
import 'package:yaml/yaml.dart';
import 'intents.dart';
import 'key_chord.dart';
import 'when_clause.dart';
/// One row in a layer: a chord, an optional when-clause, and the
/// intent to fire when the chord matches and the when-clause is true.
@immutable
class KeymapBinding {
const KeymapBinding({
required this.chord,
required this.intent,
this.when,
});
final KeyChord chord;
final ClideIntent intent;
final WhenExpr? when;
@override
String toString() => 'Binding($chord${intent.id}${when == null ? '' : ' when $when'})';
}
/// One source of bindings. Layers are merged in order — later layers
/// take precedence on (chord, when) collisions.
@immutable
class KeymapLayer {
const KeymapLayer({required this.name, required this.bindings});
final String name;
final List<KeymapBinding> bindings;
/// Parse a YAML document into a layer. Expected shape:
///
/// ```yaml
/// name: default
/// bindings:
/// - intent: activate
/// keys: [enter, space]
/// when: focused
/// - intent: palette.selectNext
/// keys: [down]
/// when: palette.open
/// ```
///
/// `keys:` may be a single chord string or a list. `when:` is
/// optional. Unknown intent ids cause a [FormatException].
factory KeymapLayer.fromYaml(String source, {String? nameOverride}) {
final doc = loadYaml(source);
if (doc is! YamlMap) {
throw const FormatException('keymap YAML must be a map at top level');
}
final name = nameOverride ?? (doc['name'] as String? ?? 'unnamed');
final raw = doc['bindings'];
if (raw is! YamlList) {
throw const FormatException('keymap YAML must define `bindings:` as a list');
}
final out = <KeymapBinding>[];
for (final entry in raw) {
if (entry is! YamlMap) {
throw FormatException('binding entries must be maps; got $entry');
}
final intentId = entry['intent'] as String?;
if (intentId == null) {
throw FormatException('binding missing `intent:` — $entry');
}
final intent = parseIntentId(intentId);
if (intent == null) {
throw FormatException('unknown intent id "$intentId" — $entry');
}
final keysRaw = entry['keys'];
final keySpecs = <String>[];
if (keysRaw is String) {
keySpecs.add(keysRaw);
} else if (keysRaw is YamlList) {
for (final k in keysRaw) {
if (k is! String) throw FormatException('keys must be strings; got $k in $entry');
keySpecs.add(k);
}
} else {
throw FormatException('binding missing `keys:` (string or list of strings) — $entry');
}
final when = WhenExpr.tryParse(entry['when'] as String?);
for (final spec in keySpecs) {
out.add(KeymapBinding(chord: KeyChord.parse(spec), intent: intent, when: when));
}
}
return KeymapLayer(name: name, bindings: out);
}
@override
String toString() => 'KeymapLayer($name, ${bindings.length} binding${bindings.length == 1 ? '' : 's'})';
}
/// A flattened keymap, ready for resolution.
@immutable
class Keymap {
Keymap(this.layers) : _effective = _flatten(layers);
final List<KeymapLayer> layers;
final List<KeymapBinding> _effective;
/// Resolve a [chord] against the current [context]. Returns the
/// highest-precedence binding whose chord matches and whose when-
/// clause (if any) evaluates true. Returns null if no match.
ClideIntent? resolve(KeyChord chord, Map<String, bool> context) {
// Effective list is highest-precedence-first; first match wins.
for (final b in _effective) {
if (b.chord != chord) continue;
if (b.when != null && !b.when!.evaluate(context)) continue;
return b.intent;
}
return null;
}
/// All resolved bindings in effective-precedence order. Exposed for
/// debug surfaces (keybindings UI, palette hints).
List<KeymapBinding> get effectiveBindings => List.unmodifiable(_effective);
/// Concatenate layers in REVERSE order (last layer first). Later
/// layers fully shadow earlier (chord, when) collisions: when we walk
/// the list, the first matching entry wins, so highest-precedence
/// must come first. We don't dedupe — a no-op match in a later layer
/// just earns the first slot.
static List<KeymapBinding> _flatten(List<KeymapLayer> layers) {
return [
for (final l in layers.reversed) ...l.bindings,
];
}
@override
String toString() => 'Keymap(${layers.map((l) => l.name).join(' < ')})';
}
+197
View File
@@ -0,0 +1,197 @@
/// Kernel service that owns the active [Keymap], scope context, and
/// resolution surface.
///
/// Layering (lowest precedence → highest):
/// 1. The active preset (asset under `assets/keymaps/<preset>.yaml`).
/// Selected by the `app.keymap.preset` setting; defaults to
/// `default`.
/// 2. A user keymap file at `<appDir>/keybindings.yaml` (per-user
/// power-user overrides).
/// 3. A settings-stored JSON overlay at `app.keymap.overrides` —
/// list of `{intent, keys, when?}` maps in the same shape as
/// preset YAML.
///
/// Scope context is a `Map<String, bool>` keyed by named flags (e.g.
/// `palette.open`, `editor.focused`). Producing services call
/// [setScopeFlag] when their state changes; consumers reference the
/// flag name in when-clauses.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show AssetBundle, KeyEvent, HardwareKeyboard, rootBundle;
import '../settings.dart';
import 'intents.dart';
import 'key_chord.dart';
import 'keymap.dart';
import 'when_clause.dart';
/// Setting key for the active preset name.
const String kKeymapPresetSetting = 'app.keymap.preset';
/// Setting key for the JSON overlay list.
const String kKeymapOverridesSetting = 'app.keymap.overrides';
/// Filename for the user keymap file under the app dir.
const String kKeymapUserFile = 'keybindings.yaml';
class KeymapService extends ChangeNotifier {
KeymapService({
required SettingsStore settings,
required Directory appDir,
AssetBundle? bundle,
}) : _settings = settings,
_appDir = appDir,
_bundle = bundle ?? rootBundle;
final SettingsStore _settings;
final Directory _appDir;
final AssetBundle _bundle;
Keymap? _active;
final Map<String, bool> _scope = {};
// The four layer slots, lowest to highest precedence. Held
// separately so [registerCommandBinding] can refresh the
// contributions layer without re-reading the preset / file /
// settings.
KeymapLayer? _preset;
final List<KeymapBinding> _contributions = [];
KeymapLayer? _userFile;
KeymapLayer? _settingsOverlay;
/// The currently effective layered keymap. Null before [load] runs.
Keymap? get keymap => _active;
/// Live read-only view of the scope context.
Map<String, bool> get scope => Map.unmodifiable(_scope);
/// Read the preset from settings (default `default`), load all
/// non-contribution layers, and rebuild the active keymap. Safe to
/// call repeatedly. Contributions registered via
/// [registerCommandBinding] are preserved across reloads.
Future<void> load() async {
final presetName = _settings.get<String>(kKeymapPresetSetting) ?? 'default';
// Preset (asset).
try {
final src = await _bundle.loadString('assets/keymaps/$presetName.yaml');
_preset = KeymapLayer.fromYaml(src, nameOverride: presetName);
} catch (_) {
// A missing preset means we ship without a default. Tests can
// inject a custom bundle. We don't surface this beyond an empty
// active map.
_preset = null;
}
// User file overlay.
final userFile = File('${_appDir.path}/$kKeymapUserFile');
if (await userFile.exists()) {
try {
_userFile = KeymapLayer.fromYaml(await userFile.readAsString(), nameOverride: 'user-file');
} on FormatException {
_userFile = null;
}
} else {
_userFile = null;
}
// Settings overlay.
final overlay = _settings.get<List<Object?>>(kKeymapOverridesSetting);
if (overlay != null && overlay.isNotEmpty) {
final asYaml = StringBuffer('name: settings-overlay\nbindings:\n');
for (final entry in overlay) {
if (entry is! Map) continue;
asYaml.writeln(' - ${jsonEncode(entry)}');
}
try {
_settingsOverlay = KeymapLayer.fromYaml(asYaml.toString(), nameOverride: 'settings-overlay');
} on FormatException {
_settingsOverlay = null;
}
} else {
_settingsOverlay = null;
}
_rebuildActive();
}
/// Register a programmatic chord → command-id binding (typically
/// from an extension's `defaultBinding`). Contributions form a
/// layer between preset and user-file: extensions establish their
/// defaults, the user can override either via the user file or
/// settings overlay.
void registerCommandBinding(String chordSpec, String commandId, {String? when}) {
_contributions.add(KeymapBinding(
chord: KeyChord.parse(chordSpec),
intent: InvokeCommandIntent(commandId),
when: WhenExpr.tryParse(when),
));
_rebuildActive();
}
/// Remove all extension-contributed bindings for [commandId]. Used
/// when an extension is disabled or unregistered.
void unregisterCommandBindings(String commandId) {
final before = _contributions.length;
_contributions.removeWhere((b) {
final i = b.intent;
return i is InvokeCommandIntent && i.commandId == commandId;
});
if (_contributions.length != before) {
_rebuildActive();
}
}
void _rebuildActive() {
final layers = <KeymapLayer>[
if (_preset != null) _preset!,
KeymapLayer(name: 'contributions', bindings: List.unmodifiable(_contributions)),
if (_userFile != null) _userFile!,
if (_settingsOverlay != null) _settingsOverlay!,
];
_active = Keymap(layers);
notifyListeners();
}
/// Resolve a [KeyEvent] against the active keymap and current scope.
/// Returns null when nothing matches.
ClideIntent? resolveEvent(KeyEvent event, HardwareKeyboard kb) {
final km = _active;
if (km == null) return null;
final chord = KeyChord.fromKeyEvent(event, kb);
if (chord == null) return null;
return km.resolve(chord, _scope);
}
/// Set a named scope flag. Producers should call this when their
/// state changes so when-clauses re-evaluate correctly. Notifies
/// listeners when the value actually changes.
void setScopeFlag(String name, bool value) {
if (_scope[name] == value) return;
_scope[name] = value;
notifyListeners();
}
/// Clear a named scope flag.
void clearScopeFlag(String name) {
if (!_scope.containsKey(name)) return;
_scope.remove(name);
notifyListeners();
}
/// Switch presets. Persists the new preset name to settings and
/// re-loads the layered keymap.
Future<void> setPreset(String name) async {
await _settings.set<String>(kKeymapPresetSetting, name);
await load();
}
/// All effective bindings, highest-precedence first. Useful for
/// debug surfaces and keybinding hints in the UI.
List<KeymapBinding> get effectiveBindings => _active?.effectiveBindings ?? const [];
}
+178
View File
@@ -0,0 +1,178 @@
/// Boolean "when:" expressions over a named context bag, VS-Code style.
///
/// Grammar:
/// expr := or
/// or := and ('||' and)*
/// and := unary ('&&' unary)*
/// unary := '!' unary | atom
/// atom := IDENT | '(' expr ')'
/// IDENT := [a-zA-Z_][a-zA-Z0-9._-]*
///
/// Identifiers resolve against a `Map<String, bool>` context. A missing
/// identifier evaluates to `false` — bindings can assume any required
/// scope flag is published by the producing service.
///
/// The grammar is intentionally small: no equality, no arithmetic, no
/// string literals. If a binding needs more, the producing service
/// should publish a richer named flag (e.g. `editor.dirty`).
library;
import 'package:flutter/foundation.dart';
@immutable
sealed class WhenExpr {
const WhenExpr();
/// Evaluate against [context]. Missing identifiers are `false`.
bool evaluate(Map<String, bool> context);
/// Parse [source]. Throws [FormatException] on syntax errors.
static WhenExpr parse(String source) => _Parser(source).parseAll();
/// Convenience: null on empty input, otherwise [parse].
static WhenExpr? tryParse(String? source) {
if (source == null || source.trim().isEmpty) return null;
return parse(source);
}
}
class WhenIdent extends WhenExpr {
const WhenIdent(this.name);
final String name;
@override
bool evaluate(Map<String, bool> context) => context[name] ?? false;
@override
String toString() => name;
}
class WhenNot extends WhenExpr {
const WhenNot(this.child);
final WhenExpr child;
@override
bool evaluate(Map<String, bool> context) => !child.evaluate(context);
@override
String toString() => '!$child';
}
class WhenAnd extends WhenExpr {
const WhenAnd(this.left, this.right);
final WhenExpr left;
final WhenExpr right;
@override
bool evaluate(Map<String, bool> context) => left.evaluate(context) && right.evaluate(context);
@override
String toString() => '($left && $right)';
}
class WhenOr extends WhenExpr {
const WhenOr(this.left, this.right);
final WhenExpr left;
final WhenExpr right;
@override
bool evaluate(Map<String, bool> context) => left.evaluate(context) || right.evaluate(context);
@override
String toString() => '($left || $right)';
}
// -- Parser -----------------------------------------------------------------
class _Parser {
_Parser(this._src);
final String _src;
int _pos = 0;
WhenExpr parseAll() {
_skip();
final e = _or();
_skip();
if (_pos != _src.length) {
throw FormatException('unexpected "${_src[_pos]}" at column ${_pos + 1} in when-clause: "$_src"');
}
return e;
}
WhenExpr _or() {
var left = _and();
while (_consume('||')) {
final right = _and();
left = WhenOr(left, right);
}
return left;
}
WhenExpr _and() {
var left = _unary();
while (_consume('&&')) {
final right = _unary();
left = WhenAnd(left, right);
}
return left;
}
WhenExpr _unary() {
_skip();
if (_consume('!')) {
return WhenNot(_unary());
}
return _atom();
}
WhenExpr _atom() {
_skip();
if (_consume('(')) {
final inner = _or();
_skip();
if (!_consume(')')) {
throw FormatException('expected ")" at column ${_pos + 1} in when-clause: "$_src"');
}
return inner;
}
final ident = _ident();
if (ident == null) {
final at = _pos < _src.length ? '"${_src[_pos]}"' : 'end of input';
throw FormatException('expected identifier at column ${_pos + 1} in when-clause: "$_src" (got $at)');
}
return WhenIdent(ident);
}
String? _ident() {
_skip();
final start = _pos;
if (_pos >= _src.length) return null;
final first = _src.codeUnitAt(_pos);
if (!_isIdentStart(first)) return null;
_pos++;
while (_pos < _src.length && _isIdentCont(_src.codeUnitAt(_pos))) {
_pos++;
}
return _src.substring(start, _pos);
}
bool _consume(String token) {
_skip();
if (_src.startsWith(token, _pos)) {
_pos += token.length;
return true;
}
return false;
}
void _skip() {
while (_pos < _src.length && _isSpace(_src.codeUnitAt(_pos))) {
_pos++;
}
}
}
bool _isSpace(int c) => c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D;
bool _isIdentStart(int c) {
// a-z | A-Z | _
return (c >= 0x61 && c <= 0x7A) || (c >= 0x41 && c <= 0x5A) || c == 0x5F;
}
bool _isIdentCont(int c) {
// a-z | A-Z | 0-9 | _ . -
return _isIdentStart(c) || (c >= 0x30 && c <= 0x39) || c == 0x2E || c == 0x2D;
}
+1
View File
@@ -67,6 +67,7 @@ flutter:
assets:
- lib/kernel/src/theme/themes/
- lib/kernel/src/i18n/catalog/
- assets/keymaps/
- assets/licenses.yaml
- assets/LICENSE
- assets/fonts/jetbrains_mono/OFL.txt
+154
View File
@@ -0,0 +1,154 @@
/// Unit tests for KeyChord parsing, equality, canonicalisation, and
/// fromKeyEvent.
library;
import 'package:clide/kernel/src/keymap/key_chord.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('KeyChord.parse', () {
test('single key without modifiers', () {
final c = KeyChord.parse('enter');
expect(c.modifiers, isEmpty);
expect(c.key, LogicalKeyboardKey.enter);
expect(c.canonical, 'enter');
});
test('modifier order is canonicalised', () {
final a = KeyChord.parse('shift+ctrl+p');
final b = KeyChord.parse('ctrl+shift+p');
expect(a, b);
expect(a.canonical, 'ctrl+shift+p');
});
test('cmd/meta/command/super/win all alias the meta modifier', () {
for (final spec in ['cmd+a', 'meta+a', 'command+a', 'super+a', 'win+a']) {
expect(KeyChord.parse(spec).modifiers, [KeyModifier.meta], reason: spec);
}
});
test('punctuation keys are recognised by name or character', () {
expect(KeyChord.parse('ctrl+slash').key, LogicalKeyboardKey.slash);
expect(KeyChord.parse('ctrl+/').key, LogicalKeyboardKey.slash);
expect(KeyChord.parse('ctrl+equal').key, LogicalKeyboardKey.equal);
expect(KeyChord.parse('ctrl+=').key, LogicalKeyboardKey.equal);
});
test('parse is case-insensitive on modifiers + key name', () {
final c = KeyChord.parse('CTRL+SHIFT+P');
expect(c.modifiers, [KeyModifier.ctrl, KeyModifier.shift]);
expect(c.key, LogicalKeyboardKey.keyP);
});
});
group('KeyChord.parse — errors', () {
test('empty string throws', () {
expect(() => KeyChord.parse(''), throwsFormatException);
});
test('unknown modifier throws', () {
expect(() => KeyChord.parse('hyper+a'), throwsFormatException);
});
test('unknown key throws', () {
expect(() => KeyChord.parse('ctrl+definitely-not-a-key'), throwsFormatException);
});
test('trailing + (missing key) throws', () {
expect(() => KeyChord.parse('ctrl+'), throwsFormatException);
});
});
group('KeyChord display + canonical', () {
test('display capitalises modifiers + key, joined with +', () {
expect(KeyChord.parse('ctrl+shift+p').display, 'Ctrl+Shift+P');
expect(KeyChord.parse('enter').display, 'ENTER');
});
test('each modifier renders its own display string', () {
expect(KeyChord.parse('ctrl+a').display, 'Ctrl+A');
expect(KeyChord.parse('alt+a').display, 'Alt+A');
expect(KeyChord.parse('shift+a').display, 'Shift+A');
expect(KeyChord.parse('meta+a').display, 'Cmd+A');
});
test('toString embeds the canonical form', () {
expect(KeyChord.parse('ctrl+shift+p').toString(), 'KeyChord(ctrl+shift+p)');
});
});
group('KeyChord equality + hashing', () {
test('equal chords have equal hash codes', () {
final a = KeyChord.parse('ctrl+shift+p');
final b = KeyChord.parse('shift+ctrl+p');
expect(a, b);
expect(a.hashCode, b.hashCode);
});
test('different keys are not equal', () {
expect(KeyChord.parse('ctrl+a'), isNot(KeyChord.parse('ctrl+b')));
});
test('different modifier sets are not equal', () {
expect(KeyChord.parse('ctrl+a'), isNot(KeyChord.parse('alt+a')));
});
});
group('KeyChord.fromKeyEvent', () {
final kb = HardwareKeyboard.instance;
tearDown(() => kb.clearState());
test('returns null for non-KeyDown / non-Repeat events', () {
final up = KeyUpEvent(
physicalKey: PhysicalKeyboardKey.keyA,
logicalKey: LogicalKeyboardKey.keyA,
timeStamp: Duration.zero,
);
expect(KeyChord.fromKeyEvent(up, kb), isNull);
});
test('returns null for a bare modifier press', () {
final down = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.controlLeft,
logicalKey: LogicalKeyboardKey.controlLeft,
timeStamp: Duration.zero,
);
expect(KeyChord.fromKeyEvent(down, kb), isNull);
});
test('maps a plain key down to a modifier-free chord', () {
final down = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.keyA,
logicalKey: LogicalKeyboardKey.keyA,
timeStamp: Duration.zero,
);
final chord = KeyChord.fromKeyEvent(down, kb)!;
expect(chord.modifiers, isEmpty);
expect(chord.key, LogicalKeyboardKey.keyA);
});
test('records every held modifier in the resulting chord', () {
// Press all four modifiers, then a non-modifier key.
for (final m in [
(PhysicalKeyboardKey.controlLeft, LogicalKeyboardKey.controlLeft),
(PhysicalKeyboardKey.altLeft, LogicalKeyboardKey.altLeft),
(PhysicalKeyboardKey.shiftLeft, LogicalKeyboardKey.shiftLeft),
(PhysicalKeyboardKey.metaLeft, LogicalKeyboardKey.metaLeft),
]) {
kb.handleKeyEvent(KeyDownEvent(physicalKey: m.$1, logicalKey: m.$2, timeStamp: Duration.zero));
}
final down = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.keyP,
logicalKey: LogicalKeyboardKey.keyP,
timeStamp: Duration.zero,
);
final chord = KeyChord.fromKeyEvent(down, kb)!;
expect(chord.key, LogicalKeyboardKey.keyP);
expect(chord.modifiers.toSet(), {KeyModifier.ctrl, KeyModifier.alt, KeyModifier.shift, KeyModifier.meta});
expect(chord.canonical, 'ctrl+alt+shift+meta+p');
});
});
}
@@ -0,0 +1,287 @@
/// Tests for the KeymapService layering + scope context + resolve.
library;
import 'dart:io';
import 'package:clide/kernel/src/keymap/intents.dart';
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_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory appDir;
late SettingsStore settings;
setUp(() async {
appDir = await Directory.systemTemp.createTemp('clide_keymap_test_');
settings = SettingsStore(appDir: appDir);
await settings.load();
});
tearDown(() async {
settings.dispose();
if (await appDir.exists()) await appDir.delete(recursive: true);
});
group('load()', () {
test('loads the default preset from the asset bundle', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
}),
);
await svc.load();
expect(svc.keymap, isNotNull);
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
});
test('honours the app.keymap.preset setting', () async {
await settings.set<String>(kKeymapPresetSetting, 'vscode');
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/vscode.yaml': 'name: vscode\nbindings:\n - intent: palette.open\n keys: ctrl+shift+p\n',
}),
);
await svc.load();
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+shift+p'), const {}), isA<PaletteOpenIntent>());
});
test('missing preset asset is tolerated (active keymap is empty)', () async {
final svc = KeymapService(settings: settings, appDir: appDir, bundle: _bundle(const {}));
await svc.load();
expect(svc.keymap, isNotNull);
expect(svc.keymap!.effectiveBindings, isEmpty);
});
test('layers a user file on top of the preset', () async {
await File('${appDir.path}/keybindings.yaml').writeAsString(
'name: user\nbindings:\n - intent: activate\n keys: ctrl+p\n',
);
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: palette.open\n keys: ctrl+p\n',
}),
);
await svc.load();
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+p'), const {}), isA<ActivateIntent>());
});
test('layers a settings overlay above the user file', () async {
await File('${appDir.path}/keybindings.yaml').writeAsString(
'name: user\nbindings:\n - intent: activate\n keys: ctrl+p\n',
);
await settings.set<List<Object?>>(kKeymapOverridesSetting, [
{'intent': 'dismiss', 'keys': 'ctrl+p'},
]);
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: palette.open\n keys: ctrl+p\n',
}),
);
await svc.load();
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+p'), const {}), isA<DismissIntent>());
});
test('tolerates a malformed user file by ignoring it', () async {
await File('${appDir.path}/keybindings.yaml').writeAsString('not: real keymap [yaml');
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
}),
);
await svc.load();
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
});
test('tolerates a malformed settings overlay entry by ignoring just the overlay', () async {
await settings.set<List<Object?>>(kKeymapOverridesSetting, [
{'intent': 'definitely.not.real', 'keys': 'ctrl+p'},
]);
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
}),
);
await svc.load();
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
});
});
group('registerCommandBinding / unregisterCommandBindings', () {
test('adds an InvokeCommandIntent that resolves after load', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
svc.registerCommandBinding('ctrl+shift+g', 'git.commit');
final intent = svc.keymap!.resolve(KeyChord.parse('ctrl+shift+g'), const {});
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 {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
svc.registerCommandBinding('ctrl+s', 'editor.save', when: 'editor.focused');
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+s'), const {}), isNull);
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+s'), {'editor.focused': true}), isA<InvokeCommandIntent>());
});
test('user file overrides a contributed binding for the same chord', () async {
await File('${appDir.path}/keybindings.yaml').writeAsString(
'name: user\nbindings:\n - intent: dismiss\n keys: ctrl+x\n',
);
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
svc.registerCommandBinding('ctrl+x', 'editor.cut');
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+x'), const {}), isA<DismissIntent>());
});
test('unregisterCommandBindings removes prior contributions', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
svc.registerCommandBinding('ctrl+x', 'editor.cut');
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+x'), const {}), isA<InvokeCommandIntent>());
svc.unregisterCommandBindings('editor.cut');
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+x'), const {}), isNull);
});
test('unregister of an unknown command is a no-op', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
svc.unregisterCommandBindings('nothing-registered'); // doesn't throw
});
});
group('scope flags', () {
test('setScopeFlag updates the context; notifies listeners on change', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
var notified = 0;
svc.addListener(() => notified++);
svc.setScopeFlag('palette.open', true);
expect(svc.scope['palette.open'], isTrue);
expect(notified, 1);
svc.setScopeFlag('palette.open', true); // no-op
expect(notified, 1);
svc.setScopeFlag('palette.open', false);
expect(notified, 2);
});
test('clearScopeFlag removes the entry; no-op when absent', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
svc.setScopeFlag('foo', true);
svc.clearScopeFlag('foo');
expect(svc.scope.containsKey('foo'), isFalse);
svc.clearScopeFlag('foo'); // no-op
});
});
group('setPreset', () {
test('switches presets and reloads', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
'assets/keymaps/vim.yaml': 'name: vim\nbindings:\n - intent: activate\n keys: escape\n',
}),
);
await svc.load();
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
await svc.setPreset('vim');
expect(settings.get<String>(kKeymapPresetSetting), 'vim');
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<ActivateIntent>());
});
});
group('resolveEvent', () {
test('returns null before load()', () {
final svc = KeymapService(settings: settings, appDir: appDir, bundle: _bundle(const {}));
final down = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.escape,
logicalKey: LogicalKeyboardKey.escape,
timeStamp: Duration.zero,
);
expect(svc.resolveEvent(down, HardwareKeyboard.instance), isNull);
});
test('returns the bound intent for a matched chord', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
}),
);
await svc.load();
final down = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.escape,
logicalKey: LogicalKeyboardKey.escape,
timeStamp: Duration.zero,
);
expect(svc.resolveEvent(down, HardwareKeyboard.instance), isA<DismissIntent>());
});
});
}
/// In-memory AssetBundle that returns whatever the constructor map says.
AssetBundle _bundle(Map<String, String> files) => _MapBundle(files);
class _MapBundle extends CachingAssetBundle {
_MapBundle(this._files);
final Map<String, String> _files;
@override
Future<ByteData> load(String key) async {
final s = _files[key];
if (s == null) throw Exception('asset not in fake bundle: $key');
return ByteData.view(Uint8List.fromList(s.codeUnits).buffer);
}
}
+188
View File
@@ -0,0 +1,188 @@
/// Unit tests for Keymap layering + resolution + KeymapLayer YAML parsing.
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_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('KeymapLayer.fromYaml', () {
test('parses a single binding with one chord', () {
const src = '''
name: test
bindings:
- intent: activate
keys: enter
''';
final layer = KeymapLayer.fromYaml(src);
expect(layer.name, 'test');
expect(layer.bindings, hasLength(1));
expect(layer.bindings.single.chord, KeyChord.parse('enter'));
expect(layer.bindings.single.intent, isA<ActivateIntent>());
expect(layer.bindings.single.when, isNull);
});
test('expands `keys:` list into one binding per chord', () {
const src = '''
name: t
bindings:
- intent: activate
keys: [enter, space]
''';
final layer = KeymapLayer.fromYaml(src);
expect(layer.bindings, hasLength(2));
expect(layer.bindings[0].chord, KeyChord.parse('enter'));
expect(layer.bindings[1].chord, KeyChord.parse('space'));
// Same intent instance reused — fine because intents are const.
expect(layer.bindings[0].intent, isA<ActivateIntent>());
});
test('stores when-clause parsed into an evaluable WhenExpr', () {
const src = '''
name: t
bindings:
- intent: palette.selectNext
keys: down
when: palette.open && !textInputFocused
''';
final layer = KeymapLayer.fromYaml(src);
final w = layer.bindings.single.when!;
expect(w.evaluate({'palette.open': true, 'textInputFocused': false}), isTrue);
expect(w.evaluate({'palette.open': true, 'textInputFocused': true}), isFalse);
expect(w.evaluate({'palette.open': false, 'textInputFocused': false}), isFalse);
});
test('command: prefix resolves to InvokeCommandIntent', () {
const src = '''
name: t
bindings:
- intent: command:git.commit
keys: ctrl+shift+g
''';
final layer = KeymapLayer.fromYaml(src);
final intent = layer.bindings.single.intent;
expect(intent, isA<InvokeCommandIntent>());
expect((intent as InvokeCommandIntent).commandId, 'git.commit');
});
test('nameOverride wins over `name:`', () {
const src = 'name: ignored\nbindings: []\n';
final layer = KeymapLayer.fromYaml(src, nameOverride: 'user-file');
expect(layer.name, 'user-file');
});
test('rejects unknown intent id', () {
const src = 'name: t\nbindings:\n - intent: definitely.not.real\n keys: enter\n';
expect(() => KeymapLayer.fromYaml(src), throwsFormatException);
});
test('rejects missing keys', () {
const src = 'name: t\nbindings:\n - intent: activate\n';
expect(() => KeymapLayer.fromYaml(src), throwsFormatException);
});
test('rejects non-map top level', () {
expect(() => KeymapLayer.fromYaml('- one\n- two\n'), throwsFormatException);
});
test('rejects missing bindings list', () {
expect(() => KeymapLayer.fromYaml('name: t\n'), throwsFormatException);
});
test('rejects non-string entries in keys list', () {
const src = 'name: t\nbindings:\n - intent: activate\n keys: [42]\n';
expect(() => KeymapLayer.fromYaml(src), throwsFormatException);
});
test('rejects unparseable keys value (neither string nor list)', () {
const src = 'name: t\nbindings:\n - intent: activate\n keys: {wrong: shape}\n';
expect(() => KeymapLayer.fromYaml(src), throwsFormatException);
});
});
group('Keymap + KeymapLayer toString', () {
test('layer toString includes name + binding count', () {
final layer = KeymapLayer.fromYaml('name: t\nbindings:\n - intent: dismiss\n keys: escape\n');
expect(layer.toString(), 'KeymapLayer(t, 1 binding)');
});
test('keymap toString lists layers low-to-high', () {
final a = KeymapLayer.fromYaml('name: a\nbindings: []\n');
final b = KeymapLayer.fromYaml('name: b\nbindings: []\n');
expect(Keymap([a, b]).toString(), 'Keymap(a < b)');
});
});
group('Keymap.resolve — single layer', () {
final layer = KeymapLayer.fromYaml('''
name: t
bindings:
- intent: activate
keys: enter
- intent: palette.selectNext
keys: down
when: palette.open
''');
test('matches an unconditional binding', () {
final km = Keymap([layer]);
expect(km.resolve(KeyChord.parse('enter'), const {}), isA<ActivateIntent>());
});
test('matches a when-gated binding when the flag is true', () {
final km = Keymap([layer]);
expect(km.resolve(KeyChord.parse('down'), {'palette.open': true}), isA<PaletteSelectNextIntent>());
});
test('skips a when-gated binding when the flag is false', () {
final km = Keymap([layer]);
expect(km.resolve(KeyChord.parse('down'), const {}), isNull);
});
test('returns null when the chord has no binding', () {
final km = Keymap([layer]);
expect(km.resolve(KeyChord.parse('ctrl+x'), const {}), isNull);
});
});
group('Keymap.resolve — layering precedence', () {
test('later layer replaces earlier binding for the same chord', () {
final preset = KeymapLayer.fromYaml('''
name: preset
bindings:
- intent: palette.open
keys: ctrl+shift+p
''');
final user = KeymapLayer.fromYaml('''
name: user
bindings:
- intent: activate
keys: ctrl+shift+p
''');
final km = Keymap([preset, user]);
// User layer wins — Activate, not PaletteOpen.
expect(km.resolve(KeyChord.parse('ctrl+shift+p'), const {}), isA<ActivateIntent>());
});
test('preset binding survives when no later layer overrides it', () {
final preset = KeymapLayer.fromYaml('''
name: preset
bindings:
- intent: dismiss
keys: escape
''');
final user = KeymapLayer.fromYaml('''
name: user
bindings:
- intent: activate
keys: enter
''');
final km = Keymap([preset, user]);
expect(km.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
expect(km.resolve(KeyChord.parse('enter'), const {}), isA<ActivateIntent>());
});
});
}
@@ -0,0 +1,117 @@
/// Unit tests for the when-clause parser + evaluator.
library;
import 'package:clide/kernel/src/keymap/when_clause.dart';
import 'package:test/test.dart';
void main() {
group('WhenExpr.parse — grammar', () {
test('single identifier', () {
final e = WhenExpr.parse('foo');
expect(e, isA<WhenIdent>());
expect(e.evaluate({'foo': true}), isTrue);
expect(e.evaluate({'foo': false}), isFalse);
expect(e.evaluate(const {}), isFalse, reason: 'missing identifier evaluates to false');
});
test('negation', () {
final e = WhenExpr.parse('!foo');
expect(e.evaluate({'foo': true}), isFalse);
expect(e.evaluate({'foo': false}), isTrue);
expect(e.evaluate(const {}), isTrue, reason: '!missing → true');
});
test('double negation', () {
final e = WhenExpr.parse('!!foo');
expect(e.evaluate({'foo': true}), isTrue);
expect(e.evaluate({'foo': false}), isFalse);
});
test('conjunction is left-associative', () {
final e = WhenExpr.parse('a && b && c');
expect(e.evaluate({'a': true, 'b': true, 'c': true}), isTrue);
expect(e.evaluate({'a': true, 'b': false, 'c': true}), isFalse);
});
test('disjunction is left-associative', () {
final e = WhenExpr.parse('a || b || c');
expect(e.evaluate({'a': false, 'b': false, 'c': true}), isTrue);
expect(e.evaluate({'a': false, 'b': false, 'c': false}), isFalse);
});
test('and binds tighter than or', () {
// a || b && c == a || (b && c)
final e = WhenExpr.parse('a || b && c');
expect(e.evaluate({'a': false, 'b': true, 'c': false}), isFalse);
expect(e.evaluate({'a': false, 'b': true, 'c': true}), isTrue);
expect(e.evaluate({'a': true, 'b': false, 'c': false}), isTrue);
});
test('parens override precedence', () {
// (a || b) && c
final e = WhenExpr.parse('(a || b) && c');
expect(e.evaluate({'a': true, 'b': false, 'c': false}), isFalse);
expect(e.evaluate({'a': true, 'b': false, 'c': true}), isTrue);
});
test('not binds tighter than and/or', () {
// !a && b → (!a) && b
final e = WhenExpr.parse('!a && b');
expect(e.evaluate({'a': false, 'b': true}), isTrue);
expect(e.evaluate({'a': true, 'b': true}), isFalse);
});
test('identifiers may contain dots, hyphens, underscores', () {
final e = WhenExpr.parse('palette.is-open && _editor_focused');
expect(e.evaluate({'palette.is-open': true, '_editor_focused': true}), isTrue);
});
test('whitespace is tolerated', () {
final e = WhenExpr.parse(' a && ( b || !c ) ');
expect(e.evaluate({'a': true, 'b': true, 'c': true}), isTrue);
expect(e.evaluate({'a': true, 'b': false, 'c': true}), isFalse);
});
});
group('WhenExpr.parse — errors', () {
test('empty input throws', () {
expect(() => WhenExpr.parse(''), throwsFormatException);
});
test('unbalanced paren throws', () {
expect(() => WhenExpr.parse('(a && b'), throwsFormatException);
});
test('trailing junk throws', () {
expect(() => WhenExpr.parse('a && b foo'), throwsFormatException);
});
test('missing operand after operator throws', () {
expect(() => WhenExpr.parse('a &&'), throwsFormatException);
});
test('bare ! throws', () {
expect(() => WhenExpr.parse('!'), throwsFormatException);
});
});
group('WhenExpr.tryParse', () {
test('null and empty return null', () {
expect(WhenExpr.tryParse(null), isNull);
expect(WhenExpr.tryParse(' '), isNull);
});
test('non-empty delegates to parse', () {
expect(WhenExpr.tryParse('foo'), isA<WhenIdent>());
});
});
group('WhenExpr.toString', () {
test('round-trips each node shape', () {
expect(WhenExpr.parse('foo').toString(), 'foo');
expect(WhenExpr.parse('!foo').toString(), '!foo');
expect(WhenExpr.parse('a && b').toString(), '(a && b)');
expect(WhenExpr.parse('a || b').toString(), '(a || b)');
});
});
}