feat(keymap): rebuild modal binding registry
Acceptance Criteria:\n- Declarative keymap sequences replace pending_key/pending_focus_chord logic.\n- :keymap show streams the active binding table and bindings export reflects new schema.\n- Vim/Emacs default keymaps resolve multi-step chords via the registry. Test Notes:\n- cargo test -p owlen-tui
This commit is contained in:
78
agents-2025-10-25.md
Normal file
78
agents-2025-10-25.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
## audit(keys): current bindings & commands
|
||||||
|
- Default Vim profile only maps 13 actions (model picker, focus cycling, palette, debug) leaving core editor/navigation keys hard-coded elsewhere, limiting customisation and discoverability (`crates/owlen-tui/keymap.toml:1`).
|
||||||
|
- Emacs profile mirrors the same minimal surface with Alt-based chords, lacking text editing staples like yank/kill rings or buffer navigation (`crates/owlen-tui/keymap_emacs.toml:1`).
|
||||||
|
- Normal-mode logic wires multi-key sequences (`gg`, `dd`, `Ctrl+W` splits) and focus chords directly in Rust, bypassing the registry and preventing remapping (`crates/owlen-tui/src/chat_app.rs:5691`).
|
||||||
|
- Command palette exposes 52 keywords but lacks grouping, fuzzy tags, and modern discoverability patterns (`crates/owlen-tui/src/commands/mod.rs:12`).
|
||||||
|
- Pane management, resize, and accessibility toggles rely on bespoke status messaging with limited visual affordances, creating inconsistency across panels (`crates/owlen-tui/src/chat_app.rs:5800`).
|
||||||
|
|
||||||
|
## research recap (vim/emacs & modern TUI UX)
|
||||||
|
- Contemporary TUI stacks (e.g., Textual) ship keymap registries with runtime swapping, fuzzy command launcher, and discoverable overlays, setting a parity bar for custom apps.
|
||||||
|
- GNU Emacs emphasises mnemonic, consistent command families (`C-x` for control commands, `M-` for meta navigation) and discourages overloading chords without documentation.
|
||||||
|
- Vim ecosystem guidance encourages using `:` commands plus modal consistency, avoiding unremappable hard-coded sequences to keep leader mappings customisable.
|
||||||
|
- Modern UX trends for 2025 favour adaptive layouts, depth via dupal layers, and clear affordances even in minimalist shells, suggesting richer theming, animation, and responsive scaffolding.
|
||||||
|
- Emerging terminal UIs adopt layered “glass + neon” aesthetics with light/dark parity and accessible contrasts instead of monochrome blocks.
|
||||||
|
- CLI design critiques highlight the need for preview panes, keyboard hints, and status context to bridge novice gaps.
|
||||||
|
|
||||||
|
## roadmap (Owlen UX parity sprint)
|
||||||
|
|
||||||
|
### 1) feat(keymap): rebuild binding registry with modal coverage
|
||||||
|
- **Status:** Complete — unified keymap tree with sequence-aware matching, binding export, and runtime introspection.
|
||||||
|
- **Highlights:**
|
||||||
|
1. Replaced ad-hoc `pending_key`/`pending_focus_chord` logic with a trie-driven resolver supporting arbitrary sequences, timeouts, and per-mode registries (`crates/owlen-tui/src/state/keymap.rs`).
|
||||||
|
2. Extended TOML schema (`crates/owlen-tui/keymap.toml`) to declare multi-step chords (`gg`, `dd`, `Ctrl+W s`, `Ctrl+K ←`), mapping them to new `AppCommand` variants consumed in `chat_app`.
|
||||||
|
3. Added `:keymap show` to stream the active binding table into the transcript, aiding discoverability and regression review (`crates/owlen-tui/src/chat_app.rs`).
|
||||||
|
- **Tests:** `cargo test -p owlen-tui` (covers keymap parser + sequence resolution).
|
||||||
|
|
||||||
|
### 2) feat(keymap): enhance Emacs profile & leader ergonomics
|
||||||
|
- **Why:** Match Emacs conventions (C-x, C-c prefixes, yank/kill, buffer switching) and provide Vim leaders for workspace, tools, search.
|
||||||
|
- **Implement:** Flesh out Emacs profile with kill-ring commands, register `C-x C-f`, `C-x C-s`, `M-g` jumps, etc. Introduce configurable Vim leader (default space) with namespaced commands for provider/model, tools, layout.
|
||||||
|
- **AC:** Built-in keymaps cover ≥90% of documented commands; help overlay lists canonical chords.
|
||||||
|
- **Tests:** Snapshot tests for `:keymap vim|emacs`, runtime switch coverage, toml schema validation.
|
||||||
|
|
||||||
|
### 3) feat(commands): modern palette with tagging & fuzzy discovery
|
||||||
|
- **Why:** Current flat list scales poorly; modern TUIs expose grouped, searchable command centers with context previews.
|
||||||
|
- **Implement:** Introduce metadata (category, mode availability, keybinding). Replace static array with data-driven registry backing palette cards, filters, quick docs. Add inline preview for high-impact commands (`:files`, `:model`).
|
||||||
|
- **AC:** Typing `focus` shows grouped matches with key hints; palette supports tag filters (e.g., `/agent`).
|
||||||
|
- **Tests:** Command search integration test, snapshot for palette rendering, lint verifying metadata completeness.
|
||||||
|
|
||||||
|
### 4) feat(tui): adaptive layout & polish refresh
|
||||||
|
- **Why:** Align Owlen with 2025 TUI styling (responsive spacing, depth cues, animated transitions) while preserving accessibility.
|
||||||
|
- **Implement:** Build layout system with breakpoints (≤80 cols stacked, ≥120 cols split). Introduce layer tokens (frosted glass, neon accent) with configurable shadows, transitions, and focus rings. Add optional micro-animations for context gauges, pane toggles.
|
||||||
|
- **AC:** UI adapts seamlessly across 80/100/140-col snapshots; focus state and warnings remain perceivable in high-contrast mode.
|
||||||
|
- **Tests:** Ratatui snapshot suite for multiple terminal sizes/themes; golden tests for animation fallbacks when disabled.
|
||||||
|
|
||||||
|
### 5) feat(guidance): inline cheat-sheets & onboarding
|
||||||
|
- **Why:** Keyboard-rich TUIs need embedded guidance to bridge novices; industry examples surface dynamic hints and quick tours.
|
||||||
|
- **Implement:** Contextual overlay triggered via `?` showing active keymap, top commands, search tips; first-run coach marks for focus chords and leader keys. Persist completion flags in config.
|
||||||
|
- **AC:** First launch displays onboarding flow; `?` overlay updates with keymap swaps.
|
||||||
|
- **Tests:** Snapshot coverage for overlays; integration test ensuring onboarding flag persists.
|
||||||
|
|
||||||
|
### 6) feat(tui): status surface & toast overhaul
|
||||||
|
- **Why:** Current status text is terse; need richer multi-line feedback with icons, timers, action shortcuts.
|
||||||
|
- **Implement:** Replace single-line status with layered HUD (primary message, subtext, progress). Add toast API with severity icons, auto-dismiss timers, and keyboard hints.
|
||||||
|
- **AC:** Streaming, tool calls, rate limits show structured cards; toasts accessible via history panel.
|
||||||
|
- **Tests:** Unit tests for toast queue, snapshot for HUD states, integration verifying keyboard hints update per keymap.
|
||||||
|
|
||||||
|
### 7) docs(tui): publish UX & keybinding playbook
|
||||||
|
- **Why:** Documenting conventions reduces drift and eases contributor onboarding; aligns with Emacs/Vim guidelines.
|
||||||
|
- **Implement:** Author guide covering modal philosophy, command metadata schema, theming tokens, animation policy. Include migration notes for custom keymaps.
|
||||||
|
- **AC:** Docs indexed from README, changelog summarises parity steps.
|
||||||
|
- **Tests:** Link checker; sample custom keymap validated in CI.
|
||||||
|
|
||||||
|
### 8) test(tui): automated UX regression suite
|
||||||
|
- **Why:** Large visual refactor demands deterministic screenshots across modes & terminal sizes.
|
||||||
|
- **Implement:** Expand `chat_snapshots.rs` to cover normal/insert/visual/command states, both keymaps, accessibility toggles, and responsive breakpoints. Wire into CI with diff artifact upload.
|
||||||
|
- **AC:** Snapshot set protects ≥90% of critical panels; CI fails on visual drift.
|
||||||
|
- **Tests:** `cargo test -p owlen-tui --test chat_snapshots` with feature gates for animations.
|
||||||
|
|
||||||
|
### 9) chore(assets): scripted screenshot pipeline
|
||||||
|
- **Why:** We need gallery-quality imagery for docs/changelog while keeping it reproducible.
|
||||||
|
- **Implement:** Add a demo harness that stages representative scenes (model picker, usage header, tool call, accessibility modes) using deterministic seeds; render frames via ratatui `TestBackend`, export ANSI dumps, and convert to PNG with an ANSI→image tool (e.g., chafa). Provide a `cargo xtask screenshots` command and update docs to reference the generated assets.
|
||||||
|
- **AC:** Running `cargo xtask screenshots` regenerates all gallery files; README/docs reference the latest images.
|
||||||
|
- **Tests:** CI smoke test invoking the harness (PNG conversion optional via feature flag); checksum guard to detect drift.
|
||||||
|
|
||||||
|
### 10) feat(compression): adaptive transcript compactor with auto-toggle
|
||||||
|
- **Why:** Long chats blow past context windows; we need an owned compression pipeline that keeps history usable without manual pruning.
|
||||||
|
- **Implement:** Build a compression service that chunks stale turns, summarises via chosen model/tool, and rewrites history entries. Auto mode enabled by default; expose config flag (`chat.auto_compress = true`), CLI opt-out (`--no-auto-compress`), and runtime command (`:compress auto on|off`, `:compress now`). Provide granular settings (threshold tokens, model override, strategy).
|
||||||
|
- **AC:** Owlen transparently compresses once history exceeds threshold; users can toggle via config/CLI/command and view status. Compression metadata logged for audit.
|
||||||
|
- **Tests:** Unit test strategy selection; integration harness simulating long conversations verifying auto-trigger, manual command, and CLI flag precedence.
|
||||||
@@ -1,99 +1,152 @@
|
|||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal"]
|
||||||
keys = ["m"]
|
sequence = ["m"]
|
||||||
command = "model.open_all"
|
command = "model.open_all"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal"]
|
||||||
keys = ["Ctrl+Shift+L"]
|
sequence = ["Ctrl+Shift+L"]
|
||||||
command = "model.open_local"
|
command = "model.open_local"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal"]
|
||||||
keys = ["Ctrl+Shift+C"]
|
sequence = ["Ctrl+Shift+C"]
|
||||||
command = "model.open_cloud"
|
command = "model.open_cloud"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal"]
|
||||||
keys = ["Ctrl+Shift+P"]
|
sequence = ["Ctrl+Shift+P"]
|
||||||
command = "model.open_available"
|
command = "model.open_available"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal", "editing"]
|
||||||
keys = ["Ctrl+P"]
|
sequence = ["Ctrl+P"]
|
||||||
command = "palette.open"
|
command = "palette.open"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "editing"
|
modes = ["normal"]
|
||||||
keys = ["Ctrl+P"]
|
sequence = ["Tab"]
|
||||||
command = "palette.open"
|
|
||||||
|
|
||||||
[[binding]]
|
|
||||||
mode = "normal"
|
|
||||||
keys = ["Tab"]
|
|
||||||
command = "focus.next"
|
command = "focus.next"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal"]
|
||||||
keys = ["Shift+Tab"]
|
sequence = ["Shift+Tab"]
|
||||||
command = "focus.prev"
|
command = "focus.prev"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal"]
|
||||||
keys = ["Ctrl+1"]
|
sequence = ["Ctrl+1"]
|
||||||
command = "focus.files"
|
command = "focus.files"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal"]
|
||||||
keys = ["Ctrl+2"]
|
sequence = ["Ctrl+2"]
|
||||||
command = "focus.chat"
|
command = "focus.chat"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal"]
|
||||||
keys = ["Ctrl+3"]
|
sequence = ["Ctrl+3"]
|
||||||
command = "focus.code"
|
command = "focus.code"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal"]
|
||||||
keys = ["Ctrl+4"]
|
sequence = ["Ctrl+4"]
|
||||||
command = "focus.thinking"
|
command = "focus.thinking"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal"]
|
||||||
keys = ["Ctrl+5"]
|
sequence = ["Ctrl+5"]
|
||||||
command = "focus.input"
|
command = "focus.input"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "editing"
|
modes = ["editing"]
|
||||||
keys = ["Enter"]
|
sequence = ["Enter"]
|
||||||
command = "composer.submit"
|
command = "composer.submit"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal"]
|
||||||
keys = ["Ctrl+;"]
|
sequence = ["Ctrl+;"]
|
||||||
command = "mode.command"
|
command = "mode.command"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "normal"
|
modes = ["normal", "editing", "visual", "command", "help"]
|
||||||
keys = ["F12"]
|
sequence = ["F12"]
|
||||||
command = "debug.toggle"
|
command = "debug.toggle"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "editing"
|
modes = ["normal"]
|
||||||
keys = ["F12"]
|
sequence = ["g", "g"]
|
||||||
command = "debug.toggle"
|
command = "navigate.top"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "visual"
|
modes = ["normal"]
|
||||||
keys = ["F12"]
|
sequence = ["g", "t"]
|
||||||
command = "debug.toggle"
|
command = "files.focus_expand"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "command"
|
modes = ["normal"]
|
||||||
keys = ["F12"]
|
sequence = ["g", "T"]
|
||||||
command = "debug.toggle"
|
command = "files.focus_expand"
|
||||||
|
|
||||||
[[binding]]
|
[[binding]]
|
||||||
mode = "help"
|
modes = ["normal"]
|
||||||
keys = ["F12"]
|
sequence = ["g", "h"]
|
||||||
command = "debug.toggle"
|
command = "files.toggle_hidden"
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
modes = ["normal"]
|
||||||
|
sequence = ["g", "H"]
|
||||||
|
command = "files.toggle_hidden"
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
modes = ["normal"]
|
||||||
|
sequence = ["d", "d"]
|
||||||
|
command = "input.clear"
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
modes = ["normal"]
|
||||||
|
sequence = ["Ctrl+W", "s"]
|
||||||
|
command = "workspace.split_horizontal"
|
||||||
|
timeout_ms = 1200
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
modes = ["normal"]
|
||||||
|
sequence = ["Ctrl+W", "S"]
|
||||||
|
command = "workspace.split_horizontal"
|
||||||
|
timeout_ms = 1200
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
modes = ["normal"]
|
||||||
|
sequence = ["Ctrl+W", "v"]
|
||||||
|
command = "workspace.split_vertical"
|
||||||
|
timeout_ms = 1200
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
modes = ["normal"]
|
||||||
|
sequence = ["Ctrl+W", "V"]
|
||||||
|
command = "workspace.split_vertical"
|
||||||
|
timeout_ms = 1200
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
modes = ["normal"]
|
||||||
|
sequence = ["Ctrl+K", "Left"]
|
||||||
|
command = "workspace.focus_left"
|
||||||
|
timeout_ms = 1200
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
modes = ["normal"]
|
||||||
|
sequence = ["Ctrl+K", "Right"]
|
||||||
|
command = "workspace.focus_right"
|
||||||
|
timeout_ms = 1200
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
modes = ["normal"]
|
||||||
|
sequence = ["Ctrl+K", "Up"]
|
||||||
|
command = "workspace.focus_up"
|
||||||
|
timeout_ms = 1200
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
modes = ["normal"]
|
||||||
|
sequence = ["Ctrl+K", "Down"]
|
||||||
|
command = "workspace.focus_down"
|
||||||
|
timeout_ms = 1200
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ use crate::model_info_panel::ModelInfoPanel;
|
|||||||
use crate::slash::{self, McpSlashCommand, SlashCommand};
|
use crate::slash::{self, McpSlashCommand, SlashCommand};
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver,
|
CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver,
|
||||||
FileNode, FileTreeState, Keymap, KeymapProfile, ModelPaletteEntry, PaletteSuggestion,
|
FileNode, FileTreeState, Keymap, KeymapEventResult, KeymapProfile, KeymapState,
|
||||||
PaneDirection, PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis,
|
ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage,
|
||||||
SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, install_global_logger,
|
RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot,
|
||||||
spawn_repo_search_task, spawn_symbol_search_task,
|
install_global_logger, spawn_repo_search_task, spawn_symbol_search_task,
|
||||||
};
|
};
|
||||||
use crate::toast::{Toast, ToastLevel, ToastManager};
|
use crate::toast::{Toast, ToastLevel, ToastManager};
|
||||||
use crate::ui::{format_token_short, format_tool_output};
|
use crate::ui::{format_token_short, format_tool_output};
|
||||||
@@ -100,7 +100,6 @@ const TUTORIAL_SYSTEM_STATUS: &str =
|
|||||||
|
|
||||||
const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL;
|
const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL;
|
||||||
|
|
||||||
const FOCUS_CHORD_TIMEOUT: Duration = Duration::from_millis(1200);
|
|
||||||
const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450);
|
const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450);
|
||||||
const RESIZE_STEP: f32 = 0.05;
|
const RESIZE_STEP: f32 = 0.05;
|
||||||
const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25];
|
const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25];
|
||||||
@@ -582,6 +581,7 @@ pub struct ChatApp {
|
|||||||
mvu_model: AppModel,
|
mvu_model: AppModel,
|
||||||
keymap: Keymap,
|
keymap: Keymap,
|
||||||
current_keymap_profile: KeymapProfile,
|
current_keymap_profile: KeymapProfile,
|
||||||
|
keymap_state: KeymapState,
|
||||||
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
|
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
|
||||||
pending_llm_request: bool, // Flag to indicate LLM request needs to be processed
|
pending_llm_request: bool, // Flag to indicate LLM request needs to be processed
|
||||||
pending_tool_execution: Option<(Uuid, Vec<owlen_core::types::ToolCall>)>, // Pending tool execution (message_id, tool_calls)
|
pending_tool_execution: Option<(Uuid, Vec<owlen_core::types::ToolCall>)>, // Pending tool execution (message_id, tool_calls)
|
||||||
@@ -590,7 +590,6 @@ pub struct ChatApp {
|
|||||||
current_thinking: Option<String>, // Current thinking content from last assistant message
|
current_thinking: Option<String>, // Current thinking content from last assistant message
|
||||||
// Holds the latest formatted Agentic ReAct actions (thought/action/observation)
|
// Holds the latest formatted Agentic ReAct actions (thought/action/observation)
|
||||||
agent_actions: Option<String>,
|
agent_actions: Option<String>,
|
||||||
pending_key: Option<char>, // For multi-key sequences like gg, dd
|
|
||||||
clipboard: String, // Vim-style clipboard for yank/paste
|
clipboard: String, // Vim-style clipboard for yank/paste
|
||||||
pending_file_action: Option<FileActionPrompt>, // Active file action prompt
|
pending_file_action: Option<FileActionPrompt>, // Active file action prompt
|
||||||
command_palette: CommandPalette, // Command mode state (buffer + suggestions)
|
command_palette: CommandPalette, // Command mode state (buffer + suggestions)
|
||||||
@@ -611,7 +610,6 @@ pub struct ChatApp {
|
|||||||
chat_line_offset: usize, // Number of leading lines trimmed for scrollback
|
chat_line_offset: usize, // Number of leading lines trimmed for scrollback
|
||||||
thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col)
|
thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col)
|
||||||
code_workspace: CodeWorkspace, // Code views with tabs/splits
|
code_workspace: CodeWorkspace, // Code views with tabs/splits
|
||||||
pending_focus_chord: Option<Instant>, // Tracks Ctrl+K focus chord timeout
|
|
||||||
last_resize_tap: Option<(PaneDirection, Instant)>, // For Alt+arrow double-tap detection
|
last_resize_tap: Option<(PaneDirection, Instant)>, // For Alt+arrow double-tap detection
|
||||||
resize_snap_index: usize, // Cycles through 25/50/75 snaps
|
resize_snap_index: usize, // Cycles through 25/50/75 snaps
|
||||||
last_snap_direction: Option<PaneDirection>,
|
last_snap_direction: Option<PaneDirection>,
|
||||||
@@ -869,6 +867,7 @@ impl ChatApp {
|
|||||||
mvu_model: AppModel::default(),
|
mvu_model: AppModel::default(),
|
||||||
keymap,
|
keymap,
|
||||||
current_keymap_profile,
|
current_keymap_profile,
|
||||||
|
keymap_state: KeymapState::default(),
|
||||||
controller_event_rx,
|
controller_event_rx,
|
||||||
pending_llm_request: false,
|
pending_llm_request: false,
|
||||||
pending_tool_execution: None,
|
pending_tool_execution: None,
|
||||||
@@ -876,7 +875,6 @@ impl ChatApp {
|
|||||||
is_loading: false,
|
is_loading: false,
|
||||||
current_thinking: None,
|
current_thinking: None,
|
||||||
agent_actions: None,
|
agent_actions: None,
|
||||||
pending_key: None,
|
|
||||||
clipboard: String::new(),
|
clipboard: String::new(),
|
||||||
pending_file_action: None,
|
pending_file_action: None,
|
||||||
command_palette: CommandPalette::new(),
|
command_palette: CommandPalette::new(),
|
||||||
@@ -897,7 +895,6 @@ impl ChatApp {
|
|||||||
chat_line_offset: 0,
|
chat_line_offset: 0,
|
||||||
thinking_cursor: (0, 0),
|
thinking_cursor: (0, 0),
|
||||||
code_workspace: CodeWorkspace::new(),
|
code_workspace: CodeWorkspace::new(),
|
||||||
pending_focus_chord: None,
|
|
||||||
last_resize_tap: None,
|
last_resize_tap: None,
|
||||||
resize_snap_index: 0,
|
resize_snap_index: 0,
|
||||||
last_snap_direction: None,
|
last_snap_direction: None,
|
||||||
@@ -2032,6 +2029,7 @@ impl ChatApp {
|
|||||||
self.reset_model_picker_state();
|
self.reset_model_picker_state();
|
||||||
}
|
}
|
||||||
self.mode = mode;
|
self.mode = mode;
|
||||||
|
self.keymap_state.reset();
|
||||||
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode }));
|
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3810,18 +3808,38 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn try_execute_command(&mut self, key: &KeyEvent) -> Result<bool> {
|
async fn try_execute_command(&mut self, key: &KeyEvent) -> Result<bool> {
|
||||||
if let Some(command) = self.keymap.resolve(self.mode, key) {
|
match self.keymap.step(self.mode, key, &mut self.keymap_state) {
|
||||||
|
KeymapEventResult::Matched(command) => {
|
||||||
if self.execute_command(command).await? {
|
if self.execute_command(command).await? {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
KeymapEventResult::Pending => {
|
||||||
|
self.update_pending_sequence_status();
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
KeymapEventResult::NoMatch => {}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_pending_sequence_status(&mut self) {
|
||||||
|
if self.keymap_state.matches_sequence(&["Ctrl+W"]) {
|
||||||
|
self.status = "Split layout: press s for horizontal, v for vertical".to_string();
|
||||||
|
self.error = None;
|
||||||
|
} else if self.keymap_state.matches_sequence(&["Ctrl+K"]) {
|
||||||
|
if self.show_model_info && self.model_info_viewport_height > 0 {
|
||||||
|
self.model_info_panel.scroll_up();
|
||||||
|
}
|
||||||
|
self.status = "Pane focus pending — use ←/→/↑/↓".to_string();
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute_command(&mut self, command: AppCommand) -> Result<bool> {
|
async fn execute_command(&mut self, command: AppCommand) -> Result<bool> {
|
||||||
match command {
|
match command {
|
||||||
AppCommand::OpenModelPicker(filter) => {
|
AppCommand::OpenModelPicker(filter) => {
|
||||||
self.pending_key = None;
|
|
||||||
if !matches!(self.mode, InputMode::Normal) {
|
if !matches!(self.mode, InputMode::Normal) {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -3831,7 +3849,6 @@ impl ChatApp {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
AppCommand::OpenCommandPalette | AppCommand::EnterCommandMode => {
|
AppCommand::OpenCommandPalette | AppCommand::EnterCommandMode => {
|
||||||
self.pending_key = None;
|
|
||||||
if !matches!(
|
if !matches!(
|
||||||
self.mode,
|
self.mode,
|
||||||
InputMode::Normal | InputMode::Editing | InputMode::Command
|
InputMode::Normal | InputMode::Editing | InputMode::Command
|
||||||
@@ -3846,7 +3863,6 @@ impl ChatApp {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
AppCommand::CycleFocusForward => {
|
AppCommand::CycleFocusForward => {
|
||||||
self.pending_key = None;
|
|
||||||
if !matches!(self.mode, InputMode::Normal) {
|
if !matches!(self.mode, InputMode::Normal) {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -3856,7 +3872,6 @@ impl ChatApp {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
AppCommand::CycleFocusBackward => {
|
AppCommand::CycleFocusBackward => {
|
||||||
self.pending_key = None;
|
|
||||||
if !matches!(self.mode, InputMode::Normal) {
|
if !matches!(self.mode, InputMode::Normal) {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -3866,7 +3881,6 @@ impl ChatApp {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
AppCommand::FocusPanel(target) => {
|
AppCommand::FocusPanel(target) => {
|
||||||
self.pending_key = None;
|
|
||||||
if !matches!(self.mode, InputMode::Normal) {
|
if !matches!(self.mode, InputMode::Normal) {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -3897,24 +3911,83 @@ impl ChatApp {
|
|||||||
if !matches!(self.mode, InputMode::Editing) {
|
if !matches!(self.mode, InputMode::Editing) {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
self.pending_key = None;
|
|
||||||
self.sync_textarea_to_buffer();
|
self.sync_textarea_to_buffer();
|
||||||
let effects = self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit));
|
let effects = self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit));
|
||||||
self.handle_app_effects(effects).await?;
|
self.handle_app_effects(effects).await?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
AppCommand::ToggleDebugLog => {
|
AppCommand::ToggleDebugLog => {
|
||||||
self.pending_key = None;
|
|
||||||
self.toggle_debug_log_panel();
|
self.toggle_debug_log_panel();
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
AppCommand::SetKeymap(profile) => {
|
AppCommand::SetKeymap(profile) => {
|
||||||
self.pending_key = None;
|
|
||||||
if profile.is_builtin() {
|
if profile.is_builtin() {
|
||||||
self.switch_keymap_profile(profile).await?;
|
self.switch_keymap_profile(profile).await?;
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
AppCommand::JumpToTop => {
|
||||||
|
if !matches!(self.mode, InputMode::Normal) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.jump_to_top();
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
AppCommand::ExpandFilePanel => {
|
||||||
|
if !matches!(self.mode, InputMode::Normal) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if self.focus_panel(FocusedPanel::Files) {
|
||||||
|
self.status = "Files panel focused".to_string();
|
||||||
|
self.error = None;
|
||||||
|
} else {
|
||||||
|
self.status = "Unable to focus Files panel".to_string();
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
AppCommand::ToggleHiddenFiles => {
|
||||||
|
if !matches!(self.mode, InputMode::Normal) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if matches!(self.focused_panel, FocusedPanel::Files) {
|
||||||
|
self.toggle_hidden_files();
|
||||||
|
} else {
|
||||||
|
self.status = "Toggle hidden files from the Files panel".to_string();
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
AppCommand::SplitPaneHorizontal => {
|
||||||
|
if !matches!(self.mode, InputMode::Normal) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.split_active_pane(SplitAxis::Horizontal);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
AppCommand::SplitPaneVertical => {
|
||||||
|
if !matches!(self.mode, InputMode::Normal) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.split_active_pane(SplitAxis::Vertical);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
AppCommand::ClearInputBuffer => {
|
||||||
|
if !matches!(self.mode, InputMode::Normal) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.input_buffer_mut().clear();
|
||||||
|
self.textarea = TextArea::default();
|
||||||
|
configure_textarea_defaults(&mut self.textarea);
|
||||||
|
self.status = "Input buffer cleared".to_string();
|
||||||
|
self.error = None;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
AppCommand::MoveWorkspaceFocus(direction) => {
|
||||||
|
if !matches!(self.mode, InputMode::Normal) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.handle_workspace_focus_move(direction);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4282,7 +4355,6 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_workspace_focus_move(&mut self, direction: PaneDirection) {
|
fn handle_workspace_focus_move(&mut self, direction: PaneDirection) {
|
||||||
self.pending_focus_chord = None;
|
|
||||||
if self.code_workspace.move_focus(direction) {
|
if self.code_workspace.move_focus(direction) {
|
||||||
self.focused_panel = FocusedPanel::Code;
|
self.focused_panel = FocusedPanel::Code;
|
||||||
self.ensure_focus_valid();
|
self.ensure_focus_valid();
|
||||||
@@ -4303,7 +4375,6 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_workspace_resize(&mut self, direction: PaneDirection) {
|
fn handle_workspace_resize(&mut self, direction: PaneDirection) {
|
||||||
self.pending_focus_chord = None;
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let is_double = self
|
let is_double = self
|
||||||
.last_resize_tap
|
.last_resize_tap
|
||||||
@@ -5702,82 +5773,12 @@ impl ChatApp {
|
|||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(started) = self.pending_focus_chord {
|
|
||||||
if started.elapsed() > FOCUS_CHORD_TIMEOUT {
|
|
||||||
self.pending_focus_chord = None;
|
|
||||||
} else if key.modifiers.is_empty() {
|
|
||||||
let direction = match key.code {
|
|
||||||
KeyCode::Left => Some(PaneDirection::Left),
|
|
||||||
KeyCode::Right => Some(PaneDirection::Right),
|
|
||||||
KeyCode::Up => Some(PaneDirection::Up),
|
|
||||||
KeyCode::Down => Some(PaneDirection::Down),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if let Some(direction) = direction {
|
|
||||||
self.handle_workspace_focus_move(direction);
|
|
||||||
return Ok(AppState::Running);
|
|
||||||
} else {
|
|
||||||
self.pending_focus_chord = None;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.pending_focus_chord = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(pending) = self.pending_key {
|
|
||||||
self.pending_key = None;
|
|
||||||
match (pending, key.code) {
|
|
||||||
('g', KeyCode::Char('g')) => {
|
|
||||||
self.jump_to_top();
|
|
||||||
}
|
|
||||||
('g', KeyCode::Char('T')) | ('g', KeyCode::Char('t')) => {
|
|
||||||
self.expand_file_panel();
|
|
||||||
self.focused_panel = FocusedPanel::Files;
|
|
||||||
self.status = "Files panel focused".to_string();
|
|
||||||
}
|
|
||||||
('g', KeyCode::Char('h')) | ('g', KeyCode::Char('H')) => {
|
|
||||||
if matches!(self.focused_panel, FocusedPanel::Files) {
|
|
||||||
self.toggle_hidden_files();
|
|
||||||
} else {
|
|
||||||
self.status =
|
|
||||||
"Toggle hidden files from the Files panel".to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
('W', KeyCode::Char('s')) | ('W', KeyCode::Char('S')) => {
|
|
||||||
self.split_active_pane(SplitAxis::Horizontal);
|
|
||||||
}
|
|
||||||
('W', KeyCode::Char('v')) | ('W', KeyCode::Char('V')) => {
|
|
||||||
self.split_active_pane(SplitAxis::Vertical);
|
|
||||||
}
|
|
||||||
('d', KeyCode::Char('d')) => {
|
|
||||||
// Clear input buffer
|
|
||||||
self.input_buffer_mut().clear();
|
|
||||||
self.textarea = TextArea::default();
|
|
||||||
configure_textarea_defaults(&mut self.textarea);
|
|
||||||
self.status = "Input buffer cleared".to_string();
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Invalid sequence, ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(AppState::Running);
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(self.focused_panel, FocusedPanel::Files)
|
if matches!(self.focused_panel, FocusedPanel::Files)
|
||||||
&& self.handle_file_panel_key(&key).await?
|
&& self.handle_file_panel_key(&key).await?
|
||||||
{
|
{
|
||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
|
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
|
||||||
&& matches!(key.code, KeyCode::Char('w') | KeyCode::Char('W'))
|
|
||||||
{
|
|
||||||
self.pending_key = Some('W');
|
|
||||||
self.status =
|
|
||||||
"Split layout: press s for horizontal, v for vertical".to_string();
|
|
||||||
return Ok(AppState::Running);
|
|
||||||
}
|
|
||||||
|
|
||||||
match (key.code, key.modifiers) {
|
match (key.code, key.modifiers) {
|
||||||
(KeyCode::Left, modifiers) if modifiers.contains(KeyModifiers::ALT) => {
|
(KeyCode::Left, modifiers) if modifiers.contains(KeyModifiers::ALT) => {
|
||||||
self.handle_workspace_resize(PaneDirection::Left);
|
self.handle_workspace_resize(PaneDirection::Left);
|
||||||
@@ -5830,16 +5831,6 @@ impl ChatApp {
|
|||||||
.scroll_down(self.model_info_viewport_height);
|
.scroll_down(self.model_info_viewport_height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(KeyCode::Char('k'), modifiers)
|
|
||||||
if modifiers.contains(KeyModifiers::CONTROL) =>
|
|
||||||
{
|
|
||||||
self.pending_focus_chord = Some(Instant::now());
|
|
||||||
self.status = "Pane focus pending — use ←/→/↑/↓".to_string();
|
|
||||||
if self.show_model_info && self.model_info_viewport_height > 0 {
|
|
||||||
self.model_info_panel.scroll_up();
|
|
||||||
}
|
|
||||||
return Ok(AppState::Running);
|
|
||||||
}
|
|
||||||
// Mode switches
|
// Mode switches
|
||||||
(KeyCode::Char('v'), KeyModifiers::NONE) => {
|
(KeyCode::Char('v'), KeyModifiers::NONE) => {
|
||||||
if matches!(self.focused_panel, FocusedPanel::Code) {
|
if matches!(self.focused_panel, FocusedPanel::Code) {
|
||||||
@@ -6208,15 +6199,6 @@ impl ChatApp {
|
|||||||
(KeyCode::Char('G'), KeyModifiers::SHIFT) => {
|
(KeyCode::Char('G'), KeyModifiers::SHIFT) => {
|
||||||
self.jump_to_bottom();
|
self.jump_to_bottom();
|
||||||
}
|
}
|
||||||
// Multi-key sequences
|
|
||||||
(KeyCode::Char('g'), KeyModifiers::NONE) => {
|
|
||||||
self.pending_key = Some('g');
|
|
||||||
self.status = "g".to_string();
|
|
||||||
}
|
|
||||||
(KeyCode::Char('d'), KeyModifiers::NONE) => {
|
|
||||||
self.pending_key = Some('d');
|
|
||||||
self.status = "d".to_string();
|
|
||||||
}
|
|
||||||
// Yank/paste (works from any panel)
|
// Yank/paste (works from any panel)
|
||||||
(KeyCode::Char('p'), KeyModifiers::NONE) => {
|
(KeyCode::Char('p'), KeyModifiers::NONE) => {
|
||||||
if !self.clipboard.is_empty() {
|
if !self.clipboard.is_empty() {
|
||||||
@@ -6330,12 +6312,9 @@ impl ChatApp {
|
|||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
(KeyCode::Esc, KeyModifiers::NONE) => {
|
(KeyCode::Esc, KeyModifiers::NONE) => {
|
||||||
self.pending_key = None;
|
|
||||||
self.set_input_mode(InputMode::Normal);
|
self.set_input_mode(InputMode::Normal);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {}
|
||||||
self.pending_key = None;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InputMode::RepoSearch => match (key.code, key.modifiers) {
|
InputMode::RepoSearch => match (key.code, key.modifiers) {
|
||||||
@@ -7980,7 +7959,33 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"keymap" => {
|
"keymap" => {
|
||||||
if let Some(arg) = args.first() {
|
if let Some(arg) = args.first().copied() {
|
||||||
|
if arg.eq_ignore_ascii_case("show")
|
||||||
|
|| arg.eq_ignore_ascii_case("list")
|
||||||
|
{
|
||||||
|
let mut lines =
|
||||||
|
String::from("Active keymap bindings:\n");
|
||||||
|
lines.push_str("Mode Sequence Command\n");
|
||||||
|
lines.push_str("------------------------------------------------\n");
|
||||||
|
for binding in self.keymap.describe_bindings() {
|
||||||
|
let mode_label = format!("{:?}", binding.mode);
|
||||||
|
let sequence = if binding.sequence.is_empty() {
|
||||||
|
String::from("<none>")
|
||||||
|
} else {
|
||||||
|
binding.sequence.join(" ")
|
||||||
|
};
|
||||||
|
lines.push_str(&format!(
|
||||||
|
"{:<14} {:<24} {}\n",
|
||||||
|
mode_label, sequence, binding.command
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.controller
|
||||||
|
.conversation_mut()
|
||||||
|
.push_system_message(lines);
|
||||||
|
self.status = "Keymap bindings listed in conversation"
|
||||||
|
.to_string();
|
||||||
|
self.error = None;
|
||||||
|
} else {
|
||||||
match KeymapProfile::from_str(arg) {
|
match KeymapProfile::from_str(arg) {
|
||||||
Some(profile) if profile.is_builtin() => {
|
Some(profile) if profile.is_builtin() => {
|
||||||
self.switch_keymap_profile(profile).await?;
|
self.switch_keymap_profile(profile).await?;
|
||||||
@@ -7997,6 +8002,7 @@ impl ChatApp {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.status = format!(
|
self.status = format!(
|
||||||
"Active keymap: {}",
|
"Active keymap: {}",
|
||||||
@@ -8408,14 +8414,12 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MouseEventKind::Down(MouseButton::Left) => {
|
MouseEventKind::Down(MouseButton::Left) => {
|
||||||
self.pending_key = None;
|
|
||||||
if let Some(region) = region {
|
if let Some(region) = region {
|
||||||
self.handle_mouse_click(region, mouse.column, mouse.row);
|
self.handle_mouse_click(region, mouse.column, mouse.row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MouseEventKind::Drag(MouseButton::Left) => {
|
MouseEventKind::Drag(MouseButton::Left) => {
|
||||||
if matches!(region, Some(UiRegion::Input)) {
|
if matches!(region, Some(UiRegion::Input)) {
|
||||||
self.pending_key = None;
|
|
||||||
self.handle_mouse_click(UiRegion::Input, mouse.column, mouse.row);
|
self.handle_mouse_click(UiRegion::Input, mouse.column, mouse.row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10853,7 +10857,7 @@ impl ChatApp {
|
|||||||
self.pending_tool_execution = None;
|
self.pending_tool_execution = None;
|
||||||
self.pending_consent = None;
|
self.pending_consent = None;
|
||||||
self.queued_consents.clear();
|
self.queued_consents.clear();
|
||||||
self.pending_key = None;
|
self.keymap_state.reset();
|
||||||
self.visual_start = None;
|
self.visual_start = None;
|
||||||
self.visual_end = None;
|
self.visual_end = None;
|
||||||
self.clipboard.clear();
|
self.clipboard.clear();
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use owlen_core::ui::FocusedPanel;
|
use owlen_core::ui::FocusedPanel;
|
||||||
|
|
||||||
use crate::{state::KeymapProfile, widgets::model_picker::FilterMode};
|
use crate::{
|
||||||
|
state::{KeymapProfile, PaneDirection},
|
||||||
|
widgets::model_picker::FilterMode,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum AppCommand {
|
pub enum AppCommand {
|
||||||
@@ -15,6 +18,13 @@ pub enum AppCommand {
|
|||||||
EnterCommandMode,
|
EnterCommandMode,
|
||||||
ToggleDebugLog,
|
ToggleDebugLog,
|
||||||
SetKeymap(KeymapProfile),
|
SetKeymap(KeymapProfile),
|
||||||
|
JumpToTop,
|
||||||
|
ExpandFilePanel,
|
||||||
|
ToggleHiddenFiles,
|
||||||
|
SplitPaneHorizontal,
|
||||||
|
SplitPaneVertical,
|
||||||
|
ClearInputBuffer,
|
||||||
|
MoveWorkspaceFocus(PaneDirection),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -76,6 +86,40 @@ impl CommandRegistry {
|
|||||||
"keymap.set_emacs".to_string(),
|
"keymap.set_emacs".to_string(),
|
||||||
AppCommand::SetKeymap(KeymapProfile::Emacs),
|
AppCommand::SetKeymap(KeymapProfile::Emacs),
|
||||||
);
|
);
|
||||||
|
commands.insert("navigate.top".to_string(), AppCommand::JumpToTop);
|
||||||
|
commands.insert(
|
||||||
|
"files.focus_expand".to_string(),
|
||||||
|
AppCommand::ExpandFilePanel,
|
||||||
|
);
|
||||||
|
commands.insert(
|
||||||
|
"files.toggle_hidden".to_string(),
|
||||||
|
AppCommand::ToggleHiddenFiles,
|
||||||
|
);
|
||||||
|
commands.insert(
|
||||||
|
"workspace.split_horizontal".to_string(),
|
||||||
|
AppCommand::SplitPaneHorizontal,
|
||||||
|
);
|
||||||
|
commands.insert(
|
||||||
|
"workspace.split_vertical".to_string(),
|
||||||
|
AppCommand::SplitPaneVertical,
|
||||||
|
);
|
||||||
|
commands.insert("input.clear".to_string(), AppCommand::ClearInputBuffer);
|
||||||
|
commands.insert(
|
||||||
|
"workspace.focus_left".to_string(),
|
||||||
|
AppCommand::MoveWorkspaceFocus(PaneDirection::Left),
|
||||||
|
);
|
||||||
|
commands.insert(
|
||||||
|
"workspace.focus_right".to_string(),
|
||||||
|
AppCommand::MoveWorkspaceFocus(PaneDirection::Right),
|
||||||
|
);
|
||||||
|
commands.insert(
|
||||||
|
"workspace.focus_up".to_string(),
|
||||||
|
AppCommand::MoveWorkspaceFocus(PaneDirection::Up),
|
||||||
|
);
|
||||||
|
commands.insert(
|
||||||
|
"workspace.focus_down".to_string(),
|
||||||
|
AppCommand::MoveWorkspaceFocus(PaneDirection::Down),
|
||||||
|
);
|
||||||
|
|
||||||
Self { commands }
|
Self { commands }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::{
|
|||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
fs,
|
fs,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
@@ -13,11 +14,28 @@ use crate::commands::registry::{AppCommand, CommandRegistry};
|
|||||||
|
|
||||||
const DEFAULT_KEYMAP: &str = include_str!("../../keymap.toml");
|
const DEFAULT_KEYMAP: &str = include_str!("../../keymap.toml");
|
||||||
const EMACS_KEYMAP: &str = include_str!("../../keymap_emacs.toml");
|
const EMACS_KEYMAP: &str = include_str!("../../keymap_emacs.toml");
|
||||||
|
const DEFAULT_SEQUENCE_TIMEOUT: Duration = Duration::from_millis(1200);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Keymap {
|
pub struct Keymap {
|
||||||
bindings: HashMap<(InputMode, KeyPattern), AppCommand>,
|
|
||||||
profile: KeymapProfile,
|
profile: KeymapProfile,
|
||||||
|
trees: HashMap<InputMode, KeymapNode>,
|
||||||
|
default_timeout: Duration,
|
||||||
|
bindings: Vec<ResolvedBinding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
struct KeymapNode {
|
||||||
|
command: Option<AppCommand>,
|
||||||
|
timeout: Option<Duration>,
|
||||||
|
children: HashMap<KeyPattern, KeymapNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ResolvedBinding {
|
||||||
|
mode: InputMode,
|
||||||
|
sequence: Vec<KeyPattern>,
|
||||||
|
command_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Keymap {
|
impl Keymap {
|
||||||
@@ -43,77 +61,215 @@ impl Keymap {
|
|||||||
|
|
||||||
let (parsed, profile) = loader.finish();
|
let (parsed, profile) = loader.finish();
|
||||||
|
|
||||||
let mut bindings = HashMap::new();
|
let mut trees: HashMap<InputMode, KeymapNode> = HashMap::new();
|
||||||
|
let mut bindings = Vec::new();
|
||||||
|
|
||||||
for entry in parsed.bindings {
|
for entry in parsed.bindings {
|
||||||
let mode = match parse_mode(&entry.mode) {
|
let modes: Vec<_> = entry
|
||||||
Some(mode) => mode,
|
.resolve_modes()
|
||||||
None => {
|
.into_iter()
|
||||||
warn!("Unknown input mode '{}' in keymap binding", entry.mode);
|
.filter_map(|mode| parse_mode(&mode))
|
||||||
|
.collect();
|
||||||
|
if modes.is_empty() {
|
||||||
|
warn!(
|
||||||
|
"Unknown input modes in keymap binding for command '{}'",
|
||||||
|
entry.command
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let command = match registry.resolve(&entry.command) {
|
let command = match registry.resolve(&entry.command) {
|
||||||
Some(cmd) => cmd,
|
Some(command) => command,
|
||||||
None => {
|
None => {
|
||||||
warn!("Unknown command '{}' in keymap binding", entry.command);
|
warn!("Unknown command '{}' in keymap binding", entry.command);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for key in entry.keys.into_iter() {
|
let sequences = entry.resolve_sequences();
|
||||||
match KeyPattern::from_str(&key) {
|
if sequences.is_empty() {
|
||||||
Some(pattern) => {
|
warn!(
|
||||||
bindings.insert((mode, pattern), command);
|
"No key sequence defined for command '{}' (modes: {:?})",
|
||||||
|
entry.command, modes
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
None => warn!(
|
|
||||||
"Unrecognised key specification '{}' for mode {}",
|
let timeout = entry.timeout_ms.map(Duration::from_millis);
|
||||||
key, entry.mode
|
|
||||||
),
|
for mut sequence_tokens in sequences {
|
||||||
|
let mut sequence = Vec::new();
|
||||||
|
let mut parse_failed = false;
|
||||||
|
for token in sequence_tokens.drain(..) {
|
||||||
|
match KeyPattern::from_str(&token) {
|
||||||
|
Some(pattern) => sequence.push(pattern),
|
||||||
|
None => {
|
||||||
|
warn!(
|
||||||
|
"Unrecognised key specification '{}' for command '{}'",
|
||||||
|
token, entry.command
|
||||||
|
);
|
||||||
|
parse_failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parse_failed || sequence.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for mode in &modes {
|
||||||
|
let tree = trees.entry(*mode).or_default();
|
||||||
|
insert_sequence(tree, &sequence, command, timeout);
|
||||||
|
bindings.push(ResolvedBinding {
|
||||||
|
mode: *mode,
|
||||||
|
sequence: sequence.clone(),
|
||||||
|
command_name: entry.command.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Self { bindings, profile }
|
Self {
|
||||||
|
profile,
|
||||||
|
trees,
|
||||||
|
default_timeout: DEFAULT_SEQUENCE_TIMEOUT,
|
||||||
|
bindings,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve(&self, mode: InputMode, event: &KeyEvent) -> Option<AppCommand> {
|
|
||||||
let pattern = KeyPattern::from_event(event)?;
|
|
||||||
self.bindings.get(&(mode, pattern)).copied()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn profile(&self) -> KeymapProfile {
|
pub fn profile(&self) -> KeymapProfile {
|
||||||
self.profile
|
self.profile
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
pub fn describe_bindings(&self) -> Vec<KeymapBindingDescription> {
|
||||||
struct KeymapConfig {
|
let mut descriptions: Vec<_> = self
|
||||||
#[serde(default, rename = "binding")]
|
.bindings
|
||||||
bindings: Vec<KeyBindingConfig>,
|
.iter()
|
||||||
}
|
.map(|binding| KeymapBindingDescription {
|
||||||
|
mode: binding.mode,
|
||||||
#[derive(Debug, Deserialize)]
|
sequence: binding
|
||||||
struct KeyBindingConfig {
|
.sequence
|
||||||
mode: String,
|
.iter()
|
||||||
command: String,
|
.map(|pattern| pattern.display_token())
|
||||||
keys: KeyList,
|
.collect(),
|
||||||
}
|
command: binding.command_name.clone(),
|
||||||
|
})
|
||||||
#[derive(Debug, Deserialize)]
|
.collect();
|
||||||
#[serde(untagged)]
|
descriptions.sort_by(|a, b| {
|
||||||
enum KeyList {
|
let mode_cmp = format!("{:?}", a.mode).cmp(&format!("{:?}", b.mode));
|
||||||
Single(String),
|
if mode_cmp != std::cmp::Ordering::Equal {
|
||||||
Multiple(Vec<String>),
|
return mode_cmp;
|
||||||
}
|
|
||||||
|
|
||||||
impl KeyList {
|
|
||||||
fn into_iter(self) -> Vec<String> {
|
|
||||||
match self {
|
|
||||||
KeyList::Single(key) => vec![key],
|
|
||||||
KeyList::Multiple(keys) => keys,
|
|
||||||
}
|
}
|
||||||
|
let seq_a = a.sequence.join(" ");
|
||||||
|
let seq_b = b.sequence.join(" ");
|
||||||
|
let seq_cmp = seq_a.cmp(&seq_b);
|
||||||
|
if seq_cmp != std::cmp::Ordering::Equal {
|
||||||
|
return seq_cmp;
|
||||||
|
}
|
||||||
|
a.command.cmp(&b.command)
|
||||||
|
});
|
||||||
|
descriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn step(
|
||||||
|
&self,
|
||||||
|
mode: InputMode,
|
||||||
|
event: &KeyEvent,
|
||||||
|
state: &mut KeymapState,
|
||||||
|
) -> KeymapEventResult {
|
||||||
|
let pattern = match KeyPattern::from_event(event) {
|
||||||
|
Some(pattern) => pattern,
|
||||||
|
None => {
|
||||||
|
state.reset();
|
||||||
|
return KeymapEventResult::NoMatch;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
state.expire_if_needed(now);
|
||||||
|
|
||||||
|
state.sequence.push(pattern);
|
||||||
|
let mut node = self.node_for_sequence(mode, &state.sequence);
|
||||||
|
|
||||||
|
if node.is_none() {
|
||||||
|
state.reset();
|
||||||
|
state.sequence.push(pattern);
|
||||||
|
node = self.node_for_sequence(mode, &state.sequence);
|
||||||
|
if node.is_none() {
|
||||||
|
state.reset();
|
||||||
|
return KeymapEventResult::NoMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let node = node.unwrap();
|
||||||
|
let timeout = node.timeout.unwrap_or(self.default_timeout);
|
||||||
|
|
||||||
|
if let Some(command) = node.command {
|
||||||
|
state.reset();
|
||||||
|
return KeymapEventResult::Matched(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.children.is_empty() {
|
||||||
|
state.reset();
|
||||||
|
return KeymapEventResult::NoMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.deadline = Some(now + timeout);
|
||||||
|
KeymapEventResult::Pending
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_for_sequence(&self, mode: InputMode, sequence: &[KeyPattern]) -> Option<&KeymapNode> {
|
||||||
|
let mut node = self.trees.get(&mode)?;
|
||||||
|
for pattern in sequence {
|
||||||
|
node = node.children.get(pattern)?;
|
||||||
|
}
|
||||||
|
Some(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum KeymapEventResult {
|
||||||
|
Matched(AppCommand),
|
||||||
|
Pending,
|
||||||
|
NoMatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct KeymapState {
|
||||||
|
sequence: Vec<KeyPattern>,
|
||||||
|
deadline: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeymapState {
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.sequence.clear();
|
||||||
|
self.deadline = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expire_if_needed(&mut self, now: Instant) {
|
||||||
|
if let Some(deadline) = self.deadline {
|
||||||
|
if now > deadline {
|
||||||
|
self.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sequence_tokens(&self) -> Vec<String> {
|
||||||
|
self.sequence
|
||||||
|
.iter()
|
||||||
|
.map(|pattern| pattern.display_token())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn matches_sequence(&self, tokens: &[&str]) -> bool {
|
||||||
|
if self.sequence.len() != tokens.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tokens.iter().enumerate().all(|(idx, token)| {
|
||||||
|
KeyPattern::from_str(token)
|
||||||
|
.map(|pattern| pattern == self.sequence[idx])
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +355,159 @@ impl KeyPattern {
|
|||||||
modifiers: normalize_modifiers(modifiers),
|
modifiers: normalize_modifiers(modifiers),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn display_token(&self) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if self.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
parts.push("Ctrl".to_string());
|
||||||
|
}
|
||||||
|
if self.modifiers.contains(KeyModifiers::ALT) {
|
||||||
|
parts.push("Alt".to_string());
|
||||||
|
}
|
||||||
|
if self.modifiers.contains(KeyModifiers::SHIFT) {
|
||||||
|
parts.push("Shift".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = match self.code {
|
||||||
|
KeyCodeKind::Char(c) => c.to_string(),
|
||||||
|
KeyCodeKind::Enter => "Enter".to_string(),
|
||||||
|
KeyCodeKind::Tab => "Tab".to_string(),
|
||||||
|
KeyCodeKind::BackTab => "BackTab".to_string(),
|
||||||
|
KeyCodeKind::Backspace => "Backspace".to_string(),
|
||||||
|
KeyCodeKind::Esc => "Esc".to_string(),
|
||||||
|
KeyCodeKind::Up => "Up".to_string(),
|
||||||
|
KeyCodeKind::Down => "Down".to_string(),
|
||||||
|
KeyCodeKind::Left => "Left".to_string(),
|
||||||
|
KeyCodeKind::Right => "Right".to_string(),
|
||||||
|
KeyCodeKind::PageUp => "PageUp".to_string(),
|
||||||
|
KeyCodeKind::PageDown => "PageDown".to_string(),
|
||||||
|
KeyCodeKind::Home => "Home".to_string(),
|
||||||
|
KeyCodeKind::End => "End".to_string(),
|
||||||
|
KeyCodeKind::F(n) => format!("F{}", n),
|
||||||
|
};
|
||||||
|
parts.push(key);
|
||||||
|
parts.join("+")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct KeymapConfig {
|
||||||
|
#[serde(default, rename = "binding")]
|
||||||
|
bindings: Vec<KeyBindingConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct KeyBindingConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
modes: Vec<String>,
|
||||||
|
command: String,
|
||||||
|
#[serde(default, rename = "keys")]
|
||||||
|
keys: Option<KeyList>,
|
||||||
|
#[serde(default)]
|
||||||
|
sequence: Option<SequenceSpec>,
|
||||||
|
#[serde(default)]
|
||||||
|
sequences: Vec<SequenceSpec>,
|
||||||
|
#[serde(default)]
|
||||||
|
timeout_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyBindingConfig {
|
||||||
|
fn resolve_modes(&self) -> Vec<String> {
|
||||||
|
if !self.modes.is_empty() {
|
||||||
|
self.modes.clone()
|
||||||
|
} else if let Some(mode) = &self.mode {
|
||||||
|
vec![mode.clone()]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_sequences(&self) -> Vec<Vec<String>> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
if let Some(keys) = self.keys.clone() {
|
||||||
|
for key in keys.into_iter() {
|
||||||
|
result.push(vec![key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(seq) = self.sequence.clone() {
|
||||||
|
result.push(seq.into_sequence());
|
||||||
|
}
|
||||||
|
|
||||||
|
for seq in self.sequences.clone() {
|
||||||
|
result.push(seq.into_sequence());
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum SequenceSpec {
|
||||||
|
Single(String),
|
||||||
|
Sequence(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SequenceSpec {
|
||||||
|
fn into_sequence(self) -> Vec<String> {
|
||||||
|
match self {
|
||||||
|
SequenceSpec::Single(value) => vec![value],
|
||||||
|
SequenceSpec::Sequence(values) => values,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum KeyList {
|
||||||
|
Single(String),
|
||||||
|
Multiple(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyList {
|
||||||
|
fn into_iter(self) -> Vec<String> {
|
||||||
|
match self {
|
||||||
|
KeyList::Single(key) => vec![key],
|
||||||
|
KeyList::Multiple(keys) => keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_sequence(
|
||||||
|
root: &mut KeymapNode,
|
||||||
|
sequence: &[KeyPattern],
|
||||||
|
command: AppCommand,
|
||||||
|
timeout: Option<Duration>,
|
||||||
|
) {
|
||||||
|
let mut node = root;
|
||||||
|
for pattern in sequence.iter() {
|
||||||
|
let child = node.children.entry(*pattern).or_default();
|
||||||
|
if let Some(duration) = timeout {
|
||||||
|
child.timeout = match child.timeout {
|
||||||
|
Some(existing) if existing <= duration => Some(existing),
|
||||||
|
_ => Some(duration),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
node = child;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(existing) = node.command {
|
||||||
|
if existing != command {
|
||||||
|
warn!(
|
||||||
|
"Keymap conflict: multiple commands mapped to sequence {:?}",
|
||||||
|
sequence
|
||||||
|
.iter()
|
||||||
|
.map(|pattern| pattern.display_token())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node.command = Some(command);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_key_token(token: &str, modifiers: &mut KeyModifiers) -> Option<KeyCodeKind> {
|
fn parse_key_token(token: &str, modifiers: &mut KeyModifiers) -> Option<KeyCodeKind> {
|
||||||
@@ -247,7 +556,7 @@ fn parse_key_token(token: &str, modifiers: &mut KeyModifiers) -> Option<KeyCodeK
|
|||||||
fn parse_mode(mode: &str) -> Option<InputMode> {
|
fn parse_mode(mode: &str) -> Option<InputMode> {
|
||||||
match mode.to_ascii_lowercase().as_str() {
|
match mode.to_ascii_lowercase().as_str() {
|
||||||
"normal" => Some(InputMode::Normal),
|
"normal" => Some(InputMode::Normal),
|
||||||
"editing" => Some(InputMode::Editing),
|
"editing" | "insert" => Some(InputMode::Editing),
|
||||||
"command" => Some(InputMode::Command),
|
"command" => Some(InputMode::Command),
|
||||||
"visual" => Some(InputMode::Visual),
|
"visual" => Some(InputMode::Visual),
|
||||||
"provider_selection" | "provider" => Some(InputMode::ProviderSelection),
|
"provider_selection" | "provider" => Some(InputMode::ProviderSelection),
|
||||||
@@ -394,6 +703,13 @@ impl KeymapLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct KeymapBindingDescription {
|
||||||
|
pub mode: InputMode,
|
||||||
|
pub sequence: Vec<String>,
|
||||||
|
pub command: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -402,20 +718,38 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn resolve_binding_from_default_keymap() {
|
fn resolve_binding_from_default_keymap() {
|
||||||
let registry = CommandRegistry::new();
|
let registry = CommandRegistry::new();
|
||||||
assert!(registry.resolve("model.open_all").is_some());
|
|
||||||
let parsed: KeymapConfig = toml::from_str(DEFAULT_KEYMAP).unwrap();
|
|
||||||
assert!(!parsed.bindings.is_empty());
|
|
||||||
let keymap = Keymap::load(None, None, ®istry);
|
let keymap = Keymap::load(None, None, ®istry);
|
||||||
|
let mut state = KeymapState::default();
|
||||||
|
|
||||||
let event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
|
let event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
|
||||||
assert!(
|
let result = keymap.step(InputMode::Normal, &event, &mut state);
|
||||||
!keymap.bindings.is_empty(),
|
assert!(matches!(
|
||||||
"expected default keymap to provide bindings"
|
result,
|
||||||
|
KeymapEventResult::Matched(AppCommand::OpenModelPicker(None))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_multi_key_sequence() {
|
||||||
|
let registry = CommandRegistry::new();
|
||||||
|
let keymap = Keymap::load(None, None, ®istry);
|
||||||
|
let mut state = KeymapState::default();
|
||||||
|
|
||||||
|
let first = keymap.step(
|
||||||
|
InputMode::Normal,
|
||||||
|
&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
|
||||||
|
&mut state,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert!(matches!(first, KeymapEventResult::Pending));
|
||||||
keymap.resolve(InputMode::Normal, &event),
|
assert!(state.matches_sequence(&["g"]));
|
||||||
Some(AppCommand::OpenModelPicker(None))
|
|
||||||
|
let second = keymap.step(
|
||||||
|
InputMode::Normal,
|
||||||
|
&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
|
||||||
|
&mut state,
|
||||||
);
|
);
|
||||||
|
assert!(matches!(second, KeymapEventResult::Matched(_)));
|
||||||
|
assert!(state.sequence_tokens().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -423,13 +757,15 @@ mod tests {
|
|||||||
let registry = CommandRegistry::new();
|
let registry = CommandRegistry::new();
|
||||||
let keymap = Keymap::load(None, Some("emacs"), ®istry);
|
let keymap = Keymap::load(None, Some("emacs"), ®istry);
|
||||||
assert_eq!(keymap.profile(), KeymapProfile::Emacs);
|
assert_eq!(keymap.profile(), KeymapProfile::Emacs);
|
||||||
assert!(
|
let mut state = KeymapState::default();
|
||||||
keymap
|
let result = keymap.step(
|
||||||
.resolve(
|
|
||||||
InputMode::Normal,
|
InputMode::Normal,
|
||||||
&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)
|
&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT),
|
||||||
)
|
&mut state,
|
||||||
.is_some()
|
|
||||||
);
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
KeymapEventResult::Matched(AppCommand::EnterCommandMode)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ pub use file_icons::{FileIconResolver, FileIconSet, IconDetection};
|
|||||||
pub use file_tree::{
|
pub use file_tree::{
|
||||||
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry,
|
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry,
|
||||||
};
|
};
|
||||||
pub use keymap::{Keymap, KeymapProfile};
|
pub use keymap::{Keymap, KeymapBindingDescription, KeymapEventResult, KeymapProfile, KeymapState};
|
||||||
pub use search::{
|
pub use search::{
|
||||||
RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind,
|
RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind,
|
||||||
RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState,
|
RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use owlen_core::state::AutoScroll;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Cardinal direction used for navigating between panes or resizing splits.
|
/// Cardinal direction used for navigating between panes or resizing splits.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum PaneDirection {
|
pub enum PaneDirection {
|
||||||
Left,
|
Left,
|
||||||
Right,
|
Right,
|
||||||
|
|||||||
Reference in New Issue
Block a user