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 0000000..09e7cc5 Binary files /dev/null and b/crates/ghostty-cli/src/boo_frames.deflate differ 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).