feat(keybindings): editable shortcuts, pane/tab actions, tmux preset (#65)

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.
This commit is contained in:
l0ng-ai
2026-07-13 15:28:36 +08:00
committed by GitHub
parent f77d62a918
commit e71efed007
10 changed files with 1706 additions and 113 deletions
+10 -1
View File
@@ -98,8 +98,10 @@ The essentials:
| | |
|---|---|
| <kbd>⌘ T</kbd> · <kbd>⌘ W</kbd> · <kbd>⌘ ⇧ T</kbd> | new tab · close tab · reopen closed tab |
| <kbd>⌘ 1</kbd>…<kbd>⌘ 9</kbd> · <kbd>⌃ ⇥</kbd> · <kbd>⌃ ⇧ ⇥</kbd> | jump to tab 19 · next tab · previous tab |
| <kbd>⌘ D</kbd> · <kbd>⌘ ⇧ D</kbd> | split right · split down |
| <kbd>⌘ ]</kbd> · <kbd>⌘ [</kbd> | next pane · previous pane |
| <kbd>⌘ ⌥ ←→↑↓</kbd> | focus the pane in that direction |
| <kbd>⌘ ⏎</kbd> · <kbd>⌘ ⇧ ⏎</kbd> | toggle fullscreen · maximize / restore the pane |
| <kbd>⌘ K</kbd> | clear the screen and scrollback |
| <kbd>⌘ P</kbd> | command palette |
@@ -107,7 +109,14 @@ The essentials:
| <kbd>⌃ R</kbd> | fuzzy-search shell history |
| <kbd>⌘ +</kbd> · <kbd>⌘ </kbd> · <kbd>⌘ 0</kbd> | 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
(<kbd>Esc</kbd> cancels, <kbd>Backspace</kbd> 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 <kbd>⌃ B</kbd>): <kbd>⌃ B</kbd>
<kbd>C</kbd> opens a tab, <kbd>⌃ B</kbd> <kbd>%</kbd> splits, <kbd>⌃ B</kbd> 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.
---
+8 -1
View File
@@ -92,8 +92,10 @@ macOS 26.3.1,取五次运行的平均值(2026-07-04):
| | |
|---|---|
| <kbd>⌘ T</kbd> · <kbd>⌘ W</kbd> · <kbd>⌘ ⇧ T</kbd> | 新建标签页 · 关闭标签页 · 恢复关闭的标签页 |
| <kbd>⌘ 1</kbd>…<kbd>⌘ 9</kbd> · <kbd>⌃ ⇥</kbd> · <kbd>⌃ ⇧ ⇥</kbd> | 跳到第 1–9 个标签页 · 下一个 · 上一个标签页 |
| <kbd>⌘ D</kbd> · <kbd>⌘ ⇧ D</kbd> | 向右分屏 · 向下分屏 |
| <kbd>⌘ ]</kbd> · <kbd>⌘ [</kbd> | 下一个窗格 · 上一个窗格 |
| <kbd>⌘ ⌥ ←→↑↓</kbd> | 按方向切换焦点窗格 |
| <kbd>⌘ ⏎</kbd> · <kbd>⌘ ⇧ ⏎</kbd> | 切换全屏 · 最大化 / 还原窗格 |
| <kbd>⌘ K</kbd> | 清屏并清空回滚缓冲区 |
| <kbd>⌘ P</kbd> | 命令面板 |
@@ -101,7 +103,12 @@ macOS 26.3.1,取五次运行的平均值(2026-07-04):
| <kbd>⌃ R</kbd> | 模糊搜索 shell 历史 |
| <kbd>⌘ +</kbd> · <kbd>⌘ </kbd> · <kbd>⌘ 0</kbd> | 字号增大 · 减小 · 重置 |
完整列表(以及你改过的自定义键位)在 **Settings → Keybindings**
**Settings → Keybindings** 列出全部快捷键。点一行、按下新键即可(<kbd>Esc</kbd>
取消,<kbd>Backspace</kbd> 恢复默认),改完立即生效。窗格缩放与交换默认不绑定键 ——
在这里绑定,或从命令面板执行。习惯 tmux?打开 **tmux** 预设,把窗格/标签页操作
映射到前缀键(默认 <kbd>⌃ B</kbd>):<kbd>⌃ B</kbd> <kbd>C</kbd> 新建标签页,
<kbd>⌃ B</kbd> <kbd>%</kbd> 分屏,<kbd>⌃ B</kbd> 接方向键切换焦点。单独按前缀键会在
短暂延迟后送达 shell,`前缀` + 未绑定的键会原样透传给终端。
---
+31
View File
@@ -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 19 (⌘/Ctrl+19, tmux `prefix 19`). 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,
+41
View File
@@ -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<String, String>,
/// 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<String, String> {
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)].
+13 -1
View File
@@ -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
+479 -27
View File
@@ -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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
if index < self.tabs.len() && index != self.active {
self.maximized = None;
@@ -1207,28 +1291,6 @@ impl Tty7App {
cx.notify();
}
// Cmd+19 (⌘ 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<Self>) {
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<Self>,
) {
// 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<Self>) {
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>) {
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<Self>) {
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>) {
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<Self>) {
// 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>) {
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>) {
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<Self>) {
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<Self>) {
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<Tty7App>, 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<Tty7App>,
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::<Config>().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::<Config>().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));
}
}
+405 -39
View File
@@ -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<String>);
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::<Config>().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::<BoundKeystrokes>()
.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<KeyBinding> = 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<KeyBinding> {
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<String> {
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<String> {
if let Some(key) = cx.global::<Config>().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::<Config>();
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")` → "<prefix> 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<String> {
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<String> {
// 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<Vec<String>> {
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<KeyBinding> {
"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::<Config>();
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::<Config>().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")
);
});
}
}
+36
View File
@@ -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),
+414
View File
@@ -40,6 +40,50 @@ pub enum Pane<L = Entity<TerminalView>> {
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<L: Clone> Pane<L> {
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<usize> {
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<L>, 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<L>, 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<Entity<TerminalView>> {
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<Entity<TerminalView>> {
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<usize> {
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::<Vec<_>>(),
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]);
}
}
+269 -44
View File
@@ -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<InputState>,
/// `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<Recording>,
/// 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<String>,
pub(crate) _subs: Vec<Subscription>,
}
/// 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<String>,
/// 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<Self>) -> 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::<Config>();
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::<Config>();
let overridden: std::collections::HashSet<String> =
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()
}