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
5462bd8e3e
commit
f2c4b40062
46 files changed
+7135
-6
No files matched your search
Generated
+132
-6
@@ -229,7 +229,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.3",
|
||||||
"shlex 1.3.0",
|
"shlex 1.3.0",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
@@ -240,7 +240,16 @@ version = "0.5.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-vec",
|
"bit-vec 0.6.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-set"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||||
|
dependencies = [
|
||||||
|
"bit-vec 0.9.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -249,6 +258,12 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-vec"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -358,6 +373,15 @@ dependencies = [
|
|||||||
"libloading",
|
"libloading",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "codespan-reporting"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compact_str"
|
name = "compact_str"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -674,7 +698,7 @@ version = "0.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
|
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-set",
|
"bit-set 0.5.3",
|
||||||
"regex",
|
"regex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -865,7 +889,6 @@ dependencies = [
|
|||||||
name = "ghostty-cli"
|
name = "ghostty-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm",
|
|
||||||
"flate2",
|
"flate2",
|
||||||
"ghostty-config",
|
"ghostty-config",
|
||||||
"ghostty-input",
|
"ghostty-input",
|
||||||
@@ -954,9 +977,13 @@ dependencies = [
|
|||||||
name = "ghostty-renderer"
|
name = "ghostty-renderer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ghostty-config",
|
"bytemuck",
|
||||||
"ghostty-font",
|
"ghostty-font",
|
||||||
"ghostty-terminal",
|
"glow",
|
||||||
|
"khronos-egl",
|
||||||
|
"log",
|
||||||
|
"naga",
|
||||||
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -972,7 +999,10 @@ dependencies = [
|
|||||||
name = "ghostty-terminal"
|
name = "ghostty-terminal"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ghostty-datastruct",
|
||||||
|
"ghostty-unicode",
|
||||||
"ghostty-vt",
|
"ghostty-vt",
|
||||||
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1008,6 +1038,18 @@ version = "0.3.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glow"
|
||||||
|
version = "0.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "263218b0ba2b0715c3370fb04674f5f8aa331594b521c94ff0a8e4a8472d1386"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"slotmap",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "half"
|
name = "half"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
@@ -1016,6 +1058,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"crunchy",
|
"crunchy",
|
||||||
|
"num-traits",
|
||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1157,6 +1200,16 @@ dependencies = [
|
|||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "khronos-egl"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"libloading",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "konst"
|
name = "konst"
|
||||||
version = "0.2.20"
|
version = "0.2.20"
|
||||||
@@ -1354,6 +1407,44 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "naga"
|
||||||
|
version = "30.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"bit-set 0.10.0",
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"codespan-reporting",
|
||||||
|
"half",
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
"indexmap",
|
||||||
|
"libm",
|
||||||
|
"log",
|
||||||
|
"naga-types",
|
||||||
|
"num-traits",
|
||||||
|
"once_cell",
|
||||||
|
"pp-rs",
|
||||||
|
"rustc-hash 1.1.0",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "naga-types"
|
||||||
|
version = "30.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
"indexmap",
|
||||||
|
"rustc-hash 1.1.0",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -1413,6 +1504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
|
"libm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1749,6 +1841,15 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pp-rs"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-xid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -1961,6 +2062,12 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -2126,6 +2233,15 @@ version = "0.4.12"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slotmap"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
|
||||||
|
dependencies = [
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.16.3"
|
version = "1.16.3"
|
||||||
@@ -2627,6 +2743,16 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wezterm-bidi"
|
name = "wezterm-bidi"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
flate2 = "1.1.10"
|
||||||
ghostty-config.workspace = true
|
ghostty-config.workspace = true
|
||||||
ghostty-input.workspace = true
|
ghostty-input.workspace = true
|
||||||
ghostty-os.workspace = true
|
ghostty-os.workspace = true
|
||||||
|
log = "0.4.34"
|
||||||
|
ratatui = "0.30.2"
|
||||||
|
thiserror = "2.0.21"
|
||||||
|
unicode-width = "0.2.2"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.27.0"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//! End-to-end runner for the CLI actions:
|
||||||
|
//! `cargo run -p ghostty-cli --example cli -- +list-actions --docs`
|
||||||
|
//!
|
||||||
|
//! Behaves like `ghostty` when an action is given (including `--version`,
|
||||||
|
//! `--help` and `-h`), and otherwise prints a note.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if let Some(code) = ghostty_cli::run_from_args(&args) {
|
||||||
|
std::process::exit(i32::from(code));
|
||||||
|
}
|
||||||
|
eprintln!("no +action given (this example only runs CLI actions)");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//! Detection of `+action` arguments (ZIG/src/cli/action.zig).
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Errors from [`detect_iter`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DetectError {
|
||||||
|
/// Multiple actions were detected. You can specify at most one
|
||||||
|
/// action on the CLI otherwise the behavior desired is ambiguous.
|
||||||
|
MultipleActions,
|
||||||
|
|
||||||
|
/// An unknown action was specified.
|
||||||
|
InvalidAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for DetectError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
DetectError::MultipleActions => f.write_str("MultipleActions"),
|
||||||
|
DetectError::InvalidAction => f.write_str("InvalidAction"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for DetectError {}
|
||||||
|
|
||||||
|
/// The action enum E can implement `detect_special_case` to return this
|
||||||
|
/// enum in order to perform various special case actions.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SpecialCase<E> {
|
||||||
|
/// Immediately return this action.
|
||||||
|
Action(E),
|
||||||
|
|
||||||
|
/// Return this action if no other action is found.
|
||||||
|
Fallback(E),
|
||||||
|
|
||||||
|
/// If there is no pending action (we haven't seen an action yet)
|
||||||
|
/// then we should return no action. This is kind of weird but is
|
||||||
|
/// a special case to allow "-e" in Ghostty.
|
||||||
|
AbortIfNoAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An enum of actions that can be detected from CLI args.
|
||||||
|
pub trait ActionEnum: Sized + Copy {
|
||||||
|
/// `std.meta.stringToEnum`.
|
||||||
|
fn from_name(name: &str) -> Option<Self>;
|
||||||
|
|
||||||
|
/// `E.detectSpecialCase`; the default has no special cases.
|
||||||
|
fn detect_special_case(_arg: &str) -> Option<SpecialCase<Self>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the action from the process CLI args (Zig: `detectArgs`).
|
||||||
|
pub fn detect_args<E: ActionEnum>() -> Result<Option<E>, DetectError> {
|
||||||
|
detect_iter(std::env::args())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the action from any iterator. Each iterator value should yield
|
||||||
|
/// a CLI argument such as "--foo".
|
||||||
|
pub fn detect_iter<E: ActionEnum, I, S>(iter: I) -> Result<Option<E>, DetectError>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
let mut fallback: Option<E> = None;
|
||||||
|
let mut pending: Option<E> = None;
|
||||||
|
for arg in iter {
|
||||||
|
let arg = arg.as_ref();
|
||||||
|
|
||||||
|
// Allow handling of special cases.
|
||||||
|
if let Some(special) = E::detect_special_case(arg) {
|
||||||
|
match special {
|
||||||
|
SpecialCase::Action(a) => return Ok(Some(a)),
|
||||||
|
SpecialCase::Fallback(a) => fallback = Some(a),
|
||||||
|
SpecialCase::AbortIfNoAction => {
|
||||||
|
if pending.is_none() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands must start with "+"
|
||||||
|
let Some(name) = arg.strip_prefix('+') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if pending.is_some() {
|
||||||
|
return Err(DetectError::MultipleActions);
|
||||||
|
}
|
||||||
|
pending = Some(E::from_name(name).ok_or(DetectError::InvalidAction)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have an action, we always return that action, even if we've
|
||||||
|
// seen "--help" or "-h" because the action may have its own help text.
|
||||||
|
if pending.is_some() {
|
||||||
|
return Ok(pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have no action but we have a fallback, then we return that.
|
||||||
|
Ok(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
use ghostty_config::cli_args::split_args;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Plain {
|
||||||
|
Foo,
|
||||||
|
Bar,
|
||||||
|
Baz,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionEnum for Plain {
|
||||||
|
fn from_name(name: &str) -> Option<Self> {
|
||||||
|
match name {
|
||||||
|
"foo" => Some(Plain::Foo),
|
||||||
|
"bar" => Some(Plain::Bar),
|
||||||
|
"baz" => Some(Plain::Baz),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Special {
|
||||||
|
Foo,
|
||||||
|
Bar,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which special case `Special` reports for which argument.
|
||||||
|
static SPECIAL_ARG: std::sync::Mutex<(&str, u8)> = std::sync::Mutex::new(("", 0));
|
||||||
|
|
||||||
|
impl ActionEnum for Special {
|
||||||
|
fn from_name(name: &str) -> Option<Self> {
|
||||||
|
match name {
|
||||||
|
"foo" => Some(Special::Foo),
|
||||||
|
"bar" => Some(Special::Bar),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_special_case(arg: &str) -> Option<SpecialCase<Self>> {
|
||||||
|
let (special, kind) = *SPECIAL_ARG.lock().unwrap();
|
||||||
|
if arg != special {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(match kind {
|
||||||
|
0 => SpecialCase::Action(Special::Foo),
|
||||||
|
1 => SpecialCase::Fallback(Special::Foo),
|
||||||
|
_ => SpecialCase::AbortIfNoAction,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The special-case tests share a global, so they run in one test body
|
||||||
|
/// each under this lock.
|
||||||
|
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
|
fn set_special(arg: &'static str, kind: u8) {
|
||||||
|
*SPECIAL_ARG.lock().unwrap() = (arg, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect<E: ActionEnum>(s: &str) -> Result<Option<E>, DetectError> {
|
||||||
|
detect_iter::<E, _, _>(split_args(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_direct_match() {
|
||||||
|
assert_eq!(Some(Plain::Foo), detect::<Plain>("+foo").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_invalid_match() {
|
||||||
|
assert_eq!(Err(DetectError::InvalidAction), detect::<Plain>("+invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_multiple_actions() {
|
||||||
|
assert_eq!(
|
||||||
|
Err(DetectError::MultipleActions),
|
||||||
|
detect::<Plain>("+foo +bar")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_no_match() {
|
||||||
|
assert_eq!(None, detect::<Plain>("--some-flag").unwrap());
|
||||||
|
let _ = Plain::Baz;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_special_case_action() {
|
||||||
|
let _g = LOCK
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
set_special("--special", 0);
|
||||||
|
assert_eq!(
|
||||||
|
Some(Special::Foo),
|
||||||
|
detect::<Special>("--special +bar").unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Some(Special::Foo),
|
||||||
|
detect::<Special>("+bar --special").unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(Some(Special::Bar), detect::<Special>("+bar").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_special_case_fallback() {
|
||||||
|
let _g = LOCK
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
set_special("--special", 1);
|
||||||
|
assert_eq!(Some(Special::Foo), detect::<Special>("--special").unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
Some(Special::Bar),
|
||||||
|
detect::<Special>("+bar --special").unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Some(Special::Bar),
|
||||||
|
detect::<Special>("--special +bar").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_special_case_abort_if_no_action() {
|
||||||
|
let _g = LOCK
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
set_special("-e", 2);
|
||||||
|
assert_eq!(None, detect::<Special>("-e").unwrap());
|
||||||
|
assert_eq!(Some(Special::Foo), detect::<Special>("+foo -e").unwrap());
|
||||||
|
assert_eq!(None, detect::<Special>("-e +bar").unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
//! `+boo` (ZIG/src/cli/boo.zig): the ghost animation from the Ghostty
|
||||||
|
//! website, played in the terminal.
|
||||||
|
//!
|
||||||
|
//! The frames are `src/build/framegen/frames/frame_*.txt` from the Zig tree,
|
||||||
|
//! joined with `\x01` and raw-deflate compressed into `boo_frames.deflate`
|
||||||
|
//! (the same format `framegen` produces for Zig's `framedata`).
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use flate2::read::DeflateDecoder;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Style};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::options::NoOptions as Options;
|
||||||
|
|
||||||
|
static FRAME_DATA: &[u8] = include_bytes!("boo_frames.deflate");
|
||||||
|
|
||||||
|
/// Width of a single frame
|
||||||
|
pub const FRAME_WIDTH: u16 = 100;
|
||||||
|
/// Height of a single frame
|
||||||
|
pub const FRAME_HEIGHT: u16 = 41;
|
||||||
|
|
||||||
|
/// One cell of an animation frame: the character and whether it is part of
|
||||||
|
/// the outline (drawn with the outline style).
|
||||||
|
pub type FrameCell = (char, bool);
|
||||||
|
|
||||||
|
/// Decompress the frames into a list of individual frames
|
||||||
|
/// (Zig: `decompressFrames`).
|
||||||
|
pub fn decompress_frames() -> Result<Vec<String>, Error> {
|
||||||
|
let mut data = Vec::new();
|
||||||
|
DeflateDecoder::new(FRAME_DATA).read_to_end(&mut data)?;
|
||||||
|
let text = String::from_utf8(data).map_err(|e| Error::Other(e.to_string()))?;
|
||||||
|
Ok(text.split('\u{1}').map(str::to_string).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum State {
|
||||||
|
Normal,
|
||||||
|
Span,
|
||||||
|
InTag,
|
||||||
|
InClosingTag,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a frame to its cells (Zig: the body of `updateFrame`).
|
||||||
|
///
|
||||||
|
/// A frame is characters with html spans. When we encounter a span, we use
|
||||||
|
/// the outline style until the span ends. That is, when we find a '<', we
|
||||||
|
/// parse until '>'. Then we use the outline style until the next '<', and
|
||||||
|
/// skip until the next '>'.
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse_frame(frame: &str) -> Vec<FrameCell> {
|
||||||
|
let mut cells = Vec::with_capacity(usize::from(FRAME_WIDTH) * usize::from(FRAME_HEIGHT));
|
||||||
|
for line in frame.split('\n') {
|
||||||
|
let mut state = State::Normal;
|
||||||
|
let mut outline = false;
|
||||||
|
for ch in line.chars() {
|
||||||
|
match state {
|
||||||
|
State::Normal => {
|
||||||
|
if ch == '<' {
|
||||||
|
state = State::InTag;
|
||||||
|
// We will be entering a span
|
||||||
|
outline = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::Span => {
|
||||||
|
if ch == '<' {
|
||||||
|
state = State::InTag;
|
||||||
|
outline = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::InTag => {
|
||||||
|
// If we encounter a '/', we are a closing tag
|
||||||
|
// If we parse all the way to a '>' we are an opening tag: we are now in a span
|
||||||
|
if ch == '/' {
|
||||||
|
state = State::InClosingTag;
|
||||||
|
} else if ch == '>' {
|
||||||
|
state = State::Span;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
State::InClosingTag => {
|
||||||
|
// If we are closing a tag, we will enter the normal state
|
||||||
|
if ch == '>' {
|
||||||
|
state = State::Normal;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push((ch, outline));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The animation state (Zig: `Boo`).
|
||||||
|
pub struct Boo {
|
||||||
|
frames: Vec<String>,
|
||||||
|
frame: usize,
|
||||||
|
/// 30 fps
|
||||||
|
pub framerate: Duration,
|
||||||
|
buffer: Vec<FrameCell>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Boo {
|
||||||
|
pub fn new() -> Result<Boo, Error> {
|
||||||
|
Ok(Boo {
|
||||||
|
frames: decompress_frames()?,
|
||||||
|
frame: 0,
|
||||||
|
framerate: Duration::from_millis(1000 / 30),
|
||||||
|
buffer: vec![(' ', false); usize::from(FRAME_WIDTH) * usize::from(FRAME_HEIGHT)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates our internal buffer with the current frame, then advances the
|
||||||
|
/// frame index.
|
||||||
|
pub fn update_frame(&mut self) {
|
||||||
|
self.buffer = parse_frame(&self.frames[self.frame]);
|
||||||
|
debug_assert_eq!(
|
||||||
|
self.buffer.len(),
|
||||||
|
usize::from(FRAME_WIDTH) * usize::from(FRAME_HEIGHT)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lastly, update the frame index
|
||||||
|
self.frame += 1;
|
||||||
|
if self.frame == self.frames.len() {
|
||||||
|
self.frame = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn frame_count(&self) -> usize {
|
||||||
|
self.frames.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the current frame centered in `buf`.
|
||||||
|
pub fn draw(&self, buf: &mut Buffer) {
|
||||||
|
let area = buf.area;
|
||||||
|
// Warn for screen size
|
||||||
|
if area.width < FRAME_WIDTH || area.height < FRAME_HEIGHT {
|
||||||
|
let text = "Screen must be at least 100w x 41h";
|
||||||
|
let w = u16::try_from(text.len()).unwrap_or(0);
|
||||||
|
let x = area.x + area.width.saturating_sub(w) / 2;
|
||||||
|
let y = area.y + area.height / 2;
|
||||||
|
buf.set_stringn(x, y, text, usize::from(area.width), Style::default());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate x and y offsets to center the animation frame
|
||||||
|
let offset_y = area.y + (area.height - FRAME_HEIGHT) / 2;
|
||||||
|
let offset_x = area.x + (area.width - FRAME_WIDTH) / 2;
|
||||||
|
|
||||||
|
let ghostty_style = Style::default();
|
||||||
|
let outline_style = Style::default().fg(Color::Indexed(4));
|
||||||
|
let frame_area = Rect::new(offset_x, offset_y, FRAME_WIDTH, FRAME_HEIGHT);
|
||||||
|
for (i, (ch, outline)) in self.buffer.iter().enumerate() {
|
||||||
|
let i = u16::try_from(i).unwrap_or(0);
|
||||||
|
let x = frame_area.x + i % FRAME_WIDTH;
|
||||||
|
let y = frame_area.y + i / FRAME_WIDTH;
|
||||||
|
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||||
|
let mut tmp = [0u8; 4];
|
||||||
|
cell.set_symbol(ch.encode_utf8(&mut tmp));
|
||||||
|
cell.set_style(if *outline {
|
||||||
|
outline_style
|
||||||
|
} else {
|
||||||
|
ghostty_style
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `boo` command is used to display the animation from the Ghostty website in the terminal
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut opts = Options;
|
||||||
|
crate::parse_options(&mut opts, args)?;
|
||||||
|
|
||||||
|
let mut boo = Boo::new()?;
|
||||||
|
let mut terminal = ratatui::try_init()?;
|
||||||
|
let result = run_loop(&mut terminal, &mut boo);
|
||||||
|
ratatui::restore();
|
||||||
|
result?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_loop(terminal: &mut ratatui::DefaultTerminal, boo: &mut Boo) -> Result<(), Error> {
|
||||||
|
let mut next_tick = Instant::now();
|
||||||
|
loop {
|
||||||
|
if Instant::now() >= next_tick {
|
||||||
|
boo.update_frame();
|
||||||
|
next_tick = Instant::now() + boo.framerate;
|
||||||
|
}
|
||||||
|
terminal.draw(|f| boo.draw(f.buffer_mut()))?;
|
||||||
|
|
||||||
|
let timeout = next_tick.saturating_duration_since(Instant::now());
|
||||||
|
if event::poll(timeout)? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
if key.kind != KeyEventKind::Release
|
||||||
|
&& ((key.code == KeyCode::Char('c')
|
||||||
|
&& key.modifiers.contains(KeyModifiers::CONTROL))
|
||||||
|
|| key.code == KeyCode::Esc)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_frames_decode_to_full_screen() {
|
||||||
|
let frames = decompress_frames().unwrap();
|
||||||
|
assert_eq!(235, frames.len());
|
||||||
|
for (i, f) in frames.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
usize::from(FRAME_WIDTH) * usize::from(FRAME_HEIGHT),
|
||||||
|
parse_frame(f).len(),
|
||||||
|
"frame {i}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spans_use_outline_style() {
|
||||||
|
// normal, then a span (outline), then normal again.
|
||||||
|
let cells = parse_frame("a<b>bc</b>d");
|
||||||
|
assert_eq!(
|
||||||
|
vec![('a', false), ('b', true), ('c', true), ('d', false)],
|
||||||
|
cells
|
||||||
|
);
|
||||||
|
// Style resets on each line (a closing tag seen in the normal state keeps the outline, like Zig).
|
||||||
|
let cells = parse_frame("<b>x\ny</b>z");
|
||||||
|
assert_eq!(vec![('x', true), ('y', false), ('z', true)], cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_advances_and_wraps() {
|
||||||
|
let mut boo = Boo::new().unwrap();
|
||||||
|
for _ in 0..boo.frame_count() {
|
||||||
|
boo.update_frame();
|
||||||
|
}
|
||||||
|
assert_eq!(0, boo.frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn small_screen_warning() {
|
||||||
|
let boo = Boo::new().unwrap();
|
||||||
|
let mut buf = Buffer::empty(Rect::new(0, 0, 60, 10));
|
||||||
|
boo.draw(&mut buf);
|
||||||
|
let line: String = (0..60).map(|x| buf[(x, 5)].symbol().to_string()).collect();
|
||||||
|
assert!(line.contains("Screen must be at least 100w x 41h"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_centers_frame() {
|
||||||
|
let mut boo = Boo::new().unwrap();
|
||||||
|
boo.update_frame();
|
||||||
|
let mut buf = Buffer::empty(Rect::new(0, 0, 120, 50));
|
||||||
|
boo.draw(&mut buf);
|
||||||
|
// Anything outside the frame area stays blank.
|
||||||
|
assert_eq!(" ", buf[(0, 0)].symbol());
|
||||||
|
assert!((10..110).any(|x| (4..45).any(|y| buf[(x, y)].symbol() != " ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,85 @@
|
|||||||
|
//! Build-time information shown by `+version` (Zig: `build_config`,
|
||||||
|
//! `builtin`, `gtk_version`, `adw_version`).
|
||||||
|
//!
|
||||||
|
//! TODO(integration): the root crate / apprt-gtk should call [`set`] with the
|
||||||
|
//! real GTK and libadwaita versions (the Zig code queries them at runtime).
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// A `major.minor.micro` version (Zig: `gtk_version.zig` / `adw_version.zig`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Version {
|
||||||
|
pub major: u32,
|
||||||
|
pub minor: u32,
|
||||||
|
pub micro: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Version {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}.{}.{}", self.major, self.minor, self.micro)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BuildInfo {
|
||||||
|
/// `build_config.version_string`.
|
||||||
|
pub version_string: String,
|
||||||
|
/// `build_config.version.build` (the git commit hash), if known.
|
||||||
|
pub commit: Option<String>,
|
||||||
|
/// `build_config.release_channel` tag name (`stable` / `tip`).
|
||||||
|
pub release_channel: String,
|
||||||
|
/// `{}` of `builtin.mode` (`.Debug`, `.ReleaseFast`, ...).
|
||||||
|
pub build_mode: String,
|
||||||
|
/// `{}` of `build_config.app_runtime`.
|
||||||
|
pub app_runtime: String,
|
||||||
|
/// `{}` of `build_config.font_backend`.
|
||||||
|
pub font_backend: String,
|
||||||
|
/// `{}` of `renderer.Renderer`.
|
||||||
|
pub renderer: String,
|
||||||
|
pub gtk_build: Option<Version>,
|
||||||
|
pub gtk_runtime: Option<Version>,
|
||||||
|
pub adw_build: Option<Version>,
|
||||||
|
pub adw_runtime: Option<Version>,
|
||||||
|
/// `build_options.x11`.
|
||||||
|
pub x11: bool,
|
||||||
|
/// `build_options.wayland`.
|
||||||
|
pub wayland: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BuildInfo {
|
||||||
|
fn default() -> Self {
|
||||||
|
BuildInfo {
|
||||||
|
version_string: "1.3.1".to_string(),
|
||||||
|
commit: None,
|
||||||
|
release_channel: "stable".to_string(),
|
||||||
|
build_mode: if cfg!(debug_assertions) {
|
||||||
|
".Debug"
|
||||||
|
} else {
|
||||||
|
".ReleaseFast"
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
app_runtime: ".gtk".to_string(),
|
||||||
|
font_backend: ".fontconfig_freetype".to_string(),
|
||||||
|
renderer: "renderer.generic.Renderer(renderer.OpenGL)".to_string(),
|
||||||
|
gtk_build: None,
|
||||||
|
gtk_runtime: None,
|
||||||
|
adw_build: None,
|
||||||
|
adw_runtime: None,
|
||||||
|
x11: true,
|
||||||
|
wayland: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static INFO: OnceLock<BuildInfo> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Install the build info. Returns false if it was already set.
|
||||||
|
pub fn set(info: BuildInfo) -> bool {
|
||||||
|
INFO.set(info).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The installed build info, or the defaults.
|
||||||
|
#[must_use]
|
||||||
|
pub fn get() -> BuildInfo {
|
||||||
|
INFO.get().cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//! `+crash-report` (ZIG/src/cli/crash_report.zig and the directory listing
|
||||||
|
//! of ZIG/src/crash/dir.zig).
|
||||||
|
//!
|
||||||
|
//! Ghostty uses sentry for crash reporting; only the listing behaviour is
|
||||||
|
//! ported.
|
||||||
|
|
||||||
|
use std::io::{IsTerminal, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use ghostty_config::types_b::Duration;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
pub use crate::options::NoOptions as Options;
|
||||||
|
|
||||||
|
/// A crash report file (Zig: `crash.Report`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Report {
|
||||||
|
pub name: String,
|
||||||
|
/// Modification time in nanoseconds since the unix epoch.
|
||||||
|
pub mtime: i128,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default crash directory (Zig: `crash.defaultDir`):
|
||||||
|
/// `$XDG_STATE_HOME/ghostty/crash`.
|
||||||
|
pub fn default_dir() -> Result<PathBuf, Error> {
|
||||||
|
let dir = ghostty_os::xdg::state(&ghostty_os::xdg::Options {
|
||||||
|
subdir: Some("ghostty/crash"),
|
||||||
|
home: None,
|
||||||
|
})
|
||||||
|
.map_err(|e| Error::Other(e.to_string()))?;
|
||||||
|
Ok(PathBuf::from(dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List the crash reports (regular files) in `dir`. A directory that cannot
|
||||||
|
/// be opened yields no reports.
|
||||||
|
pub fn list_reports(dir: &std::path::Path) -> Result<Vec<Report>, Error> {
|
||||||
|
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||||
|
return Ok(vec![]);
|
||||||
|
};
|
||||||
|
let mut reports = vec![];
|
||||||
|
for entry in rd {
|
||||||
|
let entry = entry?;
|
||||||
|
if !entry.file_type()?.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mtime = entry.metadata()?.modified()?;
|
||||||
|
let mtime = match mtime.duration_since(UNIX_EPOCH) {
|
||||||
|
Ok(d) => i128::try_from(d.as_nanos()).unwrap_or(i128::MAX),
|
||||||
|
Err(e) => -i128::try_from(e.duration().as_nanos()).unwrap_or(i128::MAX),
|
||||||
|
};
|
||||||
|
reports.push(Report {
|
||||||
|
name: entry.file_name().to_string_lossy().into_owned(),
|
||||||
|
mtime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(reports)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `crash-report` command is used to inspect and send crash reports.
|
||||||
|
///
|
||||||
|
/// When executed without any arguments, this will list existing crash reports.
|
||||||
|
///
|
||||||
|
/// This command currently only supports listing crash reports. Viewing
|
||||||
|
/// and sending crash reports is unimplemented and will be added in the future.
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut opts = Options;
|
||||||
|
crate::parse_options(&mut opts, args)?;
|
||||||
|
|
||||||
|
let stdout = std::io::stdout();
|
||||||
|
let is_tty = stdout.is_terminal();
|
||||||
|
let mut out = std::io::BufWriter::new(stdout.lock());
|
||||||
|
let result = run_inner(&default_dir()?, is_tty, now_ns(), &mut out);
|
||||||
|
let _ = out.flush();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ns() -> i128 {
|
||||||
|
match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||||
|
Ok(d) => i128::try_from(d.as_nanos()).unwrap_or(i128::MAX),
|
||||||
|
Err(_) => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_inner(
|
||||||
|
crash_dir: &std::path::Path,
|
||||||
|
is_tty: bool,
|
||||||
|
now: i128,
|
||||||
|
stdout: &mut dyn Write,
|
||||||
|
) -> Result<u8, Error> {
|
||||||
|
let mut reports = list_reports(crash_dir)?;
|
||||||
|
|
||||||
|
// If we have no reports, then we're done. If we have a tty then we
|
||||||
|
// print a message, otherwise we do nothing.
|
||||||
|
if reports.is_empty() {
|
||||||
|
if is_tty {
|
||||||
|
stdout.write_all("No crash reports! \u{1F47B}\n".as_bytes())?;
|
||||||
|
}
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest first.
|
||||||
|
reports.sort_by_key(|r| std::cmp::Reverse(r.mtime));
|
||||||
|
|
||||||
|
for report in &reports {
|
||||||
|
let diff = now - report.mtime;
|
||||||
|
let since = if diff <= 0 {
|
||||||
|
"now".to_string()
|
||||||
|
} else {
|
||||||
|
let d = Duration {
|
||||||
|
duration: u64::try_from(diff).unwrap_or(u64::MAX),
|
||||||
|
};
|
||||||
|
format!("{} ago", d.round(1_000_000_000))
|
||||||
|
};
|
||||||
|
writeln!(stdout, "{} ({since})", report.name)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_dir_lists_nothing() {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let rc = run_inner(
|
||||||
|
std::path::Path::new("/nonexistent/crash"),
|
||||||
|
true,
|
||||||
|
0,
|
||||||
|
&mut out,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(0, rc);
|
||||||
|
assert_eq!("No crash reports! \u{1F47B}\n".as_bytes(), out.as_slice());
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
run_inner(
|
||||||
|
std::path::Path::new("/nonexistent/crash"),
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
&mut out,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(0, out.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lists_newest_first() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("ghostty-cli-crash-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("subdir")).unwrap();
|
||||||
|
std::fs::write(dir.join("a.ghosttycrash"), "x").unwrap();
|
||||||
|
std::fs::write(dir.join("b.ghosttycrash"), "y").unwrap();
|
||||||
|
let old = std::fs::File::options()
|
||||||
|
.write(true)
|
||||||
|
.open(dir.join("a.ghosttycrash"))
|
||||||
|
.unwrap();
|
||||||
|
let a_time = SystemTime::now() - std::time::Duration::from_secs(1000);
|
||||||
|
old.set_modified(a_time).unwrap();
|
||||||
|
drop(old);
|
||||||
|
let a_ns = i128::try_from(a_time.duration_since(UNIX_EPOCH).unwrap().as_nanos()).unwrap();
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
run_inner(&dir, false, a_ns + (3 * 3600 + 5) * 1_000_000_000, &mut out).unwrap();
|
||||||
|
let s = String::from_utf8(out).unwrap();
|
||||||
|
let lines: Vec<_> = s.lines().collect();
|
||||||
|
assert_eq!(2, lines.len(), "{s}");
|
||||||
|
assert!(lines[0].starts_with("b.ghosttycrash ("), "{s}");
|
||||||
|
assert_eq!("a.ghosttycrash (3h 5s ago)", lines[1]);
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//! `+edit-config` (ZIG/src/cli/edit_config.zig).
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
||||||
|
use ghostty_config::config::Config;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
pub use crate::options::NoOptions as Options;
|
||||||
|
|
||||||
|
/// The `edit-config` command opens the Ghostty configuration file in the
|
||||||
|
/// editor specified by the `$VISUAL` or `$EDITOR` environment variables.
|
||||||
|
///
|
||||||
|
/// IMPORTANT: This command will not reload the configuration after
|
||||||
|
/// editing. You will need to manually reload the configuration using the
|
||||||
|
/// application menu, configured keybind, or by restarting Ghostty. We
|
||||||
|
/// plan to auto-reload in the future, but Ghostty isn't capable of
|
||||||
|
/// this yet.
|
||||||
|
///
|
||||||
|
/// The filepath opened is the default user-specific configuration
|
||||||
|
/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config.ghostty`.
|
||||||
|
/// On macOS, this may also be located at
|
||||||
|
/// `~/Library/Application Support/com.mitchellh.ghostty/config.ghostty`.
|
||||||
|
/// On macOS, whichever path exists and is non-empty will be prioritized,
|
||||||
|
/// prioritizing the Application Support directory if neither are
|
||||||
|
/// non-empty.
|
||||||
|
///
|
||||||
|
/// This command prefers the `$VISUAL` environment variable over `$EDITOR`,
|
||||||
|
/// if both are set. If neither are set, it will print an error
|
||||||
|
/// and exit.
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut stderr = std::io::stderr().lock();
|
||||||
|
|
||||||
|
let mut opts = Options;
|
||||||
|
crate::parse_options(&mut opts, args)?;
|
||||||
|
|
||||||
|
let result = run_inner(&mut stderr);
|
||||||
|
// Flushing *shouldn't* fail but...
|
||||||
|
let _ = stderr.flush();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick the editor from `$VISUAL` then `$EDITOR` (empty values are skipped).
|
||||||
|
#[must_use]
|
||||||
|
pub fn find_editor() -> Option<String> {
|
||||||
|
// VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference
|
||||||
|
for var in ["VISUAL", "EDITOR"] {
|
||||||
|
if let Some(v) = ghostty_os::getenv(var) {
|
||||||
|
if !v.is_empty() {
|
||||||
|
return Some(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_inner(stderr: &mut dyn Write) -> Result<u8, Error> {
|
||||||
|
// We load the configuration once because that will write our
|
||||||
|
// default configuration files to disk. We don't use the config.
|
||||||
|
let _config = Config::load()?;
|
||||||
|
|
||||||
|
// Find the preferred path.
|
||||||
|
let path = ghostty_config::file_load::preferred_default_file_path()
|
||||||
|
.map_err(|e| Error::Other(e.to_string()))?;
|
||||||
|
|
||||||
|
// Get our editor
|
||||||
|
let Some(editor) = find_editor() else {
|
||||||
|
// If we don't have `$EDITOR` set then we can't do anything
|
||||||
|
// but we can still print a helpful message.
|
||||||
|
write!(
|
||||||
|
stderr,
|
||||||
|
"The $EDITOR or $VISUAL environment variable is not set or is empty.\n\
|
||||||
|
This environment variable is required to edit the Ghostty configuration\n\
|
||||||
|
via this CLI command.\n\
|
||||||
|
\n\
|
||||||
|
Please set the environment variable to your preferred terminal\n\
|
||||||
|
text editor and try again.\n\
|
||||||
|
\n\
|
||||||
|
If you prefer to edit the configuration file another way,\n\
|
||||||
|
you can find the configuration file at the following path:\n\
|
||||||
|
\n"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Output the path using the OSC8 sequence so that it is linked.
|
||||||
|
writeln!(stderr, "\x1b]8;;file://{path}\x1b\\{path}\x1b]8;;\x1b\\")?;
|
||||||
|
return Ok(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flush before exec so no output is lost.
|
||||||
|
let _ = stderr.flush();
|
||||||
|
let err = std::process::Command::new(&editor).arg(&path).exec();
|
||||||
|
|
||||||
|
// If we reached this point then exec failed.
|
||||||
|
let err = match err.kind() {
|
||||||
|
std::io::ErrorKind::NotFound => "error.FileNotFound".to_string(),
|
||||||
|
std::io::ErrorKind::PermissionDenied => "error.AccessDenied".to_string(),
|
||||||
|
_ => err.to_string(),
|
||||||
|
};
|
||||||
|
write!(
|
||||||
|
stderr,
|
||||||
|
"Failed to execute the editor. Error code={err}.\n\
|
||||||
|
\n\
|
||||||
|
This is usually due to the executable path not existing, invalid\n\
|
||||||
|
permissions, or the shell environment not being set up\n\
|
||||||
|
correctly.\n\
|
||||||
|
\n\
|
||||||
|
Editor: {editor}\n\
|
||||||
|
Path: {path}\n"
|
||||||
|
)?;
|
||||||
|
Ok(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
//! The error type returned by CLI actions.
|
||||||
|
|
||||||
|
use ghostty_config::cli_args::ArgError;
|
||||||
|
|
||||||
|
/// The name used by [`ArgError::Other`] to signal that `--help`/`-h` was
|
||||||
|
/// passed (Zig: `Action.help_error`).
|
||||||
|
pub const HELP_ERROR_NAME: &str = "ActionHelpRequested";
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
/// This should be returned by actions that want to print the help text
|
||||||
|
/// (Zig: `error.ActionHelpRequested`).
|
||||||
|
#[error("ActionHelpRequested")]
|
||||||
|
ActionHelpRequested,
|
||||||
|
|
||||||
|
/// Argument parsing failed.
|
||||||
|
#[error("{0}")]
|
||||||
|
Args(ArgError),
|
||||||
|
|
||||||
|
/// Loading the configuration failed.
|
||||||
|
#[error("{0}")]
|
||||||
|
Config(#[from] ghostty_config::config::Error),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// The action exists but depends on functionality that is not ported yet.
|
||||||
|
#[error("the `+{0}` action is not yet available in this build")]
|
||||||
|
NotYetAvailable(&'static str),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ArgError> for Error {
|
||||||
|
fn from(e: ArgError) -> Self {
|
||||||
|
match e {
|
||||||
|
ArgError::Other(ref n) if n == HELP_ERROR_NAME => Error::ActionHelpRequested,
|
||||||
|
e => Error::Args(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::fmt::Error> for Error {
|
||||||
|
fn from(_: std::fmt::Error) -> Self {
|
||||||
|
Error::Other("FormatError".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
//! The CLI root (ZIG/src/cli/ghostty.zig): the [`Action`] enum, `+action`
|
||||||
|
//! dispatch and per-action `--help` output.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use crate::action::{ActionEnum, DetectError, SpecialCase, detect_iter};
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
/// Special commands that can be invoked via CLI flags. These are all
|
||||||
|
/// invoked by using `+<action>` as a CLI flag. The only exception is
|
||||||
|
/// "version" which can be invoked additionally with `--version`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Output the version and exit
|
||||||
|
Version,
|
||||||
|
|
||||||
|
/// Output help information for the CLI or configuration
|
||||||
|
Help,
|
||||||
|
|
||||||
|
/// List available fonts
|
||||||
|
ListFonts,
|
||||||
|
|
||||||
|
/// List available keybinds
|
||||||
|
ListKeybinds,
|
||||||
|
|
||||||
|
/// List available themes
|
||||||
|
ListThemes,
|
||||||
|
|
||||||
|
/// List named RGB colors
|
||||||
|
ListColors,
|
||||||
|
|
||||||
|
/// List keybind actions
|
||||||
|
ListActions,
|
||||||
|
|
||||||
|
/// Manage SSH terminfo cache for automatic remote host setup
|
||||||
|
SshCache,
|
||||||
|
|
||||||
|
/// Edit the config file in the configured terminal editor.
|
||||||
|
EditConfig,
|
||||||
|
|
||||||
|
/// Dump the config to stdout
|
||||||
|
ShowConfig,
|
||||||
|
|
||||||
|
// Validate passed config file
|
||||||
|
ValidateConfig,
|
||||||
|
|
||||||
|
// Show which font face Ghostty loads a codepoint from.
|
||||||
|
ShowFace,
|
||||||
|
|
||||||
|
// List, (eventually) view, and (eventually) send crash reports.
|
||||||
|
CrashReport,
|
||||||
|
|
||||||
|
// Boo!
|
||||||
|
Boo,
|
||||||
|
|
||||||
|
// Use IPC to tell the running Ghostty to open a new window.
|
||||||
|
NewWindow,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action {
|
||||||
|
/// All actions in declaration order.
|
||||||
|
pub const ALL: &'static [Action] = &[
|
||||||
|
Action::Version,
|
||||||
|
Action::Help,
|
||||||
|
Action::ListFonts,
|
||||||
|
Action::ListKeybinds,
|
||||||
|
Action::ListThemes,
|
||||||
|
Action::ListColors,
|
||||||
|
Action::ListActions,
|
||||||
|
Action::SshCache,
|
||||||
|
Action::EditConfig,
|
||||||
|
Action::ShowConfig,
|
||||||
|
Action::ValidateConfig,
|
||||||
|
Action::ShowFace,
|
||||||
|
Action::CrashReport,
|
||||||
|
Action::Boo,
|
||||||
|
Action::NewWindow,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The CLI name of the action (`@tagName`), e.g. `list-fonts`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Action::Version => "version",
|
||||||
|
Action::Help => "help",
|
||||||
|
Action::ListFonts => "list-fonts",
|
||||||
|
Action::ListKeybinds => "list-keybinds",
|
||||||
|
Action::ListThemes => "list-themes",
|
||||||
|
Action::ListColors => "list-colors",
|
||||||
|
Action::ListActions => "list-actions",
|
||||||
|
Action::SshCache => "ssh-cache",
|
||||||
|
Action::EditConfig => "edit-config",
|
||||||
|
Action::ShowConfig => "show-config",
|
||||||
|
Action::ValidateConfig => "validate-config",
|
||||||
|
Action::ShowFace => "show-face",
|
||||||
|
Action::CrashReport => "crash-report",
|
||||||
|
Action::Boo => "boo",
|
||||||
|
Action::NewWindow => "new-window",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `std.meta.stringToEnum`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_name(name: &str) -> Option<Action> {
|
||||||
|
Action::ALL.iter().copied().find(|a| a.name() == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the filename associated with an action. This is a relative
|
||||||
|
/// path from the root src/ directory of the Zig sources.
|
||||||
|
#[must_use]
|
||||||
|
pub fn file(self) -> String {
|
||||||
|
format!("cli/{}.zig", self.name().replace('-', "_"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The help text of the action (the doc comment of the Zig `run`
|
||||||
|
/// function, i.e. `help_strings.Action.<name>`), without trailing newline.
|
||||||
|
#[must_use]
|
||||||
|
pub fn help_text(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Action::Version => include_str!("help/version.txt"),
|
||||||
|
Action::Help => include_str!("help/help.txt"),
|
||||||
|
Action::ListFonts => include_str!("help/list-fonts.txt"),
|
||||||
|
Action::ListKeybinds => include_str!("help/list-keybinds.txt"),
|
||||||
|
Action::ListThemes => include_str!("help/list-themes.txt"),
|
||||||
|
Action::ListColors => include_str!("help/list-colors.txt"),
|
||||||
|
Action::ListActions => include_str!("help/list-actions.txt"),
|
||||||
|
Action::SshCache => include_str!("help/ssh-cache.txt"),
|
||||||
|
Action::EditConfig => include_str!("help/edit-config.txt"),
|
||||||
|
Action::ShowConfig => include_str!("help/show-config.txt"),
|
||||||
|
Action::ValidateConfig => include_str!("help/validate-config.txt"),
|
||||||
|
Action::ShowFace => include_str!("help/show-face.txt"),
|
||||||
|
Action::CrashReport => include_str!("help/crash-report.txt"),
|
||||||
|
Action::Boo => include_str!("help/boo.txt"),
|
||||||
|
Action::NewWindow => include_str!("help/new-window.txt"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the action. `args` is the full argument vector (including
|
||||||
|
/// `argv[0]`). This returns the exit code to exit with.
|
||||||
|
///
|
||||||
|
/// If the action requests its help text (`--help`/`-h`), the help text
|
||||||
|
/// is printed to stdout and 0 is returned.
|
||||||
|
pub fn run(self, args: &[String]) -> Result<u8, Error> {
|
||||||
|
match self.run_main(args) {
|
||||||
|
Err(Error::ActionHelpRequested) => {
|
||||||
|
// Future note: for now we just output the help text directly
|
||||||
|
// to stdout. In the future we can style this much prettier
|
||||||
|
// for all commands by just changing this one place.
|
||||||
|
let text = format!("{}\n", self.help_text());
|
||||||
|
let mut stdout = std::io::stdout().lock();
|
||||||
|
if let Err(e) = stdout.write_all(text.as_bytes()) {
|
||||||
|
log::warn!("failed to write help text: {e}\n");
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
if let Err(e) = stdout.flush() {
|
||||||
|
log::warn!("failed to flush help text: {e}\n");
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_main(self, args: &[String]) -> Result<u8, Error> {
|
||||||
|
match self {
|
||||||
|
Action::Version => crate::version::run(args),
|
||||||
|
Action::Help => crate::help::run(args),
|
||||||
|
Action::ListFonts => crate::list_fonts::run(args),
|
||||||
|
Action::ListKeybinds => crate::list_keybinds::run(args),
|
||||||
|
Action::ListThemes => crate::list_themes::run(args),
|
||||||
|
Action::ListColors => crate::list_colors::run(args),
|
||||||
|
Action::ListActions => crate::list_actions::run(args),
|
||||||
|
Action::SshCache => crate::ssh_cache::run(args),
|
||||||
|
Action::EditConfig => crate::edit_config::run(args),
|
||||||
|
Action::ShowConfig => crate::show_config::run(args),
|
||||||
|
Action::ValidateConfig => crate::validate_config::run(args),
|
||||||
|
Action::CrashReport => crate::crash_report::run(args),
|
||||||
|
Action::ShowFace => crate::show_face::run(args),
|
||||||
|
Action::Boo => crate::boo::run(args),
|
||||||
|
Action::NewWindow => crate::new_window::run(args),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionEnum for Action {
|
||||||
|
fn from_name(name: &str) -> Option<Self> {
|
||||||
|
Action::from_name(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_special_case(arg: &str) -> Option<SpecialCase<Self>> {
|
||||||
|
// If we see a "-e" and we haven't seen a command yet, then
|
||||||
|
// we are done looking for commands. This special case enables
|
||||||
|
// `ghostty -e ghostty +command`. If we've seen a command we
|
||||||
|
// still want to keep looking because
|
||||||
|
// `ghostty +command -e +command` is invalid.
|
||||||
|
if arg == "-e" {
|
||||||
|
return Some(SpecialCase::AbortIfNoAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case, --version always outputs the version no
|
||||||
|
// matter what, no matter what other args exist.
|
||||||
|
if arg == "--version" {
|
||||||
|
return Some(SpecialCase::Action(Action::Version));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --help matches "help" but if a subcommand is specified
|
||||||
|
// then we match the subcommand.
|
||||||
|
if arg == "--help" || arg == "-h" {
|
||||||
|
return Some(SpecialCase::Fallback(Action::Help));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Action {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(self.name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the CLI action from `args` (full argv, `argv[0]` included).
|
||||||
|
pub fn detect(args: &[String]) -> Result<Option<Action>, DetectError> {
|
||||||
|
detect_iter::<Action, _, _>(args.iter())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the
|
||||||
|
/// action from `args` (the full argv), print the errors Ghostty prints for
|
||||||
|
/// invalid invocations and run the action.
|
||||||
|
///
|
||||||
|
/// Returns `None` if no action was requested (the caller should launch the
|
||||||
|
/// terminal), or the exit code the process should terminate with.
|
||||||
|
#[must_use]
|
||||||
|
pub fn run_from_args(args: &[String]) -> Option<u8> {
|
||||||
|
let action = match detect(args) {
|
||||||
|
Ok(a) => a?,
|
||||||
|
Err(err) => {
|
||||||
|
let mut stderr = std::io::stderr().lock();
|
||||||
|
let _ = match err {
|
||||||
|
DetectError::MultipleActions => stderr.write_all(
|
||||||
|
b"Error: multiple CLI actions specified. You must specify only one\n\
|
||||||
|
action starting with the `+` character.\n",
|
||||||
|
),
|
||||||
|
DetectError::InvalidAction => stderr.write_all(
|
||||||
|
b"Error: unknown CLI action specified. CLI actions are specified with\n\
|
||||||
|
the '+' character.\n\n\
|
||||||
|
All valid CLI actions can be listed with `ghostty +help`\n",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let _ = stderr.flush();
|
||||||
|
return Some(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!("executing CLI action={action}");
|
||||||
|
Some(match action.run(args) {
|
||||||
|
Ok(code) => code,
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("CLI action failed error={err}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
use ghostty_config::cli_args::split_args;
|
||||||
|
|
||||||
|
fn detect_str(s: &str) -> Result<Option<Action>, DetectError> {
|
||||||
|
detect_iter::<Action, _, _>(split_args(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_action_none() {
|
||||||
|
assert_eq!(None, detect_str("--a=42 --b --b-f=false").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_action_version() {
|
||||||
|
for s in [
|
||||||
|
"--a=42 --b --b-f=false --version",
|
||||||
|
"--version --a=42 --b --b-f=false",
|
||||||
|
"--c=84 --d --version --a=42 --b --b-f=false",
|
||||||
|
] {
|
||||||
|
assert_eq!(Some(Action::Version), detect_str(s).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_action_plus() {
|
||||||
|
for s in [
|
||||||
|
"--a=42 --b --b-f=false +version",
|
||||||
|
"+version --a=42 --b --b-f=false",
|
||||||
|
"--c=84 --d +version --a=42 --b --b-f=false",
|
||||||
|
] {
|
||||||
|
assert_eq!(Some(Action::Version), detect_str(s).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_action_plus_ignores_e() {
|
||||||
|
assert_eq!(None, detect_str("--a=42 -e +version").unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
Err(DetectError::MultipleActions),
|
||||||
|
detect_str("+list-fonts --a=42 -e +version")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_fallback() {
|
||||||
|
assert_eq!(Some(Action::Help), detect_str("--help").unwrap());
|
||||||
|
assert_eq!(Some(Action::Help), detect_str("-h").unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
Some(Action::ListThemes),
|
||||||
|
detect_str("+list-themes --help").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_round_trip() {
|
||||||
|
for a in Action::ALL {
|
||||||
|
assert_eq!(Some(*a), Action::from_name(a.name()));
|
||||||
|
assert_ne!("", a.help_text());
|
||||||
|
assert!(a.file().starts_with("cli/"));
|
||||||
|
}
|
||||||
|
assert_eq!("cli/list_fonts.zig", Action::ListFonts.file());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! `+help` (ZIG/src/cli/help.zig).
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use ghostty_config::config_struct;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::ghostty::Action;
|
||||||
|
|
||||||
|
config_struct! {
|
||||||
|
// Note that this options struct doesn't implement the `help` decl like other
|
||||||
|
// actions. That is because the help command is special and wants to handle its
|
||||||
|
// own logic around help detection.
|
||||||
|
pub struct Options {
|
||||||
|
/// This must be registered so that it isn't an error to pass `--help`
|
||||||
|
"help" => help: bool = false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli_options!(Options);
|
||||||
|
|
||||||
|
/// The `help` command shows general help about Ghostty. Recognized as either
|
||||||
|
/// `-h, `--help`, or like other actions `+help`.
|
||||||
|
///
|
||||||
|
/// You can also specify `--help` or `-h` along with any action such as
|
||||||
|
/// `+list-themes` to see help for a specific action.
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
crate::parse_options(&mut opts, args)?;
|
||||||
|
|
||||||
|
let mut out = std::io::BufWriter::new(std::io::stdout().lock());
|
||||||
|
write_help(&mut out)?;
|
||||||
|
out.flush()?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the general help text.
|
||||||
|
pub fn write_help(stdout: &mut dyn Write) -> std::io::Result<()> {
|
||||||
|
stdout.write_all(
|
||||||
|
b"Usage: ghostty [+action] [options]
|
||||||
|
|
||||||
|
Run the Ghostty terminal emulator or a specific helper action.
|
||||||
|
|
||||||
|
If no `+action` is specified, run the Ghostty terminal emulator.
|
||||||
|
All configuration keys are available as command line options.
|
||||||
|
To specify a configuration key, use the `--<key>=<value>` syntax
|
||||||
|
where key and value are the same format you'd put into a configuration
|
||||||
|
file. For example, `--font-size=12` or `--font-family=\"Fira Code\"`.
|
||||||
|
|
||||||
|
To see a list of all available configuration options, please see
|
||||||
|
the `src/config/Config.zig` file. A future update will allow seeing
|
||||||
|
the list of configuration options from the command line.
|
||||||
|
|
||||||
|
A special command line argument `-e <command>` can be used to run
|
||||||
|
the specific command inside the terminal emulator. For example,
|
||||||
|
`ghostty -e top` will run the `top` command inside the terminal.
|
||||||
|
|
||||||
|
On macOS, launching the terminal emulator from the CLI is not
|
||||||
|
supported and only actions are supported. Use `open -na Ghostty.app`
|
||||||
|
instead, or `open -na ghostty.app --args --foo=bar --baz=quz` to pass
|
||||||
|
arguments.
|
||||||
|
|
||||||
|
Available actions:
|
||||||
|
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for action in Action::ALL {
|
||||||
|
writeln!(stdout, " +{}", action.name())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.write_all(
|
||||||
|
b"
|
||||||
|
Specify `+<action> --help` to see the help for a specific action,
|
||||||
|
where `<action>` is one of actions listed above.
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_lists_actions() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
write_help(&mut buf).unwrap();
|
||||||
|
let s = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(s.contains("Available actions:\n\n +version\n +help\n +list-fonts\n"));
|
||||||
|
assert!(s.ends_with("where `<action>` is one of actions listed above.\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
The `boo` command is used to display the animation from the Ghostty website in the terminal
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
The `crash-report` command is used to inspect and send crash reports.
|
||||||
|
|
||||||
|
When executed without any arguments, this will list existing crash reports.
|
||||||
|
|
||||||
|
This command currently only supports listing crash reports. Viewing
|
||||||
|
and sending crash reports is unimplemented and will be added in the future.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
The `edit-config` command opens the Ghostty configuration file in the
|
||||||
|
editor specified by the `$VISUAL` or `$EDITOR` environment variables.
|
||||||
|
|
||||||
|
IMPORTANT: This command will not reload the configuration after
|
||||||
|
editing. You will need to manually reload the configuration using the
|
||||||
|
application menu, configured keybind, or by restarting Ghostty. We
|
||||||
|
plan to auto-reload in the future, but Ghostty isn't capable of
|
||||||
|
this yet.
|
||||||
|
|
||||||
|
The filepath opened is the default user-specific configuration
|
||||||
|
file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config.ghostty`.
|
||||||
|
On macOS, this may also be located at
|
||||||
|
`~/Library/Application Support/com.mitchellh.ghostty/config.ghostty`.
|
||||||
|
On macOS, whichever path exists and is non-empty will be prioritized,
|
||||||
|
prioritizing the Application Support directory if neither are
|
||||||
|
non-empty.
|
||||||
|
|
||||||
|
This command prefers the `$VISUAL` environment variable over `$EDITOR`,
|
||||||
|
if both are set. If neither are set, it will print an error
|
||||||
|
and exit.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
The `help` command shows general help about Ghostty. Recognized as either
|
||||||
|
`-h, `--help`, or like other actions `+help`.
|
||||||
|
|
||||||
|
You can also specify `--help` or `-h` along with any action such as
|
||||||
|
`+list-themes` to see help for a specific action.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
The `list-actions` command is used to list all the available keybind
|
||||||
|
actions for Ghostty. These are distinct from the CLI Actions which can
|
||||||
|
be listed via `+help`
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
* `--docs`: will print out the documentation for each action.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
The `list-colors` command is used to list all the named RGB colors in
|
||||||
|
Ghostty.
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
* `--plain`: will disable formatting and make the output more
|
||||||
|
friendly for Unix tooling. This is default when not printing to a tty.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
The `list-fonts` command is used to list all the available fonts for
|
||||||
|
Ghostty. This uses the exact same font discovery mechanism Ghostty uses to
|
||||||
|
find fonts to use.
|
||||||
|
|
||||||
|
When executed with no arguments, this will list all available fonts, sorted
|
||||||
|
by family name, then font name. If a family name is given with `--family`,
|
||||||
|
the sorting will be disabled and the results instead will be shown in the
|
||||||
|
same priority order Ghostty would use to pick a font.
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
* `--bold`: Filter results to specific bold styles. It is not guaranteed
|
||||||
|
that only those styles are returned. They are only prioritized.
|
||||||
|
|
||||||
|
* `--italic`: Filter results to specific italic styles. It is not guaranteed
|
||||||
|
that only those styles are returned. They are only prioritized.
|
||||||
|
|
||||||
|
* `--style`: Filter results based on the style string advertised by a font.
|
||||||
|
It is not guaranteed that only those styles are returned. They are only
|
||||||
|
prioritized.
|
||||||
|
|
||||||
|
* `--family`: Filter results to a specific font family. The family handling
|
||||||
|
is identical to the `font-family` set of Ghostty configuration values, so
|
||||||
|
this can be used to debug why your desired font may not be loading.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
The `list-keybinds` command is used to list all the available keybinds for
|
||||||
|
Ghostty.
|
||||||
|
|
||||||
|
When executed without any arguments this will list the current keybinds
|
||||||
|
loaded by the config file. If no config file is found or there aren't any
|
||||||
|
changes to the keybinds it will print out the default ones configured for
|
||||||
|
Ghostty
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
* `--default`: will print out all the default keybinds
|
||||||
|
|
||||||
|
* `--docs`: currently does nothing, intended to print out documentation
|
||||||
|
about the action associated with the keybinds
|
||||||
|
|
||||||
|
* `--plain`: will disable formatting and make the output more
|
||||||
|
friendly for Unix tooling. This is default when not printing to a tty.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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`.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
The `new-window` will use native platform IPC to open up a new window in a
|
||||||
|
running instance of Ghostty.
|
||||||
|
|
||||||
|
If the `--class` flag is not set, the `new-window` command will try and
|
||||||
|
connect to a running instance of Ghostty based on what optimizations the
|
||||||
|
Ghostty CLI was compiled with. Otherwise the `new-window` command will try
|
||||||
|
and contact a running Ghostty instance that was configured with the same
|
||||||
|
`class` as was given on the command line.
|
||||||
|
|
||||||
|
All of the arguments after the `+new-window` argument (except for the
|
||||||
|
`--class` flag) will be sent to the remote Ghostty instance and will be
|
||||||
|
parsed as command line flags. These flags will override certain settings
|
||||||
|
when creating the first surface in the new window. Currently, only
|
||||||
|
`--working-directory`, `--command`, and `--title` are supported. `-e` will
|
||||||
|
also work as an alias for `--command`, except that if `-e` is found on the
|
||||||
|
command line all following arguments will become part of the command and no
|
||||||
|
more arguments will be parsed for configuration settings.
|
||||||
|
|
||||||
|
If `--working-directory` is found on the command line and is a relative
|
||||||
|
path (i.e. doesn't start with `/`) it will be resolved to an absolute path
|
||||||
|
relative to the current working directory that the `ghostty +new-window`
|
||||||
|
command is run from. `~/` prefixes will also be expanded to the user's home
|
||||||
|
directory.
|
||||||
|
|
||||||
|
If `--working-directory` is _not_ found on the command line, the working
|
||||||
|
directory that `ghostty +new-window` is run from will be passed to Ghostty.
|
||||||
|
|
||||||
|
GTK uses an application ID to identify instances of applications. If Ghostty
|
||||||
|
is compiled with release optimizations, the default application ID will be
|
||||||
|
`com.mitchellh.ghostty`. If Ghostty is compiled with debug optimizations,
|
||||||
|
the default application ID will be `com.mitchellh.ghostty-debug`. The
|
||||||
|
`class` configuration entry can be used to set up a custom application
|
||||||
|
ID. The class name must follow the requirements defined [in the GTK
|
||||||
|
documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html)
|
||||||
|
or it will be ignored and Ghostty will use the default as defined above.
|
||||||
|
|
||||||
|
On GTK, D-Bus activation must be properly configured. Ghostty does not need
|
||||||
|
to be running for this to open a new window, making it suitable for binding
|
||||||
|
to keys in your window manager (if other methods for configuring global
|
||||||
|
shortcuts are unavailable). D-Bus will handle launching a new instance
|
||||||
|
of Ghostty if it is not already running. See the Ghostty website for
|
||||||
|
information on properly configuring D-Bus activation.
|
||||||
|
|
||||||
|
Only supported on GTK.
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
* `--class=<class>`: If set, open up a new window in a custom instance of
|
||||||
|
Ghostty. The class must be a valid GTK application ID.
|
||||||
|
|
||||||
|
* `--command`: The command to be executed in the first surface of the new window.
|
||||||
|
|
||||||
|
* `--working-directory=<directory>`: The working directory to pass to Ghostty.
|
||||||
|
|
||||||
|
* `--title`: A title that will override the title of the first surface in
|
||||||
|
the new window. The title override may be edited or removed later.
|
||||||
|
|
||||||
|
* `-e`: Any arguments after this will be interpreted as a command to
|
||||||
|
execute inside the first surface of the new window instead of the
|
||||||
|
default command.
|
||||||
|
|
||||||
|
Available since: 1.2.0
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
The `show-config` command shows the current configuration in a valid Ghostty
|
||||||
|
configuration file format.
|
||||||
|
|
||||||
|
When executed without any arguments this will output the current
|
||||||
|
configuration that is different from the default configuration. If you're
|
||||||
|
using the default configuration this will output nothing.
|
||||||
|
|
||||||
|
If you are a new user and want to see all available options with
|
||||||
|
documentation, run `ghostty +show-config --default --docs`.
|
||||||
|
|
||||||
|
The output is not in any specific order, but the order should be consistent
|
||||||
|
between runs. The output is not guaranteed to be exactly match the input
|
||||||
|
configuration files, but it will result in the same behavior. Comments,
|
||||||
|
whitespace, and other formatting is not preserved from user configuration
|
||||||
|
files.
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
* `--default`: Show the default configuration instead of loading
|
||||||
|
the user configuration.
|
||||||
|
|
||||||
|
* `--changes-only`: Only show the options that have been changed
|
||||||
|
from the default. This has no effect if `--default` is specified.
|
||||||
|
|
||||||
|
* `--docs`: Print the documentation above each option as a comment,
|
||||||
|
This is very noisy but is very useful to learn about available
|
||||||
|
options, especially paired with `--default`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The `show-face` command shows what font face Ghostty will use to render a
|
||||||
|
specific codepoint. Note that this command does not take into consideration
|
||||||
|
grapheme clustering or any other Unicode features that might modify the
|
||||||
|
presentation of a codepoint, so this may show a different font face than
|
||||||
|
Ghostty uses to render a codepoint in a terminal session.
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
* `--cp`: Find the face for a single codepoint. The codepoint may be specified
|
||||||
|
in decimal (`--cp=65`), hexadecimal (`--cp=0x41`), octal (`--cp=0o101`), or
|
||||||
|
binary (`--cp=0b1000001`).
|
||||||
|
|
||||||
|
* `--string`: Find the face for all of the codepoints in a string. The
|
||||||
|
string must be a valid UTF-8 sequence.
|
||||||
|
|
||||||
|
* `--style`: Search for a specific style. Valid options are `regular`, `bold`,
|
||||||
|
`italic`, and `bold_italic`.
|
||||||
|
|
||||||
|
* `--presentation`: If set, force searching for a specific presentation
|
||||||
|
style. Valid options are `text` and `emoji`. If unset, the presentation
|
||||||
|
style of a codepoint will be inferred from the Unicode standard.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
Manage the SSH terminfo cache for automatic remote host setup.
|
||||||
|
|
||||||
|
When SSH integration is enabled with `shell-integration-features = ssh-terminfo`,
|
||||||
|
Ghostty automatically installs its terminfo on remote hosts. This command
|
||||||
|
manages the cache of successful installations to avoid redundant uploads.
|
||||||
|
|
||||||
|
The cache stores hostnames (or user@hostname combinations) along with timestamps.
|
||||||
|
Entries older than the expiration period are automatically removed during cache
|
||||||
|
operations. By default, entries never expire.
|
||||||
|
|
||||||
|
Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified.
|
||||||
|
If multiple are specified, one of the actions will be executed but
|
||||||
|
it isn't guaranteed which one. This is entirely unsafe so you should split
|
||||||
|
multiple actions into separate commands.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
ghostty +ssh-cache # List all cached hosts
|
||||||
|
ghostty +ssh-cache --host=example.com # Check if host is cached
|
||||||
|
ghostty +ssh-cache --add=example.com # Manually add host to cache
|
||||||
|
ghostty +ssh-cache --add=user@example.com # Add user@host combination
|
||||||
|
ghostty +ssh-cache --remove=example.com # Remove host from cache
|
||||||
|
ghostty +ssh-cache --clear # Clear entire cache
|
||||||
|
ghostty +ssh-cache --expire-days=30 # Set custom expiration period
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
The `validate-config` command is used to validate a Ghostty config file.
|
||||||
|
|
||||||
|
When executed without any arguments, this will load the config from the default
|
||||||
|
location.
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
* `--config-file`: can be passed to validate a specific target config file in
|
||||||
|
a non-default location
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
The `version` command is used to display information about Ghostty. Recognized as
|
||||||
|
either `+version` or `--version`.
|
||||||
@@ -1 +1,46 @@
|
|||||||
//! ghostty-cli: `ghostty +action` CLI actions (ZIG/src/cli).
|
//! ghostty-cli: `ghostty +action` CLI actions (ZIG/src/cli).
|
||||||
|
//!
|
||||||
|
//! Every action lives in its own module and exposes `run(args)`, which takes
|
||||||
|
//! the full process argument vector (including `argv[0]`, like Zig's
|
||||||
|
//! `args.argsIterator`) and returns the process exit code. Actions write to
|
||||||
|
//! stdout/stderr exactly like the Zig implementation.
|
||||||
|
|
||||||
|
#![allow(
|
||||||
|
clippy::wildcard_imports,
|
||||||
|
clippy::struct_field_names,
|
||||||
|
clippy::items_after_statements,
|
||||||
|
clippy::needless_pass_by_value,
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::collapsible_if,
|
||||||
|
clippy::doc_lazy_continuation,
|
||||||
|
clippy::doc_overindented_list_items
|
||||||
|
)]
|
||||||
|
|
||||||
|
#[macro_use]
|
||||||
|
mod options;
|
||||||
|
|
||||||
|
pub mod action;
|
||||||
|
pub mod boo;
|
||||||
|
pub mod build_info;
|
||||||
|
pub mod crash_report;
|
||||||
|
pub mod edit_config;
|
||||||
|
pub mod error;
|
||||||
|
pub mod ghostty;
|
||||||
|
pub mod help;
|
||||||
|
pub mod list_actions;
|
||||||
|
pub mod list_colors;
|
||||||
|
pub mod list_fonts;
|
||||||
|
pub mod list_keybinds;
|
||||||
|
pub mod list_themes;
|
||||||
|
pub mod new_window;
|
||||||
|
pub mod show_config;
|
||||||
|
pub mod show_face;
|
||||||
|
pub mod ssh_cache;
|
||||||
|
pub mod tui;
|
||||||
|
pub mod validate_config;
|
||||||
|
pub mod version;
|
||||||
|
|
||||||
|
pub use error::Error;
|
||||||
|
pub use ghostty::{Action, run_from_args};
|
||||||
|
pub use options::{NoOptions, parse_options};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
//! `+list-actions` (ZIG/src/cli/list_actions.zig).
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use ghostty_config::config_struct;
|
||||||
|
use ghostty_input::binding::ActionKey;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
config_struct! {
|
||||||
|
pub struct Options {
|
||||||
|
/// If `true`, print out documentation about the action associated with the
|
||||||
|
/// keybinds.
|
||||||
|
"docs" => docs: bool = false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli_options!(Options, help);
|
||||||
|
|
||||||
|
/// The `list-actions` command is used to list all the available keybind
|
||||||
|
/// actions for Ghostty. These are distinct from the CLI Actions which can
|
||||||
|
/// be listed via `+help`
|
||||||
|
///
|
||||||
|
/// Flags:
|
||||||
|
///
|
||||||
|
/// * `--docs`: will print out the documentation for each action.
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
crate::parse_options(&mut opts, args)?;
|
||||||
|
|
||||||
|
let out = generate_plaintext(opts.docs);
|
||||||
|
let mut stdout = std::io::stdout().lock();
|
||||||
|
stdout.write_all(out.as_bytes())?;
|
||||||
|
stdout.flush()?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `helpgen_actions.generate(.plaintext, show_docs)`.
|
||||||
|
///
|
||||||
|
/// TODO(integration): this is a local copy of
|
||||||
|
/// `ghostty_input::helpgen_actions::generate`. The embedded docs of a few
|
||||||
|
/// actions in ghostty-input (`adjust_selection`, ...) end with an extra blank
|
||||||
|
/// line compared to Ghostty's `help_strings`, which adds a stray `" "` line
|
||||||
|
/// to `--docs` output. Trailing newlines are normalised here; once the data
|
||||||
|
/// is fixed in ghostty-input this should call `helpgen_actions::generate`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn generate_plaintext(show_docs: bool) -> String {
|
||||||
|
let mut writer = String::new();
|
||||||
|
let mut stream = String::new();
|
||||||
|
for &key in ActionKey::ALL {
|
||||||
|
let name = key.name();
|
||||||
|
if name.starts_with('_') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write previously stored doc comment below all related actions
|
||||||
|
if show_docs {
|
||||||
|
writer.push_str(&stream);
|
||||||
|
writer.push('\n');
|
||||||
|
stream.clear();
|
||||||
|
writer.push_str(name);
|
||||||
|
writer.push_str(":\n");
|
||||||
|
for line in key.docs().trim_end_matches('\n').split('\n') {
|
||||||
|
stream.push_str(" ");
|
||||||
|
stream.push_str(line);
|
||||||
|
stream.push('\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
writer.push_str(name);
|
||||||
|
writer.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write any remaining buffered documentation
|
||||||
|
writer.push_str(&stream);
|
||||||
|
writer
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_only() {
|
||||||
|
let s = generate_plaintext(false);
|
||||||
|
assert!(s.starts_with("ignore\nunbind\ncsi\n"));
|
||||||
|
assert!(!s.contains(':'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn docs_have_no_stray_blank_lines_between_actions() {
|
||||||
|
let s = generate_plaintext(true);
|
||||||
|
assert!(s.starts_with("\nignore:\n Ignore this key combination.\n"));
|
||||||
|
assert!(s.contains(" respectively.\n\njump_to_prompt:\n"));
|
||||||
|
assert!(s.ends_with('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_input_crate_when_docs_are_clean() {
|
||||||
|
// The first actions have well-formed docs.
|
||||||
|
let ours = generate_plaintext(true);
|
||||||
|
let theirs = ghostty_input::helpgen_actions::generate_string(
|
||||||
|
ghostty_input::helpgen_actions::Format::Plaintext,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert!(ours.lines().take(100).eq(theirs.lines().take(100)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
//! `+list-colors` (ZIG/src/cli/list_colors.zig).
|
||||||
|
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::io::{IsTerminal, Write};
|
||||||
|
|
||||||
|
use ghostty_config::config_struct;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::tui::{self, Color, PrettySession, Style};
|
||||||
|
|
||||||
|
config_struct! {
|
||||||
|
pub struct Options {
|
||||||
|
/// If `true`, print without formatting even if printing to a tty
|
||||||
|
"plain" => plain: bool = false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli_options!(Options, help);
|
||||||
|
|
||||||
|
/// `terminal/res/rgb.txt`, the same file `x11_color` is built from.
|
||||||
|
///
|
||||||
|
/// TODO(integration): ghostty-vt's `x11_color` only exposes lookups (and
|
||||||
|
/// lower-cases the names); expose its key list and use it instead of
|
||||||
|
/// re-parsing the data here.
|
||||||
|
const RGB_DATA: &str = include_str!("../../ghostty-vt/src/res/rgb.txt");
|
||||||
|
|
||||||
|
/// All X11 color names (with their original spelling) and values. Like the
|
||||||
|
/// case-insensitive Zig map the first spelling of a name wins.
|
||||||
|
#[must_use]
|
||||||
|
pub fn x11_colors() -> Vec<(String, (u8, u8, u8))> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut out = vec![];
|
||||||
|
for line in RGB_DATA.split('\n') {
|
||||||
|
if line.len() < 12 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parse = |s: &str| s.trim_matches(' ').parse::<u8>().ok();
|
||||||
|
let (Some(r), Some(g), Some(b)) =
|
||||||
|
(parse(&line[0..3]), parse(&line[4..7]), parse(&line[8..11]))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = line[12..].trim_matches([' ', '\t', '\n']);
|
||||||
|
if seen.insert(name.to_ascii_lowercase()) {
|
||||||
|
out.push((name.to_string(), (r, g, b)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `std.ascii.orderIgnoreCase`.
|
||||||
|
fn order_ignore_case(a: &str, b: &str) -> Ordering {
|
||||||
|
let a = a.bytes().map(|c| c.to_ascii_lowercase());
|
||||||
|
let b = b.bytes().map(|c| c.to_ascii_lowercase());
|
||||||
|
a.cmp(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colors sorted by name, ignoring case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn sorted_colors() -> Vec<(String, (u8, u8, u8))> {
|
||||||
|
let mut colors = x11_colors();
|
||||||
|
colors.sort_by(|a, b| order_ignore_case(&a.0, &b.0));
|
||||||
|
colors
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `list-colors` command is used to list all the named RGB colors in
|
||||||
|
/// Ghostty.
|
||||||
|
///
|
||||||
|
/// Flags:
|
||||||
|
///
|
||||||
|
/// * `--plain`: will disable formatting and make the output more
|
||||||
|
/// friendly for Unix tooling. This is default when not printing to a tty.
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
crate::parse_options(&mut opts, args)?;
|
||||||
|
|
||||||
|
let colors = sorted_colors();
|
||||||
|
|
||||||
|
let stdout = std::io::stdout();
|
||||||
|
if tui::CAN_PRETTY_PRINT && !opts.plain && stdout.is_terminal() {
|
||||||
|
return pretty_print(&colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = std::io::BufWriter::new(stdout.lock());
|
||||||
|
write_plain(&mut out, &colors)?;
|
||||||
|
out.flush()?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plain `name = #rrggbb` listing.
|
||||||
|
pub fn write_plain(out: &mut dyn Write, colors: &[(String, (u8, u8, u8))]) -> std::io::Result<()> {
|
||||||
|
for (name, (r, g, b)) in colors {
|
||||||
|
writeln!(out, "{name} = #{r:02x}{g:02x}{b:02x}")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pretty_print(colors: &[(String, (u8, u8, u8))]) -> Result<u8, Error> {
|
||||||
|
let mut session = PrettySession::new_raw(std::io::BufWriter::new(std::io::stdout().lock()))?;
|
||||||
|
let result = pretty_rows(&mut session, colors);
|
||||||
|
let fin = session.finish();
|
||||||
|
result?;
|
||||||
|
fin?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pretty_rows<W: Write>(
|
||||||
|
session: &mut PrettySession<W>,
|
||||||
|
colors: &[(String, (u8, u8, u8))],
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let width = session.width;
|
||||||
|
let max_name_len = colors.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
|
||||||
|
|
||||||
|
// max name length plus " = #RRGGBB XX" plus " " gutter between columns
|
||||||
|
let column_size = max_name_len + 15;
|
||||||
|
// add two to take into account lack of gutter after last column
|
||||||
|
let columns = ((usize::from(width) + 2) / column_size).max(1);
|
||||||
|
|
||||||
|
let step = colors.len() / columns + 1;
|
||||||
|
for i in 0..step {
|
||||||
|
let mut row = session.row();
|
||||||
|
let mut col = 0u16;
|
||||||
|
|
||||||
|
for j in 0..columns {
|
||||||
|
let k = i + step * j;
|
||||||
|
let Some((name, (r, g, b))) = colors.get(k) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let style1 = Style {
|
||||||
|
fg: Color::Rgb(*r, *g, *b),
|
||||||
|
..Style::default()
|
||||||
|
};
|
||||||
|
let style2 = Style {
|
||||||
|
fg: Color::Rgb(*r, *g, *b),
|
||||||
|
bg: Color::Rgb(*r, *g, *b),
|
||||||
|
..Style::default()
|
||||||
|
};
|
||||||
|
let plain = Style::default();
|
||||||
|
|
||||||
|
// name of the color
|
||||||
|
col = row.print_segment(name, plain, col).col;
|
||||||
|
// push the color data to the end of the column
|
||||||
|
for _ in 0..(max_name_len - name.len()) {
|
||||||
|
col = row.print_segment(" ", plain, col).col;
|
||||||
|
}
|
||||||
|
col = row.print_segment(" = ", plain, col).col;
|
||||||
|
// rgb triple
|
||||||
|
col = row
|
||||||
|
.print_segment(&format!("#{r:02x}{g:02x}{b:02x}"), style1, col)
|
||||||
|
.col;
|
||||||
|
col = row.print_segment(" ", plain, col).col;
|
||||||
|
// colored block
|
||||||
|
col = row.print_segment(" ", style2, col).col;
|
||||||
|
// add the gutter if needed
|
||||||
|
if j + 1 < columns {
|
||||||
|
col = row.print_segment(" ", plain, col).col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear the rest of the line
|
||||||
|
while col != 0 && col < width {
|
||||||
|
col = row.print_segment(" ", Style::default(), col).col;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.print_row(&row)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_listing_matches_ghostty_format() {
|
||||||
|
let colors = sorted_colors();
|
||||||
|
assert_eq!(782, colors.len());
|
||||||
|
let mut out = Vec::new();
|
||||||
|
write_plain(&mut out, &colors[..2]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
"alice blue = #f0f8ff\nAliceBlue = #f0f8ff\n",
|
||||||
|
String::from_utf8(out).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sort_is_case_insensitive() {
|
||||||
|
assert_eq!(Ordering::Less, order_ignore_case("alice blue", "AliceBlue"));
|
||||||
|
assert_eq!(Ordering::Less, order_ignore_case("Zed", "zee"));
|
||||||
|
assert_eq!(Ordering::Equal, order_ignore_case("ABC", "abc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pretty_rows_fill_width() {
|
||||||
|
let colors = sorted_colors();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut session = PrettySession::new(&mut buf).unwrap();
|
||||||
|
session.width = 100;
|
||||||
|
pretty_rows(&mut session, &colors[..10]).unwrap();
|
||||||
|
drop(session);
|
||||||
|
let s = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(s.starts_with("\x1b[?2027h\r\x1b[m\x1b[J\x1b[?25l\x1b[?2026h\x1b[m"));
|
||||||
|
let max_len = colors[..10].iter().map(|(n, _)| n.len()).max().unwrap();
|
||||||
|
let columns = (102 / (max_len + 15)).max(1);
|
||||||
|
assert_eq!(10 / columns + 1, s.matches("\r\n").count());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! `+list-fonts` (ZIG/src/cli/list_fonts.zig).
|
||||||
|
//!
|
||||||
|
//! DEFERRED: needs the font crate (font discovery). Only the options and
|
||||||
|
//! help are ported; running the action reports that it is not available.
|
||||||
|
|
||||||
|
use ghostty_config::config_struct;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
config_struct! {
|
||||||
|
pub struct Options {
|
||||||
|
/// The font family to search for. If this is set, then only fonts
|
||||||
|
/// matching this family will be listed.
|
||||||
|
"family" => family: Option<String> = None,
|
||||||
|
|
||||||
|
/// The style name to search for.
|
||||||
|
"style" => style: Option<String> = None,
|
||||||
|
|
||||||
|
/// Font styles to search for. If this is set, then only fonts that
|
||||||
|
/// match the given styles will be listed.
|
||||||
|
"bold" => bold: bool = false,
|
||||||
|
"italic" => italic: bool = false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli_options!(Options, help);
|
||||||
|
|
||||||
|
/// The `list-fonts` command is used to list all the available fonts for
|
||||||
|
/// Ghostty. This uses the exact same font discovery mechanism Ghostty uses to
|
||||||
|
/// find fonts to use.
|
||||||
|
///
|
||||||
|
/// When executed with no arguments, this will list all available fonts, sorted
|
||||||
|
/// by family name, then font name. If a family name is given with `--family`,
|
||||||
|
/// the sorting will be disabled and the results instead will be shown in the
|
||||||
|
/// same priority order Ghostty would use to pick a font.
|
||||||
|
///
|
||||||
|
/// Flags:
|
||||||
|
///
|
||||||
|
/// * `--bold`: Filter results to specific bold styles. It is not guaranteed
|
||||||
|
/// that only those styles are returned. They are only prioritized.
|
||||||
|
///
|
||||||
|
/// * `--italic`: Filter results to specific italic styles. It is not guaranteed
|
||||||
|
/// that only those styles are returned. They are only prioritized.
|
||||||
|
///
|
||||||
|
/// * `--style`: Filter results based on the style string advertised by a font.
|
||||||
|
/// It is not guaranteed that only those styles are returned. They are only
|
||||||
|
/// prioritized.
|
||||||
|
///
|
||||||
|
/// * `--family`: Filter results to a specific font family. The family handling
|
||||||
|
/// is identical to the `font-family` set of Ghostty configuration values, so
|
||||||
|
/// this can be used to debug why your desired font may not be loading.
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
crate::parse_options(&mut opts, args)?;
|
||||||
|
|
||||||
|
// TODO(integration): port the discovery/listing once ghostty-font lands.
|
||||||
|
Err(Error::NotYetAvailable("list-fonts"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
//! `+list-keybinds` (ZIG/src/cli/list_keybinds.zig), including its pretty
|
||||||
|
//! (tty) output.
|
||||||
|
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::io::{IsTerminal, Write};
|
||||||
|
|
||||||
|
use ghostty_config::config::Config;
|
||||||
|
use ghostty_config::config_struct;
|
||||||
|
use ghostty_config::formatter::EntryFormatter;
|
||||||
|
use ghostty_config::keybinds::Keybinds;
|
||||||
|
use ghostty_input::binding::set::{Set, Value};
|
||||||
|
use ghostty_input::binding::{Action as BindingAction, Trigger, TriggerKey};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::tui::{self, Color, PrettySession, Style, gwidth};
|
||||||
|
|
||||||
|
config_struct! {
|
||||||
|
pub struct Options {
|
||||||
|
/// If `true`, print out the default keybinds instead of the ones configured
|
||||||
|
/// in the config file.
|
||||||
|
"default" => default: bool = false,
|
||||||
|
|
||||||
|
/// If `true`, print out documentation about the action associated with the
|
||||||
|
/// keybinds.
|
||||||
|
"docs" => docs: bool = false,
|
||||||
|
|
||||||
|
/// If `true`, print without formatting even if printing to a tty
|
||||||
|
"plain" => plain: bool = false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli_options!(Options, help);
|
||||||
|
|
||||||
|
/// The `list-keybinds` command is used to list all the available keybinds for
|
||||||
|
/// Ghostty.
|
||||||
|
///
|
||||||
|
/// When executed without any arguments this will list the current keybinds
|
||||||
|
/// loaded by the config file. If no config file is found or there aren't any
|
||||||
|
/// changes to the keybinds it will print out the default ones configured for
|
||||||
|
/// Ghostty
|
||||||
|
///
|
||||||
|
/// Flags:
|
||||||
|
///
|
||||||
|
/// * `--default`: will print out all the default keybinds
|
||||||
|
///
|
||||||
|
/// * `--docs`: currently does nothing, intended to print out documentation
|
||||||
|
/// about the action associated with the keybinds
|
||||||
|
///
|
||||||
|
/// * `--plain`: will disable formatting and make the output more
|
||||||
|
/// friendly for Unix tooling. This is default when not printing to a tty.
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
crate::parse_options(&mut opts, args)?;
|
||||||
|
|
||||||
|
let config = if opts.default {
|
||||||
|
Config::new_default()
|
||||||
|
} else {
|
||||||
|
Config::load()?
|
||||||
|
};
|
||||||
|
|
||||||
|
let stdout = std::io::stdout();
|
||||||
|
if tui::CAN_PRETTY_PRINT && !opts.plain && stdout.is_terminal() {
|
||||||
|
return pretty_print(&config.keybind);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = format_plain(&config.keybind, opts.docs)?;
|
||||||
|
let mut out = std::io::BufWriter::new(stdout.lock());
|
||||||
|
out.write_all(text.as_bytes())?;
|
||||||
|
// Don't forget to flush!
|
||||||
|
out.flush()?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plain `keybind = ...` listing.
|
||||||
|
pub fn format_plain(keybinds: &Keybinds, docs: bool) -> Result<String, Error> {
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut f = EntryFormatter::new("keybind", &mut text);
|
||||||
|
keybinds.format_entry_docs(&mut f, docs)?;
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flattened binding: a chain of triggers (the sequence) and the actions
|
||||||
|
/// (more than one if chained).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChordBinding {
|
||||||
|
pub table_name: Option<String>,
|
||||||
|
pub triggers: Vec<Trigger>,
|
||||||
|
pub actions: Vec<BindingAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mod_count(t: &Trigger) -> usize {
|
||||||
|
usize::from(t.mods.super_)
|
||||||
|
+ usize::from(t.mods.ctrl)
|
||||||
|
+ usize::from(t.mods.shift)
|
||||||
|
+ usize::from(t.mods.alt)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_order(t: &Trigger) -> i64 {
|
||||||
|
match t.key {
|
||||||
|
TriggerKey::Physical(k) => i64::from(k as i32),
|
||||||
|
TriggerKey::Unicode(c) => i64::from(c),
|
||||||
|
TriggerKey::CatchAll => i64::from(i32::MAX),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChordBinding {
|
||||||
|
/// Order keybinds based on various properties
|
||||||
|
/// 1. Default bindings before table bindings (tables grouped at end)
|
||||||
|
/// 2. Longest chord sequence
|
||||||
|
/// 3. Most active modifiers
|
||||||
|
/// 4. Alphabetically by active modifiers
|
||||||
|
/// 5. Trigger key order
|
||||||
|
/// 6. Within tables, sort by table name
|
||||||
|
/// These properties propagate through chorded keypresses
|
||||||
|
///
|
||||||
|
/// Adapted from Binding.lessThan
|
||||||
|
#[must_use]
|
||||||
|
pub fn less_than(&self, rhs: &ChordBinding) -> bool {
|
||||||
|
let lhs_has_table = self.table_name.is_some();
|
||||||
|
let rhs_has_table = rhs.table_name.is_some();
|
||||||
|
|
||||||
|
if lhs_has_table != rhs_has_table {
|
||||||
|
return !lhs_has_table;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(l), Some(r)) = (&self.table_name, &rhs.table_name) {
|
||||||
|
let cmp = l.as_bytes().cmp(r.as_bytes());
|
||||||
|
if cmp != Ordering::Equal {
|
||||||
|
return cmp == Ordering::Less;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lhs_len = self.triggers.len();
|
||||||
|
let rhs_len = rhs.triggers.len();
|
||||||
|
debug_assert!(lhs_len != 0);
|
||||||
|
debug_assert!(rhs_len != 0);
|
||||||
|
|
||||||
|
if lhs_len != rhs_len {
|
||||||
|
return lhs_len > rhs_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lhs_count: usize = self.triggers.iter().map(mod_count).sum();
|
||||||
|
let rhs_count: usize = rhs.triggers.iter().map(mod_count).sum();
|
||||||
|
if lhs_count != rhs_count {
|
||||||
|
return lhs_count > rhs_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (l, r) in self.triggers.iter().zip(&rhs.triggers) {
|
||||||
|
let l_int = l.mods.int();
|
||||||
|
let r_int = r.mods.int();
|
||||||
|
if l_int != r_int {
|
||||||
|
return l_int > r_int;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut l_iter = self.triggers.iter();
|
||||||
|
let mut r_iter = rhs.triggers.iter();
|
||||||
|
loop {
|
||||||
|
let (Some(l), Some(r)) = (l_iter.next(), r_iter.next()) else {
|
||||||
|
// The Zig code treats this as unreachable (the final
|
||||||
|
// iteration always returns).
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// We want catch_all to sort last.
|
||||||
|
let lhs_key = key_order(l);
|
||||||
|
let rhs_key = key_order(r);
|
||||||
|
|
||||||
|
if l_iter.len() == 0 || r_iter.len() == 0 {
|
||||||
|
return lhs_key < rhs_key;
|
||||||
|
}
|
||||||
|
if lhs_key != rhs_key {
|
||||||
|
return lhs_key < rhs_key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plain (unstyled) text of a trigger, used for width measurement
|
||||||
|
/// (the Zig `iterateBindings` width computation).
|
||||||
|
fn trigger_width(t: &Trigger) -> u16 {
|
||||||
|
let mut buf = String::new();
|
||||||
|
if t.mods.super_ {
|
||||||
|
buf.push_str("super + ");
|
||||||
|
}
|
||||||
|
if t.mods.ctrl {
|
||||||
|
buf.push_str("ctrl + ");
|
||||||
|
}
|
||||||
|
if t.mods.alt {
|
||||||
|
buf.push_str("alt + ");
|
||||||
|
}
|
||||||
|
if t.mods.shift {
|
||||||
|
buf.push_str("shift + ");
|
||||||
|
}
|
||||||
|
buf.push_str(&key_text(t.key));
|
||||||
|
gwidth(&buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_text(key: TriggerKey) -> String {
|
||||||
|
match key {
|
||||||
|
TriggerKey::Physical(k) => k.name().to_string(),
|
||||||
|
TriggerKey::Unicode(c) => {
|
||||||
|
char::from_u32(c).map_or_else(|| "\u{fffd}".to_string(), |c| c.to_string())
|
||||||
|
}
|
||||||
|
TriggerKey::CatchAll => "catch_all".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatten a binding set, recursively expanding leader keys into chords.
|
||||||
|
/// Returns the bindings and the widest chord.
|
||||||
|
#[must_use]
|
||||||
|
pub fn iterate_bindings(set: &Set) -> (Vec<ChordBinding>, u16) {
|
||||||
|
let mut widest_chord: u16 = 0;
|
||||||
|
let mut bindings = vec![];
|
||||||
|
for (key, value) in &set.bindings {
|
||||||
|
let trigger = key.0;
|
||||||
|
let width = trigger_width(&trigger);
|
||||||
|
|
||||||
|
match value {
|
||||||
|
Value::Leader(leader) => {
|
||||||
|
// Recursively iterate on the set of bindings for this leader key
|
||||||
|
let (mut sub_bindings, max_width) = iterate_bindings(leader);
|
||||||
|
|
||||||
|
// Prepend the current keybind onto the list of sub-binds
|
||||||
|
for nb in &mut sub_bindings {
|
||||||
|
nb.triggers.insert(0, trigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the longest sub-bind width to the current bind width along with a padding
|
||||||
|
// of 5 for the ' > ' spacer
|
||||||
|
widest_chord = widest_chord.max(width + max_width + 5);
|
||||||
|
bindings.extend(sub_bindings);
|
||||||
|
}
|
||||||
|
Value::Leaf(leaf) => {
|
||||||
|
widest_chord = widest_chord.max(width);
|
||||||
|
bindings.push(ChordBinding {
|
||||||
|
table_name: None,
|
||||||
|
triggers: vec![trigger],
|
||||||
|
actions: vec![leaf.action.clone()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Value::LeafChained(leaf) => {
|
||||||
|
widest_chord = widest_chord.max(width);
|
||||||
|
bindings.push(ChordBinding {
|
||||||
|
table_name: None,
|
||||||
|
triggers: vec![trigger],
|
||||||
|
actions: leaf.actions.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(bindings, widest_chord)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect and sort all bindings (default set and key tables) for the pretty
|
||||||
|
/// listing. Returns the bindings, the widest chord and the widest table
|
||||||
|
/// prefix (`table/`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn collect_bindings(keybinds: &Keybinds) -> (Vec<ChordBinding>, u16, u16) {
|
||||||
|
// Collect default bindings, recursively flattening chords
|
||||||
|
let (mut bindings, mut widest_chord) = iterate_bindings(&keybinds.set);
|
||||||
|
|
||||||
|
// Collect key table bindings
|
||||||
|
let mut widest_table_prefix: u16 = 0;
|
||||||
|
for (table_name, table_set) in &keybinds.tables {
|
||||||
|
let (mut table_bindings, table_width) = iterate_bindings(table_set);
|
||||||
|
for b in &mut table_bindings {
|
||||||
|
b.table_name = Some(table_name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings.extend(table_bindings);
|
||||||
|
widest_chord = widest_chord.max(table_width);
|
||||||
|
widest_table_prefix = widest_table_prefix.max(gwidth(table_name) + gwidth("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// std.mem.sort is stable.
|
||||||
|
bindings.sort_by(|a, b| {
|
||||||
|
if a.less_than(b) {
|
||||||
|
Ordering::Less
|
||||||
|
} else if b.less_than(a) {
|
||||||
|
Ordering::Greater
|
||||||
|
} else {
|
||||||
|
Ordering::Equal
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(bindings, widest_chord, widest_table_prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pretty_print(keybinds: &Keybinds) -> Result<u8, Error> {
|
||||||
|
let mut session = PrettySession::new_raw(std::io::BufWriter::new(std::io::stdout().lock()))?;
|
||||||
|
let result = pretty_rows(&mut session, keybinds);
|
||||||
|
let fin = session.finish();
|
||||||
|
result?;
|
||||||
|
fin?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pretty_rows<W: Write>(
|
||||||
|
session: &mut PrettySession<W>,
|
||||||
|
keybinds: &Keybinds,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let (bindings, widest_chord, widest_table_prefix) = collect_bindings(keybinds);
|
||||||
|
|
||||||
|
// Set up styles for each modifier
|
||||||
|
let fg = |i: u8| Style {
|
||||||
|
fg: Color::Index(i),
|
||||||
|
..Style::default()
|
||||||
|
};
|
||||||
|
let super_style = fg(1);
|
||||||
|
let ctrl_style = fg(2);
|
||||||
|
let alt_style = fg(3);
|
||||||
|
let shift_style = fg(4);
|
||||||
|
let table_style = fg(8);
|
||||||
|
let plain = Style::default();
|
||||||
|
let sep_style = Style {
|
||||||
|
bold: true,
|
||||||
|
fg: Color::Index(6),
|
||||||
|
..Style::default()
|
||||||
|
};
|
||||||
|
let dim_style = Style {
|
||||||
|
dim: true,
|
||||||
|
..Style::default()
|
||||||
|
};
|
||||||
|
let arg_style = fg(5);
|
||||||
|
|
||||||
|
// Print the list
|
||||||
|
for bind in &bindings {
|
||||||
|
let mut row = session.row();
|
||||||
|
let mut col: u16 = 0;
|
||||||
|
|
||||||
|
if let Some(name) = &bind.table_name {
|
||||||
|
col = row.print_segment(name, table_style, col).col;
|
||||||
|
col = row.print_segment("/", table_style, col).col;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, trigger) in bind.triggers.iter().enumerate() {
|
||||||
|
if trigger.mods.super_ {
|
||||||
|
col = row.print_segment("super", super_style, col).col;
|
||||||
|
col = row.print_segment(" + ", plain, col).col;
|
||||||
|
}
|
||||||
|
if trigger.mods.ctrl {
|
||||||
|
col = row.print_segment("ctrl ", ctrl_style, col).col;
|
||||||
|
col = row.print_segment(" + ", plain, col).col;
|
||||||
|
}
|
||||||
|
if trigger.mods.alt {
|
||||||
|
col = row.print_segment("alt ", alt_style, col).col;
|
||||||
|
col = row.print_segment(" + ", plain, col).col;
|
||||||
|
}
|
||||||
|
if trigger.mods.shift {
|
||||||
|
col = row.print_segment("shift", shift_style, col).col;
|
||||||
|
col = row.print_segment(" + ", plain, col).col;
|
||||||
|
}
|
||||||
|
col = row.print_segment(&key_text(trigger.key), plain, col).col;
|
||||||
|
|
||||||
|
// Print a separator between chorded keys
|
||||||
|
if i + 1 < bind.triggers.len() {
|
||||||
|
col = row.print_segment(" > ", sep_style, col).col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut action_col: u16 = widest_table_prefix + widest_chord + 3;
|
||||||
|
for (i, act) in bind.actions.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
action_col = row.print_segment(", ", dim_style, action_col).col;
|
||||||
|
}
|
||||||
|
|
||||||
|
let action = act.to_string();
|
||||||
|
// If our action has an argument, we print the argument in a different color
|
||||||
|
if let Some(idx) = action.find(':') {
|
||||||
|
action_col = row
|
||||||
|
.print(
|
||||||
|
&[
|
||||||
|
(&action[..idx], plain),
|
||||||
|
(&action[idx..=idx], dim_style),
|
||||||
|
(&action[idx + 1..], arg_style),
|
||||||
|
],
|
||||||
|
action_col,
|
||||||
|
)
|
||||||
|
.col;
|
||||||
|
} else {
|
||||||
|
action_col = row.print_segment(&action, plain, action_col).col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = col;
|
||||||
|
session.print_row(&row)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn default_keybinds() -> Keybinds {
|
||||||
|
Config::new_default().keybind
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_default_listing_has_entries() {
|
||||||
|
let text = format_plain(&default_keybinds(), false).unwrap();
|
||||||
|
assert!(
|
||||||
|
text.contains("keybind = ctrl+shift+c=copy_to_clipboard:mixed\n"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert!(text.lines().all(|l| l.starts_with("keybind = ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_keybinds() {
|
||||||
|
let kb = Keybinds::default();
|
||||||
|
assert_eq!("keybind = \n", format_plain(&kb, false).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collected_bindings_are_sorted() {
|
||||||
|
let (bindings, widest, table_prefix) = collect_bindings(&default_keybinds());
|
||||||
|
assert!(bindings.len() > 50);
|
||||||
|
assert!(widest > 0);
|
||||||
|
assert_eq!(0, table_prefix);
|
||||||
|
// Longest sequences come first, and ordering is a strict order.
|
||||||
|
for w in bindings.windows(2) {
|
||||||
|
assert!(!w[1].less_than(&w[0]), "{:?} vs {:?}", w[0], w[1]);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
bindings
|
||||||
|
.windows(2)
|
||||||
|
.all(|w| w[0].triggers.len() >= w[1].triggers.len())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chords_and_tables() {
|
||||||
|
let mut kb = Keybinds::default();
|
||||||
|
kb.set.parse_and_put("ctrl+a>b=new_window").unwrap();
|
||||||
|
kb.set.parse_and_put("ctrl+a>c=new_tab").unwrap();
|
||||||
|
kb.set.parse_and_put("x=close_surface").unwrap();
|
||||||
|
let (bindings, widest, _) = collect_bindings(&kb);
|
||||||
|
assert_eq!(3, bindings.len());
|
||||||
|
assert_eq!(2, bindings[0].triggers.len());
|
||||||
|
assert_eq!(1, bindings[2].triggers.len());
|
||||||
|
// "ctrl + a" (9) + " > " (5) + "b" (1)
|
||||||
|
assert_eq!(15, widest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pretty_rows_output() {
|
||||||
|
let mut kb = Keybinds::default();
|
||||||
|
kb.set
|
||||||
|
.parse_and_put("super+ctrl+shift+arrow_down=resize_split:down,10")
|
||||||
|
.unwrap();
|
||||||
|
kb.set.parse_and_put("paste=paste_from_clipboard").unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut session = PrettySession::new(&mut buf).unwrap();
|
||||||
|
session.width = 100;
|
||||||
|
pretty_rows(&mut session, &kb).unwrap();
|
||||||
|
drop(session);
|
||||||
|
let s = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(
|
||||||
|
s.contains(
|
||||||
|
"\x1b[31msuper\x1b[39m + \x1b[32mctrl \x1b[39m + \x1b[34mshift\x1b[39m + arrow_down"
|
||||||
|
),
|
||||||
|
"{s:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
s.contains("resize_split\x1b[2m:\x1b[35m\x1b[22mdown,10\r\n"),
|
||||||
|
"{s:?}"
|
||||||
|
);
|
||||||
|
assert!(s.contains("paste\x1b["), "{s:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,1762 @@
|
|||||||
|
//! The interactive theme preview of `+list-themes` (Zig: `Preview` in
|
||||||
|
//! list_themes.zig, built on libvaxis). This is a ratatui + crossterm
|
||||||
|
//! implementation with the same layout, key bindings and behaviour.
|
||||||
|
//!
|
||||||
|
//! Known differences from the vaxis version:
|
||||||
|
//! * hyperlinks (OSC 8) on theme names/paths are not emitted,
|
||||||
|
//! * double/dashed/dotted/curly underlines are drawn as single underlines,
|
||||||
|
//! * the terminal color scheme is not queried live: it defaults to light
|
||||||
|
//! like the Zig code does, but `COLORFGBG` is used as a hint if set.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use ghostty_config::config::Config;
|
||||||
|
use ghostty_config::types_a::TerminalColor;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::crossterm::event::{
|
||||||
|
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
|
||||||
|
MouseEventKind,
|
||||||
|
};
|
||||||
|
use ratatui::layout::{Position, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::{Frame, crossterm};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
ColorScheme, SMALL_LIST_THRESHOLD, ThemeListElement, absolute_path, should_include_theme,
|
||||||
|
};
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
const LOREM_IPSUM: &str = include_str!("../lorem_ipsum.txt");
|
||||||
|
|
||||||
|
/// Width of the theme list on the left.
|
||||||
|
const LIST_WIDTH: u16 = 32;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Mode {
|
||||||
|
Normal,
|
||||||
|
Help,
|
||||||
|
Search,
|
||||||
|
Save,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal's color scheme (`vaxis.Color.Scheme`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Scheme {
|
||||||
|
Light,
|
||||||
|
Dark,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal single line text input (`vaxis.widgets.TextInput`).
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct TextInput {
|
||||||
|
pub text: String,
|
||||||
|
/// Cursor position in chars.
|
||||||
|
pub cursor: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextInput {
|
||||||
|
fn byte_idx(&self, chars: usize) -> usize {
|
||||||
|
self.text
|
||||||
|
.char_indices()
|
||||||
|
.nth(chars)
|
||||||
|
.map_or(self.text.len(), |(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.text.chars().count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.text.clear();
|
||||||
|
self.cursor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an editing key. Returns true if it was consumed.
|
||||||
|
pub fn update(&mut self, key: &KeyEvent) -> bool {
|
||||||
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char(c) if !ctrl && !alt => {
|
||||||
|
let at = self.byte_idx(self.cursor);
|
||||||
|
self.text.insert(at, c);
|
||||||
|
self.cursor += 1;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if self.cursor > 0 {
|
||||||
|
let at = self.byte_idx(self.cursor - 1);
|
||||||
|
self.text.remove(at);
|
||||||
|
self.cursor -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Delete | KeyCode::Char('d') if key.code == KeyCode::Delete || ctrl => {
|
||||||
|
if self.cursor < self.len() {
|
||||||
|
let at = self.byte_idx(self.cursor);
|
||||||
|
self.text.remove(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Left | KeyCode::Char('b') if key.code == KeyCode::Left || ctrl => {
|
||||||
|
self.cursor = self.cursor.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Right | KeyCode::Char('f') if key.code == KeyCode::Right || ctrl => {
|
||||||
|
self.cursor = (self.cursor + 1).min(self.len());
|
||||||
|
}
|
||||||
|
KeyCode::Home => self.cursor = 0,
|
||||||
|
KeyCode::Char('a') if ctrl => self.cursor = 0,
|
||||||
|
KeyCode::End => self.cursor = self.len(),
|
||||||
|
KeyCode::Char('e') if ctrl => self.cursor = self.len(),
|
||||||
|
KeyCode::Char('u') if ctrl => {
|
||||||
|
let at = self.byte_idx(self.cursor);
|
||||||
|
self.text.drain(..at);
|
||||||
|
self.cursor = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Char('k') if ctrl => {
|
||||||
|
let at = self.byte_idx(self.cursor);
|
||||||
|
self.text.truncate(at);
|
||||||
|
}
|
||||||
|
KeyCode::Char('w') if ctrl => {
|
||||||
|
let upto = self.byte_idx(self.cursor);
|
||||||
|
let head = self.text[..upto].trim_end_matches(' ');
|
||||||
|
let new_end = head.rfind(' ').map_or(0, |i| i + 1);
|
||||||
|
self.text.drain(new_end..upto);
|
||||||
|
self.cursor = self.text[..new_end].chars().count();
|
||||||
|
}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `zf.rank` with `.plain = true, .to_lower = true`: every token must match
|
||||||
|
/// the (lowercased) name as a subsequence. Only whether a rank exists is used
|
||||||
|
/// by the preview, so the score is a simple span length (lower is better).
|
||||||
|
#[must_use]
|
||||||
|
pub fn rank(name: &str, tokens: &[&str]) -> Option<f64> {
|
||||||
|
let name: Vec<char> = name.to_lowercase().chars().collect();
|
||||||
|
let mut total = 0.0;
|
||||||
|
for token in tokens {
|
||||||
|
let token: Vec<char> = token.chars().collect();
|
||||||
|
if token.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Prefer a contiguous match.
|
||||||
|
let contiguous = name
|
||||||
|
.windows(token.len())
|
||||||
|
.position(|w| w == token.as_slice());
|
||||||
|
if let Some(start) = contiguous {
|
||||||
|
total += start as f64;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut ti = 0;
|
||||||
|
let mut first = None;
|
||||||
|
let mut last = 0;
|
||||||
|
for (i, c) in name.iter().enumerate() {
|
||||||
|
if ti < token.len() && *c == token[ti] {
|
||||||
|
first.get_or_insert(i);
|
||||||
|
last = i;
|
||||||
|
ti += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ti < token.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
total += 1000.0 + (last - first.unwrap_or(0)) as f64;
|
||||||
|
}
|
||||||
|
Some(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard base64 (for OSC 52).
|
||||||
|
fn base64(data: &[u8]) -> String {
|
||||||
|
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let mut out = String::new();
|
||||||
|
for chunk in data.chunks(3) {
|
||||||
|
let n = (u32::from(chunk[0]) << 16)
|
||||||
|
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
||||||
|
| u32::from(*chunk.get(2).unwrap_or(&0));
|
||||||
|
out.push(T[(n >> 18) as usize & 63] as char);
|
||||||
|
out.push(T[(n >> 12) as usize & 63] as char);
|
||||||
|
out.push(if chunk.len() > 1 {
|
||||||
|
T[(n >> 6) as usize & 63] as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
|
out.push(if chunk.len() > 2 {
|
||||||
|
T[n as usize & 63] as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached result of loading the currently previewed theme.
|
||||||
|
enum Loaded {
|
||||||
|
Ok(Box<Config>),
|
||||||
|
Err(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pending side effect requested by the state (needs the terminal).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Effect {
|
||||||
|
/// Copy the text to the clipboard (OSC 52).
|
||||||
|
Copy(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preview state (Zig: `Preview`).
|
||||||
|
pub struct Preview {
|
||||||
|
pub should_quit: bool,
|
||||||
|
pub themes: Vec<ThemeListElement>,
|
||||||
|
pub filtered: Vec<usize>,
|
||||||
|
pub current: usize,
|
||||||
|
pub window: usize,
|
||||||
|
pub hex: bool,
|
||||||
|
pub mode: Mode,
|
||||||
|
pub color_scheme: Scheme,
|
||||||
|
pub text_input: TextInput,
|
||||||
|
pub theme_filter: ColorScheme,
|
||||||
|
/// Last mouse event, consumed by the next draw.
|
||||||
|
mouse: Option<MouseEvent>,
|
||||||
|
/// The list height as of the last draw (for clamping the window).
|
||||||
|
list_height: u16,
|
||||||
|
loaded: Option<(String, Loaded)>,
|
||||||
|
/// Where `w` writes the auto theme file (overridable for tests).
|
||||||
|
pub auto_theme_path: Option<String>,
|
||||||
|
effects: Vec<Effect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_scheme() -> Scheme {
|
||||||
|
// COLORFGBG is "fg;bg" (sometimes "fg;default;bg"); bg 0-6 and 8 are dark.
|
||||||
|
if let Ok(v) = std::env::var("COLORFGBG") {
|
||||||
|
if let Some(bg) = v.rsplit(';').next().and_then(|s| s.parse::<u8>().ok()) {
|
||||||
|
return if bg <= 6 || bg == 8 {
|
||||||
|
Scheme::Dark
|
||||||
|
} else {
|
||||||
|
Scheme::Light
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Scheme::Light
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Preview {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(themes: Vec<ThemeListElement>, theme_filter: ColorScheme) -> Preview {
|
||||||
|
let mut p = Preview {
|
||||||
|
should_quit: false,
|
||||||
|
themes,
|
||||||
|
filtered: vec![],
|
||||||
|
current: 0,
|
||||||
|
window: 0,
|
||||||
|
hex: false,
|
||||||
|
mode: Mode::Normal,
|
||||||
|
color_scheme: initial_scheme(),
|
||||||
|
text_input: TextInput::default(),
|
||||||
|
theme_filter,
|
||||||
|
mouse: None,
|
||||||
|
list_height: 0,
|
||||||
|
loaded: None,
|
||||||
|
auto_theme_path: None,
|
||||||
|
effects: vec![],
|
||||||
|
};
|
||||||
|
p.update_filtered();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompute the filtered theme list (Zig: `updateFiltered`).
|
||||||
|
pub fn update_filtered(&mut self) {
|
||||||
|
let relative = self.current.saturating_sub(self.window);
|
||||||
|
let selected: Option<String> = self
|
||||||
|
.filtered
|
||||||
|
.get(self.current)
|
||||||
|
.map(|&i| self.themes[i].theme.clone());
|
||||||
|
|
||||||
|
let old = self.filtered.clone();
|
||||||
|
self.filtered.clear();
|
||||||
|
|
||||||
|
let mut theme_config = Config::new_default();
|
||||||
|
let lowered = self.text_input.text.to_ascii_lowercase();
|
||||||
|
let tokens: Vec<&str> = lowered.split(' ').filter(|t| !t.is_empty()).collect();
|
||||||
|
let searching = !self.text_input.text.is_empty();
|
||||||
|
|
||||||
|
for (i, theme) in self.themes.iter_mut().enumerate() {
|
||||||
|
// Errors are ignored here (Zig would abort the whole preview).
|
||||||
|
let _ = theme_config.load_file(&absolute_path(&theme.path));
|
||||||
|
if !should_include_theme(self.theme_filter, &theme_config) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if searching {
|
||||||
|
theme.rank = rank(&theme.theme, &tokens);
|
||||||
|
if theme.rank.is_some() {
|
||||||
|
self.filtered.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.filtered.push(i);
|
||||||
|
theme.rank = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if old == self.filtered {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.filtered.is_empty() {
|
||||||
|
self.current = 0;
|
||||||
|
self.window = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (current, window) = (|| {
|
||||||
|
let Some(selected) = &selected else {
|
||||||
|
return (0, 0);
|
||||||
|
};
|
||||||
|
for (i, &index) in self.filtered.iter().enumerate() {
|
||||||
|
if &self.themes[index].theme == selected {
|
||||||
|
// Keep the relative position but ensure all search results are visible
|
||||||
|
let new_window = i.saturating_sub(relative);
|
||||||
|
// If the new window would hide some results at the top, adjust it
|
||||||
|
return (
|
||||||
|
i,
|
||||||
|
if self.filtered.len() <= SMALL_LIST_THRESHOLD {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
new_window
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(0, 0)
|
||||||
|
})();
|
||||||
|
self.current = current;
|
||||||
|
self.window = window;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn up(&mut self, count: usize) {
|
||||||
|
if self.filtered.is_empty() {
|
||||||
|
self.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.current = self.current.saturating_sub(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn down(&mut self, count: usize) {
|
||||||
|
if self.filtered.is_empty() {
|
||||||
|
self.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.current += count;
|
||||||
|
if self.current >= self.filtered.len() {
|
||||||
|
self.current = self.filtered.len() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected(&self) -> Option<&ThemeListElement> {
|
||||||
|
self.filtered.get(self.current).map(|&i| &self.themes[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the pending side effects.
|
||||||
|
pub fn take_effects(&mut self) -> Vec<Effect> {
|
||||||
|
std::mem::take(&mut self.effects)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a terminal event (Zig: `update`).
|
||||||
|
pub fn update(&mut self, event: &Event) {
|
||||||
|
match event {
|
||||||
|
Event::Key(key) if key.kind != KeyEventKind::Release => self.update_key(key),
|
||||||
|
Event::Mouse(m) => self.mouse = Some(*m),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_key(&mut self, key: &KeyEvent) {
|
||||||
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||||
|
let plain = key.modifiers.difference(KeyModifiers::SHIFT).is_empty();
|
||||||
|
let code = key.code;
|
||||||
|
let is_char = |c: char| code == KeyCode::Char(c) && plain && !shift;
|
||||||
|
|
||||||
|
if ctrl && code == KeyCode::Char('c') {
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.mode {
|
||||||
|
Mode::Normal => {
|
||||||
|
if plain && (is_char('q') || code == KeyCode::Esc) {
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
if (plain && (is_char('?') || code == KeyCode::Char('?') || code == KeyCode::F(1)))
|
||||||
|
|| (ctrl && code == KeyCode::Char('h'))
|
||||||
|
{
|
||||||
|
self.mode = Mode::Help;
|
||||||
|
}
|
||||||
|
if is_char('/') {
|
||||||
|
self.mode = Mode::Search;
|
||||||
|
}
|
||||||
|
if plain && code == KeyCode::Enter {
|
||||||
|
self.mode = Mode::Save;
|
||||||
|
}
|
||||||
|
if ctrl && matches!(code, KeyCode::Char('x' | '/' | '_')) {
|
||||||
|
self.text_input.clear();
|
||||||
|
self.update_filtered();
|
||||||
|
}
|
||||||
|
if plain && code == KeyCode::Home {
|
||||||
|
self.current = 0;
|
||||||
|
}
|
||||||
|
if plain && code == KeyCode::End {
|
||||||
|
self.current = self.filtered.len().saturating_sub(1);
|
||||||
|
}
|
||||||
|
if plain && (matches!(code, KeyCode::Char('j' | '+') | KeyCode::Down)) {
|
||||||
|
self.down(1);
|
||||||
|
}
|
||||||
|
if plain && code == KeyCode::PageDown {
|
||||||
|
self.down(20);
|
||||||
|
}
|
||||||
|
if plain && (matches!(code, KeyCode::Char('k' | '-') | KeyCode::Up)) {
|
||||||
|
self.up(1);
|
||||||
|
}
|
||||||
|
if plain && code == KeyCode::PageUp {
|
||||||
|
self.up(20);
|
||||||
|
}
|
||||||
|
if plain && matches!(code, KeyCode::Char('h' | 'x')) && !shift {
|
||||||
|
self.hex = true;
|
||||||
|
}
|
||||||
|
if is_char('d') {
|
||||||
|
self.hex = false;
|
||||||
|
}
|
||||||
|
if is_char('c') {
|
||||||
|
if let Some(t) = self.selected() {
|
||||||
|
let name = t.theme.clone();
|
||||||
|
self.effects.push(Effect::Copy(name));
|
||||||
|
}
|
||||||
|
} else if code == KeyCode::Char('C')
|
||||||
|
|| (code == KeyCode::Char('c') && shift && !ctrl)
|
||||||
|
{
|
||||||
|
if let Some(t) = self.selected() {
|
||||||
|
let path = t.path.clone();
|
||||||
|
self.effects.push(Effect::Copy(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_char('f') {
|
||||||
|
self.theme_filter = match self.theme_filter {
|
||||||
|
ColorScheme::All => ColorScheme::Dark,
|
||||||
|
ColorScheme::Dark => ColorScheme::Light,
|
||||||
|
ColorScheme::Light => ColorScheme::All,
|
||||||
|
};
|
||||||
|
self.update_filtered();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode::Help => {
|
||||||
|
if is_char('q') {
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
if (plain
|
||||||
|
&& (is_char('?')
|
||||||
|
|| code == KeyCode::Char('?')
|
||||||
|
|| code == KeyCode::Esc
|
||||||
|
|| code == KeyCode::F(1)))
|
||||||
|
|| (ctrl && code == KeyCode::Char('h'))
|
||||||
|
{
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode::Search => {
|
||||||
|
if plain && matches!(code, KeyCode::Esc | KeyCode::Enter) {
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ctrl && matches!(code, KeyCode::Char('x' | '/' | '_')) {
|
||||||
|
self.text_input.clear();
|
||||||
|
self.update_filtered();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.text_input.update(key);
|
||||||
|
self.update_filtered();
|
||||||
|
}
|
||||||
|
Mode::Save => {
|
||||||
|
if is_char('q') {
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
if plain && matches!(code, KeyCode::Esc | KeyCode::Enter) {
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
}
|
||||||
|
if is_char('w') {
|
||||||
|
self.save_selected_theme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_selected_theme(&self) {
|
||||||
|
let Some(theme) = self.selected() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = write_auto_theme_file(self.auto_theme_path.as_deref(), &theme.theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- colors ----
|
||||||
|
|
||||||
|
fn ui_fg(&self) -> Color {
|
||||||
|
match self.color_scheme {
|
||||||
|
Scheme::Light => Color::Rgb(0, 0, 0),
|
||||||
|
Scheme::Dark => Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn ui_bg(&self) -> Color {
|
||||||
|
match self.color_scheme {
|
||||||
|
Scheme::Light => Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
Scheme::Dark => Color::Rgb(0, 0, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn ui_standard(&self) -> Style {
|
||||||
|
Style::default().fg(self.ui_fg()).bg(self.ui_bg())
|
||||||
|
}
|
||||||
|
fn ui_hover_bg(&self) -> Color {
|
||||||
|
match self.color_scheme {
|
||||||
|
Scheme::Light => Color::Rgb(0xbb, 0xbb, 0xbb),
|
||||||
|
Scheme::Dark => Color::Rgb(0x22, 0x22, 0x22),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn ui_highlighted(&self) -> Style {
|
||||||
|
Style::default().fg(self.ui_fg()).bg(self.ui_hover_bg())
|
||||||
|
}
|
||||||
|
fn ui_selected(&self) -> Style {
|
||||||
|
let bg = match self.color_scheme {
|
||||||
|
Scheme::Light => Color::Rgb(0xaa, 0xaa, 0xaa),
|
||||||
|
Scheme::Dark => Color::Rgb(0x33, 0x33, 0x33),
|
||||||
|
};
|
||||||
|
Style::default().fg(Color::Rgb(0, 0xaa, 0)).bg(bg)
|
||||||
|
}
|
||||||
|
fn ui_err(&self) -> Style {
|
||||||
|
Style::default().fg(Color::Rgb(0xff, 0, 0)).bg(self.ui_bg())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- drawing ----
|
||||||
|
|
||||||
|
/// Draw the whole UI into `buf`. Returns the cursor position (search mode).
|
||||||
|
pub fn draw(&mut self, buf: &mut Buffer) -> Option<Position> {
|
||||||
|
let area = buf.area;
|
||||||
|
let cell = RefCell::new(buf);
|
||||||
|
let win = Win { buf: &cell, area };
|
||||||
|
win.clear();
|
||||||
|
|
||||||
|
let theme_list = win.child(0, 0, LIST_WIDTH.min(area.width), area.height);
|
||||||
|
self.list_height = theme_list.area.height;
|
||||||
|
|
||||||
|
let mut highlight: Option<u16> = None;
|
||||||
|
|
||||||
|
if let Some(mouse) = self.mouse.take() {
|
||||||
|
if self.mode == Mode::Normal {
|
||||||
|
match mouse.kind {
|
||||||
|
MouseEventKind::ScrollUp => self.up(1),
|
||||||
|
MouseEventKind::ScrollDown => self.down(1),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if mouse.column < theme_list.area.width && mouse.row < theme_list.area.height {
|
||||||
|
if mouse.kind == MouseEventKind::Up(MouseButton::Left) {
|
||||||
|
let selection = self.window + usize::from(mouse.row);
|
||||||
|
if selection < self.filtered.len() {
|
||||||
|
self.current = selection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
highlight = Some(mouse.row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.filtered.is_empty() {
|
||||||
|
self.current = 0;
|
||||||
|
self.window = 0;
|
||||||
|
} else {
|
||||||
|
let height = usize::from(theme_list.area.height).max(1);
|
||||||
|
let start = self.window;
|
||||||
|
let end = self.window + height - 1;
|
||||||
|
if self.current > end {
|
||||||
|
self.window = self.current + 1 - height;
|
||||||
|
}
|
||||||
|
if self.current < start {
|
||||||
|
self.window = self.current;
|
||||||
|
}
|
||||||
|
if self.window >= self.filtered.len() {
|
||||||
|
self.window = self.filtered.len() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ui = self.ui_standard();
|
||||||
|
theme_list.fill(ui);
|
||||||
|
|
||||||
|
for row in 0..theme_list.area.height {
|
||||||
|
let index = self.window + usize::from(row);
|
||||||
|
if index >= self.filtered.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let theme = &self.themes[self.filtered[index]];
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
enum St {
|
||||||
|
Normal,
|
||||||
|
Highlighted,
|
||||||
|
Selected,
|
||||||
|
}
|
||||||
|
let style = if index == self.current {
|
||||||
|
St::Selected
|
||||||
|
} else if highlight == Some(row) {
|
||||||
|
St::Highlighted
|
||||||
|
} else {
|
||||||
|
St::Normal
|
||||||
|
};
|
||||||
|
|
||||||
|
if style == St::Selected {
|
||||||
|
theme_list.print("\u{276f} ", self.ui_selected(), row, 0);
|
||||||
|
}
|
||||||
|
theme_list.print(
|
||||||
|
&theme.theme,
|
||||||
|
match style {
|
||||||
|
St::Normal => self.ui_standard(),
|
||||||
|
St::Highlighted => self.ui_highlighted(),
|
||||||
|
St::Selected => self.ui_selected(),
|
||||||
|
},
|
||||||
|
row,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
if style == St::Selected {
|
||||||
|
let name_len = gw(&theme.theme);
|
||||||
|
if name_len + 4 < LIST_WIDTH {
|
||||||
|
for i in (2 + name_len)..(LIST_WIDTH - 2) {
|
||||||
|
theme_list.print(" ", self.ui_selected(), row, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
theme_list.print(
|
||||||
|
" \u{276e}",
|
||||||
|
self.ui_selected(),
|
||||||
|
row,
|
||||||
|
LIST_WIDTH.saturating_sub(2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.draw_preview(&win, LIST_WIDTH);
|
||||||
|
|
||||||
|
let mut cursor = None;
|
||||||
|
match self.mode {
|
||||||
|
Mode::Normal => {}
|
||||||
|
Mode::Help => {
|
||||||
|
let (width, height) = (60u16, 22u16);
|
||||||
|
let child = win.child_border(
|
||||||
|
(area.width / 2).saturating_sub(width / 2),
|
||||||
|
(area.height / 2).saturating_sub(height / 2),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
self.ui_standard(),
|
||||||
|
);
|
||||||
|
let key_help: [(&str, &str); 19] = [
|
||||||
|
("^C, q, ESC", "Quit."),
|
||||||
|
("F1, ?, ^H", "Toggle help window."),
|
||||||
|
("f", "Cycle through theme filters."),
|
||||||
|
("k, \u{2191}", "Move up 1 theme."),
|
||||||
|
("ScrollUp", "Move up 1 theme."),
|
||||||
|
("PgUp", "Move up 20 themes."),
|
||||||
|
("j, \u{2193}", "Move down 1 theme."),
|
||||||
|
("ScrollDown", "Move down 1 theme."),
|
||||||
|
("PgDown", "Move down 20 themes."),
|
||||||
|
("h, x", "Show palette numbers in hexadecimal."),
|
||||||
|
("d", "Show palette numbers in decimal."),
|
||||||
|
("c", "Copy theme name to the clipboard."),
|
||||||
|
("C", "Copy theme path to the clipboard."),
|
||||||
|
("Home", "Go to the start of the list."),
|
||||||
|
("End", "Go to the end of the list."),
|
||||||
|
("/", "Start search."),
|
||||||
|
("^X, ^/", "Clear search."),
|
||||||
|
("\u{23ce}", "Save theme or close search window."),
|
||||||
|
("w", "Write theme to auto config file."),
|
||||||
|
];
|
||||||
|
for (i, (keys, help)) in key_help.iter().enumerate() {
|
||||||
|
let row = u16::try_from(i).unwrap_or(0) + 1;
|
||||||
|
child.print(keys, self.ui_standard(), row, 2);
|
||||||
|
child.print("\u{2014}", self.ui_standard(), row, 15);
|
||||||
|
child.print(help, self.ui_standard(), row, 17);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode::Search => {
|
||||||
|
let child = win.child_border(
|
||||||
|
20,
|
||||||
|
area.height.saturating_sub(5),
|
||||||
|
area.width.saturating_sub(40),
|
||||||
|
3,
|
||||||
|
self.ui_standard(),
|
||||||
|
);
|
||||||
|
child.print(&self.text_input.text, self.ui_standard(), 0, 0);
|
||||||
|
let col = gw(&self
|
||||||
|
.text_input
|
||||||
|
.text
|
||||||
|
.chars()
|
||||||
|
.take(self.text_input.cursor)
|
||||||
|
.collect::<String>());
|
||||||
|
if col < child.area.width {
|
||||||
|
cursor = Some(Position::new(child.area.x + col, child.area.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode::Save => {
|
||||||
|
if let Some(theme) = self.selected() {
|
||||||
|
let (width, height) = (92u16, 17u16);
|
||||||
|
let child = win.child_border(
|
||||||
|
(area.width / 2).saturating_sub(width / 2),
|
||||||
|
(area.height / 2).saturating_sub(height / 2),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
self.ui_standard(),
|
||||||
|
);
|
||||||
|
let theme_line = format!("theme = {}", theme.theme);
|
||||||
|
let save_instructions: [&str; 14] = [
|
||||||
|
"To apply this theme, add the following line to your Ghostty configuration:",
|
||||||
|
"",
|
||||||
|
&theme_line,
|
||||||
|
"",
|
||||||
|
"Save the configuration file and then reload it to apply the new theme.",
|
||||||
|
"",
|
||||||
|
"Or press 'w' to write an auto theme file to your system's preferred default config path.",
|
||||||
|
"Then add the following line to your Ghostty configuration and reload:",
|
||||||
|
"",
|
||||||
|
"config-file = ?auto/theme.ghostty",
|
||||||
|
"",
|
||||||
|
"For more details on configuration and themes, visit the Ghostty documentation:",
|
||||||
|
"",
|
||||||
|
"https://ghostty.org/docs/config/reference",
|
||||||
|
];
|
||||||
|
for (i, instruction) in save_instructions.iter().enumerate() {
|
||||||
|
child.print(
|
||||||
|
instruction,
|
||||||
|
self.ui_standard(),
|
||||||
|
u16::try_from(i).unwrap_or(0) + 1,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_current(&mut self) -> Option<&Loaded> {
|
||||||
|
let theme = self.selected()?.clone();
|
||||||
|
let stale = self.loaded.as_ref().is_none_or(|(p, _)| p != &theme.path);
|
||||||
|
if stale {
|
||||||
|
let mut config = Config::new_default();
|
||||||
|
let loaded = match config.load_file(&absolute_path(&theme.path)) {
|
||||||
|
Ok(()) => Loaded::Ok(Box::new(config)),
|
||||||
|
Err(e) => Loaded::Err(error_name(&e)),
|
||||||
|
};
|
||||||
|
self.loaded = Some((theme.path, loaded));
|
||||||
|
}
|
||||||
|
self.loaded.as_ref().map(|(_, l)| l)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_preview(&mut self, win: &Win<'_, '_>, x_off: u16) {
|
||||||
|
let area = win.area;
|
||||||
|
let x_off = x_off.min(area.width);
|
||||||
|
let width = area.width - x_off;
|
||||||
|
let ui_err = self.ui_err();
|
||||||
|
let ui_std = self.ui_standard();
|
||||||
|
let hex = self.hex;
|
||||||
|
|
||||||
|
if self.filtered.is_empty() {
|
||||||
|
let child = win.child(0, 0, area.width, area.height);
|
||||||
|
child.fill(ui_std);
|
||||||
|
child.print(
|
||||||
|
"No theme found!",
|
||||||
|
ui_std,
|
||||||
|
(area.height / 2).saturating_sub(1),
|
||||||
|
(area.width / 2).saturating_sub(7),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(theme) = self.selected().cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let config = match self.load_current() {
|
||||||
|
Some(Loaded::Ok(c)) => c.clone(),
|
||||||
|
Some(Loaded::Err(err)) => {
|
||||||
|
let child = win.child(x_off, 0, width, area.height);
|
||||||
|
child.fill(ui_std);
|
||||||
|
let middle = child.area.height / 2;
|
||||||
|
let text = format!("Unable to open {} from:", theme.theme);
|
||||||
|
child.print(
|
||||||
|
&text,
|
||||||
|
ui_err,
|
||||||
|
middle.saturating_sub(1),
|
||||||
|
(child.area.width / 2).saturating_sub(gw(&text) / 2),
|
||||||
|
);
|
||||||
|
child.print(
|
||||||
|
&theme.path,
|
||||||
|
ui_err,
|
||||||
|
middle,
|
||||||
|
(child.area.width / 2).saturating_sub(gw(&theme.path) / 2),
|
||||||
|
);
|
||||||
|
child.print(
|
||||||
|
err,
|
||||||
|
ui_err,
|
||||||
|
middle + 1,
|
||||||
|
(child.area.width / 2).saturating_sub(gw(err) / 2),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut next_start: u16 = 0;
|
||||||
|
|
||||||
|
let rgb = |c: ghostty_config::types_a::Color| Color::Rgb(c.r, c.g, c.b);
|
||||||
|
let term_rgb = |c: &Option<TerminalColor>, default: Color| match c {
|
||||||
|
Some(TerminalColor::Color(c)) => rgb(*c),
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
let fg = rgb(config.foreground);
|
||||||
|
let bg = rgb(config.background);
|
||||||
|
let cursor_fg = term_rgb(&config.cursor_text, bg);
|
||||||
|
let cursor_bg = term_rgb(&config.cursor_color, fg);
|
||||||
|
let selection_fg = term_rgb(&config.selection_foreground, bg);
|
||||||
|
let selection_bg = term_rgb(&config.selection_background, fg);
|
||||||
|
let cursor = Style::default().fg(cursor_fg).bg(cursor_bg);
|
||||||
|
let standard_selection = Style::default().fg(selection_fg).bg(selection_bg);
|
||||||
|
let standard = Style::default().fg(fg).bg(bg);
|
||||||
|
let standard_bold = standard.add_modifier(Modifier::BOLD);
|
||||||
|
let standard_italic = standard.add_modifier(Modifier::ITALIC);
|
||||||
|
let standard_bold_italic = standard.add_modifier(Modifier::BOLD | Modifier::ITALIC);
|
||||||
|
// TODO: vaxis supports double/dashed/dotted/curly underlines.
|
||||||
|
let standard_underline = standard.add_modifier(Modifier::UNDERLINED);
|
||||||
|
|
||||||
|
let color = |i: usize| {
|
||||||
|
let c = config.palette.value[i];
|
||||||
|
Color::Rgb(c.r, c.g, c.b)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Header.
|
||||||
|
{
|
||||||
|
let child = win.child(x_off, next_start, width, 4);
|
||||||
|
child.fill(standard);
|
||||||
|
child.print(
|
||||||
|
&theme.theme,
|
||||||
|
standard_bold_italic,
|
||||||
|
1,
|
||||||
|
(child.area.width / 2).saturating_sub(gw(&theme.theme) / 2),
|
||||||
|
);
|
||||||
|
child.print(
|
||||||
|
&theme.path,
|
||||||
|
standard,
|
||||||
|
2,
|
||||||
|
(child.area.width / 2).saturating_sub(gw(&theme.path) / 2),
|
||||||
|
);
|
||||||
|
next_start += child.area.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostics.
|
||||||
|
if !config.diagnostics.items().is_empty() {
|
||||||
|
let n = u16::try_from(config.diagnostics.items().len()).unwrap_or(u16::MAX);
|
||||||
|
let child = win.child(x_off, next_start, width, 2 + n);
|
||||||
|
let text = "Problems were encountered trying to load the theme:";
|
||||||
|
child.print(
|
||||||
|
text,
|
||||||
|
ui_err,
|
||||||
|
0,
|
||||||
|
(child.area.width / 2).saturating_sub(gw(text) / 2),
|
||||||
|
);
|
||||||
|
for (i, diag) in config.diagnostics.items().iter().enumerate() {
|
||||||
|
child.print(
|
||||||
|
&diag.to_string(),
|
||||||
|
ui_err,
|
||||||
|
2 + u16::try_from(i).unwrap_or(0),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
next_start += 2 + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Palette.
|
||||||
|
{
|
||||||
|
let child = win.child(x_off, next_start, width, 6);
|
||||||
|
child.fill(standard);
|
||||||
|
for i in 0..16usize {
|
||||||
|
let r = u16::try_from(i / 8).unwrap_or(0);
|
||||||
|
let c = u16::try_from(i % 8).unwrap_or(0);
|
||||||
|
let text = if hex {
|
||||||
|
format!(" {i:02x}")
|
||||||
|
} else {
|
||||||
|
format!("{i:3}")
|
||||||
|
};
|
||||||
|
child.print(&text, standard, 3 * r, c * 8);
|
||||||
|
let block = Style::default().fg(color(i)).bg(bg);
|
||||||
|
child.print("\u{2588}\u{2588}\u{2588}\u{2588}", block, 3 * r, 4 + c * 8);
|
||||||
|
child.print(
|
||||||
|
"\u{2588}\u{2588}\u{2588}\u{2588}",
|
||||||
|
block,
|
||||||
|
3 * r + 1,
|
||||||
|
4 + c * 8,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
next_start += child.area.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code sample.
|
||||||
|
{
|
||||||
|
let child = win.child(x_off, next_start, width, 24);
|
||||||
|
let bold = Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD);
|
||||||
|
let c = |i: usize| Style::default().fg(color(i)).bg(bg);
|
||||||
|
let (color1, color2, color3, color4, color5, color6) =
|
||||||
|
(c(1), c(2), c(3), c(4), c(5), c(6));
|
||||||
|
let color6ul = color6.add_modifier(Modifier::UNDERLINED);
|
||||||
|
let (color10, color12, color238) = (c(10), c(12), c(238));
|
||||||
|
let _ = standard_underline;
|
||||||
|
child.fill(standard);
|
||||||
|
|
||||||
|
let rule = |row: u16, head: &str| {
|
||||||
|
child.print_segments(&[(head, color238)], row, 2);
|
||||||
|
if child.area.width > 10 {
|
||||||
|
for col in 10..child.area.width {
|
||||||
|
child.print("\u{2500}", color238, row, col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
("\u{2192}", color2),
|
||||||
|
(" ", standard),
|
||||||
|
("bat", color4),
|
||||||
|
(" ", standard),
|
||||||
|
("ziggzagg.zig", color6ul),
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
rule(
|
||||||
|
1,
|
||||||
|
"\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{252c}",
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" \u{2502} ", color238),
|
||||||
|
("File: ", standard),
|
||||||
|
("ziggzagg.zig", bold),
|
||||||
|
],
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
rule(
|
||||||
|
3,
|
||||||
|
"\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{253c}",
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 1 \u{2502} ", color238),
|
||||||
|
("const", color5),
|
||||||
|
(" std ", standard),
|
||||||
|
("= @import", color5),
|
||||||
|
("(", standard),
|
||||||
|
("\"std\"", color10),
|
||||||
|
(");", standard),
|
||||||
|
],
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(&[(" 2 \u{2502}", color238)], 5, 2);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 3 \u{2502} ", color238),
|
||||||
|
("pub ", color5),
|
||||||
|
("fn ", color12),
|
||||||
|
("main", color2),
|
||||||
|
("() ", standard),
|
||||||
|
("!", color5),
|
||||||
|
("void", color12),
|
||||||
|
(" {", standard),
|
||||||
|
],
|
||||||
|
6,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 4 \u{2502} ", color238),
|
||||||
|
("const ", color5),
|
||||||
|
("stdout ", standard),
|
||||||
|
("=", color5),
|
||||||
|
(" std.Io.getStdOut().writer();", standard),
|
||||||
|
],
|
||||||
|
7,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 5 \u{2502} ", color238),
|
||||||
|
("var ", color5),
|
||||||
|
("i:", standard),
|
||||||
|
(" usize", color12),
|
||||||
|
(" =", color5),
|
||||||
|
(" 1", color4),
|
||||||
|
(";", standard),
|
||||||
|
],
|
||||||
|
8,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 6 \u{2502} ", color238),
|
||||||
|
("while ", color5),
|
||||||
|
("(i ", standard),
|
||||||
|
("<= ", color5),
|
||||||
|
("16", color4),
|
||||||
|
(") : (i ", standard),
|
||||||
|
("+= ", color5),
|
||||||
|
("1", color4),
|
||||||
|
(") {", standard),
|
||||||
|
],
|
||||||
|
9,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 7 \u{2502} ", color238),
|
||||||
|
("if ", color5),
|
||||||
|
("(i ", standard),
|
||||||
|
("% ", color5),
|
||||||
|
("15 ", color4),
|
||||||
|
("== ", color5),
|
||||||
|
("0", color4),
|
||||||
|
(") {", standard),
|
||||||
|
],
|
||||||
|
10,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 8 \u{2502} ", color238),
|
||||||
|
("try ", color5),
|
||||||
|
("stdout.writeAll(", standard),
|
||||||
|
("\"ZiggZagg", color10),
|
||||||
|
("\\n", color12),
|
||||||
|
("\"", color10),
|
||||||
|
(");", standard),
|
||||||
|
],
|
||||||
|
11,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 9 \u{2502} ", color238),
|
||||||
|
("} ", standard),
|
||||||
|
("else if ", color5),
|
||||||
|
("(i ", standard),
|
||||||
|
("% ", color5),
|
||||||
|
("3 ", color4),
|
||||||
|
("== ", color5),
|
||||||
|
("0", color4),
|
||||||
|
(") {", standard),
|
||||||
|
],
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 10 \u{2502} ", color238),
|
||||||
|
("try ", color5),
|
||||||
|
("stdout.writeAll(", standard),
|
||||||
|
("\"Zigg", color10),
|
||||||
|
("\\n", color12),
|
||||||
|
("\"", color10),
|
||||||
|
(");", standard),
|
||||||
|
],
|
||||||
|
13,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 11 \u{2502} ", color238),
|
||||||
|
("} ", standard),
|
||||||
|
("else if ", color5),
|
||||||
|
("(i ", standard),
|
||||||
|
("% ", color5),
|
||||||
|
("5 ", color4),
|
||||||
|
("== ", color5),
|
||||||
|
("0", color4),
|
||||||
|
(") {", standard),
|
||||||
|
],
|
||||||
|
14,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 12 \u{2502} ", color238),
|
||||||
|
("try ", color5),
|
||||||
|
("stdout.writeAll(", standard),
|
||||||
|
("\"Zagg", color10),
|
||||||
|
("\\n", color12),
|
||||||
|
("\"", color10),
|
||||||
|
(");", standard),
|
||||||
|
],
|
||||||
|
15,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 13 \u{2502} ", color238),
|
||||||
|
("} ", standard),
|
||||||
|
("else ", color5),
|
||||||
|
("{", standard),
|
||||||
|
],
|
||||||
|
16,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
(" 14 \u{2502} ", color238),
|
||||||
|
("try ", color5),
|
||||||
|
("stdout.print(\"{d}\\n\", .{i})", standard_selection),
|
||||||
|
(";", cursor),
|
||||||
|
],
|
||||||
|
17,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[(" 15 \u{2502} ", color238), ("}", standard)],
|
||||||
|
18,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[(" 16 \u{2502} ", color238), ("}", standard)],
|
||||||
|
19,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(&[(" 17 \u{2502} ", color238), ("}", standard)], 20, 2);
|
||||||
|
rule(
|
||||||
|
21,
|
||||||
|
"\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2534}",
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
("ghostty ", color6),
|
||||||
|
("on ", standard),
|
||||||
|
("\u{e0a0} main ", color4),
|
||||||
|
("[+] ", color1),
|
||||||
|
("via ", standard),
|
||||||
|
("\u{e0a0} v0.13.0 ", color3),
|
||||||
|
("via ", standard),
|
||||||
|
("\u{f313} impure (ghostty-env)", color4),
|
||||||
|
],
|
||||||
|
22,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
child.print_segments(
|
||||||
|
&[
|
||||||
|
("\u{2726} ", color4),
|
||||||
|
("at ", standard),
|
||||||
|
("10:36:15 ", color3),
|
||||||
|
("\u{2192}", color2),
|
||||||
|
],
|
||||||
|
23,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
next_start += child.area.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if next_start < area.height {
|
||||||
|
let child = win.child(x_off, next_start, width, area.height - next_start);
|
||||||
|
child.fill(standard);
|
||||||
|
let mut words = LOREM_IPSUM.split([' ', '\n']);
|
||||||
|
let mut row: u16 = 1;
|
||||||
|
let mut col: u16 = 2;
|
||||||
|
while row < child.area.height {
|
||||||
|
let word = if let Some(w) = words.next() {
|
||||||
|
w
|
||||||
|
} else {
|
||||||
|
words = LOREM_IPSUM.split([' ', '\n']);
|
||||||
|
words.next().unwrap_or("")
|
||||||
|
};
|
||||||
|
let word_len = u16::try_from(word.len()).unwrap_or(u16::MAX);
|
||||||
|
if usize::from(col) + word.len() > usize::from(child.area.width) {
|
||||||
|
row += 1;
|
||||||
|
col = 2;
|
||||||
|
}
|
||||||
|
let style = match word {
|
||||||
|
"ipsum" => Style::default().fg(color(2)).bg(bg),
|
||||||
|
"consectetur" => standard_bold,
|
||||||
|
"reprehenderit" => standard_italic,
|
||||||
|
"Praesent" => standard_bold_italic,
|
||||||
|
"auctor" | "dui" | "erat" | "enim" | "odio" => {
|
||||||
|
// TODO: distinct underline styles (double, dashed,
|
||||||
|
// dotted, curly) are drawn as single underlines.
|
||||||
|
standard.add_modifier(Modifier::UNDERLINED)
|
||||||
|
}
|
||||||
|
_ => standard,
|
||||||
|
};
|
||||||
|
child.print(word, style, row, col);
|
||||||
|
col = col.saturating_add(word_len).saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Zig error name for display (`{}` of an error).
|
||||||
|
fn error_name(e: &ghostty_config::config::Error) -> String {
|
||||||
|
use ghostty_config::config::Error;
|
||||||
|
match e {
|
||||||
|
Error::Io(io) => match io.kind() {
|
||||||
|
std::io::ErrorKind::NotFound => "FileNotFound".to_string(),
|
||||||
|
std::io::ErrorKind::PermissionDenied => "AccessDenied".to_string(),
|
||||||
|
_ => io.to_string(),
|
||||||
|
},
|
||||||
|
e => e.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `resolveAutoThemePath`: `<config dir>/auto/theme.ghostty`.
|
||||||
|
pub fn resolve_auto_theme_path() -> Result<String, Error> {
|
||||||
|
let main_cfg_path = ghostty_config::file_load::preferred_default_file_path()
|
||||||
|
.map_err(|e| Error::Other(e.to_string()))?;
|
||||||
|
let base_dir = std::path::Path::new(&main_cfg_path)
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| Error::Other("BadPathName".to_string()))?;
|
||||||
|
Ok(base_dir
|
||||||
|
.join("auto")
|
||||||
|
.join("theme.ghostty")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `writeAutoThemeFile`.
|
||||||
|
pub fn write_auto_theme_file(path: Option<&str>, theme_name: &str) -> Result<(), Error> {
|
||||||
|
let auto_path = match path {
|
||||||
|
Some(p) => p.to_string(),
|
||||||
|
None => resolve_auto_theme_path()?,
|
||||||
|
};
|
||||||
|
if let Some(dir) = std::path::Path::new(&auto_path).parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
std::fs::write(&auto_path, format!("theme = {theme_name}\n"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- A tiny window abstraction over a ratatui buffer (vaxis.Window) ----
|
||||||
|
|
||||||
|
/// Display width of a string.
|
||||||
|
fn gw(s: &str) -> u16 {
|
||||||
|
crate::tui::gwidth(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clipped, offset view onto the buffer (`vaxis.Window`). All coordinates
|
||||||
|
/// passed to its methods are relative to the window.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Win<'a, 'b> {
|
||||||
|
buf: &'a RefCell<&'b mut Buffer>,
|
||||||
|
area: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Win<'_, '_> {
|
||||||
|
/// `Window.clear`.
|
||||||
|
fn clear(&self) {
|
||||||
|
self.reset_area(self.area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_area(&self, area: Rect) {
|
||||||
|
let mut buf = self.buf.borrow_mut();
|
||||||
|
for y in area.top()..area.bottom() {
|
||||||
|
for x in area.left()..area.right() {
|
||||||
|
if let Some(c) = buf.cell_mut((x, y)) {
|
||||||
|
c.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Window.fill` with a blank cell of the given style.
|
||||||
|
fn fill(&self, style: Style) {
|
||||||
|
let mut buf = self.buf.borrow_mut();
|
||||||
|
for y in self.area.top()..self.area.bottom() {
|
||||||
|
for x in self.area.left()..self.area.right() {
|
||||||
|
if let Some(c) = buf.cell_mut((x, y)) {
|
||||||
|
c.reset();
|
||||||
|
c.set_symbol(" ");
|
||||||
|
c.set_style(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Window.child` (without border); clipped to this window.
|
||||||
|
fn child(&self, x_off: u16, y_off: u16, width: u16, height: u16) -> Self {
|
||||||
|
let x = self.area.x.saturating_add(x_off).min(self.area.right());
|
||||||
|
let y = self.area.y.saturating_add(y_off).min(self.area.bottom());
|
||||||
|
let w = width.min(self.area.right() - x);
|
||||||
|
let h = height.min(self.area.bottom() - y);
|
||||||
|
Win {
|
||||||
|
buf: self.buf,
|
||||||
|
area: Rect::new(x, y, w, h),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Window.child` with a border on all sides; returns the inner window.
|
||||||
|
fn child_border(&self, x_off: u16, y_off: u16, width: u16, height: u16, style: Style) -> Self {
|
||||||
|
let outer = self.child(x_off, y_off, width, height);
|
||||||
|
outer.fill(style);
|
||||||
|
let a = outer.area;
|
||||||
|
if a.width >= 2 && a.height >= 2 {
|
||||||
|
let mut buf = self.buf.borrow_mut();
|
||||||
|
let mut put = |x: u16, y: u16, s: &str| {
|
||||||
|
if let Some(c) = buf.cell_mut((x, y)) {
|
||||||
|
c.set_symbol(s);
|
||||||
|
c.set_style(style);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for x in (a.left() + 1)..(a.right() - 1) {
|
||||||
|
put(x, a.top(), "\u{2500}");
|
||||||
|
put(x, a.bottom() - 1, "\u{2500}");
|
||||||
|
}
|
||||||
|
for y in (a.top() + 1)..(a.bottom() - 1) {
|
||||||
|
put(a.left(), y, "\u{2502}");
|
||||||
|
put(a.right() - 1, y, "\u{2502}");
|
||||||
|
}
|
||||||
|
put(a.left(), a.top(), "\u{256d}");
|
||||||
|
put(a.right() - 1, a.top(), "\u{256e}");
|
||||||
|
put(a.left(), a.bottom() - 1, "\u{2570}");
|
||||||
|
put(a.right() - 1, a.bottom() - 1, "\u{256f}");
|
||||||
|
}
|
||||||
|
outer.child(1, 1, a.width.saturating_sub(2), a.height.saturating_sub(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Window.printSegment` without wrapping (text is clipped). Returns the
|
||||||
|
/// column after the text.
|
||||||
|
fn print(&self, text: &str, style: Style, row: u16, col: u16) -> u16 {
|
||||||
|
if row >= self.area.height || col >= self.area.width {
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
let x = self.area.x + col;
|
||||||
|
let y = self.area.y + row;
|
||||||
|
let max = usize::from(self.area.width - col);
|
||||||
|
let (nx, _) = self.buf.borrow_mut().set_stringn(x, y, text, max, style);
|
||||||
|
nx - self.area.x
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Window.print`: several segments one after another.
|
||||||
|
fn print_segments(&self, segments: &[(&str, Style)], row: u16, col: u16) -> u16 {
|
||||||
|
let mut col = col;
|
||||||
|
for (text, style) in segments {
|
||||||
|
col = self.print(text, *style, row, col);
|
||||||
|
}
|
||||||
|
col
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- The terminal driver ----
|
||||||
|
|
||||||
|
/// Run the interactive preview (Zig: `preview`). Blocks until the user quits.
|
||||||
|
pub fn preview(themes: Vec<ThemeListElement>, theme_filter: ColorScheme) -> Result<(), Error> {
|
||||||
|
let mut app = Preview::new(themes, theme_filter);
|
||||||
|
let mut terminal = ratatui::try_init()?;
|
||||||
|
let result = run_loop(&mut terminal, &mut app);
|
||||||
|
ratatui::restore();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_loop(terminal: &mut ratatui::DefaultTerminal, app: &mut Preview) -> Result<(), Error> {
|
||||||
|
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
|
||||||
|
use crossterm::terminal::SetTitle;
|
||||||
|
|
||||||
|
{
|
||||||
|
let out = terminal.backend_mut();
|
||||||
|
crossterm::execute!(
|
||||||
|
out,
|
||||||
|
SetTitle("\u{1f47b} Ghostty Theme Preview \u{1f47b}"),
|
||||||
|
EnableMouseCapture
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = (|| -> Result<(), Error> {
|
||||||
|
while !app.should_quit {
|
||||||
|
terminal.draw(|f| draw_frame(app, f))?;
|
||||||
|
// Block for an event, then drain everything that is pending.
|
||||||
|
let first = event::read()?;
|
||||||
|
app.update(&first);
|
||||||
|
while event::poll(std::time::Duration::ZERO)? {
|
||||||
|
let ev = event::read()?;
|
||||||
|
app.update(&ev);
|
||||||
|
}
|
||||||
|
for effect in app.take_effects() {
|
||||||
|
match effect {
|
||||||
|
Effect::Copy(text) => {
|
||||||
|
let out = terminal.backend_mut();
|
||||||
|
write!(out, "\x1b]52;c;{}\x1b\\", base64(text.as_bytes()))?;
|
||||||
|
out.flush()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
let _ = crossterm::execute!(terminal.backend_mut(), DisableMouseCapture);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_frame(app: &mut Preview, f: &mut Frame<'_>) {
|
||||||
|
let cursor = app.draw(f.buffer_mut());
|
||||||
|
if let Some(pos) = cursor {
|
||||||
|
f.set_cursor_position(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
use ghostty_config::theme::Location;
|
||||||
|
|
||||||
|
fn make_themes(dir: &std::path::Path, names: &[(&str, &str)]) -> Vec<ThemeListElement> {
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.map(|(name, bg)| {
|
||||||
|
let path = dir.join(name);
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
format!("background = {bg}\nforeground = #eeeeee\npalette = 1=#ff0000\n"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
ThemeListElement {
|
||||||
|
location: Location::Resources,
|
||||||
|
path: path.to_string_lossy().into_owned(),
|
||||||
|
theme: (*name).to_string(),
|
||||||
|
rank: None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(code: KeyCode) -> Event {
|
||||||
|
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
|
||||||
|
}
|
||||||
|
fn ch(c: char) -> Event {
|
||||||
|
key(KeyCode::Char(c))
|
||||||
|
}
|
||||||
|
fn ctrl(c: char) -> Event {
|
||||||
|
Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app(tmp: &tempfile::TempDir) -> Preview {
|
||||||
|
let themes = make_themes(
|
||||||
|
tmp.path(),
|
||||||
|
&[
|
||||||
|
("alpha", "#000000"),
|
||||||
|
("beta", "#ffffff"),
|
||||||
|
("gamma-dark", "#101010"),
|
||||||
|
("delta-light", "#f0f0f0"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let mut p = Preview::new(themes, ColorScheme::All);
|
||||||
|
p.color_scheme = Scheme::Light;
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(p: &mut Preview, w: u16, h: u16) -> Buffer {
|
||||||
|
let mut buf = Buffer::empty(Rect::new(0, 0, w, h));
|
||||||
|
p.draw(&mut buf);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_text(buf: &Buffer, y: u16) -> String {
|
||||||
|
(0..buf.area.width)
|
||||||
|
.map(|x| buf[(x, y)].symbol().to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn navigation_and_quit() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let mut p = app(&tmp);
|
||||||
|
assert_eq!(4, p.filtered.len());
|
||||||
|
p.update(&ch('j'));
|
||||||
|
p.update(&key(KeyCode::Down));
|
||||||
|
assert_eq!(2, p.current);
|
||||||
|
p.update(&key(KeyCode::PageDown));
|
||||||
|
assert_eq!(3, p.current);
|
||||||
|
p.update(&ch('k'));
|
||||||
|
assert_eq!(2, p.current);
|
||||||
|
p.update(&key(KeyCode::PageUp));
|
||||||
|
assert_eq!(0, p.current);
|
||||||
|
p.update(&key(KeyCode::End));
|
||||||
|
assert_eq!(3, p.current);
|
||||||
|
p.update(&key(KeyCode::Home));
|
||||||
|
assert_eq!(0, p.current);
|
||||||
|
assert!(!p.should_quit);
|
||||||
|
p.update(&ch('q'));
|
||||||
|
assert!(p.should_quit);
|
||||||
|
|
||||||
|
let mut p = app(&tmp);
|
||||||
|
p.update(&ctrl('c'));
|
||||||
|
assert!(p.should_quit);
|
||||||
|
let mut p = app(&tmp);
|
||||||
|
p.update(&key(KeyCode::Esc));
|
||||||
|
assert!(p.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_cycles_and_filters() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let mut p = app(&tmp);
|
||||||
|
p.update(&ch('f'));
|
||||||
|
assert_eq!(ColorScheme::Dark, p.theme_filter);
|
||||||
|
let names: Vec<_> = p
|
||||||
|
.filtered
|
||||||
|
.iter()
|
||||||
|
.map(|&i| p.themes[i].theme.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(vec!["alpha", "gamma-dark"], names);
|
||||||
|
p.update(&ch('f'));
|
||||||
|
assert_eq!(ColorScheme::Light, p.theme_filter);
|
||||||
|
assert_eq!(2, p.filtered.len());
|
||||||
|
p.update(&ch('f'));
|
||||||
|
assert_eq!(ColorScheme::All, p.theme_filter);
|
||||||
|
assert_eq!(4, p.filtered.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_mode() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let mut p = app(&tmp);
|
||||||
|
p.update(&ch('/'));
|
||||||
|
assert_eq!(Mode::Search, p.mode);
|
||||||
|
p.update(&ch('G'));
|
||||||
|
p.update(&ch('a'));
|
||||||
|
p.update(&ch('m'));
|
||||||
|
assert_eq!("Gam", p.text_input.text);
|
||||||
|
assert_eq!(1, p.filtered.len());
|
||||||
|
assert_eq!("gamma-dark", p.themes[p.filtered[0]].theme);
|
||||||
|
p.update(&key(KeyCode::Backspace));
|
||||||
|
p.update(&key(KeyCode::Backspace));
|
||||||
|
p.update(&key(KeyCode::Backspace));
|
||||||
|
assert_eq!(4, p.filtered.len());
|
||||||
|
p.update(&ch('l'));
|
||||||
|
p.update(&ch('t'));
|
||||||
|
assert_eq!(1, p.filtered.len()); // "delta-light" is the only match for "lt"
|
||||||
|
p.update(&ctrl('x'));
|
||||||
|
assert_eq!("", p.text_input.text);
|
||||||
|
assert_eq!(4, p.filtered.len());
|
||||||
|
p.update(&key(KeyCode::Enter));
|
||||||
|
assert_eq!(Mode::Normal, p.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_toggle_and_help_mode() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let mut p = app(&tmp);
|
||||||
|
p.update(&ch('x'));
|
||||||
|
assert!(p.hex);
|
||||||
|
p.update(&ch('d'));
|
||||||
|
assert!(!p.hex);
|
||||||
|
p.update(&key(KeyCode::F(1)));
|
||||||
|
assert_eq!(Mode::Help, p.mode);
|
||||||
|
// Keys are modal: 'j' does nothing in help.
|
||||||
|
p.update(&ch('j'));
|
||||||
|
assert_eq!(0, p.current);
|
||||||
|
p.update(&key(KeyCode::Esc));
|
||||||
|
assert_eq!(Mode::Normal, p.mode);
|
||||||
|
assert!(!p.should_quit);
|
||||||
|
p.update(&ch('?'));
|
||||||
|
assert_eq!(Mode::Help, p.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_and_save() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let mut p = app(&tmp);
|
||||||
|
p.update(&ch('c'));
|
||||||
|
p.update(&Event::Key(KeyEvent::new(
|
||||||
|
KeyCode::Char('C'),
|
||||||
|
KeyModifiers::SHIFT,
|
||||||
|
)));
|
||||||
|
let effects = p.take_effects();
|
||||||
|
assert_eq!(Effect::Copy("alpha".into()), effects[0]);
|
||||||
|
assert_eq!(
|
||||||
|
Effect::Copy(tmp.path().join("alpha").to_string_lossy().into_owned()),
|
||||||
|
effects[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
let auto = tmp.path().join("cfg/auto/theme.ghostty");
|
||||||
|
p.auto_theme_path = Some(auto.to_string_lossy().into_owned());
|
||||||
|
p.update(&key(KeyCode::Enter));
|
||||||
|
assert_eq!(Mode::Save, p.mode);
|
||||||
|
p.update(&ch('w'));
|
||||||
|
assert_eq!("theme = alpha\n", std::fs::read_to_string(&auto).unwrap());
|
||||||
|
p.update(&key(KeyCode::Esc));
|
||||||
|
assert_eq!(Mode::Normal, p.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_layout() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let mut p = app(&tmp);
|
||||||
|
p.update(&ch('j'));
|
||||||
|
let buf = render(&mut p, 120, 60);
|
||||||
|
// List on the left: selected item has the marker and the arrow at col 30.
|
||||||
|
let r1 = row_text(&buf, 1);
|
||||||
|
assert!(r1.starts_with("\u{276f} beta"), "{r1:?}");
|
||||||
|
assert!(r1.contains("\u{276e}"), "{r1:?}");
|
||||||
|
assert!(row_text(&buf, 0).starts_with(" alpha"));
|
||||||
|
// Preview: theme name centered in the header, palette numbers, code sample.
|
||||||
|
let preview_header = row_text(&buf, 1);
|
||||||
|
assert!(preview_header.contains("beta"));
|
||||||
|
let all: String = (0..60)
|
||||||
|
.map(|y| row_text(&buf, y))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(all.contains(" 0"));
|
||||||
|
assert!(all.contains("ziggzagg.zig"));
|
||||||
|
assert!(all.contains("const std = @import(\"std\");"));
|
||||||
|
assert!(all.contains("Lorem ipsum"));
|
||||||
|
// The palette swatches use the theme palette color 1.
|
||||||
|
let swatch = (32..120)
|
||||||
|
.find(|&x| buf[(x, 4)].fg == Color::Rgb(255, 0, 0))
|
||||||
|
.is_some();
|
||||||
|
assert!(swatch);
|
||||||
|
// Background of the preview is the theme background.
|
||||||
|
assert_eq!(Color::Rgb(255, 255, 255), buf[(100, 0)].bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_help_and_save_and_search_overlays() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let mut p = app(&tmp);
|
||||||
|
p.update(&ch('?'));
|
||||||
|
let buf = render(&mut p, 120, 40);
|
||||||
|
let all: String = (0..40)
|
||||||
|
.map(|y| row_text(&buf, y))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(all.contains("Cycle through theme filters."));
|
||||||
|
assert!(all.contains('\u{256d}') && all.contains('\u{256f}'));
|
||||||
|
|
||||||
|
p.update(&key(KeyCode::Esc));
|
||||||
|
p.update(&key(KeyCode::Enter));
|
||||||
|
let buf = render(&mut p, 120, 40);
|
||||||
|
let all: String = (0..40)
|
||||||
|
.map(|y| row_text(&buf, y))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(all.contains("theme = alpha"));
|
||||||
|
assert!(all.contains("config-file = ?auto/theme.ghostty"));
|
||||||
|
|
||||||
|
p.update(&key(KeyCode::Esc));
|
||||||
|
p.update(&ch('/'));
|
||||||
|
p.update(&ch('b'));
|
||||||
|
let mut buf = Buffer::empty(Rect::new(0, 0, 120, 40));
|
||||||
|
let cursor = p.draw(&mut buf);
|
||||||
|
assert!(cursor.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_filter_shows_no_theme_found() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let mut p = app(&tmp);
|
||||||
|
p.update(&ch('/'));
|
||||||
|
p.update(&ch('z'));
|
||||||
|
p.update(&ch('z'));
|
||||||
|
assert_eq!(0, p.filtered.len());
|
||||||
|
let buf = render(&mut p, 80, 20);
|
||||||
|
assert!(row_text(&buf, 9).contains("No theme found!"));
|
||||||
|
// Navigation does not panic with an empty list.
|
||||||
|
p.update(&key(KeyCode::Enter));
|
||||||
|
p.update(&key(KeyCode::Esc));
|
||||||
|
p.update(&key(KeyCode::End));
|
||||||
|
p.update(&ch('j'));
|
||||||
|
p.update(&ch('c'));
|
||||||
|
assert_eq!(0, p.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unreadable_theme_shows_error() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let mut p = app(&tmp);
|
||||||
|
std::fs::remove_file(tmp.path().join("alpha")).unwrap();
|
||||||
|
let buf = render(&mut p, 120, 30);
|
||||||
|
let all: String = (0..30)
|
||||||
|
.map(|y| row_text(&buf, y))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(all.contains("Unable to open alpha from:"), "{all}");
|
||||||
|
assert!(all.contains("FileNotFound"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mouse_click_selects_and_wheel_scrolls() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let mut p = app(&tmp);
|
||||||
|
let mouse = |kind, row| {
|
||||||
|
Event::Mouse(MouseEvent {
|
||||||
|
kind,
|
||||||
|
column: 3,
|
||||||
|
row,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
p.update(&mouse(MouseEventKind::Up(MouseButton::Left), 2));
|
||||||
|
render(&mut p, 120, 30);
|
||||||
|
assert_eq!(2, p.current);
|
||||||
|
p.update(&mouse(MouseEventKind::ScrollUp, 0));
|
||||||
|
render(&mut p, 120, 30);
|
||||||
|
assert_eq!(1, p.current);
|
||||||
|
p.update(&mouse(MouseEventKind::ScrollDown, 0));
|
||||||
|
render(&mut p, 120, 30);
|
||||||
|
assert_eq!(2, p.current);
|
||||||
|
// Click below the last theme does nothing.
|
||||||
|
p.update(&mouse(MouseEventKind::Up(MouseButton::Left), 20));
|
||||||
|
render(&mut p, 120, 30);
|
||||||
|
assert_eq!(2, p.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_scrolls_with_selection() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let names: Vec<String> = (0..40).map(|i| format!("theme{i:02}")).collect();
|
||||||
|
let refs: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), "#000000")).collect();
|
||||||
|
let themes = make_themes(tmp.path(), &refs);
|
||||||
|
let mut p = Preview::new(themes, ColorScheme::All);
|
||||||
|
render(&mut p, 100, 10);
|
||||||
|
p.update(&key(KeyCode::PageDown));
|
||||||
|
let buf = render(&mut p, 100, 10);
|
||||||
|
assert_eq!(20, p.current);
|
||||||
|
assert_eq!(11, p.window);
|
||||||
|
assert!(row_text(&buf, 9).starts_with("\u{276f} theme20"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_is_subsequence() {
|
||||||
|
assert!(rank("Catppuccin Mocha", &["cat", "moc"]).is_some());
|
||||||
|
assert!(rank("Catppuccin Mocha", &["cpm"]).is_some());
|
||||||
|
assert!(rank("Catppuccin Mocha", &["xyz"]).is_none());
|
||||||
|
assert!(rank("abc", &["cba"]).is_none());
|
||||||
|
assert!(rank("abc", &[]).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_input_editing() {
|
||||||
|
let mut t = TextInput::default();
|
||||||
|
for c in "héllo wörld".chars() {
|
||||||
|
t.update(&KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
||||||
|
}
|
||||||
|
assert_eq!("héllo wörld", t.text);
|
||||||
|
t.update(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||||||
|
t.update(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||||
|
assert_eq!("héllo wörd", t.text);
|
||||||
|
t.update(&KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
|
||||||
|
assert_eq!("héllo d", t.text);
|
||||||
|
t.update(&KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
|
||||||
|
t.update(&KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
|
||||||
|
assert_eq!("éllo d", t.text);
|
||||||
|
t.update(&KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
||||||
|
assert_eq!("", t.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base64_encoding() {
|
||||||
|
assert_eq!("", base64(b""));
|
||||||
|
assert_eq!("Zg==", base64(b"f"));
|
||||||
|
assert_eq!("Zm8=", base64(b"fo"));
|
||||||
|
assert_eq!("Zm9v", base64(b"foo"));
|
||||||
|
assert_eq!("Zm9vYmFy", base64(b"foobar"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras hendrerit aliquet
|
||||||
|
turpis non dictum. Mauris pulvinar nisl sit amet dui cursus tempus. Pellentesque
|
||||||
|
ut dui justo. Etiam quis magna sagittis nisi pretium consequat vitae ut nisl.
|
||||||
|
Sed at metus id odio pulvinar sodales. Vestibulum sollicitudin, sem id tristique
|
||||||
|
vestibulum, neque ante dictum tortor, in convallis mi enim ac lorem. Suspendisse
|
||||||
|
orci ex, ullamcorper sed leo vitae, mattis egestas nisl. Morbi id est vel
|
||||||
|
ipsum mollis convallis vel at mauris. Duis vehicula facilisis placerat. Aliquam
|
||||||
|
venenatis auctor ipsum vel elementum. Proin ac tincidunt lacus. Sed facilisis
|
||||||
|
tellus ullamcorper bibendum lobortis. Pellentesque porta, lacus quis efficitur
|
||||||
|
pulvinar, sem mi varius ante, sed finibus diam ante et risus.
|
||||||
|
|
||||||
|
Morbi ut sollicitudin justo. Nulla mattis mi ac mauris tincidunt tempor. Morbi
|
||||||
|
vel gravida erat. Ut eu risus quis nisi facilisis aliquet varius id orci.
|
||||||
|
Pellentesque tortor diam, porttitor nec urna nec, convallis consectetur dui.
|
||||||
|
Vestibulum et hendrerit ipsum. Morbi pharetra dictum turpis in elementum. Ut
|
||||||
|
nec volutpat nunc, at venenatis leo. Morbi eget nulla luctus, tincidunt dui vel,
|
||||||
|
cursus urna. Maecenas ac pellentesque nisi. Quisque ut lorem porta, eleifend
|
||||||
|
metus id, pellentesque tellus.
|
||||||
|
|
||||||
|
Vivamus gravida convallis felis, at hendrerit dolor. Vestibulum tincidunt id
|
||||||
|
augue quis hendrerit. Praesent venenatis elit quis posuere gravida. Praesent
|
||||||
|
at massa a purus maximus tempus. Proin dui leo, feugiat et erat ac, tincidunt
|
||||||
|
aliquam risus. Aenean rutrum hendrerit turpis, sit amet consectetur justo porta
|
||||||
|
non. Sed auctor justo elit, sed mollis odio ullamcorper nec. Pellentesque ac
|
||||||
|
hendrerit tortor. Praesent quis viverra dui, sit amet imperdiet magna.
|
||||||
|
|
||||||
|
Mauris iaculis maximus felis, aliquet vehicula neque sagittis nec. Duis
|
||||||
|
convallis purus enim, vel scelerisque purus dignissim eu. Donec congue sapien
|
||||||
|
a neque rhoncus, sit amet accumsan libero tincidunt. Proin vitae placerat urna.
|
||||||
|
Donec dolor sapien, fringilla sed semper sit amet, sollicitudin sit amet orci.
|
||||||
|
Mauris maximus convallis vehicula. Aliquam urna ipsum, fermentum ac iaculis vel,
|
||||||
|
blandit eget lorem. Sed enim ante, sodales a diam in, convallis interdum quam.
|
||||||
|
Duis non urna risus. Proin ac neque at risus ullamcorper mattis eu vel nunc.
|
||||||
|
Proin et ipsum euismod, ullamcorper justo et, imperdiet est. Curabitur quis
|
||||||
|
arcu faucibus, bibendum nisl nec, hendrerit sapien. Curabitur vitae ante risus.
|
||||||
|
Praesent eget sagittis tortor.
|
||||||
|
|
||||||
|
Mauris aliquam nec nibh eu congue. Nullam congue auctor vestibulum. Donec
|
||||||
|
posuere sapien nec massa efficitur tincidunt. Vestibulum ante ipsum primis in
|
||||||
|
faucibus orci luctus et ultrices posuere cubilia curae; Proin molestie, nisl
|
||||||
|
in tincidunt condimentum, ante metus fermentum felis, ac molestie lacus dui vel
|
||||||
|
dolor. Donec ornare laoreet posuere. Etiam id tincidunt ante. Maecenas semper
|
||||||
|
diam ac tortor facilisis egestas. Nam eu bibendum nisl. Integer tempor nisl nec
|
||||||
|
ex consectetur, quis lobortis enim finibus. Sed ac erat posuere, fermentum metus
|
||||||
|
sed, suscipit nisl.
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//! `+new-window` (ZIG/src/cli/new_window.zig).
|
||||||
|
//!
|
||||||
|
//! DEFERRED: sending the IPC needs the GTK app (`apprt.App.performIpc`).
|
||||||
|
//! The option parsing (including the manual `+new-window` argument gathering
|
||||||
|
//! hook) is ported; running the action reports that it is not available.
|
||||||
|
|
||||||
|
use ghostty_config::cli_args::{ArgError, ArgIter, CliTarget};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
/// Options of `+new-window`.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Options {
|
||||||
|
/// If set, open up a new window in a custom instance of Ghostty.
|
||||||
|
pub class: Option<String>,
|
||||||
|
|
||||||
|
/// Did the user specify a `--working-directory` argument on the command line?
|
||||||
|
pub working_directory_seen: bool,
|
||||||
|
|
||||||
|
/// All of the arguments after `+new-window`. They will be sent to Ghostty
|
||||||
|
/// for processing.
|
||||||
|
pub arguments: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ascii_ws(c: char) -> bool {
|
||||||
|
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Options {
|
||||||
|
fn check_arg(&mut self, arg: &str) -> Result<Option<String>, ArgError> {
|
||||||
|
if let Some(rest) = arg.strip_prefix("--class=") {
|
||||||
|
self.class = Some(rest.trim_matches(is_ascii_ws).to_string());
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = arg.strip_prefix("--working-directory=") {
|
||||||
|
let stripped = rest.trim_matches(is_ascii_ws);
|
||||||
|
if stripped == "home" || stripped == "inherit" {
|
||||||
|
return Ok(Some(arg.to_string()));
|
||||||
|
}
|
||||||
|
let expanded =
|
||||||
|
ghostty_os::expand_home(stripped).map_err(|e| ArgError::Other(e.to_string()))?;
|
||||||
|
let realpath =
|
||||||
|
std::fs::canonicalize(&expanded).map_err(|e| ArgError::Other(e.to_string()))?;
|
||||||
|
self.working_directory_seen = true;
|
||||||
|
return Ok(Some(format!(
|
||||||
|
"--working-directory={}",
|
||||||
|
realpath.to_string_lossy()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(arg.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliTarget for Options {
|
||||||
|
fn parse_into_field(&mut self, _key: &str, _value: Option<&str>) -> Result<(), ArgError> {
|
||||||
|
Err(ArgError::InvalidField)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manual parse hook, collect all of the arguments after `+new-window`.
|
||||||
|
fn parse_manually_hook(&mut self, arg: &str, iter: &mut dyn ArgIter) -> Result<bool, ArgError> {
|
||||||
|
let mut e_seen = arg == "-e";
|
||||||
|
|
||||||
|
// Include the argument that triggered the manual parse hook.
|
||||||
|
if let Some(a) = self.check_arg(arg)? {
|
||||||
|
self.arguments.push(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather up the rest of the arguments to use as the command.
|
||||||
|
while let Some(param) = iter.next_arg() {
|
||||||
|
if e_seen {
|
||||||
|
self.arguments.push(param);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if param == "-e" {
|
||||||
|
e_seen = true;
|
||||||
|
self.arguments.push(param);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(a) = self.check_arg(¶m)? {
|
||||||
|
self.arguments.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_help(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn help(&mut self) -> Result<(), ArgError> {
|
||||||
|
Err(ArgError::other(crate::error::HELP_ERROR_NAME))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `new-window` will use native platform IPC to open up a new window in a
|
||||||
|
/// running instance of Ghostty.
|
||||||
|
///
|
||||||
|
/// If the `--class` flag is not set, the `new-window` command will try and
|
||||||
|
/// connect to a running instance of Ghostty based on what optimizations the
|
||||||
|
/// Ghostty CLI was compiled with. Otherwise the `new-window` command will try
|
||||||
|
/// and contact a running Ghostty instance that was configured with the same
|
||||||
|
/// `class` as was given on the command line.
|
||||||
|
///
|
||||||
|
/// All of the arguments after the `+new-window` argument (except for the
|
||||||
|
/// `--class` flag) will be sent to the remote Ghostty instance and will be
|
||||||
|
/// parsed as command line flags. These flags will override certain settings
|
||||||
|
/// when creating the first surface in the new window. Currently, only
|
||||||
|
/// `--working-directory`, `--command`, and `--title` are supported. `-e` will
|
||||||
|
/// also work as an alias for `--command`, except that if `-e` is found on the
|
||||||
|
/// command line all following arguments will become part of the command and no
|
||||||
|
/// more arguments will be parsed for configuration settings.
|
||||||
|
///
|
||||||
|
/// If `--working-directory` is found on the command line and is a relative
|
||||||
|
/// path (i.e. doesn't start with `/`) it will be resolved to an absolute path
|
||||||
|
/// relative to the current working directory that the `ghostty +new-window`
|
||||||
|
/// command is run from. `~/` prefixes will also be expanded to the user's home
|
||||||
|
/// directory.
|
||||||
|
///
|
||||||
|
/// If `--working-directory` is _not_ found on the command line, the working
|
||||||
|
/// directory that `ghostty +new-window` is run from will be passed to Ghostty.
|
||||||
|
///
|
||||||
|
/// GTK uses an application ID to identify instances of applications. If Ghostty
|
||||||
|
/// is compiled with release optimizations, the default application ID will be
|
||||||
|
/// `com.mitchellh.ghostty`. If Ghostty is compiled with debug optimizations,
|
||||||
|
/// the default application ID will be `com.mitchellh.ghostty-debug`. The
|
||||||
|
/// `class` configuration entry can be used to set up a custom application
|
||||||
|
/// ID. The class name must follow the requirements defined [in the GTK
|
||||||
|
/// documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html)
|
||||||
|
/// or it will be ignored and Ghostty will use the default as defined above.
|
||||||
|
///
|
||||||
|
/// On GTK, D-Bus activation must be properly configured. Ghostty does not need
|
||||||
|
/// to be running for this to open a new window, making it suitable for binding
|
||||||
|
/// to keys in your window manager (if other methods for configuring global
|
||||||
|
/// shortcuts are unavailable). D-Bus will handle launching a new instance
|
||||||
|
/// of Ghostty if it is not already running. See the Ghostty website for
|
||||||
|
/// information on properly configuring D-Bus activation.
|
||||||
|
///
|
||||||
|
/// Only supported on GTK.
|
||||||
|
///
|
||||||
|
/// Flags:
|
||||||
|
///
|
||||||
|
/// * `--class=<class>`: If set, open up a new window in a custom instance of
|
||||||
|
/// Ghostty. The class must be a valid GTK application ID.
|
||||||
|
///
|
||||||
|
/// * `--command`: The command to be executed in the first surface of the new window.
|
||||||
|
///
|
||||||
|
/// * `--working-directory=<directory>`: The working directory to pass to Ghostty.
|
||||||
|
///
|
||||||
|
/// * `--title`: A title that will override the title of the first surface in
|
||||||
|
/// the new window. The title override may be edited or removed later.
|
||||||
|
///
|
||||||
|
/// * `-e`: Any arguments after this will be interpreted as a command to
|
||||||
|
/// execute inside the first surface of the new window instead of the
|
||||||
|
/// default command.
|
||||||
|
///
|
||||||
|
/// Available since: 1.2.0
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
let mut iter = ghostty_config::cli_args::ArgsIterator::new(args.iter().skip(1).cloned());
|
||||||
|
match ghostty_config::cli_args::parse(&mut opts, &mut iter) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(ArgError::Other(n)) if n == crate::error::HELP_ERROR_NAME => {
|
||||||
|
return Err(Error::ActionHelpRequested);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Error parsing args: {err}");
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.working_directory_seen {
|
||||||
|
// This should be inserted at the beginning of the list, just in case `-e` was used.
|
||||||
|
let wd = std::fs::canonicalize(".")?;
|
||||||
|
opts.arguments
|
||||||
|
.insert(0, format!("--working-directory={}", wd.to_string_lossy()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(integration): send the `new_window` IPC (class: `opts.class` or
|
||||||
|
// auto-detected, arguments: `opts.arguments`) via the GTK apprt's
|
||||||
|
// `performIpc`.
|
||||||
|
Err(Error::NotYetAvailable("new-window"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
use ghostty_config::cli_args::{VecIterator, parse};
|
||||||
|
|
||||||
|
fn parse_str(s: &str) -> Options {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
let mut it = VecIterator::from_str_args(s);
|
||||||
|
parse(&mut opts, &mut it).unwrap();
|
||||||
|
opts
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gathers_arguments_and_class() {
|
||||||
|
let o = parse_str("--class=foo.bar --title=hi --command=ls");
|
||||||
|
assert_eq!(Some("foo.bar".to_string()), o.class);
|
||||||
|
assert_eq!(vec!["--title=hi", "--command=ls"], o.arguments);
|
||||||
|
assert!(!o.working_directory_seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everything_after_e_is_command() {
|
||||||
|
let o = parse_str("--title=x -e --class=nope --working-directory=/nonexistent");
|
||||||
|
assert_eq!(None, o.class);
|
||||||
|
assert_eq!(
|
||||||
|
vec![
|
||||||
|
"--title=x",
|
||||||
|
"-e",
|
||||||
|
"--class=nope",
|
||||||
|
"--working-directory=/nonexistent"
|
||||||
|
],
|
||||||
|
o.arguments
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn working_directory_is_resolved() {
|
||||||
|
let o = parse_str("--working-directory=/");
|
||||||
|
assert!(o.working_directory_seen);
|
||||||
|
assert_eq!(vec!["--working-directory=/"], o.arguments);
|
||||||
|
let o = parse_str("--working-directory=home");
|
||||||
|
assert!(!o.working_directory_seen);
|
||||||
|
assert_eq!(vec!["--working-directory=home"], o.arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_is_gathered_not_special() {
|
||||||
|
// Like Zig, the manual hook runs before the help check.
|
||||||
|
let o = parse_str("--help");
|
||||||
|
assert_eq!(vec!["--help"], o.arguments);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//! Helpers shared by the action `Options` structs.
|
||||||
|
|
||||||
|
use ghostty_config::cli_args::{self, ArgsIterator, CliTarget};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
/// Implements [`CliTarget`] for a struct declared with `config_struct!` and
|
||||||
|
/// gives it a `Default`. Pass `help` to enable `-h`/`--help` (the Zig
|
||||||
|
/// `pub fn help` decl); structs without it treat `--help` as an ordinary
|
||||||
|
/// (unknown or declared) field.
|
||||||
|
macro_rules! cli_options {
|
||||||
|
($name:ident, help, diagnostics) => {
|
||||||
|
cli_options!(@base $name);
|
||||||
|
impl ::ghostty_config::cli_args::CliTarget for $name {
|
||||||
|
fn parse_into_field(
|
||||||
|
&mut self,
|
||||||
|
key: &str,
|
||||||
|
value: ::core::option::Option<&str>,
|
||||||
|
) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> {
|
||||||
|
self.parse_field(key, value)
|
||||||
|
}
|
||||||
|
fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> {
|
||||||
|
Self::valid_values_for(key)
|
||||||
|
}
|
||||||
|
fn diagnostics_mut(
|
||||||
|
&mut self,
|
||||||
|
) -> ::core::option::Option<&mut ::ghostty_config::diagnostics::DiagnosticList> {
|
||||||
|
::core::option::Option::Some(&mut self.diagnostics)
|
||||||
|
}
|
||||||
|
fn supports_help(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn help(&mut self) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> {
|
||||||
|
::core::result::Result::Err(::ghostty_config::cli_args::ArgError::other(
|
||||||
|
$crate::error::HELP_ERROR_NAME,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
($name:ident, help) => {
|
||||||
|
cli_options!(@base $name);
|
||||||
|
impl ::ghostty_config::cli_args::CliTarget for $name {
|
||||||
|
fn parse_into_field(
|
||||||
|
&mut self,
|
||||||
|
key: &str,
|
||||||
|
value: ::core::option::Option<&str>,
|
||||||
|
) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> {
|
||||||
|
self.parse_field(key, value)
|
||||||
|
}
|
||||||
|
fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> {
|
||||||
|
Self::valid_values_for(key)
|
||||||
|
}
|
||||||
|
fn supports_help(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn help(&mut self) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> {
|
||||||
|
::core::result::Result::Err(::ghostty_config::cli_args::ArgError::other(
|
||||||
|
$crate::error::HELP_ERROR_NAME,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
($name:ident) => {
|
||||||
|
cli_options!(@base $name);
|
||||||
|
impl ::ghostty_config::cli_args::CliTarget for $name {
|
||||||
|
fn parse_into_field(
|
||||||
|
&mut self,
|
||||||
|
key: &str,
|
||||||
|
value: ::core::option::Option<&str>,
|
||||||
|
) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> {
|
||||||
|
self.parse_field(key, value)
|
||||||
|
}
|
||||||
|
fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> {
|
||||||
|
Self::valid_values_for(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(@base $name:ident) => {
|
||||||
|
impl ::core::default::Default for $name {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::raw_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the CLI arguments `args` (the full argv, including `argv[0]`,
|
||||||
|
/// which is skipped; `+action` arguments are ignored) into `opts`
|
||||||
|
/// (Zig: `args.argsIterator` + `args.parse`).
|
||||||
|
pub fn parse_options<T: CliTarget>(opts: &mut T, args: &[String]) -> Result<(), Error> {
|
||||||
|
let mut iter = ArgsIterator::new(args.iter().skip(1).cloned());
|
||||||
|
cli_args::parse(opts, &mut iter)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The options of an action that has no flags of its own (only `-h`/`--help`).
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct NoOptions;
|
||||||
|
|
||||||
|
impl CliTarget for NoOptions {
|
||||||
|
fn parse_into_field(
|
||||||
|
&mut self,
|
||||||
|
_key: &str,
|
||||||
|
_value: Option<&str>,
|
||||||
|
) -> Result<(), cli_args::ArgError> {
|
||||||
|
Err(cli_args::ArgError::InvalidField)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_help(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn help(&mut self) -> Result<(), cli_args::ArgError> {
|
||||||
|
Err(cli_args::ArgError::other(crate::error::HELP_ERROR_NAME))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//! `+show-config` (ZIG/src/cli/show_config.zig).
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use ghostty_config::config::Config;
|
||||||
|
use ghostty_config::config_struct;
|
||||||
|
use ghostty_config::file_formatter::FileFormatter;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
config_struct! {
|
||||||
|
pub struct Options {
|
||||||
|
/// If true, do not load the user configuration, only load the defaults.
|
||||||
|
"default" => default: bool = false,
|
||||||
|
|
||||||
|
/// Only show the options that have been changed from the default.
|
||||||
|
/// This has no effect if `--default` is specified.
|
||||||
|
"changes-only" => changes_only: bool = true,
|
||||||
|
|
||||||
|
/// If true print the documentation above each option as a comment,
|
||||||
|
/// if available.
|
||||||
|
"docs" => docs: bool = false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli_options!(Options, help);
|
||||||
|
|
||||||
|
/// The `show-config` command shows the current configuration in a valid Ghostty
|
||||||
|
/// configuration file format.
|
||||||
|
///
|
||||||
|
/// When executed without any arguments this will output the current
|
||||||
|
/// configuration that is different from the default configuration. If you're
|
||||||
|
/// using the default configuration this will output nothing.
|
||||||
|
///
|
||||||
|
/// If you are a new user and want to see all available options with
|
||||||
|
/// documentation, run `ghostty +show-config --default --docs`.
|
||||||
|
///
|
||||||
|
/// The output is not in any specific order, but the order should be consistent
|
||||||
|
/// between runs. The output is not guaranteed to be exactly match the input
|
||||||
|
/// configuration files, but it will result in the same behavior. Comments,
|
||||||
|
/// whitespace, and other formatting is not preserved from user configuration
|
||||||
|
/// files.
|
||||||
|
///
|
||||||
|
/// Flags:
|
||||||
|
///
|
||||||
|
/// * `--default`: Show the default configuration instead of loading
|
||||||
|
/// the user configuration.
|
||||||
|
///
|
||||||
|
/// * `--changes-only`: Only show the options that have been changed
|
||||||
|
/// from the default. This has no effect if `--default` is specified.
|
||||||
|
///
|
||||||
|
/// * `--docs`: Print the documentation above each option as a comment,
|
||||||
|
/// This is very noisy but is very useful to learn about available
|
||||||
|
/// options, especially paired with `--default`.
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
crate::parse_options(&mut opts, args)?;
|
||||||
|
|
||||||
|
let config = if opts.default {
|
||||||
|
Config::new_default()
|
||||||
|
} else {
|
||||||
|
Config::load()?
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = format_config(&config, &opts)?;
|
||||||
|
let mut stdout = std::io::stdout().lock();
|
||||||
|
stdout.write_all(text.as_bytes())?;
|
||||||
|
stdout.flush()?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format `config` according to `opts`.
|
||||||
|
pub fn format_config(config: &Config, opts: &Options) -> Result<String, Error> {
|
||||||
|
let fmt = FileFormatter {
|
||||||
|
config,
|
||||||
|
changed: !opts.default && opts.changes_only,
|
||||||
|
docs: opts.docs,
|
||||||
|
};
|
||||||
|
let mut out = String::new();
|
||||||
|
fmt.format(&mut out)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_changes_only_is_empty() {
|
||||||
|
let config = Config::new_default();
|
||||||
|
let opts = Options::default();
|
||||||
|
assert_eq!("", format_config(&config, &opts).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_docs_has_comments() {
|
||||||
|
let config = Config::new_default();
|
||||||
|
let opts = Options {
|
||||||
|
default: true,
|
||||||
|
docs: true,
|
||||||
|
..Options::default()
|
||||||
|
};
|
||||||
|
let s = format_config(&config, &opts).unwrap();
|
||||||
|
assert!(s.contains("# The font families to use."));
|
||||||
|
assert!(s.contains("\nfont-size = 12\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn options_parse() {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
let args: Vec<String> = [
|
||||||
|
"ghostty",
|
||||||
|
"+show-config",
|
||||||
|
"--default",
|
||||||
|
"--changes-only=false",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect();
|
||||||
|
crate::parse_options(&mut opts, &args).unwrap();
|
||||||
|
assert!(opts.default);
|
||||||
|
assert!(!opts.changes_only);
|
||||||
|
assert!(matches!(
|
||||||
|
crate::parse_options(&mut opts, &["g".into(), "--help".into()]),
|
||||||
|
Err(Error::ActionHelpRequested)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//! `+show-face` (ZIG/src/cli/show_face.zig).
|
||||||
|
//!
|
||||||
|
//! DEFERRED: needs the font crate (collection / codepoint resolver). Only the
|
||||||
|
//! options and help are ported; running the action reports that it is not
|
||||||
|
//! available.
|
||||||
|
|
||||||
|
use ghostty_config::{config_enum, config_struct, diagnostics::DiagnosticList};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
config_enum! {
|
||||||
|
/// Font style (Zig: `font.Style`).
|
||||||
|
pub enum Style {
|
||||||
|
Regular => "regular",
|
||||||
|
Bold => "bold",
|
||||||
|
Italic => "italic",
|
||||||
|
BoldItalic => "bold_italic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_enum! {
|
||||||
|
/// Font presentation (Zig: `font.Presentation`).
|
||||||
|
pub enum Presentation {
|
||||||
|
Text => "text",
|
||||||
|
Emoji => "emoji",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_struct! {
|
||||||
|
pub struct Options {
|
||||||
|
/// The codepoint to search for.
|
||||||
|
"cp" => cp: Option<u32> = None,
|
||||||
|
|
||||||
|
/// Search for all of the codepoints in the string.
|
||||||
|
"string" => string: Option<String> = None,
|
||||||
|
|
||||||
|
/// Font style to search for.
|
||||||
|
"style" => style: Style = Style::Regular,
|
||||||
|
|
||||||
|
/// If specified, force text or emoji presentation.
|
||||||
|
"presentation" => presentation: Option<Presentation> = None,
|
||||||
|
}
|
||||||
|
extra {
|
||||||
|
// Enable arg parsing diagnostics so that we don't get an error if
|
||||||
|
// there is a "normal" config setting on the cli.
|
||||||
|
diagnostics: DiagnosticList = DiagnosticList::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli_options!(Options, help, diagnostics);
|
||||||
|
|
||||||
|
/// The `show-face` command shows what font face Ghostty will use to render a
|
||||||
|
/// specific codepoint. Note that this command does not take into consideration
|
||||||
|
/// grapheme clustering or any other Unicode features that might modify the
|
||||||
|
/// presentation of a codepoint, so this may show a different font face than
|
||||||
|
/// Ghostty uses to render a codepoint in a terminal session.
|
||||||
|
///
|
||||||
|
/// Flags:
|
||||||
|
///
|
||||||
|
/// * `--cp`: Find the face for a single codepoint. The codepoint may be specified
|
||||||
|
/// in decimal (`--cp=65`), hexadecimal (`--cp=0x41`), octal (`--cp=0o101`), or
|
||||||
|
/// binary (`--cp=0b1000001`).
|
||||||
|
///
|
||||||
|
/// * `--string`: Find the face for all of the codepoints in a string. The
|
||||||
|
/// string must be a valid UTF-8 sequence.
|
||||||
|
///
|
||||||
|
/// * `--style`: Search for a specific style. Valid options are `regular`, `bold`,
|
||||||
|
/// `italic`, and `bold_italic`.
|
||||||
|
///
|
||||||
|
/// * `--presentation`: If set, force searching for a specific presentation
|
||||||
|
/// style. Valid options are `text` and `emoji`. If unset, the presentation
|
||||||
|
/// style of a codepoint will be inferred from the Unicode standard.
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
crate::parse_options(&mut opts, args)?;
|
||||||
|
|
||||||
|
// TODO(integration): port the face lookup once ghostty-font lands.
|
||||||
|
Err(Error::NotYetAvailable("show-face"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
//! `+ssh-cache` (ZIG/src/cli/ssh_cache.zig).
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use ghostty_config::config_struct;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
pub mod disk_cache;
|
||||||
|
pub mod entry;
|
||||||
|
|
||||||
|
pub use disk_cache::DiskCache;
|
||||||
|
pub use entry::Entry;
|
||||||
|
|
||||||
|
config_struct! {
|
||||||
|
pub struct Options {
|
||||||
|
"clear" => clear: bool = false,
|
||||||
|
"add" => add: Option<String> = None,
|
||||||
|
"remove" => remove: Option<String> = None,
|
||||||
|
"host" => host: Option<String> = None,
|
||||||
|
"expire-days" => expire_days: Option<u32> = None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli_options!(Options, help);
|
||||||
|
|
||||||
|
/// Manage the SSH terminfo cache for automatic remote host setup.
|
||||||
|
///
|
||||||
|
/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`,
|
||||||
|
/// Ghostty automatically installs its terminfo on remote hosts. This command
|
||||||
|
/// manages the cache of successful installations to avoid redundant uploads.
|
||||||
|
///
|
||||||
|
/// The cache stores hostnames (or user@hostname combinations) along with timestamps.
|
||||||
|
/// Entries older than the expiration period are automatically removed during cache
|
||||||
|
/// operations. By default, entries never expire.
|
||||||
|
///
|
||||||
|
/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified.
|
||||||
|
/// If multiple are specified, one of the actions will be executed but
|
||||||
|
/// it isn't guaranteed which one. This is entirely unsafe so you should split
|
||||||
|
/// multiple actions into separate commands.
|
||||||
|
///
|
||||||
|
/// Examples:
|
||||||
|
/// ghostty +ssh-cache # List all cached hosts
|
||||||
|
/// ghostty +ssh-cache --host=example.com # Check if host is cached
|
||||||
|
/// ghostty +ssh-cache --add=example.com # Manually add host to cache
|
||||||
|
/// ghostty +ssh-cache --add=user@example.com # Add user@host combination
|
||||||
|
/// ghostty +ssh-cache --remove=example.com # Remove host from cache
|
||||||
|
/// ghostty +ssh-cache --clear # Clear entire cache
|
||||||
|
/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
crate::parse_options(&mut opts, args)?;
|
||||||
|
|
||||||
|
let mut stdout = std::io::BufWriter::new(std::io::stdout().lock());
|
||||||
|
let mut stderr = std::io::BufWriter::new(std::io::stderr().lock());
|
||||||
|
|
||||||
|
let result = run_inner(&opts, &mut stdout, &mut stderr);
|
||||||
|
|
||||||
|
// Flushing *shouldn't* fail but...
|
||||||
|
let _ = stdout.flush();
|
||||||
|
let _ = stderr.flush();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like Zig's `runInner` with the cache at the standard location.
|
||||||
|
pub fn run_inner(
|
||||||
|
opts: &Options,
|
||||||
|
stdout: &mut dyn Write,
|
||||||
|
stderr: &mut dyn Write,
|
||||||
|
) -> Result<u8, Error> {
|
||||||
|
// Setup our disk cache to the standard location
|
||||||
|
let cache_path = DiskCache::default_path("ghostty").map_err(|e| Error::Other(e.to_string()))?;
|
||||||
|
run_with_cache(&DiskCache { path: cache_path }, opts, stdout, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_hostname(host: &str, stderr: &mut dyn Write) -> Result<u8, Error> {
|
||||||
|
writeln!(stderr, "Error: Invalid hostname format '{host}'")?;
|
||||||
|
writeln!(stderr, "Expected format: hostname or user@hostname")?;
|
||||||
|
Ok(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the action against an explicit cache.
|
||||||
|
pub fn run_with_cache(
|
||||||
|
cache: &DiskCache,
|
||||||
|
opts: &Options,
|
||||||
|
stdout: &mut dyn Write,
|
||||||
|
stderr: &mut dyn Write,
|
||||||
|
) -> Result<u8, Error> {
|
||||||
|
use disk_cache::{AddResult, Error as CacheError};
|
||||||
|
|
||||||
|
if opts.clear {
|
||||||
|
cache.clear().map_err(|e| Error::Other(e.to_string()))?;
|
||||||
|
writeln!(stdout, "Cache cleared.")?;
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(host) = &opts.add {
|
||||||
|
let result = match cache.add(host) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(CacheError::HostnameIsInvalid) => return invalid_hostname(host, stderr),
|
||||||
|
Err(CacheError::CacheIsLocked) => {
|
||||||
|
writeln!(stderr, "Error: Cache is busy, try again")?;
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
writeln!(
|
||||||
|
stderr,
|
||||||
|
"Error: Unable to add '{host}' to cache. Error: {err}"
|
||||||
|
)?;
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
AddResult::Added => writeln!(stdout, "Added '{host}' to cache.")?,
|
||||||
|
AddResult::Updated => writeln!(stdout, "Updated '{host}' cache entry.")?,
|
||||||
|
}
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(host) = &opts.remove {
|
||||||
|
match cache.remove(host) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(CacheError::HostnameIsInvalid) => return invalid_hostname(host, stderr),
|
||||||
|
Err(CacheError::CacheIsLocked) => {
|
||||||
|
writeln!(stderr, "Error: Cache is busy, try again")?;
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
writeln!(
|
||||||
|
stderr,
|
||||||
|
"Error: Unable to remove '{host}' from cache. Error: {err}"
|
||||||
|
)?;
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeln!(stdout, "Removed '{host}' from cache.")?;
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(host) = &opts.host {
|
||||||
|
let cached = match cache.contains(host) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(CacheError::HostnameIsInvalid) => return invalid_hostname(host, stderr),
|
||||||
|
Err(err) => {
|
||||||
|
writeln!(
|
||||||
|
stderr,
|
||||||
|
"Error: Unable to check host '{host}' in cache. Error: {err}"
|
||||||
|
)?;
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if cached {
|
||||||
|
writeln!(stdout, "'{host}' has Ghostty terminfo installed.")?;
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
writeln!(stdout, "'{host}' does not have Ghostty terminfo installed.")?;
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default action: list all hosts
|
||||||
|
let entries = cache.list().map_err(|e| Error::Other(e.to_string()))?;
|
||||||
|
list_entries(&entries, entry::now_timestamp(), stdout)?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_entries(
|
||||||
|
entries: &disk_cache::Entries,
|
||||||
|
now: i64,
|
||||||
|
writer: &mut dyn Write,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
if entries.is_empty() {
|
||||||
|
writeln!(writer, "No hosts in cache.")?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort entries by hostname for consistent output
|
||||||
|
let mut items: Vec<&Entry> = entries.values().collect();
|
||||||
|
items.sort_by(|a, b| a.hostname.as_bytes().cmp(b.hostname.as_bytes()));
|
||||||
|
|
||||||
|
writeln!(writer, "Cached hosts ({}):", items.len())?;
|
||||||
|
|
||||||
|
for entry in items {
|
||||||
|
let age_days = (now - entry.timestamp) / 86_400;
|
||||||
|
if age_days == 0 {
|
||||||
|
writeln!(writer, " {} (today)", entry.hostname)?;
|
||||||
|
} else if age_days == 1 {
|
||||||
|
writeln!(writer, " {} (yesterday)", entry.hostname)?;
|
||||||
|
} else {
|
||||||
|
writeln!(writer, " {} ({age_days} days ago)", entry.hostname)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn run_cache(cache: &DiskCache, opts: &Options) -> (u8, String, String) {
|
||||||
|
let (mut out, mut err) = (Vec::new(), Vec::new());
|
||||||
|
let rc = run_with_cache(cache, opts, &mut out, &mut err).unwrap();
|
||||||
|
(
|
||||||
|
rc,
|
||||||
|
String::from_utf8(out).unwrap(),
|
||||||
|
String::from_utf8(err).unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opt_add(h: &str) -> Options {
|
||||||
|
Options {
|
||||||
|
add: Some(h.into()),
|
||||||
|
..Options::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_list_host_remove_clear() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let cache = DiskCache {
|
||||||
|
path: tmp.path().join("ssh_cache"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (rc, out, _) = run_cache(&cache, &Options::default());
|
||||||
|
assert_eq!((0, "No hosts in cache.\n"), (rc, out.as_str()));
|
||||||
|
|
||||||
|
let (rc, out, _) = run_cache(&cache, &opt_add("b.com"));
|
||||||
|
assert_eq!((0, "Added 'b.com' to cache.\n"), (rc, out.as_str()));
|
||||||
|
let (rc, out, _) = run_cache(&cache, &opt_add("b.com"));
|
||||||
|
assert_eq!((0, "Updated 'b.com' cache entry.\n"), (rc, out.as_str()));
|
||||||
|
run_cache(&cache, &opt_add("a.com"));
|
||||||
|
|
||||||
|
let (rc, out, _) = run_cache(&cache, &Options::default());
|
||||||
|
assert_eq!(0, rc);
|
||||||
|
assert_eq!("Cached hosts (2):\n a.com (today)\n b.com (today)\n", out);
|
||||||
|
|
||||||
|
let host = |h: &str| Options {
|
||||||
|
host: Some(h.into()),
|
||||||
|
..Options::default()
|
||||||
|
};
|
||||||
|
let (rc, out, _) = run_cache(&cache, &host("a.com"));
|
||||||
|
assert_eq!(
|
||||||
|
(0, "'a.com' has Ghostty terminfo installed.\n"),
|
||||||
|
(rc, out.as_str())
|
||||||
|
);
|
||||||
|
let (rc, out, _) = run_cache(&cache, &host("zzz.com"));
|
||||||
|
assert_eq!(
|
||||||
|
(1, "'zzz.com' does not have Ghostty terminfo installed.\n"),
|
||||||
|
(rc, out.as_str())
|
||||||
|
);
|
||||||
|
|
||||||
|
let (rc, out, _) = run_cache(
|
||||||
|
&cache,
|
||||||
|
&Options {
|
||||||
|
remove: Some("a.com".into()),
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!((0, "Removed 'a.com' from cache.\n"), (rc, out.as_str()));
|
||||||
|
|
||||||
|
let (rc, out, _) = run_cache(
|
||||||
|
&cache,
|
||||||
|
&Options {
|
||||||
|
clear: true,
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!((0, "Cache cleared.\n"), (rc, out.as_str()));
|
||||||
|
assert!(!cache.path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_hostname_message() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let cache = DiskCache {
|
||||||
|
path: tmp.path().join("ssh_cache"),
|
||||||
|
};
|
||||||
|
let (rc, out, err) = run_cache(&cache, &opt_add("bad host"));
|
||||||
|
assert_eq!(1, rc);
|
||||||
|
assert_eq!(0, out.len());
|
||||||
|
assert_eq!(
|
||||||
|
"Error: Invalid hostname format 'bad host'\nExpected format: hostname or user@hostname\n",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_age_wording() {
|
||||||
|
let mut entries = disk_cache::Entries::new();
|
||||||
|
for (h, age) in [("a", 0), ("b", 1), ("c", 5)] {
|
||||||
|
entries.insert(
|
||||||
|
h.to_string(),
|
||||||
|
Entry {
|
||||||
|
hostname: h.to_string(),
|
||||||
|
timestamp: 1_000_000 - age * 86_400,
|
||||||
|
terminfo_version: "x".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
list_entries(&entries, 1_000_000, &mut out).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
"Cached hosts (3):\n a (today)\n b (yesterday)\n c (5 days ago)\n",
|
||||||
|
String::from_utf8(out).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
//! An SSH terminfo entry cache that stores its cache data on disk
|
||||||
|
//! (ZIG/src/cli/ssh-cache/DiskCache.zig). The cache only stores metadata
|
||||||
|
//! (hostname, terminfo value, etc.) and does not store any sensitive data.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::Ipv6Addr;
|
||||||
|
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::entry::{Entry, now_timestamp};
|
||||||
|
|
||||||
|
/// 512KB - sufficient for approximately 10k entries
|
||||||
|
const MAX_CACHE_SIZE: u64 = 512 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("CacheIsLocked")]
|
||||||
|
CacheIsLocked,
|
||||||
|
#[error("HostnameIsInvalid")]
|
||||||
|
HostnameIsInvalid,
|
||||||
|
/// The general error that is returned for any filesystem error
|
||||||
|
/// that may have resulted in the XDG lookup failing.
|
||||||
|
#[error("XdgLookupFailed")]
|
||||||
|
XdgLookupFailed,
|
||||||
|
#[error("InvalidCachePath")]
|
||||||
|
InvalidCachePath,
|
||||||
|
#[error("StreamTooLong")]
|
||||||
|
StreamTooLong,
|
||||||
|
#[error("{0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AddResult {
|
||||||
|
Added,
|
||||||
|
Updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Entries = BTreeMap<String, Entry>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiskCache {
|
||||||
|
/// Path to a file where the cache is stored.
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskCache {
|
||||||
|
/// Returns the default path for the cache for a given program.
|
||||||
|
///
|
||||||
|
/// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`.
|
||||||
|
pub fn default_path(program: &str) -> Result<PathBuf, Error> {
|
||||||
|
let state_dir = ghostty_os::xdg::state(&ghostty_os::xdg::Options {
|
||||||
|
subdir: Some(program),
|
||||||
|
home: None,
|
||||||
|
})
|
||||||
|
.map_err(|_| Error::XdgLookupFailed)?;
|
||||||
|
Ok(PathBuf::from(state_dir).join("ssh_cache"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cache data stored in the disk cache.
|
||||||
|
/// This removes the cache file from disk, effectively clearing all cached
|
||||||
|
/// SSH terminfo entries.
|
||||||
|
pub fn clear(&self) -> Result<(), Error> {
|
||||||
|
match std::fs::remove_file(&self.path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or update a hostname entry in the cache.
|
||||||
|
/// Returns `AddResult::Added` for new entries or `AddResult::Updated` for
|
||||||
|
/// existing ones. The cache file is created if it doesn't exist with
|
||||||
|
/// secure permissions (0600).
|
||||||
|
pub fn add(&self, hostname: &str) -> Result<AddResult, Error> {
|
||||||
|
if !is_valid_cache_key(hostname) {
|
||||||
|
return Err(Error::HostnameIsInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cache directory if needed
|
||||||
|
if let Some(dir) = self.path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open or create cache file with secure permissions
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&self.path)?;
|
||||||
|
fixup_permissions(&file)?;
|
||||||
|
|
||||||
|
// Lock
|
||||||
|
let _lock = lock(&file)?;
|
||||||
|
|
||||||
|
let mut entries = read_entries(&file)?;
|
||||||
|
|
||||||
|
// Add or update entry
|
||||||
|
let result = if let Some(e) = entries.get_mut(hostname) {
|
||||||
|
// Update timestamp for existing entry
|
||||||
|
e.timestamp = now_timestamp();
|
||||||
|
AddResult::Updated
|
||||||
|
} else {
|
||||||
|
entries.insert(
|
||||||
|
hostname.to_string(),
|
||||||
|
Entry {
|
||||||
|
hostname: hostname.to_string(),
|
||||||
|
timestamp: now_timestamp(),
|
||||||
|
terminfo_version: "xterm-ghostty".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
AddResult::Added
|
||||||
|
};
|
||||||
|
|
||||||
|
self.write_cache_file(&entries, None)?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a hostname entry from the cache.
|
||||||
|
/// No error is returned if the hostname doesn't exist or the cache file
|
||||||
|
/// is missing.
|
||||||
|
pub fn remove(&self, hostname: &str) -> Result<(), Error> {
|
||||||
|
if !is_valid_cache_key(hostname) {
|
||||||
|
return Err(Error::HostnameIsInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open our file
|
||||||
|
let file = match OpenOptions::new().read(true).write(true).open(&self.path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
fixup_permissions(&file)?;
|
||||||
|
|
||||||
|
// Lock
|
||||||
|
let _lock = lock(&file)?;
|
||||||
|
|
||||||
|
// Read existing entries
|
||||||
|
let mut entries = read_entries(&file)?;
|
||||||
|
entries.remove(hostname);
|
||||||
|
|
||||||
|
self.write_cache_file(&entries, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a hostname exists in the cache.
|
||||||
|
/// Returns false if the cache file doesn't exist.
|
||||||
|
pub fn contains(&self, hostname: &str) -> Result<bool, Error> {
|
||||||
|
if !is_valid_cache_key(hostname) {
|
||||||
|
return Err(Error::HostnameIsInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open our file
|
||||||
|
let file = match File::open(&self.path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries = read_entries(&file)?;
|
||||||
|
Ok(entries.contains_key(hostname))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all entries in the cache.
|
||||||
|
/// Returns an empty map if the cache file doesn't exist.
|
||||||
|
pub fn list(&self) -> Result<Entries, Error> {
|
||||||
|
let file = match File::open(&self.path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Entries::new()),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
read_entries(&file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically (temp file + rename) write the non-expired entries.
|
||||||
|
fn write_cache_file(&self, entries: &Entries, expire_days: Option<u32>) -> Result<(), Error> {
|
||||||
|
let cache_dir = self.path.parent().ok_or(Error::InvalidCachePath)?;
|
||||||
|
let cache_basename = self.path.file_name().ok_or(Error::InvalidCachePath)?;
|
||||||
|
|
||||||
|
let mut data = String::new();
|
||||||
|
for e in entries.values() {
|
||||||
|
// Only write non-expired entries
|
||||||
|
if e.is_expired(expire_days) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
e.format(&mut data);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tmp_name = cache_basename.to_os_string();
|
||||||
|
tmp_name.push(format!(".{}.tmp", std::process::id()));
|
||||||
|
let tmp_path = cache_dir.join(tmp_name);
|
||||||
|
|
||||||
|
let result = (|| -> std::io::Result<()> {
|
||||||
|
let mut f = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&tmp_path)?;
|
||||||
|
f.write_all(data.as_bytes())?;
|
||||||
|
f.sync_all()?;
|
||||||
|
std::fs::rename(&tmp_path, &self.path)
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = std::fs::remove_file(&tmp_path);
|
||||||
|
}
|
||||||
|
Ok(result?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds an advisory exclusive lock on a file until dropped.
|
||||||
|
struct Lock<'a>(Option<&'a File>);
|
||||||
|
|
||||||
|
impl Drop for Lock<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(f) = self.0 {
|
||||||
|
let _ = f.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zig: `_ = file.tryLock(.exclusive) catch return error.CacheIsLocked;`
|
||||||
|
/// The boolean result is ignored upstream, so a lock held by someone else
|
||||||
|
/// does not fail; only a real error does.
|
||||||
|
fn lock(file: &File) -> Result<Lock<'_>, Error> {
|
||||||
|
match file.try_lock() {
|
||||||
|
Ok(()) => Ok(Lock(Some(file))),
|
||||||
|
Err(std::fs::TryLockError::WouldBlock) => Ok(Lock(None)),
|
||||||
|
Err(std::fs::TryLockError::Error(_)) => Err(Error::CacheIsLocked),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixup_permissions(file: &File) -> Result<(), Error> {
|
||||||
|
// Ensure file has correct permissions (readable/writable by
|
||||||
|
// owner only)
|
||||||
|
let meta = file.metadata()?;
|
||||||
|
if meta.permissions().mode() & 0o777 != 0o600 {
|
||||||
|
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_entries(file: &File) -> Result<Entries, Error> {
|
||||||
|
let mut content = Vec::new();
|
||||||
|
let mut f = file;
|
||||||
|
(&mut f)
|
||||||
|
.take(MAX_CACHE_SIZE + 1)
|
||||||
|
.read_to_end(&mut content)?;
|
||||||
|
if content.len() as u64 > MAX_CACHE_SIZE {
|
||||||
|
return Err(Error::StreamTooLong);
|
||||||
|
}
|
||||||
|
let content = String::from_utf8_lossy(&content);
|
||||||
|
|
||||||
|
let mut entries = Entries::new();
|
||||||
|
for line in content.split('\n').filter(|l| !l.is_empty()) {
|
||||||
|
let trimmed = line.trim_matches([' ', '\t', '\r']);
|
||||||
|
let Some(entry) = Entry::parse(trimmed) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match entries.get_mut(&entry.hostname) {
|
||||||
|
None => {
|
||||||
|
entries.insert(entry.hostname.clone(), entry);
|
||||||
|
}
|
||||||
|
Some(existing) => {
|
||||||
|
// Handle duplicate entries - keep newer timestamp
|
||||||
|
if entry.timestamp > existing.timestamp {
|
||||||
|
existing.timestamp = entry.timestamp;
|
||||||
|
if existing.terminfo_version != entry.terminfo_version {
|
||||||
|
existing.terminfo_version = entry.terminfo_version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supports both standalone hostnames and user@hostname format
|
||||||
|
fn is_valid_cache_key(key: &str) -> bool {
|
||||||
|
if key.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for user@hostname format
|
||||||
|
if let Some(at_pos) = key.find('@') {
|
||||||
|
let user = &key[..at_pos];
|
||||||
|
let hostname = &key[at_pos + 1..];
|
||||||
|
return is_valid_user(user) && is_valid_host(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
is_valid_host(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks if a host is a valid hostname or IP address
|
||||||
|
fn is_valid_host(host: &str) -> bool {
|
||||||
|
// First check for valid hostnames because this is assumed to be the more
|
||||||
|
// likely ssh host format.
|
||||||
|
if ghostty_os::hostname::is_valid(host) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We also accept valid IP addresses. In practice, IPv4 addresses are also
|
||||||
|
// considered valid hostnames due to their overlapping syntax, so we can
|
||||||
|
// simplify this check to be IPv6-specific.
|
||||||
|
host.parse::<Ipv6Addr>().is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid_user(user: &str) -> bool {
|
||||||
|
if user.is_empty() || user.len() > 64 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
user.bytes()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disk_cache_default_path() {
|
||||||
|
let path = DiskCache::default_path("ghostty").unwrap();
|
||||||
|
assert!(!path.as_os_str().is_empty());
|
||||||
|
assert!(path.ends_with("ghostty/ssh_cache"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disk_cache_clear() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp.path().join("cache");
|
||||||
|
std::fs::write(&path, "HELLO!").unwrap();
|
||||||
|
|
||||||
|
let cache = DiskCache { path: path.clone() };
|
||||||
|
cache.clear().unwrap();
|
||||||
|
|
||||||
|
// Verify the file is gone
|
||||||
|
assert_eq!(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
File::open(&path).unwrap_err().kind()
|
||||||
|
);
|
||||||
|
// Clearing again is not an error.
|
||||||
|
cache.clear().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disk_cache_operations() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp.path().join("cache");
|
||||||
|
std::fs::write(&path, "HELLO!").unwrap();
|
||||||
|
|
||||||
|
// Setup our cache
|
||||||
|
let cache = DiskCache { path };
|
||||||
|
assert_eq!(AddResult::Added, cache.add("example.com").unwrap());
|
||||||
|
assert_eq!(AddResult::Updated, cache.add("example.com").unwrap());
|
||||||
|
assert!(cache.contains("example.com").unwrap());
|
||||||
|
|
||||||
|
// List
|
||||||
|
let entries = cache.list().unwrap();
|
||||||
|
assert_eq!(1, entries.len());
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
cache.remove("example.com").unwrap();
|
||||||
|
assert!(!cache.contains("example.com").unwrap());
|
||||||
|
assert_eq!(AddResult::Added, cache.add("example.com").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disk_cache_cleans_up_temp_files() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let cache = DiskCache {
|
||||||
|
path: tmp.path().join("cache"),
|
||||||
|
};
|
||||||
|
assert_eq!(AddResult::Added, cache.add("example.com").unwrap());
|
||||||
|
assert_eq!(AddResult::Added, cache.add("example.org").unwrap());
|
||||||
|
|
||||||
|
// Verify only the cache file exists and no temp files left behind
|
||||||
|
let names: Vec<_> = std::fs::read_dir(tmp.path())
|
||||||
|
.unwrap()
|
||||||
|
.map(|e| e.unwrap().file_name())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(vec![std::ffi::OsString::from("cache")], names);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disk_cache_file_permissions_and_invalid_host() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let cache = DiskCache {
|
||||||
|
path: tmp.path().join("sub/cache"),
|
||||||
|
};
|
||||||
|
cache.add("example.com").unwrap();
|
||||||
|
let mode = std::fs::metadata(&cache.path).unwrap().permissions().mode();
|
||||||
|
assert_eq!(0o600, mode & 0o777);
|
||||||
|
assert!(matches!(
|
||||||
|
cache.add("bad host"),
|
||||||
|
Err(Error::HostnameIsInvalid)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
cache.contains("host name"),
|
||||||
|
Err(Error::HostnameIsInvalid)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_entries_keep_newer_timestamp() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp.path().join("cache");
|
||||||
|
std::fs::write(&path, "a.com|10|old\na.com|20|new\na.com|15|mid\n").unwrap();
|
||||||
|
let e = DiskCache { path }.list().unwrap();
|
||||||
|
assert_eq!(20, e["a.com"].timestamp);
|
||||||
|
assert_eq!("new", e["a.com"].terminfo_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_valid_host_cases() {
|
||||||
|
// Valid hostnames
|
||||||
|
assert!(is_valid_host("localhost"));
|
||||||
|
assert!(is_valid_host("example.com"));
|
||||||
|
assert!(is_valid_host("sub.example.com"));
|
||||||
|
|
||||||
|
// IPv4 addresses
|
||||||
|
assert!(is_valid_host("127.0.0.1"));
|
||||||
|
assert!(is_valid_host("192.168.1.1"));
|
||||||
|
|
||||||
|
// IPv6 addresses
|
||||||
|
assert!(is_valid_host("::1"));
|
||||||
|
assert!(is_valid_host("2001:db8::1"));
|
||||||
|
assert!(is_valid_host("2001:db8:0:1:1:1:1:1"));
|
||||||
|
assert!(!is_valid_host("fe80::1%eth0")); // scopes not supported
|
||||||
|
|
||||||
|
// Invalid hosts
|
||||||
|
assert!(!is_valid_host(""));
|
||||||
|
assert!(!is_valid_host("host\nname"));
|
||||||
|
assert!(!is_valid_host(".example.com"));
|
||||||
|
assert!(!is_valid_host("host..domain"));
|
||||||
|
assert!(!is_valid_host("-hostname"));
|
||||||
|
assert!(!is_valid_host("hostname-"));
|
||||||
|
assert!(!is_valid_host("host name"));
|
||||||
|
assert!(!is_valid_host("host_name"));
|
||||||
|
assert!(!is_valid_host("host@domain"));
|
||||||
|
assert!(!is_valid_host("host:port"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_valid_user_cases() {
|
||||||
|
// Valid
|
||||||
|
assert!(is_valid_user("user"));
|
||||||
|
assert!(is_valid_user("user-user"));
|
||||||
|
assert!(is_valid_user("user_name"));
|
||||||
|
assert!(is_valid_user("user.name"));
|
||||||
|
assert!(is_valid_user("user123"));
|
||||||
|
|
||||||
|
// Invalid
|
||||||
|
assert!(!is_valid_user(""));
|
||||||
|
assert!(!is_valid_user("user name"));
|
||||||
|
assert!(!is_valid_user("user@example"));
|
||||||
|
assert!(!is_valid_user("user:group"));
|
||||||
|
assert!(!is_valid_user("user\nname"));
|
||||||
|
assert!(!is_valid_user(&"a".repeat(65))); // too long
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_valid_cache_key_cases() {
|
||||||
|
// Valid
|
||||||
|
assert!(is_valid_cache_key("example.com"));
|
||||||
|
assert!(is_valid_cache_key("sub.example.com"));
|
||||||
|
assert!(is_valid_cache_key("192.168.1.1"));
|
||||||
|
assert!(is_valid_cache_key("::1"));
|
||||||
|
assert!(is_valid_cache_key("user@example.com"));
|
||||||
|
assert!(is_valid_cache_key("user@192.168.1.1"));
|
||||||
|
assert!(is_valid_cache_key("user@::1"));
|
||||||
|
|
||||||
|
// Invalid
|
||||||
|
assert!(!is_valid_cache_key(""));
|
||||||
|
assert!(!is_valid_cache_key(".example.com"));
|
||||||
|
assert!(!is_valid_cache_key("@example.com"));
|
||||||
|
assert!(!is_valid_cache_key("user@"));
|
||||||
|
assert!(!is_valid_cache_key("user@@example"));
|
||||||
|
assert!(!is_valid_cache_key("user@.example.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//! A single entry within our SSH entry cache (ZIG/src/cli/ssh-cache/Entry.zig).
|
||||||
|
//! Our SSH entry cache stores which hosts we've sent our terminfo to so that
|
||||||
|
//! we don't have to send it again. It doesn't store any sensitive information.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const S_PER_DAY: i64 = 86_400;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Entry {
|
||||||
|
pub hostname: String,
|
||||||
|
pub timestamp: i64,
|
||||||
|
pub terminfo_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `std.time.timestamp()`: seconds since the unix epoch.
|
||||||
|
#[must_use]
|
||||||
|
pub fn now_timestamp() -> i64 {
|
||||||
|
match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||||
|
Ok(d) => i64::try_from(d.as_secs()).unwrap_or(i64::MAX),
|
||||||
|
Err(e) => -i64::try_from(e.duration().as_secs()).unwrap_or(i64::MAX),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Entry {
|
||||||
|
/// Parse a `hostname|timestamp|terminfo_version` line.
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse(line: &str) -> Option<Entry> {
|
||||||
|
let trimmed = line.trim_matches([' ', '\t', '\r', '\n']);
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse format: hostname|timestamp|terminfo_version
|
||||||
|
// (`tokenizeScalar` skips empty tokens.)
|
||||||
|
let mut iter = trimmed.split('|').filter(|s| !s.is_empty());
|
||||||
|
let hostname = iter.next()?;
|
||||||
|
let timestamp_str = iter.next()?;
|
||||||
|
let terminfo_version = iter.next().unwrap_or("xterm-ghostty");
|
||||||
|
let timestamp = match timestamp_str.parse::<i64>() {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("Invalid timestamp in cache entry: {timestamp_str} err={err}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(Entry {
|
||||||
|
hostname: hostname.to_string(),
|
||||||
|
timestamp,
|
||||||
|
terminfo_version: terminfo_version.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the entry in the on-disk format (`hostname|timestamp|terminfo\n`).
|
||||||
|
pub fn format(&self, out: &mut String) {
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"{}|{}|{}",
|
||||||
|
self.hostname, self.timestamp, self.terminfo_version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_expired(&self, expire_days: Option<u32>) -> bool {
|
||||||
|
self.is_expired_at(expire_days, now_timestamp())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_expired_at(&self, expire_days: Option<u32>, now: i64) -> bool {
|
||||||
|
let Some(expire_days) = expire_days else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let age_days = now.saturating_sub(self.timestamp) / S_PER_DAY;
|
||||||
|
age_days > i64::from(expire_days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entry(host: &str, timestamp: i64) -> Entry {
|
||||||
|
Entry {
|
||||||
|
hostname: host.to_string(),
|
||||||
|
timestamp,
|
||||||
|
terminfo_version: "xterm-ghostty".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_entry_expiration() {
|
||||||
|
let now = now_timestamp();
|
||||||
|
let fresh_entry = entry("test.com", now - S_PER_DAY); // 1 day old
|
||||||
|
assert!(!fresh_entry.is_expired(Some(90)));
|
||||||
|
|
||||||
|
let old_entry = entry("old.com", now - S_PER_DAY * 100); // 100 days old
|
||||||
|
assert!(old_entry.is_expired(Some(90)));
|
||||||
|
|
||||||
|
// Test never-expire case
|
||||||
|
assert!(!old_entry.is_expired(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_entry_expiration_exact_boundary() {
|
||||||
|
let now = now_timestamp();
|
||||||
|
// Exactly at expiration boundary
|
||||||
|
let boundary_entry = entry("example.com", now - S_PER_DAY * 30);
|
||||||
|
assert!(!boundary_entry.is_expired_at(Some(30), now));
|
||||||
|
assert!(boundary_entry.is_expired_at(Some(29), now));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_entry_expiration_large_timestamp() {
|
||||||
|
let now = now_timestamp();
|
||||||
|
let boundary_entry = entry("example.com", now + S_PER_DAY * 30);
|
||||||
|
assert!(!boundary_entry.is_expired(Some(30)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_entry_parsing_valid_formats() {
|
||||||
|
let e = Entry::parse("example.com|1640995200|xterm-ghostty").unwrap();
|
||||||
|
assert_eq!("example.com", e.hostname);
|
||||||
|
assert_eq!(1_640_995_200, e.timestamp);
|
||||||
|
assert_eq!("xterm-ghostty", e.terminfo_version);
|
||||||
|
|
||||||
|
// Test default terminfo version
|
||||||
|
let e = Entry::parse("test.com|1640995200").unwrap();
|
||||||
|
assert_eq!("xterm-ghostty", e.terminfo_version);
|
||||||
|
|
||||||
|
// Test complex hostnames
|
||||||
|
let e = Entry::parse("user@server.example.com|1640995200|xterm-ghostty").unwrap();
|
||||||
|
assert_eq!("user@server.example.com", e.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_entry_parsing_invalid_formats() {
|
||||||
|
assert!(Entry::parse("").is_none());
|
||||||
|
|
||||||
|
// Invalid format (no pipe)
|
||||||
|
assert!(Entry::parse("v1").is_none());
|
||||||
|
|
||||||
|
// Missing timestamp
|
||||||
|
assert!(Entry::parse("example.com").is_none());
|
||||||
|
|
||||||
|
// Invalid timestamp
|
||||||
|
assert!(Entry::parse("example.com|invalid").is_none());
|
||||||
|
|
||||||
|
// Empty terminfo should default
|
||||||
|
assert!(Entry::parse("example.com|1640995200|").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_entry_parsing_malformed_data_resilience() {
|
||||||
|
// Extra pipes should not break parsing
|
||||||
|
assert!(Entry::parse("host|123|term|extra").is_some());
|
||||||
|
|
||||||
|
// Whitespace handling
|
||||||
|
assert!(Entry::parse(" host|123|term ").is_some());
|
||||||
|
assert!(Entry::parse("\n").is_none());
|
||||||
|
assert!(Entry::parse(" \t \n").is_none());
|
||||||
|
|
||||||
|
// Extremely large timestamp
|
||||||
|
assert!(
|
||||||
|
Entry::parse("host|999999999999999999999999999999999999999999999999|xterm-ghostty")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_entry_format_round_trip() {
|
||||||
|
let e = entry("a.com", 5);
|
||||||
|
let mut s = String::new();
|
||||||
|
e.format(&mut s);
|
||||||
|
assert_eq!("a.com|5|xterm-ghostty\n", s);
|
||||||
|
assert_eq!(Some(e), Entry::parse(&s));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
//! Shared TUI helpers (ZIG/src/cli/tui.zig) plus a tiny re-implementation of
|
||||||
|
//! the part of libvaxis that Ghostty's "pretty" (non-interactive, tty)
|
||||||
|
//! listings use: composing a single row of styled cells in a window and
|
||||||
|
//! writing it with vaxis' `prettyPrint` escape sequences.
|
||||||
|
//!
|
||||||
|
//! The byte output was verified against Ghostty 1.3.1 in a pty.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
|
/// Whether pretty printing is possible on this platform
|
||||||
|
/// (`tui.can_pretty_print`; false only on iOS/tvOS/watchOS in Zig).
|
||||||
|
pub const CAN_PRETTY_PRINT: bool = true;
|
||||||
|
|
||||||
|
/// `vaxis.ctlseqs.unicode_set`.
|
||||||
|
pub const UNICODE_SET: &str = "\x1b[?2027h";
|
||||||
|
/// `vaxis.ctlseqs.unicode_reset`.
|
||||||
|
pub const UNICODE_RESET: &str = "\x1b[?2027l";
|
||||||
|
|
||||||
|
/// A cell color (`vaxis.Color`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Color {
|
||||||
|
#[default]
|
||||||
|
Default,
|
||||||
|
Index(u8),
|
||||||
|
Rgb(u8, u8, u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cell style (the subset of `vaxis.Style` that is used).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct Style {
|
||||||
|
pub fg: Color,
|
||||||
|
pub bg: Color,
|
||||||
|
pub bold: bool,
|
||||||
|
pub dim: bool,
|
||||||
|
pub italic: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum Cell {
|
||||||
|
/// Never written (`vaxis.Cell{}`): rendered by moving the cursor.
|
||||||
|
Unwritten,
|
||||||
|
/// The trailing half of a wide grapheme.
|
||||||
|
Continuation(Style),
|
||||||
|
Glyph(String, Style),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of printing a segment (`vaxis.Window.PrintResult`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct PrintResult {
|
||||||
|
pub col: u16,
|
||||||
|
pub row: u16,
|
||||||
|
pub overflow: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A vaxis window of `width` x `height` cells. The listings only ever use
|
||||||
|
/// row 0 explicitly, but text that does not fit wraps onto the following
|
||||||
|
/// rows (and is rendered there), like vaxis does.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Row {
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
rows: Vec<Vec<Cell>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display width of a string (`Window.gwidth` with unicode mode on).
|
||||||
|
#[must_use]
|
||||||
|
pub fn gwidth(s: &str) -> u16 {
|
||||||
|
let w: usize = s.chars().map(|c| c.width().unwrap_or(0)).sum();
|
||||||
|
u16::try_from(w).unwrap_or(u16::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Row {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(width: u16) -> Row {
|
||||||
|
Row::with_height(width, window_size().1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_height(width: u16, height: u16) -> Row {
|
||||||
|
Row {
|
||||||
|
width,
|
||||||
|
height: height.max(1),
|
||||||
|
rows: vec![vec![Cell::Unwritten; usize::from(width)]],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn width(&self) -> u16 {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Window.clear`.
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.rows = vec![vec![Cell::Unwritten; usize::from(self.width)]];
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cell_mut(&mut self, row: u16, col: u16) -> &mut Cell {
|
||||||
|
while self.rows.len() <= usize::from(row) {
|
||||||
|
self.rows
|
||||||
|
.push(vec![Cell::Unwritten; usize::from(self.width)]);
|
||||||
|
}
|
||||||
|
&mut self.rows[usize::from(row)][usize::from(col)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Window.printSegment` starting on row 0 at `col_offset`; wraps onto
|
||||||
|
/// the next rows when the text does not fit.
|
||||||
|
pub fn print_segment(&mut self, text: &str, style: Style, col_offset: u16) -> PrintResult {
|
||||||
|
self.print_segment_at(text, style, 0, col_offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Window.printSegment` with an explicit `row_offset`.
|
||||||
|
pub fn print_segment_at(
|
||||||
|
&mut self,
|
||||||
|
text: &str,
|
||||||
|
style: Style,
|
||||||
|
row_offset: u16,
|
||||||
|
col_offset: u16,
|
||||||
|
) -> PrintResult {
|
||||||
|
let mut col = col_offset;
|
||||||
|
let mut row = row_offset;
|
||||||
|
let mut overflow = false;
|
||||||
|
let mut last: Option<(u16, u16)> = None;
|
||||||
|
for ch in text.chars() {
|
||||||
|
let w = ch.width().unwrap_or(0);
|
||||||
|
if w == 0 {
|
||||||
|
// Combine with the previous glyph.
|
||||||
|
if let Some((r, c)) = last {
|
||||||
|
if let Cell::Glyph(g, _) = self.cell_mut(r, c) {
|
||||||
|
g.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let w16 = u16::try_from(w).unwrap_or(1);
|
||||||
|
if col + w16 > self.width {
|
||||||
|
row += 1;
|
||||||
|
col = 0;
|
||||||
|
}
|
||||||
|
if row >= self.height || w16 > self.width {
|
||||||
|
overflow = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
*self.cell_mut(row, col) = Cell::Glyph(ch.to_string(), style);
|
||||||
|
for i in 1..w16 {
|
||||||
|
*self.cell_mut(row, col + i) = Cell::Continuation(style);
|
||||||
|
}
|
||||||
|
last = Some((row, col));
|
||||||
|
col += w16;
|
||||||
|
if col >= self.width {
|
||||||
|
row += 1;
|
||||||
|
col = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrintResult { col, row, overflow }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Window.print`: print several segments one after another.
|
||||||
|
pub fn print(&mut self, segments: &[(&str, Style)], col_offset: u16) -> PrintResult {
|
||||||
|
let mut res = PrintResult {
|
||||||
|
col: col_offset,
|
||||||
|
row: 0,
|
||||||
|
overflow: false,
|
||||||
|
};
|
||||||
|
for (text, style) in segments {
|
||||||
|
res = self.print_segment_at(text, *style, res.row, res.col);
|
||||||
|
if res.overflow {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write this window like `vx.prettyPrint` does (the escape sequences
|
||||||
|
/// are identical to libvaxis': synchronized update, cursor hidden, only
|
||||||
|
/// the written part of each row, unwritten gaps skipped with CUF).
|
||||||
|
///
|
||||||
|
/// `first` must be true for the first row printed by a program (vaxis
|
||||||
|
/// then also returns to column 0 and erases below).
|
||||||
|
pub fn pretty_print(&self, w: &mut dyn Write, first: bool) -> std::io::Result<()> {
|
||||||
|
if first {
|
||||||
|
w.write_all(b"\r\x1b[m\x1b[J")?;
|
||||||
|
}
|
||||||
|
w.write_all(b"\x1b[?25l\x1b[?2026h\x1b[m")?;
|
||||||
|
|
||||||
|
// Rows after the last written row are not printed.
|
||||||
|
let used_rows = self
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.rposition(|r| r.iter().any(|c| !matches!(c, Cell::Unwritten)))
|
||||||
|
.map_or(1, |i| i + 1);
|
||||||
|
|
||||||
|
let mut cursor = Style::default();
|
||||||
|
for (ri, cells) in self.rows[..used_rows].iter().enumerate() {
|
||||||
|
if ri > 0 {
|
||||||
|
w.write_all(b"\n\r")?;
|
||||||
|
}
|
||||||
|
// Trailing unwritten cells are never emitted.
|
||||||
|
let end = cells
|
||||||
|
.iter()
|
||||||
|
.rposition(|c| !matches!(c, Cell::Unwritten))
|
||||||
|
.map_or(0, |i| i + 1);
|
||||||
|
|
||||||
|
let mut skip = 0usize;
|
||||||
|
for cell in &cells[..end] {
|
||||||
|
match cell {
|
||||||
|
Cell::Unwritten => skip += 1,
|
||||||
|
Cell::Continuation(_) => {}
|
||||||
|
Cell::Glyph(g, style) => {
|
||||||
|
if skip > 0 {
|
||||||
|
write!(w, "\x1b[{skip}C")?;
|
||||||
|
skip = 0;
|
||||||
|
}
|
||||||
|
write_style_change(w, &cursor, style)?;
|
||||||
|
cursor = *style;
|
||||||
|
w.write_all(g.as_bytes())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skip > 0 {
|
||||||
|
write!(w, "\x1b[{skip}C")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.write_all(b"\r\n\x1b[m\x1b[?2026l")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_color(w: &mut dyn Write, c: Color, base: u8) -> std::io::Result<()> {
|
||||||
|
// base is 3 for foreground, 4 for background.
|
||||||
|
match c {
|
||||||
|
Color::Default => write!(w, "\x1b[{base}9m"),
|
||||||
|
Color::Index(i) if i < 8 => write!(w, "\x1b[{base}{i}m"),
|
||||||
|
Color::Index(i) if i < 16 => write!(w, "\x1b[{}{}m", base + 6, i - 8),
|
||||||
|
Color::Index(i) => write!(w, "\x1b[{base}8:5:{i}m"),
|
||||||
|
Color::Rgb(r, g, b) => write!(w, "\x1b[{base}8:2:{r}:{g}:{b}m"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit the SGR changes to go from `cursor` to `next` in vaxis' order.
|
||||||
|
fn write_style_change(w: &mut dyn Write, cursor: &Style, next: &Style) -> std::io::Result<()> {
|
||||||
|
if cursor.fg != next.fg {
|
||||||
|
write_color(w, next.fg, 3)?;
|
||||||
|
}
|
||||||
|
if cursor.bg != next.bg {
|
||||||
|
write_color(w, next.bg, 4)?;
|
||||||
|
}
|
||||||
|
if cursor.bold != next.bold {
|
||||||
|
w.write_all(if next.bold { b"\x1b[1m" } else { b"\x1b[22m" })?;
|
||||||
|
if next.dim {
|
||||||
|
w.write_all(b"\x1b[2m")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cursor.dim != next.dim {
|
||||||
|
w.write_all(if next.dim { b"\x1b[2m" } else { b"\x1b[22m" })?;
|
||||||
|
if next.bold {
|
||||||
|
w.write_all(b"\x1b[1m")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cursor.italic != next.italic {
|
||||||
|
w.write_all(if next.italic { b"\x1b[3m" } else { b"\x1b[23m" })?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal size in columns and rows (`vaxis.Tty.getWinsize`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn window_size() -> (u16, u16) {
|
||||||
|
ratatui::crossterm::terminal::size().unwrap_or((80, 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sequences vaxis writes when it is torn down (`vx.deinit`).
|
||||||
|
pub const VAXIS_DEINIT: &str = "\x1b[?25h\x1b[m\r\x1b[J";
|
||||||
|
|
||||||
|
/// A pretty-printing session on a tty, mirroring the
|
||||||
|
/// `vaxis.Tty.init` + `vaxis.init` + mode-2027 setup used by the listing
|
||||||
|
/// actions. Dropping it restores the terminal.
|
||||||
|
pub struct PrettySession<W: Write> {
|
||||||
|
out: W,
|
||||||
|
first: bool,
|
||||||
|
raw: bool,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W: Write> PrettySession<W> {
|
||||||
|
pub fn new(mut out: W) -> std::io::Result<Self> {
|
||||||
|
let (width, height) = window_size();
|
||||||
|
// We know we are ghostty, so enable mode 2027.
|
||||||
|
out.write_all(UNICODE_SET.as_bytes())?;
|
||||||
|
Ok(PrettySession {
|
||||||
|
out,
|
||||||
|
first: true,
|
||||||
|
raw: false,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`PrettySession::new`] but also puts the tty in raw mode, like
|
||||||
|
/// `vaxis.Tty.init` does (this matters for the bytes that reach the
|
||||||
|
/// terminal: with output processing off, `\r\n` stays `\r\n`).
|
||||||
|
pub fn new_raw(out: W) -> std::io::Result<Self> {
|
||||||
|
ratatui::crossterm::terminal::enable_raw_mode()?;
|
||||||
|
let mut s = Self::new(out)?;
|
||||||
|
s.raw = true;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new empty row of the window width.
|
||||||
|
#[must_use]
|
||||||
|
pub fn row(&self) -> Row {
|
||||||
|
Row::with_height(self.width, self.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a composed row.
|
||||||
|
pub fn print_row(&mut self, row: &Row) -> std::io::Result<()> {
|
||||||
|
row.pretty_print(&mut self.out, self.first)?;
|
||||||
|
self.first = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the terminal state (also done on drop).
|
||||||
|
pub fn finish(&mut self) -> std::io::Result<()> {
|
||||||
|
let result = (|| {
|
||||||
|
self.out.write_all(UNICODE_RESET.as_bytes())?;
|
||||||
|
self.out.write_all(VAXIS_DEINIT.as_bytes())?;
|
||||||
|
self.out.flush()
|
||||||
|
})();
|
||||||
|
if self.raw {
|
||||||
|
self.raw = false;
|
||||||
|
ratatui::crossterm::terminal::disable_raw_mode()?;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W: Write> Drop for PrettySession<W> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.raw {
|
||||||
|
let _ = ratatui::crossterm::terminal::disable_raw_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn render(row: &Row, first: bool) -> String {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
row.pretty_print(&mut v, first).unwrap();
|
||||||
|
String::from_utf8(v).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_text_and_skip() {
|
||||||
|
let mut row = Row::with_height(20, 5);
|
||||||
|
let r = row.print_segment("abc", Style::default(), 0);
|
||||||
|
assert_eq!(3, r.col);
|
||||||
|
row.print_segment("x", Style::default(), 7);
|
||||||
|
assert_eq!(
|
||||||
|
"\x1b[?25l\x1b[?2026h\x1b[mabc\x1b[4Cx\r\n\x1b[m\x1b[?2026l",
|
||||||
|
render(&row, false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_row_prefix() {
|
||||||
|
let mut row = Row::with_height(5, 5);
|
||||||
|
row.print_segment("a", Style::default(), 0);
|
||||||
|
assert!(render(&row, true).starts_with("\r\x1b[m\x1b[J\x1b[?25l\x1b[?2026h\x1b[ma"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn styles_match_vaxis_order() {
|
||||||
|
let mut row = Row::with_height(40, 5);
|
||||||
|
let red = Style {
|
||||||
|
fg: Color::Index(1),
|
||||||
|
..Style::default()
|
||||||
|
};
|
||||||
|
let dim = Style {
|
||||||
|
dim: true,
|
||||||
|
..Style::default()
|
||||||
|
};
|
||||||
|
let magenta = Style {
|
||||||
|
fg: Color::Index(5),
|
||||||
|
..Style::default()
|
||||||
|
};
|
||||||
|
let rgb = Style {
|
||||||
|
fg: Color::Rgb(1, 2, 3),
|
||||||
|
bg: Color::Rgb(4, 5, 6),
|
||||||
|
..Style::default()
|
||||||
|
};
|
||||||
|
let res = row.print(&[("su", red), (":", dim), ("x", magenta), ("c", rgb)], 0);
|
||||||
|
assert_eq!(5, res.col);
|
||||||
|
assert_eq!(
|
||||||
|
"\x1b[?25l\x1b[?2026h\x1b[m\x1b[31msu\x1b[39m\x1b[2m:\x1b[35m\x1b[22mx\x1b[38:2:1:2:3m\x1b[48:2:4:5:6mc\r\n\x1b[m\x1b[?2026l",
|
||||||
|
render(&row, false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrapping_onto_following_rows() {
|
||||||
|
let mut row = Row::with_height(5, 3);
|
||||||
|
let r = row.print_segment("abcdefg", Style::default(), 2);
|
||||||
|
assert_eq!((4, 1), (r.col, r.row));
|
||||||
|
assert_eq!(
|
||||||
|
"\x1b[?25l\x1b[?2026h\x1b[m\x1b[2Cabc\n\rdefg\r\n\x1b[m\x1b[?2026l",
|
||||||
|
render(&row, false)
|
||||||
|
);
|
||||||
|
let mut row = Row::with_height(5, 1);
|
||||||
|
assert!(row.print_segment("abcdefg", Style::default(), 0).overflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wide_chars_and_overflow() {
|
||||||
|
let mut row = Row::with_height(5, 5);
|
||||||
|
let r = row.print_segment("a\u{4e2d}b", Style::default(), 0);
|
||||||
|
assert_eq!(4, r.col);
|
||||||
|
assert_eq!(2, gwidth("\u{4e2d}"));
|
||||||
|
let r = row.print_segment("zz", Style::default(), 3);
|
||||||
|
assert!(!r.overflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! `+validate-config` (ZIG/src/cli/validate_config.zig).
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use ghostty_config::config::Config;
|
||||||
|
use ghostty_config::config_struct;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
config_struct! {
|
||||||
|
pub struct Options {
|
||||||
|
/// The path of the config file to validate. If this isn't specified,
|
||||||
|
/// then the default config file paths will be validated.
|
||||||
|
"config-file" => config_file: Option<String> = None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli_options!(Options, help);
|
||||||
|
|
||||||
|
/// The `validate-config` command is used to validate a Ghostty config file.
|
||||||
|
///
|
||||||
|
/// When executed without any arguments, this will load the config from the default
|
||||||
|
/// location.
|
||||||
|
///
|
||||||
|
/// Flags:
|
||||||
|
///
|
||||||
|
/// * `--config-file`: can be passed to validate a specific target config file in
|
||||||
|
/// a non-default location
|
||||||
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||||
|
let mut opts = Options::default();
|
||||||
|
crate::parse_options(&mut opts, args)?;
|
||||||
|
|
||||||
|
let mut stdout = std::io::BufWriter::new(std::io::stdout().lock());
|
||||||
|
let result = run_inner(&opts, &mut stdout);
|
||||||
|
stdout.flush()?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result<u8, Error> {
|
||||||
|
let mut cfg = Config::new_default();
|
||||||
|
|
||||||
|
// If a config path is passed, validate it, otherwise validate default configs
|
||||||
|
if let Some(config_path) = &opts.config_file {
|
||||||
|
let abs_path = std::fs::canonicalize(config_path)?;
|
||||||
|
cfg.load_file(&abs_path.to_string_lossy())?;
|
||||||
|
cfg.load_recursive_files()?;
|
||||||
|
} else {
|
||||||
|
cfg = Config::load()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.finalize()?;
|
||||||
|
|
||||||
|
if !cfg.diagnostics.items().is_empty() {
|
||||||
|
for diag in cfg.diagnostics.items() {
|
||||||
|
writeln!(stdout, "{diag}")?;
|
||||||
|
}
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn write_tmp(name: &str, content: &str) -> String {
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("ghostty-cli-vc-{}-{name}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let p = dir.join("config");
|
||||||
|
std::fs::write(&p, content).unwrap();
|
||||||
|
p.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_good_file() {
|
||||||
|
let p = write_tmp("good", "font-size = 14\n");
|
||||||
|
let opts = Options {
|
||||||
|
config_file: Some(p),
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
assert_eq!(0, run_inner(&opts, &mut out).unwrap());
|
||||||
|
assert_eq!(0, out.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_bad_file_prints_diagnostics() {
|
||||||
|
let p = write_tmp("bad", "not-a-key = 1\nfont-size = abc\n");
|
||||||
|
let opts = Options {
|
||||||
|
config_file: Some(p.clone()),
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
assert_eq!(1, run_inner(&opts, &mut out).unwrap());
|
||||||
|
let s = String::from_utf8(out).unwrap();
|
||||||
|
assert!(
|
||||||
|
s.contains(&format!("{p}:1:not-a-key: unknown field\n")),
|
||||||
|
"{s}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
s.contains(&format!("{p}:2:font-size: invalid value \"abc\"\n")),
|
||||||
|
"{s}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_missing_file_is_error() {
|
||||||
|
let opts = Options {
|
||||||
|
config_file: Some("/nonexistent/ghostty/config".into()),
|
||||||
|
};
|
||||||
|
assert!(run_inner(&opts, &mut Vec::new()).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
//! `+version` (ZIG/src/cli/version.zig).
|
||||||
|
|
||||||
|
use std::io::{IsTerminal, Write};
|
||||||
|
|
||||||
|
use crate::build_info::{self, BuildInfo};
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
/// The `version` command is used to display information about Ghostty. Recognized as
|
||||||
|
/// either `+version` or `--version`.
|
||||||
|
pub fn run(_args: &[String]) -> Result<u8, Error> {
|
||||||
|
let stdout = std::io::stdout();
|
||||||
|
let tty = stdout.is_terminal();
|
||||||
|
let mut out = std::io::BufWriter::new(stdout.lock());
|
||||||
|
write_version(&mut out, &build_info::get(), tty)?;
|
||||||
|
out.flush()?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_na(v: Option<crate::build_info::Version>) -> String {
|
||||||
|
v.map_or_else(|| "unknown".to_string(), |v| v.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the version text.
|
||||||
|
pub fn write_version(out: &mut dyn Write, info: &BuildInfo, tty: bool) -> std::io::Result<()> {
|
||||||
|
if tty {
|
||||||
|
if let Some(commit) = &info.commit {
|
||||||
|
write!(
|
||||||
|
out,
|
||||||
|
"\x1b]8;;https://github.com/ghostty-org/ghostty/commit/{commit}\x1b\\"
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write!(out, "Ghostty {}\n\n", info.version_string)?;
|
||||||
|
if tty {
|
||||||
|
write!(out, "\x1b]8;;\x1b\\")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln!(out, "Version")?;
|
||||||
|
writeln!(out, " - version: {}", info.version_string)?;
|
||||||
|
writeln!(out, " - channel: {}", info.release_channel)?;
|
||||||
|
|
||||||
|
writeln!(out, "Build Config")?;
|
||||||
|
// The Zig build prints its compiler version here.
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" - Rust version : {}",
|
||||||
|
option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("unknown")
|
||||||
|
)?;
|
||||||
|
writeln!(out, " - build mode : {}", info.build_mode)?;
|
||||||
|
writeln!(out, " - app runtime : {}", info.app_runtime)?;
|
||||||
|
writeln!(out, " - font engine : {}", info.font_backend)?;
|
||||||
|
writeln!(out, " - renderer : {}", info.renderer)?;
|
||||||
|
// There is no libxev in the Rust port.
|
||||||
|
writeln!(out, " - libxev : n/a")?;
|
||||||
|
if info.app_runtime == ".gtk" {
|
||||||
|
let kernel_info = ghostty_os::get_kernel_info();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" - kernel version: {}",
|
||||||
|
kernel_info
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("Kernel information unavailable")
|
||||||
|
)?;
|
||||||
|
let de = match ghostty_os::desktop_environment() {
|
||||||
|
ghostty_os::DesktopEnvironment::Gnome => "gnome",
|
||||||
|
ghostty_os::DesktopEnvironment::Macos => "macos",
|
||||||
|
ghostty_os::DesktopEnvironment::Other => "other",
|
||||||
|
ghostty_os::DesktopEnvironment::Windows => "windows",
|
||||||
|
};
|
||||||
|
writeln!(out, " - desktop env : {de}")?;
|
||||||
|
writeln!(out, " - GTK version :")?;
|
||||||
|
writeln!(out, " build : {}", version_na(info.gtk_build))?;
|
||||||
|
writeln!(out, " runtime : {}", version_na(info.gtk_runtime))?;
|
||||||
|
writeln!(out, " - libadwaita : enabled")?;
|
||||||
|
writeln!(out, " build : {}", version_na(info.adw_build))?;
|
||||||
|
writeln!(out, " runtime : {}", version_na(info.adw_runtime))?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" - libX11 : {}",
|
||||||
|
if info.x11 { "enabled" } else { "disabled" }
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" - libwayland : {}",
|
||||||
|
if info.wayland { "enabled" } else { "disabled" }
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_output_layout() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
write_version(&mut buf, &BuildInfo::default(), false).unwrap();
|
||||||
|
let s = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(s.starts_with(
|
||||||
|
"Ghostty 1.3.1\n\nVersion\n - version: 1.3.1\n - channel: stable\nBuild Config\n"
|
||||||
|
));
|
||||||
|
assert!(s.contains(" - libwayland : enabled\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_tty_link() {
|
||||||
|
let info = BuildInfo {
|
||||||
|
commit: Some("abc".into()),
|
||||||
|
..BuildInfo::default()
|
||||||
|
};
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
write_version(&mut buf, &info, true).unwrap();
|
||||||
|
let s = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(
|
||||||
|
s.starts_with("\x1b]8;;https://github.com/ghostty-org/ghostty/commit/abc\x1b\\Ghostty")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# ghostty-cli port notes (C1)
|
||||||
|
|
||||||
|
Crate `crates/ghostty-cli` (deps: ghostty-config, ghostty-input, ghostty-os; plus
|
||||||
|
ratatui/crossterm for TUIs, flate2 for boo frames, unicode-width). Run end to end with
|
||||||
|
`cargo run -p ghostty-cli --example cli -- +list-actions --docs` (the example behaves
|
||||||
|
like `ghostty` for actions via `ghostty_cli::run_from_args`, which also reproduces
|
||||||
|
main_ghostty.zig's "multiple/unknown action" errors). Do not edit the root crate:
|
||||||
|
wire `run_from_args(&std::env::args().collect::<Vec<_>>())` into its main later.
|
||||||
|
|
||||||
|
## API
|
||||||
|
Every action module exposes `pub fn run(args: &[String]) -> Result<u8, Error>`; `args` is the
|
||||||
|
full argv (argv[0] skipped, `+action` args ignored, like `args.argsIterator`). `--help`/`-h`
|
||||||
|
yields `Error::ActionHelpRequested`, which `Action::run` turns into the help text (stdout, exit 0).
|
||||||
|
`Action::{ALL,name,from_name,file,help_text,run}`, `action::{detect_iter,ActionEnum,SpecialCase}`.
|
||||||
|
|
||||||
|
## File -> module
|
||||||
|
| Zig | Rust |
|
||||||
|
|---|---|
|
||||||
|
| cli.zig / action.zig | `action.rs` (generic detection + 7 tests) |
|
||||||
|
| ghostty.zig | `ghostty.rs` (`Action`, dispatch, help, `run_from_args`; 5 tests) |
|
||||||
|
| help.zig, version.zig | `help.rs`, `version.rs` (+ `build_info.rs`) |
|
||||||
|
| list_actions/list_colors/list_keybinds | same names; pretty output via `tui.rs` |
|
||||||
|
| list_themes.zig | `list_themes.rs` (listing) + `list_themes/preview.rs` (ratatui TUI) |
|
||||||
|
| show_config/validate_config/edit_config/crash_report | same names |
|
||||||
|
| ssh_cache.zig + ssh-cache/{Entry,DiskCache} | `ssh_cache.rs`, `ssh_cache/{entry,disk_cache}.rs` |
|
||||||
|
| boo.zig (+ build/framegen/frames) | `boo.rs`, `boo_frames.deflate` (235 frames, raw deflate of frames joined by \x01) |
|
||||||
|
| tui.zig | `tui.rs` (+ a small vaxis `prettyPrint` clone) |
|
||||||
|
| list_fonts/show_face/new_window | stubs: Options + help + `Error::NotYetAvailable` (TODO(integration)) |
|
||||||
|
|
||||||
|
Help texts (`help/*.txt`) are the `run` doc comments of the Zig files, extracted with the
|
||||||
|
helpgen rules (common-indent strip); `--help` output is byte-identical to Ghostty 1.3.1.
|
||||||
|
|
||||||
|
## Oracle verification (Ghostty 1.3.1 at /usr/bin/ghostty)
|
||||||
|
Byte-identical (stdout, stderr, exit code): `+list-actions` (+`--docs`, `--help`), `+list-keybinds`
|
||||||
|
(`--plain`, `--default`, `--docs`; also with a custom config containing chords, chains, tables,
|
||||||
|
catch_all, unicode keys), `+list-themes --plain` (`--path`, `--color=dark|light`; with
|
||||||
|
`GHOSTTY_RESOURCES_DIR=/usr/share/ghostty`), `+help`, `--help`, `-h`, `+validate-config` (ok and
|
||||||
|
missing file), `+crash-report`, `+ssh-cache`, bad action / multiple actions / bad flag errors,
|
||||||
|
`--help` of every action, `+show-config` (except the two known config deviations: `auto-update-channel`
|
||||||
|
tip vs stable, `gtk-opengl-debug` debug build). **Pretty (tty) output of `+list-colors` and
|
||||||
|
`+list-keybinds` is byte-identical in a pty** for widths 20..200 (including line wrapping and
|
||||||
|
the first-row/teardown sequences of libvaxis); verified with a pty harness.
|
||||||
|
|
||||||
|
Known differences:
|
||||||
|
* `+list-colors --plain`: Ghostty 1.3.1 never flushes its 4 KiB stdout buffer on this path, so its
|
||||||
|
output is truncated at 617 of 782 lines. We print all 782 (first 617 identical).
|
||||||
|
* `+list-colors` in a tty narrower than one column: Zig divides by zero; we clamp to one column.
|
||||||
|
* `+version` differs in version/build info (placeholders in `build_info`; TODO(integration): root
|
||||||
|
crate / apprt-gtk should call `build_info::set` with the real GTK/libadwaita versions, commit
|
||||||
|
and channel). "Zig version" row became "Rust version", "libxev" row prints `n/a`.
|
||||||
|
* `+edit-config` exec failure prints `error.FileNotFound`/`error.AccessDenied` for those kinds,
|
||||||
|
otherwise the Rust io error text.
|
||||||
|
* `+new-window`: the Zig parse hook swallows `--help`; ported as is. IPC not available.
|
||||||
|
|
||||||
|
## Interactive `+list-themes` (ratatui)
|
||||||
|
Same layout as the vaxis version: 32-column list with `❯ name ❮` selection, preview (header, 16
|
||||||
|
color swatches with decimal/hex numbers, 24-line code sample, lorem ipsum filler, diagnostics /
|
||||||
|
load-error panels), help (60x22), search (3-high box at bottom) and save (92x17) overlays, all
|
||||||
|
key bindings (q/Esc/^C, ?/F1/^H, /, ^X ^/, j/k/arrows, PgUp/PgDn (20), Home/End, h/x/d hex
|
||||||
|
toggle, c / C copy name/path via OSC 52, f cycle filter, Enter save dialog, w write
|
||||||
|
`auto/theme.ghostty`), mouse wheel / click / hover. Differences: no OSC 8 hyperlinks on names,
|
||||||
|
double/dashed/dotted/curly underlines drawn as single, color scheme not queried live (defaults to
|
||||||
|
light like Zig; `COLORFGBG` used as a hint), theme load errors during filtering are ignored
|
||||||
|
(Zig aborts), `zf` fuzzy ranking replaced by a subsequence matcher (only matching, not order, is
|
||||||
|
used). Logic (filtering, navigation, windowing, search, save) is unit tested; rendering is tested
|
||||||
|
against a ratatui `Buffer`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
Ported every Zig test in the ported files: action.zig (7 detect tests), ghostty.zig (5 parse
|
||||||
|
action tests), ssh-cache/Entry.zig (6), DiskCache.zig (4 operation tests + isValidHost,
|
||||||
|
isValidUser, isValidCacheKey). Skipped: none. 86 tests total including new ones (help/version
|
||||||
|
output, show-config, validate-config, crash-report, ssh-cache action, list-colors/keybinds/themes,
|
||||||
|
tui row rendering vs vaxis bytes, boo frame parsing of all 235 frames, preview state machine,
|
||||||
|
new-window arg hook).
|
||||||
|
|
||||||
|
## TODO(integration) / gaps
|
||||||
|
* `list-fonts`, `show-face` (need ghostty-font), `new-window` (GTK IPC): return `Error::NotYetAvailable`.
|
||||||
|
* `build_info::set` for real version/GTK info.
|
||||||
|
* ghostty-vt `x11_color` lacks a key listing and loses name case: `list_colors` re-parses
|
||||||
|
`ghostty-vt/src/res/rgb.txt` through a relative `include_str!` path.
|
||||||
|
* ghostty-input `ActionKey::docs()` has an extra trailing blank line for 4 actions
|
||||||
|
(adjust_selection, ...) compared to Ghostty's help strings; `list_actions::generate_plaintext`
|
||||||
|
is a local copy of `helpgen_actions::generate` that normalises trailing newlines. Fix the data in
|
||||||
|
ghostty-input and switch back to `helpgen_actions::generate`.
|
||||||
|
* crash-report only lists (sentry is not ported). `ssh-cache` locking uses `File::try_lock`;
|
||||||
|
like Zig, a lock held by another process is ignored (the boolean result is unused upstream).
|
||||||
|
* `Config::load` and `finalize` are called twice in `validate-config` (as in Zig).
|
||||||
Reference in new issue
Block a user