From 6286e02f07001f50bb6afb0d0564f60decb7e667 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 11 Oct 2026 22:22:01 +0200 Subject: [PATCH] feat(cli): port ghostty +action CLI commands Ports ZIG/src/cli into ghostty-cli: action detection/dispatch with per-action --help, and the actions help, version, list-actions, list-colors, list-keybinds, list-themes (plain listing plus an interactive ratatui browser mirroring Ghostty's libvaxis UI), show-config, validate-config, edit-config, crash-report (listing), ssh-cache and boo (235 embedded frames). Verified byte-for-byte against the installed Ghostty 1.3.1 for stdout, stderr and exit codes of most actions, incl. tty output in a pty at widths 20-200. Known differences: +list-colors --plain prints all 782 colors (Ghostty truncates at 617 because it never flushes), +version has placeholder build info until build_info::set is wired up. Deferred: list-fonts, show-face (need font discovery integration) and new-window (needs GTK IPC); they return NotYetAvailable for now. Test Plan: - cargo test -p ghostty-cli (86 tests) - cargo run -p ghostty-cli --example cli -- +list-keybinds --plain and diff against `ghostty +list-keybinds --plain` --- Cargo.lock | 138 +- crates/ghostty-cli/Cargo.toml | 8 + crates/ghostty-cli/examples/cli.rs | 14 + crates/ghostty-cli/src/action.rs | 239 +++ crates/ghostty-cli/src/boo.rs | 274 +++ crates/ghostty-cli/src/boo_frames.deflate | Bin 0 -> 77083 bytes crates/ghostty-cli/src/build_info.rs | 85 + crates/ghostty-cli/src/crash_report.rs | 176 ++ crates/ghostty-cli/src/edit_config.rs | 112 ++ crates/ghostty-cli/src/error.rs | 48 + crates/ghostty-cli/src/ghostty.rs | 331 ++++ crates/ghostty-cli/src/help.rs | 93 + crates/ghostty-cli/src/help/boo.txt | 1 + crates/ghostty-cli/src/help/crash-report.txt | 6 + crates/ghostty-cli/src/help/edit-config.txt | 20 + crates/ghostty-cli/src/help/help.txt | 5 + crates/ghostty-cli/src/help/list-actions.txt | 7 + crates/ghostty-cli/src/help/list-colors.txt | 7 + crates/ghostty-cli/src/help/list-fonts.txt | 24 + crates/ghostty-cli/src/help/list-keybinds.txt | 17 + crates/ghostty-cli/src/help/list-themes.txt | 37 + crates/ghostty-cli/src/help/new-window.txt | 62 + crates/ghostty-cli/src/help/show-config.txt | 27 + crates/ghostty-cli/src/help/show-face.txt | 21 + crates/ghostty-cli/src/help/ssh-cache.txt | 23 + .../ghostty-cli/src/help/validate-config.txt | 9 + crates/ghostty-cli/src/help/version.txt | 2 + crates/ghostty-cli/src/lib.rs | 45 + crates/ghostty-cli/src/list_actions.rs | 108 + crates/ghostty-cli/src/list_colors.rs | 208 ++ crates/ghostty-cli/src/list_fonts.rs | 57 + crates/ghostty-cli/src/list_keybinds.rs | 470 +++++ crates/ghostty-cli/src/list_themes.rs | 379 ++++ crates/ghostty-cli/src/list_themes/preview.rs | 1762 +++++++++++++++++ crates/ghostty-cli/src/lorem_ipsum.txt | 45 + crates/ghostty-cli/src/new_window.rs | 239 +++ crates/ghostty-cli/src/options.rs | 116 ++ crates/ghostty-cli/src/show_config.rs | 128 ++ crates/ghostty-cli/src/show_face.rs | 78 + crates/ghostty-cli/src/ssh_cache.rs | 308 +++ .../ghostty-cli/src/ssh_cache/disk_cache.rs | 486 +++++ crates/ghostty-cli/src/ssh_cache/entry.rs | 179 ++ crates/ghostty-cli/src/tui.rs | 427 ++++ crates/ghostty-cli/src/validate_config.rs | 113 ++ crates/ghostty-cli/src/version.rs | 120 ++ docs/rewrite/notes/ghostty-cli.md | 87 + 46 files changed, 7135 insertions(+), 6 deletions(-) create mode 100644 crates/ghostty-cli/examples/cli.rs create mode 100644 crates/ghostty-cli/src/action.rs create mode 100644 crates/ghostty-cli/src/boo.rs create mode 100644 crates/ghostty-cli/src/boo_frames.deflate create mode 100644 crates/ghostty-cli/src/build_info.rs create mode 100644 crates/ghostty-cli/src/crash_report.rs create mode 100644 crates/ghostty-cli/src/edit_config.rs create mode 100644 crates/ghostty-cli/src/error.rs create mode 100644 crates/ghostty-cli/src/ghostty.rs create mode 100644 crates/ghostty-cli/src/help.rs create mode 100644 crates/ghostty-cli/src/help/boo.txt create mode 100644 crates/ghostty-cli/src/help/crash-report.txt create mode 100644 crates/ghostty-cli/src/help/edit-config.txt create mode 100644 crates/ghostty-cli/src/help/help.txt create mode 100644 crates/ghostty-cli/src/help/list-actions.txt create mode 100644 crates/ghostty-cli/src/help/list-colors.txt create mode 100644 crates/ghostty-cli/src/help/list-fonts.txt create mode 100644 crates/ghostty-cli/src/help/list-keybinds.txt create mode 100644 crates/ghostty-cli/src/help/list-themes.txt create mode 100644 crates/ghostty-cli/src/help/new-window.txt create mode 100644 crates/ghostty-cli/src/help/show-config.txt create mode 100644 crates/ghostty-cli/src/help/show-face.txt create mode 100644 crates/ghostty-cli/src/help/ssh-cache.txt create mode 100644 crates/ghostty-cli/src/help/validate-config.txt create mode 100644 crates/ghostty-cli/src/help/version.txt create mode 100644 crates/ghostty-cli/src/list_actions.rs create mode 100644 crates/ghostty-cli/src/list_colors.rs create mode 100644 crates/ghostty-cli/src/list_fonts.rs create mode 100644 crates/ghostty-cli/src/list_keybinds.rs create mode 100644 crates/ghostty-cli/src/list_themes.rs create mode 100644 crates/ghostty-cli/src/list_themes/preview.rs create mode 100644 crates/ghostty-cli/src/lorem_ipsum.txt create mode 100644 crates/ghostty-cli/src/new_window.rs create mode 100644 crates/ghostty-cli/src/options.rs create mode 100644 crates/ghostty-cli/src/show_config.rs create mode 100644 crates/ghostty-cli/src/show_face.rs create mode 100644 crates/ghostty-cli/src/ssh_cache.rs create mode 100644 crates/ghostty-cli/src/ssh_cache/disk_cache.rs create mode 100644 crates/ghostty-cli/src/ssh_cache/entry.rs create mode 100644 crates/ghostty-cli/src/tui.rs create mode 100644 crates/ghostty-cli/src/validate_config.rs create mode 100644 crates/ghostty-cli/src/version.rs create mode 100644 docs/rewrite/notes/ghostty-cli.md diff --git a/Cargo.lock b/Cargo.lock index 4b299f8..542ac96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,7 +229,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.3", "shlex 1.3.0", "syn 2.0.119", ] @@ -240,7 +240,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", ] [[package]] @@ -249,6 +258,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bitflags" version = "1.3.2" @@ -358,6 +373,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -674,7 +698,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] @@ -865,7 +889,6 @@ dependencies = [ name = "ghostty-cli" version = "0.1.0" dependencies = [ - "crossterm", "flate2", "ghostty-config", "ghostty-input", @@ -954,9 +977,13 @@ dependencies = [ name = "ghostty-renderer" version = "0.1.0" dependencies = [ - "ghostty-config", + "bytemuck", "ghostty-font", - "ghostty-terminal", + "glow", + "khronos-egl", + "log", + "naga", + "thiserror 2.0.21", ] [[package]] @@ -972,7 +999,10 @@ dependencies = [ name = "ghostty-terminal" version = "0.1.0" dependencies = [ + "ghostty-datastruct", + "ghostty-unicode", "ghostty-vt", + "log", ] [[package]] @@ -1008,6 +1038,18 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "glow" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263218b0ba2b0715c3370fb04674f5f8aa331594b521c94ff0a8e4a8472d1386" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "half" version = "2.7.1" @@ -1016,6 +1058,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] @@ -1157,6 +1200,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", +] + [[package]] name = "konst" version = "0.2.20" @@ -1354,6 +1407,44 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "pp-rs", + "rustc-hash 1.1.0", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + [[package]] name = "nix" version = "0.29.0" @@ -1413,6 +1504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1749,6 +1841,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +[[package]] +name = "pp-rs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee" +dependencies = [ + "unicode-xid", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1961,6 +2062,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2126,6 +2233,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.16.3" @@ -2627,6 +2743,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" diff --git a/crates/ghostty-cli/Cargo.toml b/crates/ghostty-cli/Cargo.toml index 01bf699..155b445 100644 --- a/crates/ghostty-cli/Cargo.toml +++ b/crates/ghostty-cli/Cargo.toml @@ -5,9 +5,17 @@ edition.workspace = true license.workspace = true [dependencies] +flate2 = "1.1.10" ghostty-config.workspace = true ghostty-input.workspace = true ghostty-os.workspace = true +log = "0.4.34" +ratatui = "0.30.2" +thiserror = "2.0.21" +unicode-width = "0.2.2" [lints] workspace = true + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/crates/ghostty-cli/examples/cli.rs b/crates/ghostty-cli/examples/cli.rs new file mode 100644 index 0000000..1f3cd00 --- /dev/null +++ b/crates/ghostty-cli/examples/cli.rs @@ -0,0 +1,14 @@ +//! End-to-end runner for the CLI actions: +//! `cargo run -p ghostty-cli --example cli -- +list-actions --docs` +//! +//! Behaves like `ghostty` when an action is given (including `--version`, +//! `--help` and `-h`), and otherwise prints a note. + +fn main() { + let args: Vec = std::env::args().collect(); + if let Some(code) = ghostty_cli::run_from_args(&args) { + std::process::exit(i32::from(code)); + } + eprintln!("no +action given (this example only runs CLI actions)"); + std::process::exit(2); +} diff --git a/crates/ghostty-cli/src/action.rs b/crates/ghostty-cli/src/action.rs new file mode 100644 index 0000000..19f086c --- /dev/null +++ b/crates/ghostty-cli/src/action.rs @@ -0,0 +1,239 @@ +//! Detection of `+action` arguments (ZIG/src/cli/action.zig). + +use std::fmt; + +/// Errors from [`detect_iter`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetectError { + /// Multiple actions were detected. You can specify at most one + /// action on the CLI otherwise the behavior desired is ambiguous. + MultipleActions, + + /// An unknown action was specified. + InvalidAction, +} + +impl fmt::Display for DetectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DetectError::MultipleActions => f.write_str("MultipleActions"), + DetectError::InvalidAction => f.write_str("InvalidAction"), + } + } +} + +impl std::error::Error for DetectError {} + +/// The action enum E can implement `detect_special_case` to return this +/// enum in order to perform various special case actions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpecialCase { + /// Immediately return this action. + Action(E), + + /// Return this action if no other action is found. + Fallback(E), + + /// If there is no pending action (we haven't seen an action yet) + /// then we should return no action. This is kind of weird but is + /// a special case to allow "-e" in Ghostty. + AbortIfNoAction, +} + +/// An enum of actions that can be detected from CLI args. +pub trait ActionEnum: Sized + Copy { + /// `std.meta.stringToEnum`. + fn from_name(name: &str) -> Option; + + /// `E.detectSpecialCase`; the default has no special cases. + fn detect_special_case(_arg: &str) -> Option> { + None + } +} + +/// Detect the action from the process CLI args (Zig: `detectArgs`). +pub fn detect_args() -> Result, DetectError> { + detect_iter(std::env::args()) +} + +/// Detect the action from any iterator. Each iterator value should yield +/// a CLI argument such as "--foo". +pub fn detect_iter(iter: I) -> Result, DetectError> +where + I: IntoIterator, + S: AsRef, +{ + let mut fallback: Option = None; + let mut pending: Option = None; + for arg in iter { + let arg = arg.as_ref(); + + // Allow handling of special cases. + if let Some(special) = E::detect_special_case(arg) { + match special { + SpecialCase::Action(a) => return Ok(Some(a)), + SpecialCase::Fallback(a) => fallback = Some(a), + SpecialCase::AbortIfNoAction => { + if pending.is_none() { + return Ok(None); + } + } + } + } + + // Commands must start with "+" + let Some(name) = arg.strip_prefix('+') else { + continue; + }; + if pending.is_some() { + return Err(DetectError::MultipleActions); + } + pending = Some(E::from_name(name).ok_or(DetectError::InvalidAction)?); + } + + // If we have an action, we always return that action, even if we've + // seen "--help" or "-h" because the action may have its own help text. + if pending.is_some() { + return Ok(pending); + } + + // If we have no action but we have a fallback, then we return that. + Ok(fallback) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use ghostty_config::cli_args::split_args; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Plain { + Foo, + Bar, + Baz, + } + + impl ActionEnum for Plain { + fn from_name(name: &str) -> Option { + match name { + "foo" => Some(Plain::Foo), + "bar" => Some(Plain::Bar), + "baz" => Some(Plain::Baz), + _ => None, + } + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Special { + Foo, + Bar, + } + + /// Which special case `Special` reports for which argument. + static SPECIAL_ARG: std::sync::Mutex<(&str, u8)> = std::sync::Mutex::new(("", 0)); + + impl ActionEnum for Special { + fn from_name(name: &str) -> Option { + match name { + "foo" => Some(Special::Foo), + "bar" => Some(Special::Bar), + _ => None, + } + } + + fn detect_special_case(arg: &str) -> Option> { + let (special, kind) = *SPECIAL_ARG.lock().unwrap(); + if arg != special { + return None; + } + Some(match kind { + 0 => SpecialCase::Action(Special::Foo), + 1 => SpecialCase::Fallback(Special::Foo), + _ => SpecialCase::AbortIfNoAction, + }) + } + } + + /// The special-case tests share a global, so they run in one test body + /// each under this lock. + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn set_special(arg: &'static str, kind: u8) { + *SPECIAL_ARG.lock().unwrap() = (arg, kind); + } + + fn detect(s: &str) -> Result, DetectError> { + detect_iter::(split_args(s)) + } + + #[test] + fn detect_direct_match() { + assert_eq!(Some(Plain::Foo), detect::("+foo").unwrap()); + } + + #[test] + fn detect_invalid_match() { + assert_eq!(Err(DetectError::InvalidAction), detect::("+invalid")); + } + + #[test] + fn detect_multiple_actions() { + assert_eq!( + Err(DetectError::MultipleActions), + detect::("+foo +bar") + ); + } + + #[test] + fn detect_no_match() { + assert_eq!(None, detect::("--some-flag").unwrap()); + let _ = Plain::Baz; + } + + #[test] + fn detect_special_case_action() { + let _g = LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + set_special("--special", 0); + assert_eq!( + Some(Special::Foo), + detect::("--special +bar").unwrap() + ); + assert_eq!( + Some(Special::Foo), + detect::("+bar --special").unwrap() + ); + assert_eq!(Some(Special::Bar), detect::("+bar").unwrap()); + } + + #[test] + fn detect_special_case_fallback() { + let _g = LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + set_special("--special", 1); + assert_eq!(Some(Special::Foo), detect::("--special").unwrap()); + assert_eq!( + Some(Special::Bar), + detect::("+bar --special").unwrap() + ); + assert_eq!( + Some(Special::Bar), + detect::("--special +bar").unwrap() + ); + } + + #[test] + fn detect_special_case_abort_if_no_action() { + let _g = LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + set_special("-e", 2); + assert_eq!(None, detect::("-e").unwrap()); + assert_eq!(Some(Special::Foo), detect::("+foo -e").unwrap()); + assert_eq!(None, detect::("-e +bar").unwrap()); + } +} diff --git a/crates/ghostty-cli/src/boo.rs b/crates/ghostty-cli/src/boo.rs new file mode 100644 index 0000000..de46050 --- /dev/null +++ b/crates/ghostty-cli/src/boo.rs @@ -0,0 +1,274 @@ +//! `+boo` (ZIG/src/cli/boo.zig): the ghost animation from the Ghostty +//! website, played in the terminal. +//! +//! The frames are `src/build/framegen/frames/frame_*.txt` from the Zig tree, +//! joined with `\x01` and raw-deflate compressed into `boo_frames.deflate` +//! (the same format `framegen` produces for Zig's `framedata`). + +use std::io::Read; +use std::time::{Duration, Instant}; + +use flate2::read::DeflateDecoder; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; + +use crate::error::Error; +use crate::options::NoOptions as Options; + +static FRAME_DATA: &[u8] = include_bytes!("boo_frames.deflate"); + +/// Width of a single frame +pub const FRAME_WIDTH: u16 = 100; +/// Height of a single frame +pub const FRAME_HEIGHT: u16 = 41; + +/// One cell of an animation frame: the character and whether it is part of +/// the outline (drawn with the outline style). +pub type FrameCell = (char, bool); + +/// Decompress the frames into a list of individual frames +/// (Zig: `decompressFrames`). +pub fn decompress_frames() -> Result, Error> { + let mut data = Vec::new(); + DeflateDecoder::new(FRAME_DATA).read_to_end(&mut data)?; + let text = String::from_utf8(data).map_err(|e| Error::Other(e.to_string()))?; + Ok(text.split('\u{1}').map(str::to_string).collect()) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum State { + Normal, + Span, + InTag, + InClosingTag, +} + +/// Convert a frame to its cells (Zig: the body of `updateFrame`). +/// +/// A frame is characters with html spans. When we encounter a span, we use +/// the outline style until the span ends. That is, when we find a '<', we +/// parse until '>'. Then we use the outline style until the next '<', and +/// skip until the next '>'. +#[must_use] +pub fn parse_frame(frame: &str) -> Vec { + let mut cells = Vec::with_capacity(usize::from(FRAME_WIDTH) * usize::from(FRAME_HEIGHT)); + for line in frame.split('\n') { + let mut state = State::Normal; + let mut outline = false; + for ch in line.chars() { + match state { + State::Normal => { + if ch == '<' { + state = State::InTag; + // We will be entering a span + outline = true; + continue; + } + } + State::Span => { + if ch == '<' { + state = State::InTag; + outline = false; + continue; + } + } + State::InTag => { + // If we encounter a '/', we are a closing tag + // If we parse all the way to a '>' we are an opening tag: we are now in a span + if ch == '/' { + state = State::InClosingTag; + } else if ch == '>' { + state = State::Span; + } + continue; + } + State::InClosingTag => { + // If we are closing a tag, we will enter the normal state + if ch == '>' { + state = State::Normal; + } + continue; + } + } + cells.push((ch, outline)); + } + } + cells +} + +/// The animation state (Zig: `Boo`). +pub struct Boo { + frames: Vec, + frame: usize, + /// 30 fps + pub framerate: Duration, + buffer: Vec, +} + +impl Boo { + pub fn new() -> Result { + Ok(Boo { + frames: decompress_frames()?, + frame: 0, + framerate: Duration::from_millis(1000 / 30), + buffer: vec![(' ', false); usize::from(FRAME_WIDTH) * usize::from(FRAME_HEIGHT)], + }) + } + + /// Updates our internal buffer with the current frame, then advances the + /// frame index. + pub fn update_frame(&mut self) { + self.buffer = parse_frame(&self.frames[self.frame]); + debug_assert_eq!( + self.buffer.len(), + usize::from(FRAME_WIDTH) * usize::from(FRAME_HEIGHT) + ); + + // Lastly, update the frame index + self.frame += 1; + if self.frame == self.frames.len() { + self.frame = 0; + } + } + + #[must_use] + pub fn frame_count(&self) -> usize { + self.frames.len() + } + + /// Draw the current frame centered in `buf`. + pub fn draw(&self, buf: &mut Buffer) { + let area = buf.area; + // Warn for screen size + if area.width < FRAME_WIDTH || area.height < FRAME_HEIGHT { + let text = "Screen must be at least 100w x 41h"; + let w = u16::try_from(text.len()).unwrap_or(0); + let x = area.x + area.width.saturating_sub(w) / 2; + let y = area.y + area.height / 2; + buf.set_stringn(x, y, text, usize::from(area.width), Style::default()); + return; + } + + // Calculate x and y offsets to center the animation frame + let offset_y = area.y + (area.height - FRAME_HEIGHT) / 2; + let offset_x = area.x + (area.width - FRAME_WIDTH) / 2; + + let ghostty_style = Style::default(); + let outline_style = Style::default().fg(Color::Indexed(4)); + let frame_area = Rect::new(offset_x, offset_y, FRAME_WIDTH, FRAME_HEIGHT); + for (i, (ch, outline)) in self.buffer.iter().enumerate() { + let i = u16::try_from(i).unwrap_or(0); + let x = frame_area.x + i % FRAME_WIDTH; + let y = frame_area.y + i / FRAME_WIDTH; + if let Some(cell) = buf.cell_mut((x, y)) { + let mut tmp = [0u8; 4]; + cell.set_symbol(ch.encode_utf8(&mut tmp)); + cell.set_style(if *outline { + outline_style + } else { + ghostty_style + }); + } + } + } +} + +/// The `boo` command is used to display the animation from the Ghostty website in the terminal +pub fn run(args: &[String]) -> Result { + let mut opts = Options; + crate::parse_options(&mut opts, args)?; + + let mut boo = Boo::new()?; + let mut terminal = ratatui::try_init()?; + let result = run_loop(&mut terminal, &mut boo); + ratatui::restore(); + result?; + Ok(0) +} + +fn run_loop(terminal: &mut ratatui::DefaultTerminal, boo: &mut Boo) -> Result<(), Error> { + let mut next_tick = Instant::now(); + loop { + if Instant::now() >= next_tick { + boo.update_frame(); + next_tick = Instant::now() + boo.framerate; + } + terminal.draw(|f| boo.draw(f.buffer_mut()))?; + + let timeout = next_tick.saturating_duration_since(Instant::now()); + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + if key.kind != KeyEventKind::Release + && ((key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL)) + || key.code == KeyCode::Esc) + { + return Ok(()); + } + } + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn all_frames_decode_to_full_screen() { + let frames = decompress_frames().unwrap(); + assert_eq!(235, frames.len()); + for (i, f) in frames.iter().enumerate() { + assert_eq!( + usize::from(FRAME_WIDTH) * usize::from(FRAME_HEIGHT), + parse_frame(f).len(), + "frame {i}" + ); + } + } + + #[test] + fn spans_use_outline_style() { + // normal, then a span (outline), then normal again. + let cells = parse_frame("abcd"); + assert_eq!( + vec![('a', false), ('b', true), ('c', true), ('d', false)], + cells + ); + // Style resets on each line (a closing tag seen in the normal state keeps the outline, like Zig). + let cells = parse_frame("x\nyz"); + assert_eq!(vec![('x', true), ('y', false), ('z', true)], cells); + } + + #[test] + fn frame_advances_and_wraps() { + let mut boo = Boo::new().unwrap(); + for _ in 0..boo.frame_count() { + boo.update_frame(); + } + assert_eq!(0, boo.frame); + } + + #[test] + fn small_screen_warning() { + let boo = Boo::new().unwrap(); + let mut buf = Buffer::empty(Rect::new(0, 0, 60, 10)); + boo.draw(&mut buf); + let line: String = (0..60).map(|x| buf[(x, 5)].symbol().to_string()).collect(); + assert!(line.contains("Screen must be at least 100w x 41h")); + } + + #[test] + fn draw_centers_frame() { + let mut boo = Boo::new().unwrap(); + boo.update_frame(); + let mut buf = Buffer::empty(Rect::new(0, 0, 120, 50)); + boo.draw(&mut buf); + // Anything outside the frame area stays blank. + assert_eq!(" ", buf[(0, 0)].symbol()); + assert!((10..110).any(|x| (4..45).any(|y| buf[(x, y)].symbol() != " "))); + } +} diff --git a/crates/ghostty-cli/src/boo_frames.deflate b/crates/ghostty-cli/src/boo_frames.deflate new file mode 100644 index 0000000000000000000000000000000000000000..09e7cc5e73ba2eba2cec40f642b3108fd37e9457 GIT binary patch literal 77083 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 literal 0 HcmV?d00001 diff --git a/crates/ghostty-cli/src/build_info.rs b/crates/ghostty-cli/src/build_info.rs new file mode 100644 index 0000000..f112245 --- /dev/null +++ b/crates/ghostty-cli/src/build_info.rs @@ -0,0 +1,85 @@ +//! Build-time information shown by `+version` (Zig: `build_config`, +//! `builtin`, `gtk_version`, `adw_version`). +//! +//! TODO(integration): the root crate / apprt-gtk should call [`set`] with the +//! real GTK and libadwaita versions (the Zig code queries them at runtime). + +use std::sync::OnceLock; + +/// A `major.minor.micro` version (Zig: `gtk_version.zig` / `adw_version.zig`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Version { + pub major: u32, + pub minor: u32, + pub micro: u32, +} + +impl std::fmt::Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.micro) + } +} + +#[derive(Debug, Clone)] +pub struct BuildInfo { + /// `build_config.version_string`. + pub version_string: String, + /// `build_config.version.build` (the git commit hash), if known. + pub commit: Option, + /// `build_config.release_channel` tag name (`stable` / `tip`). + pub release_channel: String, + /// `{}` of `builtin.mode` (`.Debug`, `.ReleaseFast`, ...). + pub build_mode: String, + /// `{}` of `build_config.app_runtime`. + pub app_runtime: String, + /// `{}` of `build_config.font_backend`. + pub font_backend: String, + /// `{}` of `renderer.Renderer`. + pub renderer: String, + pub gtk_build: Option, + pub gtk_runtime: Option, + pub adw_build: Option, + pub adw_runtime: Option, + /// `build_options.x11`. + pub x11: bool, + /// `build_options.wayland`. + pub wayland: bool, +} + +impl Default for BuildInfo { + fn default() -> Self { + BuildInfo { + version_string: "1.3.1".to_string(), + commit: None, + release_channel: "stable".to_string(), + build_mode: if cfg!(debug_assertions) { + ".Debug" + } else { + ".ReleaseFast" + } + .to_string(), + app_runtime: ".gtk".to_string(), + font_backend: ".fontconfig_freetype".to_string(), + renderer: "renderer.generic.Renderer(renderer.OpenGL)".to_string(), + gtk_build: None, + gtk_runtime: None, + adw_build: None, + adw_runtime: None, + x11: true, + wayland: true, + } + } +} + +static INFO: OnceLock = OnceLock::new(); + +/// Install the build info. Returns false if it was already set. +pub fn set(info: BuildInfo) -> bool { + INFO.set(info).is_ok() +} + +/// The installed build info, or the defaults. +#[must_use] +pub fn get() -> BuildInfo { + INFO.get().cloned().unwrap_or_default() +} diff --git a/crates/ghostty-cli/src/crash_report.rs b/crates/ghostty-cli/src/crash_report.rs new file mode 100644 index 0000000..0da7e47 --- /dev/null +++ b/crates/ghostty-cli/src/crash_report.rs @@ -0,0 +1,176 @@ +//! `+crash-report` (ZIG/src/cli/crash_report.zig and the directory listing +//! of ZIG/src/crash/dir.zig). +//! +//! Ghostty uses sentry for crash reporting; only the listing behaviour is +//! ported. + +use std::io::{IsTerminal, Write}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use ghostty_config::types_b::Duration; + +use crate::error::Error; + +pub use crate::options::NoOptions as Options; + +/// A crash report file (Zig: `crash.Report`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Report { + pub name: String, + /// Modification time in nanoseconds since the unix epoch. + pub mtime: i128, +} + +/// The default crash directory (Zig: `crash.defaultDir`): +/// `$XDG_STATE_HOME/ghostty/crash`. +pub fn default_dir() -> Result { + let dir = ghostty_os::xdg::state(&ghostty_os::xdg::Options { + subdir: Some("ghostty/crash"), + home: None, + }) + .map_err(|e| Error::Other(e.to_string()))?; + Ok(PathBuf::from(dir)) +} + +/// List the crash reports (regular files) in `dir`. A directory that cannot +/// be opened yields no reports. +pub fn list_reports(dir: &std::path::Path) -> Result, Error> { + let Ok(rd) = std::fs::read_dir(dir) else { + return Ok(vec![]); + }; + let mut reports = vec![]; + for entry in rd { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let mtime = entry.metadata()?.modified()?; + let mtime = match mtime.duration_since(UNIX_EPOCH) { + Ok(d) => i128::try_from(d.as_nanos()).unwrap_or(i128::MAX), + Err(e) => -i128::try_from(e.duration().as_nanos()).unwrap_or(i128::MAX), + }; + reports.push(Report { + name: entry.file_name().to_string_lossy().into_owned(), + mtime, + }); + } + Ok(reports) +} + +/// The `crash-report` command is used to inspect and send crash reports. +/// +/// When executed without any arguments, this will list existing crash reports. +/// +/// This command currently only supports listing crash reports. Viewing +/// and sending crash reports is unimplemented and will be added in the future. +pub fn run(args: &[String]) -> Result { + let mut opts = Options; + crate::parse_options(&mut opts, args)?; + + let stdout = std::io::stdout(); + let is_tty = stdout.is_terminal(); + let mut out = std::io::BufWriter::new(stdout.lock()); + let result = run_inner(&default_dir()?, is_tty, now_ns(), &mut out); + let _ = out.flush(); + result +} + +fn now_ns() -> i128 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(d) => i128::try_from(d.as_nanos()).unwrap_or(i128::MAX), + Err(_) => 0, + } +} + +fn run_inner( + crash_dir: &std::path::Path, + is_tty: bool, + now: i128, + stdout: &mut dyn Write, +) -> Result { + let mut reports = list_reports(crash_dir)?; + + // If we have no reports, then we're done. If we have a tty then we + // print a message, otherwise we do nothing. + if reports.is_empty() { + if is_tty { + stdout.write_all("No crash reports! \u{1F47B}\n".as_bytes())?; + } + return Ok(0); + } + + // Newest first. + reports.sort_by_key(|r| std::cmp::Reverse(r.mtime)); + + for report in &reports { + let diff = now - report.mtime; + let since = if diff <= 0 { + "now".to_string() + } else { + let d = Duration { + duration: u64::try_from(diff).unwrap_or(u64::MAX), + }; + format!("{} ago", d.round(1_000_000_000)) + }; + writeln!(stdout, "{} ({since})", report.name)?; + } + + Ok(0) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn missing_dir_lists_nothing() { + let mut out = Vec::new(); + let rc = run_inner( + std::path::Path::new("/nonexistent/crash"), + true, + 0, + &mut out, + ) + .unwrap(); + assert_eq!(0, rc); + assert_eq!("No crash reports! \u{1F47B}\n".as_bytes(), out.as_slice()); + + let mut out = Vec::new(); + run_inner( + std::path::Path::new("/nonexistent/crash"), + false, + 0, + &mut out, + ) + .unwrap(); + assert_eq!(0, out.len()); + } + + #[test] + fn lists_newest_first() { + let dir = std::env::temp_dir().join(format!("ghostty-cli-crash-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(dir.join("subdir")).unwrap(); + std::fs::write(dir.join("a.ghosttycrash"), "x").unwrap(); + std::fs::write(dir.join("b.ghosttycrash"), "y").unwrap(); + let old = std::fs::File::options() + .write(true) + .open(dir.join("a.ghosttycrash")) + .unwrap(); + let a_time = SystemTime::now() - std::time::Duration::from_secs(1000); + old.set_modified(a_time).unwrap(); + drop(old); + let a_ns = i128::try_from(a_time.duration_since(UNIX_EPOCH).unwrap().as_nanos()).unwrap(); + + let mut out = Vec::new(); + run_inner(&dir, false, a_ns + (3 * 3600 + 5) * 1_000_000_000, &mut out).unwrap(); + let s = String::from_utf8(out).unwrap(); + let lines: Vec<_> = s.lines().collect(); + assert_eq!(2, lines.len(), "{s}"); + assert!(lines[0].starts_with("b.ghosttycrash ("), "{s}"); + assert_eq!("a.ghosttycrash (3h 5s ago)", lines[1]); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/ghostty-cli/src/edit_config.rs b/crates/ghostty-cli/src/edit_config.rs new file mode 100644 index 0000000..ddb590e --- /dev/null +++ b/crates/ghostty-cli/src/edit_config.rs @@ -0,0 +1,112 @@ +//! `+edit-config` (ZIG/src/cli/edit_config.zig). + +use std::io::Write; +use std::os::unix::process::CommandExt; + +use ghostty_config::config::Config; + +use crate::error::Error; + +pub use crate::options::NoOptions as Options; + +/// The `edit-config` command opens the Ghostty configuration file in the +/// editor specified by the `$VISUAL` or `$EDITOR` environment variables. +/// +/// IMPORTANT: This command will not reload the configuration after +/// editing. You will need to manually reload the configuration using the +/// application menu, configured keybind, or by restarting Ghostty. We +/// plan to auto-reload in the future, but Ghostty isn't capable of +/// this yet. +/// +/// The filepath opened is the default user-specific configuration +/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config.ghostty`. +/// On macOS, this may also be located at +/// `~/Library/Application Support/com.mitchellh.ghostty/config.ghostty`. +/// On macOS, whichever path exists and is non-empty will be prioritized, +/// prioritizing the Application Support directory if neither are +/// non-empty. +/// +/// This command prefers the `$VISUAL` environment variable over `$EDITOR`, +/// if both are set. If neither are set, it will print an error +/// and exit. +pub fn run(args: &[String]) -> Result { + let mut stderr = std::io::stderr().lock(); + + let mut opts = Options; + crate::parse_options(&mut opts, args)?; + + let result = run_inner(&mut stderr); + // Flushing *shouldn't* fail but... + let _ = stderr.flush(); + result +} + +/// Pick the editor from `$VISUAL` then `$EDITOR` (empty values are skipped). +#[must_use] +pub fn find_editor() -> Option { + // VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference + for var in ["VISUAL", "EDITOR"] { + if let Some(v) = ghostty_os::getenv(var) { + if !v.is_empty() { + return Some(v); + } + } + } + None +} + +fn run_inner(stderr: &mut dyn Write) -> Result { + // We load the configuration once because that will write our + // default configuration files to disk. We don't use the config. + let _config = Config::load()?; + + // Find the preferred path. + let path = ghostty_config::file_load::preferred_default_file_path() + .map_err(|e| Error::Other(e.to_string()))?; + + // Get our editor + let Some(editor) = find_editor() else { + // If we don't have `$EDITOR` set then we can't do anything + // but we can still print a helpful message. + write!( + stderr, + "The $EDITOR or $VISUAL environment variable is not set or is empty.\n\ + This environment variable is required to edit the Ghostty configuration\n\ + via this CLI command.\n\ + \n\ + Please set the environment variable to your preferred terminal\n\ + text editor and try again.\n\ + \n\ + If you prefer to edit the configuration file another way,\n\ + you can find the configuration file at the following path:\n\ + \n" + )?; + + // Output the path using the OSC8 sequence so that it is linked. + writeln!(stderr, "\x1b]8;;file://{path}\x1b\\{path}\x1b]8;;\x1b\\")?; + return Ok(1); + }; + + // Flush before exec so no output is lost. + let _ = stderr.flush(); + let err = std::process::Command::new(&editor).arg(&path).exec(); + + // If we reached this point then exec failed. + let err = match err.kind() { + std::io::ErrorKind::NotFound => "error.FileNotFound".to_string(), + std::io::ErrorKind::PermissionDenied => "error.AccessDenied".to_string(), + _ => err.to_string(), + }; + write!( + stderr, + "Failed to execute the editor. Error code={err}.\n\ + \n\ + This is usually due to the executable path not existing, invalid\n\ + permissions, or the shell environment not being set up\n\ + correctly.\n\ + \n\ + Editor: {editor}\n\ + Path: {path}\n" + )?; + Ok(1) +} diff --git a/crates/ghostty-cli/src/error.rs b/crates/ghostty-cli/src/error.rs new file mode 100644 index 0000000..c0e06df --- /dev/null +++ b/crates/ghostty-cli/src/error.rs @@ -0,0 +1,48 @@ +//! The error type returned by CLI actions. + +use ghostty_config::cli_args::ArgError; + +/// The name used by [`ArgError::Other`] to signal that `--help`/`-h` was +/// passed (Zig: `Action.help_error`). +pub const HELP_ERROR_NAME: &str = "ActionHelpRequested"; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// This should be returned by actions that want to print the help text + /// (Zig: `error.ActionHelpRequested`). + #[error("ActionHelpRequested")] + ActionHelpRequested, + + /// Argument parsing failed. + #[error("{0}")] + Args(ArgError), + + /// Loading the configuration failed. + #[error("{0}")] + Config(#[from] ghostty_config::config::Error), + + #[error("{0}")] + Io(#[from] std::io::Error), + + /// The action exists but depends on functionality that is not ported yet. + #[error("the `+{0}` action is not yet available in this build")] + NotYetAvailable(&'static str), + + #[error("{0}")] + Other(String), +} + +impl From for Error { + fn from(e: ArgError) -> Self { + match e { + ArgError::Other(ref n) if n == HELP_ERROR_NAME => Error::ActionHelpRequested, + e => Error::Args(e), + } + } +} + +impl From for Error { + fn from(_: std::fmt::Error) -> Self { + Error::Other("FormatError".to_string()) + } +} diff --git a/crates/ghostty-cli/src/ghostty.rs b/crates/ghostty-cli/src/ghostty.rs new file mode 100644 index 0000000..0d6cf4c --- /dev/null +++ b/crates/ghostty-cli/src/ghostty.rs @@ -0,0 +1,331 @@ +//! The CLI root (ZIG/src/cli/ghostty.zig): the [`Action`] enum, `+action` +//! dispatch and per-action `--help` output. + +use std::io::Write; + +use crate::action::{ActionEnum, DetectError, SpecialCase, detect_iter}; +use crate::error::Error; + +/// Special commands that can be invoked via CLI flags. These are all +/// invoked by using `+` as a CLI flag. The only exception is +/// "version" which can be invoked additionally with `--version`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Action { + /// Output the version and exit + Version, + + /// Output help information for the CLI or configuration + Help, + + /// List available fonts + ListFonts, + + /// List available keybinds + ListKeybinds, + + /// List available themes + ListThemes, + + /// List named RGB colors + ListColors, + + /// List keybind actions + ListActions, + + /// Manage SSH terminfo cache for automatic remote host setup + SshCache, + + /// Edit the config file in the configured terminal editor. + EditConfig, + + /// Dump the config to stdout + ShowConfig, + + // Validate passed config file + ValidateConfig, + + // Show which font face Ghostty loads a codepoint from. + ShowFace, + + // List, (eventually) view, and (eventually) send crash reports. + CrashReport, + + // Boo! + Boo, + + // Use IPC to tell the running Ghostty to open a new window. + NewWindow, +} + +impl Action { + /// All actions in declaration order. + pub const ALL: &'static [Action] = &[ + Action::Version, + Action::Help, + Action::ListFonts, + Action::ListKeybinds, + Action::ListThemes, + Action::ListColors, + Action::ListActions, + Action::SshCache, + Action::EditConfig, + Action::ShowConfig, + Action::ValidateConfig, + Action::ShowFace, + Action::CrashReport, + Action::Boo, + Action::NewWindow, + ]; + + /// The CLI name of the action (`@tagName`), e.g. `list-fonts`. + #[must_use] + pub fn name(self) -> &'static str { + match self { + Action::Version => "version", + Action::Help => "help", + Action::ListFonts => "list-fonts", + Action::ListKeybinds => "list-keybinds", + Action::ListThemes => "list-themes", + Action::ListColors => "list-colors", + Action::ListActions => "list-actions", + Action::SshCache => "ssh-cache", + Action::EditConfig => "edit-config", + Action::ShowConfig => "show-config", + Action::ValidateConfig => "validate-config", + Action::ShowFace => "show-face", + Action::CrashReport => "crash-report", + Action::Boo => "boo", + Action::NewWindow => "new-window", + } + } + + /// `std.meta.stringToEnum`. + #[must_use] + pub fn from_name(name: &str) -> Option { + Action::ALL.iter().copied().find(|a| a.name() == name) + } + + /// Returns the filename associated with an action. This is a relative + /// path from the root src/ directory of the Zig sources. + #[must_use] + pub fn file(self) -> String { + format!("cli/{}.zig", self.name().replace('-', "_")) + } + + /// The help text of the action (the doc comment of the Zig `run` + /// function, i.e. `help_strings.Action.`), without trailing newline. + #[must_use] + pub fn help_text(self) -> &'static str { + match self { + Action::Version => include_str!("help/version.txt"), + Action::Help => include_str!("help/help.txt"), + Action::ListFonts => include_str!("help/list-fonts.txt"), + Action::ListKeybinds => include_str!("help/list-keybinds.txt"), + Action::ListThemes => include_str!("help/list-themes.txt"), + Action::ListColors => include_str!("help/list-colors.txt"), + Action::ListActions => include_str!("help/list-actions.txt"), + Action::SshCache => include_str!("help/ssh-cache.txt"), + Action::EditConfig => include_str!("help/edit-config.txt"), + Action::ShowConfig => include_str!("help/show-config.txt"), + Action::ValidateConfig => include_str!("help/validate-config.txt"), + Action::ShowFace => include_str!("help/show-face.txt"), + Action::CrashReport => include_str!("help/crash-report.txt"), + Action::Boo => include_str!("help/boo.txt"), + Action::NewWindow => include_str!("help/new-window.txt"), + } + } + + /// Run the action. `args` is the full argument vector (including + /// `argv[0]`). This returns the exit code to exit with. + /// + /// If the action requests its help text (`--help`/`-h`), the help text + /// is printed to stdout and 0 is returned. + pub fn run(self, args: &[String]) -> Result { + match self.run_main(args) { + Err(Error::ActionHelpRequested) => { + // Future note: for now we just output the help text directly + // to stdout. In the future we can style this much prettier + // for all commands by just changing this one place. + let text = format!("{}\n", self.help_text()); + let mut stdout = std::io::stdout().lock(); + if let Err(e) = stdout.write_all(text.as_bytes()) { + log::warn!("failed to write help text: {e}\n"); + return Ok(1); + } + if let Err(e) = stdout.flush() { + log::warn!("failed to flush help text: {e}\n"); + return Ok(1); + } + Ok(0) + } + other => other, + } + } + + fn run_main(self, args: &[String]) -> Result { + match self { + Action::Version => crate::version::run(args), + Action::Help => crate::help::run(args), + Action::ListFonts => crate::list_fonts::run(args), + Action::ListKeybinds => crate::list_keybinds::run(args), + Action::ListThemes => crate::list_themes::run(args), + Action::ListColors => crate::list_colors::run(args), + Action::ListActions => crate::list_actions::run(args), + Action::SshCache => crate::ssh_cache::run(args), + Action::EditConfig => crate::edit_config::run(args), + Action::ShowConfig => crate::show_config::run(args), + Action::ValidateConfig => crate::validate_config::run(args), + Action::CrashReport => crate::crash_report::run(args), + Action::ShowFace => crate::show_face::run(args), + Action::Boo => crate::boo::run(args), + Action::NewWindow => crate::new_window::run(args), + } + } +} + +impl ActionEnum for Action { + fn from_name(name: &str) -> Option { + Action::from_name(name) + } + + fn detect_special_case(arg: &str) -> Option> { + // If we see a "-e" and we haven't seen a command yet, then + // we are done looking for commands. This special case enables + // `ghostty -e ghostty +command`. If we've seen a command we + // still want to keep looking because + // `ghostty +command -e +command` is invalid. + if arg == "-e" { + return Some(SpecialCase::AbortIfNoAction); + } + + // Special case, --version always outputs the version no + // matter what, no matter what other args exist. + if arg == "--version" { + return Some(SpecialCase::Action(Action::Version)); + } + + // --help matches "help" but if a subcommand is specified + // then we match the subcommand. + if arg == "--help" || arg == "-h" { + return Some(SpecialCase::Fallback(Action::Help)); + } + + None + } +} + +impl std::fmt::Display for Action { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } +} + +/// Detect the CLI action from `args` (full argv, `argv[0]` included). +pub fn detect(args: &[String]) -> Result, DetectError> { + detect_iter::(args.iter()) +} + +/// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the +/// action from `args` (the full argv), print the errors Ghostty prints for +/// invalid invocations and run the action. +/// +/// Returns `None` if no action was requested (the caller should launch the +/// terminal), or the exit code the process should terminate with. +#[must_use] +pub fn run_from_args(args: &[String]) -> Option { + let action = match detect(args) { + Ok(a) => a?, + Err(err) => { + let mut stderr = std::io::stderr().lock(); + let _ = match err { + DetectError::MultipleActions => stderr.write_all( + b"Error: multiple CLI actions specified. You must specify only one\n\ + action starting with the `+` character.\n", + ), + DetectError::InvalidAction => stderr.write_all( + b"Error: unknown CLI action specified. CLI actions are specified with\n\ + the '+' character.\n\n\ + All valid CLI actions can be listed with `ghostty +help`\n", + ), + }; + let _ = stderr.flush(); + return Some(1); + } + }; + + log::info!("executing CLI action={action}"); + Some(match action.run(args) { + Ok(code) => code, + Err(err) => { + log::error!("CLI action failed error={err}"); + 1 + } + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use ghostty_config::cli_args::split_args; + + fn detect_str(s: &str) -> Result, DetectError> { + detect_iter::(split_args(s)) + } + + #[test] + fn parse_action_none() { + assert_eq!(None, detect_str("--a=42 --b --b-f=false").unwrap()); + } + + #[test] + fn parse_action_version() { + for s in [ + "--a=42 --b --b-f=false --version", + "--version --a=42 --b --b-f=false", + "--c=84 --d --version --a=42 --b --b-f=false", + ] { + assert_eq!(Some(Action::Version), detect_str(s).unwrap()); + } + } + + #[test] + fn parse_action_plus() { + for s in [ + "--a=42 --b --b-f=false +version", + "+version --a=42 --b --b-f=false", + "--c=84 --d +version --a=42 --b --b-f=false", + ] { + assert_eq!(Some(Action::Version), detect_str(s).unwrap()); + } + } + + #[test] + fn parse_action_plus_ignores_e() { + assert_eq!(None, detect_str("--a=42 -e +version").unwrap()); + assert_eq!( + Err(DetectError::MultipleActions), + detect_str("+list-fonts --a=42 -e +version") + ); + } + + #[test] + fn help_fallback() { + assert_eq!(Some(Action::Help), detect_str("--help").unwrap()); + assert_eq!(Some(Action::Help), detect_str("-h").unwrap()); + assert_eq!( + Some(Action::ListThemes), + detect_str("+list-themes --help").unwrap() + ); + } + + #[test] + fn names_round_trip() { + for a in Action::ALL { + assert_eq!(Some(*a), Action::from_name(a.name())); + assert_ne!("", a.help_text()); + assert!(a.file().starts_with("cli/")); + } + assert_eq!("cli/list_fonts.zig", Action::ListFonts.file()); + } +} diff --git a/crates/ghostty-cli/src/help.rs b/crates/ghostty-cli/src/help.rs new file mode 100644 index 0000000..bdc7622 --- /dev/null +++ b/crates/ghostty-cli/src/help.rs @@ -0,0 +1,93 @@ +//! `+help` (ZIG/src/cli/help.zig). + +use std::io::Write; + +use ghostty_config::config_struct; + +use crate::error::Error; +use crate::ghostty::Action; + +config_struct! { + // Note that this options struct doesn't implement the `help` decl like other + // actions. That is because the help command is special and wants to handle its + // own logic around help detection. + pub struct Options { + /// This must be registered so that it isn't an error to pass `--help` + "help" => help: bool = false, + } +} +cli_options!(Options); + +/// The `help` command shows general help about Ghostty. Recognized as either +/// `-h, `--help`, or like other actions `+help`. +/// +/// You can also specify `--help` or `-h` along with any action such as +/// `+list-themes` to see help for a specific action. +pub fn run(args: &[String]) -> Result { + let mut opts = Options::default(); + crate::parse_options(&mut opts, args)?; + + let mut out = std::io::BufWriter::new(std::io::stdout().lock()); + write_help(&mut out)?; + out.flush()?; + Ok(0) +} + +/// Write the general help text. +pub fn write_help(stdout: &mut dyn Write) -> std::io::Result<()> { + stdout.write_all( + b"Usage: ghostty [+action] [options] + +Run the Ghostty terminal emulator or a specific helper action. + +If no `+action` is specified, run the Ghostty terminal emulator. +All configuration keys are available as command line options. +To specify a configuration key, use the `--=` syntax +where key and value are the same format you'd put into a configuration +file. For example, `--font-size=12` or `--font-family=\"Fira Code\"`. + +To see a list of all available configuration options, please see +the `src/config/Config.zig` file. A future update will allow seeing +the list of configuration options from the command line. + +A special command line argument `-e ` can be used to run +the specific command inside the terminal emulator. For example, +`ghostty -e top` will run the `top` command inside the terminal. + +On macOS, launching the terminal emulator from the CLI is not +supported and only actions are supported. Use `open -na Ghostty.app` +instead, or `open -na ghostty.app --args --foo=bar --baz=quz` to pass +arguments. + +Available actions: + +", + )?; + + for action in Action::ALL { + writeln!(stdout, " +{}", action.name())?; + } + + stdout.write_all( + b" +Specify `+ --help` to see the help for a specific action, +where `` is one of actions listed above. +", + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn help_lists_actions() { + let mut buf = Vec::new(); + write_help(&mut buf).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("Available actions:\n\n +version\n +help\n +list-fonts\n")); + assert!(s.ends_with("where `` is one of actions listed above.\n")); + } +} diff --git a/crates/ghostty-cli/src/help/boo.txt b/crates/ghostty-cli/src/help/boo.txt new file mode 100644 index 0000000..f64b856 --- /dev/null +++ b/crates/ghostty-cli/src/help/boo.txt @@ -0,0 +1 @@ +The `boo` command is used to display the animation from the Ghostty website in the terminal \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/crash-report.txt b/crates/ghostty-cli/src/help/crash-report.txt new file mode 100644 index 0000000..6b3a294 --- /dev/null +++ b/crates/ghostty-cli/src/help/crash-report.txt @@ -0,0 +1,6 @@ +The `crash-report` command is used to inspect and send crash reports. + +When executed without any arguments, this will list existing crash reports. + +This command currently only supports listing crash reports. Viewing +and sending crash reports is unimplemented and will be added in the future. \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/edit-config.txt b/crates/ghostty-cli/src/help/edit-config.txt new file mode 100644 index 0000000..ac4114b --- /dev/null +++ b/crates/ghostty-cli/src/help/edit-config.txt @@ -0,0 +1,20 @@ +The `edit-config` command opens the Ghostty configuration file in the +editor specified by the `$VISUAL` or `$EDITOR` environment variables. + +IMPORTANT: This command will not reload the configuration after +editing. You will need to manually reload the configuration using the +application menu, configured keybind, or by restarting Ghostty. We +plan to auto-reload in the future, but Ghostty isn't capable of +this yet. + +The filepath opened is the default user-specific configuration +file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config.ghostty`. +On macOS, this may also be located at +`~/Library/Application Support/com.mitchellh.ghostty/config.ghostty`. +On macOS, whichever path exists and is non-empty will be prioritized, +prioritizing the Application Support directory if neither are +non-empty. + +This command prefers the `$VISUAL` environment variable over `$EDITOR`, +if both are set. If neither are set, it will print an error +and exit. \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/help.txt b/crates/ghostty-cli/src/help/help.txt new file mode 100644 index 0000000..22550a8 --- /dev/null +++ b/crates/ghostty-cli/src/help/help.txt @@ -0,0 +1,5 @@ +The `help` command shows general help about Ghostty. Recognized as either +`-h, `--help`, or like other actions `+help`. + +You can also specify `--help` or `-h` along with any action such as +`+list-themes` to see help for a specific action. \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/list-actions.txt b/crates/ghostty-cli/src/help/list-actions.txt new file mode 100644 index 0000000..7e3e943 --- /dev/null +++ b/crates/ghostty-cli/src/help/list-actions.txt @@ -0,0 +1,7 @@ +The `list-actions` command is used to list all the available keybind +actions for Ghostty. These are distinct from the CLI Actions which can +be listed via `+help` + +Flags: + + * `--docs`: will print out the documentation for each action. \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/list-colors.txt b/crates/ghostty-cli/src/help/list-colors.txt new file mode 100644 index 0000000..767b4fd --- /dev/null +++ b/crates/ghostty-cli/src/help/list-colors.txt @@ -0,0 +1,7 @@ +The `list-colors` command is used to list all the named RGB colors in +Ghostty. + +Flags: + + * `--plain`: will disable formatting and make the output more + friendly for Unix tooling. This is default when not printing to a tty. \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/list-fonts.txt b/crates/ghostty-cli/src/help/list-fonts.txt new file mode 100644 index 0000000..7c9da45 --- /dev/null +++ b/crates/ghostty-cli/src/help/list-fonts.txt @@ -0,0 +1,24 @@ +The `list-fonts` command is used to list all the available fonts for +Ghostty. This uses the exact same font discovery mechanism Ghostty uses to +find fonts to use. + +When executed with no arguments, this will list all available fonts, sorted +by family name, then font name. If a family name is given with `--family`, +the sorting will be disabled and the results instead will be shown in the +same priority order Ghostty would use to pick a font. + +Flags: + + * `--bold`: Filter results to specific bold styles. It is not guaranteed + that only those styles are returned. They are only prioritized. + + * `--italic`: Filter results to specific italic styles. It is not guaranteed + that only those styles are returned. They are only prioritized. + + * `--style`: Filter results based on the style string advertised by a font. + It is not guaranteed that only those styles are returned. They are only + prioritized. + + * `--family`: Filter results to a specific font family. The family handling + is identical to the `font-family` set of Ghostty configuration values, so + this can be used to debug why your desired font may not be loading. \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/list-keybinds.txt b/crates/ghostty-cli/src/help/list-keybinds.txt new file mode 100644 index 0000000..6b7f4e4 --- /dev/null +++ b/crates/ghostty-cli/src/help/list-keybinds.txt @@ -0,0 +1,17 @@ +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. \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/list-themes.txt b/crates/ghostty-cli/src/help/list-themes.txt new file mode 100644 index 0000000..5dfea0e --- /dev/null +++ b/crates/ghostty-cli/src/help/list-themes.txt @@ -0,0 +1,37 @@ +The `list-themes` command is used to preview or list all the available +themes for Ghostty. + +If this command is run from a TTY, a TUI preview of the themes will be +shown. While in the preview, `F1` will bring up a help screen and `ESC` will +exit the preview. Other keys that can be used to navigate the preview are +listed in the help screen. + +If this command is not run from a TTY, or the output is piped to another +command, a plain list of theme names will be printed to the screen. A plain +list can be forced using the `--plain` CLI flag. + +Two different directories will be searched for themes. + +The first directory is the `themes` subdirectory of your Ghostty +configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or +`~/.config/ghostty/themes`. + +The second directory is the `themes` subdirectory of the Ghostty resources +directory. Ghostty ships with a multitude of themes that will be installed +into this directory. On macOS, this directory is the +`Ghostty.app/Contents/Resources/ghostty/themes`. On Linux, this directory +is the `share/ghostty/themes` (wherever you installed the Ghostty "share" +directory). If you're running Ghostty from the source, this is the +`zig-out/share/ghostty/themes` directory. + +You can also set the `GHOSTTY_RESOURCES_DIR` environment variable to point +to the resources directory. + +Flags: + + * `--path`: Show the full path to the theme. + + * `--plain`: Force a plain listing of themes. + + * `--color`: Specify the color scheme of the themes included in the list. + This can be `dark`, `light`, or `all`. The default is `all`. \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/new-window.txt b/crates/ghostty-cli/src/help/new-window.txt new file mode 100644 index 0000000..853c1c5 --- /dev/null +++ b/crates/ghostty-cli/src/help/new-window.txt @@ -0,0 +1,62 @@ +The `new-window` will use native platform IPC to open up a new window in a +running instance of Ghostty. + +If the `--class` flag is not set, the `new-window` command will try and +connect to a running instance of Ghostty based on what optimizations the +Ghostty CLI was compiled with. Otherwise the `new-window` command will try +and contact a running Ghostty instance that was configured with the same +`class` as was given on the command line. + +All of the arguments after the `+new-window` argument (except for the +`--class` flag) will be sent to the remote Ghostty instance and will be +parsed as command line flags. These flags will override certain settings +when creating the first surface in the new window. Currently, only +`--working-directory`, `--command`, and `--title` are supported. `-e` will +also work as an alias for `--command`, except that if `-e` is found on the +command line all following arguments will become part of the command and no +more arguments will be parsed for configuration settings. + +If `--working-directory` is found on the command line and is a relative +path (i.e. doesn't start with `/`) it will be resolved to an absolute path +relative to the current working directory that the `ghostty +new-window` +command is run from. `~/` prefixes will also be expanded to the user's home +directory. + +If `--working-directory` is _not_ found on the command line, the working +directory that `ghostty +new-window` is run from will be passed to Ghostty. + +GTK uses an application ID to identify instances of applications. If Ghostty +is compiled with release optimizations, the default application ID will be +`com.mitchellh.ghostty`. If Ghostty is compiled with debug optimizations, +the default application ID will be `com.mitchellh.ghostty-debug`. The +`class` configuration entry can be used to set up a custom application +ID. The class name must follow the requirements defined [in the GTK +documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html) +or it will be ignored and Ghostty will use the default as defined above. + +On GTK, D-Bus activation must be properly configured. Ghostty does not need +to be running for this to open a new window, making it suitable for binding +to keys in your window manager (if other methods for configuring global +shortcuts are unavailable). D-Bus will handle launching a new instance +of Ghostty if it is not already running. See the Ghostty website for +information on properly configuring D-Bus activation. + +Only supported on GTK. + +Flags: + + * `--class=`: If set, open up a new window in a custom instance of + Ghostty. The class must be a valid GTK application ID. + + * `--command`: The command to be executed in the first surface of the new window. + + * `--working-directory=`: The working directory to pass to Ghostty. + + * `--title`: A title that will override the title of the first surface in + the new window. The title override may be edited or removed later. + + * `-e`: Any arguments after this will be interpreted as a command to + execute inside the first surface of the new window instead of the + default command. + +Available since: 1.2.0 \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/show-config.txt b/crates/ghostty-cli/src/help/show-config.txt new file mode 100644 index 0000000..50b7fbf --- /dev/null +++ b/crates/ghostty-cli/src/help/show-config.txt @@ -0,0 +1,27 @@ +The `show-config` command shows the current configuration in a valid Ghostty +configuration file format. + +When executed without any arguments this will output the current +configuration that is different from the default configuration. If you're +using the default configuration this will output nothing. + +If you are a new user and want to see all available options with +documentation, run `ghostty +show-config --default --docs`. + +The output is not in any specific order, but the order should be consistent +between runs. The output is not guaranteed to be exactly match the input +configuration files, but it will result in the same behavior. Comments, +whitespace, and other formatting is not preserved from user configuration +files. + +Flags: + + * `--default`: Show the default configuration instead of loading + the user configuration. + + * `--changes-only`: Only show the options that have been changed + from the default. This has no effect if `--default` is specified. + + * `--docs`: Print the documentation above each option as a comment, + This is very noisy but is very useful to learn about available + options, especially paired with `--default`. \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/show-face.txt b/crates/ghostty-cli/src/help/show-face.txt new file mode 100644 index 0000000..52ab89a --- /dev/null +++ b/crates/ghostty-cli/src/help/show-face.txt @@ -0,0 +1,21 @@ +The `show-face` command shows what font face Ghostty will use to render a +specific codepoint. Note that this command does not take into consideration +grapheme clustering or any other Unicode features that might modify the +presentation of a codepoint, so this may show a different font face than +Ghostty uses to render a codepoint in a terminal session. + +Flags: + + * `--cp`: Find the face for a single codepoint. The codepoint may be specified + in decimal (`--cp=65`), hexadecimal (`--cp=0x41`), octal (`--cp=0o101`), or + binary (`--cp=0b1000001`). + + * `--string`: Find the face for all of the codepoints in a string. The + string must be a valid UTF-8 sequence. + + * `--style`: Search for a specific style. Valid options are `regular`, `bold`, + `italic`, and `bold_italic`. + + * `--presentation`: If set, force searching for a specific presentation + style. Valid options are `text` and `emoji`. If unset, the presentation + style of a codepoint will be inferred from the Unicode standard. \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/ssh-cache.txt b/crates/ghostty-cli/src/help/ssh-cache.txt new file mode 100644 index 0000000..0e5b44b --- /dev/null +++ b/crates/ghostty-cli/src/help/ssh-cache.txt @@ -0,0 +1,23 @@ +Manage the SSH terminfo cache for automatic remote host setup. + +When SSH integration is enabled with `shell-integration-features = ssh-terminfo`, +Ghostty automatically installs its terminfo on remote hosts. This command +manages the cache of successful installations to avoid redundant uploads. + +The cache stores hostnames (or user@hostname combinations) along with timestamps. +Entries older than the expiration period are automatically removed during cache +operations. By default, entries never expire. + +Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified. +If multiple are specified, one of the actions will be executed but +it isn't guaranteed which one. This is entirely unsafe so you should split +multiple actions into separate commands. + +Examples: + ghostty +ssh-cache # List all cached hosts + ghostty +ssh-cache --host=example.com # Check if host is cached + ghostty +ssh-cache --add=example.com # Manually add host to cache + ghostty +ssh-cache --add=user@example.com # Add user@host combination + ghostty +ssh-cache --remove=example.com # Remove host from cache + ghostty +ssh-cache --clear # Clear entire cache + ghostty +ssh-cache --expire-days=30 # Set custom expiration period \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/validate-config.txt b/crates/ghostty-cli/src/help/validate-config.txt new file mode 100644 index 0000000..0340fa5 --- /dev/null +++ b/crates/ghostty-cli/src/help/validate-config.txt @@ -0,0 +1,9 @@ +The `validate-config` command is used to validate a Ghostty config file. + +When executed without any arguments, this will load the config from the default +location. + +Flags: + + * `--config-file`: can be passed to validate a specific target config file in + a non-default location \ No newline at end of file diff --git a/crates/ghostty-cli/src/help/version.txt b/crates/ghostty-cli/src/help/version.txt new file mode 100644 index 0000000..015832a --- /dev/null +++ b/crates/ghostty-cli/src/help/version.txt @@ -0,0 +1,2 @@ +The `version` command is used to display information about Ghostty. Recognized as +either `+version` or `--version`. \ No newline at end of file diff --git a/crates/ghostty-cli/src/lib.rs b/crates/ghostty-cli/src/lib.rs index 0ed9a05..da9d7cd 100644 --- a/crates/ghostty-cli/src/lib.rs +++ b/crates/ghostty-cli/src/lib.rs @@ -1 +1,46 @@ //! ghostty-cli: `ghostty +action` CLI actions (ZIG/src/cli). +//! +//! Every action lives in its own module and exposes `run(args)`, which takes +//! the full process argument vector (including `argv[0]`, like Zig's +//! `args.argsIterator`) and returns the process exit code. Actions write to +//! stdout/stderr exactly like the Zig implementation. + +#![allow( + clippy::wildcard_imports, + clippy::struct_field_names, + clippy::items_after_statements, + clippy::needless_pass_by_value, + clippy::too_many_arguments, + clippy::type_complexity, + clippy::collapsible_if, + clippy::doc_lazy_continuation, + clippy::doc_overindented_list_items +)] + +#[macro_use] +mod options; + +pub mod action; +pub mod boo; +pub mod build_info; +pub mod crash_report; +pub mod edit_config; +pub mod error; +pub mod ghostty; +pub mod help; +pub mod list_actions; +pub mod list_colors; +pub mod list_fonts; +pub mod list_keybinds; +pub mod list_themes; +pub mod new_window; +pub mod show_config; +pub mod show_face; +pub mod ssh_cache; +pub mod tui; +pub mod validate_config; +pub mod version; + +pub use error::Error; +pub use ghostty::{Action, run_from_args}; +pub use options::{NoOptions, parse_options}; diff --git a/crates/ghostty-cli/src/list_actions.rs b/crates/ghostty-cli/src/list_actions.rs new file mode 100644 index 0000000..48f59da --- /dev/null +++ b/crates/ghostty-cli/src/list_actions.rs @@ -0,0 +1,108 @@ +//! `+list-actions` (ZIG/src/cli/list_actions.zig). + +use std::io::Write; + +use ghostty_config::config_struct; +use ghostty_input::binding::ActionKey; + +use crate::error::Error; + +config_struct! { + pub struct Options { + /// If `true`, print out documentation about the action associated with the + /// keybinds. + "docs" => docs: bool = false, + } +} +cli_options!(Options, help); + +/// The `list-actions` command is used to list all the available keybind +/// actions for Ghostty. These are distinct from the CLI Actions which can +/// be listed via `+help` +/// +/// Flags: +/// +/// * `--docs`: will print out the documentation for each action. +pub fn run(args: &[String]) -> Result { + let mut opts = Options::default(); + crate::parse_options(&mut opts, args)?; + + let out = generate_plaintext(opts.docs); + let mut stdout = std::io::stdout().lock(); + stdout.write_all(out.as_bytes())?; + stdout.flush()?; + Ok(0) +} + +/// `helpgen_actions.generate(.plaintext, show_docs)`. +/// +/// TODO(integration): this is a local copy of +/// `ghostty_input::helpgen_actions::generate`. The embedded docs of a few +/// actions in ghostty-input (`adjust_selection`, ...) end with an extra blank +/// line compared to Ghostty's `help_strings`, which adds a stray `" "` line +/// to `--docs` output. Trailing newlines are normalised here; once the data +/// is fixed in ghostty-input this should call `helpgen_actions::generate`. +#[must_use] +pub fn generate_plaintext(show_docs: bool) -> String { + let mut writer = String::new(); + let mut stream = String::new(); + for &key in ActionKey::ALL { + let name = key.name(); + if name.starts_with('_') { + continue; + } + + // Write previously stored doc comment below all related actions + if show_docs { + writer.push_str(&stream); + writer.push('\n'); + stream.clear(); + writer.push_str(name); + writer.push_str(":\n"); + for line in key.docs().trim_end_matches('\n').split('\n') { + stream.push_str(" "); + stream.push_str(line); + stream.push('\n'); + } + } else { + writer.push_str(name); + writer.push('\n'); + } + } + + // Write any remaining buffered documentation + writer.push_str(&stream); + writer +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn names_only() { + let s = generate_plaintext(false); + assert!(s.starts_with("ignore\nunbind\ncsi\n")); + assert!(!s.contains(':')); + } + + #[test] + fn docs_have_no_stray_blank_lines_between_actions() { + let s = generate_plaintext(true); + assert!(s.starts_with("\nignore:\n Ignore this key combination.\n")); + assert!(s.contains(" respectively.\n\njump_to_prompt:\n")); + assert!(s.ends_with('\n')); + } + + #[test] + fn matches_input_crate_when_docs_are_clean() { + // The first actions have well-formed docs. + let ours = generate_plaintext(true); + let theirs = ghostty_input::helpgen_actions::generate_string( + ghostty_input::helpgen_actions::Format::Plaintext, + true, + ); + assert!(ours.lines().take(100).eq(theirs.lines().take(100))); + } +} diff --git a/crates/ghostty-cli/src/list_colors.rs b/crates/ghostty-cli/src/list_colors.rs new file mode 100644 index 0000000..bb97965 --- /dev/null +++ b/crates/ghostty-cli/src/list_colors.rs @@ -0,0 +1,208 @@ +//! `+list-colors` (ZIG/src/cli/list_colors.zig). + +use std::cmp::Ordering; +use std::io::{IsTerminal, Write}; + +use ghostty_config::config_struct; + +use crate::error::Error; +use crate::tui::{self, Color, PrettySession, Style}; + +config_struct! { + pub struct Options { + /// If `true`, print without formatting even if printing to a tty + "plain" => plain: bool = false, + } +} +cli_options!(Options, help); + +/// `terminal/res/rgb.txt`, the same file `x11_color` is built from. +/// +/// TODO(integration): ghostty-vt's `x11_color` only exposes lookups (and +/// lower-cases the names); expose its key list and use it instead of +/// re-parsing the data here. +const RGB_DATA: &str = include_str!("../../ghostty-vt/src/res/rgb.txt"); + +/// All X11 color names (with their original spelling) and values. Like the +/// case-insensitive Zig map the first spelling of a name wins. +#[must_use] +pub fn x11_colors() -> Vec<(String, (u8, u8, u8))> { + let mut seen = std::collections::HashSet::new(); + let mut out = vec![]; + for line in RGB_DATA.split('\n') { + if line.len() < 12 { + continue; + } + let parse = |s: &str| s.trim_matches(' ').parse::().ok(); + let (Some(r), Some(g), Some(b)) = + (parse(&line[0..3]), parse(&line[4..7]), parse(&line[8..11])) + else { + continue; + }; + let name = line[12..].trim_matches([' ', '\t', '\n']); + if seen.insert(name.to_ascii_lowercase()) { + out.push((name.to_string(), (r, g, b))); + } + } + out +} + +/// `std.ascii.orderIgnoreCase`. +fn order_ignore_case(a: &str, b: &str) -> Ordering { + let a = a.bytes().map(|c| c.to_ascii_lowercase()); + let b = b.bytes().map(|c| c.to_ascii_lowercase()); + a.cmp(b) +} + +/// The colors sorted by name, ignoring case. +#[must_use] +pub fn sorted_colors() -> Vec<(String, (u8, u8, u8))> { + let mut colors = x11_colors(); + colors.sort_by(|a, b| order_ignore_case(&a.0, &b.0)); + colors +} + +/// The `list-colors` command is used to list all the named RGB colors in +/// Ghostty. +/// +/// Flags: +/// +/// * `--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 { + let mut opts = Options::default(); + crate::parse_options(&mut opts, args)?; + + let colors = sorted_colors(); + + let stdout = std::io::stdout(); + if tui::CAN_PRETTY_PRINT && !opts.plain && stdout.is_terminal() { + return pretty_print(&colors); + } + + let mut out = std::io::BufWriter::new(stdout.lock()); + write_plain(&mut out, &colors)?; + out.flush()?; + Ok(0) +} + +/// The plain `name = #rrggbb` listing. +pub fn write_plain(out: &mut dyn Write, colors: &[(String, (u8, u8, u8))]) -> std::io::Result<()> { + for (name, (r, g, b)) in colors { + writeln!(out, "{name} = #{r:02x}{g:02x}{b:02x}")?; + } + Ok(()) +} + +fn pretty_print(colors: &[(String, (u8, u8, u8))]) -> Result { + let mut session = PrettySession::new_raw(std::io::BufWriter::new(std::io::stdout().lock()))?; + let result = pretty_rows(&mut session, colors); + let fin = session.finish(); + result?; + fin?; + Ok(0) +} + +fn pretty_rows( + session: &mut PrettySession, + colors: &[(String, (u8, u8, u8))], +) -> std::io::Result<()> { + let width = session.width; + let max_name_len = colors.iter().map(|(n, _)| n.len()).max().unwrap_or(0); + + // max name length plus " = #RRGGBB XX" plus " " gutter between columns + let column_size = max_name_len + 15; + // add two to take into account lack of gutter after last column + let columns = ((usize::from(width) + 2) / column_size).max(1); + + let step = colors.len() / columns + 1; + for i in 0..step { + let mut row = session.row(); + let mut col = 0u16; + + for j in 0..columns { + let k = i + step * j; + let Some((name, (r, g, b))) = colors.get(k) else { + continue; + }; + + let style1 = Style { + fg: Color::Rgb(*r, *g, *b), + ..Style::default() + }; + let style2 = Style { + fg: Color::Rgb(*r, *g, *b), + bg: Color::Rgb(*r, *g, *b), + ..Style::default() + }; + let plain = Style::default(); + + // name of the color + col = row.print_segment(name, plain, col).col; + // push the color data to the end of the column + for _ in 0..(max_name_len - name.len()) { + col = row.print_segment(" ", plain, col).col; + } + col = row.print_segment(" = ", plain, col).col; + // rgb triple + col = row + .print_segment(&format!("#{r:02x}{g:02x}{b:02x}"), style1, col) + .col; + col = row.print_segment(" ", plain, col).col; + // colored block + col = row.print_segment(" ", style2, col).col; + // add the gutter if needed + if j + 1 < columns { + col = row.print_segment(" ", plain, col).col; + } + } + + // clear the rest of the line + while col != 0 && col < width { + col = row.print_segment(" ", Style::default(), col).col; + } + + session.print_row(&row)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn plain_listing_matches_ghostty_format() { + let colors = sorted_colors(); + assert_eq!(782, colors.len()); + let mut out = Vec::new(); + write_plain(&mut out, &colors[..2]).unwrap(); + assert_eq!( + "alice blue = #f0f8ff\nAliceBlue = #f0f8ff\n", + String::from_utf8(out).unwrap() + ); + } + + #[test] + fn sort_is_case_insensitive() { + assert_eq!(Ordering::Less, order_ignore_case("alice blue", "AliceBlue")); + assert_eq!(Ordering::Less, order_ignore_case("Zed", "zee")); + assert_eq!(Ordering::Equal, order_ignore_case("ABC", "abc")); + } + + #[test] + fn pretty_rows_fill_width() { + let colors = sorted_colors(); + let mut buf = Vec::new(); + let mut session = PrettySession::new(&mut buf).unwrap(); + session.width = 100; + pretty_rows(&mut session, &colors[..10]).unwrap(); + drop(session); + let s = String::from_utf8(buf).unwrap(); + assert!(s.starts_with("\x1b[?2027h\r\x1b[m\x1b[J\x1b[?25l\x1b[?2026h\x1b[m")); + let max_len = colors[..10].iter().map(|(n, _)| n.len()).max().unwrap(); + let columns = (102 / (max_len + 15)).max(1); + assert_eq!(10 / columns + 1, s.matches("\r\n").count()); + } +} diff --git a/crates/ghostty-cli/src/list_fonts.rs b/crates/ghostty-cli/src/list_fonts.rs new file mode 100644 index 0000000..a68fefc --- /dev/null +++ b/crates/ghostty-cli/src/list_fonts.rs @@ -0,0 +1,57 @@ +//! `+list-fonts` (ZIG/src/cli/list_fonts.zig). +//! +//! DEFERRED: needs the font crate (font discovery). Only the options and +//! help are ported; running the action reports that it is not available. + +use ghostty_config::config_struct; + +use crate::error::Error; + +config_struct! { + pub struct Options { + /// The font family to search for. If this is set, then only fonts + /// matching this family will be listed. + "family" => family: Option = None, + + /// The style name to search for. + "style" => style: Option = None, + + /// Font styles to search for. If this is set, then only fonts that + /// match the given styles will be listed. + "bold" => bold: bool = false, + "italic" => italic: bool = false, + } +} +cli_options!(Options, help); + +/// The `list-fonts` command is used to list all the available fonts for +/// Ghostty. This uses the exact same font discovery mechanism Ghostty uses to +/// find fonts to use. +/// +/// When executed with no arguments, this will list all available fonts, sorted +/// by family name, then font name. If a family name is given with `--family`, +/// the sorting will be disabled and the results instead will be shown in the +/// same priority order Ghostty would use to pick a font. +/// +/// Flags: +/// +/// * `--bold`: Filter results to specific bold styles. It is not guaranteed +/// that only those styles are returned. They are only prioritized. +/// +/// * `--italic`: Filter results to specific italic styles. It is not guaranteed +/// that only those styles are returned. They are only prioritized. +/// +/// * `--style`: Filter results based on the style string advertised by a font. +/// It is not guaranteed that only those styles are returned. They are only +/// prioritized. +/// +/// * `--family`: Filter results to a specific font family. The family handling +/// is identical to the `font-family` set of Ghostty configuration values, so +/// this can be used to debug why your desired font may not be loading. +pub fn run(args: &[String]) -> Result { + let mut opts = Options::default(); + crate::parse_options(&mut opts, args)?; + + // TODO(integration): port the discovery/listing once ghostty-font lands. + Err(Error::NotYetAvailable("list-fonts")) +} diff --git a/crates/ghostty-cli/src/list_keybinds.rs b/crates/ghostty-cli/src/list_keybinds.rs new file mode 100644 index 0000000..d8f7b7f --- /dev/null +++ b/crates/ghostty-cli/src/list_keybinds.rs @@ -0,0 +1,470 @@ +//! `+list-keybinds` (ZIG/src/cli/list_keybinds.zig), including its pretty +//! (tty) output. + +use std::cmp::Ordering; +use std::io::{IsTerminal, Write}; + +use ghostty_config::config::Config; +use ghostty_config::config_struct; +use ghostty_config::formatter::EntryFormatter; +use ghostty_config::keybinds::Keybinds; +use ghostty_input::binding::set::{Set, Value}; +use ghostty_input::binding::{Action as BindingAction, Trigger, TriggerKey}; + +use crate::error::Error; +use crate::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 { + 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 { + 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, + pub triggers: Vec, + pub actions: Vec, +} + +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, 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, 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 { + 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( + session: &mut PrettySession, + 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:?}"); + } +} diff --git a/crates/ghostty-cli/src/list_themes.rs b/crates/ghostty-cli/src/list_themes.rs new file mode 100644 index 0000000..aebdcdb --- /dev/null +++ b/crates/ghostty-cli/src/list_themes.rs @@ -0,0 +1,379 @@ +//! `+list-themes` (ZIG/src/cli/list_themes.zig). +//! +//! The non-interactive listing is a faithful port. The interactive preview +//! (libvaxis in Zig) is implemented with ratatui + crossterm in [`preview`]. + +use std::cmp::Ordering; +use std::fmt::Write as _; +use std::io::{IsTerminal, Write}; + +use ghostty_config::config::Config; +use ghostty_config::theme::{Location, LocationIterator}; +use ghostty_config::{config_enum, config_struct}; + +use crate::error::Error; +use crate::tui; + +pub mod preview; + +// When the number of filtered themes is less than or equal to this threshold, +// the window position will be reset to 0 to show all results from the top. +// This ensures better visibility for small result sets while maintaining +// scroll position for larger lists. +pub(crate) const SMALL_LIST_THRESHOLD: usize = 10; + +config_enum! { + pub enum ColorScheme { + All => "all", + Dark => "dark", + Light => "light", + } +} + +config_struct! { + pub struct Options { + /// If true, print the full path to the theme. + "path" => path: bool = false, + + /// If true, force a plain list of themes. + "plain" => plain: bool = false, + + /// Specifies the color scheme of the themes to include in the list. + "color" => color: ColorScheme = ColorScheme::All, + } +} +cli_options!(Options, help); + +/// An entry of the theme list (Zig: `ThemeListElement`). +#[derive(Debug, Clone, PartialEq)] +pub struct ThemeListElement { + pub location: Location, + pub path: String, + pub theme: String, + pub rank: Option, +} + +impl ThemeListElement { + /// TODO: use Unicode-aware comparison (as in Zig). + #[must_use] + pub fn order(&self, other: &ThemeListElement) -> Ordering { + let a = self.theme.bytes().map(|c| c.to_ascii_lowercase()); + let b = other.theme.bytes().map(|c| c.to_ascii_lowercase()); + a.cmp(b) + } + + /// `file://` URI of the theme file (Zig: `toUri`). + #[must_use] + pub fn to_uri(&self) -> String { + let mut out = String::from("file://"); + for b in self.path.bytes() { + // std.Uri path component escaping: unreserved + sub-delims + ":@/". + if b.is_ascii_alphanumeric() + || matches!( + b, + b'-' | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + | b'/' + ) + { + out.push(b as char); + } else { + let _ = write!(out, "%{b:02X}"); + } + } + out + } +} + +/// The name of a theme location (`{t}` of `themepkg.Location`). +#[must_use] +pub fn location_name(loc: Location) -> &'static str { + match loc { + Location::User => "user", + Location::Resources => "resources", + } +} + +/// Collect the themes of all theme directories. +/// +/// Returns the unsorted list (in directory order). Errors opening a +/// directory other than "not found" are printed to stderr and skipped. +#[must_use] +pub fn collect_themes(resources_dir: Option<&str>) -> Vec { + let mut themes = vec![]; + let mut it = LocationIterator::new(resources_dir); + while let Some((location, dir)) = it.next_location() { + let rd = match std::fs::read_dir(&dir) { + Ok(rd) => rd, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => { + eprintln!("error trying to open {dir}: {e}"); + continue; + } + }; + + for entry in rd.flatten() { + let Ok(ft) = entry.file_type() else { + continue; + }; + if !(ft.is_file() || ft.is_symlink()) { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if name == ".DS_Store" { + continue; + } + themes.push(ThemeListElement { + path: std::path::Path::new(&dir) + .join(&name) + .to_string_lossy() + .into_owned(), + location, + theme: name, + rank: None, + }); + } + } + themes +} + +/// Whether the theme with the given loaded config matches the color filter. +#[must_use] +pub fn should_include_theme(theme_filter: ColorScheme, theme_config: &Config) -> bool { + let rf = f32::from(theme_config.background.r) / 255.0; + let gf = f32::from(theme_config.background.g) / 255.0; + let bf = f32::from(theme_config.background.b) / 255.0; + let luminance = 0.2126 * rf + 0.7152 * gf + 0.0722 * bf; + let is_dark = luminance < 0.5; + theme_filter == ColorScheme::All + || (theme_filter == ColorScheme::Dark && is_dark) + || (theme_filter == ColorScheme::Light && !is_dark) +} + +/// Make `path` absolute (`Config::load_file` requires absolute paths). +pub(crate) fn absolute_path(path: &str) -> String { + std::path::absolute(path) + .map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()) +} + +fn resources_dir() -> Option { + ghostty_os::resources_dir() + .ok() + .and_then(|r| r.app().map(str::to_string)) +} + +/// The `list-themes` command is used to preview or list all the available +/// themes for Ghostty. +/// +/// If this command is run from a TTY, a TUI preview of the themes will be +/// shown. While in the preview, `F1` will bring up a help screen and `ESC` will +/// exit the preview. Other keys that can be used to navigate the preview are +/// listed in the help screen. +/// +/// If this command is not run from a TTY, or the output is piped to another +/// command, a plain list of theme names will be printed to the screen. A plain +/// list can be forced using the `--plain` CLI flag. +/// +/// Two different directories will be searched for themes. +/// +/// The first directory is the `themes` subdirectory of your Ghostty +/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or +/// `~/.config/ghostty/themes`. +/// +/// The second directory is the `themes` subdirectory of the Ghostty resources +/// directory. Ghostty ships with a multitude of themes that will be installed +/// into this directory. On macOS, this directory is the +/// `Ghostty.app/Contents/Resources/ghostty/themes`. On Linux, this directory +/// is the `share/ghostty/themes` (wherever you installed the Ghostty "share" +/// directory). If you're running Ghostty from the source, this is the +/// `zig-out/share/ghostty/themes` directory. +/// +/// You can also set the `GHOSTTY_RESOURCES_DIR` environment variable to point +/// to the resources directory. +/// +/// Flags: +/// +/// * `--path`: Show the full path to the theme. +/// +/// * `--plain`: Force a plain listing of themes. +/// +/// * `--color`: Specify the color scheme of the themes included in the list. +/// This can be `dark`, `light`, or `all`. The default is `all`. +pub fn run(args: &[String]) -> Result { + let mut opts = Options::default(); + crate::parse_options(&mut opts, args)?; + + let stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + + let resources_dir = resources_dir(); + if resources_dir.is_none() { + writeln!( + stderr, + "Could not find the Ghostty resources directory. Please ensure \ + that Ghostty is installed correctly." + )?; + } + + let mut themes = collect_themes(resources_dir.as_deref()); + + if themes.is_empty() { + write!( + stderr, + "No themes found, check to make sure that the themes were installed correctly." + )?; + return Ok(1); + } + + // sortUnstable in Zig; a stable sort is a valid unstable order. + themes.sort_by(ThemeListElement::order); + + if tui::CAN_PRETTY_PRINT && !opts.plain && stdout.is_terminal() { + preview::preview(themes, opts.color)?; + return Ok(0); + } + + let mut out = std::io::BufWriter::new(stdout.lock()); + write_plain(&mut out, &themes, &opts)?; + // Don't forget to flush! + out.flush()?; + Ok(0) +} + +/// The plain listing. Like Zig, one `Config` is reused for every theme +/// file (so a theme that does not set `background` inherits the previous +/// theme's value). +pub fn write_plain( + out: &mut dyn Write, + themes: &[ThemeListElement], + opts: &Options, +) -> Result<(), Error> { + let mut theme_config = Config::new_default(); + for theme in themes { + theme_config.load_file(&absolute_path(&theme.path))?; + if !should_include_theme(opts.color, &theme_config) { + continue; + } + let loc = location_name(theme.location); + if opts.path { + writeln!(out, "{} ({loc}) {}", theme.theme, theme.path)?; + } else { + writeln!(out, "{} ({loc})", theme.theme)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn theme_dir(files: &[(&str, &str)]) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("themes"); + std::fs::create_dir_all(&dir).unwrap(); + for (name, content) in files { + std::fs::write(dir.join(name), content).unwrap(); + } + tmp + } + + #[test] + fn collect_sort_and_filter() { + let tmp = theme_dir(&[ + ("Zeta", "background = #ffffff\n"), + ("alpha", "background = #000000\n"), + ("Beta", "background = 202020\n"), + (".DS_Store", "junk"), + ]); + let res = tmp.path().to_string_lossy().into_owned(); + let mut themes: Vec<_> = collect_themes(Some(&res)) + .into_iter() + .filter(|t| t.location == Location::Resources) + .collect(); + themes.sort_by(ThemeListElement::order); + let names: Vec<_> = themes.iter().map(|t| t.theme.as_str()).collect(); + assert_eq!(vec!["alpha", "Beta", "Zeta"], names); + + let list = |color, path| { + let mut out = Vec::new(); + let opts = Options { + path, + color, + ..Options::default() + }; + write_plain(&mut out, &themes, &opts).unwrap(); + String::from_utf8(out).unwrap() + }; + assert_eq!( + "alpha (resources)\nBeta (resources)\nZeta (resources)\n", + list(ColorScheme::All, false) + ); + assert_eq!( + "alpha (resources)\nBeta (resources)\n", + list(ColorScheme::Dark, false) + ); + assert_eq!("Zeta (resources)\n", list(ColorScheme::Light, false)); + assert_eq!( + format!("Zeta (resources) {res}/themes/Zeta\n"), + list(ColorScheme::Light, true) + ); + } + + #[test] + fn luminance_threshold() { + let mut cfg = Config::new_default(); + cfg.background = ghostty_config::types_a::Color { + r: 127, + g: 127, + b: 127, + }; + assert!(should_include_theme(ColorScheme::Dark, &cfg)); + cfg.background = ghostty_config::types_a::Color { + r: 128, + g: 128, + b: 128, + }; + assert!(should_include_theme(ColorScheme::Light, &cfg)); + assert!(should_include_theme(ColorScheme::All, &cfg)); + } + + #[test] + fn uri_escaping() { + let t = ThemeListElement { + location: Location::User, + path: "/a b/c%d".into(), + theme: "x".into(), + rank: None, + }; + assert_eq!("file:///a%20b/c%25d", t.to_uri()); + } + + #[test] + fn options_color() { + let mut opts = Options::default(); + crate::parse_options( + &mut opts, + &["g".into(), "--color=dark".into(), "--path".into()], + ) + .unwrap(); + assert_eq!(ColorScheme::Dark, opts.color); + assert!(opts.path); + assert!(crate::parse_options(&mut opts, &["g".into(), "--color=blue".into()]).is_err()); + } +} diff --git a/crates/ghostty-cli/src/list_themes/preview.rs b/crates/ghostty-cli/src/list_themes/preview.rs new file mode 100644 index 0000000..8d99129 --- /dev/null +++ b/crates/ghostty-cli/src/list_themes/preview.rs @@ -0,0 +1,1762 @@ +//! The interactive theme preview of `+list-themes` (Zig: `Preview` in +//! list_themes.zig, built on libvaxis). This is a ratatui + crossterm +//! implementation with the same layout, key bindings and behaviour. +//! +//! Known differences from the vaxis version: +//! * hyperlinks (OSC 8) on theme names/paths are not emitted, +//! * double/dashed/dotted/curly underlines are drawn as single underlines, +//! * the terminal color scheme is not queried live: it defaults to light +//! like the Zig code does, but `COLORFGBG` is used as a hint if set. + +use std::cell::RefCell; +use std::io::Write; + +use ghostty_config::config::Config; +use ghostty_config::types_a::TerminalColor; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, +}; +use ratatui::layout::{Position, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::{Frame, crossterm}; + +use super::{ + ColorScheme, SMALL_LIST_THRESHOLD, ThemeListElement, absolute_path, should_include_theme, +}; +use crate::error::Error; + +const LOREM_IPSUM: &str = include_str!("../lorem_ipsum.txt"); + +/// Width of the theme list on the left. +const LIST_WIDTH: u16 = 32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Normal, + Help, + Search, + Save, +} + +/// The terminal's color scheme (`vaxis.Color.Scheme`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scheme { + Light, + Dark, +} + +/// A minimal single line text input (`vaxis.widgets.TextInput`). +#[derive(Debug, Clone, Default)] +pub struct TextInput { + pub text: String, + /// Cursor position in chars. + pub cursor: usize, +} + +impl TextInput { + fn byte_idx(&self, chars: usize) -> usize { + self.text + .char_indices() + .nth(chars) + .map_or(self.text.len(), |(i, _)| i) + } + + fn len(&self) -> usize { + self.text.chars().count() + } + + pub fn clear(&mut self) { + self.text.clear(); + self.cursor = 0; + } + + /// Handle an editing key. Returns true if it was consumed. + pub fn update(&mut self, key: &KeyEvent) -> bool { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + match key.code { + KeyCode::Char(c) if !ctrl && !alt => { + let at = self.byte_idx(self.cursor); + self.text.insert(at, c); + self.cursor += 1; + } + KeyCode::Backspace => { + if self.cursor > 0 { + let at = self.byte_idx(self.cursor - 1); + self.text.remove(at); + self.cursor -= 1; + } + } + KeyCode::Delete | KeyCode::Char('d') if key.code == KeyCode::Delete || ctrl => { + if self.cursor < self.len() { + let at = self.byte_idx(self.cursor); + self.text.remove(at); + } + } + KeyCode::Left | KeyCode::Char('b') if key.code == KeyCode::Left || ctrl => { + self.cursor = self.cursor.saturating_sub(1); + } + KeyCode::Right | KeyCode::Char('f') if key.code == KeyCode::Right || ctrl => { + self.cursor = (self.cursor + 1).min(self.len()); + } + KeyCode::Home => self.cursor = 0, + KeyCode::Char('a') if ctrl => self.cursor = 0, + KeyCode::End => self.cursor = self.len(), + KeyCode::Char('e') if ctrl => self.cursor = self.len(), + KeyCode::Char('u') if ctrl => { + let at = self.byte_idx(self.cursor); + self.text.drain(..at); + self.cursor = 0; + } + KeyCode::Char('k') if ctrl => { + let at = self.byte_idx(self.cursor); + self.text.truncate(at); + } + KeyCode::Char('w') if ctrl => { + let upto = self.byte_idx(self.cursor); + let head = self.text[..upto].trim_end_matches(' '); + let new_end = head.rfind(' ').map_or(0, |i| i + 1); + self.text.drain(new_end..upto); + self.cursor = self.text[..new_end].chars().count(); + } + _ => return false, + } + true + } +} + +/// `zf.rank` with `.plain = true, .to_lower = true`: every token must match +/// the (lowercased) name as a subsequence. Only whether a rank exists is used +/// by the preview, so the score is a simple span length (lower is better). +#[must_use] +pub fn rank(name: &str, tokens: &[&str]) -> Option { + let name: Vec = name.to_lowercase().chars().collect(); + let mut total = 0.0; + for token in tokens { + let token: Vec = token.chars().collect(); + if token.is_empty() { + continue; + } + // Prefer a contiguous match. + let contiguous = name + .windows(token.len()) + .position(|w| w == token.as_slice()); + if let Some(start) = contiguous { + total += start as f64; + continue; + } + let mut ti = 0; + let mut first = None; + let mut last = 0; + for (i, c) in name.iter().enumerate() { + if ti < token.len() && *c == token[ti] { + first.get_or_insert(i); + last = i; + ti += 1; + } + } + if ti < token.len() { + return None; + } + total += 1000.0 + (last - first.unwrap_or(0)) as f64; + } + Some(total) +} + +/// Standard base64 (for OSC 52). +fn base64(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for chunk in data.chunks(3) { + let n = (u32::from(chunk[0]) << 16) + | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) + | u32::from(*chunk.get(2).unwrap_or(&0)); + out.push(T[(n >> 18) as usize & 63] as char); + out.push(T[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { + T[(n >> 6) as usize & 63] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + T[n as usize & 63] as char + } else { + '=' + }); + } + out +} + +/// Cached result of loading the currently previewed theme. +enum Loaded { + Ok(Box), + Err(String), +} + +/// A pending side effect requested by the state (needs the terminal). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Effect { + /// Copy the text to the clipboard (OSC 52). + Copy(String), +} + +/// The preview state (Zig: `Preview`). +pub struct Preview { + pub should_quit: bool, + pub themes: Vec, + pub filtered: Vec, + pub current: usize, + pub window: usize, + pub hex: bool, + pub mode: Mode, + pub color_scheme: Scheme, + pub text_input: TextInput, + pub theme_filter: ColorScheme, + /// Last mouse event, consumed by the next draw. + mouse: Option, + /// The list height as of the last draw (for clamping the window). + list_height: u16, + loaded: Option<(String, Loaded)>, + /// Where `w` writes the auto theme file (overridable for tests). + pub auto_theme_path: Option, + effects: Vec, +} + +fn initial_scheme() -> Scheme { + // COLORFGBG is "fg;bg" (sometimes "fg;default;bg"); bg 0-6 and 8 are dark. + if let Ok(v) = std::env::var("COLORFGBG") { + if let Some(bg) = v.rsplit(';').next().and_then(|s| s.parse::().ok()) { + return if bg <= 6 || bg == 8 { + Scheme::Dark + } else { + Scheme::Light + }; + } + } + Scheme::Light +} + +impl Preview { + #[must_use] + pub fn new(themes: Vec, theme_filter: ColorScheme) -> Preview { + let mut p = Preview { + should_quit: false, + themes, + filtered: vec![], + current: 0, + window: 0, + hex: false, + mode: Mode::Normal, + color_scheme: initial_scheme(), + text_input: TextInput::default(), + theme_filter, + mouse: None, + list_height: 0, + loaded: None, + auto_theme_path: None, + effects: vec![], + }; + p.update_filtered(); + p + } + + /// Recompute the filtered theme list (Zig: `updateFiltered`). + pub fn update_filtered(&mut self) { + let relative = self.current.saturating_sub(self.window); + let selected: Option = self + .filtered + .get(self.current) + .map(|&i| self.themes[i].theme.clone()); + + let old = self.filtered.clone(); + self.filtered.clear(); + + let mut theme_config = Config::new_default(); + let lowered = self.text_input.text.to_ascii_lowercase(); + let tokens: Vec<&str> = lowered.split(' ').filter(|t| !t.is_empty()).collect(); + let searching = !self.text_input.text.is_empty(); + + for (i, theme) in self.themes.iter_mut().enumerate() { + // Errors are ignored here (Zig would abort the whole preview). + let _ = theme_config.load_file(&absolute_path(&theme.path)); + if !should_include_theme(self.theme_filter, &theme_config) { + continue; + } + if searching { + theme.rank = rank(&theme.theme, &tokens); + if theme.rank.is_some() { + self.filtered.push(i); + } + } else { + self.filtered.push(i); + theme.rank = None; + } + } + + if old == self.filtered { + return; + } + + if self.filtered.is_empty() { + self.current = 0; + self.window = 0; + return; + } + + let (current, window) = (|| { + let Some(selected) = &selected else { + return (0, 0); + }; + for (i, &index) in self.filtered.iter().enumerate() { + if &self.themes[index].theme == selected { + // Keep the relative position but ensure all search results are visible + let new_window = i.saturating_sub(relative); + // If the new window would hide some results at the top, adjust it + return ( + i, + if self.filtered.len() <= SMALL_LIST_THRESHOLD { + 0 + } else { + new_window + }, + ); + } + } + (0, 0) + })(); + self.current = current; + self.window = window; + } + + pub fn up(&mut self, count: usize) { + if self.filtered.is_empty() { + self.current = 0; + return; + } + self.current = self.current.saturating_sub(count); + } + + pub fn down(&mut self, count: usize) { + if self.filtered.is_empty() { + self.current = 0; + return; + } + self.current += count; + if self.current >= self.filtered.len() { + self.current = self.filtered.len() - 1; + } + } + + fn selected(&self) -> Option<&ThemeListElement> { + self.filtered.get(self.current).map(|&i| &self.themes[i]) + } + + /// Take the pending side effects. + pub fn take_effects(&mut self) -> Vec { + std::mem::take(&mut self.effects) + } + + /// Handle a terminal event (Zig: `update`). + pub fn update(&mut self, event: &Event) { + match event { + Event::Key(key) if key.kind != KeyEventKind::Release => self.update_key(key), + Event::Mouse(m) => self.mouse = Some(*m), + _ => {} + } + } + + fn update_key(&mut self, key: &KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + let plain = key.modifiers.difference(KeyModifiers::SHIFT).is_empty(); + let code = key.code; + let is_char = |c: char| code == KeyCode::Char(c) && plain && !shift; + + if ctrl && code == KeyCode::Char('c') { + self.should_quit = true; + } + + match self.mode { + Mode::Normal => { + if plain && (is_char('q') || code == KeyCode::Esc) { + self.should_quit = true; + } + if (plain && (is_char('?') || code == KeyCode::Char('?') || code == KeyCode::F(1))) + || (ctrl && code == KeyCode::Char('h')) + { + self.mode = Mode::Help; + } + if is_char('/') { + self.mode = Mode::Search; + } + if plain && code == KeyCode::Enter { + self.mode = Mode::Save; + } + if ctrl && matches!(code, KeyCode::Char('x' | '/' | '_')) { + self.text_input.clear(); + self.update_filtered(); + } + if plain && code == KeyCode::Home { + self.current = 0; + } + if plain && code == KeyCode::End { + self.current = self.filtered.len().saturating_sub(1); + } + if plain && (matches!(code, KeyCode::Char('j' | '+') | KeyCode::Down)) { + self.down(1); + } + if plain && code == KeyCode::PageDown { + self.down(20); + } + if plain && (matches!(code, KeyCode::Char('k' | '-') | KeyCode::Up)) { + self.up(1); + } + if plain && code == KeyCode::PageUp { + self.up(20); + } + if plain && matches!(code, KeyCode::Char('h' | 'x')) && !shift { + self.hex = true; + } + if is_char('d') { + self.hex = false; + } + if is_char('c') { + if let Some(t) = self.selected() { + let name = t.theme.clone(); + self.effects.push(Effect::Copy(name)); + } + } else if code == KeyCode::Char('C') + || (code == KeyCode::Char('c') && shift && !ctrl) + { + if let Some(t) = self.selected() { + let path = t.path.clone(); + self.effects.push(Effect::Copy(path)); + } + } + if is_char('f') { + self.theme_filter = match self.theme_filter { + ColorScheme::All => ColorScheme::Dark, + ColorScheme::Dark => ColorScheme::Light, + ColorScheme::Light => ColorScheme::All, + }; + self.update_filtered(); + } + } + Mode::Help => { + if is_char('q') { + self.should_quit = true; + } + if (plain + && (is_char('?') + || code == KeyCode::Char('?') + || code == KeyCode::Esc + || code == KeyCode::F(1))) + || (ctrl && code == KeyCode::Char('h')) + { + self.mode = Mode::Normal; + } + } + Mode::Search => { + if plain && matches!(code, KeyCode::Esc | KeyCode::Enter) { + self.mode = Mode::Normal; + return; + } + if ctrl && matches!(code, KeyCode::Char('x' | '/' | '_')) { + self.text_input.clear(); + self.update_filtered(); + return; + } + self.text_input.update(key); + self.update_filtered(); + } + Mode::Save => { + if is_char('q') { + self.should_quit = true; + } + if plain && matches!(code, KeyCode::Esc | KeyCode::Enter) { + self.mode = Mode::Normal; + } + if is_char('w') { + self.save_selected_theme(); + } + } + } + } + + fn save_selected_theme(&self) { + let Some(theme) = self.selected() else { + return; + }; + let _ = write_auto_theme_file(self.auto_theme_path.as_deref(), &theme.theme); + } + + // ---- colors ---- + + fn ui_fg(&self) -> Color { + match self.color_scheme { + Scheme::Light => Color::Rgb(0, 0, 0), + Scheme::Dark => Color::Rgb(0xff, 0xff, 0xff), + } + } + fn ui_bg(&self) -> Color { + match self.color_scheme { + Scheme::Light => Color::Rgb(0xff, 0xff, 0xff), + Scheme::Dark => Color::Rgb(0, 0, 0), + } + } + fn ui_standard(&self) -> Style { + Style::default().fg(self.ui_fg()).bg(self.ui_bg()) + } + fn ui_hover_bg(&self) -> Color { + match self.color_scheme { + Scheme::Light => Color::Rgb(0xbb, 0xbb, 0xbb), + Scheme::Dark => Color::Rgb(0x22, 0x22, 0x22), + } + } + fn ui_highlighted(&self) -> Style { + Style::default().fg(self.ui_fg()).bg(self.ui_hover_bg()) + } + fn ui_selected(&self) -> Style { + let bg = match self.color_scheme { + Scheme::Light => Color::Rgb(0xaa, 0xaa, 0xaa), + Scheme::Dark => Color::Rgb(0x33, 0x33, 0x33), + }; + Style::default().fg(Color::Rgb(0, 0xaa, 0)).bg(bg) + } + fn ui_err(&self) -> Style { + Style::default().fg(Color::Rgb(0xff, 0, 0)).bg(self.ui_bg()) + } + + // ---- drawing ---- + + /// Draw the whole UI into `buf`. Returns the cursor position (search mode). + pub fn draw(&mut self, buf: &mut Buffer) -> Option { + let area = buf.area; + let cell = RefCell::new(buf); + let win = Win { buf: &cell, area }; + win.clear(); + + let theme_list = win.child(0, 0, LIST_WIDTH.min(area.width), area.height); + self.list_height = theme_list.area.height; + + let mut highlight: Option = None; + + if let Some(mouse) = self.mouse.take() { + if self.mode == Mode::Normal { + match mouse.kind { + MouseEventKind::ScrollUp => self.up(1), + MouseEventKind::ScrollDown => self.down(1), + _ => {} + } + if mouse.column < theme_list.area.width && mouse.row < theme_list.area.height { + if mouse.kind == MouseEventKind::Up(MouseButton::Left) { + let selection = self.window + usize::from(mouse.row); + if selection < self.filtered.len() { + self.current = selection; + } + } + highlight = Some(mouse.row); + } + } + } + + if self.filtered.is_empty() { + self.current = 0; + self.window = 0; + } else { + let height = usize::from(theme_list.area.height).max(1); + let start = self.window; + let end = self.window + height - 1; + if self.current > end { + self.window = self.current + 1 - height; + } + if self.current < start { + self.window = self.current; + } + if self.window >= self.filtered.len() { + self.window = self.filtered.len() - 1; + } + } + + let ui = self.ui_standard(); + theme_list.fill(ui); + + for row in 0..theme_list.area.height { + let index = self.window + usize::from(row); + if index >= self.filtered.len() { + break; + } + let theme = &self.themes[self.filtered[index]]; + + #[derive(PartialEq)] + enum St { + Normal, + Highlighted, + Selected, + } + let style = if index == self.current { + St::Selected + } else if highlight == Some(row) { + St::Highlighted + } else { + St::Normal + }; + + if style == St::Selected { + theme_list.print("\u{276f} ", self.ui_selected(), row, 0); + } + theme_list.print( + &theme.theme, + match style { + St::Normal => self.ui_standard(), + St::Highlighted => self.ui_highlighted(), + St::Selected => self.ui_selected(), + }, + row, + 2, + ); + if style == St::Selected { + let name_len = gw(&theme.theme); + if name_len + 4 < LIST_WIDTH { + for i in (2 + name_len)..(LIST_WIDTH - 2) { + theme_list.print(" ", self.ui_selected(), row, i); + } + } + theme_list.print( + " \u{276e}", + self.ui_selected(), + row, + LIST_WIDTH.saturating_sub(2), + ); + } + } + + self.draw_preview(&win, LIST_WIDTH); + + let mut cursor = None; + match self.mode { + Mode::Normal => {} + Mode::Help => { + let (width, height) = (60u16, 22u16); + let child = win.child_border( + (area.width / 2).saturating_sub(width / 2), + (area.height / 2).saturating_sub(height / 2), + width, + height, + self.ui_standard(), + ); + let key_help: [(&str, &str); 19] = [ + ("^C, q, ESC", "Quit."), + ("F1, ?, ^H", "Toggle help window."), + ("f", "Cycle through theme filters."), + ("k, \u{2191}", "Move up 1 theme."), + ("ScrollUp", "Move up 1 theme."), + ("PgUp", "Move up 20 themes."), + ("j, \u{2193}", "Move down 1 theme."), + ("ScrollDown", "Move down 1 theme."), + ("PgDown", "Move down 20 themes."), + ("h, x", "Show palette numbers in hexadecimal."), + ("d", "Show palette numbers in decimal."), + ("c", "Copy theme name to the clipboard."), + ("C", "Copy theme path to the clipboard."), + ("Home", "Go to the start of the list."), + ("End", "Go to the end of the list."), + ("/", "Start search."), + ("^X, ^/", "Clear search."), + ("\u{23ce}", "Save theme or close search window."), + ("w", "Write theme to auto config file."), + ]; + for (i, (keys, help)) in key_help.iter().enumerate() { + let row = u16::try_from(i).unwrap_or(0) + 1; + child.print(keys, self.ui_standard(), row, 2); + child.print("\u{2014}", self.ui_standard(), row, 15); + child.print(help, self.ui_standard(), row, 17); + } + } + Mode::Search => { + let child = win.child_border( + 20, + area.height.saturating_sub(5), + area.width.saturating_sub(40), + 3, + self.ui_standard(), + ); + child.print(&self.text_input.text, self.ui_standard(), 0, 0); + let col = gw(&self + .text_input + .text + .chars() + .take(self.text_input.cursor) + .collect::()); + if col < child.area.width { + cursor = Some(Position::new(child.area.x + col, child.area.y)); + } + } + Mode::Save => { + if let Some(theme) = self.selected() { + let (width, height) = (92u16, 17u16); + let child = win.child_border( + (area.width / 2).saturating_sub(width / 2), + (area.height / 2).saturating_sub(height / 2), + width, + height, + self.ui_standard(), + ); + let theme_line = format!("theme = {}", theme.theme); + let save_instructions: [&str; 14] = [ + "To apply this theme, add the following line to your Ghostty configuration:", + "", + &theme_line, + "", + "Save the configuration file and then reload it to apply the new theme.", + "", + "Or press 'w' to write an auto theme file to your system's preferred default config path.", + "Then add the following line to your Ghostty configuration and reload:", + "", + "config-file = ?auto/theme.ghostty", + "", + "For more details on configuration and themes, visit the Ghostty documentation:", + "", + "https://ghostty.org/docs/config/reference", + ]; + for (i, instruction) in save_instructions.iter().enumerate() { + child.print( + instruction, + self.ui_standard(), + u16::try_from(i).unwrap_or(0) + 1, + 2, + ); + } + } + } + } + cursor + } + + fn load_current(&mut self) -> Option<&Loaded> { + let theme = self.selected()?.clone(); + let stale = self.loaded.as_ref().is_none_or(|(p, _)| p != &theme.path); + if stale { + let mut config = Config::new_default(); + let loaded = match config.load_file(&absolute_path(&theme.path)) { + Ok(()) => Loaded::Ok(Box::new(config)), + Err(e) => Loaded::Err(error_name(&e)), + }; + self.loaded = Some((theme.path, loaded)); + } + self.loaded.as_ref().map(|(_, l)| l) + } + + fn draw_preview(&mut self, win: &Win<'_, '_>, x_off: u16) { + let area = win.area; + let x_off = x_off.min(area.width); + let width = area.width - x_off; + let ui_err = self.ui_err(); + let ui_std = self.ui_standard(); + let hex = self.hex; + + if self.filtered.is_empty() { + let child = win.child(0, 0, area.width, area.height); + child.fill(ui_std); + child.print( + "No theme found!", + ui_std, + (area.height / 2).saturating_sub(1), + (area.width / 2).saturating_sub(7), + ); + return; + } + + let Some(theme) = self.selected().cloned() else { + return; + }; + let config = match self.load_current() { + Some(Loaded::Ok(c)) => c.clone(), + Some(Loaded::Err(err)) => { + let child = win.child(x_off, 0, width, area.height); + child.fill(ui_std); + let middle = child.area.height / 2; + let text = format!("Unable to open {} from:", theme.theme); + child.print( + &text, + ui_err, + middle.saturating_sub(1), + (child.area.width / 2).saturating_sub(gw(&text) / 2), + ); + child.print( + &theme.path, + ui_err, + middle, + (child.area.width / 2).saturating_sub(gw(&theme.path) / 2), + ); + child.print( + err, + ui_err, + middle + 1, + (child.area.width / 2).saturating_sub(gw(err) / 2), + ); + return; + } + None => return, + }; + + let mut next_start: u16 = 0; + + let rgb = |c: ghostty_config::types_a::Color| Color::Rgb(c.r, c.g, c.b); + let term_rgb = |c: &Option, default: Color| match c { + Some(TerminalColor::Color(c)) => rgb(*c), + _ => default, + }; + let fg = rgb(config.foreground); + let bg = rgb(config.background); + let cursor_fg = term_rgb(&config.cursor_text, bg); + let cursor_bg = term_rgb(&config.cursor_color, fg); + let selection_fg = term_rgb(&config.selection_foreground, bg); + let selection_bg = term_rgb(&config.selection_background, fg); + let cursor = Style::default().fg(cursor_fg).bg(cursor_bg); + let standard_selection = Style::default().fg(selection_fg).bg(selection_bg); + let standard = Style::default().fg(fg).bg(bg); + let standard_bold = standard.add_modifier(Modifier::BOLD); + let standard_italic = standard.add_modifier(Modifier::ITALIC); + let standard_bold_italic = standard.add_modifier(Modifier::BOLD | Modifier::ITALIC); + // TODO: vaxis supports double/dashed/dotted/curly underlines. + let standard_underline = standard.add_modifier(Modifier::UNDERLINED); + + let color = |i: usize| { + let c = config.palette.value[i]; + Color::Rgb(c.r, c.g, c.b) + }; + + // Header. + { + let child = win.child(x_off, next_start, width, 4); + child.fill(standard); + child.print( + &theme.theme, + standard_bold_italic, + 1, + (child.area.width / 2).saturating_sub(gw(&theme.theme) / 2), + ); + child.print( + &theme.path, + standard, + 2, + (child.area.width / 2).saturating_sub(gw(&theme.path) / 2), + ); + next_start += child.area.height; + } + + // Diagnostics. + if !config.diagnostics.items().is_empty() { + let n = u16::try_from(config.diagnostics.items().len()).unwrap_or(u16::MAX); + let child = win.child(x_off, next_start, width, 2 + n); + let text = "Problems were encountered trying to load the theme:"; + child.print( + text, + ui_err, + 0, + (child.area.width / 2).saturating_sub(gw(text) / 2), + ); + for (i, diag) in config.diagnostics.items().iter().enumerate() { + child.print( + &diag.to_string(), + ui_err, + 2 + u16::try_from(i).unwrap_or(0), + 2, + ); + } + next_start += 2 + n; + } + + // Palette. + { + let child = win.child(x_off, next_start, width, 6); + child.fill(standard); + for i in 0..16usize { + let r = u16::try_from(i / 8).unwrap_or(0); + let c = u16::try_from(i % 8).unwrap_or(0); + let text = if hex { + format!(" {i:02x}") + } else { + format!("{i:3}") + }; + child.print(&text, standard, 3 * r, c * 8); + let block = Style::default().fg(color(i)).bg(bg); + child.print("\u{2588}\u{2588}\u{2588}\u{2588}", block, 3 * r, 4 + c * 8); + child.print( + "\u{2588}\u{2588}\u{2588}\u{2588}", + block, + 3 * r + 1, + 4 + c * 8, + ); + } + next_start += child.area.height; + } + + // Code sample. + { + let child = win.child(x_off, next_start, width, 24); + let bold = Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD); + let c = |i: usize| Style::default().fg(color(i)).bg(bg); + let (color1, color2, color3, color4, color5, color6) = + (c(1), c(2), c(3), c(4), c(5), c(6)); + let color6ul = color6.add_modifier(Modifier::UNDERLINED); + let (color10, color12, color238) = (c(10), c(12), c(238)); + let _ = standard_underline; + child.fill(standard); + + let rule = |row: u16, head: &str| { + child.print_segments(&[(head, color238)], row, 2); + if child.area.width > 10 { + for col in 10..child.area.width { + child.print("\u{2500}", color238, row, col); + } + } + }; + + child.print_segments( + &[ + ("\u{2192}", color2), + (" ", standard), + ("bat", color4), + (" ", standard), + ("ziggzagg.zig", color6ul), + ], + 0, + 2, + ); + rule( + 1, + "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{252c}", + ); + child.print_segments( + &[ + (" \u{2502} ", color238), + ("File: ", standard), + ("ziggzagg.zig", bold), + ], + 2, + 2, + ); + rule( + 3, + "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{253c}", + ); + child.print_segments( + &[ + (" 1 \u{2502} ", color238), + ("const", color5), + (" std ", standard), + ("= @import", color5), + ("(", standard), + ("\"std\"", color10), + (");", standard), + ], + 4, + 2, + ); + child.print_segments(&[(" 2 \u{2502}", color238)], 5, 2); + child.print_segments( + &[ + (" 3 \u{2502} ", color238), + ("pub ", color5), + ("fn ", color12), + ("main", color2), + ("() ", standard), + ("!", color5), + ("void", color12), + (" {", standard), + ], + 6, + 2, + ); + child.print_segments( + &[ + (" 4 \u{2502} ", color238), + ("const ", color5), + ("stdout ", standard), + ("=", color5), + (" std.Io.getStdOut().writer();", standard), + ], + 7, + 2, + ); + child.print_segments( + &[ + (" 5 \u{2502} ", color238), + ("var ", color5), + ("i:", standard), + (" usize", color12), + (" =", color5), + (" 1", color4), + (";", standard), + ], + 8, + 2, + ); + child.print_segments( + &[ + (" 6 \u{2502} ", color238), + ("while ", color5), + ("(i ", standard), + ("<= ", color5), + ("16", color4), + (") : (i ", standard), + ("+= ", color5), + ("1", color4), + (") {", standard), + ], + 9, + 2, + ); + child.print_segments( + &[ + (" 7 \u{2502} ", color238), + ("if ", color5), + ("(i ", standard), + ("% ", color5), + ("15 ", color4), + ("== ", color5), + ("0", color4), + (") {", standard), + ], + 10, + 2, + ); + child.print_segments( + &[ + (" 8 \u{2502} ", color238), + ("try ", color5), + ("stdout.writeAll(", standard), + ("\"ZiggZagg", color10), + ("\\n", color12), + ("\"", color10), + (");", standard), + ], + 11, + 2, + ); + child.print_segments( + &[ + (" 9 \u{2502} ", color238), + ("} ", standard), + ("else if ", color5), + ("(i ", standard), + ("% ", color5), + ("3 ", color4), + ("== ", color5), + ("0", color4), + (") {", standard), + ], + 12, + 2, + ); + child.print_segments( + &[ + (" 10 \u{2502} ", color238), + ("try ", color5), + ("stdout.writeAll(", standard), + ("\"Zigg", color10), + ("\\n", color12), + ("\"", color10), + (");", standard), + ], + 13, + 2, + ); + child.print_segments( + &[ + (" 11 \u{2502} ", color238), + ("} ", standard), + ("else if ", color5), + ("(i ", standard), + ("% ", color5), + ("5 ", color4), + ("== ", color5), + ("0", color4), + (") {", standard), + ], + 14, + 2, + ); + child.print_segments( + &[ + (" 12 \u{2502} ", color238), + ("try ", color5), + ("stdout.writeAll(", standard), + ("\"Zagg", color10), + ("\\n", color12), + ("\"", color10), + (");", standard), + ], + 15, + 2, + ); + child.print_segments( + &[ + (" 13 \u{2502} ", color238), + ("} ", standard), + ("else ", color5), + ("{", standard), + ], + 16, + 2, + ); + child.print_segments( + &[ + (" 14 \u{2502} ", color238), + ("try ", color5), + ("stdout.print(\"{d}\\n\", .{i})", standard_selection), + (";", cursor), + ], + 17, + 2, + ); + child.print_segments( + &[(" 15 \u{2502} ", color238), ("}", standard)], + 18, + 2, + ); + child.print_segments( + &[(" 16 \u{2502} ", color238), ("}", standard)], + 19, + 2, + ); + child.print_segments(&[(" 17 \u{2502} ", color238), ("}", standard)], 20, 2); + rule( + 21, + "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2534}", + ); + child.print_segments( + &[ + ("ghostty ", color6), + ("on ", standard), + ("\u{e0a0} main ", color4), + ("[+] ", color1), + ("via ", standard), + ("\u{e0a0} v0.13.0 ", color3), + ("via ", standard), + ("\u{f313} impure (ghostty-env)", color4), + ], + 22, + 2, + ); + child.print_segments( + &[ + ("\u{2726} ", color4), + ("at ", standard), + ("10:36:15 ", color3), + ("\u{2192}", color2), + ], + 23, + 2, + ); + next_start += child.area.height; + } + + if next_start < area.height { + let child = win.child(x_off, next_start, width, area.height - next_start); + child.fill(standard); + let mut words = LOREM_IPSUM.split([' ', '\n']); + let mut row: u16 = 1; + let mut col: u16 = 2; + while row < child.area.height { + let word = if let Some(w) = words.next() { + w + } else { + words = LOREM_IPSUM.split([' ', '\n']); + words.next().unwrap_or("") + }; + let word_len = u16::try_from(word.len()).unwrap_or(u16::MAX); + if usize::from(col) + word.len() > usize::from(child.area.width) { + row += 1; + col = 2; + } + let style = match word { + "ipsum" => Style::default().fg(color(2)).bg(bg), + "consectetur" => standard_bold, + "reprehenderit" => standard_italic, + "Praesent" => standard_bold_italic, + "auctor" | "dui" | "erat" | "enim" | "odio" => { + // TODO: distinct underline styles (double, dashed, + // dotted, curly) are drawn as single underlines. + standard.add_modifier(Modifier::UNDERLINED) + } + _ => standard, + }; + child.print(word, style, row, col); + col = col.saturating_add(word_len).saturating_add(1); + } + } + } +} + +/// The Zig error name for display (`{}` of an error). +fn error_name(e: &ghostty_config::config::Error) -> String { + use ghostty_config::config::Error; + match e { + Error::Io(io) => match io.kind() { + std::io::ErrorKind::NotFound => "FileNotFound".to_string(), + std::io::ErrorKind::PermissionDenied => "AccessDenied".to_string(), + _ => io.to_string(), + }, + e => e.to_string(), + } +} + +/// `resolveAutoThemePath`: `/auto/theme.ghostty`. +pub fn resolve_auto_theme_path() -> Result { + let main_cfg_path = ghostty_config::file_load::preferred_default_file_path() + .map_err(|e| Error::Other(e.to_string()))?; + let base_dir = std::path::Path::new(&main_cfg_path) + .parent() + .ok_or_else(|| Error::Other("BadPathName".to_string()))?; + Ok(base_dir + .join("auto") + .join("theme.ghostty") + .to_string_lossy() + .into_owned()) +} + +/// `writeAutoThemeFile`. +pub fn write_auto_theme_file(path: Option<&str>, theme_name: &str) -> Result<(), Error> { + let auto_path = match path { + Some(p) => p.to_string(), + None => resolve_auto_theme_path()?, + }; + if let Some(dir) = std::path::Path::new(&auto_path).parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&auto_path, format!("theme = {theme_name}\n"))?; + Ok(()) +} + +// ---- A tiny window abstraction over a ratatui buffer (vaxis.Window) ---- + +/// Display width of a string. +fn gw(s: &str) -> u16 { + crate::tui::gwidth(s) +} + +/// A clipped, offset view onto the buffer (`vaxis.Window`). All coordinates +/// passed to its methods are relative to the window. +#[derive(Clone, Copy)] +struct Win<'a, 'b> { + buf: &'a RefCell<&'b mut Buffer>, + area: Rect, +} + +impl Win<'_, '_> { + /// `Window.clear`. + fn clear(&self) { + self.reset_area(self.area); + } + + fn reset_area(&self, area: Rect) { + let mut buf = self.buf.borrow_mut(); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + if let Some(c) = buf.cell_mut((x, y)) { + c.reset(); + } + } + } + } + + /// `Window.fill` with a blank cell of the given style. + fn fill(&self, style: Style) { + let mut buf = self.buf.borrow_mut(); + for y in self.area.top()..self.area.bottom() { + for x in self.area.left()..self.area.right() { + if let Some(c) = buf.cell_mut((x, y)) { + c.reset(); + c.set_symbol(" "); + c.set_style(style); + } + } + } + } + + /// `Window.child` (without border); clipped to this window. + fn child(&self, x_off: u16, y_off: u16, width: u16, height: u16) -> Self { + let x = self.area.x.saturating_add(x_off).min(self.area.right()); + let y = self.area.y.saturating_add(y_off).min(self.area.bottom()); + let w = width.min(self.area.right() - x); + let h = height.min(self.area.bottom() - y); + Win { + buf: self.buf, + area: Rect::new(x, y, w, h), + } + } + + /// `Window.child` with a border on all sides; returns the inner window. + fn child_border(&self, x_off: u16, y_off: u16, width: u16, height: u16, style: Style) -> Self { + let outer = self.child(x_off, y_off, width, height); + outer.fill(style); + let a = outer.area; + if a.width >= 2 && a.height >= 2 { + let mut buf = self.buf.borrow_mut(); + let mut put = |x: u16, y: u16, s: &str| { + if let Some(c) = buf.cell_mut((x, y)) { + c.set_symbol(s); + c.set_style(style); + } + }; + for x in (a.left() + 1)..(a.right() - 1) { + put(x, a.top(), "\u{2500}"); + put(x, a.bottom() - 1, "\u{2500}"); + } + for y in (a.top() + 1)..(a.bottom() - 1) { + put(a.left(), y, "\u{2502}"); + put(a.right() - 1, y, "\u{2502}"); + } + put(a.left(), a.top(), "\u{256d}"); + put(a.right() - 1, a.top(), "\u{256e}"); + put(a.left(), a.bottom() - 1, "\u{2570}"); + put(a.right() - 1, a.bottom() - 1, "\u{256f}"); + } + outer.child(1, 1, a.width.saturating_sub(2), a.height.saturating_sub(2)) + } + + /// `Window.printSegment` without wrapping (text is clipped). Returns the + /// column after the text. + fn print(&self, text: &str, style: Style, row: u16, col: u16) -> u16 { + if row >= self.area.height || col >= self.area.width { + return col; + } + let x = self.area.x + col; + let y = self.area.y + row; + let max = usize::from(self.area.width - col); + let (nx, _) = self.buf.borrow_mut().set_stringn(x, y, text, max, style); + nx - self.area.x + } + + /// `Window.print`: several segments one after another. + fn print_segments(&self, segments: &[(&str, Style)], row: u16, col: u16) -> u16 { + let mut col = col; + for (text, style) in segments { + col = self.print(text, *style, row, col); + } + col + } +} + +// ---- The terminal driver ---- + +/// Run the interactive preview (Zig: `preview`). Blocks until the user quits. +pub fn preview(themes: Vec, theme_filter: ColorScheme) -> Result<(), Error> { + let mut app = Preview::new(themes, theme_filter); + let mut terminal = ratatui::try_init()?; + let result = run_loop(&mut terminal, &mut app); + ratatui::restore(); + result +} + +fn run_loop(terminal: &mut ratatui::DefaultTerminal, app: &mut Preview) -> Result<(), Error> { + use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; + use crossterm::terminal::SetTitle; + + { + let out = terminal.backend_mut(); + crossterm::execute!( + out, + SetTitle("\u{1f47b} Ghostty Theme Preview \u{1f47b}"), + EnableMouseCapture + )?; + } + + let result = (|| -> Result<(), Error> { + while !app.should_quit { + terminal.draw(|f| draw_frame(app, f))?; + // Block for an event, then drain everything that is pending. + let first = event::read()?; + app.update(&first); + while event::poll(std::time::Duration::ZERO)? { + let ev = event::read()?; + app.update(&ev); + } + for effect in app.take_effects() { + match effect { + Effect::Copy(text) => { + let out = terminal.backend_mut(); + write!(out, "\x1b]52;c;{}\x1b\\", base64(text.as_bytes()))?; + out.flush()?; + } + } + } + } + Ok(()) + })(); + + let _ = crossterm::execute!(terminal.backend_mut(), DisableMouseCapture); + result +} + +fn draw_frame(app: &mut Preview, f: &mut Frame<'_>) { + let cursor = app.draw(f.buffer_mut()); + if let Some(pos) = cursor { + f.set_cursor_position(pos); + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use ghostty_config::theme::Location; + + fn make_themes(dir: &std::path::Path, names: &[(&str, &str)]) -> Vec { + names + .iter() + .map(|(name, bg)| { + let path = dir.join(name); + std::fs::write( + &path, + format!("background = {bg}\nforeground = #eeeeee\npalette = 1=#ff0000\n"), + ) + .unwrap(); + ThemeListElement { + location: Location::Resources, + path: path.to_string_lossy().into_owned(), + theme: (*name).to_string(), + rank: None, + } + }) + .collect() + } + + fn key(code: KeyCode) -> Event { + Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) + } + fn ch(c: char) -> Event { + key(KeyCode::Char(c)) + } + fn ctrl(c: char) -> Event { + Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)) + } + + fn app(tmp: &tempfile::TempDir) -> Preview { + let themes = make_themes( + tmp.path(), + &[ + ("alpha", "#000000"), + ("beta", "#ffffff"), + ("gamma-dark", "#101010"), + ("delta-light", "#f0f0f0"), + ], + ); + let mut p = Preview::new(themes, ColorScheme::All); + p.color_scheme = Scheme::Light; + p + } + + fn render(p: &mut Preview, w: u16, h: u16) -> Buffer { + let mut buf = Buffer::empty(Rect::new(0, 0, w, h)); + p.draw(&mut buf); + buf + } + + fn row_text(buf: &Buffer, y: u16) -> String { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol().to_string()) + .collect() + } + + #[test] + fn navigation_and_quit() { + let tmp = tempfile::tempdir().unwrap(); + let mut p = app(&tmp); + assert_eq!(4, p.filtered.len()); + p.update(&ch('j')); + p.update(&key(KeyCode::Down)); + assert_eq!(2, p.current); + p.update(&key(KeyCode::PageDown)); + assert_eq!(3, p.current); + p.update(&ch('k')); + assert_eq!(2, p.current); + p.update(&key(KeyCode::PageUp)); + assert_eq!(0, p.current); + p.update(&key(KeyCode::End)); + assert_eq!(3, p.current); + p.update(&key(KeyCode::Home)); + assert_eq!(0, p.current); + assert!(!p.should_quit); + p.update(&ch('q')); + assert!(p.should_quit); + + let mut p = app(&tmp); + p.update(&ctrl('c')); + assert!(p.should_quit); + let mut p = app(&tmp); + p.update(&key(KeyCode::Esc)); + assert!(p.should_quit); + } + + #[test] + fn filter_cycles_and_filters() { + let tmp = tempfile::tempdir().unwrap(); + let mut p = app(&tmp); + p.update(&ch('f')); + assert_eq!(ColorScheme::Dark, p.theme_filter); + let names: Vec<_> = p + .filtered + .iter() + .map(|&i| p.themes[i].theme.as_str()) + .collect(); + assert_eq!(vec!["alpha", "gamma-dark"], names); + p.update(&ch('f')); + assert_eq!(ColorScheme::Light, p.theme_filter); + assert_eq!(2, p.filtered.len()); + p.update(&ch('f')); + assert_eq!(ColorScheme::All, p.theme_filter); + assert_eq!(4, p.filtered.len()); + } + + #[test] + fn search_mode() { + let tmp = tempfile::tempdir().unwrap(); + let mut p = app(&tmp); + p.update(&ch('/')); + assert_eq!(Mode::Search, p.mode); + p.update(&ch('G')); + p.update(&ch('a')); + p.update(&ch('m')); + assert_eq!("Gam", p.text_input.text); + assert_eq!(1, p.filtered.len()); + assert_eq!("gamma-dark", p.themes[p.filtered[0]].theme); + p.update(&key(KeyCode::Backspace)); + p.update(&key(KeyCode::Backspace)); + p.update(&key(KeyCode::Backspace)); + assert_eq!(4, p.filtered.len()); + p.update(&ch('l')); + p.update(&ch('t')); + assert_eq!(1, p.filtered.len()); // "delta-light" is the only match for "lt" + p.update(&ctrl('x')); + assert_eq!("", p.text_input.text); + assert_eq!(4, p.filtered.len()); + p.update(&key(KeyCode::Enter)); + assert_eq!(Mode::Normal, p.mode); + } + + #[test] + fn hex_toggle_and_help_mode() { + let tmp = tempfile::tempdir().unwrap(); + let mut p = app(&tmp); + p.update(&ch('x')); + assert!(p.hex); + p.update(&ch('d')); + assert!(!p.hex); + p.update(&key(KeyCode::F(1))); + assert_eq!(Mode::Help, p.mode); + // Keys are modal: 'j' does nothing in help. + p.update(&ch('j')); + assert_eq!(0, p.current); + p.update(&key(KeyCode::Esc)); + assert_eq!(Mode::Normal, p.mode); + assert!(!p.should_quit); + p.update(&ch('?')); + assert_eq!(Mode::Help, p.mode); + } + + #[test] + fn copy_and_save() { + let tmp = tempfile::tempdir().unwrap(); + let mut p = app(&tmp); + p.update(&ch('c')); + p.update(&Event::Key(KeyEvent::new( + KeyCode::Char('C'), + KeyModifiers::SHIFT, + ))); + let effects = p.take_effects(); + assert_eq!(Effect::Copy("alpha".into()), effects[0]); + assert_eq!( + Effect::Copy(tmp.path().join("alpha").to_string_lossy().into_owned()), + effects[1] + ); + + let auto = tmp.path().join("cfg/auto/theme.ghostty"); + p.auto_theme_path = Some(auto.to_string_lossy().into_owned()); + p.update(&key(KeyCode::Enter)); + assert_eq!(Mode::Save, p.mode); + p.update(&ch('w')); + assert_eq!("theme = alpha\n", std::fs::read_to_string(&auto).unwrap()); + p.update(&key(KeyCode::Esc)); + assert_eq!(Mode::Normal, p.mode); + } + + #[test] + fn draw_layout() { + let tmp = tempfile::tempdir().unwrap(); + let mut p = app(&tmp); + p.update(&ch('j')); + let buf = render(&mut p, 120, 60); + // List on the left: selected item has the marker and the arrow at col 30. + let r1 = row_text(&buf, 1); + assert!(r1.starts_with("\u{276f} beta"), "{r1:?}"); + assert!(r1.contains("\u{276e}"), "{r1:?}"); + assert!(row_text(&buf, 0).starts_with(" alpha")); + // Preview: theme name centered in the header, palette numbers, code sample. + let preview_header = row_text(&buf, 1); + assert!(preview_header.contains("beta")); + let all: String = (0..60) + .map(|y| row_text(&buf, y)) + .collect::>() + .join("\n"); + assert!(all.contains(" 0")); + assert!(all.contains("ziggzagg.zig")); + assert!(all.contains("const std = @import(\"std\");")); + assert!(all.contains("Lorem ipsum")); + // The palette swatches use the theme palette color 1. + let swatch = (32..120) + .find(|&x| buf[(x, 4)].fg == Color::Rgb(255, 0, 0)) + .is_some(); + assert!(swatch); + // Background of the preview is the theme background. + assert_eq!(Color::Rgb(255, 255, 255), buf[(100, 0)].bg); + } + + #[test] + fn draw_help_and_save_and_search_overlays() { + let tmp = tempfile::tempdir().unwrap(); + let mut p = app(&tmp); + p.update(&ch('?')); + let buf = render(&mut p, 120, 40); + let all: String = (0..40) + .map(|y| row_text(&buf, y)) + .collect::>() + .join("\n"); + assert!(all.contains("Cycle through theme filters.")); + assert!(all.contains('\u{256d}') && all.contains('\u{256f}')); + + p.update(&key(KeyCode::Esc)); + p.update(&key(KeyCode::Enter)); + let buf = render(&mut p, 120, 40); + let all: String = (0..40) + .map(|y| row_text(&buf, y)) + .collect::>() + .join("\n"); + assert!(all.contains("theme = alpha")); + assert!(all.contains("config-file = ?auto/theme.ghostty")); + + p.update(&key(KeyCode::Esc)); + p.update(&ch('/')); + p.update(&ch('b')); + let mut buf = Buffer::empty(Rect::new(0, 0, 120, 40)); + let cursor = p.draw(&mut buf); + assert!(cursor.is_some()); + } + + #[test] + fn empty_filter_shows_no_theme_found() { + let tmp = tempfile::tempdir().unwrap(); + let mut p = app(&tmp); + p.update(&ch('/')); + p.update(&ch('z')); + p.update(&ch('z')); + assert_eq!(0, p.filtered.len()); + let buf = render(&mut p, 80, 20); + assert!(row_text(&buf, 9).contains("No theme found!")); + // Navigation does not panic with an empty list. + p.update(&key(KeyCode::Enter)); + p.update(&key(KeyCode::Esc)); + p.update(&key(KeyCode::End)); + p.update(&ch('j')); + p.update(&ch('c')); + assert_eq!(0, p.current); + } + + #[test] + fn unreadable_theme_shows_error() { + let tmp = tempfile::tempdir().unwrap(); + let mut p = app(&tmp); + std::fs::remove_file(tmp.path().join("alpha")).unwrap(); + let buf = render(&mut p, 120, 30); + let all: String = (0..30) + .map(|y| row_text(&buf, y)) + .collect::>() + .join("\n"); + assert!(all.contains("Unable to open alpha from:"), "{all}"); + assert!(all.contains("FileNotFound")); + } + + #[test] + fn mouse_click_selects_and_wheel_scrolls() { + let tmp = tempfile::tempdir().unwrap(); + let mut p = app(&tmp); + let mouse = |kind, row| { + Event::Mouse(MouseEvent { + kind, + column: 3, + row, + modifiers: KeyModifiers::NONE, + }) + }; + p.update(&mouse(MouseEventKind::Up(MouseButton::Left), 2)); + render(&mut p, 120, 30); + assert_eq!(2, p.current); + p.update(&mouse(MouseEventKind::ScrollUp, 0)); + render(&mut p, 120, 30); + assert_eq!(1, p.current); + p.update(&mouse(MouseEventKind::ScrollDown, 0)); + render(&mut p, 120, 30); + assert_eq!(2, p.current); + // Click below the last theme does nothing. + p.update(&mouse(MouseEventKind::Up(MouseButton::Left), 20)); + render(&mut p, 120, 30); + assert_eq!(2, p.current); + } + + #[test] + fn window_scrolls_with_selection() { + let tmp = tempfile::tempdir().unwrap(); + let names: Vec = (0..40).map(|i| format!("theme{i:02}")).collect(); + let refs: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), "#000000")).collect(); + let themes = make_themes(tmp.path(), &refs); + let mut p = Preview::new(themes, ColorScheme::All); + render(&mut p, 100, 10); + p.update(&key(KeyCode::PageDown)); + let buf = render(&mut p, 100, 10); + assert_eq!(20, p.current); + assert_eq!(11, p.window); + assert!(row_text(&buf, 9).starts_with("\u{276f} theme20")); + } + + #[test] + fn rank_is_subsequence() { + assert!(rank("Catppuccin Mocha", &["cat", "moc"]).is_some()); + assert!(rank("Catppuccin Mocha", &["cpm"]).is_some()); + assert!(rank("Catppuccin Mocha", &["xyz"]).is_none()); + assert!(rank("abc", &["cba"]).is_none()); + assert!(rank("abc", &[]).is_some()); + } + + #[test] + fn text_input_editing() { + let mut t = TextInput::default(); + for c in "héllo wörld".chars() { + t.update(&KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + assert_eq!("héllo wörld", t.text); + t.update(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + t.update(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert_eq!("héllo wörd", t.text); + t.update(&KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert_eq!("héllo d", t.text); + t.update(&KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); + t.update(&KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); + assert_eq!("éllo d", t.text); + t.update(&KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!("", t.text); + } + + #[test] + fn base64_encoding() { + assert_eq!("", base64(b"")); + assert_eq!("Zg==", base64(b"f")); + assert_eq!("Zm8=", base64(b"fo")); + assert_eq!("Zm9v", base64(b"foo")); + assert_eq!("Zm9vYmFy", base64(b"foobar")); + } +} diff --git a/crates/ghostty-cli/src/lorem_ipsum.txt b/crates/ghostty-cli/src/lorem_ipsum.txt new file mode 100644 index 0000000..13b9a52 --- /dev/null +++ b/crates/ghostty-cli/src/lorem_ipsum.txt @@ -0,0 +1,45 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras hendrerit aliquet +turpis non dictum. Mauris pulvinar nisl sit amet dui cursus tempus. Pellentesque +ut dui justo. Etiam quis magna sagittis nisi pretium consequat vitae ut nisl. +Sed at metus id odio pulvinar sodales. Vestibulum sollicitudin, sem id tristique +vestibulum, neque ante dictum tortor, in convallis mi enim ac lorem. Suspendisse +orci ex, ullamcorper sed leo vitae, mattis egestas nisl. Morbi id est vel +ipsum mollis convallis vel at mauris. Duis vehicula facilisis placerat. Aliquam +venenatis auctor ipsum vel elementum. Proin ac tincidunt lacus. Sed facilisis +tellus ullamcorper bibendum lobortis. Pellentesque porta, lacus quis efficitur +pulvinar, sem mi varius ante, sed finibus diam ante et risus. + +Morbi ut sollicitudin justo. Nulla mattis mi ac mauris tincidunt tempor. Morbi +vel gravida erat. Ut eu risus quis nisi facilisis aliquet varius id orci. +Pellentesque tortor diam, porttitor nec urna nec, convallis consectetur dui. +Vestibulum et hendrerit ipsum. Morbi pharetra dictum turpis in elementum. Ut +nec volutpat nunc, at venenatis leo. Morbi eget nulla luctus, tincidunt dui vel, +cursus urna. Maecenas ac pellentesque nisi. Quisque ut lorem porta, eleifend +metus id, pellentesque tellus. + +Vivamus gravida convallis felis, at hendrerit dolor. Vestibulum tincidunt id +augue quis hendrerit. Praesent venenatis elit quis posuere gravida. Praesent +at massa a purus maximus tempus. Proin dui leo, feugiat et erat ac, tincidunt +aliquam risus. Aenean rutrum hendrerit turpis, sit amet consectetur justo porta +non. Sed auctor justo elit, sed mollis odio ullamcorper nec. Pellentesque ac +hendrerit tortor. Praesent quis viverra dui, sit amet imperdiet magna. + +Mauris iaculis maximus felis, aliquet vehicula neque sagittis nec. Duis +convallis purus enim, vel scelerisque purus dignissim eu. Donec congue sapien +a neque rhoncus, sit amet accumsan libero tincidunt. Proin vitae placerat urna. +Donec dolor sapien, fringilla sed semper sit amet, sollicitudin sit amet orci. +Mauris maximus convallis vehicula. Aliquam urna ipsum, fermentum ac iaculis vel, +blandit eget lorem. Sed enim ante, sodales a diam in, convallis interdum quam. +Duis non urna risus. Proin ac neque at risus ullamcorper mattis eu vel nunc. +Proin et ipsum euismod, ullamcorper justo et, imperdiet est. Curabitur quis +arcu faucibus, bibendum nisl nec, hendrerit sapien. Curabitur vitae ante risus. +Praesent eget sagittis tortor. + +Mauris aliquam nec nibh eu congue. Nullam congue auctor vestibulum. Donec +posuere sapien nec massa efficitur tincidunt. Vestibulum ante ipsum primis in +faucibus orci luctus et ultrices posuere cubilia curae; Proin molestie, nisl +in tincidunt condimentum, ante metus fermentum felis, ac molestie lacus dui vel +dolor. Donec ornare laoreet posuere. Etiam id tincidunt ante. Maecenas semper +diam ac tortor facilisis egestas. Nam eu bibendum nisl. Integer tempor nisl nec +ex consectetur, quis lobortis enim finibus. Sed ac erat posuere, fermentum metus +sed, suscipit nisl. diff --git a/crates/ghostty-cli/src/new_window.rs b/crates/ghostty-cli/src/new_window.rs new file mode 100644 index 0000000..ef1d3ff --- /dev/null +++ b/crates/ghostty-cli/src/new_window.rs @@ -0,0 +1,239 @@ +//! `+new-window` (ZIG/src/cli/new_window.zig). +//! +//! DEFERRED: sending the IPC needs the GTK app (`apprt.App.performIpc`). +//! The option parsing (including the manual `+new-window` argument gathering +//! hook) is ported; running the action reports that it is not available. + +use ghostty_config::cli_args::{ArgError, ArgIter, CliTarget}; + +use crate::error::Error; + +/// Options of `+new-window`. +#[derive(Debug, Clone, Default)] +pub struct Options { + /// If set, open up a new window in a custom instance of Ghostty. + pub class: Option, + + /// Did the user specify a `--working-directory` argument on the command line? + pub working_directory_seen: bool, + + /// All of the arguments after `+new-window`. They will be sent to Ghostty + /// for processing. + pub arguments: Vec, +} + +fn is_ascii_ws(c: char) -> bool { + matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c') +} + +impl Options { + fn check_arg(&mut self, arg: &str) -> Result, ArgError> { + if let Some(rest) = arg.strip_prefix("--class=") { + self.class = Some(rest.trim_matches(is_ascii_ws).to_string()); + return Ok(None); + } + + if let Some(rest) = arg.strip_prefix("--working-directory=") { + let stripped = rest.trim_matches(is_ascii_ws); + if stripped == "home" || stripped == "inherit" { + return Ok(Some(arg.to_string())); + } + let expanded = + ghostty_os::expand_home(stripped).map_err(|e| ArgError::Other(e.to_string()))?; + let realpath = + std::fs::canonicalize(&expanded).map_err(|e| ArgError::Other(e.to_string()))?; + self.working_directory_seen = true; + return Ok(Some(format!( + "--working-directory={}", + realpath.to_string_lossy() + ))); + } + + Ok(Some(arg.to_string())) + } +} + +impl CliTarget for Options { + fn parse_into_field(&mut self, _key: &str, _value: Option<&str>) -> Result<(), ArgError> { + Err(ArgError::InvalidField) + } + + /// Manual parse hook, collect all of the arguments after `+new-window`. + fn parse_manually_hook(&mut self, arg: &str, iter: &mut dyn ArgIter) -> Result { + let mut e_seen = arg == "-e"; + + // Include the argument that triggered the manual parse hook. + if let Some(a) = self.check_arg(arg)? { + self.arguments.push(a); + } + + // Gather up the rest of the arguments to use as the command. + while let Some(param) = iter.next_arg() { + if e_seen { + self.arguments.push(param); + continue; + } + if param == "-e" { + e_seen = true; + self.arguments.push(param); + continue; + } + if let Some(a) = self.check_arg(¶m)? { + self.arguments.push(a); + } + } + + Ok(false) + } + + fn supports_help(&self) -> bool { + true + } + + fn help(&mut self) -> Result<(), ArgError> { + Err(ArgError::other(crate::error::HELP_ERROR_NAME)) + } +} + +/// The `new-window` will use native platform IPC to open up a new window in a +/// running instance of Ghostty. +/// +/// If the `--class` flag is not set, the `new-window` command will try and +/// connect to a running instance of Ghostty based on what optimizations the +/// Ghostty CLI was compiled with. Otherwise the `new-window` command will try +/// and contact a running Ghostty instance that was configured with the same +/// `class` as was given on the command line. +/// +/// All of the arguments after the `+new-window` argument (except for the +/// `--class` flag) will be sent to the remote Ghostty instance and will be +/// parsed as command line flags. These flags will override certain settings +/// when creating the first surface in the new window. Currently, only +/// `--working-directory`, `--command`, and `--title` are supported. `-e` will +/// also work as an alias for `--command`, except that if `-e` is found on the +/// command line all following arguments will become part of the command and no +/// more arguments will be parsed for configuration settings. +/// +/// If `--working-directory` is found on the command line and is a relative +/// path (i.e. doesn't start with `/`) it will be resolved to an absolute path +/// relative to the current working directory that the `ghostty +new-window` +/// command is run from. `~/` prefixes will also be expanded to the user's home +/// directory. +/// +/// If `--working-directory` is _not_ found on the command line, the working +/// directory that `ghostty +new-window` is run from will be passed to Ghostty. +/// +/// GTK uses an application ID to identify instances of applications. If Ghostty +/// is compiled with release optimizations, the default application ID will be +/// `com.mitchellh.ghostty`. If Ghostty is compiled with debug optimizations, +/// the default application ID will be `com.mitchellh.ghostty-debug`. The +/// `class` configuration entry can be used to set up a custom application +/// ID. The class name must follow the requirements defined [in the GTK +/// documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html) +/// or it will be ignored and Ghostty will use the default as defined above. +/// +/// On GTK, D-Bus activation must be properly configured. Ghostty does not need +/// to be running for this to open a new window, making it suitable for binding +/// to keys in your window manager (if other methods for configuring global +/// shortcuts are unavailable). D-Bus will handle launching a new instance +/// of Ghostty if it is not already running. See the Ghostty website for +/// information on properly configuring D-Bus activation. +/// +/// Only supported on GTK. +/// +/// Flags: +/// +/// * `--class=`: If set, open up a new window in a custom instance of +/// Ghostty. The class must be a valid GTK application ID. +/// +/// * `--command`: The command to be executed in the first surface of the new window. +/// +/// * `--working-directory=`: The working directory to pass to Ghostty. +/// +/// * `--title`: A title that will override the title of the first surface in +/// the new window. The title override may be edited or removed later. +/// +/// * `-e`: Any arguments after this will be interpreted as a command to +/// execute inside the first surface of the new window instead of the +/// default command. +/// +/// Available since: 1.2.0 +pub fn run(args: &[String]) -> Result { + let mut opts = Options::default(); + let mut iter = ghostty_config::cli_args::ArgsIterator::new(args.iter().skip(1).cloned()); + match ghostty_config::cli_args::parse(&mut opts, &mut iter) { + Ok(()) => {} + Err(ArgError::Other(n)) if n == crate::error::HELP_ERROR_NAME => { + return Err(Error::ActionHelpRequested); + } + Err(err) => { + eprintln!("Error parsing args: {err}"); + return Ok(1); + } + } + + if !opts.working_directory_seen { + // This should be inserted at the beginning of the list, just in case `-e` was used. + let wd = std::fs::canonicalize(".")?; + opts.arguments + .insert(0, format!("--working-directory={}", wd.to_string_lossy())); + } + + // TODO(integration): send the `new_window` IPC (class: `opts.class` or + // auto-detected, arguments: `opts.arguments`) via the GTK apprt's + // `performIpc`. + Err(Error::NotYetAvailable("new-window")) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use ghostty_config::cli_args::{VecIterator, parse}; + + fn parse_str(s: &str) -> Options { + let mut opts = Options::default(); + let mut it = VecIterator::from_str_args(s); + parse(&mut opts, &mut it).unwrap(); + opts + } + + #[test] + fn gathers_arguments_and_class() { + let o = parse_str("--class=foo.bar --title=hi --command=ls"); + assert_eq!(Some("foo.bar".to_string()), o.class); + assert_eq!(vec!["--title=hi", "--command=ls"], o.arguments); + assert!(!o.working_directory_seen); + } + + #[test] + fn everything_after_e_is_command() { + let o = parse_str("--title=x -e --class=nope --working-directory=/nonexistent"); + assert_eq!(None, o.class); + assert_eq!( + vec![ + "--title=x", + "-e", + "--class=nope", + "--working-directory=/nonexistent" + ], + o.arguments + ); + } + + #[test] + fn working_directory_is_resolved() { + let o = parse_str("--working-directory=/"); + assert!(o.working_directory_seen); + assert_eq!(vec!["--working-directory=/"], o.arguments); + let o = parse_str("--working-directory=home"); + assert!(!o.working_directory_seen); + assert_eq!(vec!["--working-directory=home"], o.arguments); + } + + #[test] + fn help_is_gathered_not_special() { + // Like Zig, the manual hook runs before the help check. + let o = parse_str("--help"); + assert_eq!(vec!["--help"], o.arguments); + } +} diff --git a/crates/ghostty-cli/src/options.rs b/crates/ghostty-cli/src/options.rs new file mode 100644 index 0000000..36f8158 --- /dev/null +++ b/crates/ghostty-cli/src/options.rs @@ -0,0 +1,116 @@ +//! Helpers shared by the action `Options` structs. + +use ghostty_config::cli_args::{self, ArgsIterator, CliTarget}; + +use crate::error::Error; + +/// Implements [`CliTarget`] for a struct declared with `config_struct!` and +/// gives it a `Default`. Pass `help` to enable `-h`/`--help` (the Zig +/// `pub fn help` decl); structs without it treat `--help` as an ordinary +/// (unknown or declared) field. +macro_rules! cli_options { + ($name:ident, help, diagnostics) => { + cli_options!(@base $name); + impl ::ghostty_config::cli_args::CliTarget for $name { + fn parse_into_field( + &mut self, + key: &str, + value: ::core::option::Option<&str>, + ) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> { + self.parse_field(key, value) + } + fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> { + Self::valid_values_for(key) + } + fn diagnostics_mut( + &mut self, + ) -> ::core::option::Option<&mut ::ghostty_config::diagnostics::DiagnosticList> { + ::core::option::Option::Some(&mut self.diagnostics) + } + fn supports_help(&self) -> bool { + true + } + fn help(&mut self) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> { + ::core::result::Result::Err(::ghostty_config::cli_args::ArgError::other( + $crate::error::HELP_ERROR_NAME, + )) + } + } + }; + ($name:ident, help) => { + cli_options!(@base $name); + impl ::ghostty_config::cli_args::CliTarget for $name { + fn parse_into_field( + &mut self, + key: &str, + value: ::core::option::Option<&str>, + ) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> { + self.parse_field(key, value) + } + fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> { + Self::valid_values_for(key) + } + fn supports_help(&self) -> bool { + true + } + fn help(&mut self) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> { + ::core::result::Result::Err(::ghostty_config::cli_args::ArgError::other( + $crate::error::HELP_ERROR_NAME, + )) + } + } + }; + ($name:ident) => { + cli_options!(@base $name); + impl ::ghostty_config::cli_args::CliTarget for $name { + fn parse_into_field( + &mut self, + key: &str, + value: ::core::option::Option<&str>, + ) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> { + self.parse_field(key, value) + } + fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> { + Self::valid_values_for(key) + } + } + }; + (@base $name:ident) => { + impl ::core::default::Default for $name { + fn default() -> Self { + Self::raw_default() + } + } + }; +} + +/// Parse the CLI arguments `args` (the full argv, including `argv[0]`, +/// which is skipped; `+action` arguments are ignored) into `opts` +/// (Zig: `args.argsIterator` + `args.parse`). +pub fn parse_options(opts: &mut T, args: &[String]) -> Result<(), Error> { + let mut iter = ArgsIterator::new(args.iter().skip(1).cloned()); + cli_args::parse(opts, &mut iter)?; + Ok(()) +} + +/// The options of an action that has no flags of its own (only `-h`/`--help`). +#[derive(Debug, Clone, Default)] +pub struct NoOptions; + +impl CliTarget for NoOptions { + fn parse_into_field( + &mut self, + _key: &str, + _value: Option<&str>, + ) -> Result<(), cli_args::ArgError> { + Err(cli_args::ArgError::InvalidField) + } + + fn supports_help(&self) -> bool { + true + } + + fn help(&mut self) -> Result<(), cli_args::ArgError> { + Err(cli_args::ArgError::other(crate::error::HELP_ERROR_NAME)) + } +} diff --git a/crates/ghostty-cli/src/show_config.rs b/crates/ghostty-cli/src/show_config.rs new file mode 100644 index 0000000..7544b10 --- /dev/null +++ b/crates/ghostty-cli/src/show_config.rs @@ -0,0 +1,128 @@ +//! `+show-config` (ZIG/src/cli/show_config.zig). + +use std::io::Write; + +use ghostty_config::config::Config; +use ghostty_config::config_struct; +use ghostty_config::file_formatter::FileFormatter; + +use crate::error::Error; + +config_struct! { + pub struct Options { + /// If true, do not load the user configuration, only load the defaults. + "default" => default: bool = false, + + /// Only show the options that have been changed from the default. + /// This has no effect if `--default` is specified. + "changes-only" => changes_only: bool = true, + + /// If true print the documentation above each option as a comment, + /// if available. + "docs" => docs: bool = false, + } +} +cli_options!(Options, help); + +/// The `show-config` command shows the current configuration in a valid Ghostty +/// configuration file format. +/// +/// When executed without any arguments this will output the current +/// configuration that is different from the default configuration. If you're +/// using the default configuration this will output nothing. +/// +/// If you are a new user and want to see all available options with +/// documentation, run `ghostty +show-config --default --docs`. +/// +/// The output is not in any specific order, but the order should be consistent +/// between runs. The output is not guaranteed to be exactly match the input +/// configuration files, but it will result in the same behavior. Comments, +/// whitespace, and other formatting is not preserved from user configuration +/// files. +/// +/// Flags: +/// +/// * `--default`: Show the default configuration instead of loading +/// the user configuration. +/// +/// * `--changes-only`: Only show the options that have been changed +/// from the default. This has no effect if `--default` is specified. +/// +/// * `--docs`: Print the documentation above each option as a comment, +/// This is very noisy but is very useful to learn about available +/// options, especially paired with `--default`. +pub fn run(args: &[String]) -> Result { + let mut opts = Options::default(); + crate::parse_options(&mut opts, args)?; + + let config = if opts.default { + Config::new_default() + } else { + Config::load()? + }; + + let text = format_config(&config, &opts)?; + let mut stdout = std::io::stdout().lock(); + stdout.write_all(text.as_bytes())?; + stdout.flush()?; + Ok(0) +} + +/// Format `config` according to `opts`. +pub fn format_config(config: &Config, opts: &Options) -> Result { + let fmt = FileFormatter { + config, + changed: !opts.default && opts.changes_only, + docs: opts.docs, + }; + let mut out = String::new(); + fmt.format(&mut out)?; + Ok(out) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn default_changes_only_is_empty() { + let config = Config::new_default(); + let opts = Options::default(); + assert_eq!("", format_config(&config, &opts).unwrap()); + } + + #[test] + fn default_docs_has_comments() { + let config = Config::new_default(); + let opts = Options { + default: true, + docs: true, + ..Options::default() + }; + let s = format_config(&config, &opts).unwrap(); + assert!(s.contains("# The font families to use.")); + assert!(s.contains("\nfont-size = 12\n")); + } + + #[test] + fn options_parse() { + let mut opts = Options::default(); + let args: Vec = [ + "ghostty", + "+show-config", + "--default", + "--changes-only=false", + ] + .iter() + .map(ToString::to_string) + .collect(); + crate::parse_options(&mut opts, &args).unwrap(); + assert!(opts.default); + assert!(!opts.changes_only); + assert!(matches!( + crate::parse_options(&mut opts, &["g".into(), "--help".into()]), + Err(Error::ActionHelpRequested) + )); + } +} diff --git a/crates/ghostty-cli/src/show_face.rs b/crates/ghostty-cli/src/show_face.rs new file mode 100644 index 0000000..f6afa0d --- /dev/null +++ b/crates/ghostty-cli/src/show_face.rs @@ -0,0 +1,78 @@ +//! `+show-face` (ZIG/src/cli/show_face.zig). +//! +//! DEFERRED: needs the font crate (collection / codepoint resolver). Only the +//! options and help are ported; running the action reports that it is not +//! available. + +use ghostty_config::{config_enum, config_struct, diagnostics::DiagnosticList}; + +use crate::error::Error; + +config_enum! { + /// Font style (Zig: `font.Style`). + pub enum Style { + Regular => "regular", + Bold => "bold", + Italic => "italic", + BoldItalic => "bold_italic", + } +} + +config_enum! { + /// Font presentation (Zig: `font.Presentation`). + pub enum Presentation { + Text => "text", + Emoji => "emoji", + } +} + +config_struct! { + pub struct Options { + /// The codepoint to search for. + "cp" => cp: Option = None, + + /// Search for all of the codepoints in the string. + "string" => string: Option = None, + + /// Font style to search for. + "style" => style: Style = Style::Regular, + + /// If specified, force text or emoji presentation. + "presentation" => presentation: Option = None, + } + extra { + // Enable arg parsing diagnostics so that we don't get an error if + // there is a "normal" config setting on the cli. + diagnostics: DiagnosticList = DiagnosticList::default(), + } +} +cli_options!(Options, help, diagnostics); + +/// The `show-face` command shows what font face Ghostty will use to render a +/// specific codepoint. Note that this command does not take into consideration +/// grapheme clustering or any other Unicode features that might modify the +/// presentation of a codepoint, so this may show a different font face than +/// Ghostty uses to render a codepoint in a terminal session. +/// +/// Flags: +/// +/// * `--cp`: Find the face for a single codepoint. The codepoint may be specified +/// in decimal (`--cp=65`), hexadecimal (`--cp=0x41`), octal (`--cp=0o101`), or +/// binary (`--cp=0b1000001`). +/// +/// * `--string`: Find the face for all of the codepoints in a string. The +/// string must be a valid UTF-8 sequence. +/// +/// * `--style`: Search for a specific style. Valid options are `regular`, `bold`, +/// `italic`, and `bold_italic`. +/// +/// * `--presentation`: If set, force searching for a specific presentation +/// style. Valid options are `text` and `emoji`. If unset, the presentation +/// style of a codepoint will be inferred from the Unicode standard. +pub fn run(args: &[String]) -> Result { + let mut opts = Options::default(); + crate::parse_options(&mut opts, args)?; + + // TODO(integration): port the face lookup once ghostty-font lands. + Err(Error::NotYetAvailable("show-face")) +} diff --git a/crates/ghostty-cli/src/ssh_cache.rs b/crates/ghostty-cli/src/ssh_cache.rs new file mode 100644 index 0000000..dfbcb0f --- /dev/null +++ b/crates/ghostty-cli/src/ssh_cache.rs @@ -0,0 +1,308 @@ +//! `+ssh-cache` (ZIG/src/cli/ssh_cache.zig). + +use std::io::Write; + +use ghostty_config::config_struct; + +use crate::error::Error; + +pub mod disk_cache; +pub mod entry; + +pub use disk_cache::DiskCache; +pub use entry::Entry; + +config_struct! { + pub struct Options { + "clear" => clear: bool = false, + "add" => add: Option = None, + "remove" => remove: Option = None, + "host" => host: Option = None, + "expire-days" => expire_days: Option = None, + } +} +cli_options!(Options, help); + +/// Manage the SSH terminfo cache for automatic remote host setup. +/// +/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`, +/// Ghostty automatically installs its terminfo on remote hosts. This command +/// manages the cache of successful installations to avoid redundant uploads. +/// +/// The cache stores hostnames (or user@hostname combinations) along with timestamps. +/// Entries older than the expiration period are automatically removed during cache +/// operations. By default, entries never expire. +/// +/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified. +/// If multiple are specified, one of the actions will be executed but +/// it isn't guaranteed which one. This is entirely unsafe so you should split +/// multiple actions into separate commands. +/// +/// Examples: +/// ghostty +ssh-cache # List all cached hosts +/// ghostty +ssh-cache --host=example.com # Check if host is cached +/// ghostty +ssh-cache --add=example.com # Manually add host to cache +/// ghostty +ssh-cache --add=user@example.com # Add user@host combination +/// ghostty +ssh-cache --remove=example.com # Remove host from cache +/// ghostty +ssh-cache --clear # Clear entire cache +/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period +pub fn run(args: &[String]) -> Result { + let mut opts = Options::default(); + crate::parse_options(&mut opts, args)?; + + let mut stdout = std::io::BufWriter::new(std::io::stdout().lock()); + let mut stderr = std::io::BufWriter::new(std::io::stderr().lock()); + + let result = run_inner(&opts, &mut stdout, &mut stderr); + + // Flushing *shouldn't* fail but... + let _ = stdout.flush(); + let _ = stderr.flush(); + result +} + +/// Like Zig's `runInner` with the cache at the standard location. +pub fn run_inner( + opts: &Options, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> Result { + // Setup our disk cache to the standard location + let cache_path = DiskCache::default_path("ghostty").map_err(|e| Error::Other(e.to_string()))?; + run_with_cache(&DiskCache { path: cache_path }, opts, stdout, stderr) +} + +fn invalid_hostname(host: &str, stderr: &mut dyn Write) -> Result { + writeln!(stderr, "Error: Invalid hostname format '{host}'")?; + writeln!(stderr, "Expected format: hostname or user@hostname")?; + Ok(1) +} + +/// Run the action against an explicit cache. +pub fn run_with_cache( + cache: &DiskCache, + opts: &Options, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> Result { + use disk_cache::{AddResult, Error as CacheError}; + + if opts.clear { + cache.clear().map_err(|e| Error::Other(e.to_string()))?; + writeln!(stdout, "Cache cleared.")?; + return Ok(0); + } + + if let Some(host) = &opts.add { + let result = match cache.add(host) { + Ok(r) => r, + Err(CacheError::HostnameIsInvalid) => return invalid_hostname(host, stderr), + Err(CacheError::CacheIsLocked) => { + writeln!(stderr, "Error: Cache is busy, try again")?; + return Ok(1); + } + Err(err) => { + writeln!( + stderr, + "Error: Unable to add '{host}' to cache. Error: {err}" + )?; + return Ok(1); + } + }; + + match result { + AddResult::Added => writeln!(stdout, "Added '{host}' to cache.")?, + AddResult::Updated => writeln!(stdout, "Updated '{host}' cache entry.")?, + } + return Ok(0); + } + + if let Some(host) = &opts.remove { + match cache.remove(host) { + Ok(()) => {} + Err(CacheError::HostnameIsInvalid) => return invalid_hostname(host, stderr), + Err(CacheError::CacheIsLocked) => { + writeln!(stderr, "Error: Cache is busy, try again")?; + return Ok(1); + } + Err(err) => { + writeln!( + stderr, + "Error: Unable to remove '{host}' from cache. Error: {err}" + )?; + return Ok(1); + } + } + writeln!(stdout, "Removed '{host}' from cache.")?; + return Ok(0); + } + + if let Some(host) = &opts.host { + let cached = match cache.contains(host) { + Ok(c) => c, + Err(CacheError::HostnameIsInvalid) => return invalid_hostname(host, stderr), + Err(err) => { + writeln!( + stderr, + "Error: Unable to check host '{host}' in cache. Error: {err}" + )?; + return Ok(1); + } + }; + + if cached { + writeln!(stdout, "'{host}' has Ghostty terminfo installed.")?; + return Ok(0); + } + writeln!(stdout, "'{host}' does not have Ghostty terminfo installed.")?; + return Ok(1); + } + + // Default action: list all hosts + let entries = cache.list().map_err(|e| Error::Other(e.to_string()))?; + list_entries(&entries, entry::now_timestamp(), stdout)?; + Ok(0) +} + +fn list_entries( + entries: &disk_cache::Entries, + now: i64, + writer: &mut dyn Write, +) -> std::io::Result<()> { + if entries.is_empty() { + writeln!(writer, "No hosts in cache.")?; + return Ok(()); + } + + // Sort entries by hostname for consistent output + let mut items: Vec<&Entry> = entries.values().collect(); + items.sort_by(|a, b| a.hostname.as_bytes().cmp(b.hostname.as_bytes())); + + writeln!(writer, "Cached hosts ({}):", items.len())?; + + for entry in items { + let age_days = (now - entry.timestamp) / 86_400; + if age_days == 0 { + writeln!(writer, " {} (today)", entry.hostname)?; + } else if age_days == 1 { + writeln!(writer, " {} (yesterday)", entry.hostname)?; + } else { + writeln!(writer, " {} ({age_days} days ago)", entry.hostname)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn run_cache(cache: &DiskCache, opts: &Options) -> (u8, String, String) { + let (mut out, mut err) = (Vec::new(), Vec::new()); + let rc = run_with_cache(cache, opts, &mut out, &mut err).unwrap(); + ( + rc, + String::from_utf8(out).unwrap(), + String::from_utf8(err).unwrap(), + ) + } + + fn opt_add(h: &str) -> Options { + Options { + add: Some(h.into()), + ..Options::default() + } + } + + #[test] + fn add_list_host_remove_clear() { + let tmp = tempfile::tempdir().unwrap(); + let cache = DiskCache { + path: tmp.path().join("ssh_cache"), + }; + + let (rc, out, _) = run_cache(&cache, &Options::default()); + assert_eq!((0, "No hosts in cache.\n"), (rc, out.as_str())); + + let (rc, out, _) = run_cache(&cache, &opt_add("b.com")); + assert_eq!((0, "Added 'b.com' to cache.\n"), (rc, out.as_str())); + let (rc, out, _) = run_cache(&cache, &opt_add("b.com")); + assert_eq!((0, "Updated 'b.com' cache entry.\n"), (rc, out.as_str())); + run_cache(&cache, &opt_add("a.com")); + + let (rc, out, _) = run_cache(&cache, &Options::default()); + assert_eq!(0, rc); + assert_eq!("Cached hosts (2):\n a.com (today)\n b.com (today)\n", out); + + let host = |h: &str| Options { + host: Some(h.into()), + ..Options::default() + }; + let (rc, out, _) = run_cache(&cache, &host("a.com")); + assert_eq!( + (0, "'a.com' has Ghostty terminfo installed.\n"), + (rc, out.as_str()) + ); + let (rc, out, _) = run_cache(&cache, &host("zzz.com")); + assert_eq!( + (1, "'zzz.com' does not have Ghostty terminfo installed.\n"), + (rc, out.as_str()) + ); + + let (rc, out, _) = run_cache( + &cache, + &Options { + remove: Some("a.com".into()), + ..Options::default() + }, + ); + assert_eq!((0, "Removed 'a.com' from cache.\n"), (rc, out.as_str())); + + let (rc, out, _) = run_cache( + &cache, + &Options { + clear: true, + ..Options::default() + }, + ); + assert_eq!((0, "Cache cleared.\n"), (rc, out.as_str())); + assert!(!cache.path.exists()); + } + + #[test] + fn invalid_hostname_message() { + let tmp = tempfile::tempdir().unwrap(); + let cache = DiskCache { + path: tmp.path().join("ssh_cache"), + }; + let (rc, out, err) = run_cache(&cache, &opt_add("bad host")); + assert_eq!(1, rc); + assert_eq!(0, out.len()); + assert_eq!( + "Error: Invalid hostname format 'bad host'\nExpected format: hostname or user@hostname\n", + err + ); + } + + #[test] + fn list_age_wording() { + let mut entries = disk_cache::Entries::new(); + for (h, age) in [("a", 0), ("b", 1), ("c", 5)] { + entries.insert( + h.to_string(), + Entry { + hostname: h.to_string(), + timestamp: 1_000_000 - age * 86_400, + terminfo_version: "x".into(), + }, + ); + } + let mut out = Vec::new(); + list_entries(&entries, 1_000_000, &mut out).unwrap(); + assert_eq!( + "Cached hosts (3):\n a (today)\n b (yesterday)\n c (5 days ago)\n", + String::from_utf8(out).unwrap() + ); + } +} diff --git a/crates/ghostty-cli/src/ssh_cache/disk_cache.rs b/crates/ghostty-cli/src/ssh_cache/disk_cache.rs new file mode 100644 index 0000000..6891e70 --- /dev/null +++ b/crates/ghostty-cli/src/ssh_cache/disk_cache.rs @@ -0,0 +1,486 @@ +//! An SSH terminfo entry cache that stores its cache data on disk +//! (ZIG/src/cli/ssh-cache/DiskCache.zig). The cache only stores metadata +//! (hostname, terminfo value, etc.) and does not store any sensitive data. + +use std::collections::BTreeMap; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::net::Ipv6Addr; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::PathBuf; + +use super::entry::{Entry, now_timestamp}; + +/// 512KB - sufficient for approximately 10k entries +const MAX_CACHE_SIZE: u64 = 512 * 1024; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("CacheIsLocked")] + CacheIsLocked, + #[error("HostnameIsInvalid")] + HostnameIsInvalid, + /// The general error that is returned for any filesystem error + /// that may have resulted in the XDG lookup failing. + #[error("XdgLookupFailed")] + XdgLookupFailed, + #[error("InvalidCachePath")] + InvalidCachePath, + #[error("StreamTooLong")] + StreamTooLong, + #[error("{0}")] + Io(#[from] std::io::Error), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddResult { + Added, + Updated, +} + +pub type Entries = BTreeMap; + +#[derive(Debug, Clone)] +pub struct DiskCache { + /// Path to a file where the cache is stored. + pub path: PathBuf, +} + +impl DiskCache { + /// Returns the default path for the cache for a given program. + /// + /// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`. + pub fn default_path(program: &str) -> Result { + let state_dir = ghostty_os::xdg::state(&ghostty_os::xdg::Options { + subdir: Some(program), + home: None, + }) + .map_err(|_| Error::XdgLookupFailed)?; + Ok(PathBuf::from(state_dir).join("ssh_cache")) + } + + /// Clear all cache data stored in the disk cache. + /// This removes the cache file from disk, effectively clearing all cached + /// SSH terminfo entries. + pub fn clear(&self) -> Result<(), Error> { + match std::fs::remove_file(&self.path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } + } + + /// Add or update a hostname entry in the cache. + /// Returns `AddResult::Added` for new entries or `AddResult::Updated` for + /// existing ones. The cache file is created if it doesn't exist with + /// secure permissions (0600). + pub fn add(&self, hostname: &str) -> Result { + if !is_valid_cache_key(hostname) { + return Err(Error::HostnameIsInvalid); + } + + // Create cache directory if needed + if let Some(dir) = self.path.parent() { + std::fs::create_dir_all(dir)?; + } + + // Open or create cache file with secure permissions + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(&self.path)?; + fixup_permissions(&file)?; + + // Lock + let _lock = lock(&file)?; + + let mut entries = read_entries(&file)?; + + // Add or update entry + let result = if let Some(e) = entries.get_mut(hostname) { + // Update timestamp for existing entry + e.timestamp = now_timestamp(); + AddResult::Updated + } else { + entries.insert( + hostname.to_string(), + Entry { + hostname: hostname.to_string(), + timestamp: now_timestamp(), + terminfo_version: "xterm-ghostty".to_string(), + }, + ); + AddResult::Added + }; + + self.write_cache_file(&entries, None)?; + Ok(result) + } + + /// Remove a hostname entry from the cache. + /// No error is returned if the hostname doesn't exist or the cache file + /// is missing. + pub fn remove(&self, hostname: &str) -> Result<(), Error> { + if !is_valid_cache_key(hostname) { + return Err(Error::HostnameIsInvalid); + } + + // Open our file + let file = match OpenOptions::new().read(true).write(true).open(&self.path) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + fixup_permissions(&file)?; + + // Lock + let _lock = lock(&file)?; + + // Read existing entries + let mut entries = read_entries(&file)?; + entries.remove(hostname); + + self.write_cache_file(&entries, None) + } + + /// Check if a hostname exists in the cache. + /// Returns false if the cache file doesn't exist. + pub fn contains(&self, hostname: &str) -> Result { + if !is_valid_cache_key(hostname) { + return Err(Error::HostnameIsInvalid); + } + + // Open our file + let file = match File::open(&self.path) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(e.into()), + }; + + let entries = read_entries(&file)?; + Ok(entries.contains_key(hostname)) + } + + /// List all entries in the cache. + /// Returns an empty map if the cache file doesn't exist. + pub fn list(&self) -> Result { + let file = match File::open(&self.path) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Entries::new()), + Err(e) => return Err(e.into()), + }; + read_entries(&file) + } + + /// Atomically (temp file + rename) write the non-expired entries. + fn write_cache_file(&self, entries: &Entries, expire_days: Option) -> Result<(), Error> { + let cache_dir = self.path.parent().ok_or(Error::InvalidCachePath)?; + let cache_basename = self.path.file_name().ok_or(Error::InvalidCachePath)?; + + let mut data = String::new(); + for e in entries.values() { + // Only write non-expired entries + if e.is_expired(expire_days) { + continue; + } + e.format(&mut data); + } + + let mut tmp_name = cache_basename.to_os_string(); + tmp_name.push(format!(".{}.tmp", std::process::id())); + let tmp_path = cache_dir.join(tmp_name); + + let result = (|| -> std::io::Result<()> { + let mut f = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&tmp_path)?; + f.write_all(data.as_bytes())?; + f.sync_all()?; + std::fs::rename(&tmp_path, &self.path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp_path); + } + Ok(result?) + } +} + +/// Holds an advisory exclusive lock on a file until dropped. +struct Lock<'a>(Option<&'a File>); + +impl Drop for Lock<'_> { + fn drop(&mut self) { + if let Some(f) = self.0 { + let _ = f.unlock(); + } + } +} + +/// Zig: `_ = file.tryLock(.exclusive) catch return error.CacheIsLocked;` +/// The boolean result is ignored upstream, so a lock held by someone else +/// does not fail; only a real error does. +fn lock(file: &File) -> Result, Error> { + match file.try_lock() { + Ok(()) => Ok(Lock(Some(file))), + Err(std::fs::TryLockError::WouldBlock) => Ok(Lock(None)), + Err(std::fs::TryLockError::Error(_)) => Err(Error::CacheIsLocked), + } +} + +fn fixup_permissions(file: &File) -> Result<(), Error> { + // Ensure file has correct permissions (readable/writable by + // owner only) + let meta = file.metadata()?; + if meta.permissions().mode() & 0o777 != 0o600 { + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +fn read_entries(file: &File) -> Result { + let mut content = Vec::new(); + let mut f = file; + (&mut f) + .take(MAX_CACHE_SIZE + 1) + .read_to_end(&mut content)?; + if content.len() as u64 > MAX_CACHE_SIZE { + return Err(Error::StreamTooLong); + } + let content = String::from_utf8_lossy(&content); + + let mut entries = Entries::new(); + for line in content.split('\n').filter(|l| !l.is_empty()) { + let trimmed = line.trim_matches([' ', '\t', '\r']); + let Some(entry) = Entry::parse(trimmed) else { + continue; + }; + + match entries.get_mut(&entry.hostname) { + None => { + entries.insert(entry.hostname.clone(), entry); + } + Some(existing) => { + // Handle duplicate entries - keep newer timestamp + if entry.timestamp > existing.timestamp { + existing.timestamp = entry.timestamp; + if existing.terminfo_version != entry.terminfo_version { + existing.terminfo_version = entry.terminfo_version; + } + } + } + } + } + + Ok(entries) +} + +// Supports both standalone hostnames and user@hostname format +fn is_valid_cache_key(key: &str) -> bool { + if key.is_empty() { + return false; + } + + // Check for user@hostname format + if let Some(at_pos) = key.find('@') { + let user = &key[..at_pos]; + let hostname = &key[at_pos + 1..]; + return is_valid_user(user) && is_valid_host(hostname); + } + + is_valid_host(key) +} + +// Checks if a host is a valid hostname or IP address +fn is_valid_host(host: &str) -> bool { + // First check for valid hostnames because this is assumed to be the more + // likely ssh host format. + if ghostty_os::hostname::is_valid(host) { + return true; + } + + // We also accept valid IP addresses. In practice, IPv4 addresses are also + // considered valid hostnames due to their overlapping syntax, so we can + // simplify this check to be IPv6-specific. + host.parse::().is_ok() +} + +fn is_valid_user(user: &str) -> bool { + if user.is_empty() || user.len() > 64 { + return false; + } + user.bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.')) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn disk_cache_default_path() { + let path = DiskCache::default_path("ghostty").unwrap(); + assert!(!path.as_os_str().is_empty()); + assert!(path.ends_with("ghostty/ssh_cache")); + } + + #[test] + fn disk_cache_clear() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("cache"); + std::fs::write(&path, "HELLO!").unwrap(); + + let cache = DiskCache { path: path.clone() }; + cache.clear().unwrap(); + + // Verify the file is gone + assert_eq!( + std::io::ErrorKind::NotFound, + File::open(&path).unwrap_err().kind() + ); + // Clearing again is not an error. + cache.clear().unwrap(); + } + + #[test] + fn disk_cache_operations() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("cache"); + std::fs::write(&path, "HELLO!").unwrap(); + + // Setup our cache + let cache = DiskCache { path }; + assert_eq!(AddResult::Added, cache.add("example.com").unwrap()); + assert_eq!(AddResult::Updated, cache.add("example.com").unwrap()); + assert!(cache.contains("example.com").unwrap()); + + // List + let entries = cache.list().unwrap(); + assert_eq!(1, entries.len()); + + // Remove + cache.remove("example.com").unwrap(); + assert!(!cache.contains("example.com").unwrap()); + assert_eq!(AddResult::Added, cache.add("example.com").unwrap()); + } + + #[test] + fn disk_cache_cleans_up_temp_files() { + let tmp = tempfile::tempdir().unwrap(); + let cache = DiskCache { + path: tmp.path().join("cache"), + }; + assert_eq!(AddResult::Added, cache.add("example.com").unwrap()); + assert_eq!(AddResult::Added, cache.add("example.org").unwrap()); + + // Verify only the cache file exists and no temp files left behind + let names: Vec<_> = std::fs::read_dir(tmp.path()) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(vec![std::ffi::OsString::from("cache")], names); + } + + #[test] + fn disk_cache_file_permissions_and_invalid_host() { + let tmp = tempfile::tempdir().unwrap(); + let cache = DiskCache { + path: tmp.path().join("sub/cache"), + }; + cache.add("example.com").unwrap(); + let mode = std::fs::metadata(&cache.path).unwrap().permissions().mode(); + assert_eq!(0o600, mode & 0o777); + assert!(matches!( + cache.add("bad host"), + Err(Error::HostnameIsInvalid) + )); + assert!(matches!( + cache.contains("host name"), + Err(Error::HostnameIsInvalid) + )); + } + + #[test] + fn duplicate_entries_keep_newer_timestamp() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("cache"); + std::fs::write(&path, "a.com|10|old\na.com|20|new\na.com|15|mid\n").unwrap(); + let e = DiskCache { path }.list().unwrap(); + assert_eq!(20, e["a.com"].timestamp); + assert_eq!("new", e["a.com"].terminfo_version); + } + + #[test] + fn is_valid_host_cases() { + // Valid hostnames + assert!(is_valid_host("localhost")); + assert!(is_valid_host("example.com")); + assert!(is_valid_host("sub.example.com")); + + // IPv4 addresses + assert!(is_valid_host("127.0.0.1")); + assert!(is_valid_host("192.168.1.1")); + + // IPv6 addresses + assert!(is_valid_host("::1")); + assert!(is_valid_host("2001:db8::1")); + assert!(is_valid_host("2001:db8:0:1:1:1:1:1")); + assert!(!is_valid_host("fe80::1%eth0")); // scopes not supported + + // Invalid hosts + assert!(!is_valid_host("")); + assert!(!is_valid_host("host\nname")); + assert!(!is_valid_host(".example.com")); + assert!(!is_valid_host("host..domain")); + assert!(!is_valid_host("-hostname")); + assert!(!is_valid_host("hostname-")); + assert!(!is_valid_host("host name")); + assert!(!is_valid_host("host_name")); + assert!(!is_valid_host("host@domain")); + assert!(!is_valid_host("host:port")); + } + + #[test] + fn is_valid_user_cases() { + // Valid + assert!(is_valid_user("user")); + assert!(is_valid_user("user-user")); + assert!(is_valid_user("user_name")); + assert!(is_valid_user("user.name")); + assert!(is_valid_user("user123")); + + // Invalid + assert!(!is_valid_user("")); + assert!(!is_valid_user("user name")); + assert!(!is_valid_user("user@example")); + assert!(!is_valid_user("user:group")); + assert!(!is_valid_user("user\nname")); + assert!(!is_valid_user(&"a".repeat(65))); // too long + } + + #[test] + fn is_valid_cache_key_cases() { + // Valid + assert!(is_valid_cache_key("example.com")); + assert!(is_valid_cache_key("sub.example.com")); + assert!(is_valid_cache_key("192.168.1.1")); + assert!(is_valid_cache_key("::1")); + assert!(is_valid_cache_key("user@example.com")); + assert!(is_valid_cache_key("user@192.168.1.1")); + assert!(is_valid_cache_key("user@::1")); + + // Invalid + assert!(!is_valid_cache_key("")); + assert!(!is_valid_cache_key(".example.com")); + assert!(!is_valid_cache_key("@example.com")); + assert!(!is_valid_cache_key("user@")); + assert!(!is_valid_cache_key("user@@example")); + assert!(!is_valid_cache_key("user@.example.com")); + } +} diff --git a/crates/ghostty-cli/src/ssh_cache/entry.rs b/crates/ghostty-cli/src/ssh_cache/entry.rs new file mode 100644 index 0000000..a777e96 --- /dev/null +++ b/crates/ghostty-cli/src/ssh_cache/entry.rs @@ -0,0 +1,179 @@ +//! A single entry within our SSH entry cache (ZIG/src/cli/ssh-cache/Entry.zig). +//! Our SSH entry cache stores which hosts we've sent our terminfo to so that +//! we don't have to send it again. It doesn't store any sensitive information. + +use std::fmt::Write as _; +use std::time::{SystemTime, UNIX_EPOCH}; + +const S_PER_DAY: i64 = 86_400; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub hostname: String, + pub timestamp: i64, + pub terminfo_version: String, +} + +/// `std.time.timestamp()`: seconds since the unix epoch. +#[must_use] +pub fn now_timestamp() -> i64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(d) => i64::try_from(d.as_secs()).unwrap_or(i64::MAX), + Err(e) => -i64::try_from(e.duration().as_secs()).unwrap_or(i64::MAX), + } +} + +impl Entry { + /// Parse a `hostname|timestamp|terminfo_version` line. + #[must_use] + pub fn parse(line: &str) -> Option { + let trimmed = line.trim_matches([' ', '\t', '\r', '\n']); + if trimmed.is_empty() { + return None; + } + + // Parse format: hostname|timestamp|terminfo_version + // (`tokenizeScalar` skips empty tokens.) + let mut iter = trimmed.split('|').filter(|s| !s.is_empty()); + let hostname = iter.next()?; + let timestamp_str = iter.next()?; + let terminfo_version = iter.next().unwrap_or("xterm-ghostty"); + let timestamp = match timestamp_str.parse::() { + Ok(t) => t, + Err(err) => { + log::warn!("Invalid timestamp in cache entry: {timestamp_str} err={err}"); + return None; + } + }; + + Some(Entry { + hostname: hostname.to_string(), + timestamp, + terminfo_version: terminfo_version.to_string(), + }) + } + + /// Write the entry in the on-disk format (`hostname|timestamp|terminfo\n`). + pub fn format(&self, out: &mut String) { + let _ = writeln!( + out, + "{}|{}|{}", + self.hostname, self.timestamp, self.terminfo_version + ); + } + + #[must_use] + pub fn is_expired(&self, expire_days: Option) -> bool { + self.is_expired_at(expire_days, now_timestamp()) + } + + fn is_expired_at(&self, expire_days: Option, now: i64) -> bool { + let Some(expire_days) = expire_days else { + return false; + }; + let age_days = now.saturating_sub(self.timestamp) / S_PER_DAY; + age_days > i64::from(expire_days) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn entry(host: &str, timestamp: i64) -> Entry { + Entry { + hostname: host.to_string(), + timestamp, + terminfo_version: "xterm-ghostty".to_string(), + } + } + + #[test] + fn cache_entry_expiration() { + let now = now_timestamp(); + let fresh_entry = entry("test.com", now - S_PER_DAY); // 1 day old + assert!(!fresh_entry.is_expired(Some(90))); + + let old_entry = entry("old.com", now - S_PER_DAY * 100); // 100 days old + assert!(old_entry.is_expired(Some(90))); + + // Test never-expire case + assert!(!old_entry.is_expired(None)); + } + + #[test] + fn cache_entry_expiration_exact_boundary() { + let now = now_timestamp(); + // Exactly at expiration boundary + let boundary_entry = entry("example.com", now - S_PER_DAY * 30); + assert!(!boundary_entry.is_expired_at(Some(30), now)); + assert!(boundary_entry.is_expired_at(Some(29), now)); + } + + #[test] + fn cache_entry_expiration_large_timestamp() { + let now = now_timestamp(); + let boundary_entry = entry("example.com", now + S_PER_DAY * 30); + assert!(!boundary_entry.is_expired(Some(30))); + } + + #[test] + fn cache_entry_parsing_valid_formats() { + let e = Entry::parse("example.com|1640995200|xterm-ghostty").unwrap(); + assert_eq!("example.com", e.hostname); + assert_eq!(1_640_995_200, e.timestamp); + assert_eq!("xterm-ghostty", e.terminfo_version); + + // Test default terminfo version + let e = Entry::parse("test.com|1640995200").unwrap(); + assert_eq!("xterm-ghostty", e.terminfo_version); + + // Test complex hostnames + let e = Entry::parse("user@server.example.com|1640995200|xterm-ghostty").unwrap(); + assert_eq!("user@server.example.com", e.hostname); + } + + #[test] + fn cache_entry_parsing_invalid_formats() { + assert!(Entry::parse("").is_none()); + + // Invalid format (no pipe) + assert!(Entry::parse("v1").is_none()); + + // Missing timestamp + assert!(Entry::parse("example.com").is_none()); + + // Invalid timestamp + assert!(Entry::parse("example.com|invalid").is_none()); + + // Empty terminfo should default + assert!(Entry::parse("example.com|1640995200|").is_some()); + } + + #[test] + fn cache_entry_parsing_malformed_data_resilience() { + // Extra pipes should not break parsing + assert!(Entry::parse("host|123|term|extra").is_some()); + + // Whitespace handling + assert!(Entry::parse(" host|123|term ").is_some()); + assert!(Entry::parse("\n").is_none()); + assert!(Entry::parse(" \t \n").is_none()); + + // Extremely large timestamp + assert!( + Entry::parse("host|999999999999999999999999999999999999999999999999|xterm-ghostty") + .is_none() + ); + } + + #[test] + fn cache_entry_format_round_trip() { + let e = entry("a.com", 5); + let mut s = String::new(); + e.format(&mut s); + assert_eq!("a.com|5|xterm-ghostty\n", s); + assert_eq!(Some(e), Entry::parse(&s)); + } +} diff --git a/crates/ghostty-cli/src/tui.rs b/crates/ghostty-cli/src/tui.rs new file mode 100644 index 0000000..6db03ed --- /dev/null +++ b/crates/ghostty-cli/src/tui.rs @@ -0,0 +1,427 @@ +//! Shared TUI helpers (ZIG/src/cli/tui.zig) plus a tiny re-implementation of +//! the part of libvaxis that Ghostty's "pretty" (non-interactive, tty) +//! listings use: composing a single row of styled cells in a window and +//! writing it with vaxis' `prettyPrint` escape sequences. +//! +//! The byte output was verified against Ghostty 1.3.1 in a pty. + +use std::io::Write; + +use unicode_width::UnicodeWidthChar; + +/// Whether pretty printing is possible on this platform +/// (`tui.can_pretty_print`; false only on iOS/tvOS/watchOS in Zig). +pub const CAN_PRETTY_PRINT: bool = true; + +/// `vaxis.ctlseqs.unicode_set`. +pub const UNICODE_SET: &str = "\x1b[?2027h"; +/// `vaxis.ctlseqs.unicode_reset`. +pub const UNICODE_RESET: &str = "\x1b[?2027l"; + +/// A cell color (`vaxis.Color`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Color { + #[default] + Default, + Index(u8), + Rgb(u8, u8, u8), +} + +/// A cell style (the subset of `vaxis.Style` that is used). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Style { + pub fg: Color, + pub bg: Color, + pub bold: bool, + pub dim: bool, + pub italic: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Cell { + /// Never written (`vaxis.Cell{}`): rendered by moving the cursor. + Unwritten, + /// The trailing half of a wide grapheme. + Continuation(Style), + Glyph(String, Style), +} + +/// The result of printing a segment (`vaxis.Window.PrintResult`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PrintResult { + pub col: u16, + pub row: u16, + pub overflow: bool, +} + +/// A vaxis window of `width` x `height` cells. The listings only ever use +/// row 0 explicitly, but text that does not fit wraps onto the following +/// rows (and is rendered there), like vaxis does. +#[derive(Debug, Clone)] +pub struct Row { + width: u16, + height: u16, + rows: Vec>, +} + +/// Display width of a string (`Window.gwidth` with unicode mode on). +#[must_use] +pub fn gwidth(s: &str) -> u16 { + let w: usize = s.chars().map(|c| c.width().unwrap_or(0)).sum(); + u16::try_from(w).unwrap_or(u16::MAX) +} + +impl Row { + #[must_use] + pub fn new(width: u16) -> Row { + Row::with_height(width, window_size().1) + } + + #[must_use] + pub fn with_height(width: u16, height: u16) -> Row { + Row { + width, + height: height.max(1), + rows: vec![vec![Cell::Unwritten; usize::from(width)]], + } + } + + #[must_use] + pub fn width(&self) -> u16 { + self.width + } + + /// `Window.clear`. + pub fn clear(&mut self) { + self.rows = vec![vec![Cell::Unwritten; usize::from(self.width)]]; + } + + fn cell_mut(&mut self, row: u16, col: u16) -> &mut Cell { + while self.rows.len() <= usize::from(row) { + self.rows + .push(vec![Cell::Unwritten; usize::from(self.width)]); + } + &mut self.rows[usize::from(row)][usize::from(col)] + } + + /// `Window.printSegment` starting on row 0 at `col_offset`; wraps onto + /// the next rows when the text does not fit. + pub fn print_segment(&mut self, text: &str, style: Style, col_offset: u16) -> PrintResult { + self.print_segment_at(text, style, 0, col_offset) + } + + /// `Window.printSegment` with an explicit `row_offset`. + pub fn print_segment_at( + &mut self, + text: &str, + style: Style, + row_offset: u16, + col_offset: u16, + ) -> PrintResult { + let mut col = col_offset; + let mut row = row_offset; + let mut overflow = false; + let mut last: Option<(u16, u16)> = None; + for ch in text.chars() { + let w = ch.width().unwrap_or(0); + if w == 0 { + // Combine with the previous glyph. + if let Some((r, c)) = last { + if let Cell::Glyph(g, _) = self.cell_mut(r, c) { + g.push(ch); + } + } + continue; + } + let w16 = u16::try_from(w).unwrap_or(1); + if col + w16 > self.width { + row += 1; + col = 0; + } + if row >= self.height || w16 > self.width { + overflow = true; + break; + } + *self.cell_mut(row, col) = Cell::Glyph(ch.to_string(), style); + for i in 1..w16 { + *self.cell_mut(row, col + i) = Cell::Continuation(style); + } + last = Some((row, col)); + col += w16; + if col >= self.width { + row += 1; + col = 0; + } + } + PrintResult { col, row, overflow } + } + + /// `Window.print`: print several segments one after another. + pub fn print(&mut self, segments: &[(&str, Style)], col_offset: u16) -> PrintResult { + let mut res = PrintResult { + col: col_offset, + row: 0, + overflow: false, + }; + for (text, style) in segments { + res = self.print_segment_at(text, *style, res.row, res.col); + if res.overflow { + break; + } + } + res + } + + /// Write this window like `vx.prettyPrint` does (the escape sequences + /// are identical to libvaxis': synchronized update, cursor hidden, only + /// the written part of each row, unwritten gaps skipped with CUF). + /// + /// `first` must be true for the first row printed by a program (vaxis + /// then also returns to column 0 and erases below). + pub fn pretty_print(&self, w: &mut dyn Write, first: bool) -> std::io::Result<()> { + if first { + w.write_all(b"\r\x1b[m\x1b[J")?; + } + w.write_all(b"\x1b[?25l\x1b[?2026h\x1b[m")?; + + // Rows after the last written row are not printed. + let used_rows = self + .rows + .iter() + .rposition(|r| r.iter().any(|c| !matches!(c, Cell::Unwritten))) + .map_or(1, |i| i + 1); + + let mut cursor = Style::default(); + for (ri, cells) in self.rows[..used_rows].iter().enumerate() { + if ri > 0 { + w.write_all(b"\n\r")?; + } + // Trailing unwritten cells are never emitted. + let end = cells + .iter() + .rposition(|c| !matches!(c, Cell::Unwritten)) + .map_or(0, |i| i + 1); + + let mut skip = 0usize; + for cell in &cells[..end] { + match cell { + Cell::Unwritten => skip += 1, + Cell::Continuation(_) => {} + Cell::Glyph(g, style) => { + if skip > 0 { + write!(w, "\x1b[{skip}C")?; + skip = 0; + } + write_style_change(w, &cursor, style)?; + cursor = *style; + w.write_all(g.as_bytes())?; + } + } + } + if skip > 0 { + write!(w, "\x1b[{skip}C")?; + } + } + + w.write_all(b"\r\n\x1b[m\x1b[?2026l") + } +} + +fn write_color(w: &mut dyn Write, c: Color, base: u8) -> std::io::Result<()> { + // base is 3 for foreground, 4 for background. + match c { + Color::Default => write!(w, "\x1b[{base}9m"), + Color::Index(i) if i < 8 => write!(w, "\x1b[{base}{i}m"), + Color::Index(i) if i < 16 => write!(w, "\x1b[{}{}m", base + 6, i - 8), + Color::Index(i) => write!(w, "\x1b[{base}8:5:{i}m"), + Color::Rgb(r, g, b) => write!(w, "\x1b[{base}8:2:{r}:{g}:{b}m"), + } +} + +/// Emit the SGR changes to go from `cursor` to `next` in vaxis' order. +fn write_style_change(w: &mut dyn Write, cursor: &Style, next: &Style) -> std::io::Result<()> { + if cursor.fg != next.fg { + write_color(w, next.fg, 3)?; + } + if cursor.bg != next.bg { + write_color(w, next.bg, 4)?; + } + if cursor.bold != next.bold { + w.write_all(if next.bold { b"\x1b[1m" } else { b"\x1b[22m" })?; + if next.dim { + w.write_all(b"\x1b[2m")?; + } + } + if cursor.dim != next.dim { + w.write_all(if next.dim { b"\x1b[2m" } else { b"\x1b[22m" })?; + if next.bold { + w.write_all(b"\x1b[1m")?; + } + } + if cursor.italic != next.italic { + w.write_all(if next.italic { b"\x1b[3m" } else { b"\x1b[23m" })?; + } + Ok(()) +} + +/// The terminal size in columns and rows (`vaxis.Tty.getWinsize`). +#[must_use] +pub fn window_size() -> (u16, u16) { + ratatui::crossterm::terminal::size().unwrap_or((80, 24)) +} + +/// Sequences vaxis writes when it is torn down (`vx.deinit`). +pub const VAXIS_DEINIT: &str = "\x1b[?25h\x1b[m\r\x1b[J"; + +/// A pretty-printing session on a tty, mirroring the +/// `vaxis.Tty.init` + `vaxis.init` + mode-2027 setup used by the listing +/// actions. Dropping it restores the terminal. +pub struct PrettySession { + out: W, + first: bool, + raw: bool, + pub width: u16, + pub height: u16, +} + +impl PrettySession { + pub fn new(mut out: W) -> std::io::Result { + let (width, height) = window_size(); + // We know we are ghostty, so enable mode 2027. + out.write_all(UNICODE_SET.as_bytes())?; + Ok(PrettySession { + out, + first: true, + raw: false, + width, + height, + }) + } + + /// Like [`PrettySession::new`] but also puts the tty in raw mode, like + /// `vaxis.Tty.init` does (this matters for the bytes that reach the + /// terminal: with output processing off, `\r\n` stays `\r\n`). + pub fn new_raw(out: W) -> std::io::Result { + ratatui::crossterm::terminal::enable_raw_mode()?; + let mut s = Self::new(out)?; + s.raw = true; + Ok(s) + } + + /// A new empty row of the window width. + #[must_use] + pub fn row(&self) -> Row { + Row::with_height(self.width, self.height) + } + + /// Write a composed row. + pub fn print_row(&mut self, row: &Row) -> std::io::Result<()> { + row.pretty_print(&mut self.out, self.first)?; + self.first = false; + Ok(()) + } + + /// Restore the terminal state (also done on drop). + pub fn finish(&mut self) -> std::io::Result<()> { + let result = (|| { + self.out.write_all(UNICODE_RESET.as_bytes())?; + self.out.write_all(VAXIS_DEINIT.as_bytes())?; + self.out.flush() + })(); + if self.raw { + self.raw = false; + ratatui::crossterm::terminal::disable_raw_mode()?; + } + result + } +} + +impl Drop for PrettySession { + fn drop(&mut self) { + if self.raw { + let _ = ratatui::crossterm::terminal::disable_raw_mode(); + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn render(row: &Row, first: bool) -> String { + let mut v = Vec::new(); + row.pretty_print(&mut v, first).unwrap(); + String::from_utf8(v).unwrap() + } + + #[test] + fn plain_text_and_skip() { + let mut row = Row::with_height(20, 5); + let r = row.print_segment("abc", Style::default(), 0); + assert_eq!(3, r.col); + row.print_segment("x", Style::default(), 7); + assert_eq!( + "\x1b[?25l\x1b[?2026h\x1b[mabc\x1b[4Cx\r\n\x1b[m\x1b[?2026l", + render(&row, false) + ); + } + + #[test] + fn first_row_prefix() { + let mut row = Row::with_height(5, 5); + row.print_segment("a", Style::default(), 0); + assert!(render(&row, true).starts_with("\r\x1b[m\x1b[J\x1b[?25l\x1b[?2026h\x1b[ma")); + } + + #[test] + fn styles_match_vaxis_order() { + let mut row = Row::with_height(40, 5); + let red = Style { + fg: Color::Index(1), + ..Style::default() + }; + let dim = Style { + dim: true, + ..Style::default() + }; + let magenta = Style { + fg: Color::Index(5), + ..Style::default() + }; + let rgb = Style { + fg: Color::Rgb(1, 2, 3), + bg: Color::Rgb(4, 5, 6), + ..Style::default() + }; + let res = row.print(&[("su", red), (":", dim), ("x", magenta), ("c", rgb)], 0); + assert_eq!(5, res.col); + assert_eq!( + "\x1b[?25l\x1b[?2026h\x1b[m\x1b[31msu\x1b[39m\x1b[2m:\x1b[35m\x1b[22mx\x1b[38:2:1:2:3m\x1b[48:2:4:5:6mc\r\n\x1b[m\x1b[?2026l", + render(&row, false) + ); + } + + #[test] + fn wrapping_onto_following_rows() { + let mut row = Row::with_height(5, 3); + let r = row.print_segment("abcdefg", Style::default(), 2); + assert_eq!((4, 1), (r.col, r.row)); + assert_eq!( + "\x1b[?25l\x1b[?2026h\x1b[m\x1b[2Cabc\n\rdefg\r\n\x1b[m\x1b[?2026l", + render(&row, false) + ); + let mut row = Row::with_height(5, 1); + assert!(row.print_segment("abcdefg", Style::default(), 0).overflow); + } + + #[test] + fn wide_chars_and_overflow() { + let mut row = Row::with_height(5, 5); + let r = row.print_segment("a\u{4e2d}b", Style::default(), 0); + assert_eq!(4, r.col); + assert_eq!(2, gwidth("\u{4e2d}")); + let r = row.print_segment("zz", Style::default(), 3); + assert!(!r.overflow); + } +} diff --git a/crates/ghostty-cli/src/validate_config.rs b/crates/ghostty-cli/src/validate_config.rs new file mode 100644 index 0000000..b015250 --- /dev/null +++ b/crates/ghostty-cli/src/validate_config.rs @@ -0,0 +1,113 @@ +//! `+validate-config` (ZIG/src/cli/validate_config.zig). + +use std::io::Write; + +use ghostty_config::config::Config; +use ghostty_config::config_struct; + +use crate::error::Error; + +config_struct! { + pub struct Options { + /// The path of the config file to validate. If this isn't specified, + /// then the default config file paths will be validated. + "config-file" => config_file: Option = None, + } +} +cli_options!(Options, help); + +/// The `validate-config` command is used to validate a Ghostty config file. +/// +/// When executed without any arguments, this will load the config from the default +/// location. +/// +/// Flags: +/// +/// * `--config-file`: can be passed to validate a specific target config file in +/// a non-default location +pub fn run(args: &[String]) -> Result { + let mut opts = Options::default(); + crate::parse_options(&mut opts, args)?; + + let mut stdout = std::io::BufWriter::new(std::io::stdout().lock()); + let result = run_inner(&opts, &mut stdout); + stdout.flush()?; + result +} + +fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result { + let mut cfg = Config::new_default(); + + // If a config path is passed, validate it, otherwise validate default configs + if let Some(config_path) = &opts.config_file { + let abs_path = std::fs::canonicalize(config_path)?; + cfg.load_file(&abs_path.to_string_lossy())?; + cfg.load_recursive_files()?; + } else { + cfg = Config::load()?; + } + + cfg.finalize()?; + + if !cfg.diagnostics.items().is_empty() { + for diag in cfg.diagnostics.items() { + writeln!(stdout, "{diag}")?; + } + return Ok(1); + } + + Ok(0) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn write_tmp(name: &str, content: &str) -> String { + let dir = + std::env::temp_dir().join(format!("ghostty-cli-vc-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("config"); + std::fs::write(&p, content).unwrap(); + p.to_string_lossy().into_owned() + } + + #[test] + fn validate_good_file() { + let p = write_tmp("good", "font-size = 14\n"); + let opts = Options { + config_file: Some(p), + }; + let mut out = Vec::new(); + assert_eq!(0, run_inner(&opts, &mut out).unwrap()); + assert_eq!(0, out.len()); + } + + #[test] + fn validate_bad_file_prints_diagnostics() { + let p = write_tmp("bad", "not-a-key = 1\nfont-size = abc\n"); + let opts = Options { + config_file: Some(p.clone()), + }; + let mut out = Vec::new(); + assert_eq!(1, run_inner(&opts, &mut out).unwrap()); + let s = String::from_utf8(out).unwrap(); + assert!( + s.contains(&format!("{p}:1:not-a-key: unknown field\n")), + "{s}" + ); + assert!( + s.contains(&format!("{p}:2:font-size: invalid value \"abc\"\n")), + "{s}" + ); + } + + #[test] + fn validate_missing_file_is_error() { + let opts = Options { + config_file: Some("/nonexistent/ghostty/config".into()), + }; + assert!(run_inner(&opts, &mut Vec::new()).is_err()); + } +} diff --git a/crates/ghostty-cli/src/version.rs b/crates/ghostty-cli/src/version.rs new file mode 100644 index 0000000..001d6d8 --- /dev/null +++ b/crates/ghostty-cli/src/version.rs @@ -0,0 +1,120 @@ +//! `+version` (ZIG/src/cli/version.zig). + +use std::io::{IsTerminal, Write}; + +use crate::build_info::{self, BuildInfo}; +use crate::error::Error; + +/// The `version` command is used to display information about Ghostty. Recognized as +/// either `+version` or `--version`. +pub fn run(_args: &[String]) -> Result { + let stdout = std::io::stdout(); + let tty = stdout.is_terminal(); + let mut out = std::io::BufWriter::new(stdout.lock()); + write_version(&mut out, &build_info::get(), tty)?; + out.flush()?; + Ok(0) +} + +fn version_na(v: Option) -> String { + v.map_or_else(|| "unknown".to_string(), |v| v.to_string()) +} + +/// Write the version text. +pub fn write_version(out: &mut dyn Write, info: &BuildInfo, tty: bool) -> std::io::Result<()> { + if tty { + if let Some(commit) = &info.commit { + write!( + out, + "\x1b]8;;https://github.com/ghostty-org/ghostty/commit/{commit}\x1b\\" + )?; + } + } + write!(out, "Ghostty {}\n\n", info.version_string)?; + if tty { + write!(out, "\x1b]8;;\x1b\\")?; + } + + writeln!(out, "Version")?; + writeln!(out, " - version: {}", info.version_string)?; + writeln!(out, " - channel: {}", info.release_channel)?; + + writeln!(out, "Build Config")?; + // The Zig build prints its compiler version here. + writeln!( + out, + " - Rust version : {}", + option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("unknown") + )?; + writeln!(out, " - build mode : {}", info.build_mode)?; + writeln!(out, " - app runtime : {}", info.app_runtime)?; + writeln!(out, " - font engine : {}", info.font_backend)?; + writeln!(out, " - renderer : {}", info.renderer)?; + // There is no libxev in the Rust port. + writeln!(out, " - libxev : n/a")?; + if info.app_runtime == ".gtk" { + let kernel_info = ghostty_os::get_kernel_info(); + writeln!( + out, + " - kernel version: {}", + kernel_info + .as_deref() + .unwrap_or("Kernel information unavailable") + )?; + let de = match ghostty_os::desktop_environment() { + ghostty_os::DesktopEnvironment::Gnome => "gnome", + ghostty_os::DesktopEnvironment::Macos => "macos", + ghostty_os::DesktopEnvironment::Other => "other", + ghostty_os::DesktopEnvironment::Windows => "windows", + }; + writeln!(out, " - desktop env : {de}")?; + writeln!(out, " - GTK version :")?; + writeln!(out, " build : {}", version_na(info.gtk_build))?; + writeln!(out, " runtime : {}", version_na(info.gtk_runtime))?; + writeln!(out, " - libadwaita : enabled")?; + writeln!(out, " build : {}", version_na(info.adw_build))?; + writeln!(out, " runtime : {}", version_na(info.adw_runtime))?; + writeln!( + out, + " - libX11 : {}", + if info.x11 { "enabled" } else { "disabled" } + )?; + writeln!( + out, + " - libwayland : {}", + if info.wayland { "enabled" } else { "disabled" } + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn version_output_layout() { + let mut buf = Vec::new(); + write_version(&mut buf, &BuildInfo::default(), false).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.starts_with( + "Ghostty 1.3.1\n\nVersion\n - version: 1.3.1\n - channel: stable\nBuild Config\n" + )); + assert!(s.contains(" - libwayland : enabled\n")); + } + + #[test] + fn version_tty_link() { + let info = BuildInfo { + commit: Some("abc".into()), + ..BuildInfo::default() + }; + let mut buf = Vec::new(); + write_version(&mut buf, &info, true).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!( + s.starts_with("\x1b]8;;https://github.com/ghostty-org/ghostty/commit/abc\x1b\\Ghostty") + ); + } +} diff --git a/docs/rewrite/notes/ghostty-cli.md b/docs/rewrite/notes/ghostty-cli.md new file mode 100644 index 0000000..db419af --- /dev/null +++ b/docs/rewrite/notes/ghostty-cli.md @@ -0,0 +1,87 @@ +# ghostty-cli port notes (C1) + +Crate `crates/ghostty-cli` (deps: ghostty-config, ghostty-input, ghostty-os; plus +ratatui/crossterm for TUIs, flate2 for boo frames, unicode-width). Run end to end with +`cargo run -p ghostty-cli --example cli -- +list-actions --docs` (the example behaves +like `ghostty` for actions via `ghostty_cli::run_from_args`, which also reproduces +main_ghostty.zig's "multiple/unknown action" errors). Do not edit the root crate: +wire `run_from_args(&std::env::args().collect::>())` into its main later. + +## API +Every action module exposes `pub fn run(args: &[String]) -> Result`; `args` is the +full argv (argv[0] skipped, `+action` args ignored, like `args.argsIterator`). `--help`/`-h` +yields `Error::ActionHelpRequested`, which `Action::run` turns into the help text (stdout, exit 0). +`Action::{ALL,name,from_name,file,help_text,run}`, `action::{detect_iter,ActionEnum,SpecialCase}`. + +## File -> module +| Zig | Rust | +|---|---| +| cli.zig / action.zig | `action.rs` (generic detection + 7 tests) | +| ghostty.zig | `ghostty.rs` (`Action`, dispatch, help, `run_from_args`; 5 tests) | +| help.zig, version.zig | `help.rs`, `version.rs` (+ `build_info.rs`) | +| list_actions/list_colors/list_keybinds | same names; pretty output via `tui.rs` | +| list_themes.zig | `list_themes.rs` (listing) + `list_themes/preview.rs` (ratatui TUI) | +| show_config/validate_config/edit_config/crash_report | same names | +| ssh_cache.zig + ssh-cache/{Entry,DiskCache} | `ssh_cache.rs`, `ssh_cache/{entry,disk_cache}.rs` | +| boo.zig (+ build/framegen/frames) | `boo.rs`, `boo_frames.deflate` (235 frames, raw deflate of frames joined by \x01) | +| tui.zig | `tui.rs` (+ a small vaxis `prettyPrint` clone) | +| list_fonts/show_face/new_window | stubs: Options + help + `Error::NotYetAvailable` (TODO(integration)) | + +Help texts (`help/*.txt`) are the `run` doc comments of the Zig files, extracted with the +helpgen rules (common-indent strip); `--help` output is byte-identical to Ghostty 1.3.1. + +## Oracle verification (Ghostty 1.3.1 at /usr/bin/ghostty) +Byte-identical (stdout, stderr, exit code): `+list-actions` (+`--docs`, `--help`), `+list-keybinds` +(`--plain`, `--default`, `--docs`; also with a custom config containing chords, chains, tables, +catch_all, unicode keys), `+list-themes --plain` (`--path`, `--color=dark|light`; with +`GHOSTTY_RESOURCES_DIR=/usr/share/ghostty`), `+help`, `--help`, `-h`, `+validate-config` (ok and +missing file), `+crash-report`, `+ssh-cache`, bad action / multiple actions / bad flag errors, +`--help` of every action, `+show-config` (except the two known config deviations: `auto-update-channel` +tip vs stable, `gtk-opengl-debug` debug build). **Pretty (tty) output of `+list-colors` and +`+list-keybinds` is byte-identical in a pty** for widths 20..200 (including line wrapping and +the first-row/teardown sequences of libvaxis); verified with a pty harness. + +Known differences: +* `+list-colors --plain`: Ghostty 1.3.1 never flushes its 4 KiB stdout buffer on this path, so its + output is truncated at 617 of 782 lines. We print all 782 (first 617 identical). +* `+list-colors` in a tty narrower than one column: Zig divides by zero; we clamp to one column. +* `+version` differs in version/build info (placeholders in `build_info`; TODO(integration): root + crate / apprt-gtk should call `build_info::set` with the real GTK/libadwaita versions, commit + and channel). "Zig version" row became "Rust version", "libxev" row prints `n/a`. +* `+edit-config` exec failure prints `error.FileNotFound`/`error.AccessDenied` for those kinds, + otherwise the Rust io error text. +* `+new-window`: the Zig parse hook swallows `--help`; ported as is. IPC not available. + +## Interactive `+list-themes` (ratatui) +Same layout as the vaxis version: 32-column list with `❯ name ❮` selection, preview (header, 16 +color swatches with decimal/hex numbers, 24-line code sample, lorem ipsum filler, diagnostics / +load-error panels), help (60x22), search (3-high box at bottom) and save (92x17) overlays, all +key bindings (q/Esc/^C, ?/F1/^H, /, ^X ^/, j/k/arrows, PgUp/PgDn (20), Home/End, h/x/d hex +toggle, c / C copy name/path via OSC 52, f cycle filter, Enter save dialog, w write +`auto/theme.ghostty`), mouse wheel / click / hover. Differences: no OSC 8 hyperlinks on names, +double/dashed/dotted/curly underlines drawn as single, color scheme not queried live (defaults to +light like Zig; `COLORFGBG` used as a hint), theme load errors during filtering are ignored +(Zig aborts), `zf` fuzzy ranking replaced by a subsequence matcher (only matching, not order, is +used). Logic (filtering, navigation, windowing, search, save) is unit tested; rendering is tested +against a ratatui `Buffer`. + +## Tests +Ported every Zig test in the ported files: action.zig (7 detect tests), ghostty.zig (5 parse +action tests), ssh-cache/Entry.zig (6), DiskCache.zig (4 operation tests + isValidHost, +isValidUser, isValidCacheKey). Skipped: none. 86 tests total including new ones (help/version +output, show-config, validate-config, crash-report, ssh-cache action, list-colors/keybinds/themes, +tui row rendering vs vaxis bytes, boo frame parsing of all 235 frames, preview state machine, +new-window arg hook). + +## TODO(integration) / gaps +* `list-fonts`, `show-face` (need ghostty-font), `new-window` (GTK IPC): return `Error::NotYetAvailable`. +* `build_info::set` for real version/GTK info. +* ghostty-vt `x11_color` lacks a key listing and loses name case: `list_colors` re-parses + `ghostty-vt/src/res/rgb.txt` through a relative `include_str!` path. +* ghostty-input `ActionKey::docs()` has an extra trailing blank line for 4 actions + (adjust_selection, ...) compared to Ghostty's help strings; `list_actions::generate_plaintext` + is a local copy of `helpgen_actions::generate` that normalises trailing newlines. Fix the data in + ghostty-input and switch back to `helpgen_actions::generate`. +* crash-report only lists (sentry is not ported). `ssh-cache` locking uses `File::try_lock`; + like Zig, a lock held by another process is ignored (the boolean result is unused upstream). +* `Config::load` and `finalize` are called twice in `validate-config` (as in Zig).