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:
ddidderr committed 2026-10-11 22:22:01 +02:00
1 parent 5462bd8e3e
commit f2c4b40062
46 files changed
+7135 -6

No files matched your search

+239
View File
@@ -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());
}
}
+274
View File
@@ -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.
+85
View File
@@ -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()
}
+176
View File
@@ -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();
}
}
+112
View File
@@ -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)
}
+48
View File
@@ -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())
}
}
+331
View File
@@ -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());
}
}
+93
View 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"));
}
}
+1
View File
@@ -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.
+5
View File
@@ -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`.
+21
View File
@@ -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.
+23
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
The `version` command is used to display information about Ghostty. Recognized as
either `+version` or `--version`.
+45
View File
@@ -1 +1,46 @@
//! 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};
+108
View File
@@ -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)));
}
}
+208
View File
@@ -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());
}
}
+57
View File
@@ -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"))
}
+470
View File
@@ -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:?}");
}
}
+379
View File
@@ -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"));
}
}
+45
View File
@@ -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.
+239
View File
@@ -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(&param)? {
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);
}
}
+116
View File
@@ -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))
}
}
+128
View File
@@ -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)
));
}
}
+78
View File
@@ -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"))
}
+308
View File
@@ -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"));
}
}
+179
View File
@@ -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));
}
}
+427
View File
@@ -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);
}
}
+113
View File
@@ -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());
}
}
+120
View File
@@ -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")
);
}
}