mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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 1–9 · 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
@@ -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,`前缀` + 未绑定的键会原样透传给终端。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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+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<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
@@ -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")
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user