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`
380 lines
12 KiB
Rust
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());
|
|
}
|
|
}
|