From e71efed007fbe1c6a0ecd20400edcfcc90ea684b Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Mon, 13 Jul 2026 15:28:36 +0800 Subject: [PATCH] feat(keybindings): editable shortcuts, pane/tab actions, tmux preset (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #61's tmux-like input model in three layers — editable shortcuts (Settings → Keybindings), directional pane focus/resize/swap + relative tab nav + Activate Tab 1-9, and a tmux prefix preset — without parsing ~/.tmux.conf and without changing zero-config defaults. Closes #61. --- README.md | 11 +- README.zh-CN.md | 9 +- src/core/actions.rs | 31 +++ src/core/config.rs | 41 ++++ src/terminal/input.rs | 14 +- src/ui/app.rs | 506 +++++++++++++++++++++++++++++++++++++++--- src/ui/keymap.rs | 444 ++++++++++++++++++++++++++++++++---- src/ui/palette.rs | 36 +++ src/ui/pane.rs | 414 ++++++++++++++++++++++++++++++++++ src/ui/settings.rs | 313 ++++++++++++++++++++++---- 10 files changed, 1706 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 794a42aa..a25d2563 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,10 @@ The essentials: | | | |---|---| | ⌘ T · ⌘ W · ⌘ ⇧ T | new tab · close tab · reopen closed tab | +| ⌘ 1⌘ 9 · ⌃ ⇥ · ⌃ ⇧ ⇥ | jump to tab 1–9 · next tab · previous tab | | ⌘ D · ⌘ ⇧ D | split right · split down | | ⌘ ] · ⌘ [ | next pane · previous pane | +| ⌘ ⌥ ←→↑↓ | focus the pane in that direction | | ⌘ ⏎ · ⌘ ⇧ ⏎ | toggle fullscreen · maximize / restore the pane | | ⌘ K | clear the screen and scrollback | | ⌘ P | command palette | @@ -107,7 +109,14 @@ The essentials: | ⌃ R | fuzzy-search shell history | | ⌘ + · ⌘ − · ⌘ 0 | font size up · down · reset | -The full list — and any overrides — lives in **Settings → Keybindings**. +**Settings → Keybindings** lists every shortcut. Click one, press the new keys +(Esc cancels, Backspace resets to default), and it takes +effect immediately. Pane resize and swap have no default keys — bind them here or +run them from the command palette. Prefer tmux muscle memory? Flip the **tmux** +preset to remap pane/tab actions onto a prefix (default ⌃ B): ⌃ B +C opens a tab, ⌃ B % splits, ⌃ B then an +arrow moves focus. A bare prefix reaches the shell after a brief pause, and +`prefix` + an unbound key is passed straight through to the terminal. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index b3793156..debc4d30 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -92,8 +92,10 @@ macOS 26.3.1,取五次运行的平均值(2026-07-04): | | | |---|---| | ⌘ T · ⌘ W · ⌘ ⇧ T | 新建标签页 · 关闭标签页 · 恢复关闭的标签页 | +| ⌘ 1⌘ 9 · ⌃ ⇥ · ⌃ ⇧ ⇥ | 跳到第 1–9 个标签页 · 下一个 · 上一个标签页 | | ⌘ D · ⌘ ⇧ D | 向右分屏 · 向下分屏 | | ⌘ ] · ⌘ [ | 下一个窗格 · 上一个窗格 | +| ⌘ ⌥ ←→↑↓ | 按方向切换焦点窗格 | | ⌘ ⏎ · ⌘ ⇧ ⏎ | 切换全屏 · 最大化 / 还原窗格 | | ⌘ K | 清屏并清空回滚缓冲区 | | ⌘ P | 命令面板 | @@ -101,7 +103,12 @@ macOS 26.3.1,取五次运行的平均值(2026-07-04): | ⌃ R | 模糊搜索 shell 历史 | | ⌘ + · ⌘ − · ⌘ 0 | 字号增大 · 减小 · 重置 | -完整列表(以及你改过的自定义键位)在 **Settings → Keybindings**。 +**Settings → Keybindings** 列出全部快捷键。点一行、按下新键即可(Esc +取消,Backspace 恢复默认),改完立即生效。窗格缩放与交换默认不绑定键 —— +在这里绑定,或从命令面板执行。习惯 tmux?打开 **tmux** 预设,把窗格/标签页操作 +映射到前缀键(默认 ⌃ B):⌃ B C 新建标签页, +⌃ B % 分屏,⌃ B 接方向键切换焦点。单独按前缀键会在 +短暂延迟后送达 shell,`前缀` + 未绑定的键会原样透传给终端。 --- diff --git a/src/core/actions.rs b/src/core/actions.rs index a2b2a3d4..723da0f0 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -14,6 +14,37 @@ actions!( SplitDown, FocusNextPane, FocusPrevPane, + // Directional pane focus (tmux `prefix ←/→/↑/↓`): move focus to the + // adjacent pane in that direction. + FocusPaneLeft, + FocusPaneRight, + FocusPaneUp, + FocusPaneDown, + // Grow (Right/Down) or shrink (Left/Up) the focused pane along the + // matching axis by nudging its nearest enclosing split's ratio. + ResizePaneLeft, + ResizePaneRight, + ResizePaneUp, + ResizePaneDown, + // Swap the focused pane with its next / previous sibling in leaf order + // (tmux `prefix }` / `prefix {`); focus follows the moved pane. + SwapPaneNext, + SwapPanePrev, + // Relative tab navigation (tmux `prefix n` / `prefix p`). + NextTab, + PrevTab, + // Jump straight to tab 1‑9 (⌘/Ctrl+1‑9, tmux `prefix 1‑9`). Unit actions + // rather than one parameterized action so config/Settings can index them + // by name like every other binding. + ActivateTab1, + ActivateTab2, + ActivateTab3, + ActivateTab4, + ActivateTab5, + ActivateTab6, + ActivateTab7, + ActivateTab8, + ActivateTab9, IncreaseFontSize, DecreaseFontSize, ResetFontSize, diff --git a/src/core/config.rs b/src/core/config.rs index 55504d66..6bd8faed 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -47,6 +47,17 @@ pub struct Config { /// actions and unparseable keystrokes are ignored (with a warning) so a bad /// entry never blocks startup. pub keybindings: HashMap, + /// Keybinding preset layered between the built-in defaults and the user's + /// `keybindings` overrides. `"default"` (the default) adds nothing; `"tmux"` + /// remaps pane/tab actions onto `prefix`-led sequences (e.g. `ctrl-b c`). + /// Parsed leniently — an unknown value resolves back to the default preset. + #[serde(default = "default_preset")] + pub keybinding_preset: String, + /// The prefix chord the `tmux` preset builds its sequences from (tmux's + /// `C-b`). Only meaningful when `keybinding_preset` is `"tmux"`. Validated as + /// a gpui keystroke where it's consumed; a common alternative is `ctrl-a`. + #[serde(default = "default_prefix")] + pub prefix: String, /// Optional shell override for the terminals tty7 spawns. When unset (the /// default), the platform's default shell is used: the user's login shell on /// Unix (via `$SHELL`), and PowerShell on Windows (PowerShell 7 when @@ -242,6 +253,8 @@ impl Default for Config { // depend on ui). Unknown ids fall back to it anyway. theme_preset: "light".to_string(), keybindings: HashMap::new(), + keybinding_preset: default_preset(), + prefix: default_prefix(), // `None` → the platform default shell (login shell on Unix, // PowerShell 7 / Windows PowerShell on Windows), chosen by the // daemon at spawn time. @@ -486,6 +499,16 @@ pub fn extra_env() -> HashMap { Config::load().env } +/// Serde default for [`Config::keybinding_preset`]: the no-op `"default"` preset. +fn default_preset() -> String { + "default".to_string() +} + +/// Serde default for [`Config::prefix`]: tmux's classic `C-b`. +fn default_prefix() -> String { + "ctrl-b".to_string() +} + /// Upper bound on `scrollback_limit`. Matches alacritty_terminal's own history /// ceiling — asking for more just wastes memory since the emulator caps there. pub const MAX_SCROLLBACK: usize = 100_000; @@ -681,6 +704,24 @@ mod tests { assert_eq!(clamp(usize::MAX), MAX_SCROLLBACK); // ceiling } + #[test] + fn keybinding_preset_and_prefix_default_and_round_trip() { + // Missing fields fall back to the no-op preset and the tmux-classic prefix. + let cfg = Config::default(); + assert_eq!(cfg.keybinding_preset, "default"); + assert_eq!(cfg.prefix, "ctrl-b"); + + let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert_eq!(cfg.keybinding_preset, "default"); + assert_eq!(cfg.prefix, "ctrl-b"); + + // Explicit values survive a parse. + let cfg: Config = + serde_json::from_str(r#"{"keybinding_preset": "tmux", "prefix": "ctrl-a"}"#).unwrap(); + assert_eq!(cfg.keybinding_preset, "tmux"); + assert_eq!(cfg.prefix, "ctrl-a"); + } + #[test] fn config_deserialize_fills_missing_fields_from_defaults() { // Only one field present; the rest must fall back via #[serde(default)]. diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 88242281..e4fc4bfe 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -522,7 +522,19 @@ impl InputHandler for TerminalInputHandler { None } - fn prefers_ime_for_printable_keys(&mut self, _window: &mut Window, _cx: &mut App) -> bool { + fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, _cx: &mut App) -> bool { + // While a multi-key keybinding is mid-sequence — e.g. the tmux preset's + // `ctrl-b` prefix is held pending — the next key belongs to the keymap, + // not the IME. macOS otherwise diverts printable keys straight to the IME + // when a CJK input source is active (see `query_prefers_ime_for_printable_keys` + // in gpui's macOS backend), so `ctrl-b x` would type an `x` and let the + // prefix time out instead of completing the sequence. Declining IME here + // lets the keystroke reach `dispatch_key` and finish the chord; when no + // sequence is pending this is a no-op, so normal CJK composition is + // unaffected. + if window.has_pending_keystrokes() { + return false; + } // Route printable keys to the IME so CJK composes. Whether the committed // text lands in the terminal or the search query is decided by focus in // `input_text` — so opening the search bar no longer disables CJK input in diff --git a/src/ui/app.rs b/src/ui/app.rs index ca1a697f..1ee943f3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2,8 +2,7 @@ //! with the active terminal filling the rest. Owns all tabs (each its own PTY). use gpui::{ - App, Axis, Context, Entity, KeyDownEvent, PromptLevel, Subscription, Window, div, prelude::*, - px, + App, Axis, Context, Entity, PromptLevel, Subscription, Window, div, prelude::*, px, }; use gpui_component::color_picker::{ColorPickerEvent, ColorPickerState}; use gpui_component::input::{InputEvent, InputState}; @@ -19,9 +18,9 @@ use crate::core::shells::DetectedShell; use crate::daemon::protocol::ShellSpec; use crate::terminal::view::{ChildExited, TerminalView}; use crate::ui::palette::{Command, CommandKind, PaletteEvent, PaletteView}; -use crate::ui::pane::{CloseOutcome, Pane}; +use crate::ui::pane::{CloseOutcome, Dir, Pane}; use crate::ui::presets::Fill; -use crate::ui::settings::{SettingsSection, SettingsState, ThemeEditor}; +use crate::ui::settings::{Recording, SettingsSection, SettingsState, ThemeEditor, humanize_action}; use crate::ui::theme::{apply_theme, set_menus}; /// One editable color of a user theme, targeted by the in-app color editor. Maps @@ -59,6 +58,16 @@ pub(crate) const LINE_HEIGHT_STEP: f32 = 0.05; /// otherwise keep growing without limit. const MAX_CLOSED_TABS: usize = 20; +/// How much one resize step nudges a split's ratio (see `resize_pane`). Matches +/// the divider's clamp band granularity in `pane.rs`. +const RESIZE_STEP: f32 = 0.05; + +/// Quiet window after the last captured chord before a recorded shortcut is +/// committed (see `schedule_recording_commit`). Long enough to type a second +/// chord of a sequence (`ctrl-b x`), short enough that a single chord commits +/// promptly. +const RECORD_COMMIT_DELAY_MS: u64 = 650; + /// One tab: a split-pane tree plus an optional user-assigned name. pub struct Tab { /// The tab's split-pane tree (one or more terminals). For a settings tab @@ -163,6 +172,10 @@ pub struct Tty7App { /// Generation counter for the delayed badge reveal: bumped on every /// modifier transition and keypress so a stale timer can't fire. pub(crate) mod_hint_gen: u64, + /// Generation counter for the keybinding-capture commit timer: bumped on + /// every captured chord, cancel, and start, so a stale pause-to-commit + /// timer can't fire after the sequence changed or capture ended. + record_gen: u64, /// Focus target for the home page (the zero-tab state; see `ui::home`). /// Keeping something focused keeps keystrokes flowing through the window's /// dispatch path, so ⌘T & friends still reach the root action handlers. @@ -259,6 +272,7 @@ impl Tty7App { maximized: None, mod_hint_badges: false, mod_hint_gen: 0, + record_gen: 0, home_focus: cx.focus_handle(), detected_shells: Vec::new(), }; @@ -1049,6 +1063,76 @@ impl Tty7App { cx.notify(); } + /// Move focus to the pane adjacent to the focused one in `dir` (tmux + /// directional focus). A no-op when there's no neighbor that way. + fn focus_pane_dir(&mut self, dir: Dir, window: &mut Window, cx: &mut Context) { + let Some(target) = self + .tabs + .get(self.active) + .and_then(|tab| tab.pane.neighbor_in_dir(dir, window, cx)) + else { + return; + }; + self.maximized = None; + self.focus_leaf(&target, window, cx); + cx.notify(); + } + + /// Grow/shrink the focused pane along `dir` by one step, adjusting its + /// nearest matching-axis split. Persists the new layout. A no-op when no + /// split matches (e.g. a single-pane tab, or no divider on that axis). + fn resize_pane(&mut self, dir: Dir, window: &mut Window, cx: &mut Context) { + let changed = self + .tabs + .get(self.active) + .is_some_and(|tab| tab.pane.resize_focused_pane(dir, RESIZE_STEP, window, cx)); + if changed { + self.save_session(cx); + cx.notify(); + } + } + + /// Swap the focused pane with its next / previous sibling in leaf order + /// (tmux `prefix }` / `prefix {`). The terminals trade tree positions; + /// focus rides along with the moved terminal. Needs at least two panes. + fn swap_pane(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { + let (from, len) = match self.tabs.get(self.active) { + Some(tab) => (tab.pane.focused_index(window, cx), tab.pane.leaves().len()), + None => return, + }; + if len < 2 { + return; + } + let from = from.unwrap_or(0); + let to = if forward { + (from + 1) % len + } else { + (from + len - 1) % len + }; + if let Some(tab) = self.tabs.get_mut(self.active) { + if tab.pane.swap_leaf_indices(from, to) { + self.maximized = None; + self.save_session(cx); + cx.notify(); + } + } + } + + /// Switch to the next / previous tab, wrapping around (tmux `prefix n/p`). + /// A no-op with fewer than two tabs. + fn cycle_tab(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { + let n = self.tabs.len(); + if n < 2 { + return; + } + let next = if forward { + (self.active + 1) % n + } else { + (self.active + n - 1) % n + }; + self.activate(next, window, cx); + } + pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context) { if index < self.tabs.len() && index != self.active { self.maximized = None; @@ -1207,28 +1291,6 @@ impl Tty7App { cx.notify(); } - // Cmd+1‑9 (⌘ on macOS, Ctrl elsewhere) tab switching. New Tab / Close Tab / - // Toggle Theme are bound via the keymap (see `init`) so they share one path - // with the menu bar. - fn on_key_down(&mut self, ev: &KeyDownEvent, window: &mut Window, cx: &mut Context) { - let m = &ev.keystroke.modifiers; - // Use the portable "secondary" modifier so this matches the keymap's - // `secondary-*` bindings. Reject the other platform-ish key (⌃ on macOS, - // Win/Super elsewhere) and Alt so only the bare secondary chord triggers. - let extra_platform = if cfg!(target_os = "macos") { - m.control - } else { - m.platform - }; - if !m.secondary() || m.alt || extra_platform { - return; - } - // Cmd/Ctrl+1..9 → tabs 0..8 (the 0 key has no tab and is ignored). - if let Some(n @ 1..=9) = ev.keystroke.key.chars().next().and_then(|c| c.to_digit(10)) { - self.activate(n as usize - 1, window, cx); - } - } - // ----- Command palette ------------------------------------------------- /// Build the full command catalog: the static commands plus one @@ -1302,6 +1364,18 @@ impl Tty7App { ClosePane => self.close_pane(window, cx), NextPane => self.cycle_pane(true, window, cx), PrevPane => self.cycle_pane(false, window, cx), + FocusPaneLeft => self.focus_pane_dir(Dir::Left, window, cx), + FocusPaneRight => self.focus_pane_dir(Dir::Right, window, cx), + FocusPaneUp => self.focus_pane_dir(Dir::Up, window, cx), + FocusPaneDown => self.focus_pane_dir(Dir::Down, window, cx), + ResizePaneLeft => self.resize_pane(Dir::Left, window, cx), + ResizePaneRight => self.resize_pane(Dir::Right, window, cx), + ResizePaneUp => self.resize_pane(Dir::Up, window, cx), + ResizePaneDown => self.resize_pane(Dir::Down, window, cx), + SwapPaneNext => self.swap_pane(true, window, cx), + SwapPanePrev => self.swap_pane(false, window, cx), + NextTab => self.cycle_tab(true, window, cx), + PrevTab => self.cycle_tab(false, window, cx), ToggleMaximizePane => self.toggle_maximize(window, cx), ToggleFullscreen => window.toggle_fullscreen(), ResetFontSize => self.reset_font_size(cx), @@ -1393,6 +1467,8 @@ impl Tty7App { theme_editor: None, theme_panel_open: false, theme_search, + recording: None, + rebinding_note: None, _subs: subs, }), }); @@ -1828,10 +1904,222 @@ impl Tty7App { .and_then(|t| t.settings.as_mut()) { s.section = target; + // Leaving the Keybindings page abandons any in-progress capture, so + // the interceptor doesn't keep swallowing keys off-screen. + s.recording = None; } cx.notify(); } + // ----- Keybindings editing (Settings → Keybindings) -------------------- + + /// Begin capturing a new shortcut for `action`: install a keystroke + /// interceptor that swallows the next keypress and records it, and stash it + /// on the settings state so it stays active only while recording. Any prior + /// capture is replaced. + pub(crate) fn start_recording_key( + &mut self, + action: String, + _window: &mut Window, + cx: &mut Context, + ) { + // The interceptor fires app-wide *before* keymap dispatch, so a chord + // like ⌘T is captured here instead of opening a new tab. It runs until + // the returned `Subscription` is dropped (capture done / Esc / cancel). + let this = cx.weak_entity(); + let intercept = cx.intercept_keystrokes(move |ev, _window, cx| { + let keystroke = ev.keystroke.clone(); + let _ = this.update(cx, |this, cx| this.on_record_key(&keystroke, cx)); + // Keep the key from also triggering an action / reaching a surface. + cx.stop_propagation(); + }); + self.record_gen = self.record_gen.wrapping_add(1); + if let Some(s) = self.active_settings_mut() { + s.rebinding_note = None; + s.recording = Some(Recording { + action, + chords: Vec::new(), + _intercept: intercept, + }); + } + cx.notify(); + } + + /// Handle a keystroke captured during recording. Esc cancels. Backspace + /// removes the last captured chord, or — with nothing captured yet — resets + /// the action to its default. Any other key appends a chord and (re)starts + /// the pause-to-commit timer, so single chords and sequences (e.g. the tmux + /// preset's `ctrl-b x`) are recorded the same way. + fn on_record_key(&mut self, keystroke: &gpui::Keystroke, cx: &mut Context) { + let Some((action, has_chords)) = self + .active_settings() + .and_then(|s| s.recording.as_ref()) + .map(|r| (r.action.clone(), !r.chords.is_empty())) + else { + return; + }; + match keystroke.key.as_str() { + "escape" => { + self.stop_recording(cx); + return; + } + "backspace" | "delete" => { + if has_chords { + // Edit the sequence: drop the last chord and keep capturing. + if let Some(r) = self.active_settings_mut().and_then(|s| s.recording.as_mut()) { + r.chords.pop(); + } + let still_has = self + .active_settings() + .and_then(|s| s.recording.as_ref()) + .is_some_and(|r| !r.chords.is_empty()); + if still_has { + self.schedule_recording_commit(cx); + } else { + // Nothing left to commit; wait for a fresh keypress. + self.record_gen = self.record_gen.wrapping_add(1); + } + cx.notify(); + } else { + self.stop_recording(cx); + self.reset_keybinding(action, cx); + } + return; + } + _ => {} + } + // A lone modifier press (⌘ held, no key yet) has nothing to bind — keep + // waiting for a real key. + let Some(spec) = crate::ui::keymap::spec_from_keystroke(keystroke) else { + return; + }; + if let Some(r) = self.active_settings_mut().and_then(|s| s.recording.as_mut()) { + r.chords.push(spec); + } + self.schedule_recording_commit(cx); + cx.notify(); + } + + /// (Re)arm the pause-to-commit timer: after a short quiet window with no new + /// chord, the captured sequence is committed. Bumping `record_gen` first + /// invalidates any earlier timer, so only the latest keypress's timer fires. + fn schedule_recording_commit(&mut self, cx: &mut Context) { + self.record_gen = self.record_gen.wrapping_add(1); + let generation = self.record_gen; + cx.spawn(async move |this, cx| { + smol::Timer::after(std::time::Duration::from_millis(RECORD_COMMIT_DELAY_MS)).await; + let _ = this.update(cx, |this, cx| { + if this.record_gen == generation { + this.commit_recording(cx); + } + }); + }) + .detach(); + } + + /// Commit the captured chords (joined into a sequence spec) as the action's + /// override. A no-op if capture ended or nothing was captured. + fn commit_recording(&mut self, cx: &mut Context) { + let Some((action, chords)) = self + .active_settings() + .and_then(|s| s.recording.as_ref()) + .filter(|r| !r.chords.is_empty()) + .map(|r| (r.action.clone(), r.chords.clone())) + else { + return; + }; + self.stop_recording(cx); + self.assign_keybinding(action, chords.join(" "), cx); + } + + /// Drop the active capture (interceptor released, any pending commit timer + /// invalidated) without changing anything. + fn stop_recording(&mut self, cx: &mut Context) { + self.record_gen = self.record_gen.wrapping_add(1); + if let Some(s) = self.active_settings_mut() { + s.recording = None; + } + cx.notify(); + } + + /// Assign `spec` to `action`. If another action already owns that keystroke, + /// unbind it (last-writer-wins would otherwise be order-dependent) and note + /// the takeover so the user can undo it with a reset. + fn assign_keybinding(&mut self, action: String, spec: String, cx: &mut Context) { + // Find the current owner of this exact keystroke, if it isn't `action`. + let displaced = crate::ui::keymap::effective_bindings(cx) + .into_iter() + .find(|(a, k)| *k == spec && *a != action) + .map(|(a, _)| a); + let note = displaced.as_ref().map(|other| { + format!( + "{} took the shortcut from {}, which is now unset.", + humanize_action(&action), + humanize_action(other) + ) + }); + self.update_config(cx, |cfg| { + if let Some(other) = &displaced { + // Explicit empty override = "unbound" (distinct from a reset, + // which would restore that action's default and re-conflict). + cfg.keybindings.insert(other.clone(), String::new()); + } + cfg.keybindings.insert(action, spec); + }); + crate::ui::keymap::rebind(cx); + if let Some(s) = self.active_settings_mut() { + s.rebinding_note = note; + } + cx.notify(); + } + + /// Reset one action to its built-in default (drop its override) and + /// re-install the keymap. + pub(crate) fn reset_keybinding(&mut self, action: String, cx: &mut Context) { + self.update_config(cx, |cfg| { + cfg.keybindings.remove(&action); + }); + crate::ui::keymap::rebind(cx); + if let Some(s) = self.active_settings_mut() { + s.recording = None; + s.rebinding_note = None; + } + cx.notify(); + } + + /// Clear every keybinding override, restoring the full default table. + pub(crate) fn restore_default_keybindings(&mut self, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.keybindings.clear()); + crate::ui::keymap::rebind(cx); + if let Some(s) = self.active_settings_mut() { + s.recording = None; + s.rebinding_note = None; + } + cx.notify(); + } + + /// Switch the keybinding preset ("default" / "tmux") and re-install the + /// keymap so the change is live immediately. + pub(crate) fn set_keybinding_preset(&mut self, preset: &str, cx: &mut Context) { + let preset = preset.to_string(); + self.update_config(cx, |cfg| cfg.keybinding_preset = preset); + crate::ui::keymap::rebind(cx); + if let Some(s) = self.active_settings_mut() { + s.recording = None; + s.rebinding_note = None; + } + cx.notify(); + } + + /// Set the tmux preset's prefix chord (e.g. `ctrl-b` / `ctrl-a`) and + /// re-install the keymap. + pub(crate) fn set_keybinding_prefix(&mut self, prefix: &str, cx: &mut Context) { + let prefix = prefix.to_string(); + self.update_config(cx, |cfg| cfg.prefix = prefix); + crate::ui::keymap::rebind(cx); + cx.notify(); + } + /// Open `config.json` with the OS default handler (Settings → Keybindings). /// A fresh install may never have saved yet, so write the current config /// first — the button must not point at a missing file. @@ -1906,7 +2194,6 @@ impl Render for Tty7App { .flex_col() .bg(cx.theme().background) .text_color(cx.theme().foreground) - .on_key_down(cx.listener(Self::on_key_down)) .on_modifiers_changed(cx.listener(Self::on_modifiers_changed)) .on_action(cx.listener(|this, _: &NewTab, window, cx| this.new_tab(window, cx))) .on_action( @@ -1930,6 +2217,51 @@ impl Render for Tty7App { this.cycle_pane(false, window, cx) }), ) + .on_action(cx.listener(|this, _: &FocusPaneLeft, window, cx| { + this.focus_pane_dir(Dir::Left, window, cx) + })) + .on_action(cx.listener(|this, _: &FocusPaneRight, window, cx| { + this.focus_pane_dir(Dir::Right, window, cx) + })) + .on_action(cx.listener(|this, _: &FocusPaneUp, window, cx| { + this.focus_pane_dir(Dir::Up, window, cx) + })) + .on_action(cx.listener(|this, _: &FocusPaneDown, window, cx| { + this.focus_pane_dir(Dir::Down, window, cx) + })) + .on_action(cx.listener(|this, _: &ResizePaneLeft, window, cx| { + this.resize_pane(Dir::Left, window, cx) + })) + .on_action(cx.listener(|this, _: &ResizePaneRight, window, cx| { + this.resize_pane(Dir::Right, window, cx) + })) + .on_action(cx.listener(|this, _: &ResizePaneUp, window, cx| { + this.resize_pane(Dir::Up, window, cx) + })) + .on_action(cx.listener(|this, _: &ResizePaneDown, window, cx| { + this.resize_pane(Dir::Down, window, cx) + })) + .on_action(cx.listener(|this, _: &SwapPaneNext, window, cx| { + this.swap_pane(true, window, cx) + })) + .on_action(cx.listener(|this, _: &SwapPanePrev, window, cx| { + this.swap_pane(false, window, cx) + })) + .on_action(cx.listener(|this, _: &NextTab, window, cx| { + this.cycle_tab(true, window, cx) + })) + .on_action(cx.listener(|this, _: &PrevTab, window, cx| { + this.cycle_tab(false, window, cx) + })) + .on_action(cx.listener(|this, _: &ActivateTab1, window, cx| this.activate(0, window, cx))) + .on_action(cx.listener(|this, _: &ActivateTab2, window, cx| this.activate(1, window, cx))) + .on_action(cx.listener(|this, _: &ActivateTab3, window, cx| this.activate(2, window, cx))) + .on_action(cx.listener(|this, _: &ActivateTab4, window, cx| this.activate(3, window, cx))) + .on_action(cx.listener(|this, _: &ActivateTab5, window, cx| this.activate(4, window, cx))) + .on_action(cx.listener(|this, _: &ActivateTab6, window, cx| this.activate(5, window, cx))) + .on_action(cx.listener(|this, _: &ActivateTab7, window, cx| this.activate(6, window, cx))) + .on_action(cx.listener(|this, _: &ActivateTab8, window, cx| this.activate(7, window, cx))) + .on_action(cx.listener(|this, _: &ActivateTab9, window, cx| this.activate(8, window, cx))) .on_action(cx.listener(|this, _: &IncreaseFontSize, _window, cx| { this.change_font_size(FONT_SIZE_STEP, cx) })) @@ -2119,3 +2451,123 @@ fn new_terminal( .detach(); view } + +#[cfg(test)] +mod keybinding_gpui_tests { + use crate::core::config::Config; + use crate::core::session::Session; + use crate::ui::app::Tty7App; + use crate::ui::settings::SettingsSection; + use gpui::{TestAppContext, VisualTestContext, WindowHandle}; + + fn harness(cx: &mut TestAppContext) -> (WindowHandle, VisualTestContext) { + // The pause-to-commit is a real `smol::Timer` (off the deterministic + // executor), so waiting on it parks the test thread. + cx.executor().allow_parking(); + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(Config::default()); + crate::ui::keymap::init(cx); + }); + let window = + cx.add_window(|window, cx| Tty7App::with_session(Some(Session::default()), window, cx)); + window + .update(cx, |_, window, _| window.activate_window()) + .unwrap(); + cx.background_executor.run_until_parked(); + let vcx = VisualTestContext::from_window(window.into(), cx); + (window, vcx) + } + + /// Open Settings → Keybindings and begin capturing `action`. + fn begin_capture( + window: &WindowHandle, + vcx: &mut VisualTestContext, + action: &str, + ) { + let action = action.to_string(); + window + .update(vcx, |app, window, cx| { + app.toggle_settings(window, cx); + app.select_settings_section(SettingsSection::Keybindings, cx); + app.start_recording_key(action, window, cx); + }) + .unwrap(); + } + + /// Poll (bounded) until `action` has the expected override in config — the + /// commit fires on a real ~650ms timer. + fn wait_for_binding(vcx: &mut VisualTestContext, action: &str, expected: &str) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + vcx.background_executor.run_until_parked(); + let got = vcx.update(|_, cx| cx.global::().keybindings.get(action).cloned()); + if got.as_deref() == Some(expected) { + return; + } + assert!( + std::time::Instant::now() < deadline, + "binding for {action} never became {expected:?} (last {got:?})" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + + // End-to-end: open Settings → Keybindings, capture a shortcut for New Tab, + // and confirm the recorded keystroke is normalized, persisted to config, and + // the capture ends. This drives the real interceptor path installed by + // `start_recording_key`, not just the pure helpers. + #[gpui::test] + fn recording_a_shortcut_writes_the_override_and_ends_capture(cx: &mut TestAppContext) { + let (window, mut vcx) = harness(cx); + begin_capture(&window, &mut vcx, "NewTab"); + // The platform-primary modifier normalizes to `secondary` on write. + vcx.simulate_keystrokes("secondary-shift-n"); + wait_for_binding(&mut vcx, "NewTab", "secondary-shift-n"); + + let recording = window + .update(&mut vcx, |app, _, _| { + app.active_settings().map(|s| s.recording.is_some()) + }) + .unwrap(); + assert_eq!(recording, Some(false), "capture should end after committing"); + } + + // A two-chord sequence (the tmux-style `ctrl-b x`) records as one binding. + #[gpui::test] + fn recording_a_two_chord_sequence_writes_the_full_spec(cx: &mut TestAppContext) { + let (window, mut vcx) = harness(cx); + begin_capture(&window, &mut vcx, "CloseActiveTab"); + // Two chords in quick succession, then the pause commits the sequence. + // `secondary-b` is used (not a bare `ctrl-b`) so the recorded spec is + // identical on macOS and elsewhere — the primary modifier normalizes to + // `secondary` either way. + vcx.simulate_keystrokes("secondary-b"); + vcx.simulate_keystrokes("x"); + wait_for_binding(&mut vcx, "CloseActiveTab", "secondary-b x"); + } + + // Esc during capture cancels without touching config. + #[gpui::test] + fn escape_cancels_capture_without_writing(cx: &mut TestAppContext) { + let (window, mut vcx) = harness(cx); + window + .update(&mut vcx, |app, window, cx| { + app.toggle_settings(window, cx); + app.select_settings_section(SettingsSection::Keybindings, cx); + app.start_recording_key("NewTab".to_string(), window, cx); + }) + .unwrap(); + vcx.simulate_keystrokes("escape"); + vcx.background_executor.run_until_parked(); + + let stored = vcx.update(|_, cx| cx.global::().keybindings.contains_key("NewTab")); + assert!(!stored, "Esc must not persist a binding"); + let recording = window + .update(&mut vcx, |app, _, _| { + app.active_settings().map(|s| s.recording.is_some()) + }) + .unwrap(); + assert_eq!(recording, Some(false)); + } +} diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 4cf8ec00..3c15be73 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -3,42 +3,26 @@ //! the menu bar, and global actions at startup. Kept separate from the window //! shell so `app.rs` stays focused on tab/pane orchestration. -use gpui::{App, KeyBinding}; +use gpui::{App, Global, KeyBinding, Keystroke, NoAction}; use crate::core::actions::*; use crate::core::config::Config; use crate::terminal::view::ClearScrollback; use crate::ui::theme::set_menus; +/// The set of keystrokes currently installed for app actions, remembered so a +/// later [`rebind`] can neutralize them with `NoAction` bindings instead of +/// clearing the whole keymap (which would also wipe gpui-component's own input / +/// list / menu bindings). Stored as a GPUI global. +#[derive(Default)] +struct BoundKeystrokes(Vec); +impl Global for BoundKeystrokes {} + /// Install the application menu bar, keybindings, and global actions. /// Call once at startup with the app context. pub fn init(cx: &mut App) { - // Start from the built-in defaults, then layer the user's `keybindings` - // overrides on top (remapping an action's key, or adding an entry for an - // action we'll validate below). - let overrides = cx.global::().keybindings.clone(); - let mut effective: Vec<(String, String)> = default_bindings() - .into_iter() - .map(|(a, k)| (a.to_string(), k.to_string())) - .collect(); - for (action, key) in overrides { - match effective.iter_mut().find(|(a, _)| *a == action) { - Some(slot) => slot.1 = key, - None => effective.push((action, key)), - } - } - - let mut bindings = Vec::new(); - for (action, key) in &effective { - if !keystroke_is_valid(key) { - log::warn!("ignoring keybinding for '{action}': invalid keystroke '{key}'"); - continue; - } - match make_binding(action, key) { - Some(b) => bindings.push(b), - None => log::warn!("ignoring keybinding: unknown action '{action}'"), - } - } + let effective = effective_bindings(cx); + let mut bindings = action_bindings(&effective); // `+` arrives as `=`, so keep a fixed `secondary-+` alias for zoom-in // alongside whatever IncreaseFontSize is bound to. bindings.push(KeyBinding::new("secondary-+", IncreaseFontSize, None)); @@ -50,13 +34,80 @@ pub fn init(cx: &mut App) { bindings.push(KeyBinding::new("tab", SendTab, Some("Terminal"))); bindings.push(KeyBinding::new("shift-tab", SendBackTab, Some("Terminal"))); cx.bind_keys(bindings); + cx.set_global(BoundKeystrokes(bound_keystrokes(&effective))); cx.on_action(|_: &Quit, cx: &mut App| cx.quit()); set_menus(cx); } +/// Re-apply keybindings after the effective table changes (an edit in Settings, +/// a preset switch). Appends a `NoAction` binding for every previously-installed +/// keystroke — which suppresses the earlier binding of that keystroke in GPUI's +/// depth-then-index dispatch — then re-adds the current effective bindings, which +/// win because they're added last. The keymap only grows (bounded per process), +/// but we never `clear()` it, so gpui-component's bindings survive untouched. +pub fn rebind(cx: &mut App) { + let previous = cx + .try_global::() + .map(|b| b.0.clone()) + .unwrap_or_default(); + let effective = effective_bindings(cx); + + // Neutralize each old keystroke (global NoAction is enabled in every + // context, so it also suppresses the Terminal-scoped ClearScrollback). + let mut bindings: Vec = previous + .iter() + .filter(|k| keystroke_is_valid(k)) + .map(|k| KeyBinding::new(k, NoAction {}, None)) + .collect(); + bindings.extend(action_bindings(&effective)); + cx.bind_keys(bindings); + cx.set_global(BoundKeystrokes(bound_keystrokes(&effective))); + + // Rebuild the menu bar so its macOS key equivalents track the new keymap. + // AppKit dispatches a menu shortcut (e.g. ⌘W → Close) *before* GPUI's keymap, + // so a stale equivalent would fire the old action even though we suppressed + // its keybinding with `NoAction`. `set_menus` re-resolves each item's + // equivalent from the current keymap (via `bindings_for_action`, which skips + // the suppressed bindings), so a rebound action loses its old ⌘-shortcut and + // gains the new one. + set_menus(cx); +} + +/// Build the `KeyBinding`s for an effective table, skipping unbound rows (empty +/// keystroke) and any that fail validation. +fn action_bindings(effective: &[(String, String)]) -> Vec { + let mut bindings = Vec::new(); + for (action, key) in effective { + if key.is_empty() { + continue; // an action with no assigned key + } + if !keystroke_is_valid(key) { + log::warn!("ignoring keybinding for '{action}': invalid keystroke '{key}'"); + continue; + } + match make_binding(action, key) { + Some(b) => bindings.push(b), + None => log::warn!("ignoring keybinding: unknown action '{action}'"), + } + } + bindings +} + +/// The valid, non-empty keystrokes an effective table actually installs — the +/// list [`rebind`] remembers so it can suppress them on the next change. +fn bound_keystrokes(effective: &[(String, String)]) -> Vec { + effective + .iter() + .filter(|(_, k)| !k.is_empty() && keystroke_is_valid(k)) + .map(|(_, k)| k.clone()) + .collect() +} + /// The built-in action → default-keystroke table. The single source of truth for -/// both the default keymap and the names the user can override in config. +/// the default keymap, the names the user can override, and the rows the Settings +/// list renders. An empty keystroke means "no default key" (bind one in Settings +/// or config); it's shown as "—" and never installed. pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { // `secondary-` is gpui's cross-platform modifier: ⌘ on macOS, Ctrl elsewhere // (see `Keystroke::parse`). Using it keeps the same muscle memory on Windows @@ -68,6 +119,32 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("SplitDown", "secondary-shift-d"), ("FocusNextPane", "secondary-]"), ("FocusPrevPane", "secondary-["), + // Directional pane focus: ⌘⌥ / Ctrl+Alt + arrow. + ("FocusPaneLeft", "secondary-alt-left"), + ("FocusPaneRight", "secondary-alt-right"), + ("FocusPaneUp", "secondary-alt-up"), + ("FocusPaneDown", "secondary-alt-down"), + // Resize / swap have no default chord — they're reachable from the + // command palette and bindable in Settings (and the tmux preset). + ("ResizePaneLeft", ""), + ("ResizePaneRight", ""), + ("ResizePaneUp", ""), + ("ResizePaneDown", ""), + ("SwapPaneNext", ""), + ("SwapPanePrev", ""), + // Relative tab nav. Ctrl+Tab is free of an OS/terminal meaning on the + // platforms we ship; rebind if a given setup disagrees. + ("NextTab", "ctrl-tab"), + ("PrevTab", "ctrl-shift-tab"), + ("ActivateTab1", "secondary-1"), + ("ActivateTab2", "secondary-2"), + ("ActivateTab3", "secondary-3"), + ("ActivateTab4", "secondary-4"), + ("ActivateTab5", "secondary-5"), + ("ActivateTab6", "secondary-6"), + ("ActivateTab7", "secondary-7"), + ("ActivateTab8", "secondary-8"), + ("ActivateTab9", "secondary-9"), ("IncreaseFontSize", "secondary-="), ("DecreaseFontSize", "secondary--"), ("ResetFontSize", "secondary-0"), @@ -85,20 +162,158 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ] } -/// The effective keystroke for an action: the user's override if present, -/// otherwise the built-in default. `None` if the action has no binding at all. -/// Used to surface shortcut hints in the UI (command palette, settings). -pub(crate) fn effective_key(action: &str, cx: &App) -> Option { - if let Some(key) = cx.global::().keybindings.get(action) { - return Some(key.clone()); - } - default_bindings() +/// The full effective action → keystroke table: the built-in defaults, with the +/// active preset layered on top (tmux prefix sequences), then the user's config +/// overrides last. The single source of truth for installing the keymap and for +/// what the Settings list shows. Empty keystrokes (unbound actions) are kept. +pub(crate) fn effective_bindings(cx: &App) -> Vec<(String, String)> { + let cfg = cx.global::(); + let mut effective: Vec<(String, String)> = default_bindings() .into_iter() - .find(|(a, _)| *a == action) - .map(|(_, k)| k.to_string()) + .map(|(a, k)| (a.to_string(), k.to_string())) + .collect(); + // Preset layer: remaps the actions it covers onto prefix-led sequences. + for (action, key) in preset_bindings(&cfg.keybinding_preset, &cfg.prefix) { + set_binding(&mut effective, &action, key); + } + // User overrides win. Unknown action names (typos, stale keys) are ignored. + for (action, key) in &cfg.keybindings { + set_binding(&mut effective, action, key.clone()); + } + effective } -/// Split a keybinding spec ("secondary-shift-d", "secondary--") into display +/// Update the keystroke of an existing action in the effective table. Unknown +/// action names are ignored so a bad preset/override entry can't inject a row. +fn set_binding(effective: &mut [(String, String)], action: &str, key: String) { + if let Some(slot) = effective.iter_mut().find(|(a, _)| a == action) { + slot.1 = key; + } +} + +/// The keybinding overlay a preset contributes: `(action, keystroke)` pairs with +/// the prefix already substituted in. The `default` preset contributes nothing. +fn preset_bindings(preset: &str, prefix: &str) -> Vec<(String, String)> { + match preset { + "tmux" => tmux_preset(prefix), + _ => Vec::new(), + } +} + +/// The tmux-style preset: pane/tab actions mapped onto `prefix key` sequences +/// (e.g. `ctrl-b c` → New Tab). GPUI plays the prefix through to the shell after +/// a 1s timeout if no sequence completes, so a bare prefix still reaches readline. +fn tmux_preset(prefix: &str) -> Vec<(String, String)> { + // `p("c")` → " c". The trailing key can be shifted punctuation + // (`%`, `"`, `{`, `}`): GPUI matches those via the typed key's `key_char`, + // so binding the literal glyph works without spelling out `shift-…`. + let p = |key: &str| format!("{prefix} {key}"); + [ + ("NewTab", p("c")), + ("CloseActiveTab", p("x")), + ("SplitRight", p("%")), + ("SplitDown", p("\"")), + ("FocusPaneLeft", p("left")), + ("FocusPaneRight", p("right")), + ("FocusPaneUp", p("up")), + ("FocusPaneDown", p("down")), + ("ResizePaneLeft", p("ctrl-left")), + ("ResizePaneRight", p("ctrl-right")), + ("ResizePaneUp", p("ctrl-up")), + ("ResizePaneDown", p("ctrl-down")), + ("SwapPanePrev", p("{")), + ("SwapPaneNext", p("}")), + ("ToggleMaximizePane", p("z")), + ("FocusNextPane", p("o")), + ("FocusPrevPane", p(";")), + ("NextTab", p("n")), + ("PrevTab", p("p")), + ("ActivateTab1", p("1")), + ("ActivateTab2", p("2")), + ("ActivateTab3", p("3")), + ("ActivateTab4", p("4")), + ("ActivateTab5", p("5")), + ("ActivateTab6", p("6")), + ("ActivateTab7", p("7")), + ("ActivateTab8", p("8")), + ("ActivateTab9", p("9")), + ] + .into_iter() + .map(|(a, k)| (a.to_string(), k)) + .collect() +} + +/// The effective keystroke for an action, from the merged table. `None` when the +/// action has no binding at all (unbound). Used to surface shortcut hints in the +/// UI (command palette, settings). +pub(crate) fn effective_key(action: &str, cx: &App) -> Option { + effective_bindings(cx) + .into_iter() + .find(|(a, _)| a == action) + .map(|(_, k)| k) + .filter(|k| !k.is_empty()) +} + +/// Serialize a recorded keystroke into a config spec string (the inverse of +/// `Keystroke::parse`), normalizing the platform's primary modifier to the +/// portable `secondary` so a recorded shortcut stays cross-platform. Returns +/// `None` for a lone modifier press (nothing to bind yet). +pub(crate) fn spec_from_keystroke(ks: &Keystroke) -> Option { + // A modifier-only keystroke has one of these as its `key`; there's no real + // key to bind, so keep recording. + if matches!( + ks.key.as_str(), + "shift" | "control" | "alt" | "platform" | "function" | "cmd" | "ctrl" + ) { + return None; + } + let m = &ks.modifiers; + let mut parts: Vec<&str> = Vec::new(); + #[cfg(target_os = "macos")] + { + if m.platform { + parts.push("secondary"); + } + if m.control { + parts.push("ctrl"); + } + } + #[cfg(not(target_os = "macos"))] + { + if m.control { + parts.push("secondary"); + } + if m.platform { + parts.push("cmd"); + } + } + if m.alt { + parts.push("alt"); + } + if m.shift { + parts.push("shift"); + } + if m.function { + parts.push("fn"); + } + let mut spec = String::new(); + for part in parts { + spec.push_str(part); + spec.push('-'); + } + spec.push_str(&ks.key); + Some(spec) +} + +/// Split a keybinding spec into its whitespace-separated chords, each rendered +/// as its own list of display tokens. A single chord ("secondary-t") yields one +/// group; a tmux-style sequence ("ctrl-b n") yields two, so the UI can draw them +/// as distinct keycap clusters (`⌃B` then `N`). +pub(crate) fn key_chords(spec: &str) -> Vec> { + spec.split_whitespace().map(key_tokens).collect() +} + +/// Split one keybinding chord ("secondary-shift-d", "secondary--") into display /// tokens, mapping modifiers to per-platform labels (mac glyphs vs. Windows/Linux /// words). Modifiers always lead; whatever remains is the key itself — which may /// be "-", so we can't simply split on '-'. @@ -186,6 +401,27 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "SplitDown" => KeyBinding::new(keystroke, SplitDown, None), "FocusNextPane" => KeyBinding::new(keystroke, FocusNextPane, None), "FocusPrevPane" => KeyBinding::new(keystroke, FocusPrevPane, None), + "FocusPaneLeft" => KeyBinding::new(keystroke, FocusPaneLeft, None), + "FocusPaneRight" => KeyBinding::new(keystroke, FocusPaneRight, None), + "FocusPaneUp" => KeyBinding::new(keystroke, FocusPaneUp, None), + "FocusPaneDown" => KeyBinding::new(keystroke, FocusPaneDown, None), + "ResizePaneLeft" => KeyBinding::new(keystroke, ResizePaneLeft, None), + "ResizePaneRight" => KeyBinding::new(keystroke, ResizePaneRight, None), + "ResizePaneUp" => KeyBinding::new(keystroke, ResizePaneUp, None), + "ResizePaneDown" => KeyBinding::new(keystroke, ResizePaneDown, None), + "SwapPaneNext" => KeyBinding::new(keystroke, SwapPaneNext, None), + "SwapPanePrev" => KeyBinding::new(keystroke, SwapPanePrev, None), + "NextTab" => KeyBinding::new(keystroke, NextTab, None), + "PrevTab" => KeyBinding::new(keystroke, PrevTab, None), + "ActivateTab1" => KeyBinding::new(keystroke, ActivateTab1, None), + "ActivateTab2" => KeyBinding::new(keystroke, ActivateTab2, None), + "ActivateTab3" => KeyBinding::new(keystroke, ActivateTab3, None), + "ActivateTab4" => KeyBinding::new(keystroke, ActivateTab4, None), + "ActivateTab5" => KeyBinding::new(keystroke, ActivateTab5, None), + "ActivateTab6" => KeyBinding::new(keystroke, ActivateTab6, None), + "ActivateTab7" => KeyBinding::new(keystroke, ActivateTab7, None), + "ActivateTab8" => KeyBinding::new(keystroke, ActivateTab8, None), + "ActivateTab9" => KeyBinding::new(keystroke, ActivateTab9, None), "IncreaseFontSize" => KeyBinding::new(keystroke, IncreaseFontSize, None), "DecreaseFontSize" => KeyBinding::new(keystroke, DecreaseFontSize, None), "ResetFontSize" => KeyBinding::new(keystroke, ResetFontSize, None), @@ -233,4 +469,134 @@ mod tests { assert_eq!(key_tokens("secondary-="), vec![SECONDARY, "="]); assert_eq!(key_tokens("secondary-,"), vec![SECONDARY, ","]); } + + #[test] + fn key_chords_splits_a_sequence_into_keycap_groups() { + // A tmux-style sequence renders as two distinct clusters. + assert_eq!( + key_chords("ctrl-b n"), + vec![vec!["⌃".to_string(), "B".to_string()], vec!["N".to_string()]] + ); + // A single chord is one group. + assert_eq!(key_chords("secondary-t"), vec![vec![SECONDARY, "T"]]); + } + + #[test] + fn every_default_action_has_a_binding_builder_or_is_unbound() { + // Every action the defaults name must be constructible by `make_binding` + // (a missing arm would silently drop the binding), and each default key + // must be empty (unbound) or a valid keystroke. + for (action, key) in default_bindings() { + if !key.is_empty() { + assert!( + keystroke_is_valid(key), + "default keystroke for {action} is invalid: {key:?}" + ); + assert!( + make_binding(action, key).is_some(), + "no make_binding arm for action {action}" + ); + } + } + } + + #[test] + fn tmux_preset_keystrokes_all_parse_and_map_to_actions() { + // Every preset row must produce an installable binding: a parseable + // sequence (including the shifted punctuation `% " { }`) and a known + // action. A silent parse failure would leave the preset key dead. + for (action, key) in tmux_preset("ctrl-b") { + assert!( + keystroke_is_valid(&key), + "tmux preset keystroke for {action} does not parse: {key:?}" + ); + assert!( + make_binding(&action, &key).is_some(), + "tmux preset action {action} has no make_binding arm" + ); + } + } + + #[test] + fn spec_from_keystroke_round_trips_through_parse() { + // A recorded keystroke → spec string → parsed keystroke must be stable, + // and the platform primary modifier must normalize to `secondary`. + for spec in [ + "secondary-t", + "secondary-shift-t", + "secondary-alt-left", + "ctrl-shift-tab", + "secondary--", + ] { + let ks = Keystroke::parse(spec).unwrap(); + let round = spec_from_keystroke(&ks).expect("real key produces a spec"); + let reparsed = Keystroke::parse(&round).unwrap(); + assert_eq!( + (reparsed.modifiers, reparsed.key), + (ks.modifiers, ks.key), + "round trip diverged for {spec}" + ); + } + } + + #[test] + fn spec_from_keystroke_ignores_a_lone_modifier() { + // Parsing "secondary" yields a keystroke whose *key* is the modifier; + // there's nothing to bind yet, so recording keeps waiting. + let ks = Keystroke::parse("secondary").unwrap(); + assert_eq!(spec_from_keystroke(&ks), None); + } +} + +#[cfg(test)] +mod gpui_tests { + use super::*; + use crate::core::config::Config; + use gpui::TestAppContext; + + // Install the keymap for real (init → edit config → rebind) against a live + // `App`. Every effective keystroke goes through `KeyBinding::new`, which + // panics on a bad spec — so this catches a preset/default that only *looks* + // valid, and confirms the three-layer merge (default → tmux preset → user + // override) resolves as expected. + #[gpui::test] + fn init_then_rebind_installs_the_merged_table(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(Config::default()); + init(cx); + + // Turn on the tmux preset and override one action on top of it. + { + let cfg = cx.global_mut::(); + cfg.keybinding_preset = "tmux".to_string(); + cfg.keybindings + .insert("NewTab".to_string(), "secondary-shift-n".to_string()); + } + rebind(cx); + + let eff = effective_bindings(cx); + let key_of = |action: &str| { + eff.iter() + .find(|(a, _)| a == action) + .map(|(_, k)| k.clone()) + .unwrap() + }; + // User override beats the preset's `prefix c` for NewTab. + assert_eq!(key_of("NewTab"), "secondary-shift-n"); + // A preset-only remap surfaces its prefix sequence. + assert_eq!(key_of("SplitRight"), "ctrl-b %"); + // An action the preset doesn't touch keeps its default. + assert_eq!(key_of("TogglePalette"), "secondary-p"); + + // Switching back to the default preset drops the sequences. + cx.global_mut::().keybinding_preset = "default".to_string(); + rebind(cx); + let eff = effective_bindings(cx); + assert_eq!( + eff.iter().find(|(a, _)| a == "SplitRight").map(|(_, k)| k.as_str()), + Some("secondary-d") + ); + }); + } } diff --git a/src/ui/palette.rs b/src/ui/palette.rs index d40fb69f..04fc27a9 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -35,6 +35,18 @@ pub enum CommandKind { ResetFontSize, NextPane, PrevPane, + FocusPaneLeft, + FocusPaneRight, + FocusPaneUp, + FocusPaneDown, + ResizePaneLeft, + ResizePaneRight, + ResizePaneUp, + ResizePaneDown, + SwapPaneNext, + SwapPanePrev, + NextTab, + PrevTab, ToggleMaximizePane, ToggleFullscreen, ClearTerminal, @@ -65,6 +77,18 @@ impl CommandKind { ResetFontSize => "ResetFontSize", NextPane => "FocusNextPane", PrevPane => "FocusPrevPane", + FocusPaneLeft => "FocusPaneLeft", + FocusPaneRight => "FocusPaneRight", + FocusPaneUp => "FocusPaneUp", + FocusPaneDown => "FocusPaneDown", + ResizePaneLeft => "ResizePaneLeft", + ResizePaneRight => "ResizePaneRight", + ResizePaneUp => "ResizePaneUp", + ResizePaneDown => "ResizePaneDown", + SwapPaneNext => "SwapPaneNext", + SwapPanePrev => "SwapPanePrev", + NextTab => "NextTab", + PrevTab => "PrevTab", ToggleMaximizePane => "ToggleMaximizePane", ToggleFullscreen => "ToggleFullscreen", ClearTerminal => "ClearScrollback", @@ -108,6 +132,18 @@ impl Command { Command::new("Close Pane/Tab", ClosePane), Command::new("Next Pane", NextPane), Command::new("Previous Pane", PrevPane), + Command::new("Focus Pane Left", FocusPaneLeft), + Command::new("Focus Pane Right", FocusPaneRight), + Command::new("Focus Pane Up", FocusPaneUp), + Command::new("Focus Pane Down", FocusPaneDown), + Command::new("Resize Pane Left", ResizePaneLeft), + Command::new("Resize Pane Right", ResizePaneRight), + Command::new("Resize Pane Up", ResizePaneUp), + Command::new("Resize Pane Down", ResizePaneDown), + Command::new("Swap Pane Next", SwapPaneNext), + Command::new("Swap Pane Previous", SwapPanePrev), + Command::new("Next Tab", NextTab), + Command::new("Previous Tab", PrevTab), Command::new("Toggle Maximize Pane", ToggleMaximizePane), Command::new("Toggle Fullscreen", ToggleFullscreen), Command::new("Clear", ClearTerminal), diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 631c16b9..48c9b40e 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -40,6 +40,50 @@ pub enum Pane> { Empty, } +/// A direction for pane focus / resize, mapped from the arrow-key actions. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Dir { + Left, + Right, + Up, + Down, +} + +impl Dir { + /// The split axis this direction operates along: Left/Right divide width + /// (a horizontal split), Up/Down divide height (a vertical split). + fn axis(self) -> Axis { + match self { + Dir::Left | Dir::Right => Axis::Horizontal, + Dir::Up | Dir::Down => Axis::Vertical, + } + } + + /// Whether this direction *grows* the focused pane (Right/Down) as opposed + /// to shrinking it (Left/Up). + fn grows(self) -> bool { + matches!(self, Dir::Right | Dir::Down) + } +} + +/// A leaf's normalized rectangle within the tab (the whole tab is the unit +/// square `0,0 → 1,1`). Derived purely from split axes and ratios, so directional +/// focus is a geometry query independent of the actual pixel layout. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +/// Overlap length of two 1-D intervals `[a0, a0+alen)` and `[b0, b0+blen)` +/// (0 when they don't overlap). Used to score how well two panes line up on the +/// axis perpendicular to a move. +fn overlap_1d(a0: f32, alen: f32, b0: f32, blen: f32) -> f32 { + ((a0 + alen).min(b0 + blen) - a0.max(b0)).max(0.0) +} + /// Result of attempting to close the focused leaf. pub enum CloseOutcome { /// No focused leaf in this subtree. @@ -166,6 +210,186 @@ impl Pane { Pane::Empty => CloseOutcome::NotFound, } } + + /// Push a mutable reference to every leaf payload, depth-first (`a` before + /// `b`), matching `leaves()` order. Used by `swap_leaf_indices`. + fn collect_leaves_mut<'a>(&'a mut self, out: &mut Vec<&'a mut L>) { + match self { + Pane::Leaf(v) => out.push(v), + Pane::Split { a, b, .. } => { + a.collect_leaves_mut(out); + b.collect_leaves_mut(out); + } + Pane::Empty => {} + } + } + + /// Swap the payloads of the leaves at ordered indices `i` and `j` (indices + /// into `leaves()`), leaving the tree *structure* untouched — only the two + /// terminals trade places. Returns whether the swap happened (false for + /// `i == j` or an out-of-range index). + pub fn swap_leaf_indices(&mut self, i: usize, j: usize) -> bool { + if i == j { + return false; + } + let mut refs: Vec<&mut L> = Vec::new(); + self.collect_leaves_mut(&mut refs); + let (lo, hi) = (i.min(j), i.max(j)); + if hi >= refs.len() { + return false; + } + // Split so the two `&mut L` come from disjoint slices — the borrow + // checker won't let us index the same slice mutably twice. + let (left, right) = refs.split_at_mut(hi); + std::mem::swap(&mut *left[lo], &mut *right[0]); + true + } + + /// The normalized rectangle of every leaf within the unit-square tab, in + /// `leaves()` order. A horizontal split divides width at its ratio (`a` left, + /// `b` right); a vertical split divides height (`a` top, `b` bottom) — the + /// same geometry `render` lays out with flex. + pub fn leaf_rects(&self) -> Vec<(L, Rect)> { + let mut out = Vec::new(); + self.collect_rects( + Rect { + x: 0.0, + y: 0.0, + w: 1.0, + h: 1.0, + }, + &mut out, + ); + out + } + + fn collect_rects(&self, area: Rect, out: &mut Vec<(L, Rect)>) { + match self { + Pane::Leaf(v) => out.push((v.clone(), area)), + Pane::Split { axis, a, b, ratio, .. } => { + let r = ratio.get().clamp(MIN_RATIO, MAX_RATIO); + match axis { + Axis::Horizontal => { + let aw = area.w * r; + a.collect_rects(Rect { w: aw, ..area }, out); + b.collect_rects( + Rect { + x: area.x + aw, + w: area.w - aw, + ..area + }, + out, + ); + } + Axis::Vertical => { + let ah = area.h * r; + a.collect_rects(Rect { h: ah, ..area }, out); + b.collect_rects( + Rect { + y: area.y + ah, + h: area.h - ah, + ..area + }, + out, + ); + } + } + } + Pane::Empty => {} + } + } + + /// The ordered index of the pane adjacent to leaf `from` in direction `dir`, + /// or `None` at the edge. tmux semantics: among panes whose edge sits on the + /// far side of `from` in that direction and which overlap it on the + /// perpendicular axis, pick the nearest edge, breaking ties by the largest + /// overlap. + pub fn neighbor_in_direction(&self, from: usize, dir: Dir) -> Option { + let rects = self.leaf_rects(); + let f = rects.get(from)?.1; + const EPS: f32 = 1e-4; + let mut best: Option<(usize, f32, f32)> = None; // (index, edge distance, overlap) + for (i, (_, c)) in rects.iter().enumerate() { + if i == from { + continue; + } + let (dist, overlap) = match dir { + Dir::Left => (f.x - (c.x + c.w), overlap_1d(f.y, f.h, c.y, c.h)), + Dir::Right => (c.x - (f.x + f.w), overlap_1d(f.y, f.h, c.y, c.h)), + Dir::Up => (f.y - (c.y + c.h), overlap_1d(f.x, f.w, c.x, c.w)), + Dir::Down => (c.y - (f.y + f.h), overlap_1d(f.x, f.w, c.x, c.w)), + }; + // Must lie in the requested direction (distance ≥ 0) and share some + // perpendicular extent, or it isn't a real neighbor. + if dist < -EPS || overlap <= EPS { + continue; + } + let better = match best { + None => true, + Some((_, bd, bo)) => dist < bd - EPS || (dist <= bd + EPS && overlap > bo + EPS), + }; + if better { + best = Some((i, dist, overlap)); + } + } + best.map(|(i, _, _)| i) + } + + /// Grow or shrink the focused pane along `dir` by `step`, by nudging the + /// ratio of its nearest enclosing split whose axis matches `dir`. `step` + /// grows the focused pane when `dir` is Right/Down and shrinks it when + /// Left/Up, regardless of which side of the split it sits on. Ratios stay + /// clamped to the legal band. Returns whether a matching split was found. + /// Takes `&self`: split ratios live in shared `Cell`s, so no `&mut` needed. + pub fn resize_focused(&self, is_focused: &impl Fn(&L) -> bool, dir: Dir, step: f32) -> bool { + let mut path: Vec<(&Pane, bool)> = Vec::new(); + if !self.focus_path(is_focused, &mut path) { + return false; + } + let target_axis = dir.axis(); + // Nearest enclosing matching-axis split = deepest entry in the path. + for (node, went_a) in path.iter().rev() { + if let Pane::Split { axis, ratio, .. } = node { + if *axis == target_axis { + // ratio is `a`'s share; +step enlarges `a`. Growing the + // focused pane means +step when it's in `a` and we grow, or + // in `b` and we shrink (== moves the divider toward `b`). + let delta = if *went_a == dir.grows() { step } else { -step }; + let r = (ratio.get() + delta).clamp(MIN_RATIO, MAX_RATIO); + ratio.set(r); + return true; + } + } + } + false + } + + /// Record the path of splits from the root down to the focused leaf, each + /// tagged with whether the leaf lies in the split's `a` (true) or `b` + /// (false) child. Returns whether the focused leaf was found. + fn focus_path<'a>( + &'a self, + is_focused: &impl Fn(&L) -> bool, + path: &mut Vec<(&'a Pane, bool)>, + ) -> bool { + match self { + Pane::Leaf(v) => is_focused(v), + Pane::Split { a, b, .. } => { + path.push((self, true)); + if a.focus_path(is_focused, path) { + return true; + } + path.pop(); + path.push((self, false)); + if b.focus_path(is_focused, path) { + return true; + } + path.pop(); + false + } + Pane::Empty => false, + } + } } /// Focus- and render-aware operations on the concrete terminal-view tree. @@ -196,6 +420,42 @@ impl Pane> { self.focused_leaf(window, cx).or_else(|| self.first_leaf()) } + /// The pane adjacent to the focused one in direction `dir`, matched by + /// normalized geometry (tmux directional focus). `None` when nothing is + /// focused or the focused pane is already at that edge. + pub fn neighbor_in_dir( + &self, + dir: Dir, + window: &Window, + cx: &App, + ) -> Option> { + let focused = self.focused_leaf(window, cx)?; + let leaves = self.leaves(); + let from = leaves + .iter() + .position(|l| l.entity_id() == focused.entity_id())?; + let target = self.neighbor_in_direction(from, dir)?; + leaves.get(target).cloned() + } + + /// Resize the focused pane along `dir` by `step` (see the generic + /// `resize_focused`). Returns whether a matching split was adjusted. + pub fn resize_focused_pane(&self, dir: Dir, step: f32, window: &Window, cx: &App) -> bool { + let Some(focused) = self.focused_leaf(window, cx) else { + return false; + }; + self.resize_focused(&|v| v.entity_id() == focused.entity_id(), dir, step) + } + + /// The ordered index of the focused leaf within `leaves()`, if any. Lets the + /// shell pick the swap partner (`index ± 1`) without re-walking the tree. + pub fn focused_index(&self, window: &Window, cx: &App) -> Option { + let focused = self.focused_leaf(window, cx)?; + self.leaves() + .iter() + .position(|l| l.entity_id() == focused.entity_id()) + } + /// Split a specific leaf (matched by entity identity) along `axis`, inserting /// `new` as the second child. The target must be captured *before* creating /// `new`, since constructing a terminal steals window focus. @@ -758,4 +1018,158 @@ mod tests { )); assert!(matches!(pane, Pane::Empty)); } + + /// The rect for leaf `id` in a pane, by value. + fn rect_of(pane: &TestPane, id: u32) -> Rect { + pane.leaf_rects() + .into_iter() + .find(|(v, _)| *v == id) + .map(|(_, r)| r) + .unwrap() + } + + /// Assert two rects match within floating-point tolerance (ratios multiply + /// out to values like 0.39999998, so exact equality is too strict). + fn assert_rect(got: Rect, want: Rect) { + let close = |a: f32, b: f32| (a - b).abs() < 1e-5; + assert!( + close(got.x, want.x) && close(got.y, want.y) && close(got.w, want.w) && close(got.h, want.h), + "rect {got:?} != {want:?}" + ); + } + + // Nested splits with non-even ratios must tile the unit square exactly: + // a horizontal split divides width, a nested vertical split divides its + // child's height, and the pieces stay gap-free and non-overlapping. + #[test] + fn leaf_rects_tile_the_unit_square_with_nested_ratios() { + // [0 |(0.25) [1 /(0.6) 2]] + let pane = TestPane::split_node( + Axis::Horizontal, + 0.25, + Pane::Leaf(0), + TestPane::split_node(Axis::Vertical, 0.6, Pane::Leaf(1), Pane::Leaf(2)), + ); + assert_rect(rect_of(&pane, 0), Rect { x: 0.0, y: 0.0, w: 0.25, h: 1.0 }); + assert_rect(rect_of(&pane, 1), Rect { x: 0.25, y: 0.0, w: 0.75, h: 0.6 }); + assert_rect(rect_of(&pane, 2), Rect { x: 0.25, y: 0.6, w: 0.75, h: 0.4 }); + // Rects come back in leaves() order. + assert_eq!( + pane.leaf_rects().iter().map(|(v, _)| *v).collect::>(), + pane.leaves() + ); + } + + // Directional focus is edge-adjacency: right of 0 is 1, and from 1 the pane + // to the left is 0. A pane with no neighbor in a direction returns None. + #[test] + fn neighbor_in_direction_finds_the_adjacent_pane() { + // [0 | 1] + let mut pane = TestPane::leaf(0); + split(&mut pane, 0, Axis::Horizontal, 1); + let idx = |id: u32| pane.leaves().iter().position(|v| *v == id).unwrap(); + assert_eq!(pane.neighbor_in_direction(idx(0), Dir::Right), Some(idx(1))); + assert_eq!(pane.neighbor_in_direction(idx(1), Dir::Left), Some(idx(0))); + // Nothing above/below in a purely horizontal split. + assert_eq!(pane.neighbor_in_direction(idx(0), Dir::Up), None); + assert_eq!(pane.neighbor_in_direction(idx(1), Dir::Right), None); + } + + // When several panes sit in the requested direction, the one with the + // largest perpendicular overlap wins (tmux's "line up with the cursor"). + #[test] + fn neighbor_in_direction_prefers_the_largest_overlap() { + // Left column is 0 (full height); right column is stacked [1 /(0.7) 2]. + // Moving right from 0 should land on 1 — it covers 70% of the shared + // edge versus 2's 30%. + let pane = TestPane::split_node( + Axis::Horizontal, + 0.5, + Pane::Leaf(0), + TestPane::split_node(Axis::Vertical, 0.7, Pane::Leaf(1), Pane::Leaf(2)), + ); + let idx = |id: u32| pane.leaves().iter().position(|v| *v == id).unwrap(); + assert_eq!(pane.neighbor_in_direction(idx(0), Dir::Right), Some(idx(1))); + } + + // Resize nudges the nearest matching-axis ancestor's ratio and always grows + // the focused pane on Right/Down, whichever side it's on. + #[test] + fn resize_grows_the_focused_pane_from_either_side() { + let build = || TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(0), Pane::Leaf(1)); + let ratio = |p: &TestPane| match p { + Pane::Split { ratio, .. } => ratio.get(), + _ => unreachable!(), + }; + // Focus in `a` (left): Right grows a → ratio up. + let p = build(); + assert!(p.resize_focused(&is(0), Dir::Right, 0.05)); + assert!((ratio(&p) - 0.55).abs() < 1e-6); + // Focus in `b` (right): Right grows b → ratio down. + let p = build(); + assert!(p.resize_focused(&is(1), Dir::Right, 0.05)); + assert!((ratio(&p) - 0.45).abs() < 1e-6); + // Left shrinks the focused pane (focus in a → ratio down). + let p = build(); + assert!(p.resize_focused(&is(0), Dir::Left, 0.05)); + assert!((ratio(&p) - 0.45).abs() < 1e-6); + } + + // A resize whose axis matches no ancestor split is a no-op: a purely + // horizontal split has no vertical divider to move. + #[test] + fn resize_without_a_matching_axis_is_a_noop() { + let pane = TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(0), Pane::Leaf(1)); + assert!(!pane.resize_focused(&is(0), Dir::Up, 0.05)); + assert!(!pane.resize_focused(&is(0), Dir::Down, 0.05)); + // An unfocused/absent target also reports no-op. + assert!(!pane.resize_focused(&is(99), Dir::Right, 0.05)); + } + + // Resize with a nested tree targets the *nearest* enclosing matching-axis + // split, not an outer one of the same axis. + #[test] + fn resize_targets_the_nearest_matching_axis_ancestor() { + // [0 |(0.5) [1 |(0.5) 2]] — two nested horizontal splits. + let pane = TestPane::split_node( + Axis::Horizontal, + 0.5, + Pane::Leaf(0), + TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(1), Pane::Leaf(2)), + ); + assert!(pane.resize_focused(&is(1), Dir::Right, 0.05)); + // Inner split moved; outer untouched. + match &pane { + Pane::Split { ratio, b, .. } => { + assert!((ratio.get() - 0.5).abs() < 1e-6, "outer split must not move"); + match &**b { + Pane::Split { ratio, .. } => { + assert!((ratio.get() - 0.55).abs() < 1e-6, "inner split should grow 1"); + } + _ => unreachable!(), + } + } + _ => unreachable!(), + } + } + + // Swapping two leaves trades their payloads but keeps the tree shape and + // leaf *positions* — only the values at those positions change. + #[test] + fn swap_leaf_indices_trades_payloads_in_place() { + // [[0 / 3] | [1 / 2]] → leaves = [0, 3, 1, 2] + let mut pane = TestPane::leaf(0); + split(&mut pane, 0, Axis::Horizontal, 1); + split(&mut pane, 1, Axis::Vertical, 2); + split(&mut pane, 0, Axis::Vertical, 3); + assert_eq!(pane.leaves(), vec![0, 3, 1, 2]); + // Swap positions 0 and 2 (values 0 and 1). + assert!(pane.swap_leaf_indices(0, 2)); + assert_eq!(pane.leaves(), vec![1, 3, 0, 2]); + assert_well_formed(&pane); + // No-op cases. + assert!(!pane.swap_leaf_indices(1, 1)); + assert!(!pane.swap_leaf_indices(0, 99)); + assert_eq!(pane.leaves(), vec![1, 3, 0, 2]); + } } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 9f596eb0..6eac9261 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -7,7 +7,7 @@ use gpui::{ AnyElement, Context, Div, Entity, FontWeight, Image, ImageFormat, KeyDownEvent, SharedString, - Stateful, Subscription, Window, div, img, prelude::*, px, rgb, + Stateful, Subscription, Window, div, img, prelude::*, px, relative, rgb, }; use gpui_component::Selectable as _; use gpui_component::button::{Button, ButtonGroup, ButtonVariants as _}; @@ -22,7 +22,6 @@ use std::sync::Arc; use crate::core::config::{Config, CursorStyle, NewTabPosition, NotifyMode}; use crate::ui::app::{FONT_SIZE_STEP, LINE_HEIGHT_STEP, ThemeEdit, Tty7App}; -use crate::ui::keymap::default_bindings; use crate::ui::presets; /// Which section of the settings panel is currently selected in the sidebar. @@ -97,9 +96,32 @@ pub(crate) struct SettingsState { pub(crate) theme_panel_open: bool, /// Live filter for the theme picker panel's list. pub(crate) theme_search: Entity, + /// `Some` while a Keybindings row is capturing a new shortcut: the action + /// being rebound plus the live keystroke interceptor that swallows and + /// records the next keypress (see `Tty7App::start_recording_key`). + pub(crate) recording: Option, + /// A transient one-line note under the Keybindings header — e.g. after a + /// captured key was already taken and its previous owner was unbound. + /// Cleared when the next capture starts. + pub(crate) rebinding_note: Option, pub(crate) _subs: Vec, } +/// In-progress capture of a new shortcut for one action (click a Keybindings +/// row). The interceptor lives here so it stays active only while recording; +/// dropping it (capture done / Esc) removes the key swallow. +pub(crate) struct Recording { + /// The action name whose shortcut is being captured. + pub(crate) action: String, + /// The chords captured so far, each a config spec (e.g. `["ctrl-b", "x"]`). + /// A single chord is the common case; more than one records a sequence like + /// the tmux preset's `ctrl-b x`. Committed (joined by spaces) after a short + /// pause with no further keys. + pub(crate) chords: Vec, + /// Keeps the keystroke interceptor alive for the duration of the capture. + pub(crate) _intercept: Subscription, +} + /// Sentinel first row in the bold/italic font pickers meaning "no distinct face /// — reuse the primary family with synthesized emphasis". Chosen to be an /// unlikely real font name. @@ -107,7 +129,7 @@ pub(crate) const FONT_DEFAULT_LABEL: &str = "Default (match primary)"; /// Humanize a CamelCase action name for display: "CloseActiveTab" → "Close /// Active Tab". -fn humanize_action(action: &str) -> String { +pub(crate) fn humanize_action(action: &str) -> String { let mut out = String::new(); for (i, ch) in action.chars().enumerate() { if i > 0 && ch.is_uppercase() { @@ -1048,9 +1070,17 @@ impl Tty7App { let accent = rgb(p.accent); let ansi = |i: usize| rgb(to_u32(p.ansi16[i])); let fg = rgb(p.foreground); - // A "line of code": thin rounded bars, sized like words and tightly - // spaced so the preview reads as real terminal text, not fat pills. - let bar = |w: f32, color: gpui::Rgba| div().h(px(4.)).w(px(w)).rounded(px(1.5)).bg(color); + // A "line of code": thin rounded bars whose widths are *fractions* of the + // preview, so the same shape reads well in the narrow "Current theme" card + // and the wider picker instead of clustering at the left edge. Rows stay + // ragged-right like real terminal text. + let bar = |frac: f32, color: gpui::Rgba| { + div() + .h(px(4.)) + .w(relative(frac)) + .rounded(px(1.5)) + .bg(color) + }; v_flex() .w_full() @@ -1065,26 +1095,26 @@ impl Tty7App { .items_center() .gap_2() .child(div().text_size(px(11.)).text_color(accent).child("❯")) - .child(bar(60., fg)), + .child(bar(0.5, fg)), ) .child( h_flex() .gap_2() - .child(bar(26., ansi(2))) - .child(bar(46., ansi(4))) - .child(bar(16., ansi(3))), + .child(bar(0.2, ansi(2))) + .child(bar(0.36, ansi(4))) + .child(bar(0.12, ansi(3))), ) .child( h_flex() .gap_2() - .child(bar(18., ansi(1))) - .child(bar(52., fg)), + .child(bar(0.14, ansi(1))) + .child(bar(0.44, fg)), ) .child( h_flex() .gap_2() - .child(bar(14., ansi(6))) - .child(bar(38., accent)), + .child(bar(0.1, ansi(6))) + .child(bar(0.32, accent)), ) } @@ -1231,7 +1261,12 @@ impl Tty7App { .gap_1p5() .cursor_pointer() .child( + // Percent width (`w_full` in the preview) only resolves + // against a *definite* parent, so pin the card to the + // panel's content width (300 − px_4 gutters) — same reason + // the search box above is sized explicitly. div() + .w(px(268.)) .rounded_lg() .overflow_hidden() .border_1() @@ -1293,26 +1328,43 @@ impl Tty7App { /// Keybindings section: the effective shortcut list (defaults + overrides). fn render_settings_keybindings(&self, cx: &mut Context) -> AnyElement { - let theme = cx.theme(); - let foreground = theme.foreground; - let border = theme.border; - let kbd_bg = theme.secondary.opacity(0.6); + let (foreground, muted, border, kbd_bg, accent) = { + let t = cx.theme(); + ( + t.foreground, + t.muted_foreground, + t.border, + t.secondary.opacity(0.6), + t.primary, + ) + }; - let cfg = cx.global::(); - let keybindings: Vec<(String, String)> = default_bindings() - .into_iter() - .map(|(action, key)| { - let key = cfg - .keybindings - .get(action) - .cloned() - .unwrap_or_else(|| key.to_string()); - (action.to_string(), key) - }) - .collect(); + // Config-derived state, read into owned values so the `cx` borrow is + // free for `effective_bindings` and the click listeners below. + let (preset, prefix, overridden) = { + let cfg = cx.global::(); + let overridden: std::collections::HashSet = + cfg.keybindings.keys().cloned().collect(); + ( + cfg.keybinding_preset.clone(), + cfg.prefix.clone(), + overridden, + ) + }; + let tmux = preset == "tmux"; + let effective = crate::ui::keymap::effective_bindings(cx); - // A single key glyph rendered as a small keycap, so a shortcut reads like - // keys on a keyboard rather than a run of slashed-together text. + // The row currently capturing a shortcut (action + chords so far), and + // any pending takeover note. + let recording = self + .active_settings() + .and_then(|s| s.recording.as_ref()) + .map(|r| (r.action.clone(), r.chords.clone())); + let note = self + .active_settings() + .and_then(|s| s.rebinding_note.clone()); + + // One key glyph as a small keycap, so a shortcut reads like real keys. let keycap = move |tok: String| { div() .flex() @@ -1330,14 +1382,173 @@ impl Tty7App { .child(tok) }; - let count = keybindings.len(); - let mut list = v_flex(); - for (i, (action, key)) in keybindings.into_iter().enumerate() { + // A preset toggle button, highlighted when active. + let preset_button = |id: &'static str, label: &'static str, value: &'static str, on: bool| { + Button::new(id) + .label(label) + .small() + .selected(on) + .on_click(cx.listener(move |this, _, _w, cx| { + this.set_keybinding_preset(value, cx) + })) + }; + // A prefix choice button (tmux preset only). + let prefix_button = |id: &'static str, label: &'static str, value: &'static str, on: bool| { + Button::new(id) + .label(label) + .small() + .selected(on) + .on_click(cx.listener(move |this, _, _w, cx| { + this.set_keybinding_prefix(value, cx) + })) + }; + + let preset_row = h_flex() + .items_center() + .justify_between() + .py_2() + .child( + v_flex() + .gap_0p5() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child("Preset"), + ) + .child(div().text_xs().text_color(muted).child( + "tmux remaps pane/tab actions onto prefix sequences (e.g. Ctrl-B then C).", + )), + ) + .child( + h_flex() + .gap_1() + .child(preset_button("preset-default", "Default", "default", !tmux)) + .child(preset_button("preset-tmux", "tmux", "tmux", tmux)), + ); + + let prefix_row = h_flex() + .items_center() + .justify_between() + .py_2() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child("Prefix"), + ) + .child( + h_flex() + .gap_1() + .child(prefix_button( + "prefix-ctrl-b", + "Ctrl-B", + "ctrl-b", + prefix == "ctrl-b", + )) + .child(prefix_button( + "prefix-ctrl-a", + "Ctrl-A", + "ctrl-a", + prefix == "ctrl-a", + )), + ); + + let count = effective.len(); + let mut list = v_flex().mt_2(); + for (i, (action, key)) in effective.into_iter().enumerate() { + let is_recording = recording + .as_ref() + .is_some_and(|(a, _)| a == &action); + let is_overridden = overridden.contains(&action); + + // Keycap clusters for a spec: one cluster per whitespace-separated + // chord (a sequence like `ctrl-b x` draws as two clusters), with a + // wider gap between clusters than within one. + let keycaps = |spec: &str| { + h_flex().gap_2().children( + crate::ui::keymap::key_chords(spec) + .into_iter() + .map(|chord| h_flex().gap_1().children(chord.into_iter().map(&keycap))), + ) + }; + + // Right side: the live capture (chords so far + hint), the keycap + // sequence, or "—". + let captured: gpui::AnyElement = if is_recording { + let chords = recording + .as_ref() + .map(|(_, c)| c.clone()) + .unwrap_or_default(); + let row = h_flex().gap_2().items_center(); + let row = if chords.is_empty() { + row.child( + div() + .text_xs() + .text_color(accent) + .child("Press keys…"), + ) + } else { + row.child(keycaps(&chords.join(" "))).child( + div() + .text_xs() + .text_color(muted) + .child("pause to save · Esc"), + ) + }; + row.into_any_element() + } else if key.is_empty() { + div() + .text_sm() + .text_color(muted) + .child("—") + .into_any_element() + } else { + keycaps(&key).into_any_element() + }; + + // The whole right cell is clickable to start capturing this row. + let action_for_click = action.clone(); + let capture = div() + .id(SharedString::from(format!("kb-{action}"))) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded_md() + .cursor_pointer() + .when(is_recording, |d| { + d.border_1().border_color(accent) + }) + .hover(|d| d.bg(kbd_bg)) + .child(captured) + .on_click(cx.listener(move |this, _, window, cx| { + this.start_recording_key(action_for_click.clone(), window, cx) + })); + + let action_for_reset = action.clone(); + let right = h_flex().items_center().gap_1().child(capture).when( + is_overridden, + |r| { + r.child( + Button::new(SharedString::from(format!("reset-{action}"))) + .label("Reset") + .small() + .on_click(cx.listener(move |this, _, _w, cx| { + this.reset_keybinding(action_for_reset.clone(), cx) + })), + ) + }, + ); + list = list.child( h_flex() .items_center() .justify_between() - .py_2p5() + .py_1p5() .when(i + 1 < count, |s| s.border_b_1().border_color(border)) .child( div() @@ -1345,22 +1556,36 @@ impl Tty7App { .text_color(foreground) .child(humanize_action(&action)), ) - .child( - h_flex().gap_1().children( - crate::ui::keymap::key_tokens(&key) - .into_iter() - .map(|t| keycap(t)), - ), - ), + .child(right), ); } v_flex() .child(self.section_intro( "Keyboard Shortcuts", - "Remap keys by editing config.json (restart to apply).", + "Click a shortcut, then press the new keys — it saves after a brief pause. Chain keys for a sequence like Ctrl-B then X. Esc cancels; Backspace removes the last key, or resets to default. Changes apply immediately.", cx, )) + .child(preset_row) + .when(tmux, |v| v.child(prefix_row)) + .when(tmux, |v| { + v.child(div().py_1().text_xs().text_color(muted).child( + "With a prefix active, a bare prefix key reaches the shell after a ~1s pause, and prefix + an unbound key is sent through to the terminal.", + )) + }) + .when_some(note, |v, note| { + v.child(div().py_1().text_xs().text_color(accent).child(note)) + }) + .child( + h_flex().justify_end().py_2().child( + Button::new("kb-restore-all") + .label("Restore all defaults") + .small() + .on_click(cx.listener(|this, _, _w, cx| { + this.restore_default_keybindings(cx) + })), + ), + ) .child(list) .into_any_element() }