From 798ba524f1656bb97c7494f2b16d524284c2e20b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 17 May 2026 21:40:02 +0200 Subject: [PATCH] =?UTF-8?q?keystroke=20mapper=20layer=20=E2=80=94=20intent?= =?UTF-8?q?s,=20presets,=20when-clauses=20(T-117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.yaml 2. extension-registered command bindings (via KeymapService.registerCommandBinding from ExtensionManager) 3. user file at /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) --- .pql/changelog/ticket_history/2026-05.sql | 78 +++++ .pql/changelog/tickets/2026-05.sql | 93 ++++++ CHANGELOG.md | 4 + assets/keymaps/default.yaml | 56 ++++ lib/app.dart | 105 ++++--- lib/kernel/kernel.dart | 5 + lib/kernel/src/extensions_manager.dart | 8 + lib/kernel/src/facade.dart | 8 + lib/kernel/src/keymap/intents.dart | 152 ++++++++++ lib/kernel/src/keymap/key_chord.dart | 213 +++++++++++++ lib/kernel/src/keymap/keymap.dart | 147 +++++++++ lib/kernel/src/keymap/keymap_service.dart | 197 ++++++++++++ lib/kernel/src/keymap/when_clause.dart | 178 +++++++++++ pubspec.yaml | 1 + test/kernel/src/keymap/key_chord_test.dart | 154 ++++++++++ .../src/keymap/keymap_service_test.dart | 287 ++++++++++++++++++ test/kernel/src/keymap/keymap_test.dart | 188 ++++++++++++ test/kernel/src/keymap/when_clause_test.dart | 117 +++++++ 18 files changed, 1944 insertions(+), 47 deletions(-) create mode 100644 assets/keymaps/default.yaml create mode 100644 lib/kernel/src/keymap/intents.dart create mode 100644 lib/kernel/src/keymap/key_chord.dart create mode 100644 lib/kernel/src/keymap/keymap.dart create mode 100644 lib/kernel/src/keymap/keymap_service.dart create mode 100644 lib/kernel/src/keymap/when_clause.dart create mode 100644 test/kernel/src/keymap/key_chord_test.dart create mode 100644 test/kernel/src/keymap/keymap_service_test.dart create mode 100644 test/kernel/src/keymap/keymap_test.dart create mode 100644 test/kernel/src/keymap/when_clause_test.dart diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index fa521f37..fb5a9fc2 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -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:` 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:` bindings. +2. Add a small mode-tracking service that publishes `vim.` 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:` 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; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 5f6d65f9..5491fb05 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -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:` 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:` 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:` bindings. +2. Add a small mode-tracking service that publishes `vim.` 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/.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); diff --git a/CHANGELOG.md b/CHANGELOG.md index c317f365..60a98b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/assets/keymaps/default.yaml b/assets/keymaps/default.yaml new file mode 100644 index 00000000..a0846edf --- /dev/null +++ b/assets/keymaps/default.yaml @@ -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: # see kernel/src/keymap/intents.dart +# keys: | [, ...] # `ctrl+shift+p`, `cmd+enter`, ... +# when: # 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] diff --git a/lib/app.dart b/lib/app.dart index 5d06b663..413495df 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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: >{ + TextScaleIncreaseIntent: CallbackAction( + onInvoke: (_) { + setState(() => _textScale = (_textScale + _scaleStep).clamp(_scaleMin, _scaleMax)); + return null; + }, + ), + TextScaleDecreaseIntent: CallbackAction( + onInvoke: (_) { + setState(() => _textScale = (_textScale - _scaleStep).clamp(_scaleMin, _scaleMax)); + return null; + }, + ), + TextScaleResetIntent: CallbackAction( + onInvoke: (_) { + setState(() => _textScale = 1.0); + return null; + }, + ), + InvokeCommandIntent: CallbackAction( + 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); } } diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index cbf2aa61..bbd993d9 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -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'; diff --git a/lib/kernel/src/extensions_manager.dart b/lib/kernel/src/extensions_manager.dart index b99e5711..07dfd761 100644 --- a/lib/kernel/src/extensions_manager.dart +++ b/lib/kernel/src/extensions_manager.dart @@ -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 _: diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index 4609181d..bce61d18 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -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 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(); diff --git a/lib/kernel/src/keymap/intents.dart b/lib/kernel/src/keymap/intents.dart new file mode 100644 index 00000000..d2d32572 --- /dev/null +++ b/lib/kernel/src/keymap/intents.dart @@ -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 builtinIntents = { + for (final i in _allBuiltin) i.id: () => i, +}; + +const List _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:` → [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; +} diff --git a/lib/kernel/src/keymap/key_chord.dart b/lib/kernel/src/keymap/key_chord.dart new file mode 100644 index 00000000..d8170546 --- /dev/null +++ b/lib/kernel/src/keymap/key_chord.dart @@ -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 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 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 = { + 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 = {}; + 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 _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)})'; +} diff --git a/lib/kernel/src/keymap/keymap.dart b/lib/kernel/src/keymap/keymap.dart new file mode 100644 index 00000000..9a1a4d9c --- /dev/null +++ b/lib/kernel/src/keymap/keymap.dart @@ -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 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 = []; + 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 = []; + 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 layers; + final List _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 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 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 _flatten(List layers) { + return [ + for (final l in layers.reversed) ...l.bindings, + ]; + } + + @override + String toString() => 'Keymap(${layers.map((l) => l.name).join(' < ')})'; +} diff --git a/lib/kernel/src/keymap/keymap_service.dart b/lib/kernel/src/keymap/keymap_service.dart new file mode 100644 index 00000000..0e361805 --- /dev/null +++ b/lib/kernel/src/keymap/keymap_service.dart @@ -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/.yaml`). +/// Selected by the `app.keymap.preset` setting; defaults to +/// `default`. +/// 2. A user keymap file at `/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` 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 _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 _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 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 load() async { + final presetName = _settings.get(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>(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 = [ + 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 setPreset(String name) async { + await _settings.set(kKeymapPresetSetting, name); + await load(); + } + + /// All effective bindings, highest-precedence first. Useful for + /// debug surfaces and keybinding hints in the UI. + List get effectiveBindings => _active?.effectiveBindings ?? const []; +} diff --git a/lib/kernel/src/keymap/when_clause.dart b/lib/kernel/src/keymap/when_clause.dart new file mode 100644 index 00000000..c51e4cd8 --- /dev/null +++ b/lib/kernel/src/keymap/when_clause.dart @@ -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` 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 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 context) => context[name] ?? false; + @override + String toString() => name; +} + +class WhenNot extends WhenExpr { + const WhenNot(this.child); + final WhenExpr child; + @override + bool evaluate(Map 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 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 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; +} diff --git a/pubspec.yaml b/pubspec.yaml index 2917e296..a41e065a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/kernel/src/keymap/key_chord_test.dart b/test/kernel/src/keymap/key_chord_test.dart new file mode 100644 index 00000000..85ef9217 --- /dev/null +++ b/test/kernel/src/keymap/key_chord_test.dart @@ -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'); + }); + }); +} diff --git a/test/kernel/src/keymap/keymap_service_test.dart b/test/kernel/src/keymap/keymap_service_test.dart new file mode 100644 index 00000000..53240cb8 --- /dev/null +++ b/test/kernel/src/keymap/keymap_service_test.dart @@ -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()); + }); + + test('honours the app.keymap.preset setting', () async { + await settings.set(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()); + }); + + 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()); + }); + + 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>(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()); + }); + + 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()); + }); + + test('tolerates a malformed settings overlay entry by ignoring just the overlay', () async { + await settings.set>(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()); + }); + }); + + 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()); + 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()); + }); + + 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()); + }); + + 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()); + 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()); + await svc.setPreset('vim'); + expect(settings.get(kKeymapPresetSetting), 'vim'); + expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA()); + }); + }); + + 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()); + }); + }); +} + +/// In-memory AssetBundle that returns whatever the constructor map says. +AssetBundle _bundle(Map files) => _MapBundle(files); + +class _MapBundle extends CachingAssetBundle { + _MapBundle(this._files); + final Map _files; + + @override + Future 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); + } +} diff --git a/test/kernel/src/keymap/keymap_test.dart b/test/kernel/src/keymap/keymap_test.dart new file mode 100644 index 00000000..9a32a88b --- /dev/null +++ b/test/kernel/src/keymap/keymap_test.dart @@ -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()); + 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()); + }); + + 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()); + 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()); + }); + + 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()); + }); + + 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()); + }); + + 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()); + expect(km.resolve(KeyChord.parse('enter'), const {}), isA()); + }); + }); +} diff --git a/test/kernel/src/keymap/when_clause_test.dart b/test/kernel/src/keymap/when_clause_test.dart new file mode 100644 index 00000000..131c00e0 --- /dev/null +++ b/test/kernel/src/keymap/when_clause_test.dart @@ -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()); + 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()); + }); + }); + + 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)'); + }); + }); +}