Files
ghostty-rs/crates/ghostty-cli/src/list_themes.rs
T
ddidderr f76179f49a fix: +show-face u21/UTF-8 semantics, Zig output buffering, isAbsolute
Resolves RV14 (docs/rewrite/notes/review-rv14-codex.md, final-Z17.md):
- +show-face --cp parses as u21 (new U21 field type): out-of-range
  values give an invalid-value diagnostic and exit 1 instead of a panic;
  --string accepts raw bytes and fails with error.InvalidUtf8 after the
  codepoint lookup like Zig.
- CLI actions write through ZigWriter, a model of Zig 0.15's buffered
  File.writer (fixed buffer, drain on overflow, explicit flush), so
  stdout/stderr interleave in Zig's flush order (+show-face, help,
  version, validate-config, show-config, list-*, ssh-cache, crash-report,
  edit-config, new-window).
- Zig 0.15.2 std.fs.path.isAbsolute port (Windows: \theme and /theme are
  absolute) replaces Path::is_absolute at every Zig isAbsolute site.
- io_uring is available iff a 256-entry ring can be created, like
  libxev; the reactor falls back to IORING_OP_TIMEOUT without EXT_ARG.
- Windows basename (`λ:theme`) verified against 0.15.2 (test added).

Test Plan:
- cargo test --workspace --no-fail-fast
- diff +show-face outputs (pipe) against /usr/bin/ghostty
2026-10-12 15:15:18 +02:00

442 lines
15 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,
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<f64>,
}
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<ThemeListElement> {
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<EncodedOs> {
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<u8, Error> {
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());
}
}