//! `+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, fmt::Write as _, io::{IsTerminal, Write}, }; use ghostty_config::{ config::Config, config_enum, config_struct, theme::{Location, LocationIterator}, }; use ghostty_os::osbytes::{EncodedOs, config_str_to_bytes, os_to_config_string}; use crate::{error::Error, 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: EncodedOs, 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 = config_str_to_bytes(&self.theme) .into_iter() .map(|c| c.to_ascii_lowercase()); let b = config_str_to_bytes(&other.theme) .into_iter() .map(|c| c.to_ascii_lowercase()); a.cmp(b) } /// Plain text for TUI display/search, separate from byte-preserving export. #[must_use] pub fn display_name(&self) -> String { ghostty_os::osbytes::config_str_to_text(&self.theme) } /// `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.to_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<&EncodedOs>) -> Vec { let mut themes = vec![]; let mut it = LocationIterator::new(resources_dir); while let Some((location, dir)) = it.next_location() { let rd = match dir.to_path().and_then(std::fs::read_dir) { Ok(rd) => rd, Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, Err(e) => { eprintln!("error trying to open {}: {e}", dir.to_text_lossy()); continue; } }; for entry in rd.flatten() { let Ok(ft) = entry.file_type() else { continue; }; if !(ft.is_file() || ft.is_symlink()) { continue; } // Entry names are raw bytes: keep them via the reversible escape. let raw_name = entry.file_name(); let name = os_to_config_string(&raw_name); if name == ".DS_Store" { continue; } themes.push(ThemeListElement { path: dir.join(&EncodedOs::from_os(&raw_name)), 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: &EncodedOs) -> EncodedOs { path.to_path() .and_then(std::path::absolute) .map_or_else(|_| path.clone(), |p| EncodedOs::from_path(&p)) } fn resources_dir() -> Option { ghostty_os::resources_dir() .ok() .and_then(|r| r.app().cloned()) } /// 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: &[impl AsRef<[u8]>]) -> Result { let mut opts = Options::default(); crate::parse_options(&mut opts, args)?; let stdout = std::io::stdout(); let mut stderr = crate::zig_writer::ZigWriter::new(crate::zig_writer::stderr_sink(), 4096); 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_ref()); 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 = crate::zig_writer::ZigWriter::new(crate::zig_writer::stdout_sink(), 4096); 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); // Names/paths are raw bytes in Zig: decode the reversible escape so // non-UTF-8 bytes are written back unchanged. let line = if opts.path { format!("{} ({loc}) {}\n", theme.theme, theme.path.as_encoded()) } else { format!("{} ({loc})\n", theme.theme) }; out.write_all(&config_str_to_bytes(&line))?; } 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 } #[cfg(unix)] #[test] fn collect_themes_keeps_non_utf8_names() { use std::os::unix::ffi::OsStrExt; let tmp = tempfile::tempdir().unwrap(); let dir = tmp.path().join("themes"); std::fs::create_dir_all(&dir).unwrap(); let raw = std::ffi::OsStr::from_bytes(b"th\xffeme"); std::fs::write(dir.join(raw), "background = #000000\n").unwrap(); let res = EncodedOs::from_path(tmp.path()); let themes: Vec<_> = collect_themes(Some(&res)) .into_iter() .filter(|t| t.location == Location::Resources) .collect(); assert_eq!(1, themes.len()); assert_eq!(config_str_to_bytes(&themes[0].theme), b"th\xffeme".to_vec()); // The path points at the real file and the listing loads it. assert_eq!(themes[0].path.to_path().unwrap(), dir.join(raw)); let mut out = Vec::new(); write_plain(&mut out, &themes, &Options::default()).unwrap(); assert_eq!(out, b"th\xffeme (resources)\n"); } #[test] fn theme_display_and_sort_use_original_names() { let theme = |name: EncodedOs| ThemeListElement { theme: name.into_encoded(), path: EncodedOs::new(), location: Location::Resources, rank: None, }; let literal = theme(EncodedOs::from_plain_text("\u{fdd0}é")); let invalid = theme(EncodedOs::from_bytes(b"\xff")); assert_eq!(literal.display_name(), "\u{fdd0}é"); assert_eq!(literal.order(&invalid), Ordering::Less); } #[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 = EncodedOs::from_path(tmp.path()); 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) {}/themes/Zeta\n", res.as_encoded()), 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: EncodedOs::from_encoded("/a b/c%d"), theme: "x".into(), rank: None, }; assert_eq!("file:///a%20b/c%25d", t.to_uri()); } #[test] fn uri_escaping_percent_encodes_original_bytes() { let t = ThemeListElement { location: Location::User, path: EncodedOs::from_bytes(b"/a/\xff\xc3x"), theme: "x".into(), rank: None, }; assert_eq!("file:///a/%FF%C3x", t.to_uri()); } #[test] fn options_color() { let mut opts = Options::default(); crate::parse_options(&mut opts, &["g", "--color=dark", "--path"]).unwrap(); assert_eq!(ColorScheme::Dark, opts.color); assert!(opts.path); assert!(crate::parse_options(&mut opts, &["g", "--color=blue"]).is_err()); } }