Files
ghostty-rs/crates/ghostty-cli/src/list_themes.rs
T
ddidderr 6286e02f07 feat(cli): port ghostty +action CLI commands
Ports ZIG/src/cli into ghostty-cli: action detection/dispatch with
per-action --help, and the actions help, version, list-actions,
list-colors, list-keybinds, list-themes (plain listing plus an
interactive ratatui browser mirroring Ghostty's libvaxis UI),
show-config, validate-config, edit-config, crash-report (listing),
ssh-cache and boo (235 embedded frames).

Verified byte-for-byte against the installed Ghostty 1.3.1 for stdout,
stderr and exit codes of most actions, incl. tty output in a pty at
widths 20-200. Known differences: +list-colors --plain prints all 782
colors (Ghostty truncates at 617 because it never flushes), +version
has placeholder build info until build_info::set is wired up.

Deferred: list-fonts, show-face (need font discovery integration) and
new-window (needs GTK IPC); they return NotYetAvailable for now.

Test Plan:
- cargo test -p ghostty-cli (86 tests)
- cargo run -p ghostty-cli --example cli -- +list-keybinds --plain
  and diff against `ghostty +list-keybinds --plain`
2026-10-11 22:22:01 +02:00

380 lines
12 KiB
Rust

//! `+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<f64>,
}
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<ThemeListElement> {
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<String> {
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<u8, Error> {
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());
}
}