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`
This commit is contained in:
1 parent
a97ae04f1d
commit
6286e02f07
46 files changed
+7135
-6
No files matched your search
@@ -0,0 +1,379 @@
|
||||
//! `+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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user