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