Files
ghostty-rs/crates/ghostty-cli/src/list_keybinds.rs
T
ddidderr 126da77a37 style: apply nightly rustfmt import grouping across the workspace
rustfmt.toml sets group_imports, imports_granularity and imports_layout,
which are nightly-only; agents formatted with stable rustfmt, which
ignored them. This is a formatting-only pass with `cargo +nightly fmt`.

Test Plan:
- cargo clippy --workspace --all-targets -- -D warnings
- cargo test --workspace
2026-10-12 05:03:37 +02:00

481 lines
15 KiB
Rust

//! `+list-keybinds` (ZIG/src/cli/list_keybinds.zig), including its pretty
//! (tty) output.
use std::{
cmp::Ordering,
io::{IsTerminal, Write},
};
use ghostty_config::{
config::Config,
config_struct,
formatter::EntryFormatter,
keybinds::Keybinds,
};
use ghostty_input::binding::{
Action as BindingAction,
Trigger,
TriggerKey,
set::{Set, Value},
};
use crate::{
error::Error,
tui::{self, Color, PrettySession, Style, gwidth},
};
config_struct! {
pub struct Options {
/// If `true`, print out the default keybinds instead of the ones configured
/// in the config file.
"default" => default: bool = false,
/// If `true`, print out documentation about the action associated with the
/// keybinds.
"docs" => docs: bool = false,
/// If `true`, print without formatting even if printing to a tty
"plain" => plain: bool = false,
}
}
cli_options!(Options, help);
/// The `list-keybinds` command is used to list all the available keybinds for
/// Ghostty.
///
/// When executed without any arguments this will list the current keybinds
/// loaded by the config file. If no config file is found or there aren't any
/// changes to the keybinds it will print out the default ones configured for
/// Ghostty
///
/// Flags:
///
/// * `--default`: will print out all the default keybinds
///
/// * `--docs`: currently does nothing, intended to print out documentation
/// about the action associated with the keybinds
///
/// * `--plain`: will disable formatting and make the output more
/// friendly for Unix tooling. This is default when not printing to a tty.
pub fn run(args: &[String]) -> Result<u8, Error> {
let mut opts = Options::default();
crate::parse_options(&mut opts, args)?;
let config = if opts.default {
Config::new_default()
} else {
Config::load()?
};
let stdout = std::io::stdout();
if tui::CAN_PRETTY_PRINT && !opts.plain && stdout.is_terminal() {
return pretty_print(&config.keybind);
}
let text = format_plain(&config.keybind, opts.docs)?;
let mut out = std::io::BufWriter::new(stdout.lock());
out.write_all(text.as_bytes())?;
// Don't forget to flush!
out.flush()?;
Ok(0)
}
/// The plain `keybind = ...` listing.
pub fn format_plain(keybinds: &Keybinds, docs: bool) -> Result<String, Error> {
let mut text = String::new();
let mut f = EntryFormatter::new("keybind", &mut text);
keybinds.format_entry_docs(&mut f, docs)?;
Ok(text)
}
/// A flattened binding: a chain of triggers (the sequence) and the actions
/// (more than one if chained).
#[derive(Debug, Clone)]
pub struct ChordBinding {
pub table_name: Option<String>,
pub triggers: Vec<Trigger>,
pub actions: Vec<BindingAction>,
}
fn mod_count(t: &Trigger) -> usize {
usize::from(t.mods.super_)
+ usize::from(t.mods.ctrl)
+ usize::from(t.mods.shift)
+ usize::from(t.mods.alt)
}
fn key_order(t: &Trigger) -> i64 {
match t.key {
TriggerKey::Physical(k) => i64::from(k as i32),
TriggerKey::Unicode(c) => i64::from(c),
TriggerKey::CatchAll => i64::from(i32::MAX),
}
}
impl ChordBinding {
/// Order keybinds based on various properties
/// 1. Default bindings before table bindings (tables grouped at end)
/// 2. Longest chord sequence
/// 3. Most active modifiers
/// 4. Alphabetically by active modifiers
/// 5. Trigger key order
/// 6. Within tables, sort by table name
/// These properties propagate through chorded keypresses
///
/// Adapted from Binding.lessThan
#[must_use]
pub fn less_than(&self, rhs: &ChordBinding) -> bool {
let lhs_has_table = self.table_name.is_some();
let rhs_has_table = rhs.table_name.is_some();
if lhs_has_table != rhs_has_table {
return !lhs_has_table;
}
if let (Some(l), Some(r)) = (&self.table_name, &rhs.table_name) {
let cmp = l.as_bytes().cmp(r.as_bytes());
if cmp != Ordering::Equal {
return cmp == Ordering::Less;
}
}
let lhs_len = self.triggers.len();
let rhs_len = rhs.triggers.len();
debug_assert!(lhs_len != 0);
debug_assert!(rhs_len != 0);
if lhs_len != rhs_len {
return lhs_len > rhs_len;
}
let lhs_count: usize = self.triggers.iter().map(mod_count).sum();
let rhs_count: usize = rhs.triggers.iter().map(mod_count).sum();
if lhs_count != rhs_count {
return lhs_count > rhs_count;
}
for (l, r) in self.triggers.iter().zip(&rhs.triggers) {
let l_int = l.mods.int();
let r_int = r.mods.int();
if l_int != r_int {
return l_int > r_int;
}
}
let mut l_iter = self.triggers.iter();
let mut r_iter = rhs.triggers.iter();
loop {
let (Some(l), Some(r)) = (l_iter.next(), r_iter.next()) else {
// The Zig code treats this as unreachable (the final
// iteration always returns).
return false;
};
// We want catch_all to sort last.
let lhs_key = key_order(l);
let rhs_key = key_order(r);
if l_iter.len() == 0 || r_iter.len() == 0 {
return lhs_key < rhs_key;
}
if lhs_key != rhs_key {
return lhs_key < rhs_key;
}
}
}
}
/// The plain (unstyled) text of a trigger, used for width measurement
/// (the Zig `iterateBindings` width computation).
fn trigger_width(t: &Trigger) -> u16 {
let mut buf = String::new();
if t.mods.super_ {
buf.push_str("super + ");
}
if t.mods.ctrl {
buf.push_str("ctrl + ");
}
if t.mods.alt {
buf.push_str("alt + ");
}
if t.mods.shift {
buf.push_str("shift + ");
}
buf.push_str(&key_text(t.key));
gwidth(&buf)
}
fn key_text(key: TriggerKey) -> String {
match key {
TriggerKey::Physical(k) => k.name().to_string(),
TriggerKey::Unicode(c) => {
char::from_u32(c).map_or_else(|| "\u{fffd}".to_string(), |c| c.to_string())
}
TriggerKey::CatchAll => "catch_all".to_string(),
}
}
/// Flatten a binding set, recursively expanding leader keys into chords.
/// Returns the bindings and the widest chord.
#[must_use]
pub fn iterate_bindings(set: &Set) -> (Vec<ChordBinding>, u16) {
let mut widest_chord: u16 = 0;
let mut bindings = vec![];
for (key, value) in &set.bindings {
let trigger = key.0;
let width = trigger_width(&trigger);
match value {
Value::Leader(leader) => {
// Recursively iterate on the set of bindings for this leader key
let (mut sub_bindings, max_width) = iterate_bindings(leader);
// Prepend the current keybind onto the list of sub-binds
for nb in &mut sub_bindings {
nb.triggers.insert(0, trigger);
}
// Add the longest sub-bind width to the current bind width along with a padding
// of 5 for the ' > ' spacer
widest_chord = widest_chord.max(width + max_width + 5);
bindings.extend(sub_bindings);
}
Value::Leaf(leaf) => {
widest_chord = widest_chord.max(width);
bindings.push(ChordBinding {
table_name: None,
triggers: vec![trigger],
actions: vec![leaf.action.clone()],
});
}
Value::LeafChained(leaf) => {
widest_chord = widest_chord.max(width);
bindings.push(ChordBinding {
table_name: None,
triggers: vec![trigger],
actions: leaf.actions.clone(),
});
}
}
}
(bindings, widest_chord)
}
/// Collect and sort all bindings (default set and key tables) for the pretty
/// listing. Returns the bindings, the widest chord and the widest table
/// prefix (`table/`).
#[must_use]
pub fn collect_bindings(keybinds: &Keybinds) -> (Vec<ChordBinding>, u16, u16) {
// Collect default bindings, recursively flattening chords
let (mut bindings, mut widest_chord) = iterate_bindings(&keybinds.set);
// Collect key table bindings
let mut widest_table_prefix: u16 = 0;
for (table_name, table_set) in &keybinds.tables {
let (mut table_bindings, table_width) = iterate_bindings(table_set);
for b in &mut table_bindings {
b.table_name = Some(table_name.clone());
}
bindings.extend(table_bindings);
widest_chord = widest_chord.max(table_width);
widest_table_prefix = widest_table_prefix.max(gwidth(table_name) + gwidth("/"));
}
// std.mem.sort is stable.
bindings.sort_by(|a, b| {
if a.less_than(b) {
Ordering::Less
} else if b.less_than(a) {
Ordering::Greater
} else {
Ordering::Equal
}
});
(bindings, widest_chord, widest_table_prefix)
}
fn pretty_print(keybinds: &Keybinds) -> Result<u8, Error> {
let mut session = PrettySession::new_raw(std::io::BufWriter::new(std::io::stdout().lock()))?;
let result = pretty_rows(&mut session, keybinds);
let fin = session.finish();
result?;
fin?;
Ok(0)
}
fn pretty_rows<W: Write>(
session: &mut PrettySession<W>,
keybinds: &Keybinds,
) -> std::io::Result<()> {
let (bindings, widest_chord, widest_table_prefix) = collect_bindings(keybinds);
// Set up styles for each modifier
let fg = |i: u8| Style {
fg: Color::Index(i),
..Style::default()
};
let super_style = fg(1);
let ctrl_style = fg(2);
let alt_style = fg(3);
let shift_style = fg(4);
let table_style = fg(8);
let plain = Style::default();
let sep_style = Style {
bold: true,
fg: Color::Index(6),
..Style::default()
};
let dim_style = Style {
dim: true,
..Style::default()
};
let arg_style = fg(5);
// Print the list
for bind in &bindings {
let mut row = session.row();
let mut col: u16 = 0;
if let Some(name) = &bind.table_name {
col = row.print_segment(name, table_style, col).col;
col = row.print_segment("/", table_style, col).col;
}
for (i, trigger) in bind.triggers.iter().enumerate() {
if trigger.mods.super_ {
col = row.print_segment("super", super_style, col).col;
col = row.print_segment(" + ", plain, col).col;
}
if trigger.mods.ctrl {
col = row.print_segment("ctrl ", ctrl_style, col).col;
col = row.print_segment(" + ", plain, col).col;
}
if trigger.mods.alt {
col = row.print_segment("alt ", alt_style, col).col;
col = row.print_segment(" + ", plain, col).col;
}
if trigger.mods.shift {
col = row.print_segment("shift", shift_style, col).col;
col = row.print_segment(" + ", plain, col).col;
}
col = row.print_segment(&key_text(trigger.key), plain, col).col;
// Print a separator between chorded keys
if i + 1 < bind.triggers.len() {
col = row.print_segment(" > ", sep_style, col).col;
}
}
let mut action_col: u16 = widest_table_prefix + widest_chord + 3;
for (i, act) in bind.actions.iter().enumerate() {
if i > 0 {
action_col = row.print_segment(", ", dim_style, action_col).col;
}
let action = act.to_string();
// If our action has an argument, we print the argument in a different color
if let Some(idx) = action.find(':') {
action_col = row
.print(
&[
(&action[..idx], plain),
(&action[idx..=idx], dim_style),
(&action[idx + 1..], arg_style),
],
action_col,
)
.col;
} else {
action_col = row.print_segment(&action, plain, action_col).col;
}
}
let _ = col;
session.print_row(&row)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn default_keybinds() -> Keybinds {
Config::new_default().keybind
}
#[test]
fn plain_default_listing_has_entries() {
let text = format_plain(&default_keybinds(), false).unwrap();
assert!(
text.contains("keybind = ctrl+shift+c=copy_to_clipboard:mixed\n"),
"{text}"
);
assert!(text.lines().all(|l| l.starts_with("keybind = ")));
}
#[test]
fn empty_keybinds() {
let kb = Keybinds::default();
assert_eq!("keybind = \n", format_plain(&kb, false).unwrap());
}
#[test]
fn collected_bindings_are_sorted() {
let (bindings, widest, table_prefix) = collect_bindings(&default_keybinds());
assert!(bindings.len() > 50);
assert!(widest > 0);
assert_eq!(0, table_prefix);
// Longest sequences come first, and ordering is a strict order.
for w in bindings.windows(2) {
assert!(!w[1].less_than(&w[0]), "{:?} vs {:?}", w[0], w[1]);
}
assert!(
bindings
.windows(2)
.all(|w| w[0].triggers.len() >= w[1].triggers.len())
);
}
#[test]
fn chords_and_tables() {
let mut kb = Keybinds::default();
kb.set.parse_and_put("ctrl+a>b=new_window").unwrap();
kb.set.parse_and_put("ctrl+a>c=new_tab").unwrap();
kb.set.parse_and_put("x=close_surface").unwrap();
let (bindings, widest, _) = collect_bindings(&kb);
assert_eq!(3, bindings.len());
assert_eq!(2, bindings[0].triggers.len());
assert_eq!(1, bindings[2].triggers.len());
// "ctrl + a" (9) + " > " (5) + "b" (1)
assert_eq!(15, widest);
}
#[test]
fn pretty_rows_output() {
let mut kb = Keybinds::default();
kb.set
.parse_and_put("super+ctrl+shift+arrow_down=resize_split:down,10")
.unwrap();
kb.set.parse_and_put("paste=paste_from_clipboard").unwrap();
let mut buf = Vec::new();
let mut session = PrettySession::new(&mut buf).unwrap();
session.width = 100;
pretty_rows(&mut session, &kb).unwrap();
drop(session);
let s = String::from_utf8(buf).unwrap();
assert!(
s.contains(
"\x1b[31msuper\x1b[39m + \x1b[32mctrl \x1b[39m + \x1b[34mshift\x1b[39m + arrow_down"
),
"{s:?}"
);
assert!(
s.contains("resize_split\x1b[2m:\x1b[35m\x1b[22mdown,10\r\n"),
"{s:?}"
);
assert!(s.contains("paste\x1b["), "{s:?}");
}
}