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()
}