Zig parses config values (cli/args.zig, Config.zig field parsers, config/string.zig) from raw byte slices, and each field type decides whether it requires UTF-8 (InvalidValue otherwise) or stores bytes. The port escape-encoded raw bytes into Strings at the line reader and let fields parse the escaped text, so fields could observe encoding artifacts; successive audits kept finding such leaks (RV5-RV8). Field parsers now receive the original bytes: text fields validate UTF-8 where Zig does, byte/path fields store bytes (EncodedOs/OsString). This resolves all 9 RV8 findings (docs/rewrite/notes/review-rv8-codex.md), each with a regression test: Windows path conversion fails with BadPathName instead of substituting U+FFFD; Windows process launch uses strict UTF-8 like Zig's Command; the WTF-8 decoder accepts paired- surrogate encodings like Zig; literal U+FDD0 Unicode key triggers; selection-word-chars and clipboard-codepoint-map reject invalid UTF-8; font style / codepoint-map family names use original bytes; C title actions forward bytes; D-Bus new-window/Flatpak conversions match Zig. Design, sweep and validation: docs/rewrite/notes/final-bytes-refactor.md. Also moves the bundled iTerm2-Color-Schemes license out of resources/themes/ so +list-themes no longer lists it as a theme (output is now byte-identical to Ghostty 1.3.1). Done by Codex (gpt-6-astra), reviewed and verified by the orchestrator. Test Plan: - cargo test --workspace --no-fail-fast (3428 passed, exit 0) - just check-macos (both), just check-windows, just check-wasm - +show-config --default --docs, +list-keybinds, +list-actions --docs, +list-themes --plain compared with /usr/bin/ghostty
1086 lines
37 KiB
Rust
1086 lines
37 KiB
Rust
//! Keybinds (port of `Config.Keybinds` from `config/Config.zig`).
|
|
|
|
use std::fmt;
|
|
|
|
use ghostty_input::{
|
|
Key,
|
|
Mods,
|
|
binding::{
|
|
Action,
|
|
AdjustSelection,
|
|
CloseTabMode,
|
|
CopyToClipboard,
|
|
Flags,
|
|
InspectorMode,
|
|
NavigateSearch,
|
|
Set,
|
|
SplitDirection,
|
|
SplitFocusDirection,
|
|
SplitResizeDirection,
|
|
SplitResizeParameter,
|
|
Trigger,
|
|
TriggerKey,
|
|
WriteScreen,
|
|
set::{EntryFormatter as BindingEntryFormatter, Value},
|
|
},
|
|
};
|
|
use ghostty_os::osbytes::EncodedOs;
|
|
|
|
use crate::{
|
|
cli_args::{ArgError, ConfigField},
|
|
formatter::EntryFormatter,
|
|
};
|
|
|
|
/// The most recent binding target for `chain=` additions.
|
|
///
|
|
/// This is intentionally tracked at the Keybinds level so that chains can
|
|
/// apply across table boundaries according to parse order.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub enum ChainTarget {
|
|
#[default]
|
|
Root,
|
|
Table(EncodedOs),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Keybinds {
|
|
pub set: Set,
|
|
|
|
/// Defined key tables. The default key table is always the root "set",
|
|
/// which allows all table names to be available without reservation.
|
|
/// Insertion ordered.
|
|
pub tables: Vec<(EncodedOs, Set)>,
|
|
|
|
/// The most recent binding target for `chain=` additions.
|
|
pub chain_target: ChainTarget,
|
|
}
|
|
|
|
fn mods(ctrl: bool, shift: bool, alt: bool, super_: bool) -> Mods {
|
|
Mods {
|
|
ctrl,
|
|
shift,
|
|
alt,
|
|
super_,
|
|
..Mods::default()
|
|
}
|
|
}
|
|
|
|
fn uni(c: char, mods: Mods) -> Trigger {
|
|
Trigger {
|
|
key: TriggerKey::Unicode(c as u32),
|
|
mods,
|
|
}
|
|
}
|
|
|
|
fn phys(key: Key, mods: Mods) -> Trigger {
|
|
Trigger {
|
|
key: TriggerKey::Physical(key),
|
|
mods,
|
|
}
|
|
}
|
|
|
|
fn performable() -> Flags {
|
|
Flags {
|
|
performable: true,
|
|
..Flags::default()
|
|
}
|
|
}
|
|
|
|
fn to_arg_error(e: ghostty_input::binding::Error) -> ArgError {
|
|
match e {
|
|
ghostty_input::binding::Error::InvalidFormat => ArgError::other("InvalidFormat"),
|
|
ghostty_input::binding::Error::InvalidAction => ArgError::other("InvalidAction"),
|
|
}
|
|
}
|
|
|
|
impl Keybinds {
|
|
/// Reset to the default keybinds (Zig: `init`) for the build target.
|
|
pub fn init(&mut self) {
|
|
self.init_for(ghostty_input::platform::is_darwin());
|
|
}
|
|
|
|
/// Reset to the default keybinds for Darwin (`true`) or non-Darwin
|
|
/// (`false`). Zig selects with `builtin.target.os.tag.isDarwin()` at
|
|
/// compile time; the parameter lets both variants be tested anywhere.
|
|
#[allow(clippy::too_many_lines)]
|
|
pub fn init_for(&mut self, darwin: bool) {
|
|
self.set = Set::default();
|
|
self.tables = Vec::new();
|
|
self.chain_target = ChainTarget::Root;
|
|
|
|
let none = Mods::default();
|
|
let cs = mods(true, true, false, false);
|
|
// `inputpkg.ctrlOrSuper` for the requested platform.
|
|
let cos = |m: Mods| ghostty_input::key::ctrl_or_super_for(m, darwin);
|
|
let set = &mut self.set;
|
|
|
|
// keybinds for opening and reloading config
|
|
set.put(
|
|
uni(',', cos(mods(false, true, false, false))),
|
|
Action::ReloadConfig,
|
|
);
|
|
set.put(uni(',', cos(none)), Action::OpenConfig);
|
|
|
|
{
|
|
set.put(
|
|
phys(Key::Copy, none),
|
|
Action::CopyToClipboard(CopyToClipboard::Mixed),
|
|
);
|
|
set.put(phys(Key::Paste, none), Action::PasteFromClipboard);
|
|
|
|
// On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an
|
|
// alt keybinding for Copy and shift+ins is an alt keybinding for Paste
|
|
//
|
|
// The order of these blocks is important. The *last* added keybind for a given action is
|
|
// what will display in the menu. We want the more typical keybinds after this block to be
|
|
// the standard
|
|
if !darwin {
|
|
set.put(
|
|
phys(Key::Insert, mods(true, false, false, false)),
|
|
Action::CopyToClipboard(CopyToClipboard::Mixed),
|
|
);
|
|
set.put(
|
|
phys(Key::Insert, mods(false, true, false, false)),
|
|
Action::PasteFromClipboard,
|
|
);
|
|
}
|
|
|
|
// On macOS we default to super but Linux ctrl+shift since
|
|
// ctrl+c is to kill the process.
|
|
let m = if darwin {
|
|
mods(false, false, false, true)
|
|
} else {
|
|
cs
|
|
};
|
|
set.put_flags(
|
|
uni('c', m),
|
|
Action::CopyToClipboard(CopyToClipboard::Mixed),
|
|
performable(),
|
|
);
|
|
set.put_flags(uni('v', m), Action::PasteFromClipboard, performable());
|
|
}
|
|
|
|
// Increase font size mapping for keyboards with dedicated plus keys (like german)
|
|
// Note: this order matters below because the C API will only return
|
|
// the last keybinding for a given action. The macOS app uses this to
|
|
// set the expected keybind for the menu.
|
|
set.put(uni('=', cos(none)), Action::IncreaseFontSize(1.0));
|
|
set.put(uni('+', cos(none)), Action::IncreaseFontSize(1.0));
|
|
set.put(uni('-', cos(none)), Action::DecreaseFontSize(1.0));
|
|
set.put(uni('0', cos(none)), Action::ResetFontSize);
|
|
|
|
set.put(
|
|
uni('j', mods(true, true, false, true)),
|
|
Action::WriteScreenFile(WriteScreen::COPY),
|
|
);
|
|
set.put(
|
|
uni('j', cos(mods(false, true, false, false))),
|
|
Action::WriteScreenFile(WriteScreen::PASTE),
|
|
);
|
|
set.put(
|
|
uni('j', cos(mods(false, true, true, false))),
|
|
Action::WriteScreenFile(WriteScreen::OPEN),
|
|
);
|
|
|
|
// Expand Selection
|
|
let shift = mods(false, true, false, false);
|
|
for (k, a) in [
|
|
(Key::ArrowLeft, AdjustSelection::Left),
|
|
(Key::ArrowRight, AdjustSelection::Right),
|
|
(Key::ArrowUp, AdjustSelection::Up),
|
|
(Key::ArrowDown, AdjustSelection::Down),
|
|
(Key::PageUp, AdjustSelection::PageUp),
|
|
(Key::PageDown, AdjustSelection::PageDown),
|
|
(Key::Home, AdjustSelection::Home),
|
|
(Key::End, AdjustSelection::End),
|
|
] {
|
|
set.put_flags(phys(k, shift), Action::AdjustSelection(a), performable());
|
|
}
|
|
|
|
// Tabs common to all platforms
|
|
let ctrl = mods(true, false, false, false);
|
|
set.put(phys(Key::Tab, cs), Action::PreviousTab);
|
|
set.put(phys(Key::Tab, ctrl), Action::NextTab);
|
|
|
|
// Windowing
|
|
if !darwin {
|
|
set.put(uni('n', cs), Action::NewWindow);
|
|
set.put(uni('w', cs), Action::CloseSurface);
|
|
set.put(uni('q', cs), Action::Quit);
|
|
set.put(
|
|
phys(Key::F4, mods(false, false, true, false)),
|
|
Action::CloseWindow,
|
|
);
|
|
set.put(uni('t', cs), Action::NewTab);
|
|
set.put(uni('w', cs), Action::CloseTab(CloseTabMode::This));
|
|
set.put_flags(phys(Key::ArrowLeft, cs), Action::PreviousTab, performable());
|
|
set.put_flags(phys(Key::ArrowRight, cs), Action::NextTab, performable());
|
|
set.put_flags(phys(Key::PageUp, ctrl), Action::PreviousTab, performable());
|
|
set.put_flags(phys(Key::PageDown, ctrl), Action::NextTab, performable());
|
|
set.put(uni('o', cs), Action::NewSplit(SplitDirection::Right));
|
|
set.put(uni('e', cs), Action::NewSplit(SplitDirection::Down));
|
|
let ctrl_super = mods(true, false, false, true);
|
|
set.put_flags(
|
|
uni('[', ctrl_super),
|
|
Action::GotoSplit(SplitFocusDirection::Previous),
|
|
performable(),
|
|
);
|
|
set.put_flags(
|
|
uni(']', ctrl_super),
|
|
Action::GotoSplit(SplitFocusDirection::Next),
|
|
performable(),
|
|
);
|
|
let ctrl_alt = mods(true, false, true, false);
|
|
for (k, d) in [
|
|
(Key::ArrowUp, SplitFocusDirection::Up),
|
|
(Key::ArrowDown, SplitFocusDirection::Down),
|
|
(Key::ArrowLeft, SplitFocusDirection::Left),
|
|
(Key::ArrowRight, SplitFocusDirection::Right),
|
|
] {
|
|
set.put_flags(phys(k, ctrl_alt), Action::GotoSplit(d), performable());
|
|
}
|
|
|
|
// Resizing splits
|
|
let super_ctrl_shift = mods(true, true, false, true);
|
|
for (k, d) in [
|
|
(Key::ArrowUp, SplitResizeDirection::Up),
|
|
(Key::ArrowDown, SplitResizeDirection::Down),
|
|
(Key::ArrowLeft, SplitResizeDirection::Left),
|
|
(Key::ArrowRight, SplitResizeDirection::Right),
|
|
] {
|
|
set.put_flags(
|
|
phys(k, super_ctrl_shift),
|
|
Action::ResizeSplit(SplitResizeParameter(d, 10)),
|
|
performable(),
|
|
);
|
|
}
|
|
|
|
// Viewport scrolling
|
|
set.put(phys(Key::Home, shift), Action::ScrollToTop);
|
|
set.put(phys(Key::End, shift), Action::ScrollToBottom);
|
|
set.put(phys(Key::PageUp, shift), Action::ScrollPageUp);
|
|
set.put(phys(Key::PageDown, shift), Action::ScrollPageDown);
|
|
|
|
// Semantic prompts
|
|
set.put(phys(Key::PageUp, cs), Action::JumpToPrompt(-1));
|
|
set.put(phys(Key::PageDown, cs), Action::JumpToPrompt(1));
|
|
|
|
// Search
|
|
set.put_flags(uni('f', cs), Action::StartSearch, performable());
|
|
set.put_flags(phys(Key::Escape, none), Action::EndSearch, performable());
|
|
|
|
// Inspector, matching Chromium
|
|
set.put(uni('i', cs), Action::Inspector(InspectorMode::Toggle));
|
|
|
|
// Terminal
|
|
set.put(uni('a', cs), Action::SelectAll);
|
|
|
|
// Selection clipboard paste
|
|
set.put(phys(Key::Insert, shift), Action::PasteFromSelection);
|
|
}
|
|
{
|
|
// On macOS we default to super but everywhere else
|
|
// is alt.
|
|
let m = if darwin {
|
|
mods(false, false, false, true)
|
|
} else {
|
|
mods(false, false, true, false)
|
|
};
|
|
|
|
// Cmd/Alt+N for goto tab N
|
|
let digits = [
|
|
Key::Digit1,
|
|
Key::Digit2,
|
|
Key::Digit3,
|
|
Key::Digit4,
|
|
Key::Digit5,
|
|
Key::Digit6,
|
|
Key::Digit7,
|
|
Key::Digit8,
|
|
];
|
|
// On macOS we keep these not performable so that the keyboard
|
|
// shortcuts in tabs work. In the future the correct fix is to
|
|
// fix the reverse mapping lookup to allow us to lookup
|
|
// performable keybinds conditionally.
|
|
let tab_flags = Flags {
|
|
performable: !darwin,
|
|
..Flags::default()
|
|
};
|
|
for (i, key) in digits.into_iter().enumerate() {
|
|
let n = i + 1;
|
|
let c = char::from(b'0' + n as u8);
|
|
// We register BOTH the physical `digit_N` key and the unicode
|
|
// `N` key. This allows most keyboard layouts to work with
|
|
// this shortcut. Namely, AZERTY doesn't produce unicode `N`
|
|
// for their digit keys (they're on shifted keys on the same
|
|
// physical keys).
|
|
set.put_flags(phys(key, m), Action::GotoTab(n), tab_flags);
|
|
// Important: this must be the LAST binding set so that the
|
|
// libghostty trigger API returns this one for the action,
|
|
// so that things like the macOS tab bar key equivalent label
|
|
// work properly.
|
|
set.put_flags(uni(c, m), Action::GotoTab(n), tab_flags);
|
|
}
|
|
// See comment above with the numeric goto_tab
|
|
set.put_flags(uni('9', m), Action::LastTab, tab_flags);
|
|
}
|
|
|
|
// Toggle fullscreen
|
|
set.put(phys(Key::Enter, cos(none)), Action::ToggleFullscreen);
|
|
|
|
// Toggle zoom a split
|
|
set.put(phys(Key::Enter, cos(shift)), Action::ToggleSplitZoom);
|
|
|
|
// Toggle command palette, matches VSCode
|
|
set.put(uni('p', cos(shift)), Action::ToggleCommandPalette);
|
|
|
|
// Mac-specific keyboard bindings.
|
|
if darwin {
|
|
Self::init_mac_bindings(set);
|
|
}
|
|
}
|
|
|
|
/// The Mac-specific default keybinds (tail of Zig `Keybinds.init`).
|
|
#[allow(clippy::too_many_lines)]
|
|
fn init_mac_bindings(set: &mut Set) {
|
|
let sup = mods(false, false, false, true);
|
|
let sup_shift = mods(false, true, false, true);
|
|
let sup_alt = mods(false, false, true, true);
|
|
let sup_ctrl = mods(true, false, false, true);
|
|
|
|
set.put(uni('q', sup), Action::Quit);
|
|
set.put_flags(uni('k', sup), Action::ClearScreen, performable());
|
|
set.put(uni('a', sup), Action::SelectAll);
|
|
|
|
// Undo/redo
|
|
set.put_flags(uni('t', sup_shift), Action::Undo, performable());
|
|
set.put_flags(uni('z', sup), Action::Undo, performable());
|
|
set.put_flags(uni('z', sup_shift), Action::Redo, performable());
|
|
|
|
// Viewport scrolling
|
|
set.put(phys(Key::Home, sup), Action::ScrollToTop);
|
|
set.put(phys(Key::End, sup), Action::ScrollToBottom);
|
|
set.put(phys(Key::PageUp, sup), Action::ScrollPageUp);
|
|
set.put(phys(Key::PageDown, sup), Action::ScrollPageDown);
|
|
set.put_flags(uni('j', sup), Action::ScrollToSelection, performable());
|
|
|
|
// Semantic prompts
|
|
set.put(phys(Key::ArrowUp, sup_shift), Action::JumpToPrompt(-1));
|
|
set.put(phys(Key::ArrowDown, sup_shift), Action::JumpToPrompt(1));
|
|
|
|
// Mac windowing
|
|
set.put(uni('n', sup), Action::NewWindow);
|
|
set.put(uni('w', sup), Action::CloseSurface);
|
|
set.put(uni('w', sup_alt), Action::CloseTab(CloseTabMode::This));
|
|
set.put(uni('w', sup_shift), Action::CloseWindow);
|
|
set.put(
|
|
uni('w', mods(false, true, true, true)),
|
|
Action::CloseAllWindows,
|
|
);
|
|
set.put(uni('t', sup), Action::NewTab);
|
|
set.put(uni('[', sup_shift), Action::PreviousTab);
|
|
set.put(uni(']', sup_shift), Action::NextTab);
|
|
set.put(uni('d', sup), Action::NewSplit(SplitDirection::Right));
|
|
set.put(uni('d', sup_shift), Action::NewSplit(SplitDirection::Down));
|
|
set.put(
|
|
uni('[', sup),
|
|
Action::GotoSplit(SplitFocusDirection::Previous),
|
|
);
|
|
set.put(uni(']', sup), Action::GotoSplit(SplitFocusDirection::Next));
|
|
for (k, d) in [
|
|
(Key::ArrowUp, SplitFocusDirection::Up),
|
|
(Key::ArrowDown, SplitFocusDirection::Down),
|
|
(Key::ArrowLeft, SplitFocusDirection::Left),
|
|
(Key::ArrowRight, SplitFocusDirection::Right),
|
|
] {
|
|
set.put(phys(k, sup_alt), Action::GotoSplit(d));
|
|
}
|
|
for (k, d) in [
|
|
(Key::ArrowUp, SplitResizeDirection::Up),
|
|
(Key::ArrowDown, SplitResizeDirection::Down),
|
|
(Key::ArrowLeft, SplitResizeDirection::Left),
|
|
(Key::ArrowRight, SplitResizeDirection::Right),
|
|
] {
|
|
set.put(
|
|
phys(k, sup_ctrl),
|
|
Action::ResizeSplit(SplitResizeParameter(d, 10)),
|
|
);
|
|
}
|
|
set.put(uni('=', sup_ctrl), Action::EqualizeSplits);
|
|
|
|
// Jump to prompt, matches Terminal.app
|
|
set.put(phys(Key::ArrowUp, sup), Action::JumpToPrompt(-1));
|
|
set.put(phys(Key::ArrowDown, sup), Action::JumpToPrompt(1));
|
|
|
|
// Search
|
|
set.put_flags(uni('f', sup), Action::StartSearch, performable());
|
|
set.put_flags(uni('e', sup), Action::SearchSelection, performable());
|
|
set.put_flags(uni('f', sup_shift), Action::EndSearch, performable());
|
|
set.put_flags(
|
|
phys(Key::Escape, Mods::default()),
|
|
Action::EndSearch,
|
|
performable(),
|
|
);
|
|
set.put_flags(
|
|
uni('g', sup),
|
|
Action::NavigateSearch(NavigateSearch::Next),
|
|
performable(),
|
|
);
|
|
set.put_flags(
|
|
uni('g', sup_shift),
|
|
Action::NavigateSearch(NavigateSearch::Previous),
|
|
performable(),
|
|
);
|
|
|
|
// Inspector, matching Chromium
|
|
set.put(uni('i', sup_alt), Action::Inspector(InspectorMode::Toggle));
|
|
|
|
// Alternate keybind, common to Mac programs
|
|
set.put(uni('f', sup_ctrl), Action::ToggleFullscreen);
|
|
|
|
// Selection clipboard paste, matches Terminal.app
|
|
set.put(uni('v', sup_shift), Action::PasteFromSelection);
|
|
|
|
// "Natural text editing" keybinds. This forces these keys to go back
|
|
// to legacy encoding (not fixterms). It seems macOS users more than
|
|
// others are used to these keys so we set them as defaults. If
|
|
// people want to get back to the fixterm encoding they can set
|
|
// the keybinds to `unbind`.
|
|
set.put(phys(Key::ArrowRight, sup), Action::Text("\\x05".into()));
|
|
set.put(phys(Key::ArrowLeft, sup), Action::Text("\\x01".into()));
|
|
set.put(phys(Key::Backspace, sup), Action::Text("\\x15".into()));
|
|
set.put(
|
|
phys(Key::ArrowLeft, mods(false, false, true, false)),
|
|
Action::Esc("b".into()),
|
|
);
|
|
set.put(
|
|
phys(Key::ArrowRight, mods(false, false, true, false)),
|
|
Action::Esc("f".into()),
|
|
);
|
|
}
|
|
|
|
fn table_index(&self, name: &EncodedOs) -> Option<usize> {
|
|
self.tables.iter().position(|(n, _)| n == name)
|
|
}
|
|
|
|
/// Get a table by name.
|
|
#[must_use]
|
|
pub fn table(&self, name: &str) -> Option<&Set> {
|
|
self.tables
|
|
.iter()
|
|
.find(|(n, _)| n == &EncodedOs::from_plain_text(name))
|
|
.map(|(_, s)| s)
|
|
}
|
|
|
|
/// Like format_entry but has an option to include docs.
|
|
pub fn format_entry_docs(&self, f: &mut EntryFormatter<'_>, docs: bool) -> fmt::Result {
|
|
if self.set.bindings.is_empty() && self.tables.is_empty() {
|
|
return writeln!(f.writer, "{} = ", f.name);
|
|
}
|
|
|
|
// Format root set bindings
|
|
for (k, v) in &self.set.bindings {
|
|
if docs {
|
|
// Zig looks up help by `@tagName(v)` of the set value
|
|
// (leader/leaf/leaf_chained), which never matches a keybind
|
|
// action name, so only the blank line is ever written.
|
|
f.writer.write_str("\n")?;
|
|
}
|
|
|
|
let mut buf = ghostty_os::osbytes::valid_str_to_config_string(&k.0.to_string());
|
|
emit(v, &mut buf, f)?;
|
|
}
|
|
|
|
// Format table bindings
|
|
for (table_name, table_set) in &self.tables {
|
|
for (k, v) in &table_set.bindings {
|
|
let mut buf = format!(
|
|
"{}/{}",
|
|
table_name.as_encoded(),
|
|
ghostty_os::osbytes::valid_str_to_config_string(&k.0.to_string())
|
|
);
|
|
emit(v, &mut buf, f)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct Adapter<'a, 'b> {
|
|
f: &'a mut EntryFormatter<'b>,
|
|
result: fmt::Result,
|
|
}
|
|
|
|
impl BindingEntryFormatter for Adapter<'_, '_> {
|
|
fn format_entry(&mut self, value: &str) {
|
|
if self.result.is_ok() {
|
|
self.result = self
|
|
.f
|
|
.format_entry(&ghostty_os::osbytes::EncodedOs::from_encoded(value));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn emit(v: &Value, buf: &mut String, f: &mut EntryFormatter<'_>) -> fmt::Result {
|
|
let mut adapter = Adapter { f, result: Ok(()) };
|
|
v.format_entries(buf, &mut adapter);
|
|
adapter.result
|
|
}
|
|
|
|
fn equal_set(a: &Set, b: &Set) -> bool {
|
|
// Two keybinds are considered equal if their primary bindings
|
|
// are the same. We don't compare reverse mappings and such.
|
|
if a.bindings.len() != b.bindings.len() {
|
|
return false;
|
|
}
|
|
|
|
for (k, av) in &a.bindings {
|
|
let Some(bv) = b.bindings.get(k) else {
|
|
return false;
|
|
};
|
|
match (av, bv) {
|
|
(Value::Leader(x), Value::Leader(y)) => {
|
|
if !equal_set(x, y) {
|
|
return false;
|
|
}
|
|
}
|
|
(Value::Leaf(x), Value::Leaf(y)) => {
|
|
if x != y {
|
|
return false;
|
|
}
|
|
}
|
|
(Value::LeafChained(x), Value::LeafChained(y)) => {
|
|
if x.flags != y.flags || x.actions != y.actions {
|
|
return false;
|
|
}
|
|
}
|
|
_ => return false,
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
impl PartialEq for Keybinds {
|
|
/// Compare if two of our value are equal (Zig: `equal`).
|
|
fn eq(&self, other: &Self) -> bool {
|
|
if !equal_set(&self.set, &other.set) {
|
|
return false;
|
|
}
|
|
|
|
// Compare tables
|
|
if self.tables.len() != other.tables.len() {
|
|
return false;
|
|
}
|
|
for (name, set) in &self.tables {
|
|
let Some(other_set) = other
|
|
.tables
|
|
.iter()
|
|
.find(|(n, _)| n == name)
|
|
.map(|(_, set)| set)
|
|
else {
|
|
return false;
|
|
};
|
|
if !equal_set(set, other_set) {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
}
|
|
|
|
impl ConfigField for Keybinds {
|
|
fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
|
|
let value = input.ok_or(ArgError::ValueRequired)?;
|
|
|
|
// Check for special values
|
|
if value.is_empty() {
|
|
log::info!("config has 'keybind =', using default keybinds");
|
|
self.init();
|
|
return Ok(());
|
|
}
|
|
|
|
if value == b"clear" {
|
|
log::info!("config has 'keybind = clear', all keybinds cleared");
|
|
self.set = Set::default();
|
|
self.tables = Vec::new();
|
|
self.chain_target = ChainTarget::Root;
|
|
return Ok(());
|
|
}
|
|
|
|
// Check for table syntax: "name/" or "name/binding"
|
|
// We look for '/' only before the first '=' to avoid matching
|
|
// action arguments like "foo=text:/hello".
|
|
let eq_idx = value.iter().position(|b| *b == b'=').unwrap_or(value.len());
|
|
if let Some(slash_idx) = value[..eq_idx].iter().position(|b| *b == b'/') {
|
|
let table_name = &value[..slash_idx];
|
|
|
|
// Length zero is valid, so you can set `/=action` for the slash key.
|
|
// Ignore '+', '>' because they can be part of sequences and
|
|
// triggers. This lets things like `ctrl+/=action` work.
|
|
if !table_name.is_empty() && !table_name.iter().any(|b| matches!(b, b'+' | b'>')) {
|
|
let binding = &value[slash_idx + 1..];
|
|
let table_name = EncodedOs::from_bytes(table_name);
|
|
|
|
// Get or create the table
|
|
let idx = if let Some(i) = self.table_index(&table_name) {
|
|
i
|
|
} else {
|
|
self.tables.push((table_name.clone(), Set::default()));
|
|
self.tables.len() - 1
|
|
};
|
|
|
|
// If there's no binding after the slash, this is a table
|
|
// definition/clear command
|
|
if binding.is_empty() {
|
|
log::debug!("config has 'keybind = {table_name:?}/', table cleared");
|
|
self.tables[idx].1 = Set::default();
|
|
self.chain_target = ChainTarget::Root;
|
|
return Ok(());
|
|
}
|
|
|
|
// Chains are only allowed at the root level. Their target is
|
|
// tracked globally by parse order in `self.chain_target`.
|
|
if binding.starts_with(b"chain=") {
|
|
return Err(ArgError::other("InvalidFormat"));
|
|
}
|
|
|
|
// Parse and add the binding to the table
|
|
self.tables[idx]
|
|
.1
|
|
.parse_and_put_bytes(binding)
|
|
.map_err(to_arg_error)?;
|
|
self.chain_target = ChainTarget::Table(table_name.clone());
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
if value.starts_with(b"chain=") {
|
|
match self.chain_target.clone() {
|
|
ChainTarget::Root => self.set.parse_and_put_bytes(value).map_err(to_arg_error)?,
|
|
ChainTarget::Table(table_name) => {
|
|
let Some(idx) = self.table_index(&table_name) else {
|
|
self.chain_target = ChainTarget::Root;
|
|
return Err(ArgError::other("InvalidFormat"));
|
|
};
|
|
self.tables[idx]
|
|
.1
|
|
.parse_and_put_bytes(value)
|
|
.map_err(to_arg_error)?;
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Parse into default set
|
|
self.set.parse_and_put_bytes(value).map_err(to_arg_error)?;
|
|
self.chain_target = ChainTarget::Root;
|
|
Ok(())
|
|
}
|
|
|
|
/// An empty value resets to the default keybinds (Zig: `init`).
|
|
fn reset(&mut self, _default: Self) {
|
|
self.init();
|
|
}
|
|
|
|
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
|
|
self.format_entry_docs(f, false)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn fmt_kb(name: &str, kb: &Keybinds) -> String {
|
|
let mut s = String::new();
|
|
crate::formatter::format_entry(name, kb, &mut s).unwrap();
|
|
s
|
|
}
|
|
|
|
fn parsed(inputs: &[&str]) -> Keybinds {
|
|
let mut kb = Keybinds::default();
|
|
for i in inputs {
|
|
kb.parse_cli(Some(i)).unwrap();
|
|
}
|
|
kb
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli() {
|
|
let mut set = Keybinds::default();
|
|
set.parse_cli(Some("shift+a=copy_to_clipboard")).unwrap();
|
|
set.parse_cli(Some("shift+a=csi:hello")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn format_config_single() {
|
|
let list = parsed(&["shift+a=csi:hello"]);
|
|
assert_eq!(fmt_kb("a", &list), "a = shift+a=csi:hello\n");
|
|
}
|
|
|
|
// Regression test for https://github.com/ghostty-org/ghostty/issues/2734
|
|
#[test]
|
|
fn format_config_multiple_items() {
|
|
let list = parsed(&["ctrl+z>1=goto_tab:1", "ctrl+z>2=goto_tab:2"]);
|
|
// Note they turn into translated keys because they match
|
|
// their ASCII mapping.
|
|
assert_eq!(
|
|
fmt_kb("keybind", &list),
|
|
"keybind = ctrl+z>1=goto_tab:1\nkeybind = ctrl+z>2=goto_tab:2\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_config_multiple_items_nested() {
|
|
let list = parsed(&[
|
|
"ctrl+a>ctrl+b>n=new_window",
|
|
"ctrl+a>ctrl+b>w=close_window",
|
|
"ctrl+a>ctrl+c>t=new_tab",
|
|
"ctrl+b>ctrl+d>a=previous_tab",
|
|
]);
|
|
// NB: This does not currently retain the order of the keybinds.
|
|
let want = "a = ctrl+a>ctrl+b>n=new_window\n\
|
|
a = ctrl+a>ctrl+b>w=close_window\n\
|
|
a = ctrl+a>ctrl+c>t=new_tab\n\
|
|
a = ctrl+b>ctrl+d>a=previous_tab\n";
|
|
assert_eq!(fmt_kb("a", &list), want);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_table_definition() {
|
|
let kb = parsed(&["foo/shift+a=copy_to_clipboard"]);
|
|
assert_eq!(kb.tables.len(), 1);
|
|
assert!(kb.table("foo").is_some());
|
|
assert_eq!(kb.table("foo").unwrap().bindings.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_table_clear() {
|
|
let mut kb = parsed(&["foo/shift+a=copy_to_clipboard"]);
|
|
assert_eq!(kb.table("foo").unwrap().bindings.len(), 1);
|
|
kb.parse_cli(Some("foo/")).unwrap();
|
|
assert_eq!(kb.table("foo").unwrap().bindings.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_table_multiple_bindings() {
|
|
let kb = parsed(&[
|
|
"foo/shift+a=copy_to_clipboard",
|
|
"foo/shift+b=paste_from_clipboard",
|
|
"bar/ctrl+c=close_window",
|
|
]);
|
|
assert_eq!(kb.tables.len(), 2);
|
|
assert_eq!(kb.table("foo").unwrap().bindings.len(), 2);
|
|
assert_eq!(kb.table("bar").unwrap().bindings.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_table_does_not_affect_root_set() {
|
|
let kb = parsed(&[
|
|
"shift+a=copy_to_clipboard",
|
|
"foo/shift+b=paste_from_clipboard",
|
|
]);
|
|
assert_eq!(kb.set.bindings.len(), 1);
|
|
assert_eq!(kb.table("foo").unwrap().bindings.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_table_empty_name_is_invalid() {
|
|
let mut kb = Keybinds::default();
|
|
assert_eq!(
|
|
kb.parse_cli(Some("/shift+a=copy_to_clipboard")),
|
|
Err(ArgError::other("InvalidFormat"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_table_with_key_sequence() {
|
|
let kb = parsed(&["foo/ctrl+a>ctrl+b=new_window"]);
|
|
assert_eq!(kb.table("foo").unwrap().bindings.len(), 1);
|
|
}
|
|
|
|
fn assert_root_only(input: &str) {
|
|
let kb = parsed(&[input]);
|
|
assert_eq!(kb.set.bindings.len(), 1);
|
|
assert_eq!(kb.tables.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_slash_in_action_argument_is_not_a_table() {
|
|
assert_root_only("ctrl+a=text:/hello");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_slash_as_key_with_modifier_is_not_a_table() {
|
|
assert_root_only("ctrl+/=text:foo");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_shift_slash_as_key_is_not_a_table() {
|
|
assert_root_only("shift+/=ignore");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_bare_slash_as_key_is_not_a_table() {
|
|
assert_root_only("/=text:foo");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_slash_in_key_sequence_is_not_a_table() {
|
|
assert_root_only("ctrl+a>ctrl+/=new_window");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_table_with_slash_in_binding() {
|
|
let kb = parsed(&["mytable//=text:foo"]);
|
|
assert_eq!(kb.set.bindings.len(), 0);
|
|
assert_eq!(kb.tables.len(), 1);
|
|
assert_eq!(kb.table("mytable").unwrap().bindings.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_table_with_sequence_containing_slash() {
|
|
let kb = parsed(&["mytable/a>/=new_window"]);
|
|
assert_eq!(kb.set.bindings.len(), 0);
|
|
assert_eq!(kb.tables.len(), 1);
|
|
assert!(kb.table("mytable").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_chain_without_prior_parsed_binding_is_invalid() {
|
|
let mut kb = Keybinds::default();
|
|
assert_eq!(
|
|
kb.parse_cli(Some("chain=new_tab")),
|
|
Err(ArgError::other("InvalidFormat"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_table_chain_syntax_is_invalid() {
|
|
let mut kb = Keybinds::default();
|
|
kb.parse_cli(Some("foo/a=text:hello")).unwrap();
|
|
assert_eq!(
|
|
kb.parse_cli(Some("foo/chain=deactivate_key_table")),
|
|
Err(ArgError::other("InvalidFormat"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_chain_applies_to_most_recent_table_binding() {
|
|
let kb = parsed(&[
|
|
"ctrl+n=activate_key_table:foo",
|
|
"foo/a=text:hello",
|
|
"chain=deactivate_key_table",
|
|
]);
|
|
|
|
let root = kb
|
|
.set
|
|
.get(Trigger {
|
|
mods: mods(true, false, false, false),
|
|
key: TriggerKey::Unicode('n' as u32),
|
|
})
|
|
.unwrap();
|
|
let leaf = root.as_leaf().unwrap();
|
|
assert!(matches!(leaf.action, Action::ActivateKeyTable(_)));
|
|
|
|
let foo = kb
|
|
.table("foo")
|
|
.unwrap()
|
|
.get(Trigger {
|
|
mods: Mods::default(),
|
|
key: TriggerKey::Unicode('a' as u32),
|
|
})
|
|
.unwrap();
|
|
let chained = foo.as_leaf_chained().unwrap();
|
|
assert_eq!(chained.actions.len(), 2);
|
|
assert!(matches!(chained.actions[0], Action::Text(_)));
|
|
assert!(matches!(chained.actions[1], Action::DeactivateKeyTable));
|
|
}
|
|
|
|
#[test]
|
|
fn clone_with_tables() {
|
|
let kb = parsed(&[
|
|
"shift+a=copy_to_clipboard",
|
|
"foo/shift+b=paste_from_clipboard",
|
|
"bar/ctrl+c=close_window",
|
|
]);
|
|
let cloned = kb.clone();
|
|
assert_eq!(kb.set.bindings.len(), cloned.set.bindings.len());
|
|
assert_eq!(kb.tables.len(), cloned.tables.len());
|
|
assert_eq!(
|
|
kb.table("foo").unwrap().bindings.len(),
|
|
cloned.table("foo").unwrap().bindings.len()
|
|
);
|
|
assert_eq!(
|
|
kb.table("bar").unwrap().bindings.len(),
|
|
cloned.table("bar").unwrap().bindings.len()
|
|
);
|
|
assert_eq!(kb, cloned);
|
|
}
|
|
|
|
#[test]
|
|
fn equal_with_tables() {
|
|
let a = parsed(&["foo/shift+a=copy_to_clipboard"]);
|
|
let b = parsed(&["foo/shift+a=copy_to_clipboard"]);
|
|
assert_eq!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn equal_with_tables_different_table_count() {
|
|
let a = parsed(&["foo/shift+a=copy_to_clipboard"]);
|
|
let b = parsed(&[
|
|
"foo/shift+a=copy_to_clipboard",
|
|
"bar/shift+b=paste_from_clipboard",
|
|
]);
|
|
assert_ne!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn equal_with_tables_different_table_names() {
|
|
let a = parsed(&["foo/shift+a=copy_to_clipboard"]);
|
|
let b = parsed(&["bar/shift+a=copy_to_clipboard"]);
|
|
assert_ne!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn equal_with_tables_different_bindings() {
|
|
let a = parsed(&["foo/shift+a=copy_to_clipboard"]);
|
|
let b = parsed(&["foo/shift+b=paste_from_clipboard"]);
|
|
assert_ne!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn format_entry_with_tables() {
|
|
let kb = parsed(&["foo/shift+a=csi:hello"]);
|
|
assert_eq!(fmt_kb("keybind", &kb), "keybind = foo/shift+a=csi:hello\n");
|
|
}
|
|
|
|
#[test]
|
|
fn format_entry_with_tables_and_root_set() {
|
|
let kb = parsed(&["shift+b=csi:world", "foo/shift+a=csi:hello"]);
|
|
let out = fmt_kb("keybind", &kb);
|
|
assert!(out.contains("keybind = shift+b=csi:world\n"));
|
|
assert!(out.contains("keybind = foo/shift+a=csi:hello\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_clear_clears_tables() {
|
|
let mut kb = parsed(&[
|
|
"shift+a=copy_to_clipboard",
|
|
"foo/shift+b=paste_from_clipboard",
|
|
"bar/ctrl+c=close_window",
|
|
]);
|
|
assert_eq!(kb.set.bindings.len(), 1);
|
|
assert_eq!(kb.tables.len(), 2);
|
|
kb.parse_cli(Some("clear")).unwrap();
|
|
assert_eq!(kb.set.bindings.len(), 0);
|
|
assert_eq!(kb.tables.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_reset_clears_tables() {
|
|
let mut kb = parsed(&[
|
|
"foo/shift+a=copy_to_clipboard",
|
|
"bar/shift+b=paste_from_clipboard",
|
|
]);
|
|
assert_eq!(kb.tables.len(), 2);
|
|
kb.parse_cli(Some("")).unwrap();
|
|
assert_eq!(kb.tables.len(), 0);
|
|
assert!(!kb.set.bindings.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn reset_calls_init() {
|
|
let mut kb = parsed(&["foo/shift+a=copy_to_clipboard"]);
|
|
ConfigField::reset(&mut kb, Keybinds::default());
|
|
assert_eq!(kb.tables.len(), 0);
|
|
assert!(!kb.set.bindings.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn default_keybinds_format_roundtrip() {
|
|
// Every default binding must format and re-parse to an equal set.
|
|
let mut kb = Keybinds::default();
|
|
kb.init();
|
|
let out = fmt_kb("keybind", &kb);
|
|
let mut again = Keybinds::default();
|
|
for line in out.lines() {
|
|
let v = line.strip_prefix("keybind = ").unwrap();
|
|
again.parse_cli(Some(v)).unwrap();
|
|
}
|
|
assert_eq!(kb.set.bindings.len(), again.set.bindings.len());
|
|
}
|
|
|
|
fn lookup(kb: &Keybinds, trigger: Trigger) -> Option<&Value> {
|
|
kb.set.get(trigger)
|
|
}
|
|
|
|
#[test]
|
|
fn default_keybinds_darwin_use_super() {
|
|
let mut kb = Keybinds::default();
|
|
kb.init_for(true);
|
|
let sup = mods(false, false, false, true);
|
|
|
|
// cmd+c copies (performable), cmd+q quits, cmd+t opens a tab.
|
|
for (c, want) in [
|
|
('c', Action::CopyToClipboard(CopyToClipboard::Mixed)),
|
|
('q', Action::Quit),
|
|
('t', Action::NewTab),
|
|
('1', Action::GotoTab(1)),
|
|
] {
|
|
let Some(Value::Leaf(l)) = lookup(&kb, uni(c, sup)) else {
|
|
panic!("missing super+{c}");
|
|
};
|
|
assert_eq!(l.action, want);
|
|
}
|
|
|
|
// tab shortcuts are NOT performable on macOS.
|
|
let Some(Value::Leaf(l)) = lookup(&kb, uni('1', sup)) else {
|
|
panic!()
|
|
};
|
|
assert!(!l.flags.performable);
|
|
let Some(Value::Leaf(l)) = lookup(&kb, uni('c', sup)) else {
|
|
panic!()
|
|
};
|
|
assert!(l.flags.performable);
|
|
|
|
// No ctrl+shift+n window binding on macOS, no ctrl+insert copy.
|
|
assert!(lookup(&kb, uni('n', mods(true, true, false, false))).is_none());
|
|
assert!(lookup(&kb, phys(Key::Insert, mods(true, false, false, false))).is_none());
|
|
|
|
// Natural text editing.
|
|
let Some(Value::Leaf(l)) = lookup(&kb, phys(Key::ArrowLeft, sup)) else {
|
|
panic!()
|
|
};
|
|
assert_eq!(l.action, Action::Text("\\x01".into()));
|
|
|
|
// ctrlOrSuper picks super: super+comma opens the config.
|
|
let Some(Value::Leaf(l)) = lookup(&kb, uni(',', sup)) else {
|
|
panic!()
|
|
};
|
|
assert_eq!(l.action, Action::OpenConfig);
|
|
|
|
// Formatted defaults re-parse to the same number of bindings.
|
|
let out = fmt_kb("keybind", &kb);
|
|
let mut again = Keybinds::default();
|
|
for line in out.lines() {
|
|
let v = line.strip_prefix("keybind = ").unwrap();
|
|
again.parse_cli(Some(v)).unwrap();
|
|
}
|
|
assert_eq!(kb.set.bindings.len(), again.set.bindings.len());
|
|
}
|
|
|
|
#[test]
|
|
fn default_keybinds_non_darwin_use_ctrl_shift() {
|
|
let mut kb = Keybinds::default();
|
|
kb.init_for(false);
|
|
let cs = mods(true, true, false, false);
|
|
let Some(Value::Leaf(l)) = lookup(&kb, uni('c', cs)) else {
|
|
panic!()
|
|
};
|
|
assert_eq!(l.action, Action::CopyToClipboard(CopyToClipboard::Mixed));
|
|
assert!(lookup(&kb, uni('q', mods(false, false, false, true))).is_none());
|
|
}
|
|
}
|