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
442 lines
15 KiB
Rust
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());
|
|
}
|
|
}
|