diff --git a/crates/ghostty-config/Cargo.toml b/crates/ghostty-config/Cargo.toml index bf63bcd..138debe 100644 --- a/crates/ghostty-config/Cargo.toml +++ b/crates/ghostty-config/Cargo.toml @@ -5,9 +5,15 @@ edition.workspace = true license.workspace = true [dependencies] +const_format = "0.2.36" ghostty-input.workspace = true ghostty-os.workspace = true ghostty-vt.workspace = true +log = "0.4.34" +thiserror = "2.0.21" [lints] workspace = true + +[dev-dependencies] +onig = "6.5.3" diff --git a/crates/ghostty-config/src/cli_args.rs b/crates/ghostty-config/src/cli_args.rs new file mode 100644 index 0000000..cfff20a --- /dev/null +++ b/crates/ghostty-config/src/cli_args.rs @@ -0,0 +1,1433 @@ +//! Generic CLI / config-line argument parser (port of `cli/args.zig`). +//! +//! The Zig implementation uses comptime reflection over the destination +//! struct. In Rust this is done with two traits: +//! +//! * [`ConfigField`]: implemented by every type that can be a field value +//! (bool, ints, floats, strings, enums, repeatable lists, ...). It knows +//! how to parse itself from a `--key=value` value and how to format itself +//! in the `+show-config` format. +//! * [`CliTarget`]: implemented by the destination struct (usually generated +//! with the [`config_struct!`](crate::config_struct) macro). It maps a key +//! to a field and optionally provides diagnostics, compatibility handlers, +//! a manual-parse hook and a `--help` hook. + +use crate::comma_splitter::CommaSplitter; +use crate::diagnostics::{Diagnostic, DiagnosticList, Location}; +use crate::formatter::EntryFormatter; +use std::fmt; +use std::io::BufRead; + +/// For trimming +pub const WHITESPACE: &str = " \t"; + +/// Trim [`WHITESPACE`] characters from both ends. +#[must_use] +pub fn trim_ws(s: &str) -> &str { + s.trim_matches(|c| c == ' ' || c == '\t') +} + +/// The base errors for arg parsing. Additional errors can be returned due +/// to type-specific parsing but these are always possible. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArgError { + ValueRequired, + InvalidField, + InvalidValue, + /// A type-specific error. The string is the (Zig) error name and is + /// shown to the user as `unknown error `. + Other(String), +} + +impl ArgError { + #[must_use] + pub fn other(name: &str) -> Self { + ArgError::Other(name.to_string()) + } +} + +impl fmt::Display for ArgError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ArgError::ValueRequired => f.write_str("ValueRequired"), + ArgError::InvalidField => f.write_str("InvalidField"), + ArgError::InvalidValue => f.write_str("InvalidValue"), + ArgError::Other(s) => f.write_str(s), + } + } +} + +impl std::error::Error for ArgError {} + +impl From for ArgError { + fn from(e: crate::comma_splitter::Error) -> Self { + ArgError::Other(e.to_string()) + } +} + +/// A type that can be the value of a configuration / CLI field. +pub trait ConfigField: Clone + PartialEq + Default + fmt::Debug { + /// For enum types, the list of valid values (used in error messages). + const VALID_VALUES: Option<&'static [&'static str]> = None; + + /// Parse `value` (the text after `=`, `None` if there was no `=`) into + /// `self`. Repeatable types accumulate; scalar types overwrite. + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError>; + + /// Called when the value was set to the empty string: reset to the + /// field's default value (Zig: the `init` function or the declared + /// default). + fn reset(&mut self, default: Self) { + *self = default; + } + + /// Write this value in the `+show-config` format. + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result; +} + +/// The destination of CLI parsing. +pub trait CliTarget { + /// Parse a single key/value pair into the destination + /// (Zig: `parseIntoField`). Returns `ArgError::InvalidField` if the key + /// is unknown. + fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError>; + + /// For enum-typed fields, the valid values (used for diagnostics). + fn valid_values(&self, _key: &str) -> Option<&'static [&'static str]> { + None + } + + /// The diagnostic list, if supported (Zig: `_diagnostics` field). + fn diagnostics_mut(&mut self) -> Option<&mut DiagnosticList> { + None + } + + /// Manual parse hook (Zig: `parseManuallyHook`). Return `Ok(false)` to + /// stop parsing. + fn parse_manually_hook( + &mut self, + _arg: &str, + _iter: &mut dyn ArgIter, + ) -> Result { + Ok(true) + } + + /// Whether `--help`/`-h` is supported (Zig: `help` decl). If so, + /// [`CliTarget::help`] is called when seen. + fn supports_help(&self) -> bool { + false + } + + fn help(&mut self) -> Result<(), ArgError> { + Ok(()) + } + + /// Compatibility handler (Zig: `compatibility` decl). Called when + /// parsing a field fails. Return true if handled. + fn compatibility(&mut self, _key: &str, _value: Option<&str>) -> bool { + false + } +} + +/// An iterator over arguments (Zig: any iterator with `next`). +pub trait ArgIter { + fn next_arg(&mut self) -> Option; + + /// Location for diagnostics. + fn location(&self) -> Option { + None + } +} + +/// Parse the command line arguments from iter into dst. +/// +/// If the destination reports diagnostics (see +/// [`CliTarget::diagnostics_mut`]) then errors for individual fields are +/// added to the list rather than returned. +pub fn parse(dst: &mut T, iter: &mut dyn ArgIter) -> Result<(), ArgError> { + while let Some(arg) = iter.next_arg() { + // Do manual parsing if we have a hook for it. + if !dst.parse_manually_hook(&arg, iter)? { + return Ok(()); + } + + // If the destination supports help then we check for it, call + // the help function and return. + if dst.supports_help() && (arg == "--help" || arg == "-h") { + dst.help()?; + return Ok(()); + } + + // If this doesn't start with "--" then it isn't a config + // flag. We don't support positional arguments or configuration + // values set with spaces so this is an error. + let Some(rest) = arg.strip_prefix("--") else { + let loc = iter.location().unwrap_or_default(); + let Some(diags) = dst.diagnostics_mut() else { + return Err(ArgError::InvalidField); + }; + diags.append(Diagnostic { + key: arg.clone(), + message: "invalid field".to_string(), + location: loc, + }); + continue; + }; + + let (key, value): (&str, Option<&str>) = match rest.find('=') { + Some(idx) => (&rest[..idx], Some(&rest[idx + 1..])), + None => (rest, None), + }; + + if let Err(err) = dst.parse_into_field(key, value) { + // Fall back to compatibility handlers if able. + if dst.compatibility(key, value) { + log::info!( + "compatibility handler for {key} handled error, you may be using a deprecated field: {err}" + ); + continue; + } + + let loc = iter.location().unwrap_or_default(); + let message = match &err { + ArgError::InvalidField => "unknown field".to_string(), + ArgError::ValueRequired => { + let mut m = String::from("value required"); + format_values(dst.valid_values(key), &mut m); + m + } + ArgError::InvalidValue => { + let mut m = match value { + Some(v) => format!("invalid value \"{v}\""), + None => "invalid value \"null\"".to_string(), + }; + format_values(dst.valid_values(key), &mut m); + m + } + ArgError::Other(name) => format!("unknown error error.{name}"), + }; + + let Some(diags) = dst.diagnostics_mut() else { + return Err(err); + }; + diags.append(Diagnostic { + key: key.to_string(), + message, + location: loc, + }); + } + } + + Ok(()) +} + +fn format_values(values: Option<&[&str]>, out: &mut String) { + if let Some(values) = values { + out.push_str(", valid values are: "); + out.push_str(&values.join(", ")); + } +} + +/// Convenience for a compatibility handler that renames a field: parses +/// `value` into the field `to` of `dst`. Returns true if it succeeded. +pub fn compatibility_renamed( + dst: &mut T, + to: &str, + value: Option<&str>, +) -> bool { + match dst.parse_into_field(to, value) { + Ok(()) => true, + Err(err) => { + log::warn!("error parsing renamed field {to}: {err}"); + false + } + } +} + +/// Split a tagged-union value `tag:value` (Zig: `parseTaggedUnion`). The tag +/// is trimmed of whitespace. If there is no colon the value is empty. +#[must_use] +pub fn split_tagged_union(v: &str) -> (&str, &str) { + match v.find(':') { + Some(idx) => (trim_ws(&v[..idx]), &v[idx + 1..]), + None => (trim_ws(v), ""), + } +} + +/// Parse an "auto struct" value like `a:hello,b:42` (Zig: `parseAutoStruct`). +/// +/// Entries are split by commas (with quoting / escaping, see +/// [`CommaSplitter`]). Each entry must be `key:value`; the value is trimmed +/// and, if double-quoted, decoded as a Zig string literal. For every entry +/// `set(key, value)` is called; it must return `Ok(true)` if the key matched +/// a field, `Ok(false)` if the key is unknown (which yields `InvalidValue`). +/// The caller is responsible for tracking which fields were set and for +/// applying defaults / required-field checks afterwards. +pub fn parse_auto_struct_with( + v: &str, + mut set: impl FnMut(&str, &str) -> Result, +) -> Result<(), ArgError> { + let mut iter = CommaSplitter::new(v); + while let Some(entry) = iter.next_field()? { + let idx = entry.find(':').ok_or(ArgError::InvalidValue)?; + let key = trim_ws(&entry[..idx]); + let raw = trim_ws(&entry[idx + 1..]); + + let decoded; + let value = if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') { + decoded = parse_zig_string_literal(raw).ok_or(ArgError::InvalidValue)?; + decoded.as_str() + } else { + raw + }; + + if !set(key, value)? { + return Err(ArgError::InvalidValue); + } + } + Ok(()) +} + +/// Decode a double-quoted Zig string literal (including the quotes). +/// Returns `None` on failure. Bytes produced by `\x` escapes that do not form +/// valid UTF-8 are converted lossily. +#[must_use] +pub fn parse_zig_string_literal(s: &str) -> Option { + let b = s.as_bytes(); + if b.len() < 2 || b[0] != b'"' || b[b.len() - 1] != b'"' { + return None; + } + let inner = &b[1..b.len() - 1]; + let mut out: Vec = Vec::with_capacity(inner.len()); + let mut i = 0; + while i < inner.len() { + let c = inner[i]; + match c { + b'\\' => { + i += 1; + let e = *inner.get(i)?; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'\\' => out.push(b'\\'), + b'\'' => out.push(b'\''), + b'"' => out.push(b'"'), + b'x' => { + let h = inner.get(i + 1..i + 3)?; + let h = std::str::from_utf8(h).ok()?; + out.push(u8::from_str_radix(h, 16).ok()?); + i += 2; + } + b'u' => { + if *inner.get(i + 1)? != b'{' { + return None; + } + let close = inner[i + 2..].iter().position(|&x| x == b'}')? + i + 2; + let h = std::str::from_utf8(&inner[i + 2..close]).ok()?; + if h.is_empty() { + return None; + } + let cp = u32::from_str_radix(h, 16).ok()?; + let ch = char::from_u32(cp)?; + let mut buf = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); + i = close; + } + _ => return None, + } + } + b'"' | b'\n' => return None, + _ => out.push(c), + } + i += 1; + } + Some(String::from_utf8_lossy(&out).into_owned()) +} + +/// Parse a "packed struct" value (comma separated names, optionally +/// prefixed with `no-`, or a standalone boolean). `names` are the field +/// names; `set(index, value)` is called for each setting. +pub fn parse_packed_struct_with( + v: &str, + names: &[&str], + mut set: impl FnMut(usize, bool), +) -> Result<(), ArgError> { + // Allow standalone boolean values like "true" and "false" to + // turn on or off all of the struct's fields. + if let Ok(b) = parse_bool(v) { + for i in 0..names.len() { + set(i, b); + } + return Ok(()); + } + + for part_raw in v.split(',') { + let trimmed = trim_ws(part_raw); + let (part, value) = match trimmed.strip_prefix("no-") { + Some(rest) => (rest, false), + None => (trimmed, true), + }; + match names.iter().position(|n| *n == part) { + Some(i) => set(i, value), + None => return Err(ArgError::InvalidValue), + } + } + Ok(()) +} + +pub fn parse_bool(v: &str) -> Result { + match v { + "1" | "t" | "T" | "true" => Ok(true), + "0" | "f" | "F" | "false" => Ok(false), + _ => Err(ArgError::InvalidValue), + } +} + +/// Parse an integer like Zig's `std.fmt.parseInt(T, s, 0)`: optional sign, +/// optional `0x`/`0o`/`0b` prefix, underscores between digits allowed. +pub fn parse_int_base0(s: &str) -> Option { + let (neg, rest) = match s.as_bytes().first()? { + b'-' => (true, &s[1..]), + b'+' => (false, &s[1..]), + _ => (false, s), + }; + let (base, digits) = if rest.len() >= 2 && rest.as_bytes()[0] == b'0' { + match rest.as_bytes()[1] { + b'x' => (16, &rest[2..]), + b'o' => (8, &rest[2..]), + b'b' => (2, &rest[2..]), + _ => (10, rest), + } + } else { + (10, rest) + }; + if digits.is_empty() { + return None; + } + let db = digits.as_bytes(); + if db[0] == b'_' || db[db.len() - 1] == b'_' || digits.contains("__") { + return None; + } + let mut acc: i128 = 0; + for &c in db { + if c == b'_' { + continue; + } + let d = i128::from((c as char).to_digit(base)?); + acc = acc.checked_mul(i128::from(base))?.checked_add(d)?; + } + Some(if neg { -acc } else { acc }) +} + +// --------------------------------------------------------------------- +// ConfigField impls for primitives + +impl ConfigField for bool { + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { + *self = parse_bool(value.unwrap_or("t"))?; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + writeln!(f.writer, "{} = {}", f.name, self) + } +} + +macro_rules! impl_int_field { + ($($t:ty),*) => {$( + impl ConfigField for $t { + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { + let v = value.ok_or(ArgError::ValueRequired)?; + let n = parse_int_base0(v).ok_or(ArgError::InvalidValue)?; + *self = <$t>::try_from(n).map_err(|_| ArgError::InvalidValue)?; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + writeln!(f.writer, "{} = {}", f.name, self) + } + } + )*}; +} +impl_int_field!(u8, u16, u32, u64, usize, i8, i16, i32, i64, isize); + +macro_rules! impl_float_field { + ($($t:ty),*) => {$( + impl ConfigField for $t { + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { + let v = value.ok_or(ArgError::ValueRequired)?; + *self = v.parse::<$t>().map_err(|_| ArgError::InvalidValue)?; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + writeln!(f.writer, "{} = {}", f.name, self) + } + } + )*}; +} +impl_float_field!(f32, f64); + +impl ConfigField for String { + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { + *self = value.ok_or(ArgError::ValueRequired)?.to_string(); + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + writeln!(f.writer, "{} = {}", f.name, self) + } +} + +impl ConfigField for Option { + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { + // For optional fields, we just treat it as the child type. + if let Some(v) = self { + v.parse_cli(value) + } else { + let mut tmp = T::default(); + tmp.parse_cli(value)?; + *self = Some(tmp); + Ok(()) + } + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + match self { + Some(v) => v.format_entry(f), + None => writeln!(f.writer, "{} = ", f.name), + } + } +} + +// --------------------------------------------------------------------- +// Iterators + +/// An iterator that considers its location to be CLI args. It +/// iterates through an underlying iterator and increments a counter +/// to track the current CLI arg index. +/// +/// This also ignores any argument that starts with `+`. It assumes that +/// actions were parsed out before this iterator was created. +pub struct ArgsIterator> { + /// The underlying args iterator. + pub iterator: I, + /// Our current index into the iterator. This is 1-indexed because + /// the 0 value is used to indicate that we haven't read any values yet. + pub index: usize, +} + +impl> ArgsIterator { + pub fn new(iterator: I) -> Self { + Self { iterator, index: 0 } + } + + pub fn next(&mut self) -> Option { + loop { + let value = self.iterator.next()?; + self.index += 1; + // We ignore any argument that starts with "+". This is used + // to indicate actions and are expected to be parsed out before + // this iterator is created. + if value.starts_with('+') { + continue; + } + return Some(value); + } + } +} + +impl> ArgIter for ArgsIterator { + fn next_arg(&mut self) -> Option { + self.next() + } + + fn location(&self) -> Option { + Some(Location::Cli(self.index)) + } +} + +/// Create an args iterator for the process args. This will skip argv0. +#[must_use] +pub fn args_iterator() -> ArgsIterator> { + ArgsIterator::new(std::env::args().skip(1)) +} + +/// An iterator valid for arg parsing from a slice. +pub struct SliceIterator<'a> { + pub slice: &'a [String], + pub idx: usize, +} + +impl ArgIter for SliceIterator<'_> { + fn next_arg(&mut self) -> Option { + let v = self.slice.get(self.idx)?; + self.idx += 1; + Some(v.clone()) + } +} + +/// Construct a [`SliceIterator`] from a slice. +#[must_use] +pub fn slice_iterator(slice: &[String]) -> SliceIterator<'_> { + SliceIterator { slice, idx: 0 } +} + +/// A simple whitespace-splitting iterator used in tests and for +/// string-based argument parsing. +pub struct VecIterator { + items: std::vec::IntoIter, +} + +impl VecIterator { + #[must_use] + pub fn new(items: Vec) -> Self { + Self { + items: items.into_iter(), + } + } + + /// Split on spaces (no quoting except double quotes around a value). + #[must_use] + pub fn from_str_args(s: &str) -> Self { + Self::new(split_args(s)) + } +} + +impl ArgIter for VecIterator { + fn next_arg(&mut self) -> Option { + self.items.next() + } +} + +/// Split a string into args like `std.process.ArgIteratorGeneral`: whitespace +/// separated, double quotes group (and are removed). +#[must_use] +pub fn split_args(s: &str) -> Vec { + let mut out = vec![]; + let mut cur = String::new(); + let mut in_arg = false; + let mut quoted = false; + for c in s.chars() { + match c { + '"' => { + quoted = !quoted; + in_arg = true; + } + c if c.is_whitespace() && !quoted => { + if in_arg { + out.push(std::mem::take(&mut cur)); + in_arg = false; + } + } + c => { + cur.push(c); + in_arg = true; + } + } + } + if in_arg { + out.push(cur); + } + out +} + +/// Reads CLI args by line. Each CLI arg is expected to be a single line. +/// This is used to implement configuration files. +pub struct LineIterator { + /// Our stateful reader. + pub r: R, + /// Filepath that is used for diagnostics. It is only used for + /// diagnostic messages so it can be formatted however you want. + pub filepath: String, + /// The current line that we're on. 1-indexed; zero if we haven't read + /// any lines yet. + pub line: usize, +} + +impl LineIterator { + /// The maximum size a single line can be. We don't expect any + /// CLI arg to exceed this size. + pub const MAX_LINE_SIZE: usize = 4096; + + pub fn new(r: R) -> Self { + Self { + r, + filepath: String::new(), + line: 0, + } + } + + pub fn with_path(r: R, filepath: impl Into) -> Self { + Self { + r, + filepath: filepath.into(), + line: 0, + } + } + + pub fn next(&mut self) -> Option { + let mut buf = Vec::new(); + let entry: String = loop { + buf.clear(); + match self.r.read_until(b'\n', &mut buf) { + Ok(0) => return None, + Ok(_) => {} + Err(e) => { + log::warn!("cannot read from \"{}\": {e}", self.filepath); + return None; + } + } + if buf.last() == Some(&b'\n') { + buf.pop(); + } + if buf.len() > Self::MAX_LINE_SIZE - 2 { + log::warn!("cannot read from \"{}\": StreamTooLong", self.filepath); + return None; + } + + self.line += 1; + + let line = String::from_utf8_lossy(&buf); + // Trim any whitespace (including CR) around it + let trim = line.trim_matches(|c| c == ' ' || c == '\t' || c == '\r'); + + // Ignore blank lines and comments + if trim.is_empty() || trim.starts_with('#') { + continue; + } + break trim.to_string(); + }; + + let mut result = String::from("--"); + if let Some(idx) = entry.find('=') { + let key = trim_ws(&entry[..idx]); + let mut value = trim_ws(&entry[idx + 1..]); + + // Detect a quoted string. + if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + // Trim quotes since our CLI args processor expects + // quotes to already be gone. + value = &value[1..value.len() - 1]; + } + + result.push_str(key); + result.push('='); + result.push_str(value); + } else { + result.push_str(&entry); + } + Some(result) + } +} + +impl ArgIter for LineIterator { + fn next_arg(&mut self) -> Option { + self.next() + } + + fn location(&self) -> Option { + // If we have no filepath then we have no location. + if self.filepath.is_empty() { + return None; + } + Some(Location::File { + path: self.filepath.clone(), + line: self.line, + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::{config_enum, config_packed, config_struct}; + use std::io::Cursor; + + fn parse_str(dst: &mut T, s: &str) -> Result<(), ArgError> { + let mut it = VecIterator::from_str_args(s); + parse(dst, &mut it) + } + + // ---- test structs ------------------------------------------------- + + config_struct! { + pub struct Simple { + "a" => a: String = String::new(), + "b" => b: bool = false, + "b-f" => b_f: bool = true, + } + extra { + diags: DiagnosticList = DiagnosticList::default(), + } + } + impl Default for Simple { + fn default() -> Self { + Self::raw_default() + } + } + impl CliTarget for Simple { + fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { + self.parse_field(key, value) + } + } + + config_struct! { + pub struct Quoted { + "a" => a: u8 = 0, + "b" => b: String = String::new(), + } + } + impl CliTarget for Quoted { + fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { + self.parse_field(key, value) + } + } + + config_struct! { + pub struct Reset { + "a" => a: u8 = 42, + "b" => b: bool = false, + } + } + impl CliTarget for Reset { + fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { + self.parse_field(key, value) + } + } + + config_enum! { + pub enum OneTwo { One => "one", Two => "two" } + } + + config_struct! { + pub struct Diag { + "a" => a: String = String::new(), + "b" => b: OneTwo = OneTwo::One, + } + extra { + diags: DiagnosticList = DiagnosticList::default(), + } + } + impl CliTarget for Diag { + fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { + self.parse_field(key, value) + } + fn valid_values(&self, key: &str) -> Option<&'static [&'static str]> { + Self::valid_values_for(key) + } + fn diagnostics_mut(&mut self) -> Option<&mut DiagnosticList> { + Some(&mut self.diags) + } + } + + #[test] + fn parse_simple() { + let mut data = Simple::raw_default(); + parse_str(&mut data, "--a=42 --b --b-f=false").unwrap(); + assert_eq!(data.a, "42"); + assert!(data.b); + assert!(!data.b_f); + + // Reparsing works + parse_str(&mut data, "--a=84").unwrap(); + assert_eq!(data.a, "84"); + assert!(data.b); + assert!(!data.b_f); + } + + #[test] + fn parse_quoted_value() { + let mut data = Quoted::raw_default(); + parse_str(&mut data, "--a=\"42\" --b=\"hello!\"").unwrap(); + assert_eq!(data.a, 42); + assert_eq!(data.b, "hello!"); + } + + #[test] + fn parse_empty_value_resets_to_default() { + let mut data = Reset::raw_default(); + parse_str(&mut data, "--a= --b=").unwrap(); + assert_eq!(data.a, 42); + assert!(!data.b); + } + + #[test] + fn parse_positional_arguments_are_invalid() { + let mut data = Reset::raw_default(); + data.a = 0; + let r = parse_str(&mut data, "--a=84 what"); + assert_eq!(r, Err(ArgError::InvalidField)); + assert_eq!(data.a, 84); + } + + #[test] + fn parse_diagnostic_tracking() { + let mut data = Diag::raw_default(); + parse_str(&mut data, "--what --a=42").unwrap(); + assert_eq!(data.a, "42"); + assert_eq!(data.diags.items().len(), 1); + let diag = &data.diags.items()[0]; + assert_eq!(diag.location, Location::None); + assert_eq!(diag.key, "what"); + assert_eq!(diag.message, "unknown field"); + } + + #[test] + fn parse_diagnostic_location() { + let mut data = Diag::raw_default(); + let mut iter = LineIterator::with_path(Cursor::new("a=42\nwhat\nb=two"), "test"); + parse(&mut data, &mut iter).unwrap(); + assert_eq!(data.a, "42"); + assert_eq!(data.b, OneTwo::Two); + assert_eq!(data.diags.items().len(), 1); + let diag = &data.diags.items()[0]; + assert_eq!(diag.key, "what"); + assert_eq!(diag.message, "unknown field"); + assert_eq!( + diag.location, + Location::File { + path: "test".into(), + line: 2 + } + ); + } + + #[test] + fn parse_diagnostic_invalid_enum_value_lists_valid_values() { + let mut data = Diag::raw_default(); + parse_str(&mut data, "--b=three").unwrap(); + assert_eq!( + data.diags.items()[0].message, + "invalid value \"three\", valid values are: one, two" + ); + } + + pub struct Compat { + pub a: bool, + } + impl CliTarget for Compat { + fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { + if key == "a" { + // Fails for anything but a valid bool. + return self.a.parse_cli(value); + } + Err(ArgError::InvalidField) + } + fn compatibility(&mut self, key: &str, value: Option<&str>) -> bool { + if key == "a" && value == Some("yuh") { + self.a = true; + return true; + } + false + } + } + + #[test] + fn parse_compatibility_handler() { + let mut data = Compat { a: false }; + parse_str(&mut data, "--a=yuh").unwrap(); + assert!(data.a); + } + + pub struct Renamed { + pub a: bool, + pub b: bool, + } + impl CliTarget for Renamed { + fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { + match key { + "a" => self.a.parse_cli(value), + "b" => self.b.parse_cli(value), + _ => Err(ArgError::InvalidField), + } + } + fn compatibility(&mut self, key: &str, value: Option<&str>) -> bool { + if key == "old" { + return compatibility_renamed(self, "a", value); + } + false + } + } + + #[test] + fn parse_compatibility_renamed() { + let mut data = Renamed { a: false, b: false }; + parse_str(&mut data, "--old=true --b=true").unwrap(); + assert!(data.a); + assert!(data.b); + } + + config_struct! { + pub struct Underscore { + "a" => a: String = "12".to_string(), + } + } + impl CliTarget for Underscore { + fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { + // Underscore-prefixed fields are never parseable. + if key.starts_with('_') { + return Err(ArgError::InvalidField); + } + self.parse_field(key, value) + } + } + + #[test] + fn parse_into_field_ignore_underscore_prefixed_fields() { + let mut data = Underscore::raw_default(); + assert_eq!( + data.parse_into_field("_a", Some("42")), + Err(ArgError::InvalidField) + ); + assert_eq!(data.a, "12"); + } + + #[derive(Clone, PartialEq, Debug)] + pub struct WithInit { + pub v: String, + } + impl Default for WithInit { + fn default() -> Self { + Self { + v: "default".into(), + } + } + } + impl ConfigField for WithInit { + fn parse_cli(&mut self, _value: Option<&str>) -> Result<(), ArgError> { + Ok(()) + } + fn reset(&mut self, _default: Self) { + // `init` + self.v = "HELLO!".into(); + } + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + writeln!(f.writer, "{} = {}", f.name, self.v) + } + } + + config_struct! { + pub struct HasInit { + "a" => a: WithInit = WithInit::default(), + } + } + + #[test] + fn parse_into_field_struct_with_init_func() { + let mut data = HasInit::raw_default(); + data.parse_field("a", Some("")).unwrap(); + assert_eq!(data.a.v, "HELLO!"); + } + + macro_rules! one_field { + ($name:ident, $ty:ty, $def:expr) => { + config_struct! { + pub struct $name { + "v" => v: $ty = $def, + } + } + }; + } + + #[test] + fn parse_into_field_string() { + one_field!(S, String, String::new()); + let mut d = S::raw_default(); + d.parse_field("v", Some("42")).unwrap(); + assert_eq!(d.v, "42"); + } + + #[test] + fn parse_into_field_bool() { + one_field!(S, bool, false); + let mut d = S::raw_default(); + for t in ["1", "t", "T", "true"] { + d.parse_field("v", Some(t)).unwrap(); + assert!(d.v); + d.v = false; + } + for f in ["0", "f", "F", "false"] { + d.v = true; + d.parse_field("v", Some(f)).unwrap(); + assert!(!d.v); + } + } + + #[test] + fn parse_into_field_unsigned_numbers() { + one_field!(S, u8, 0); + let mut d = S::raw_default(); + d.parse_field("v", Some("1")).unwrap(); + assert_eq!(d.v, 1); + d.parse_field("v", Some("0x10")).unwrap(); + assert_eq!(d.v, 16); + assert_eq!(d.parse_field("v", Some("256")), Err(ArgError::InvalidValue)); + assert_eq!(d.parse_field("v", Some("-1")), Err(ArgError::InvalidValue)); + } + + #[test] + fn parse_into_field_floats() { + one_field!(S, f64, 0.0); + let mut d = S::raw_default(); + d.parse_field("v", Some("1")).unwrap(); + assert!((d.v - 1.0).abs() < f64::EPSILON); + } + + config_enum! { + pub enum Three { One => "one", Two => "two", Three => "three" } + } + + #[test] + fn parse_into_field_enums() { + one_field!(S, Three, Three::One); + let mut d = S::raw_default(); + d.parse_field("v", Some("two")).unwrap(); + assert_eq!(d.v, Three::Two); + } + + config_packed! { + pub struct Pk { + "a" => a: false, + "b" => b: true, + } + } + + #[test] + fn parse_into_field_packed_struct() { + one_field!(S, Pk, Pk::default()); + let mut d = S::raw_default(); + d.parse_field("v", Some("b")).unwrap(); + assert!(!d.v.a); + assert!(d.v.b); + } + + #[test] + fn parse_into_field_packed_struct_negation() { + one_field!(S, Pk, Pk::default()); + let mut d = S::raw_default(); + d.parse_field("v", Some("a,no-b")).unwrap(); + assert!(d.v.a); + assert!(!d.v.b); + } + + #[test] + fn parse_into_field_packed_struct_true_false() { + one_field!(S, Pk, Pk::default()); + let mut d = S::raw_default(); + d.parse_field("v", Some("true")).unwrap(); + assert!(d.v.a && d.v.b); + d.parse_field("v", Some("false")).unwrap(); + assert!(!d.v.a && !d.v.b); + assert_eq!( + d.parse_field("v", Some("true,a")), + Err(ArgError::InvalidValue) + ); + } + + #[test] + fn parse_into_field_packed_struct_whitespace() { + one_field!(S, Pk, Pk::default()); + let mut d = S::raw_default(); + d.parse_field("v", Some(" a, no-b ")).unwrap(); + assert!(d.v.a); + assert!(!d.v.b); + } + + #[test] + fn parse_into_field_optional_field() { + one_field!(S, Option, None); + let mut d = S::raw_default(); + d.parse_field("v", Some("1")).unwrap(); + assert_eq!(d.v, Some(true)); + // Unset + d.parse_field("v", Some("")).unwrap(); + assert_eq!(d.v, None); + } + + #[derive(Clone, PartialEq, Debug, Default)] + pub struct WithParse { + pub v: String, + } + impl ConfigField for WithParse { + fn parse_cli(&mut self, _value: Option<&str>) -> Result<(), ArgError> { + self.v = "HELLO!".into(); + Ok(()) + } + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + writeln!(f.writer, "{} = {}", f.name, self.v) + } + } + + #[test] + fn parse_into_field_struct_with_parse_func() { + one_field!(S, WithParse, WithParse::default()); + let mut d = S::raw_default(); + d.parse_field("v", Some("42")).unwrap(); + assert_eq!(d.v.v, "HELLO!"); + } + + #[test] + fn parse_into_field_optional_struct_with_parse_func() { + one_field!(S, Option, None); + let mut d = S::raw_default(); + d.parse_field("v", Some("42")).unwrap(); + assert_eq!(d.v.unwrap().v, "HELLO!"); + } + + #[derive(Clone, PartialEq, Debug)] + pub struct Basic { + pub a: String, + pub b: u32, + pub c: u8, + } + impl Default for Basic { + fn default() -> Self { + Self { + a: String::new(), + b: 0, + c: 12, + } + } + } + impl ConfigField for Basic { + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { + let v = value.ok_or(ArgError::ValueRequired)?; + let mut r = Basic::default(); + let mut set = [false; 3]; + parse_auto_struct_with(v, |k, val| { + match k { + "a" => { + r.a.parse_cli(Some(val))?; + set[0] = true; + } + "b" => { + r.b.parse_cli(Some(val))?; + set[1] = true; + } + "c" => { + r.c.parse_cli(Some(val))?; + set[2] = true; + } + _ => return Ok(false), + } + Ok(true) + })?; + // a and b are required + if !set[0] || !set[1] { + return Err(ArgError::InvalidValue); + } + *self = r; + Ok(()) + } + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + writeln!( + f.writer, + "{} = a:{},b:{},c:{}", + f.name, self.a, self.b, self.c + ) + } + } + + #[test] + fn parse_into_field_struct_with_basic_fields() { + one_field!(S, Basic, Basic::default()); + let mut d = S::raw_default(); + d.parse_field("v", Some("a:hello,b:42")).unwrap(); + assert_eq!(d.v.a, "hello"); + assert_eq!(d.v.b, 42); + assert_eq!(d.v.c, 12); + + d.parse_field("v", Some("a:world,b:84,c:24")).unwrap(); + assert_eq!(d.v.a, "world"); + assert_eq!(d.v.b, 84); + assert_eq!(d.v.c, 24); + + assert_eq!( + d.parse_field("v", Some("a:hello")), + Err(ArgError::InvalidValue) + ); + } + + #[derive(Clone, PartialEq, Debug, Default)] + pub enum Tu { + #[default] + None, + A(u8), + B(u8), + C, + D(String), + } + impl ConfigField for Tu { + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { + let v = value.ok_or(ArgError::ValueRequired)?; + let (tag, rest) = split_tagged_union(v); + *self = match tag { + "a" => { + let mut x = 0u8; + x.parse_cli(Some(rest))?; + Tu::A(x) + } + "b" => { + let mut x = 0u8; + x.parse_cli(Some(rest))?; + Tu::B(x) + } + "c" => { + if !rest.is_empty() { + return Err(ArgError::InvalidValue); + } + Tu::C + } + "d" => Tu::D(rest.to_string()), + _ => return Err(ArgError::InvalidValue), + }; + Ok(()) + } + fn format_entry(&self, _f: &mut EntryFormatter<'_>) -> fmt::Result { + Ok(()) + } + } + + #[test] + fn parse_into_field_tagged_union() { + one_field!(S, Tu, Tu::None); + let mut d = S::raw_default(); + d.parse_field("v", Some("a:1")).unwrap(); + assert_eq!(d.v, Tu::A(1)); + d.parse_field("v", Some("b:2")).unwrap(); + assert_eq!(d.v, Tu::B(2)); + d.parse_field("v", Some("c")).unwrap(); + assert_eq!(d.v, Tu::C); + d.parse_field("v", Some("d:hello")).unwrap(); + assert_eq!(d.v, Tu::D("hello".into())); + } + + #[test] + fn parse_into_field_tagged_union_unknown_field() { + one_field!(S, Tu, Tu::None); + let mut d = S::raw_default(); + assert_eq!(d.parse_field("v", Some("x:1")), Err(ArgError::InvalidValue)); + } + + #[test] + fn parse_into_field_tagged_union_invalid_field_value() { + one_field!(S, Tu, Tu::None); + let mut d = S::raw_default(); + assert_eq!( + d.parse_field("v", Some("a:hello")), + Err(ArgError::InvalidValue) + ); + } + + #[test] + fn parse_into_field_tagged_union_missing_tag() { + one_field!(S, Tu, Tu::None); + let mut d = S::raw_default(); + assert_eq!(d.parse_field("v", Some("a")), Err(ArgError::InvalidValue)); + assert_eq!(d.parse_field("v", Some(":a")), Err(ArgError::InvalidValue)); + } + + #[test] + fn args_iterator() { + let child = split_args("--what +list-things --a=42").into_iter(); + let mut iter = ArgsIterator::new(child); + assert_eq!(iter.next().unwrap(), "--what"); + assert_eq!(iter.next().unwrap(), "--a=42"); + assert_eq!(iter.next(), None); + assert_eq!(iter.next(), None); + } + + fn line_iter(s: &str) -> LineIterator>> { + LineIterator::new(Cursor::new(s.as_bytes().to_vec())) + } + + #[test] + fn line_iterator() { + let mut iter = line_iter( + "A\nB=42\nC\n\n# A comment\nD\n\n # An indented comment\n E\n\n# A quoted string with whitespace\nF= \"value \"", + ); + assert_eq!(iter.next().unwrap(), "--A"); + assert_eq!(iter.next().unwrap(), "--B=42"); + assert_eq!(iter.next().unwrap(), "--C"); + assert_eq!(iter.next().unwrap(), "--D"); + assert_eq!(iter.next().unwrap(), "--E"); + assert_eq!(iter.next().unwrap(), "--F=value "); + assert_eq!(iter.next(), None); + assert_eq!(iter.next(), None); + } + + #[test] + fn line_iterator_end_in_newline() { + let mut iter = line_iter("A\n\n"); + assert_eq!(iter.next().unwrap(), "--A"); + assert_eq!(iter.next(), None); + assert_eq!(iter.next(), None); + } + + #[test] + fn line_iterator_spaces_around_equals() { + let mut iter = line_iter("A = B\n\n"); + assert_eq!(iter.next().unwrap(), "--A=B"); + assert_eq!(iter.next(), None); + assert_eq!(iter.next(), None); + } + + #[test] + fn line_iterator_no_value() { + let mut iter = line_iter("A = \n\n"); + assert_eq!(iter.next().unwrap(), "--A="); + assert_eq!(iter.next(), None); + } + + #[test] + fn line_iterator_with_crlf_line_endings() { + let mut iter = line_iter("A\r\nB = C\r\n"); + assert_eq!(iter.next().unwrap(), "--A"); + assert_eq!(iter.next().unwrap(), "--B=C"); + assert_eq!(iter.next(), None); + assert_eq!(iter.next(), None); + } + + #[test] + fn line_iterator_with_buffered_reader() { + let r = std::io::BufReader::with_capacity(2, Cursor::new(b"A\nB = C\n".to_vec())); + let mut iter = LineIterator::new(r); + assert_eq!(iter.next().unwrap(), "--A"); + assert_eq!(iter.next().unwrap(), "--B=C"); + assert_eq!(iter.next(), None); + assert_eq!(iter.next(), None); + } + + #[test] + fn line_iterator_with_buffered_and_primed_reader() { + use std::io::BufRead; + let mut r = std::io::BufReader::with_capacity(2, Cursor::new(b"A\nB = C\n".to_vec())); + r.fill_buf().unwrap(); + let mut iter = LineIterator::new(r); + assert_eq!(iter.next().unwrap(), "--A"); + assert_eq!(iter.next().unwrap(), "--B=C"); + assert_eq!(iter.next(), None); + assert_eq!(iter.next(), None); + } + + #[test] + fn parse_int_base0_forms() { + assert_eq!(parse_int_base0("0x_1"), None); + assert_eq!(parse_int_base0("1_000"), Some(1000)); + assert_eq!(parse_int_base0("-0b11"), Some(-3)); + assert_eq!(parse_int_base0("0o17"), Some(15)); + assert_eq!(parse_int_base0(""), None); + } + + #[test] + fn zig_string_literal_decode() { + assert_eq!( + parse_zig_string_literal("\"a\\n\\x41\\u{1F47B}\"").unwrap(), + "a\nA\u{1F47B}" + ); + assert_eq!(parse_zig_string_literal("\"\\q\""), None); + } +} diff --git a/crates/ghostty-config/src/clipboard_codepoint_map.rs b/crates/ghostty-config/src/clipboard_codepoint_map.rs new file mode 100644 index 0000000..f49fd68 --- /dev/null +++ b/crates/ghostty-config/src/clipboard_codepoint_map.rs @@ -0,0 +1,63 @@ +//! ClipboardCodepointMap is a map of codepoints to replacement values +//! for clipboard operations. When copying text to clipboard, matching +//! codepoints will be replaced with their mapped values. + +/// Replacement value for a codepoint range. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Replacement { + /// A single replacement codepoint. + Codepoint(u32), + /// A UTF-8 encoded string to replace with. + String(String), +} + +/// One entry of the map. +/// +/// TODO(integration): this mirrors `terminal/formatter.zig` `CodepointMap`; +/// replace with `ghostty_terminal::formatter::CodepointMap` once available. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// Unicode codepoint range to replace. + /// Asserts: range[0] <= range[1] + pub range: [u32; 2], + /// Replacement value for this range. + pub replacement: Replacement, +} + +/// The list of entries. +/// +/// Note: we do a linear search because we expect to always have very +/// few entries, so the overhead of a binary search is not worth it. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ClipboardCodepointMap { + pub list: Vec, +} + +impl ClipboardCodepointMap { + /// Add an entry to the map. + /// + /// For conflicting codepoints, entries added later take priority over + /// entries added earlier. + pub fn add(&mut self, entry: Entry) { + assert!(entry.range[0] <= entry.range[1]); + self.list.push(entry); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn add_and_clone() { + let mut m = ClipboardCodepointMap::default(); + m.add(Entry { + range: [1, 2], + replacement: Replacement::String("x".into()), + }); + let c = m.clone(); + assert_eq!(m, c); + assert_eq!(c.list.len(), 1); + } +} diff --git a/crates/ghostty-config/src/comma_splitter.rs b/crates/ghostty-config/src/comma_splitter.rs new file mode 100644 index 0000000..864e13d --- /dev/null +++ b/crates/ghostty-config/src/comma_splitter.rs @@ -0,0 +1,354 @@ +//! Iterator to split a string into fields by commas, taking into account +//! quotes and escapes. +//! +//! Supports the same escapes as in Zig literal strings. +//! +//! Quotes must begin and end with a double quote (`"`). It is an error to not +//! end a quote that was begun. To include a double quote inside a quote (or to +//! not have a double quote start a quoted section) escape it with a backslash. +//! +//! Single quotes (`'`) are not special, they do not begin a quoted block. +//! +//! Zig multiline string literals are NOT supported. +//! +//! Quotes and escapes are not stripped or decoded, that must be handled as a +//! separate step! + +/// Errors the splitter can produce. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Error { + UnclosedQuote, + UnfinishedEscape, + IllegalEscape, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Error::UnclosedQuote => "UnclosedQuote", + Error::UnfinishedEscape => "UnfinishedEscape", + Error::IllegalEscape => "IllegalEscape", + }; + f.write_str(s) + } +} + +impl std::error::Error for Error {} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum State { + Normal, + Quoted, + Escape, + HexEscape, + UnicodeEscape, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum UnicodeState { + Start, + Digits, +} + +/// Splits a string on commas (see module docs). +#[derive(Debug, Clone)] +pub struct CommaSplitter<'a> { + /// the string that we are splitting + str: &'a str, + /// how much of the string has been consumed so far + index: usize, +} + +impl<'a> CommaSplitter<'a> { + /// initialize a splitter with the given string + #[must_use] + pub fn new(str: &'a str) -> Self { + Self { str, index: 0 } + } + + /// return the next field, `Ok(None)` if no more fields + pub fn next_field(&mut self) -> Result, Error> { + let bytes = self.str.as_bytes(); + if self.index >= bytes.len() { + return Ok(None); + } + + let start = self.index; + let mut state = State::Normal; + // state to return to when done processing an escape sequence + let mut last = State::Normal; + let mut hexescape_digits = 0usize; + let mut unicodeescape_state = UnicodeState::Start; + let mut unicodeescape_digits = 0usize; + let mut unicodeescape_value = 0usize; + + loop { + match state { + State::Normal => { + if self.index >= bytes.len() { + return Ok(Some(&self.str[start..])); + } + match bytes[self.index] { + b',' => { + self.index += 1; + return Ok(Some(&self.str[start..self.index - 1])); + } + b'"' => { + self.index += 1; + state = State::Quoted; + } + b'\\' => { + self.index += 1; + last = State::Normal; + state = State::Escape; + } + _ => self.index += 1, + } + } + State::Quoted => { + if self.index >= bytes.len() { + return Err(Error::UnclosedQuote); + } + match bytes[self.index] { + b'"' => { + self.index += 1; + state = State::Normal; + } + b'\\' => { + self.index += 1; + last = State::Quoted; + state = State::Escape; + } + _ => self.index += 1, + } + } + State::Escape => { + if self.index >= bytes.len() { + return Err(Error::UnfinishedEscape); + } + match bytes[self.index] { + b'n' | b'r' | b't' | b'\\' | b'\'' | b'"' => { + self.index += 1; + state = last; + } + b'x' => { + self.index += 1; + hexescape_digits = 0; + state = State::HexEscape; + } + b'u' => { + self.index += 1; + unicodeescape_state = UnicodeState::Start; + unicodeescape_digits = 0; + unicodeescape_value = 0; + state = State::UnicodeEscape; + } + _ => return Err(Error::IllegalEscape), + } + } + State::HexEscape => { + if self.index >= bytes.len() { + return Err(Error::UnfinishedEscape); + } + if bytes[self.index].is_ascii_hexdigit() { + self.index += 1; + hexescape_digits += 1; + if hexescape_digits == 2 { + state = last; + } + } else { + return Err(Error::IllegalEscape); + } + } + State::UnicodeEscape => { + if self.index >= bytes.len() { + return Err(Error::UnfinishedEscape); + } + match unicodeescape_state { + UnicodeState::Start => { + if bytes[self.index] == b'{' { + self.index += 1; + unicodeescape_value = 0; + unicodeescape_state = UnicodeState::Digits; + } else { + return Err(Error::IllegalEscape); + } + } + UnicodeState::Digits => { + let c = bytes[self.index]; + // Note: the Zig implementation does not add 10 for + // the a-f digits; this is preserved. + let d = match c { + b'}' => { + self.index += 1; + if unicodeescape_digits == 0 { + return Err(Error::IllegalEscape); + } + state = last; + continue; + } + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a', + b'A'..=b'F' => c - b'A', + _ => return Err(Error::IllegalEscape), + }; + self.index += 1; + unicodeescape_digits += 1; + unicodeescape_value <<= 4; + unicodeescape_value += usize::from(d); + if unicodeescape_value > 0x10ffff { + return Err(Error::IllegalEscape); + } + } + } + } + } + } + } + + /// Return any remaining string data, whether it has a comma or not. + pub fn rest(&mut self) -> Option<&'a str> { + if self.index >= self.str.len() { + return None; + } + let r = &self.str[self.index..]; + self.index = self.str.len(); + Some(r) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn all(s: &str) -> Result, Error> { + let mut sp = CommaSplitter::new(s); + let mut v = vec![]; + while let Some(x) = sp.next_field()? { + v.push(x); + } + Ok(v) + } + + fn first_err(s: &str) -> Error { + CommaSplitter::new(s).next_field().unwrap_err() + } + + #[test] + fn splitter_1() { + assert_eq!(all("a,b,c").unwrap(), ["a", "b", "c"]); + } + #[test] + fn splitter_2() { + assert_eq!(all("").unwrap(), Vec::<&str>::new()); + } + #[test] + fn splitter_3() { + assert_eq!(all("a").unwrap(), ["a"]); + } + #[test] + fn splitter_4() { + assert_eq!(all("\\x5a").unwrap(), ["\\x5a"]); + } + #[test] + fn splitter_5() { + assert_eq!(all("'a',b").unwrap(), ["'a'", "b"]); + } + #[test] + fn splitter_6() { + assert_eq!(all("'a,b',c").unwrap(), ["'a", "b'", "c"]); + } + #[test] + fn splitter_7() { + assert_eq!(all("\"a,b\",c").unwrap(), ["\"a,b\"", "c"]); + } + #[test] + fn splitter_8() { + assert_eq!(all(" a , b ").unwrap(), [" a ", " b "]); + } + #[test] + fn splitter_9() { + assert_eq!(first_err("\\x"), Error::UnfinishedEscape); + } + #[test] + fn splitter_10() { + assert_eq!(first_err("\\x5"), Error::UnfinishedEscape); + } + #[test] + fn splitter_11() { + assert_eq!(first_err("\\u"), Error::UnfinishedEscape); + } + #[test] + fn splitter_12() { + assert_eq!(first_err("\\u{"), Error::UnfinishedEscape); + } + #[test] + fn splitter_13() { + assert_eq!(first_err("\\u{}"), Error::IllegalEscape); + } + #[test] + fn splitter_14() { + assert_eq!(first_err("\\u{h1}"), Error::IllegalEscape); + } + #[test] + fn splitter_15() { + assert_eq!(all("\\u{10ffff}").unwrap(), ["\\u{10ffff}"]); + } + #[test] + fn splitter_16() { + assert_eq!(first_err("\\u{110000}"), Error::IllegalEscape); + } + #[test] + fn splitter_17() { + assert_eq!(first_err("\\d"), Error::IllegalEscape); + } + #[test] + fn splitter_18() { + assert_eq!( + all("\\n\\r\\t\\\"\\'\\\\").unwrap(), + ["\\n\\r\\t\\\"\\'\\\\"] + ); + } + #[test] + fn splitter_19() { + assert_eq!(all("\"abc'def'ghi\"").unwrap(), ["\"abc'def'ghi\""]); + } + #[test] + fn splitter_20() { + assert_eq!(all("\",\",abc").unwrap(), ["\",\"", "abc"]); + } + #[test] + fn splitter_21() { + assert_eq!(all("'a','b', 'c'").unwrap(), ["'a'", "'b'", " 'c'"]); + } + #[test] + fn splitter_22() { + assert_eq!(first_err("abc\"def"), Error::UnclosedQuote); + } + #[test] + fn splitter_23() { + assert_eq!( + all("title:\"Focus Split: Up\",description:\"Focus the split above, if it exists.\",action:goto_split:up").unwrap(), + [ + "title:\"Focus Split: Up\"", + "description:\"Focus the split above, if it exists.\"", + "action:goto_split:up" + ] + ); + } + #[test] + fn splitter_24() { + let mut s = CommaSplitter::new("a,b,c,def"); + assert_eq!(s.next_field().unwrap(), Some("a")); + assert_eq!(s.next_field().unwrap(), Some("b")); + assert_eq!(s.rest(), Some("c,def")); + assert_eq!(s.next_field().unwrap(), None); + } + #[test] + fn splitter_25() { + let mut s = CommaSplitter::new("a,\\u{10,df}"); + assert_eq!(s.next_field().unwrap(), Some("a")); + assert_eq!(s.next_field(), Err(Error::IllegalEscape)); + } +} diff --git a/crates/ghostty-config/src/command.rs b/crates/ghostty-config/src/command.rs new file mode 100644 index 0000000..30b495b --- /dev/null +++ b/crates/ghostty-config/src/command.rs @@ -0,0 +1,271 @@ +//! A command to execute (port of `config/command.zig`). + +use crate::cli_args::{ArgError, ConfigField}; +use crate::formatter::EntryFormatter; +use std::fmt; + +/// A command to execute (argv0 and args). +/// +/// A command is specified as a simple string such as "nvim a b c". +/// By default, we expect the downstream to do some sort of shell expansion +/// on this string. +/// +/// If a command is already expanded and the user does NOT want to do +/// shell expansion (because this usually requires a round trip into +/// /bin/sh or equivalent), specify a `direct:`-prefix. e.g. +/// `direct:nvim a b c`. +/// +/// The whitespace before or around the prefix is ignored. For example, +/// ` direct:nvim a b c` and `direct: nvim a b c` are equivalent. +/// +/// If the command is not absolute, it'll be looked up via the PATH. +/// For the shell-expansion case, we let the shell do this. For the +/// direct case, we do this directly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + /// Execute a command directly, e.g. via `exec`. The format here + /// is already structured to be ready to passed directly to `exec` + /// with index zero being the command to execute. + /// + /// Index zero is not guaranteed to be an absolute path, and may require + /// PATH lookup. It is up to the downstream to do this, usually via + /// delegation to something like `execvp`. + Direct(Vec), + + /// Execute a command via shell expansion. This provides the command + /// as a single string that is expected to be expanded in some way + /// (up to the downstream). Usually `/bin/sh -c`. + Shell(String), +} + +impl Default for Command { + fn default() -> Self { + Command::Shell(String::new()) + } +} + +/// Iterates over each argument in the command. +#[derive(Debug, Clone)] +pub struct ArgIterator { + args: std::vec::IntoIter, +} + +impl Iterator for ArgIterator { + type Item = String; + + fn next(&mut self) -> Option { + self.args.next() + } +} + +/// Best-effort shell string splitter matching Zig's +/// `std.process.ArgIteratorGeneral(.{})` (whitespace separated, double +/// quotes group, backslashes only special before a double quote). +fn split_general(s: &str) -> Vec { + let b = s.as_bytes(); + let mut out = vec![]; + let mut i = 0; + loop { + // skip whitespace + while i < b.len() && (b[i] == b' ' || b[i] == b'\t') { + i += 1; + } + if i >= b.len() { + break; + } + + let mut cur: Vec = vec![]; + let mut quoted = false; + while i < b.len() { + match b[i] { + b' ' | b'\t' if !quoted => break, + b'\\' => { + let mut n = 0; + while i < b.len() && b[i] == b'\\' { + n += 1; + i += 1; + } + if i < b.len() && b[i] == b'"' { + cur.extend(std::iter::repeat_n(b'\\', n / 2)); + if n % 2 == 1 { + cur.push(b'"'); + i += 1; + } + } else { + cur.extend(std::iter::repeat_n(b'\\', n)); + } + } + b'"' => { + quoted = !quoted; + i += 1; + } + c => { + cur.push(c); + i += 1; + } + } + } + out.push(String::from_utf8_lossy(&cur).into_owned()); + } + out +} + +impl Command { + /// Creates a command as a single string, joining arguments as + /// necessary with spaces. Its not guaranteed that this is a valid + /// command; it is only meant to be human readable. + #[must_use] + pub fn string(&self) -> String { + match self { + Command::Shell(v) => v.clone(), + Command::Direct(v) => v.join(" "), + } + } + + /// Get an iterator over the arguments array. + /// + /// For direct commands this just iterates over the array. For shell + /// commands this uses a best effort shell string parser (like Zig's + /// `ArgIteratorGeneral`). This is not guaranteed to be 100% accurate, + /// but it works for common cases. + #[must_use] + pub fn arg_iterator(&self) -> ArgIterator { + let args = match self { + Command::Direct(v) => v.clone(), + Command::Shell(v) => split_general(v), + }; + ArgIterator { + args: args.into_iter(), + } + } +} + +impl ConfigField for Command { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + // Input is required. Whitespace on the edges isn't needed. + // Commands must be non-empty. + let input = input.ok_or(ArgError::ValueRequired)?; + let trimmed = input.trim_matches(' '); + if trimmed.is_empty() { + return Err(ArgError::ValueRequired); + } + + // If we have a `:` then we MIGHT have a prefix to specify what + // tag we should use. + let (direct, str) = match trimmed.find(':') { + Some(idx) if &trimmed[..idx] == "direct" => (true, &trimmed[idx + 1..]), + Some(idx) if &trimmed[..idx] == "shell" => (false, &trimmed[idx + 1..]), + _ => (false, trimmed), + }; + + let str = str.trim_matches(' '); + *self = if direct { + // We're not shell expanding, so the arguments are naively + // split on spaces. + Command::Direct(str.split(' ').map(str::to_string).collect()) + } else { + Command::Shell(str.to_string()) + }; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + match self { + Command::Shell(v) => writeln!(f.writer, "{} = {v}", f.name), + Command::Direct(v) => writeln!(f.writer, "{} = direct:{}", f.name, v.join(" ")), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::formatter::format_entry; + + #[test] + fn command_parse_cli_errors() { + let mut v = Command::default(); + assert_eq!(v.parse_cli(None), Err(ArgError::ValueRequired)); + assert_eq!(v.parse_cli(Some("")), Err(ArgError::ValueRequired)); + assert_eq!(v.parse_cli(Some(" ")), Err(ArgError::ValueRequired)); + } + + #[test] + fn command_parse_cli_shell_expanded() { + let mut v = Command::default(); + v.parse_cli(Some("echo hello")).unwrap(); + assert_eq!(v, Command::Shell("echo hello".into())); + + // Spaces are stripped + v.parse_cli(Some(" echo hello ")).unwrap(); + assert_eq!(v, Command::Shell("echo hello".into())); + } + + #[test] + fn command_parse_cli_direct() { + let mut v = Command::default(); + v.parse_cli(Some("direct:echo hello")).unwrap(); + assert_eq!(v, Command::Direct(vec!["echo".into(), "hello".into()])); + + // Spaces around the prefix + v.parse_cli(Some(" direct: echo hello")).unwrap(); + assert_eq!(v, Command::Direct(vec!["echo".into(), "hello".into()])); + } + + #[test] + fn command_arg_iterator_shell() { + let v = Command::Shell("echo hello world".into()); + let mut it = v.arg_iterator(); + assert_eq!(it.next().unwrap(), "echo"); + assert_eq!(it.next().unwrap(), "hello"); + assert_eq!(it.next().unwrap(), "world"); + assert!(it.next().is_none()); + } + + #[test] + fn command_arg_iterator_shell_quotes() { + let v = Command::Shell("echo \"hello world\" a\\\"b".into()); + let args: Vec = v.arg_iterator().collect(); + assert_eq!(args, ["echo", "hello world", "a\"b"]); + } + + #[test] + fn command_arg_iterator_direct() { + let v = Command::Direct(vec!["echo".into(), "hello world".into()]); + let mut it = v.arg_iterator(); + assert_eq!(it.next().unwrap(), "echo"); + assert_eq!(it.next().unwrap(), "hello world"); + assert!(it.next().is_none()); + } + + #[test] + fn command_string_shell() { + let v = Command::Shell("echo hello world".into()); + assert_eq!(v.string(), "echo hello world"); + } + + #[test] + fn command_string_direct() { + let v = Command::Direct(vec!["echo".into(), "hello world".into()]); + assert_eq!(v.string(), "echo hello world"); + } + + #[test] + fn command_format_config_shell() { + let mut v = Command::default(); + v.parse_cli(Some("echo hello")).unwrap(); + let mut s = String::new(); + format_entry("a", &v, &mut s).unwrap(); + assert_eq!(s, "a = echo hello\n"); + } + + #[test] + fn command_format_config_direct() { + let mut v = Command::default(); + v.parse_cli(Some("direct: echo hello")).unwrap(); + let mut s = String::new(); + format_entry("a", &v, &mut s).unwrap(); + assert_eq!(s, "a = direct:echo hello\n"); + } +} diff --git a/crates/ghostty-config/src/conditional.rs b/crates/ghostty-config/src/conditional.rs new file mode 100644 index 0000000..ee4f59d --- /dev/null +++ b/crates/ghostty-config/src/conditional.rs @@ -0,0 +1,134 @@ +//! Conditional configuration state (port of `config/conditional.zig`). +//! +//! Conditionals in Ghostty configuration are based on a static, typed +//! state of the world instead of a dynamic key-value set. + +/// The theme of the underlying OS desktop environment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)] +pub enum Theme { + #[default] + Light, + Dark, +} + +impl Theme { + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Theme::Light => "light", + Theme::Dark => "dark", + } + } +} + +/// The target OS (Zig: `std.Target.Os.Tag`); only the Linux build is +/// supported, but the other tags are kept so matching by name works. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Os { + Linux, + Macos, + Windows, + Other, +} + +impl Os { + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Os::Linux => "linux", + Os::Macos => "macos", + Os::Windows => "windows", + Os::Other => "other", + } + } + + /// The OS of the current build. + #[must_use] + pub fn current() -> Os { + if cfg!(target_os = "linux") { + Os::Linux + } else if cfg!(target_os = "macos") { + Os::Macos + } else if cfg!(target_os = "windows") { + Os::Windows + } else { + Os::Other + } + } +} + +/// The static, typed state conditionals are matched against. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct State { + /// The theme of the underlying OS desktop environment. + pub theme: Theme, + /// The target OS of the current build. + pub os: Os, +} + +impl Default for State { + fn default() -> Self { + Self { + theme: Theme::Light, + os: Os::current(), + } + } +} + +impl State { + /// Tests the conditional against the state and returns true if it matches. + #[must_use] + pub fn match_(&self, cond: &Conditional) -> bool { + let value = match cond.key { + Key::Theme => self.theme.as_str(), + Key::Os => self.os.as_str(), + }; + match cond.op { + Op::Eq => value == cond.value, + Op::Ne => value != cond.value, + } + } +} + +/// An enum of the available conditional configuration keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Key { + Theme, + Os, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Op { + Eq, + Ne, +} + +/// A single conditional that can be true or false. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Conditional { + pub key: Key, + pub op: Op, + pub value: String, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn conditional_enum_match() { + let state = State { + theme: Theme::Dark, + ..State::default() + }; + let c = |op, v: &str| Conditional { + key: Key::Theme, + op, + value: v.to_string(), + }; + assert!(state.match_(&c(Op::Eq, "dark"))); + assert!(!state.match_(&c(Op::Ne, "dark"))); + assert!(state.match_(&c(Op::Ne, "light"))); + } +} diff --git a/crates/ghostty-config/src/config-template b/crates/ghostty-config/src/config-template new file mode 100644 index 0000000..6330913 --- /dev/null +++ b/crates/ghostty-config/src/config-template @@ -0,0 +1,52 @@ +# This is the configuration file for Ghostty. +# +# This template file has been automatically created at the following +# path since Ghostty couldn't find any existing config files on your system: +# +# {[path]s} +# +# The template does not set any default options, since Ghostty ships +# with sensible defaults for all options. +# Note that you should not paste the output of `ghostty +show-config +# --default` into your config: some default options actually conflict with each other +# when explicitly set in a configuration file. Instead, only set the +# options you actually need. +# +# Run `ghostty +show-config --default --docs` to view a list of +# all available config options and their default values. +# +# Additionally, each config option is also explained in detail +# on Ghostty's website, at https://ghostty.org/docs/config. +# +# Ghostty can reload the configuration while running by using the menu +# options or the bound key (default: Command + Shift + comma on macOS and +# Control + Shift + comma on other platforms). Not all config options can be +# reloaded while running; some only apply to new windows and others may require +# a full restart to take effect. + +# Config syntax crash course +# ========================== +# # The config file consists of simple key-value pairs, +# # separated by equals signs. +# font-family = Iosevka +# window-padding-x = 2 +# +# # Spacing around the equals sign does not matter. +# # All of these are identical: +# key=value +# key= value +# key =value +# key = value +# +# # Any line beginning with a # is a comment. It's not possible to put +# # a comment after a config option, since it would be interpreted as a +# # part of the value. For example, this will have a value of "#123abc": +# background = #123abc +# +# # Empty values are used to reset config keys to default. +# key = +# +# # Some config options have unique syntaxes for their value, +# # which is explained in the docs for that config option. +# # Just for example: +# resize-overlay-duration = 4s 200ms diff --git a/crates/ghostty-config/src/config.rs b/crates/ghostty-config/src/config.rs new file mode 100644 index 0000000..06c6188 --- /dev/null +++ b/crates/ghostty-config/src/config.rs @@ -0,0 +1,5315 @@ +//! The configuration struct (port of `config/Config.zig`). +//! +//! Every option is declared once in the [`config_struct!`](crate::config_struct) +//! invocation below with its exact name, type, default value and doc comment. +//! The doc comments are user-visible (`ghostty +show-config --docs`). + +use crate::cli_args::{self, ArgError, ArgIter, CliTarget}; +use crate::command::Command; +use crate::conditional::{self, Conditional}; +use crate::config_enum; +use crate::diagnostics::{Diagnostic, DiagnosticList}; +use crate::io::RepeatableReadableIO; +use crate::key::Key; +use crate::keybinds::Keybinds; +use crate::macros::ConfigStruct; +use crate::metric_modifier::MetricModifier; +use crate::path::{Path, RepeatablePath}; +use crate::repeatable_string_map::RepeatableStringMap; +use crate::types_a::*; +use crate::types_b::*; +use std::collections::HashSet; + +const NS_PER_S: u64 = 1_000_000_000; +const NS_PER_MS: u64 = 1_000_000; + +config_enum! { + /// The release channel of the build (Zig: `build_config.ReleaseChannel`). + pub enum ReleaseChannel { + Stable => "stable", + Tip => "tip", + } +} + +crate::config_struct! { + pub struct Config { + /// Set Ghostty's graphical user interface language to a language other than the + /// system default language. For example: + /// + /// language = de + /// + /// will force the strings in Ghostty's graphical user interface to be in German + /// rather than the system default. + /// + /// This will not affect the language used by programs run _within_ Ghostty. + /// Those will continue to use the default system language. There are also many + /// non-GUI elements in Ghostty that are not translated - this setting will have + /// no effect on those. + /// + /// Warning: This setting cannot be reloaded at runtime. To change the language + /// you must fully restart Ghostty. + /// + /// GTK only. + /// Available since 1.3.0. + "language" => language: Option = None, + + /// The font families to use. + /// + /// You can generate the list of valid values using the CLI: + /// + /// ghostty +list-fonts + /// + /// This configuration can be repeated multiple times to specify preferred + /// fallback fonts when the requested codepoint is not available in the primary + /// font. This is particularly useful for multiple languages, symbolic fonts, + /// etc. + /// + /// Notes on emoji specifically: On macOS, Ghostty by default will always use + /// Apple Color Emoji and on Linux will always use Noto Emoji. You can + /// override this behavior by specifying a font family here that contains + /// emoji glyphs. + /// + /// The specific styles (bold, italic, bold italic) do not need to be + /// explicitly set. If a style is not set, then the regular style (font-family) + /// will be searched for stylistic variants. If a stylistic variant is not + /// found, Ghostty will use the regular style. This prevents falling back to a + /// different font family just to get a style such as bold. This also applies + /// if you explicitly specify a font family for a style. For example, if you + /// set `font-family-bold = FooBar` and "FooBar" cannot be found, Ghostty will + /// use whatever font is set for `font-family` for the bold style. + /// + /// Finally, some styles may be synthesized if they are not supported. + /// For example, if a font does not have an italic style and no alternative + /// italic font is specified, Ghostty will synthesize an italic style by + /// applying a slant to the regular style. If you want to disable these + /// synthesized styles then you can use the `font-style` configurations + /// as documented below. + /// + /// You can disable styles completely by using the `font-style` set of + /// configurations. See the documentation for `font-style` for more information. + /// + /// If you want to overwrite a previous set value rather than append a fallback, + /// specify the value as `""` (empty string) to reset the list and then set the + /// new values. For example: + /// + /// font-family = "" + /// font-family = "My Favorite Font" + /// + /// Setting any of these as CLI arguments will automatically clear the + /// values set in configuration files so you don't need to specify + /// `--font-family=""` before setting a new value. You only need to specify + /// this within config files if you want to clear previously set values in + /// configuration files or on the CLI if you want to clear values set on the + /// CLI. + "font-family" => font_family: RepeatableString = RepeatableString::default(), + + "font-family-bold" => font_family_bold: RepeatableString = RepeatableString::default(), + + "font-family-italic" => font_family_italic: RepeatableString = RepeatableString::default(), + + "font-family-bold-italic" => font_family_bold_italic: RepeatableString = RepeatableString::default(), + + /// The named font style to use for each of the requested terminal font styles. + /// This looks up the style based on the font style string advertised by the + /// font itself. For example, "Iosevka Heavy" has a style of "Heavy". + /// + /// You can also use these fields to completely disable a font style. If you set + /// the value of the configuration below to literal `false` then that font style + /// will be disabled. If the running program in the terminal requests a disabled + /// font style, the regular font style will be used instead. + /// + /// These are only valid if its corresponding font-family is also specified. If + /// no font-family is specified, then the font-style is ignored unless you're + /// disabling the font style. + "font-style" => font_style: FontStyle = FontStyle::Default, + + "font-style-bold" => font_style_bold: FontStyle = FontStyle::Default, + + "font-style-italic" => font_style_italic: FontStyle = FontStyle::Default, + + "font-style-bold-italic" => font_style_bold_italic: FontStyle = FontStyle::Default, + + /// Control whether Ghostty should synthesize a style if the requested style is + /// not available in the specified font-family. + /// + /// Ghostty can synthesize bold, italic, and bold italic styles if the font + /// does not have a specific style. For bold, this is done by drawing an + /// outline around the glyph of varying thickness. For italic, this is done by + /// applying a slant to the glyph. For bold italic, both of these are applied. + /// + /// Synthetic styles are not perfect and will generally not look as good + /// as a font that has the style natively. However, they are useful to + /// provide styled text when the font does not have the style. + /// + /// Set this to "false" or "true" to disable or enable synthetic styles + /// completely. You can disable specific styles using "no-bold", "no-italic", + /// and "no-bold-italic". You can disable multiple styles by separating them + /// with a comma. For example, "no-bold,no-italic". + /// + /// Available style keys are: `bold`, `italic`, `bold-italic`. + /// + /// If synthetic styles are disabled, then the regular style will be used + /// instead if the requested style is not available. If the font has the + /// requested style, then the font will be used as-is since the style is + /// not synthetic. + /// + /// Warning: An easy mistake is to disable `bold` or `italic` but not + /// `bold-italic`. Disabling only `bold` or `italic` will NOT disable either + /// in the `bold-italic` style. If you want to disable `bold-italic`, you must + /// explicitly disable it. You cannot partially disable `bold-italic`. + /// + /// By default, synthetic styles are enabled. + "font-synthetic-style" => font_synthetic_style: FontSyntheticStyle = FontSyntheticStyle::default(), + + /// Apply a font feature. To enable multiple font features you can repeat + /// this multiple times or use a comma-separated list of feature settings. + /// + /// The syntax for feature settings is as follows, where `feat` is a feature: + /// + /// * Enable features with e.g. `feat`, `+feat`, `feat on`, `feat=1`. + /// * Disabled features with e.g. `-feat`, `feat off`, `feat=0`. + /// * Set a feature value with e.g. `feat=2`, `feat = 3`, `feat 4`. + /// * Feature names may be wrapped in quotes, meaning this config should be + /// syntactically compatible with the `font-feature-settings` CSS property. + /// + /// The syntax is fairly loose, but invalid settings will be silently ignored. + /// + /// The font feature will apply to all fonts rendered by Ghostty. A future + /// enhancement will allow targeting specific faces. + /// + /// To disable programming ligatures, use `-calt` since this is the typical + /// feature name for programming ligatures. To look into what font features + /// your font has and what they do, use a font inspection tool such as + /// [fontdrop.info](https://fontdrop.info). + /// + /// To generally disable most ligatures, use `-calt, -liga, -dlig`. + "font-feature" => font_feature: RepeatableString = RepeatableString::default(), + + /// Font size in points. This value can be a non-integer and the nearest integer + /// pixel size will be selected. If you have a high dpi display where 1pt = 2px + /// then you can get an odd numbered pixel size by specifying a half point. + /// + /// For example, 13.5pt @ 2px/pt = 27px + /// + /// Changing this configuration at runtime will only affect existing + /// terminals that have NOT manually adjusted their font size in some way + /// (e.g. increased or decreased the font size). Terminals that have manually + /// adjusted their font size will retain their manually adjusted size. + /// Otherwise, the font size of existing terminals will be updated on + /// reload. + /// + /// On Linux with GTK, font size is scaled according to both display-wide and + /// text-specific scaling factors, which are often managed by your desktop + /// environment (e.g. the GNOME display scale and large text settings). + "font-size" => font_size: f32 = 12.0, + + /// A repeatable configuration to set one or more font variations values for + /// a variable font. A variable font is a single font, usually with a filename + /// ending in `-VF.ttf` or `-VF.otf` that contains one or more configurable axes + /// for things such as weight, slant, etc. Not all fonts support variations; + /// only fonts that explicitly state they are variable fonts will work. + /// + /// The format of this is `id=value` where `id` is the axis identifier. An axis + /// identifier is always a 4 character string, such as `wght`. To get the list + /// of supported axes, look at your font documentation or use a font inspection + /// tool. + /// + /// Invalid ids and values are usually ignored. For example, if a font only + /// supports weights from 100 to 700, setting `wght=800` will do nothing (it + /// will not be clamped to 700). You must consult your font's documentation to + /// see what values are supported. + /// + /// Common axes are: `wght` (weight), `slnt` (slant), `ital` (italic), `opsz` + /// (optical size), `wdth` (width), `GRAD` (gradient), etc. + "font-variation" => font_variation: RepeatableFontVariation = RepeatableFontVariation::default(), + + "font-variation-bold" => font_variation_bold: RepeatableFontVariation = RepeatableFontVariation::default(), + + "font-variation-italic" => font_variation_italic: RepeatableFontVariation = RepeatableFontVariation::default(), + + "font-variation-bold-italic" => font_variation_bold_italic: RepeatableFontVariation = RepeatableFontVariation::default(), + + /// Force one or a range of Unicode codepoints to map to a specific named font. + /// This is useful if you want to support special symbols or if you want to use + /// specific glyphs that render better for your specific font. + /// + /// The syntax is `codepoint=fontname` where `codepoint` is either a single + /// codepoint or a range. Codepoints must be specified as full Unicode + /// hex values, such as `U+ABCD`. Codepoints ranges are specified as + /// `U+ABCD-U+DEFG`. You can specify multiple ranges for the same font separated + /// by commas, such as `U+ABCD-U+DEFG,U+1234-U+5678=fontname`. The font name is + /// the same value as you would use for `font-family`. + /// + /// This configuration can be repeated multiple times to specify multiple + /// codepoint mappings. + /// + /// Changing this configuration at runtime will only affect new terminals, + /// i.e. new windows, tabs, etc. + "font-codepoint-map" => font_codepoint_map: RepeatableCodepointMap = RepeatableCodepointMap::default(), + + /// Map specific Unicode codepoints to replacement values when copying text + /// to clipboard. + /// + /// This configuration allows you to replace specific Unicode characters with + /// other characters or strings when copying terminal content to the clipboard. + /// This is useful for converting special terminal symbols to more compatible + /// characters for pasting into other applications. + /// + /// The syntax is similar to `font-codepoint-map`: + /// - Single codepoint: `U+1234=U+ABCD` or `U+1234=replacement_text` + /// - Codepoint range: `U+1234-U+5678=U+ABCD` + /// + /// Examples: + /// - `clipboard-codepoint-map = U+2500=U+002D` (box drawing horizontal → hyphen) + /// - `clipboard-codepoint-map = U+2502=U+007C` (box drawing vertical → pipe) + /// - `clipboard-codepoint-map = U+03A3=SUM` (Greek sigma → "SUM") + /// + /// This configuration can be repeated multiple times to specify multiple + /// mappings. Later entries take priority over earlier ones for overlapping + /// ranges. + /// + /// Note: This only applies to text copying operations, not URL copying. + "clipboard-codepoint-map" => clipboard_codepoint_map: RepeatableClipboardCodepointMap = RepeatableClipboardCodepointMap::default(), + + /// Draw fonts with a thicker stroke, if supported. + /// This is currently only supported on macOS. + "font-thicken" => font_thicken: bool = false, + + /// Strength of thickening when `font-thicken` is enabled. + /// + /// Valid values are integers between `0` and `255`. `0` does not correspond to + /// *no* thickening, rather it corresponds to the lightest available thickening. + /// + /// Has no effect when `font-thicken` is set to `false`. + /// + /// This is currently only supported on macOS. + "font-thicken-strength" => font_thicken_strength: u8 = 255, + + /// Locations to break font shaping into multiple runs. + /// + /// A "run" is a contiguous segment of text that is shaped together. "Shaping" + /// is the process of converting text (codepoints) into glyphs (renderable + /// characters). This is how ligatures are formed, among other things. + /// For example, if a coding font turns "!=" into a single glyph, then it + /// must see "!" and "=" next to each other in a single run. When a run + /// is broken, the text is shaped separately. To continue our example, if + /// "!" is at the end of one run and "=" is at the start of the next run, + /// then the ligature will not be formed. + /// + /// Ghostty breaks runs at certain points to improve readability or usability. + /// For example, Ghostty by default will break runs under the cursor so that + /// text editing can see the individual characters rather than a ligature. + /// This configuration lets you configure this behavior. + /// + /// Combine values with a comma to set multiple options. Prefix an + /// option with "no-" to disable it. Enabling and disabling options + /// can be done at the same time. + /// + /// Available options: + /// + /// * `cursor` - Break runs under the cursor. + /// + /// Available since: 1.2.0 + "font-shaping-break" => font_shaping_break: FontShapingBreak = FontShapingBreak::default(), + + /// What color space to use when performing alpha blending. + /// + /// This affects the appearance of text and of any images with transparency. + /// Additionally, custom shaders will receive colors in the configured space. + /// + /// On macOS the default is `native`, on all other platforms the default is + /// `linear-corrected`. + /// + /// Valid values: + /// + /// * `native` - Perform alpha blending in the native color space for the OS. + /// On macOS this corresponds to Display P3, and on Linux it's sRGB. + /// + /// * `linear` - Perform alpha blending in linear space. This will eliminate + /// the darkening artifacts around the edges of text that are very visible + /// when certain color combinations are used (e.g. red / green), but makes + /// dark text look much thinner than normal and light text much thicker. + /// This is also sometimes known as "gamma correction". + /// + /// * `linear-corrected` - Same as `linear`, but with a correction step applied + /// for text that makes it look nearly or completely identical to `native`, + /// but without any of the darkening artifacts. + /// + /// Available since: 1.1.0 + "alpha-blending" => alpha_blending: AlphaBlending = AlphaBlending::LinearCorrected, + + /// All of the configurations behavior adjust various metrics determined by the + /// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%, + /// etc.). In each case, the values represent the amount to change the original + /// value. + /// + /// For example, a value of `1` increases the value by 1; it does not set it to + /// literally 1. A value of `20%` increases the value by 20%. And so on. + /// + /// There is little to no validation on these values so the wrong values (e.g. + /// `-100%`) can cause the terminal to be unusable. Use with caution and reason. + /// + /// Some values are clamped to minimum or maximum values. This can make it + /// appear that certain values are ignored. For example, many `*-thickness` + /// adjustments cannot go below 1px. + /// + /// `adjust-cell-height` has some additional behaviors to describe: + /// + /// * The font will be centered vertically in the cell. + /// + /// * The cursor will remain the same size as the font, but may be + /// adjusted separately with `adjust-cursor-height`. + /// + /// * Powerline glyphs will be adjusted along with the cell height so + /// that things like status lines continue to look aligned. + "adjust-cell-width" => adjust_cell_width: Option = None, + + "adjust-cell-height" => adjust_cell_height: Option = None, + + /// Distance in pixels or percentage adjustment from the bottom of the cell to the text baseline. + /// Increase to move baseline UP, decrease to move baseline DOWN. + /// See the notes about adjustments in `adjust-cell-width`. + "adjust-font-baseline" => adjust_font_baseline: Option = None, + + /// Distance in pixels or percentage adjustment from the top of the cell to the top of the underline. + /// Increase to move underline DOWN, decrease to move underline UP. + /// See the notes about adjustments in `adjust-cell-width`. + "adjust-underline-position" => adjust_underline_position: Option = None, + + /// Thickness in pixels of the underline. + /// See the notes about adjustments in `adjust-cell-width`. + "adjust-underline-thickness" => adjust_underline_thickness: Option = None, + + /// Distance in pixels or percentage adjustment from the top of the cell to the top of the strikethrough. + /// Increase to move strikethrough DOWN, decrease to move strikethrough UP. + /// See the notes about adjustments in `adjust-cell-width`. + "adjust-strikethrough-position" => adjust_strikethrough_position: Option = None, + + /// Thickness in pixels or percentage adjustment of the strikethrough. + /// See the notes about adjustments in `adjust-cell-width`. + "adjust-strikethrough-thickness" => adjust_strikethrough_thickness: Option = None, + + /// Distance in pixels or percentage adjustment from the top of the cell to the top of the overline. + /// Increase to move overline DOWN, decrease to move overline UP. + /// See the notes about adjustments in `adjust-cell-width`. + "adjust-overline-position" => adjust_overline_position: Option = None, + + /// Thickness in pixels or percentage adjustment of the overline. + /// See the notes about adjustments in `adjust-cell-width`. + "adjust-overline-thickness" => adjust_overline_thickness: Option = None, + + /// Thickness in pixels or percentage adjustment of the bar cursor and outlined rect cursor. + /// See the notes about adjustments in `adjust-cell-width`. + "adjust-cursor-thickness" => adjust_cursor_thickness: Option = None, + + /// Height in pixels or percentage adjustment of the cursor. Currently applies to all cursor types: + /// bar, rect, and outlined rect. + /// See the notes about adjustments in `adjust-cell-width`. + "adjust-cursor-height" => adjust_cursor_height: Option = None, + + /// Thickness in pixels or percentage adjustment of box drawing characters. + /// See the notes about adjustments in `adjust-cell-width`. + "adjust-box-thickness" => adjust_box_thickness: Option = None, + + /// Height in pixels or percentage adjustment of maximum height for nerd font icons. + /// + /// A positive (negative) value will increase (decrease) the maximum icon + /// height. This may not affect all icons equally: the effect depends on whether + /// the default size of the icon is height-constrained, which in turn depends on + /// the aspect ratio of both the icon and your primary font. + /// + /// Certain icons designed for box drawing and terminal graphics, such as + /// Powerline symbols, are not affected by this option. + /// + /// See the notes about adjustments in `adjust-cell-width`. + /// + /// Available in: 1.2.0 + "adjust-icon-height" => adjust_icon_height: Option = None, + + /// The method to use for calculating the cell width of a grapheme cluster. + /// The default value is `unicode` which uses the Unicode standard to determine + /// grapheme width. This results in correct grapheme width but may result in + /// cursor-desync issues with some programs (such as shells) that may use a + /// legacy method such as `wcswidth`. + /// + /// Valid values are: + /// + /// * `legacy` - Use a legacy method to determine grapheme width, such as + /// wcswidth This maximizes compatibility with legacy programs but may result + /// in incorrect grapheme width for certain graphemes such as skin-tone + /// emoji, non-English characters, etc. + /// + /// This is called "legacy" and not something more specific because the + /// behavior is undefined and we want to retain the ability to modify it. + /// For example, we may or may not use libc `wcswidth` now or in the future. + /// + /// * `unicode` - Use the Unicode standard to determine grapheme width. + /// + /// If a running program explicitly enables terminal mode 2027, then `unicode` + /// width will be forced regardless of this configuration. When mode 2027 is + /// reset, this configuration will be used again. + /// + /// This configuration can be changed at runtime but will not affect existing + /// terminals. Only new terminals will use the new configuration. + "grapheme-width-method" => grapheme_width_method: GraphemeWidthMethod = GraphemeWidthMethod::Unicode, + + /// FreeType load flags to enable. The format of this is a list of flags to + /// enable separated by commas. If you prefix a flag with `no-` then it is + /// disabled. If you omit a flag, its default value is used, so you must + /// explicitly disable flags you don't want. You can also use `true` or `false` + /// to turn all flags on or off. + /// + /// This configuration only applies to Ghostty builds that use FreeType. + /// This is usually the case only for Linux builds. macOS uses CoreText + /// and does not have an equivalent configuration. + /// + /// Available flags: + /// + /// * `hinting` - Enable or disable hinting. Enabled by default. + /// + /// * `force-autohint` - Always use the freetype auto-hinter instead of + /// the font's native hinter. Disabled by default. + /// + /// * `monochrome` - Instructs renderer to use 1-bit monochrome rendering. + /// This will disable anti-aliasing, and probably not look very good unless + /// you're using a pixel font. Disabled by default. + /// + /// * `autohint` - Enable the freetype auto-hinter. Enabled by default. + /// + /// * `light` - Use a light hinting style, better preserving glyph shapes. + /// This is the most common setting in GTK apps and therefore also Ghostty's + /// default. This has no effect if `monochrome` is enabled. Enabled by + /// default. + /// + /// Example: `hinting`, `no-hinting`, `force-autohint`, `no-force-autohint` + "freetype-load-flags" => freetype_load_flags: FreetypeLoadFlags = FreetypeLoadFlags::default(), + + /// A theme to use. This can be a built-in theme name, a custom theme + /// name, or an absolute path to a custom theme file. Ghostty also supports + /// specifying a different theme to use for light and dark mode. Each + /// option is documented below. + /// + /// If the theme is an absolute pathname, Ghostty will attempt to load that + /// file as a theme. If that file does not exist or is inaccessible, an error + /// will be logged and no other directories will be searched. + /// + /// If the theme is not an absolute pathname, two different directories will be + /// searched for a file name that matches the theme. This is case sensitive on + /// systems with case-sensitive filesystems. It is an error for a theme name to + /// include path separators unless it is an absolute pathname. + /// + /// 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 list is in the + /// `Ghostty.app/Contents/Resources/ghostty/themes` directory. On Linux, this + /// list is in the `share/ghostty/themes` directory (wherever you installed the + /// Ghostty "share" directory. + /// + /// To see a list of available themes, run `ghostty +list-themes`. + /// + /// A theme file is simply another Ghostty configuration file. They share + /// the same syntax and same configuration options. A theme can set any valid + /// configuration option so please do not use a theme file from an untrusted + /// source. The built-in themes are audited to only set safe configuration + /// options. + /// + /// Some options cannot be set within theme files. The reason these are not + /// supported should be self-evident. A theme file cannot set `theme` or + /// `config-file`. At the time of writing this, Ghostty will not show any + /// warnings or errors if you set these options in a theme file but they will + /// be silently ignored. + /// + /// Any additional colors specified via background, foreground, palette, etc. + /// will override the colors specified in the theme. + /// + /// To specify a different theme for light and dark mode, use the following + /// syntax: `light:theme-name,dark:theme-name`. For example: + /// `light:Rose Pine Dawn,dark:Rose Pine`. Whitespace around all values are + /// trimmed and order of light and dark does not matter. Both light and dark + /// must be specified in this form. In this form, the theme used will be + /// based on the current desktop environment theme. + /// + /// There are some known bugs with light/dark mode theming. These will + /// be fixed in a future update: + /// + /// - macOS: titlebar tabs style is not updated when switching themes. + "theme" => theme: Option = None, + + /// Background color for the window. + /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. + "background" => background: Color = Color { r: 0x28, g: 0x2C, b: 0x34 }, + + /// Foreground color for the window. + /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. + "foreground" => foreground: Color = Color { r: 0xFF, g: 0xFF, b: 0xFF }, + + /// Background image for the terminal. + /// + /// This should be a path to a PNG or JPEG file, other image formats are + /// not yet supported. + /// + /// The background image is currently per-terminal, not per-window. If + /// you are a heavy split user, the background image will be repeated across + /// splits. A future improvement to Ghostty will address this. + /// + /// WARNING: Background images are currently duplicated in VRAM per-terminal. + /// For sufficiently large images, this could lead to a large increase in + /// memory usage (specifically VRAM usage). A future Ghostty improvement + /// will resolve this by sharing image textures across terminals. + /// + /// Available since: 1.2.0 + "background-image" => background_image: Option = None, + + /// Background image opacity. + /// + /// This is relative to the value of `background-opacity`. + /// + /// A value of `1.0` (the default) will result in the background image being + /// placed on top of the general background color, and then the combined result + /// will be adjusted to the opacity specified by `background-opacity`. + /// + /// A value less than `1.0` will result in the background image being mixed + /// with the general background color before the combined result is adjusted + /// to the configured `background-opacity`. + /// + /// A value greater than `1.0` will result in the background image having a + /// higher opacity than the general background color. For instance, if the + /// configured `background-opacity` is `0.5` and `background-image-opacity` + /// is set to `1.5`, then the final opacity of the background image will be + /// `0.5 * 1.5 = 0.75`. + /// + /// Available since: 1.2.0 + "background-image-opacity" => background_image_opacity: f32 = 1.0, + + /// Background image position. + /// + /// Valid values are: + /// * `top-left` + /// * `top-center` + /// * `top-right` + /// * `center-left` + /// * `center` + /// * `center-right` + /// * `bottom-left` + /// * `bottom-center` + /// * `bottom-right` + /// + /// The default value is `center`. + /// + /// Available since: 1.2.0 + "background-image-position" => background_image_position: BackgroundImagePosition = BackgroundImagePosition::Center, + + /// Background image fit. + /// + /// Valid values are: + /// + /// * `contain` + /// + /// Preserving the aspect ratio, scale the background image to the largest + /// size that can still be contained within the terminal, so that the whole + /// image is visible. + /// + /// * `cover` + /// + /// Preserving the aspect ratio, scale the background image to the smallest + /// size that can completely cover the terminal. This may result in one or + /// more edges of the image being clipped by the edge of the terminal. + /// + /// * `stretch` + /// + /// Stretch the background image to the full size of the terminal, without + /// preserving the aspect ratio. + /// + /// * `none` + /// + /// Don't scale the background image. + /// + /// The default value is `contain`. + /// + /// Available since: 1.2.0 + "background-image-fit" => background_image_fit: BackgroundImageFit = BackgroundImageFit::Contain, + + /// Whether to repeat the background image or not. + /// + /// If this is set to true, the background image will be repeated if there + /// would otherwise be blank space around it because it doesn't completely + /// fill the terminal area. + /// + /// The default value is `false`. + /// + /// Available since: 1.2.0 + "background-image-repeat" => background_image_repeat: bool = false, + + /// The foreground and background color for selection. If this is not set, then + /// the selection color is just the inverted window background and foreground + /// (note: not to be confused with the cell bg/fg). + /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. + /// Since version 1.2.0, this can also be set to `cell-foreground` to match + /// the cell foreground color, or `cell-background` to match the cell + /// background color. + "selection-foreground" => selection_foreground: Option = None, + + "selection-background" => selection_background: Option = None, + + /// Whether to clear selected text when typing. This defaults to `true`. + /// This is typical behavior for most terminal emulators as well as + /// text input fields. If you set this to `false`, then the selected text + /// will not be cleared when typing. + /// + /// "Typing" is specifically defined as any non-modifier (shift, control, + /// alt, etc.) keypress that produces data to be sent to the application + /// running within the terminal (e.g. the shell). Additionally, selection + /// is cleared when any preedit or composition state is started (e.g. + /// when typing languages such as Japanese). + /// + /// If this is `false`, then the selection can still be manually + /// cleared by clicking once or by pressing `escape`. + /// + /// Available since: 1.2.0 + "selection-clear-on-typing" => selection_clear_on_typing: bool = true, + + /// Whether to clear selected text after copying. This defaults to `false`. + /// + /// When set to `true`, the selection will be automatically cleared after + /// any copy operation that invokes the `copy_to_clipboard` keyboard binding. + /// Importantly, this will not clear the selection if the copy operation + /// was invoked via `copy-on-select`. + /// + /// When set to `false`, the selection remains visible after copying, allowing + /// to see what was copied and potentially perform additional operations + /// on the same selection. + "selection-clear-on-copy" => selection_clear_on_copy: bool = false, + + /// Characters that mark word boundaries during text selection operations such + /// as double-clicking. When selecting a word, the selection will stop at any + /// of these characters. + /// + /// This is similar to the `WORDCHARS` environment variable in zsh, except this + /// specifies the boundary characters rather than the word characters. The + /// default includes common delimiters and punctuation that typically separate + /// words in code and prose. + /// + /// Each character in this string becomes a word boundary. Multi-byte UTF-8 + /// characters are supported, but only single codepoints can be specified. + /// Multi-codepoint sequences (e.g. emoji) are not supported. + /// + /// The null character (U+0000) is always treated as a boundary and does not + /// need to be included in this configuration. + /// + /// Default: `` \t'"│`|:;,()[]{}<>$ `` + /// + /// To add or remove specific characters, you can set this to a custom value. + /// For example, to treat semicolons as part of words: + /// + /// selection-word-chars = " \t'\"│`|:,()[]{}<>$" + /// + /// Available since: 1.3.0 + "selection-word-chars" => selection_word_chars: SelectionWordChars = SelectionWordChars::default(), + + /// The minimum contrast ratio between the foreground and background colors. + /// The contrast ratio is a value between 1 and 21. A value of 1 allows for no + /// contrast (e.g. black on black). This value is the contrast ratio as defined + /// by the [WCAG 2.0 specification](https://www.w3.org/TR/WCAG20/). + /// + /// If you want to avoid invisible text (same color as background), a value of + /// 1.1 is a good value. If you want to avoid text that is difficult to read, a + /// value of 3 or higher is a good value. The higher the value, the more likely + /// that text will become black or white. + /// + /// This value does not apply to Emoji or images. + "minimum-contrast" => minimum_contrast: f64 = 1.0, + + /// Color palette for the 256 color form that many terminal applications use. + /// The syntax of this configuration is `N=COLOR` where `N` is 0 to 255 (for + /// the 256 colors in the terminal color table) and `COLOR` is a typical RGB + /// color code such as `#AABBCC` or `AABBCC`, or a named X11 color. For example, + /// `palette = 5=#BB78D9` will set the 'purple' color. + /// + /// The palette index can be in decimal, binary, octal, or hexadecimal. + /// Decimal is assumed unless a prefix is used: `0b` for binary, `0o` for octal, + /// and `0x` for hexadecimal. + /// + /// For definitions on the color indices and what they canonically map to, + /// [see this cheat sheet](https://www.ditig.com/256-colors-cheat-sheet). + /// + /// For most themes, you only need to set the first 16 colors (0–15) since the + /// rest of the palette (16–255) will be automatically generated by + /// default (see `palette-generate` for more details). + "palette" => palette: Palette = Palette::default(), + + /// Whether to automatically generate the extended 256 color palette + /// (indices 16–255) from the base 16 ANSI colors. + /// + /// This lets theme authors specify only the base 16 colors and have the + /// rest of the palette be automatically generated in a consistent and + /// aesthetic way. + /// + /// When enabled, the 6×6×6 color cube and 24-step grayscale ramp are + /// derived from interpolations of the base palette, giving a more cohesive + /// look. Colors that have been explicitly set via `palette` are never + /// overwritten. + /// + /// The default value is false (disabled), because many legacy programs + /// using the 256-color palette hardcode assumptions about what these + /// colors are (mostly assuming the xterm 256 color palette). However, this + /// is still a very useful tool for theme authors and users who want + /// to customize their palette without having to specify all 256 colors. + /// + /// For more information on how the generation works, see here: + /// https://gist.github.com/jake-stewart/0a8ea46159a7da2c808e5be2177e1783 + /// + /// Available since: 1.3.0 + "palette-generate" => palette_generate: bool = false, + + /// Invert the palette colors generated when `palette-generate` is enabled, + /// so that the colors go in reverse order. This allows palette-based + /// applications to work well in both light and dark mode since the + /// palettes are always relatively good colors. + /// + /// This defaults to off because some legacy terminal applications + /// hardcode the assumption that palette indices 16–231 are ordered from + /// darkest to lightest, so enabling this would make them unreadable. + /// This is not a generally good assumption and we encourage modern + /// terminal applications to use the indices in a more semantic way. + /// + /// This has no effect if `palette-generate` is disabled. + /// + /// For more information see `palette-generate`. + /// + /// Available since: 1.3.0 + "palette-harmonious" => palette_harmonious: bool = false, + + /// The color of the cursor. If this is not set, a default will be chosen. + /// + /// Direct colors can be specified as either hex (`#RRGGBB` or `RRGGBB`) + /// or a named X11 color. + /// + /// Additionally, special values can be used to set the color to match + /// other colors at runtime: + /// + /// * `cell-foreground` - Match the cell foreground color. + /// (Available since: 1.2.0) + /// + /// * `cell-background` - Match the cell background color. + /// (Available since: 1.2.0) + "cursor-color" => cursor_color: Option = None, + + /// The opacity level (opposite of transparency) of the cursor. A value of 1 + /// is fully opaque and a value of 0 is fully transparent. A value less than 0 + /// or greater than 1 will be clamped to the nearest valid value. Note that a + /// sufficiently small value such as 0.3 may be effectively invisible and may + /// make it difficult to find the cursor. + "cursor-opacity" => cursor_opacity: f64 = 1.0, + + /// The style of the cursor. This sets the default style. A running program can + /// still request an explicit cursor style using escape sequences (such as `CSI + /// q`). Shell configurations will often request specific cursor styles. + /// + /// Note that shell integration will automatically set the cursor to a bar at + /// a prompt, regardless of this configuration. You can disable that behavior + /// by specifying `shell-integration-features = no-cursor` or disabling shell + /// integration entirely. + /// + /// Valid values are: + /// + /// * `block` + /// * `bar` + /// * `underline` + /// * `block_hollow` + "cursor-style" => cursor_style: CursorStyle = CursorStyle::Block, + + /// Sets the default blinking state of the cursor. This is just the default + /// state; running programs may override the cursor style using `DECSCUSR` (`CSI + /// q`). + /// + /// If this is not set, the cursor blinks by default. Note that this is not the + /// same as a "true" value, as noted below. + /// + /// If this is not set at all (`null`), then Ghostty will respect DEC Mode 12 + /// (AT&T cursor blink) as an alternate approach to turning blinking on/off. If + /// this is set to any value other than null, DEC mode 12 will be ignored but + /// `DECSCUSR` will still be respected. + /// + /// Valid values are: + /// + /// * ` ` (blank) + /// * `true` + /// * `false` + "cursor-style-blink" => cursor_style_blink: Option = None, + + /// The color of the text under the cursor. If this is not set, a default will + /// be chosen. + /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. + /// Since version 1.2.0, this can also be set to `cell-foreground` to match + /// the cell foreground color, or `cell-background` to match the cell + /// background color. + "cursor-text" => cursor_text: Option = None, + + /// Enables the ability to move the cursor at prompts by clicking on a + /// location in the prompt text. + /// + /// This feature requires shell integration, specifically prompt marking + /// via `OSC 133`. Some shells like Fish (v4) and Nu (0.111+) natively + /// support this while others may require additional configuration or + /// Ghostty's shell integration features to be enabled. + /// + /// Depending on the shell, this works either by translating your click + /// position into a series of synthetic arrow key movements or by sending + /// a click event directly to the shell. In either case, some unexpected + /// behavior around edge cases is possible. + "cursor-click-to-move" => cursor_click_to_move: bool = true, + + /// Hide the mouse immediately when typing. The mouse becomes visible again + /// when the mouse is used (button, movement, etc.). Platform-specific behavior + /// may dictate other scenarios where the mouse is shown. For example on macOS, + /// the mouse is shown again when a new window, tab, or split is created. + "mouse-hide-while-typing" => mouse_hide_while_typing: bool = false, + + /// When to scroll the surface to the bottom. The format of this is a list of + /// options to enable separated by commas. If you prefix an option with `no-` + /// then it is disabled. If you omit an option, its default value is used. + /// + /// Available options: + /// + /// - `keystroke` If set, scroll the surface to the bottom when the user + /// presses a key that results in data being sent to the PTY (basically + /// anything but modifiers or keybinds that are processed by Ghostty). + /// + /// - `output` If set, scroll the surface to the bottom if there is new data + /// to display (e.g., when new lines are printed to the terminal). + /// + /// The default is `keystroke, no-output`. + "scroll-to-bottom" => scroll_to_bottom: ScrollToBottom = ScrollToBottom::default(), + + /// Determines whether running programs can detect the shift key pressed with a + /// mouse click. Typically, the shift key is used to extend mouse selection. + /// + /// The default value of `false` means that the shift key is not sent with + /// the mouse protocol and will extend the selection. This value can be + /// conditionally overridden by the running program with the `XTSHIFTESCAPE` + /// sequence. + /// + /// The value `true` means that the shift key is sent with the mouse protocol + /// but the running program can override this behavior with `XTSHIFTESCAPE`. + /// + /// The value `never` is the same as `false` but the running program cannot + /// override this behavior with `XTSHIFTESCAPE`. The value `always` is the + /// same as `true` but the running program cannot override this behavior with + /// `XTSHIFTESCAPE`. + /// + /// If you always want shift to extend mouse selection even if the program + /// requests otherwise, set this to `never`. + /// + /// Valid values are: + /// + /// * `true` + /// * `false` + /// * `always` + /// * `never` + "mouse-shift-capture" => mouse_shift_capture: MouseShiftCapture = MouseShiftCapture::False, + + /// Enable or disable mouse reporting. When set to `false`, mouse events will + /// not be reported to terminal applications even if they request it. This + /// allows you to always use the mouse for selection and other terminal UI + /// interactions without applications capturing mouse input. + /// + /// When set to `true` (the default), terminal applications can request mouse + /// reporting and will receive mouse events according to their requested mode. + /// + /// This can be toggled at runtime using the `toggle_mouse_reporting` keybind + /// action. + "mouse-reporting" => mouse_reporting: bool = true, + + /// Multiplier for scrolling distance with the mouse wheel. + /// + /// A prefix of `precision:` or `discrete:` can be used to set the multiplier + /// only for scrolling with the specific type of devices. These can be + /// comma-separated to set both types of multipliers at the same time, e.g. + /// `precision:0.1,discrete:3`. If no prefix is used, the multiplier applies + /// to all scrolling devices. Specifying a prefix was introduced in Ghostty + /// 1.2.1. + /// + /// The value will be clamped to [0.01, 10,000]. Both of these are extreme + /// and you're likely to have a bad experience if you set either extreme. + /// + /// The default value is "3" for discrete devices and "1" for precision devices. + "mouse-scroll-multiplier" => mouse_scroll_multiplier: MouseScrollMultiplier = MouseScrollMultiplier::default(), + + /// The opacity level (opposite of transparency) of the background. A value of + /// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0 + /// or greater than 1 will be clamped to the nearest valid value. + /// + /// On macOS, background opacity is disabled when the terminal enters native + /// fullscreen. This is because the background becomes gray and it can cause + /// widgets to show through which isn't generally desirable. + /// + /// On macOS, changing this configuration requires restarting Ghostty completely. + "background-opacity" => background_opacity: f64 = 1.0, + + /// Applies background opacity to cells with an explicit background color + /// set. + /// + /// Normally, `background-opacity` is only applied to the window background. + /// If a cell has an explicit background color set, such as red, then that + /// background color will be fully opaque. An effect of this is that some + /// terminal applications that repaint the background color of the terminal + /// such as a Neovim and Tmux may not respect the `background-opacity` + /// (by design). + /// + /// Setting this to `true` will apply the `background-opacity` to all cells + /// regardless of whether they have an explicit background color set or not. + /// + /// Available since: 1.2.0 + "background-opacity-cells" => background_opacity_cells: bool = false, + + /// Whether to blur the background when `background-opacity` is less than 1. + /// + /// Valid values are: + /// + /// * a nonnegative integer specifying the *blur intensity* + /// * `false`, equivalent to a blur intensity of 0 + /// * `true`, equivalent to the default blur intensity of 20, which is + /// reasonable for a good looking blur. Higher blur intensities may + /// cause strange rendering and performance issues. + /// + /// On macOS 26.0 and later, there are additional special values that + /// can be set to use the native macOS glass effects: + /// + /// * `macos-glass-regular` - Standard glass effect with some opacity + /// * `macos-glass-clear` - Highly transparent glass effect + /// + /// If the macOS values are set, then this implies `background-blur = true` + /// on non-macOS platforms. + /// + /// Supported on macOS and on some Linux desktop environments, including: + /// + /// * KDE Plasma (Wayland and X11) + /// + /// Warning: the exact blur intensity is _ignored_ under KDE Plasma, and setting + /// this setting to either `true` or any positive blur intensity value would + /// achieve the same effect. The reason is that KWin, the window compositor + /// powering Plasma, only has one global blur setting and does not allow + /// applications to specify individual blur settings. + /// + /// To configure KWin's global blur setting, open System Settings and go to + /// "Apps & Windows" > "Window Management" > "Desktop Effects" and select the + /// "Blur" plugin. If disabled, enable it by ticking the checkbox to the left. + /// Then click on the "Configure" button and there will be two sliders that + /// allow you to set background blur and noise intensities for all apps, + /// including Ghostty. + /// + /// All other Linux desktop environments are as of now unsupported. Users may + /// need to set environment-specific settings and/or install third-party plugins + /// in order to support background blur, as there isn't a unified interface for + /// doing so. + "background-blur" => background_blur: BackgroundBlur = BackgroundBlur::False, + + /// The opacity level (opposite of transparency) of an unfocused split. + /// Unfocused splits by default are slightly faded out to make it easier to see + /// which split is focused. To disable this feature, set this value to 1. + /// + /// A value of 1 is fully opaque and a value of 0 is fully transparent. Because + /// "0" is not useful (it makes the window look very weird), the minimum value + /// is 0.15. This value still looks weird but you can at least see what's going + /// on. A value outside of the range 0.15 to 1 will be clamped to the nearest + /// valid value. + "unfocused-split-opacity" => unfocused_split_opacity: f64 = 0.7, + + /// The color to dim the unfocused split. Unfocused splits are dimmed by + /// rendering a semi-transparent rectangle over the split. This sets the color of + /// that rectangle and can be used to carefully control the dimming effect. + /// + /// This will default to the background color. + /// + /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. + "unfocused-split-fill" => unfocused_split_fill: Option = None, + + /// The color of the split divider. If this is not set, a default will be chosen. + /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. + /// + /// Available since: 1.1.0 + "split-divider-color" => split_divider_color: Option = None, + + /// Control when Ghostty preserves a zoomed split. Under normal circumstances, + /// any operation that changes focus or layout of the split tree in a window + /// will unzoom any zoomed split. This configuration allows you to control + /// this behavior. + /// + /// This can be set to `navigation` to preserve the zoomed split state + /// when navigating to another split (e.g. via `goto_split`). This will + /// change the zoomed split to the newly focused split instead of unzooming. + /// + /// Any options can also be prefixed with `no-` to disable that option. + /// + /// Example: `split-preserve-zoom = navigation` + /// + /// Available since: 1.3.0 + "split-preserve-zoom" => split_preserve_zoom: SplitPreserveZoom = SplitPreserveZoom::default(), + + /// The foreground and background color for search matches. This only applies + /// to non-focused search matches, also known as candidate matches. + /// + /// Valid values: + /// + /// - Hex (`#RRGGBB` or `RRGGBB`) + /// - Named X11 color + /// - "cell-foreground" to match the cell foreground color + /// - "cell-background" to match the cell background color + /// + /// The default value is black text on a golden yellow background. + "search-foreground" => search_foreground: TerminalColor = TerminalColor::Color(Color { r: 0, g: 0, b: 0 }), + + "search-background" => search_background: TerminalColor = TerminalColor::Color(Color { r: 0xFF, g: 0xE0, b: 0x82 }), + + /// The foreground and background color for the currently selected search match. + /// This is the focused match that will be jumped to when using next/previous + /// search navigation. + /// + /// Valid values: + /// + /// - Hex (`#RRGGBB` or `RRGGBB`) + /// - Named X11 color + /// - "cell-foreground" to match the cell foreground color + /// - "cell-background" to match the cell background color + /// + /// The default value is black text on a soft peach background. + "search-selected-foreground" => search_selected_foreground: TerminalColor = TerminalColor::Color(Color { r: 0, g: 0, b: 0 }), + + "search-selected-background" => search_selected_background: TerminalColor = TerminalColor::Color(Color { r: 0xF2, g: 0xA5, b: 0x7E }), + + /// The command to run, usually a shell. If this is not an absolute path, it'll + /// be looked up in the `PATH`. If this is not set, a default will be looked up + /// from your system. The rules for the default lookup are: + /// + /// * `SHELL` environment variable + /// + /// * `passwd` entry (user information) + /// + /// This can contain additional arguments to run the command with. If additional + /// arguments are provided, the command will be executed using `/bin/sh -c` + /// to offload shell argument expansion. + /// + /// To avoid shell expansion altogether, prefix the command with `direct:`, e.g. + /// `direct:nvim foo`. This will avoid the roundtrip to `/bin/sh` but will also + /// not support any shell parsing such as arguments with spaces, filepaths with + /// `~`, globs, etc. (Available since: 1.2.0) + /// + /// You can also explicitly prefix the command with `shell:` to always wrap the + /// command in a shell. This can be used to ensure our heuristics to choose the + /// right mode are not used in case they are wrong. (Available since: 1.2.0) + /// + /// This command will be used for all new terminal surfaces, i.e. new windows, + /// tabs, etc. If you want to run a command only for the first terminal surface + /// created when Ghostty starts, use the `initial-command` configuration. + /// + /// Ghostty supports the common `-e` flag for executing a command with + /// arguments. For example, `ghostty -e fish --with --custom --args`. + /// This flag sets the `initial-command` configuration, see that for more + /// information. + "command" => command: Option = None, + + /// This is the same as "command", but only applies to the first terminal + /// surface created when Ghostty starts. Subsequent terminal surfaces will use + /// the `command` configuration. + /// + /// After the first terminal surface is created (or closed), there is no + /// way to run this initial command again automatically. As such, setting + /// this at runtime works but will only affect the next terminal surface + /// if it is the first one ever created. + /// + /// If you're using the `ghostty` CLI there is also a shortcut to set this + /// with arguments directly: you can use the `-e` flag. For example: `ghostty -e + /// fish --with --custom --args`. The `-e` flag automatically forces some + /// other behaviors as well: + /// + /// * Disables shell expansion since the input is expected to already + /// be shell-expanded by the upstream (e.g. the shell used to type in + /// the `ghostty -e` command). + /// + /// * `gtk-single-instance=false` - This ensures that a new instance is + /// launched and the CLI args are respected. + /// + /// * `quit-after-last-window-closed=true` - This ensures that the Ghostty + /// process will exit when the command exits. Additionally, the + /// `quit-after-last-window-closed-delay` is unset. + /// + /// * `shell-integration=detect` (if not `none`) - This prevents forcibly + /// injecting any configured shell integration into the command's + /// environment. With `-e` its highly unlikely that you're executing a + /// shell and forced shell integration is likely to cause problems + /// (e.g. by wrapping your command in a shell, setting env vars, etc.). + /// This is a safety measure to prevent unexpected behavior. If you want + /// shell integration with a `-e`-executed command, you must either + /// name your binary appropriately or source the shell integration script + /// manually. + "initial-command" => initial_command: Option = None, + + /// Controls when command finished notifications are sent. There are + /// three options: + /// + /// * `never` - Never send notifications (the default). + /// * `unfocused` - Only send notifications if the surface that the command is + /// running in is not focused. + /// * `always` - Always send notifications. + /// + /// Command finished notifications requires that either shell integration is + /// enabled, or that your shell sends OSC 133 escape sequences to mark the start + /// and end of commands. + /// + /// On GTK, there is a context menu item that will enable command finished + /// notifications for a single command, overriding the `never` and `unfocused` + /// options. + /// + /// Available since 1.3.0. + "notify-on-command-finish" => notify_on_command_finish: NotifyOnCommandFinish = NotifyOnCommandFinish::Never, + + /// If command finished notifications are enabled, this controls how the user is + /// notified. + /// + /// Available options: + /// + /// * `bell` - enabled by default + /// * `notify` - disabled by default + /// + /// Options can be combined by listing them as a comma separated list. Options + /// can be negated by prefixing them with `no-`. For example `no-bell,notify`. + /// + /// Available since 1.3.0. + "notify-on-command-finish-action" => notify_on_command_finish_action: NotifyOnCommandFinishAction = NotifyOnCommandFinishAction { bell: true, notify: false }, + + /// If command finished notifications are enabled, this controls how long a + /// command must have been running before a notification will be sent. The + /// default is five seconds. + /// + /// The duration is specified as a series of numbers followed by time units. + /// Whitespace is allowed between numbers and units. Each number and unit will + /// be added together to form the total duration. + /// + /// The allowed time units are as follows: + /// + /// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments + /// are made for leap years or leap seconds. + /// * `d` - one SI day, or 86400 seconds. + /// * `h` - one hour, or 3600 seconds. + /// * `m` - one minute, or 60 seconds. + /// * `s` - one second. + /// * `ms` - one millisecond, or 0.001 second. + /// * `us` or `µs` - one microsecond, or 0.000001 second. + /// * `ns` - one nanosecond, or 0.000000001 second. + /// + /// Examples: + /// * `1h30m` + /// * `45s` + /// + /// Units can be repeated and will be added together. This means that + /// `1h1h` is equivalent to `2h`. This is confusing and should be avoided. + /// A future update may disallow this. + /// + /// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. Any + /// value larger than this will be clamped to the maximum value. + /// + /// Available since 1.3.0 + "notify-on-command-finish-after" => notify_on_command_finish_after: Duration = Duration { duration: 5 * NS_PER_S }, + + /// Extra environment variables to pass to commands launched in a terminal + /// surface. The format is `env=KEY=VALUE`. + /// + /// `env = foo=bar` + /// `env = bar=baz` + /// + /// Setting `env` to an empty string will reset the entire map to default + /// (empty). + /// + /// `env =` + /// + /// Setting a key to an empty string will remove that particular key and + /// corresponding value from the map. + /// + /// `env = foo=bar` + /// `env = foo=` + /// + /// will result in `foo` not being passed to the launched commands. + /// + /// Setting a key multiple times will overwrite previous entries. + /// + /// `env = foo=bar` + /// `env = foo=baz` + /// + /// will result in `foo=baz` being passed to the launched commands. + /// + /// These environment variables will override any existing environment + /// variables set by Ghostty. For example, if you set `GHOSTTY_RESOURCES_DIR` + /// then the value you set here will override the value Ghostty typically + /// automatically injects. + /// + /// These environment variables _will not_ be passed to commands run by Ghostty + /// for other purposes, like `open` or `xdg-open` used to open URLs in your + /// browser. + /// + /// Available since: 1.2.0 + "env" => env: RepeatableStringMap = RepeatableStringMap::default(), + + /// Data to send as input to the command on startup. + /// + /// The configured `command` will be launched using the typical rules, + /// then the data specified as this input will be written to the pty + /// before any other input can be provided. + /// + /// The bytes are sent as-is with no additional encoding. Therefore, be + /// cautious about input that can contain control characters, because this + /// can be used to execute programs in a shell. + /// + /// The format of this value is: + /// + /// * `raw:` - Send raw text as-is. This uses Zig string literal + /// syntax so you can specify control characters and other standard + /// escapes. + /// + /// * `path:` - Read a filepath and send the contents. The path + /// must be to a file with finite length. e.g. don't use a device + /// such as `/dev/stdin` or `/dev/urandom` as these will block + /// terminal startup indefinitely. Files are limited to 10MB + /// in size to prevent excessive memory usage. If you have files + /// larger than this you should write a script to read the file + /// and send it to the terminal. + /// + /// If no valid prefix is found, it is assumed to be a `raw:` input. + /// This is an ergonomic choice to allow you to simply write + /// `input = "Hello, world!"` (a common case) without needing to prefix + /// every value with `raw:`. + /// + /// This can be repeated multiple times to send more data. The data + /// is concatenated directly with no separator characters in between + /// (e.g. no newline). + /// + /// If any of the input sources do not exist, then none of the input + /// will be sent. Input sources are not verified until the terminal + /// is starting, so missing paths will not show up in config validation. + /// + /// Changing this configuration at runtime will only affect new + /// terminals. + /// + /// Available since: 1.2.0 + "input" => input: RepeatableReadableIO = RepeatableReadableIO::default(), + + /// If true, keep the terminal open after the command exits. Normally, the + /// terminal window closes when the running command (such as a shell) exits. + /// With this true, the terminal window will stay open until any keypress is + /// received. + /// + /// This is primarily useful for scripts or debugging. + "wait-after-command" => wait_after_command: bool = false, + + /// The number of milliseconds of runtime below which we consider a process exit + /// to be abnormal. This is used to show an error message when the process exits + /// too quickly. + /// + /// On Linux, this must be paired with a non-zero exit code. On macOS, we allow + /// any exit code because of the way shell processes are launched via the login + /// command. + "abnormal-command-exit-runtime" => abnormal_command_exit_runtime: u32 = 250, + + /// The size of the scrollback buffer in bytes. This also includes the active + /// screen. No matter what this is set to, enough memory will always be + /// allocated for the visible screen and anything leftover is the limit for + /// the scrollback. + /// + /// When this limit is reached, the oldest lines are removed from the + /// scrollback. + /// + /// Scrollback currently exists completely in memory. This means that the + /// larger this value, the larger potential memory usage. Scrollback is + /// allocated lazily up to this limit, so if you set this to a very large + /// value, it will not immediately consume a lot of memory. + /// + /// This size is per terminal surface, not for the entire application. + /// + /// It is not currently possible to set an unlimited scrollback buffer. + /// This is a future planned feature. + /// + /// This can be changed at runtime but will only affect new terminal surfaces. + "scrollback-limit" => scrollback_limit: usize = 10_000_000, + + /// Control when the scrollbar is shown to scroll the scrollback buffer. + /// + /// The default value is `system`. + /// + /// Valid values: + /// + /// * `system` - Respect the system settings for when to show scrollbars. + /// For example, on macOS, this will respect the "Scrollbar behavior" + /// system setting which by default usually only shows scrollbars while + /// actively scrolling or hovering the gutter. + /// + /// * `never` - Never show a scrollbar. You can still scroll using the mouse, + /// keybind actions, etc. but you will not have a visual UI widget showing + /// a scrollbar. + "scrollbar" => scrollbar: Scrollbar = Scrollbar::System, + + /// Match a regular expression against the terminal text and associate clicking + /// it with an action. This can be used to match URLs, file paths, etc. Actions + /// can be opening using the system opener (e.g. `open` or `xdg-open`) or + /// executing any arbitrary binding action. + /// + /// Links that are configured earlier take precedence over links that are + /// configured later. + /// + /// A default link that matches a URL and opens it in the system opener always + /// exists. This can be disabled using `link-url`. + /// + /// TODO: This can't currently be set! + "link" => link: RepeatableLink = RepeatableLink::default(), + + /// Enable URL matching. URLs are matched on hover with control (Linux) or + /// command (macOS) pressed and open using the default system application for + /// the linked URL. + /// + /// The URL matcher is always lowest priority of any configured links (see + /// `link`). If you want to customize URL matching, use `link` and disable this. + "link-url" => link_url: bool = true, + + /// Show link previews for a matched URL. + /// + /// When true, link previews are shown for all matched URLs. When false, link + /// previews are never shown. When set to "osc8", link previews are only shown + /// for hyperlinks created with the OSC 8 sequence (in this case, the link text + /// can differ from the link destination). + /// + /// Available since: 1.2.0 + "link-previews" => link_previews: LinkPreviews = LinkPreviews::True, + + /// Whether to start the window in a maximized state. This setting applies + /// to new windows and does not apply to tabs, splits, etc. However, this setting + /// will apply to all new windows, not just the first one. + /// + /// Available since: 1.1.0 + "maximize" => maximize: bool = false, + + /// Start new windows in fullscreen. This setting applies to new windows and + /// does not apply to tabs, splits, etc. However, this setting will apply to all + /// new windows, not just the first one. + /// + /// Allowable values are: + /// + /// * `false` - Don't start in fullscreen (default) + /// * `true` - Start in native fullscreen + /// * `non-native` - (macOS only) Start in non-native fullscreen, hiding the + /// menu bar. This is faster than native fullscreen since it doesn't use + /// animations. On non-macOS platforms, this behaves the same as `true`. + /// * `non-native-visible-menu` - (macOS only) Start in non-native fullscreen, + /// keeping the menu bar visible. On non-macOS platforms, behaves like `true`. + /// * `non-native-padded-notch` - (macOS only) Start in non-native fullscreen, + /// hiding the menu bar but padding for the notch on applicable devices. + /// On non-macOS platforms, behaves like `true`. + /// + /// Important: tabs DO NOT WORK with non-native fullscreen modes. Non-native + /// fullscreen removes the titlebar and macOS native tabs require the titlebar. + /// If you use tabs, use `true` (native) instead. + /// + /// On macOS, `true` (native fullscreen) does not work if `window-decoration` + /// is set to `false`, because native fullscreen on macOS requires window + /// decorations. + "fullscreen" => fullscreen: Fullscreen = Fullscreen::False, + + /// The title Ghostty will use for the window. This will force the title of the + /// window to be this title at all times and Ghostty will ignore any set title + /// escape sequences programs (such as Neovim) may send. + /// + /// If you want a blank title, set this to one or more spaces by quoting + /// the value. For example, `title = " "`. This effectively hides the title. + /// This is necessary because setting a blank value resets the title to the + /// default value of the running program. + /// + /// This configuration can be reloaded at runtime. If it is set, the title + /// will update for all windows. If it is unset, the next title change escape + /// sequence will be honored but previous changes will not retroactively + /// be set. This latter case may require you to restart programs such as Neovim + /// to get the new title. + "title" => title: Option = None, + + /// The setting that will change the application class value. + /// + /// This controls the class field of the `WM_CLASS` X11 property (when running + /// under X11), the Wayland application ID (when running under Wayland), and the + /// bus name that Ghostty uses to connect to DBus. + /// + /// Note that changing this value between invocations will create new, separate + /// instances, of Ghostty when running with `gtk-single-instance=true`. See that + /// option for more details. + /// + /// Changing this value may break launching Ghostty from `.desktop` files, via + /// DBus activation, or systemd user services as the system is expecting Ghostty + /// to connect to DBus using the default `class` when it is launched. + /// + /// The class name must follow the requirements defined [in the GTK + /// documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html). + /// + /// The default is `com.mitchellh.ghostty`. + /// + /// This only affects GTK builds. + "class" => class: Option = None, + + /// This controls the instance name field of the `WM_CLASS` X11 property when + /// running under X11. It has no effect otherwise. + /// + /// The default is `ghostty`. + /// + /// This only affects GTK builds. + "x11-instance-name" => x11_instance_name: Option = None, + + /// The directory to change to after starting the command. + /// + /// This setting is secondary to the `window-inherit-working-directory` + /// setting. If a previous Ghostty terminal exists in the same process, + /// `window-inherit-working-directory` will take precedence. Otherwise, this + /// setting will be used. Typically, this setting is used only for the first + /// window. + /// + /// The default is `inherit` except in special scenarios listed next. On macOS, + /// if Ghostty can detect it is launched from launchd (double-clicked) or + /// `open`, then it defaults to `home`. On Linux with GTK, if Ghostty can detect + /// it was launched from a desktop launcher, then it defaults to `home`. + /// + /// The value of this must be an absolute path, a path prefixed with `~/` + /// (the tilde will be expanded to the user's home directory), or + /// one of the special values below: + /// + /// * `home` - The home directory of the executing user. + /// + /// * `inherit` - The working directory of the launching process. + "working-directory" => working_directory: Option = None, + + /// Key bindings. The format is `trigger=action`. Duplicate triggers will + /// overwrite previously set values. The list of actions is available in + /// the documentation or using the `ghostty +list-actions` command. + /// + /// Trigger: `+`-separated list of keys and modifiers. Example: `ctrl+a`, + /// `ctrl+shift+b`, `up`. + /// + /// If the key is a single Unicode codepoint, the trigger will match + /// any presses that produce that codepoint. These are impacted by + /// keyboard layouts. For example, `a` will match the `a` key on a + /// QWERTY keyboard, but will match the `q` key on a AZERTY keyboard + /// (assuming US physical layout). + /// + /// For Unicode codepoints, matching is done by comparing the set of + /// modifiers with the unmodified codepoint. The unmodified codepoint is + /// sometimes called an "unshifted character" in other software, but all + /// modifiers are considered, not only shift. For example, `ctrl+a` will match + /// `a` but not `ctrl+shift+a` (which is `A` on a US keyboard). + /// + /// Further, codepoint matching is case-insensitive and the unmodified + /// codepoint is always case folded for comparison. As a result, + /// `ctrl+A` configured will match when `ctrl+a` is pressed. Note that + /// this means some key combinations are impossible depending on keyboard + /// layout. For example, `ctrl+_` is impossible on a US keyboard because + /// `_` is `shift+-` and `ctrl+shift+-` is not equal to `ctrl+_` (because + /// the modifiers don't match!). More details on impossible key combinations + /// can be found at this excellent source written by Qt developers: + /// https://doc.qt.io/qt-6/qkeysequence.html#keyboard-layout-issues + /// + /// Physical key codes can be specified by using any of the key codes + /// as specified by the [W3C specification](https://www.w3.org/TR/uievents-code/). + /// For example, `KeyA` will match the physical `a` key on a US standard + /// keyboard regardless of the keyboard layout. These are case-sensitive. + /// + /// For aesthetic reasons, the w3c codes also support snake case. For + /// example, `key_a` is equivalent to `KeyA`. The only exceptions are + /// function keys, e.g. `F1` is `f1` (no underscore). This is a consequence + /// of our internal code using snake case but is purposely supported + /// and tested so it is safe to use. It allows an all-lowercase binding + /// which I find more aesthetically pleasing. + /// + /// Function keys such as `insert`, `up`, `f5`, etc. are also specified + /// using the keys as specified by the previously linked W3C specification. + /// + /// Physical keys always match with a higher priority than Unicode codepoints, + /// so if you specify both `a` and `KeyA`, the physical key will always be used + /// regardless of what order they are configured. + /// + /// The special key `catch_all` can be used to match any key that is not + /// otherwise bound. This can be combined with modifiers, for example + /// `ctrl+catch_all` will match any key pressed with `ctrl` that is not + /// otherwise bound. When looking up a binding, Ghostty first tries to match + /// `catch_all` with modifiers. If no match is found and the event has + /// modifiers, it falls back to `catch_all` without modifiers. + /// + /// Valid modifiers are `shift`, `ctrl` (alias: `control`), `alt` (alias: `opt`, + /// `option`), and `super` (alias: `cmd`, `command`). You may use the modifier + /// or the alias. When debugging keybinds, the non-aliased modifier will always + /// be used in output. + /// + /// Note: The fn or "globe" key on keyboards are not supported as a + /// modifier. This is a limitation of the operating systems and GUI toolkits + /// that Ghostty uses. + /// + /// Some additional notes for triggers: + /// + /// * modifiers cannot repeat, `ctrl+ctrl+a` is invalid. + /// + /// * modifiers and keys can be in any order, `shift+a+ctrl` is *weird*, + /// but valid. + /// + /// * only a single key input is allowed, `ctrl+a+b` is invalid. + /// + /// You may also specify multiple triggers separated by `>` to require a + /// sequence of triggers to activate the action. For example, + /// `ctrl+a>n=new_window` will only trigger the `new_window` action if the + /// user presses `ctrl+a` followed separately by `n`. In other software, this + /// is sometimes called a leader key, a key chord, a key table, etc. There + /// is no hardcoded limit on the number of parts in a sequence. + /// + /// Warning: If you define a sequence as a CLI argument to `ghostty`, + /// you probably have to quote the keybind since `>` is a special character + /// in most shells. Example: ghostty --keybind='ctrl+a>n=new_window' + /// + /// A trigger sequence has some special handling: + /// + /// * Ghostty will wait an indefinite amount of time for the next key in + /// the sequence. There is no way to specify a timeout. The only way to + /// force the output of a prefix key is to assign another keybind to + /// specifically output that key (e.g. `ctrl+a>ctrl+a=text:foo`) or + /// press an unbound key which will send both keys to the program. + /// + /// * If an unbound key is pressed during a sequence and a `catch_all` + /// binding exists that would `ignore` the input, the entire sequence + /// is dropped and nothing happens. Otherwise, the entire sequence is + /// encoded and sent to the running program as if no keybind existed. + /// + /// * If a prefix in a sequence is previously bound, the sequence will + /// override the previous binding. For example, if `ctrl+a` is bound to + /// `new_window` and `ctrl+a>n` is bound to `new_tab`, pressing `ctrl+a` + /// will do nothing. + /// + /// * Adding to the above, if a previously bound sequence prefix is + /// used in a new, non-sequence binding, the entire previously bound + /// sequence will be unbound. For example, if you bind `ctrl+a>n` and + /// `ctrl+a>t`, and then bind `ctrl+a` directly, both `ctrl+a>n` and + /// `ctrl+a>t` will become unbound. + /// + /// * Trigger sequences are not allowed for `global:` or `all:`-prefixed + /// triggers. This is a limitation we could remove in the future. + /// + /// Action is the action to take when the trigger is satisfied. It takes the + /// format `action` or `action:param`. The latter form is only valid if the + /// action requires a parameter. + /// + /// * `ignore` - Do nothing, ignore the key input. This can be used to + /// black hole certain inputs to have no effect. + /// + /// * `unbind` - Remove the binding. This makes it so the previous action + /// is removed, and the key will be sent through to the child command + /// if it is printable. Unbind will remove any matching trigger, + /// including `physical:`-prefixed triggers without specifying the + /// prefix. + /// + /// * `csi:text` - Send a CSI sequence. e.g. `csi:A` sends "cursor up". + /// + /// * `esc:text` - Send an escape sequence. e.g. `esc:d` deletes to the + /// end of the word to the right. + /// + /// * `text:text` - Send a string. Uses Zig string literal syntax. + /// e.g. `text:\x15` sends Ctrl-U. + /// + /// * All other actions can be found in the documentation or by using the + /// `ghostty +list-actions` command. + /// + /// Some notes for the action: + /// + /// * The parameter is taken as-is after the `:`. Double quotes or + /// other mechanisms are included and NOT parsed. If you want to + /// send a string value that includes spaces, wrap the entire + /// trigger/action in double quotes. Example: `--keybind="up=csi:A B"` + /// + /// There are some additional special values that can be specified for + /// keybind: + /// + /// * `keybind=clear` will clear all set keybindings. Warning: this + /// removes ALL keybindings up to this point, including the default + /// keybindings. + /// + /// The keybind trigger can be prefixed with some special values to change + /// the behavior of the keybind. These are: + /// + /// * `all:` + /// + /// Make the keybind apply to all terminal surfaces. By default, + /// keybinds only apply to the focused terminal surface. If this is true, + /// then the keybind will be sent to all terminal surfaces. This only + /// applies to actions that are surface-specific. For actions that + /// are already global (e.g. `quit`), this prefix has no effect. + /// + /// Available since: 1.0.0 + /// + /// * `global:` + /// + /// Make the keybind global. By default, keybinds only work within Ghostty + /// and under the right conditions (application focused, sometimes terminal + /// focused, etc.). If you want a keybind to work globally across your system + /// (e.g. even when Ghostty is not focused), specify this prefix. + /// This prefix implies `all:`. + /// + /// Note: this does not work in all environments; see the additional notes + /// below for more information. + /// + /// Available since: 1.0.0 on macOS, 1.2.0 on GTK + /// + /// * `unconsumed:` + /// + /// Do not consume the input. By default, a keybind will consume the input, + /// meaning that the associated encoding (if any) will not be sent to the + /// running program in the terminal. If you wish to send the encoded value + /// to the program, specify the `unconsumed:` prefix before the entire + /// keybind. For example: `unconsumed:ctrl+a=reload_config`. `global:` and + /// `all:`-prefixed keybinds will always consume the input regardless of + /// this setting. Since they are not associated with a specific terminal + /// surface, they're never encoded. + /// + /// Available since: 1.0.0 + /// + /// * `performable:` + /// + /// Only consume the input if the action is able to be performed. + /// For example, the `copy_to_clipboard` action will only consume the input + /// if there is a selection to copy. If there is no selection, Ghostty + /// behaves as if the keybind was not set. This has no effect with `global:` + /// or `all:`-prefixed keybinds. For key sequences, this will reset the + /// sequence if the action is not performable (acting identically to not + /// having a keybind set at all). + /// + /// Performable keybinds will not appear as menu shortcuts in the + /// application menu. This is because the menu shortcuts force the + /// action to be performed regardless of the state of the terminal. + /// Performable keybinds will still work, they just won't appear as + /// a shortcut label in the menu. + /// + /// Available since: 1.1.0 + /// + /// Keybind triggers are not unique per prefix combination. For example, + /// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind + /// set later will overwrite the keybind set earlier. In this case, the + /// `global:` keybind will be used. + /// + /// Multiple prefixes can be specified. For example, + /// `global:unconsumed:ctrl+a=reload_config` will make the keybind global + /// and not consume the input to reload the config. + /// + /// Note: `global:` is only supported on macOS and certain Linux platforms. + /// + /// On macOS, this feature requires accessibility permissions to be granted + /// to Ghostty. When a `global:` keybind is specified and Ghostty is launched + /// or reloaded, Ghostty will attempt to request these permissions. + /// If the permissions are not granted, the keybind will not work. On macOS, + /// you can find these permissions in System Preferences -> Privacy & Security + /// -> Accessibility. + /// + /// On Linux, you need a desktop environment that implements the + /// [Global Shortcuts](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html) + /// protocol as a part of its XDG desktop protocol implementation. + /// Desktop environments that are known to support (or not support) + /// global shortcuts include: + /// + /// - Users using KDE Plasma (since [5.27](https://kde.org/announcements/plasma/5/5.27.0/#wayland)) + /// and GNOME (since [48](https://release.gnome.org/48/#and-thats-not-all)) should be able + /// to use global shortcuts with little to no configuration. + /// + /// - Some manual configuration is required on Hyprland. Consult the steps + /// outlined on the [Hyprland Wiki](https://wiki.hyprland.org/Configuring/Binds/#dbus-global-shortcuts) + /// to set up global shortcuts correctly. + /// (Important: [`xdg-desktop-portal-hyprland`](https://wiki.hyprland.org/Hypr-Ecosystem/xdg-desktop-portal-hyprland/) + /// must also be installed!) + /// + /// - Notably, global shortcuts have not been implemented on wlroots-based + /// compositors like Sway (see [upstream issue](https://github.com/emersion/xdg-desktop-portal-wlr/issues/240)). + /// + /// ## Chained Actions + /// + /// A keybind can have multiple actions by using the `chain` keyword for + /// subsequent actions. When a keybind is activated, all chained actions are + /// executed in order. The syntax is: + /// + /// ```ini + /// keybind = ctrl+a=new_window + /// keybind = chain=goto_split:left + /// ``` + /// + /// This binds `ctrl+a` to first open a new window, then move focus to the + /// left split. Each `chain` entry appends an action to the most recently + /// defined keybind. You can chain as many actions as you want: + /// + /// ```ini + /// keybind = ctrl+a=new_window + /// keybind = chain=goto_split:left + /// keybind = chain=toggle_fullscreen + /// ``` + /// + /// Chained actions cannot have prefixes like `global:` or `unconsumed:`. + /// The flags from the original keybind apply to the entire chain. + /// + /// Chained actions work with key sequences as well. For example: + /// + /// ```ini + /// keybind = ctrl+a>n=new_window + /// keybind = chain=goto_split:left + /// ```` + /// + /// Chains with key sequences apply to the most recent binding in the + /// sequence. + /// + /// Chained keybinds are available since Ghostty 1.3.0. + /// + /// ## Key Tables + /// + /// You may also create a named set of keybindings known as a "key table." + /// A key table must be explicitly activated for the bindings to become + /// available. This can be used to implement features such as a + /// "copy mode", "vim mode", etc. Generically, this can implement modal + /// keyboard input. + /// + /// Key tables are defined using the syntax `/`. The + /// `` value is everything documented above for keybinds. The + /// `
` value is the name of the key table. Table names can contain + /// anything except `/`, `=`, `+`, and `>`. The characters `+` and `>` are + /// reserved for keybind syntax (modifier combinations and key sequences). + /// For example `foo/ctrl+a=new_window` defines a binding within a table + /// named `foo`. + /// + /// Tables are activated and deactivated using the binding actions + /// `activate_key_table:` and `deactivate_key_table`. Other table + /// related binding actions also exist; see the documentation for a full list. + /// These are the primary way to interact with key tables. + /// + /// Binding lookup proceeds from the innermost table outward, so keybinds in + /// the default table remain available unless explicitly unbound in an inner + /// table. + /// + /// A key table has some special syntax and handling: + /// + /// * `/` (with no binding) defines and clears a table, resetting all + /// of its keybinds and settings. + /// + /// * You cannot activate a table that is already the innermost table; such + /// attempts are ignored. However, the same table can appear multiple times + /// in the stack as long as it is not innermost (e.g., `A -> B -> A -> B` + /// is valid, but `A -> B -> B` is not). + /// + /// * A table can be activated in one-shot mode using + /// `activate_key_table_once:`. A one-shot table is automatically + /// deactivated when any non-catch-all binding is invoked. + /// + /// * Key sequences work within tables: `foo/ctrl+a>ctrl+b=new_window`. + /// If an invalid key is pressed, the sequence ends but the table remains + /// active. + /// + /// * Chain actions work within tables, the `chain` keyword applies to + /// the most recently defined binding in the table. e.g. if you set + /// `table/ctrl+a=new_window` you can chain by using `chain=text:hello`. + /// Important: chain itself doesn't get prefixed with the table name, + /// since it applies to the most recent binding in any table. + /// + /// * Prefixes like `global:` work within tables: + /// `foo/global:ctrl+a=new_window`. + /// + /// Key tables are available since Ghostty 1.3.0. + "keybind" => keybind: Keybinds = Keybinds::default(), + + /// Remap modifier keys within Ghostty. This allows you to swap or reassign + /// modifier keys at the application level without affecting system-wide + /// settings. + /// + /// The format is `from=to` where both `from` and `to` are modifier key names. + /// You can use generic names like `ctrl`, `alt`, `shift`, `super` (macOS: + /// `cmd`/`command`) or sided names like `left_ctrl`, `right_alt`, etc. + /// + /// This will NOT change keyboard layout or key encodings outside of Ghostty. + /// For example, on macOS, `option+a` may still produce `å` even if `option` is + /// remapped to `ctrl`. Desktop environments usually handle key layout long + /// before Ghostty receives the key events. + /// + /// Example: + /// + /// key-remap = ctrl=super + /// key-remap = left_control=right_alt + /// + /// Important notes: + /// + /// * This is a one-way remap. If you remap `ctrl=super`, then the physical + /// Ctrl key acts as Super, but the Super key remains Super. + /// + /// * Remaps are not transitive. If you remap `ctrl=super` and `alt=ctrl`, + /// pressing Alt will produce Ctrl, NOT Super. + /// + /// * This affects both keybind matching and terminal input encoding. + /// This does NOT impact keyboard layout or how keys are interpreted + /// prior to Ghostty receiving them. For example, `option+a` on macOS + /// may still produce `å` even if `option` is remapped to `ctrl`. + /// + /// * Generic modifiers (e.g. `ctrl`) match both left and right physical keys. + /// Use sided names (e.g. `left_ctrl`) to remap only one side. + /// + /// There are other edge case scenarios that may not behave as expected + /// but are working as intended the way this feature is designed: + /// + /// * On macOS, bindings in the main menu will trigger before any remapping + /// is done. This is because macOS itself handles menu activation and + /// this happens before Ghostty receives the key event. To workaround + /// this, you should unbind the menu items and rebind them using your + /// desired modifier. + /// + /// This configuration can be repeated to specify multiple remaps. + "key-remap" => key_remap: ghostty_input::KeyRemapSet = ghostty_input::KeyRemapSet::default(), + + /// Horizontal window padding. This applies padding between the terminal cells + /// and the left and right window borders. The value is in points, meaning that + /// it will be scaled appropriately for screen DPI. + /// + /// If this value is set too large, the screen will render nothing, because the + /// grid will be completely squished by the padding. It is up to you as the user + /// to pick a reasonable value. If you pick an unreasonable value, a warning + /// will appear in the logs. + /// + /// Changing this configuration at runtime will only affect new terminals, i.e. + /// new windows, tabs, etc. + /// + /// To set a different left and right padding, specify two numerical values + /// separated by a comma. For example, `window-padding-x = 2,4` will set the + /// left padding to 2 and the right padding to 4. If you want to set both + /// paddings to the same value, you can use a single value. For example, + /// `window-padding-x = 2` will set both paddings to 2. + "window-padding-x" => window_padding_x: WindowPadding = WindowPadding { top_left: 2, bottom_right: 2 }, + + /// Vertical window padding. This applies padding between the terminal cells and + /// the top and bottom window borders. The value is in points, meaning that it + /// will be scaled appropriately for screen DPI. + /// + /// If this value is set too large, the screen will render nothing, because the + /// grid will be completely squished by the padding. It is up to you as the user + /// to pick a reasonable value. If you pick an unreasonable value, a warning + /// will appear in the logs. + /// + /// Changing this configuration at runtime will only affect new terminals, + /// i.e. new windows, tabs, etc. + /// + /// To set a different top and bottom padding, specify two numerical values + /// separated by a comma. For example, `window-padding-y = 2,4` will set the + /// top padding to 2 and the bottom padding to 4. If you want to set both + /// paddings to the same value, you can use a single value. For example, + /// `window-padding-y = 2` will set both paddings to 2. + "window-padding-y" => window_padding_y: WindowPadding = WindowPadding { top_left: 2, bottom_right: 2 }, + + /// The viewport dimensions are usually not perfectly divisible by the cell + /// size. In this case, some extra padding on the end of a column and the bottom + /// of the final row may exist. If this is `true`, then this extra padding + /// is automatically balanced between all four edges to minimize imbalance on + /// one side. If this is `false`, the top left grid cell will always hug the + /// edge with zero padding other than what may be specified with the other + /// `window-padding` options. + /// + /// If other `window-padding` fields are set and this is `true`, this will still + /// apply. The other padding is applied first and may affect how many grid cells + /// actually exist, and this is applied last in order to balance the padding + /// given a certain viewport size and grid cell size. + "window-padding-balance" => window_padding_balance: bool = false, + + /// The color of the padding area of the window. Valid values are: + /// + /// * `background` - The background color specified in `background`. + /// * `extend` - Extend the background color of the nearest grid cell. + /// * `extend-always` - Same as "extend" but always extends without applying + /// any of the heuristics that disable extending noted below. + /// + /// The "extend" value will be disabled in certain scenarios. On primary + /// screen applications (e.g. not something like Neovim), the color will not + /// be extended vertically if any of the following are true: + /// + /// * The nearest row has any cells that have the default background color. + /// The thinking is that in this case, the default background color looks + /// fine as a padding color. + /// * The nearest row is a prompt row (requires shell integration). The + /// thinking here is that prompts often contain powerline glyphs that + /// do not look good extended. + /// * The nearest row contains a perfect fit powerline character. These + /// don't look good extended. + "window-padding-color" => window_padding_color: WindowPaddingColor = WindowPaddingColor::Background, + + /// Synchronize rendering with the screen refresh rate. If true, this will + /// minimize tearing and align redraws with the screen but may cause input + /// latency. If false, this will maximize redraw frequency but may cause tearing, + /// and under heavy load may use more CPU and power. + /// + /// This defaults to true because out-of-sync rendering on macOS can + /// cause kernel panics (macOS 14.4+) and performance issues for external + /// displays over some hardware such as DisplayLink. If you want to minimize + /// input latency, set this to false with the known aforementioned risks. + /// + /// Changing this value at runtime will only affect new terminals. + /// + /// This setting is only supported currently on macOS. + "window-vsync" => window_vsync: bool = true, + + /// If true, new windows will inherit the working directory of the + /// previously focused window. If no window was previously focused, the default + /// working directory will be used (the `working-directory` option). + "window-inherit-working-directory" => window_inherit_working_directory: bool = true, + + /// If true, new tabs will inherit the working directory of the + /// previously focused tab. If no tab was previously focused, the default + /// working directory will be used (the `working-directory` option). + "tab-inherit-working-directory" => tab_inherit_working_directory: bool = true, + + /// If true, new split panes will inherit the working directory of the + /// previously focused split. If no split was previously focused, the default + /// working directory will be used (the `working-directory` option). + "split-inherit-working-directory" => split_inherit_working_directory: bool = true, + + /// If true, new windows and tabs will inherit the font size of the previously + /// focused window. If no window was previously focused, the default font size + /// will be used. If this is false, the default font size specified in the + /// configuration `font-size` will be used. + "window-inherit-font-size" => window_inherit_font_size: bool = true, + + /// Configure a preference for window decorations. This setting specifies + /// a _preference_; the actual OS, desktop environment, window manager, etc. + /// may override this preference. Ghostty will do its best to respect this + /// preference but it may not always be possible. + /// + /// Valid values: + /// + /// * `none` + /// + /// All window decorations will be disabled. Titlebar, borders, etc. will + /// not be shown. On macOS, this will also disable tabs (enforced by the + /// system). + /// + /// * `auto` + /// + /// Automatically decide to use either client-side or server-side + /// decorations based on the detected preferences of the current OS and + /// desktop environment. This option usually makes Ghostty look the most + /// "native" for your desktop. + /// + /// * `client` + /// + /// Prefer client-side decorations. + /// + /// Available since: 1.1.0 + /// + /// * `server` + /// + /// Prefer server-side decorations. This is only relevant on Linux with GTK, + /// either on X11, or Wayland on a compositor that supports the + /// `org_kde_kwin_server_decoration` protocol (e.g. KDE Plasma, but almost + /// any non-GNOME desktop supports this protocol). + /// + /// If `server` is set but the environment doesn't support server-side + /// decorations, client-side decorations will be used instead. + /// + /// Available since: 1.1.0 + /// + /// The default value is `auto`. + /// + /// For the sake of backwards compatibility and convenience, this setting also + /// accepts boolean true and false values. If set to `true`, this is equivalent + /// to `auto`. If set to `false`, this is equivalent to `none`. + /// This is convenient for users who live primarily on systems that don't + /// differentiate between client and server-side decorations (e.g. macOS and + /// Windows). + /// + /// The "toggle_window_decorations" keybind action can be used to create + /// a keybinding to toggle this setting at runtime. + /// + /// macOS: To hide the titlebar without removing the native window borders + /// or rounded corners, use `macos-titlebar-style = hidden` instead. + "window-decoration" => window_decoration: WindowDecoration = WindowDecoration::Auto, + + /// The font that will be used for the application's window and tab titles. + /// + /// If this setting is left unset, the system default font will be used. + /// + /// Note: any font available on the system may be used, this font is not + /// required to be a fixed-width font. + /// + /// Available since: 1.0.0 on macOS, 1.1.0 on GTK + "window-title-font-family" => window_title_font_family: Option = None, + + /// The text that will be displayed in the subtitle of the window. Valid values: + /// + /// * `false` - Disable the subtitle. + /// * `working-directory` - Set the subtitle to the working directory of the + /// surface. + /// + /// This feature is only supported on GTK. + /// + /// Available since: 1.1.0 + "window-subtitle" => window_subtitle: WindowSubtitle = WindowSubtitle::False, + + /// The theme to use for the windows. Valid values: + /// + /// * `auto` - Determine the theme based on the configured terminal + /// background color. This has no effect if the "theme" configuration + /// has separate light and dark themes. In that case, the behavior + /// of "auto" is equivalent to "system". + /// * `system` - Use the system theme. + /// * `light` - Use the light theme regardless of system theme. + /// * `dark` - Use the dark theme regardless of system theme. + /// * `ghostty` - Use the background and foreground colors specified in the + /// Ghostty configuration. This is only supported on Linux builds. + /// + /// On macOS, if `macos-titlebar-style` is `tabs` or `transparent`, the window theme will be + /// automatically set based on the luminosity of the terminal background color. + /// This only applies to terminal windows. This setting will still apply to + /// non-terminal windows within Ghostty. + /// + /// This is currently only supported on macOS and Linux. + "window-theme" => window_theme: WindowTheme = WindowTheme::Auto, + + /// The color space to use when interpreting terminal colors. "Terminal colors" + /// refers to colors specified in your configuration and colors produced by + /// direct-color SGR sequences. + /// + /// Valid values: + /// + /// * `srgb` - Interpret colors in the sRGB color space. This is the default. + /// * `display-p3` - Interpret colors in the Display P3 color space. + /// + /// This setting is currently only supported on macOS. + "window-colorspace" => window_colorspace: WindowColorspace = WindowColorspace::Srgb, + + /// The initial window size. This size is in terminal grid cells by default. + /// Both values must be set to take effect. If only one value is set, it is + /// ignored. + /// + /// We don't currently support specifying a size in pixels but a future change + /// can enable that. If this isn't specified, the app runtime will determine + /// some default size. + /// + /// Note that the window manager may put limits on the size or override the + /// size. For example, a tiling window manager may force the window to be a + /// certain size to fit within the grid. There is nothing Ghostty will do about + /// this, but it will make an effort. + /// + /// Sizes larger than the screen size will be clamped to the screen size. + /// This can be used to create a maximized-by-default window size. + /// + /// This will not affect new tabs, splits, or other nested terminal elements. + /// This only affects the initial window size of any new window. Changing this + /// value will not affect the size of the window after it has been created. This + /// is only used for the initial size. + /// + /// BUG: On Linux with GTK, the calculated window size will not properly take + /// into account window decorations. As a result, the grid dimensions will not + /// exactly match this configuration. If window decorations are disabled (see + /// `window-decoration`), then this will work as expected. + /// + /// Windows smaller than 10 wide by 4 high are not allowed. + "window-height" => window_height: u32 = 0, + + "window-width" => window_width: u32 = 0, + + /// The starting window position. This position is in pixels and is relative + /// to the top-left corner of the primary monitor. Both values must be set to take + /// effect. If only one value is set, it is ignored. + /// + /// Note that the window manager may put limits on the position or override + /// the position. For example, a tiling window manager may force the window + /// to be a certain position to fit within the grid. There is nothing Ghostty + /// will do about this, but it will make an effort. + /// + /// Also note that negative values are also up to the operating system and + /// window manager. Some window managers may not allow windows to be placed + /// off-screen. + /// + /// Invalid positions are runtime-specific, but generally the positions are + /// clamped to the nearest valid position. + /// + /// On macOS, the window position is relative to the top-left corner of + /// the visible screen area. This means that if the menu bar is visible, the + /// window will be placed below the menu bar. + /// + /// Note: this is only supported on macOS. The GTK runtime does not support + /// setting the window position, as windows are only allowed position + /// themselves in X11 and not Wayland. + "window-position-x" => window_position_x: Option = None, + + "window-position-y" => window_position_y: Option = None, + + /// Whether to enable saving and restoring window state. Window state includes + /// their position, size, tabs, splits, etc. Some window state requires shell + /// integration, such as preserving working directories. See `shell-integration` + /// for more information. + /// + /// There are three valid values for this configuration: + /// + /// * `default` will use the default system behavior. On macOS, this + /// will only save state if the application is forcibly terminated + /// or if it is configured systemwide via Settings.app. + /// + /// * `never` will never save window state. + /// + /// * `always` will always save window state whenever Ghostty is exited. + /// + /// If you change this value to `never` while Ghostty is not running, the next + /// Ghostty launch will NOT restore the window state. + /// + /// If you change this value to `default` while Ghostty is not running and the + /// previous exit saved state, the next Ghostty launch will still restore the + /// window state. This is because Ghostty cannot know if the previous exit was + /// due to a forced save or not (macOS doesn't provide this information). + /// + /// If you change this value so that window state is saved while Ghostty is not + /// running, the previous window state will not be restored because Ghostty only + /// saves state on exit if this is enabled. + /// + /// The default value is `default`. + /// + /// This is currently only supported on macOS. This has no effect on Linux. + "window-save-state" => window_save_state: WindowSaveState = WindowSaveState::Default, + + /// Resize the window in discrete increments of the focused surface's cell size. + /// If this is disabled, surfaces are resized in pixel increments. Currently + /// only supported on macOS. + "window-step-resize" => window_step_resize: bool = false, + + /// The position where new tabs are created. Valid values: + /// + /// * `current` - Insert the new tab after the currently focused tab, + /// or at the end if there are no focused tabs. + /// + /// * `end` - Insert the new tab at the end of the tab list. + "window-new-tab-position" => window_new_tab_position: WindowNewTabPosition = WindowNewTabPosition::Current, + + /// Whether to show the tab bar. + /// + /// Valid values: + /// + /// - `always` + /// + /// Always display the tab bar, even when there's only one tab. + /// + /// Available since: 1.2.0 + /// + /// - `auto` *(default)* + /// + /// Automatically show and hide the tab bar. The tab bar is only + /// shown when there are two or more tabs present. + /// + /// - `never` + /// + /// Never show the tab bar. Tabs are only accessible via the tab + /// overview or by keybind actions. + /// + /// Currently only supported on Linux (GTK). + "window-show-tab-bar" => window_show_tab_bar: WindowShowTabBar = WindowShowTabBar::Auto, + + /// Background color for the window titlebar. This only takes effect if + /// window-theme is set to ghostty. Currently only supported in the GTK app + /// runtime. + /// + /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. + "window-titlebar-background" => window_titlebar_background: Option = None, + + /// Foreground color for the window titlebar. This only takes effect if + /// window-theme is set to ghostty. Currently only supported in the GTK app + /// runtime. + /// + /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. + "window-titlebar-foreground" => window_titlebar_foreground: Option = None, + + /// This controls when resize overlays are shown. Resize overlays are a + /// transient popup that shows the size of the terminal while the surfaces are + /// being resized. The possible options are: + /// + /// * `always` - Always show resize overlays. + /// * `never` - Never show resize overlays. + /// * `after-first` - The resize overlay will not appear when the surface + /// is first created, but will show up if the surface is + /// subsequently resized. + /// + /// The default is `after-first`. + "resize-overlay" => resize_overlay: ResizeOverlay = ResizeOverlay::AfterFirst, + + /// If resize overlays are enabled, this controls the position of the overlay. + /// The possible options are: + /// + /// * `center` + /// * `top-left` + /// * `top-center` + /// * `top-right` + /// * `bottom-left` + /// * `bottom-center` + /// * `bottom-right` + /// + /// The default is `center`. + "resize-overlay-position" => resize_overlay_position: ResizeOverlayPosition = ResizeOverlayPosition::Center, + + /// If resize overlays are enabled, this controls how long the overlay is + /// visible on the screen before it is hidden. The default is ¾ of a second or + /// 750 ms. + /// + /// The duration is specified as a series of numbers followed by time units. + /// Whitespace is allowed between numbers and units. Each number and unit will + /// be added together to form the total duration. + /// + /// The allowed time units are as follows: + /// + /// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments + /// are made for leap years or leap seconds. + /// * `d` - one SI day, or 86400 seconds. + /// * `h` - one hour, or 3600 seconds. + /// * `m` - one minute, or 60 seconds. + /// * `s` - one second. + /// * `ms` - one millisecond, or 0.001 second. + /// * `us` or `µs` - one microsecond, or 0.000001 second. + /// * `ns` - one nanosecond, or 0.000000001 second. + /// + /// Examples: + /// * `1h30m` + /// * `45s` + /// + /// Units can be repeated and will be added together. This means that + /// `1h1h` is equivalent to `2h`. This is confusing and should be avoided. + /// A future update may disallow this. + /// + /// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. Any + /// value larger than this will be clamped to the maximum value. + /// + /// Available since 1.0.0 + "resize-overlay-duration" => resize_overlay_duration: Duration = Duration { duration: 750 * NS_PER_MS }, + + /// If true, when there are multiple split panes, the mouse selects the pane + /// that is focused. This only applies to the currently focused window; e.g. + /// mousing over a split in an unfocused window will not focus that split + /// and bring the window to front. + /// + /// Default is false. + "focus-follows-mouse" => focus_follows_mouse: bool = false, + + /// Whether to allow programs running in the terminal to read/write to the + /// system clipboard (OSC 52, for googling). The default is to allow clipboard + /// reading after prompting the user and allow writing unconditionally. + /// + /// Valid values are: + /// + /// * `ask` + /// * `allow` + /// * `deny` + /// + "clipboard-read" => clipboard_read: ClipboardAccess = ClipboardAccess::Ask, + + "clipboard-write" => clipboard_write: ClipboardAccess = ClipboardAccess::Allow, + + /// Trims trailing whitespace on data that is copied to the clipboard. This does + /// not affect data sent to the clipboard via `clipboard-write`. This only + /// applies to trailing whitespace on lines that have other characters. + /// Completely blank lines always have their whitespace trimmed. + "clipboard-trim-trailing-spaces" => clipboard_trim_trailing_spaces: bool = true, + + /// Require confirmation before pasting text that appears unsafe. This helps + /// prevent a "copy/paste attack" where a user may accidentally execute unsafe + /// commands by pasting text with newlines. + "clipboard-paste-protection" => clipboard_paste_protection: bool = true, + + /// If true, bracketed pastes will be considered safe. By default, bracketed + /// pastes are considered safe. "Bracketed" pastes are pastes while the running + /// program has bracketed paste mode enabled (a setting set by the running + /// program, not the terminal emulator). + "clipboard-paste-bracketed-safe" => clipboard_paste_bracketed_safe: bool = true, + + /// Enables or disabled title reporting (CSI 21 t). This escape sequence + /// allows the running program to query the terminal title. This is a common + /// security issue and is disabled by default. + /// + /// Warning: This can expose sensitive information at best and enable + /// arbitrary code execution at worst (with a maliciously crafted title + /// and a minor amount of user interaction). + /// + /// Available since: 1.0.1 + "title-report" => title_report: bool = false, + + /// The total amount of bytes that can be used for image data (e.g. the Kitty + /// image protocol) per terminal screen. The maximum value is 4,294,967,295 + /// (4GiB). The default is 320MB. If this is set to zero, then all image + /// protocols will be disabled. + /// + /// This value is separate for primary and alternate screens so the effective + /// limit per surface is double. + "image-storage-limit" => image_storage_limit: u32 = 320 * 1000 * 1000, + + /// Whether to automatically copy selected text to the clipboard. `true` + /// will prefer to copy to the selection clipboard, otherwise it will copy to + /// the system clipboard. + /// + /// The value `clipboard` will always copy text to the selection clipboard + /// as well as the system clipboard. + /// + /// Middle-click paste will always use the selection clipboard. Middle-click + /// paste is always enabled even if this is `false`. + /// + /// The default value is true on Linux and macOS. + "copy-on-select" => copy_on_select: CopyOnSelect = CopyOnSelect::True, + + /// The action to take when the user right-clicks on the terminal surface. + /// + /// Valid values: + /// * `context-menu` - Show the context menu. + /// * `paste` - Paste the contents of the clipboard. + /// * `copy` - Copy the selected text to the clipboard. + /// * `copy-or-paste` - If there is a selection, copy the selected text to + /// the clipboard; otherwise, paste the contents of the clipboard. + /// * `ignore` - Do nothing, ignore the right-click. + /// + /// The default value is `context-menu`. + "right-click-action" => right_click_action: RightClickAction = RightClickAction::ContextMenu, + + /// The time in milliseconds between clicks to consider a click a repeat + /// (double, triple, etc.) or an entirely new single click. A value of zero will + /// use a platform-specific default. The default on macOS is determined by the + /// OS settings. On every other platform it is 500ms. + "click-repeat-interval" => click_repeat_interval: u32 = 0, + + /// Additional configuration files to read. This configuration can be repeated + /// to read multiple configuration files. Configuration files themselves can + /// load more configuration files. Paths are relative to the file containing the + /// `config-file` directive. For command-line arguments, paths are relative to + /// the current working directory. + /// + /// Prepend a ? character to the file path to suppress errors if the file does + /// not exist. If you want to include a file that begins with a literal ? + /// character, surround the file path in double quotes ("). + /// + /// Cycles are not allowed. If a cycle is detected, an error will be logged and + /// the configuration file will be ignored. + /// + /// Configuration files are loaded after the configuration they're defined + /// within in the order they're defined. **THIS IS A VERY SUBTLE BUT IMPORTANT + /// POINT.** To put it another way: configuration files do not take effect + /// until after the entire configuration is loaded. For example, in the + /// configuration below: + /// + /// ``` + /// config-file = "foo" + /// a = 1 + /// ``` + /// + /// If "foo" contains `a = 2`, the final value of `a` will be 2, because + /// `foo` is loaded after the configuration file that configures the + /// nested `config-file` value. + "config-file" => config_file: RepeatablePath = RepeatablePath::default(), + + /// When this is true, the default configuration file paths will be loaded. + /// The default configuration file paths are currently only the XDG + /// config path ($XDG_CONFIG_HOME/ghostty/config.ghostty). + /// + /// If this is false, the default configuration paths will not be loaded. + /// This is targeted directly at using Ghostty from the CLI in a way + /// that minimizes external effects. + /// + /// This is a CLI-only configuration. Setting this in a configuration file + /// will have no effect. It is not an error, but it will not do anything. + /// This configuration can only be set via CLI arguments. + "config-default-files" => config_default_files: bool = true, + + /// Confirms that a surface should be closed before closing it. + /// + /// This defaults to `true`. If set to `false`, surfaces will close without + /// any confirmation. This can also be set to `always`, which will always + /// confirm closing a surface, even if shell integration says a process isn't + /// running. + "confirm-close-surface" => confirm_close_surface: ConfirmCloseSurface = ConfirmCloseSurface::True, + + /// Whether or not to quit after the last surface is closed. + /// + /// This defaults to `false` on macOS since that is standard behavior for + /// a macOS application. On Linux, this defaults to `true` since that is + /// generally expected behavior. + /// + /// On Linux, if this is `true`, Ghostty can delay quitting fully until a + /// configurable amount of time has passed after the last window is closed. + /// See the documentation of `quit-after-last-window-closed-delay`. + "quit-after-last-window-closed" => quit_after_last_window_closed: bool = true, + + /// Controls how long Ghostty will stay running after the last open surface has + /// been closed. This only has an effect if `quit-after-last-window-closed` is + /// also set to `true`. + /// + /// The minimum value for this configuration is `1s`. Any values lower than + /// this will be clamped to `1s`. + /// + /// The duration is specified as a series of numbers followed by time units. + /// Whitespace is allowed between numbers and units. Each number and unit will + /// be added together to form the total duration. + /// + /// The allowed time units are as follows: + /// + /// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments + /// are made for leap years or leap seconds. + /// * `d` - one SI day, or 86400 seconds. + /// * `h` - one hour, or 3600 seconds. + /// * `m` - one minute, or 60 seconds. + /// * `s` - one second. + /// * `ms` - one millisecond, or 0.001 second. + /// * `us` or `µs` - one microsecond, or 0.000001 second. + /// * `ns` - one nanosecond, or 0.000000001 second. + /// + /// Examples: + /// * `1h30m` + /// * `45s` + /// + /// Units can be repeated and will be added together. This means that + /// `1h1h` is equivalent to `2h`. This is confusing and should be avoided. + /// A future update may disallow this. + /// + /// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. Any + /// value larger than this will be clamped to the maximum value. + /// + /// By default `quit-after-last-window-closed-delay` is unset and + /// Ghostty will quit immediately after the last window is closed if + /// `quit-after-last-window-closed` is `true`. + /// + /// Only implemented on Linux. + "quit-after-last-window-closed-delay" => quit_after_last_window_closed_delay: Option = None, + + /// This controls whether an initial window is created when Ghostty + /// is run. Note that if `quit-after-last-window-closed` is `true` and + /// `quit-after-last-window-closed-delay` is set, setting `initial-window` to + /// `false` will mean that Ghostty will quit after the configured delay if no + /// window is ever created. Only implemented on Linux and macOS. + "initial-window" => initial_window: bool = true, + + /// The duration that undo operations remain available. After this + /// time, the operation will be removed from the undo stack and + /// cannot be undone. + /// + /// The default value is 5 seconds. + /// + /// This timeout applies per operation, meaning that if you perform + /// multiple operations, each operation will have its own timeout. + /// New operations do not reset the timeout of previous operations. + /// + /// A timeout of zero will effectively disable undo operations. It is + /// not possible to set an infinite timeout, but you can set a very + /// large timeout to effectively disable the timeout (on the order of years). + /// This is highly discouraged, as it will cause the undo stack to grow + /// indefinitely, memory usage to grow unbounded, and terminal sessions + /// to never actually quit. + /// + /// The duration is specified as a series of numbers followed by time units. + /// Whitespace is allowed between numbers and units. Each number and unit will + /// be added together to form the total duration. + /// + /// The allowed time units are as follows: + /// + /// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments + /// are made for leap years or leap seconds. + /// * `d` - one SI day, or 86400 seconds. + /// * `h` - one hour, or 3600 seconds. + /// * `m` - one minute, or 60 seconds. + /// * `s` - one second. + /// * `ms` - one millisecond, or 0.001 second. + /// * `us` or `µs` - one microsecond, or 0.000001 second. + /// * `ns` - one nanosecond, or 0.000000001 second. + /// + /// Examples: + /// * `1h30m` + /// * `45s` + /// + /// Units can be repeated and will be added together. This means that + /// `1h1h` is equivalent to `2h`. This is confusing and should be avoided. + /// A future update may disallow this. + /// + /// This configuration is only supported on macOS. Linux doesn't + /// support undo operations at all so this configuration has no + /// effect. + /// + /// Available since: 1.2.0 + "undo-timeout" => undo_timeout: Duration = Duration { duration: 5 * NS_PER_S }, + + /// The position of the "quick" terminal window. To learn more about the + /// quick terminal, see the documentation for the `toggle_quick_terminal` + /// binding action. + /// + /// Valid values are: + /// + /// * `top` - Terminal appears at the top of the screen. + /// * `bottom` - Terminal appears at the bottom of the screen. + /// * `left` - Terminal appears at the left of the screen. + /// * `right` - Terminal appears at the right of the screen. + /// * `center` - Terminal appears at the center of the screen. + /// + /// On macOS, changing this configuration requires restarting Ghostty + /// completely. + /// + /// Note: There is no default keybind for toggling the quick terminal. + /// To enable this feature, bind the `toggle_quick_terminal` action to a key. + "quick-terminal-position" => quick_terminal_position: QuickTerminalPosition = QuickTerminalPosition::Top, + + /// The size of the quick terminal. + /// + /// The size can be specified either as a percentage of the screen dimensions + /// (height/width), or as an absolute size in pixels. Percentage values are + /// suffixed with `%` (e.g. `20%`) while pixel values are suffixed with `px` + /// (e.g. `300px`). A bare value without a suffix is a config error. + /// + /// When only one size is specified, the size parameter affects the size of + /// the quick terminal on its *primary axis*, which depends on its position: + /// height for quick terminals placed on the top or bottom, and width for left + /// or right. The primary axis of a centered quick terminal depends on the + /// monitor's orientation: height when on a landscape monitor, and width when + /// on a portrait monitor. + /// + /// The *secondary axis* would be maximized for non-center positioned + /// quick terminals unless another size parameter is specified, separated + /// from the first by a comma (`,`). Percentage and pixel sizes can be mixed + /// together: for instance, a size of `50%,500px` for a top-positioned quick + /// terminal would be half a screen tall, and 500 pixels wide. + /// + /// Available since: 1.2.0 + "quick-terminal-size" => quick_terminal_size: QuickTerminalSize = QuickTerminalSize::default(), + + /// The layer of the quick terminal window. The higher the layer, + /// the more windows the quick terminal may conceal. + /// + /// Valid values are: + /// + /// * `overlay` + /// + /// The quick terminal appears in front of all windows. + /// + /// * `top` (default) + /// + /// The quick terminal appears in front of normal windows but behind + /// fullscreen overlays like lock screens. + /// + /// * `bottom` + /// + /// The quick terminal appears behind normal windows but in front of + /// wallpapers and other windows in the background layer. + /// + /// * `background` + /// + /// The quick terminal appears behind all windows. + /// + /// GTK Wayland only. + /// + /// Available since: 1.2.0 + "gtk-quick-terminal-layer" => gtk_quick_terminal_layer: QuickTerminalLayer = QuickTerminalLayer::Top, + + /// The namespace for the quick terminal window. + /// + /// This is an identifier that is used by the Wayland compositor and/or + /// scripts to determine the type of layer surfaces and to possibly apply + /// unique effects. + /// + /// GTK Wayland only. + /// + /// Available since: 1.2.0 + "gtk-quick-terminal-namespace" => gtk_quick_terminal_namespace: String = "ghostty-quick-terminal".to_string(), + + /// The screen where the quick terminal should show up. + /// + /// Valid values are: + /// + /// * `main` - The screen that the operating system recommends as the main + /// screen. On macOS, this is the screen that is currently receiving + /// keyboard input. This screen is defined by the operating system and + /// not chosen by Ghostty. + /// + /// * `mouse` - The screen that the mouse is currently hovered over. + /// + /// * `macos-menu-bar` - The screen that contains the macOS menu bar as + /// set in the display settings on macOS. This is a bit confusing because + /// every screen on macOS has a menu bar, but this is the screen that + /// contains the primary menu bar. + /// + /// The default value is `main` because this is the recommended screen + /// by the operating system. + /// + /// Only implemented on macOS. + "quick-terminal-screen" => quick_terminal_screen: QuickTerminalScreen = QuickTerminalScreen::Main, + + /// Duration (in seconds) of the quick terminal enter and exit animation. + /// Set it to 0 to disable animation completely. This can be changed at + /// runtime. + /// + /// Only implemented on macOS. + "quick-terminal-animation-duration" => quick_terminal_animation_duration: f64 = 0.2, + + /// Automatically hide the quick terminal when focus shifts to another window. + /// Set it to false for the quick terminal to remain open even when it loses focus. + /// + /// Defaults to true on macOS and on false on Linux/BSD. This is because global + /// shortcuts on Linux require system configuration and are considerably less + /// accessible than on macOS, meaning that it is more preferable to keep the + /// quick terminal open until the user has completed their task. + /// This default may change in the future. + "quick-terminal-autohide" => quick_terminal_autohide: bool = false, + + /// This configuration option determines the behavior of the quick terminal + /// when switching between macOS spaces. macOS spaces are virtual desktops + /// that can be manually created or are automatically created when an + /// application is in full-screen mode. + /// + /// Valid values are: + /// + /// * `move` - When switching to another space, the quick terminal will + /// also moved to the current space. + /// + /// * `remain` - The quick terminal will stay only in the space where it + /// was originally opened and will not follow when switching to another + /// space. + /// + /// The default value is `move`. + /// + /// Only implemented on macOS. + /// On Linux the behavior is always equivalent to `move`. + /// + /// Available since: 1.1.0 + "quick-terminal-space-behavior" => quick_terminal_space_behavior: QuickTerminalSpaceBehavior = QuickTerminalSpaceBehavior::Move, + + /// Determines under which circumstances that the quick terminal should receive + /// keyboard input. See the corresponding [Wayland documentation](https://wayland.app/protocols/wlr-layer-shell-unstable-v1#zwlr_layer_surface_v1:enum:keyboard_interactivity) + /// for a more detailed explanation of the behavior of each option. + /// + /// > [!NOTE] + /// > The exact behavior of each option may differ significantly across + /// > compositors -- experiment with them on your system to find one that + /// > suits your liking! + /// + /// Valid values are: + /// + /// * `none` + /// + /// The quick terminal will not receive any keyboard input. + /// + /// * `on-demand` (default) + /// + /// The quick terminal would only receive keyboard input when it is focused. + /// + /// * `exclusive` + /// + /// The quick terminal will always receive keyboard input, even when another + /// window is currently focused. + /// + /// Only has an effect on Linux Wayland. + /// On macOS the behavior is always equivalent to `on-demand`. + /// + /// Available since: 1.2.0 + "quick-terminal-keyboard-interactivity" => quick_terminal_keyboard_interactivity: QuickTerminalKeyboardInteractivity = QuickTerminalKeyboardInteractivity::OnDemand, + + /// Whether to enable shell integration auto-injection or not. Shell integration + /// greatly enhances the terminal experience by enabling a number of features: + /// + /// * Working directory reporting so new tabs, splits inherit the + /// previous terminal's working directory. + /// + /// * Prompt marking that enables the "jump_to_prompt" keybinding. + /// + /// * If you're sitting at a prompt, closing a terminal will not ask + /// for confirmation. + /// + /// * Resizing the window with a complex prompt usually paints much + /// better. + /// + /// Allowable values are: + /// + /// * `none` - Do not do any automatic injection. You can still manually + /// configure your shell to enable the integration. + /// + /// * `detect` - Detect the shell based on the filename. + /// + /// * `bash`, `elvish`, `fish`, `nushell`, `zsh` - Use this specific shell injection scheme. + /// + /// The default value is `detect`. + "shell-integration" => shell_integration: ShellIntegration = ShellIntegration::Detect, + + /// Shell integration features to enable. These require our shell integration + /// to be loaded, either automatically via shell-integration or manually. + /// + /// The format of this is a list of features to enable separated by commas. If + /// you prefix a feature with `no-` then it is disabled. If you omit a feature, + /// its default value is used, so you must explicitly disable features you don't + /// want. You can also use `true` or `false` to turn all features on or off. + /// + /// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` + /// + /// Available features: + /// + /// * `cursor` - Set the cursor to a bar at the prompt. + /// + /// * `sudo` - Set sudo wrapper to preserve terminfo. + /// + /// * `title` - Set the window title via shell integration. + /// + /// * `ssh-env` - Enable SSH environment variable compatibility. Automatically + /// converts TERM from `xterm-ghostty` to `xterm-256color` when connecting to + /// remote hosts and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION. + /// Whether or not these variables will be accepted by the remote host(s) will + /// depend on whether or not the variables are allowed in their sshd_config. + /// (Available since: 1.2.0) + /// + /// * `ssh-terminfo` - Enable automatic terminfo installation on remote hosts. + /// Attempts to install Ghostty's terminfo entry using `infocmp` and `tic` when + /// connecting to hosts that lack it. Requires `infocmp` to be available locally + /// and `tic` to be available on remote hosts. Once terminfo is installed on a + /// remote host, it will be automatically "cached" to avoid repeat installations. + /// If desired, the `+ssh-cache` CLI action can be used to manage the installation + /// cache manually using various arguments. + /// (Available since: 1.2.0) + /// + /// * `path` - Add Ghostty's binary directory to PATH. This ensures the `ghostty` + /// command is available in the shell even if shell init scripts reset PATH. + /// This is particularly useful on macOS where PATH is often overridden by + /// system scripts. The directory is only added if not already present. + /// + /// SSH features work independently and can be combined for optimal experience: + /// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its + /// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to + /// `xterm-256color` with environment variables if terminfo installation fails. + "shell-integration-features" => shell_integration_features: ShellIntegrationFeatures = ShellIntegrationFeatures::default(), + + /// Custom entries into the command palette. + /// + /// Each entry requires the title, the corresponding action, and an optional + /// description. Each field should be prefixed with the field name, a colon + /// (`:`), and then the specified value. The syntax for actions is identical + /// to the one for keybind actions. Whitespace in between fields is ignored. + /// + /// If you need to embed commas or any other special characters in the values, + /// enclose the value in double quotes and it will be interpreted as a Zig + /// string literal. This is also useful for including whitespace at the + /// beginning or the end of a value. See the + /// [Zig documentation](https://ziglang.org/documentation/master/#Escape-Sequences) + /// for more information on string literals. Note that multiline string literals + /// are not supported. + /// + /// Double quotes can not be used around the field names. + /// + /// ```ini + /// command-palette-entry = title:Reset Font Style, action:csi:0m + /// command-palette-entry = title:Crash on Main Thread,description:Causes a crash on the main (UI) thread.,action:crash:main + /// command-palette-entry = title:Focus Split: Right,description:"Focus the split to the right, if it exists.",action:goto_split:right + /// command-palette-entry = title:"Ghostty",description:"Add a little Ghostty to your terminal.",action:"text:\xf0\x9f\x91\xbb" + /// ``` + /// + /// By default, the command palette is preloaded with most actions that might + /// be useful in an interactive setting yet do not have easily accessible or + /// memorizable shortcuts. The default entries can be cleared by setting this + /// setting to an empty value: + /// + /// ```ini + /// command-palette-entry = + /// ``` + /// + /// Available since: 1.2.0 + "command-palette-entry" => command_palette_entry: RepeatableCommand = RepeatableCommand::default(), + + /// Sets the reporting format for OSC sequences that request color information. + /// Ghostty currently supports OSC 10 (foreground), OSC 11 (background), and + /// OSC 4 (256 color palette) queries, and by default the reported values + /// are scaled-up RGB values, where each component are 16 bits. This is how + /// most terminals report these values. However, some legacy applications may + /// require 8-bit, unscaled, components. We also support turning off reporting + /// altogether. The components are lowercase hex values. + /// + /// Allowable values are: + /// + /// * `none` - OSC 4/10/11 queries receive no reply + /// + /// * `8-bit` - Color components are return unscaled, e.g. `rr/gg/bb` + /// + /// * `16-bit` - Color components are returned scaled, e.g. `rrrr/gggg/bbbb` + /// + /// The default value is `16-bit`. + "osc-color-report-format" => osc_color_report_format: OSCColorReportFormat = OSCColorReportFormat::Bit16, + + /// If true, allows the "KAM" mode (ANSI mode 2) to be used within + /// the terminal. KAM disables keyboard input at the request of the + /// application. This is not a common feature and is not recommended + /// to be enabled. This will not be documented further because + /// if you know you need KAM, you know. If you don't know if you + /// need KAM, you don't need it. + "vt-kam-allowed" => vt_kam_allowed: bool = false, + + /// Custom shaders to run after the default shaders. This is a file path + /// to a GLSL-syntax shader for all platforms. + /// + /// Warning: Invalid shaders can cause Ghostty to become unusable such as by + /// causing the window to be completely black. If this happens, you can + /// unset this configuration to disable the shader. + /// + /// Custom shader support is based on and compatible with the Shadertoy shaders. + /// Shaders should specify a `mainImage` function and the available uniforms + /// largely match Shadertoy, with some caveats and Ghostty-specific extensions. + /// + /// The uniform values available to shaders are as follows: + /// + /// * `sampler2D iChannel0` - Input texture. + /// + /// A texture containing the current terminal screen. If multiple custom + /// shaders are specified, the output of previous shaders is written to + /// this texture, to allow combining multiple effects. + /// + /// * `vec3 iResolution` - Output texture size, `[width, height, 1]` (in px). + /// + /// * `float iTime` - Time in seconds since first frame was rendered. + /// + /// * `float iTimeDelta` - Time in seconds since previous frame was rendered. + /// + /// * `float iFrameRate` - Average framerate. (NOT CURRENTLY SUPPORTED) + /// + /// * `int iFrame` - Number of frames that have been rendered so far. + /// + /// * `float iChannelTime[4]` - Current time for video or sound input. (N/A) + /// + /// * `vec3 iChannelResolution[4]` - Resolutions of the 4 input samplers. + /// + /// Currently only `iChannel0` exists, and `iChannelResolution[0]` is + /// identical to `iResolution`. + /// + /// * `vec4 iMouse` - Mouse input info. (NOT CURRENTLY SUPPORTED) + /// + /// * `vec4 iDate` - Date/time info. (NOT CURRENTLY SUPPORTED) + /// + /// * `float iSampleRate` - Sample rate for audio. (N/A) + /// + /// Ghostty-specific extensions: + /// + /// * `vec4 iCurrentCursor` - Info about the terminal cursor. + /// + /// - `iCurrentCursor.xy` is the -X, +Y corner of the current cursor. + /// - `iCurrentCursor.zw` is the width and height of the current cursor. + /// + /// * `vec4 iPreviousCursor` - Info about the previous terminal cursor. + /// + /// * `vec4 iCurrentCursorColor` - Color of the terminal cursor. + /// + /// * `vec4 iPreviousCursorColor` - Color of the previous terminal cursor. + /// + /// * `vec4 iCurrentCursorStyle` - Style of the terminal cursor + /// + /// Macros simplified use are defined for the various cursor styles: + /// + /// - `CURSORSTYLE_BLOCK` or `0` + /// - `CURSORSTYLE_BLOCK_HOLLOW` or `1` + /// - `CURSORSTYLE_BAR` or `2` + /// - `CURSORSTYLE_UNDERLINE` or `3` + /// - `CURSORSTYLE_LOCK` or `4` + /// + /// * `vec4 iPreviousCursorStyle` - Style of the previous terminal cursor + /// + /// * `vec4 iCursorVisible` - Visibility of the terminal cursor. + /// + /// * `float iTimeCursorChange` - Timestamp of terminal cursor change. + /// + /// When the terminal cursor changes position or color, this is set to + /// the same time as the `iTime` uniform, allowing you to compute the + /// time since the change by subtracting this from `iTime`. + /// + /// * `float iTimeFocus` - Timestamp when the surface last gained iFocus. + /// + /// When the surface gains focus, this is set to the current value of + /// `iTime`, similar to how `iTimeCursorChange` works. This allows you + /// to compute the time since focus was gained or lost by calculating + /// `iTime - iTimeFocus`. Use this to create animations that restart + /// when the terminal regains focus. + /// + /// * `int iFocus` - Current focus state of the surface. + /// + /// Set to 1.0 when the surface is focused, 0.0 when unfocused. This + /// allows shaders to detect unfocused state and avoid animation artifacts + /// from large time deltas caused by infrequent "deceptive frames" + /// (e.g., modifier key presses, link hover events in unfocused split panes). + /// Check `iFocus > 0` to determine if the surface is currently focused. + /// + /// * `vec3 iPalette[256]` - The 256-color terminal palette. + /// + /// RGB values for all 256 colors in the terminal palette, normalized + /// to [0.0, 1.0]. Index 0-15 are the ANSI colors, 16-231 are the 6x6x6 + /// color cube, and 232-255 are the grayscale colors. + /// + /// * `vec3 iBackgroundColor` - Terminal background color (RGB). + /// + /// * `vec3 iForegroundColor` - Terminal foreground color (RGB). + /// + /// * `vec3 iCursorColor` - Terminal cursor color (RGB). + /// + /// * `vec3 iCursorText` - Terminal cursor text color (RGB). + /// + /// * `vec3 iSelectionBackgroundColor` - Selection background color (RGB). + /// + /// * `vec3 iSelectionForegroundColor` - Selection foreground color (RGB). + /// + /// If the shader fails to compile, the shader will be ignored. Any errors + /// related to shader compilation will not show up as configuration errors + /// and only show up in the log, since shader compilation happens after + /// configuration loading on the dedicated render thread. For interactive + /// development, use [shadertoy.com](https://shadertoy.com). + /// + /// This can be repeated multiple times to load multiple shaders. The shaders + /// will be run in the order they are specified. + /// + /// This can be changed at runtime and will affect all open terminals. + "custom-shader" => custom_shader: RepeatablePath = RepeatablePath::default(), + + /// If `true` (default), the focused terminal surface will run an animation + /// loop when custom shaders are used. This uses slightly more CPU (generally + /// less than 10%) but allows the shader to animate. This only runs if there + /// are custom shaders and the terminal is focused. + /// + /// If this is set to `false`, the terminal and custom shader will only render + /// when the terminal is updated. This is more efficient but the shader will + /// not animate. + /// + /// This can also be set to `always`, which will always run the animation + /// loop regardless of whether the terminal is focused or not. The animation + /// loop will still only run when custom shaders are used. Note that this + /// will use more CPU per terminal surface and can become quite expensive + /// depending on the shader and your terminal usage. + /// + /// This can be changed at runtime and will affect all open terminals. + "custom-shader-animation" => custom_shader_animation: CustomShaderAnimation = CustomShaderAnimation::True, + + /// Bell features to enable if bell support is available in your runtime. Not + /// all features are available on all runtimes. The format of this is a list of + /// features to enable separated by commas. If you prefix a feature with `no-` + /// then it is disabled. If you omit a feature, its default value is used. + /// + /// Valid values are: + /// + /// * `system` + /// + /// Instruct the system to notify the user using built-in system functions. + /// This could result in an audiovisual effect, a notification, or something + /// else entirely. Changing these effects require altering system settings: + /// for instance under the "Sound > Alert Sound" setting in GNOME, + /// or the "Accessibility > System Bell" settings in KDE Plasma. + /// + /// On macOS, this plays the system alert sound. + /// + /// * `audio` + /// + /// Play a custom sound. (Available since 1.3.0 on macOS) + /// + /// * `attention` *(enabled by default)* + /// + /// Request the user's attention when Ghostty is unfocused, until it has + /// received focus again. On macOS, this will bounce the app icon in the + /// dock once. On Linux, the behavior depends on the desktop environment + /// and/or the window manager/compositor: + /// + /// - On KDE, the background of the desktop icon in the task bar would be + /// highlighted; + /// + /// - On GNOME, you may receive a notification that, when clicked, would + /// bring the Ghostty window into focus; + /// + /// - On Sway, the window may be decorated with a distinctly colored border; + /// + /// - On other systems this may have no effect at all. + /// + /// * `title` *(enabled by default)* + /// + /// Prepend a bell emoji (🔔) to the title of the alerted surface until the + /// terminal is re-focused or interacted with (such as on keyboard input). + /// + /// * `border` + /// + /// Display a border around the alerted surface until the terminal is + /// re-focused or interacted with (such as on keyboard input). + /// + /// Available since: 1.2.0 on GTK, 1.2.1 on macOS + /// + /// Example: `audio`, `no-audio`, `system`, `no-system` + /// + /// Available since: 1.2.0 + "bell-features" => bell_features: BellFeatures = BellFeatures::default(), + + /// If `audio` is an enabled bell feature, this is a path to an audio file. If + /// the path is not absolute, it is considered relative to the directory of the + /// configuration file that it is referenced from, or from the current working + /// directory if this is used as a CLI flag. The path may be prefixed with `~/` + /// to reference the user's home directory. + /// + /// Available since: 1.2.0 on GTK, 1.3.0 on macOS. + "bell-audio-path" => bell_audio_path: Option = None, + + /// If `audio` is an enabled bell feature, this is the volume to play the audio + /// file at (relative to the system volume). This is a floating point number + /// ranging from 0.0 (silence) to 1.0 (as loud as possible). The default is 0.5. + /// + /// Available since: 1.2.0 on GTK, 1.3.0 on macOS. + "bell-audio-volume" => bell_audio_volume: f64 = 0.5, + + /// Control the in-app notifications that Ghostty shows. + /// + /// On Linux (GTK), in-app notifications show up as toasts. Toasts appear + /// overlaid on top of the terminal window. They are used to show information + /// that is not critical but may be important. + /// + /// Possible notifications are: + /// + /// - `clipboard-copy` (default: true) - Show a notification when text is copied + /// to the clipboard. + /// - `config-reload` (default: true) - Show a notification when + /// the configuration is reloaded. + /// + /// To specify a notification to enable, specify the name of the notification. + /// To specify a notification to disable, prefix the name with `no-`. For + /// example, to disable `clipboard-copy`, set this configuration to + /// `no-clipboard-copy`. To enable it, set this configuration to `clipboard-copy`. + /// + /// Multiple notifications can be enabled or disabled by separating them + /// with a comma. + /// + /// A value of "false" will disable all notifications. A value of "true" will + /// enable all notifications. + /// + /// This configuration only applies to GTK. + /// + /// Available since: 1.1.0 + "app-notifications" => app_notifications: AppNotifications = AppNotifications::default(), + + /// If anything other than false, fullscreen mode on macOS will not use the + /// native fullscreen, but make the window fullscreen without animations and + /// using a new space. It's faster than the native fullscreen mode since it + /// doesn't use animations. + /// + /// Important: tabs DO NOT WORK in this mode. Non-native fullscreen removes + /// the titlebar and macOS native tabs require the titlebar. If you use tabs, + /// you should not use this mode. + /// + /// If you fullscreen a window with tabs, the currently focused tab will + /// become fullscreen while the others will remain in a separate window in + /// the background. You can switch to that window using normal window-switching + /// keybindings such as command+tilde. When you exit fullscreen, the window + /// will return to the tabbed state it was in before. + /// + /// Allowable values are: + /// + /// * `true` - Use non-native macOS fullscreen, hide the menu bar + /// * `false` - Use native macOS fullscreen + /// * `visible-menu` - Use non-native macOS fullscreen, keep the menu bar + /// visible + /// * `padded-notch` - Use non-native macOS fullscreen, hide the menu bar, + /// but ensure the window is not obscured by the notch on applicable + /// devices. The area around the notch will remain transparent currently, + /// but in the future we may fill it with the window background color. + /// + /// Changing this option at runtime works, but will only apply to the next + /// time the window is made fullscreen. If a window is already fullscreen, + /// it will retain the previous setting until fullscreen is exited. + "macos-non-native-fullscreen" => macos_non_native_fullscreen: NonNativeFullscreen = NonNativeFullscreen::False, + + /// Whether the window buttons in the macOS titlebar are visible. The window + /// buttons are the colored buttons in the upper left corner of most macOS apps, + /// also known as the traffic lights, that allow you to close, miniaturize, and + /// zoom the window. + /// + /// This setting has no effect when `window-decoration = none` or + /// `macos-titlebar-style = hidden`, as the window buttons are always hidden in + /// these modes. + /// + /// Valid values are: + /// + /// * `visible` - Show the window buttons. + /// * `hidden` - Hide the window buttons. + /// + /// The default value is `visible`. + /// + /// Changing this option at runtime only applies to new windows. + /// + /// Available since: 1.2.0 + "macos-window-buttons" => macos_window_buttons: MacWindowButtons = MacWindowButtons::Visible, + + /// The style of the macOS titlebar. Available values are: "native", + /// "transparent", "tabs", and "hidden". + /// + /// The "native" style uses the native macOS titlebar with zero customization. + /// The titlebar will match your window theme (see `window-theme`). + /// + /// The "transparent" style is the same as "native" but the titlebar will + /// be transparent and allow your window background color to come through. + /// This makes a more seamless window appearance but looks a little less + /// typical for a macOS application and may not work well with all themes. + /// + /// The "transparent" style will also update in real-time to dynamic + /// changes to the window background color, e.g. via OSC 11. To make this + /// more aesthetically pleasing, this only happens if the terminal is + /// a window, tab, or split that borders the top of the window. This + /// avoids a disjointed appearance where the titlebar color changes + /// but all the topmost terminals don't match. + /// + /// The "tabs" style is a completely custom titlebar that integrates the + /// tab bar into the titlebar. This titlebar always matches the background + /// color of the terminal. There are some limitations to this style: + /// On macOS 13 and below, saved window state will not restore tabs correctly. + /// macOS 14 does not have this issue and any other macOS version has not + /// been tested. + /// + /// The "hidden" style hides the titlebar. Unlike `window-decoration = none`, + /// however, it does not remove the frame from the window or cause it to have + /// squared corners. Changing to or from this option at run-time may affect + /// existing windows in buggy ways. + /// + /// When "hidden", the top titlebar area can no longer be used for dragging + /// the window. To drag the window, you can use option+click on the resizable + /// areas of the frame to drag the window. This is a standard macOS behavior + /// and not something Ghostty enables. + /// + /// The default value is "transparent". This is an opinionated choice + /// but its one I think is the most aesthetically pleasing and works in + /// most cases. + /// + /// Changing this option at runtime only applies to new windows. + "macos-titlebar-style" => macos_titlebar_style: MacTitlebarStyle = MacTitlebarStyle::Transparent, + + /// Whether the proxy icon in the macOS titlebar is visible. The proxy icon + /// is the icon that represents the folder of the current working directory. + /// You can see this very clearly in the macOS built-in Terminal.app + /// titlebar. + /// + /// The proxy icon is only visible with the native macOS titlebar style. + /// + /// Valid values are: + /// + /// * `visible` - Show the proxy icon. + /// * `hidden` - Hide the proxy icon. + /// + /// The default value is `visible`. + /// + /// This setting can be changed at runtime and will affect all currently + /// open windows but only after their working directory changes again. + /// Therefore, to make this work after changing the setting, you must + /// usually `cd` to a different directory, open a different file in an + /// editor, etc. + "macos-titlebar-proxy-icon" => macos_titlebar_proxy_icon: MacTitlebarProxyIcon = MacTitlebarProxyIcon::Visible, + + /// Controls the windowing behavior when dropping a file or folder + /// onto the Ghostty icon in the macOS dock. + /// + /// Valid values are: + /// + /// * `new-tab` - Create a new tab in the current window, or open + /// a new window if none exist. + /// * `new-window` - Create a new window unconditionally. + /// + /// The default value is `new-tab`. + /// + /// This setting is only supported on macOS and has no effect on other + /// platforms. + "macos-dock-drop-behavior" => macos_dock_drop_behavior: MacOSDockDropBehavior = MacOSDockDropBehavior::NewTab, + + /// macOS doesn't have a distinct "alt" key and instead has the "option" + /// key which behaves slightly differently. On macOS by default, the + /// option key plus a character will sometimes produce a Unicode character. + /// For example, on US standard layouts option-b produces "∫". This may be + /// undesirable if you want to use "option" as an "alt" key for keybindings + /// in terminal programs or shells. + /// + /// This configuration lets you change the behavior so that option is treated + /// as alt. + /// + /// The default behavior (unset) will depend on your active keyboard + /// layout. If your keyboard layout is one of the keyboard layouts listed + /// below, then the default value is "true". Otherwise, the default + /// value is "false". Keyboard layouts with a default value of "true" are: + /// + /// - U.S. Standard + /// - U.S. International + /// + /// Note that if an *Option*-sequence doesn't produce a printable character, it + /// will be treated as *Alt* regardless of this setting. (e.g. `alt+ctrl+a`). + /// + /// Explicit values that can be set: + /// + /// If `true`, the *Option* key will be treated as *Alt*. This makes terminal + /// sequences expecting *Alt* to work properly, but will break Unicode input + /// sequences on macOS if you use them via the *Alt* key. + /// + /// You may set this to `false` to restore the macOS *Alt* key unicode + /// sequences but this will break terminal sequences expecting *Alt* to work. + /// + /// The values `left` or `right` enable this for the left or right *Option* + /// key, respectively. + "macos-option-as-alt" => macos_option_as_alt: Option = None, + + /// Whether to enable the macOS window shadow. The default value is true. + /// With some window managers and window transparency settings, you may + /// find false more visually appealing. + "macos-window-shadow" => macos_window_shadow: bool = true, + + /// If true, the macOS icon in the dock and app switcher will be hidden. This is + /// mainly intended for those primarily using the quick-terminal mode. + /// + /// Note that setting this to true means that keyboard layout changes + /// will no longer be automatic. + /// + /// Control whether macOS app is excluded from the dock and app switcher, + /// a "hidden" state. This is mainly intended for those primarily using + /// quick-terminal mode, but is a general configuration for any use + /// case. + /// + /// Available values: + /// + /// * `never` - The macOS app is never hidden. + /// * `always` - The macOS app is always hidden. + /// + /// Note: When the macOS application is hidden, keyboard layout changes + /// will no longer be automatic. This is a limitation of macOS. + /// + /// Available since: 1.2.0 + "macos-hidden" => macos_hidden: MacHidden = MacHidden::Never, + + /// If true, Ghostty on macOS will automatically enable the "Secure Input" + /// feature when it detects that a password prompt is being displayed. + /// + /// "Secure Input" is a macOS security feature that prevents applications from + /// reading keyboard events. This can always be enabled manually using the + /// `Ghostty > Secure Keyboard Entry` menu item. + /// + /// Note that automatic password prompt detection is based on heuristics + /// and may not always work as expected. Specifically, it does not work + /// over SSH connections, but there may be other cases where it also + /// doesn't work. + /// + /// A reason to disable this feature is if you find that it is interfering + /// with legitimate accessibility software (or software that uses the + /// accessibility APIs), since secure input prevents any application from + /// reading keyboard events. + "macos-auto-secure-input" => macos_auto_secure_input: bool = true, + + /// If true, Ghostty will show a graphical indication when secure input is + /// enabled. This indication is generally recommended to know when secure input + /// is enabled. + /// + /// Normally, secure input is only active when a password prompt is displayed + /// or it is manually (and typically temporarily) enabled. However, if you + /// always have secure input enabled, the indication can be distracting and + /// you may want to disable it. + "macos-secure-input-indication" => macos_secure_input_indication: bool = true, + + /// If true, Ghostty exposes and handles the built-in AppleScript dictionary + /// on macOS. + /// + /// If false, all AppleScript interactions are disabled. This includes + /// AppleScript commands and AppleScript object lookup for windows, tabs, + /// and terminals. + /// + /// The default is true. + "macos-applescript" => macos_applescript: bool = true, + + /// Customize the macOS app icon. + /// + /// This only affects the icon that appears in the dock, application + /// switcher, etc. This does not affect the icon in Finder because + /// that is controlled by a hardcoded value in the signed application + /// bundle and can't be changed at runtime. For more details on what + /// exactly is affected, see the `NSApplication.icon` Apple documentation; + /// that is the API that is being used to set the icon. + /// + /// Valid values: + /// + /// * `official` - Use the official Ghostty icon. + /// * `blueprint`, `chalkboard`, `microchip`, `glass`, `holographic`, + /// `paper`, `retro`, `xray` - Official variants of the Ghostty icon + /// hand-created by artists (no AI). + /// * `custom` - Use a completely custom icon. The location must be specified + /// using the additional `macos-custom-icon` configuration + /// * `custom-style` - Use the official Ghostty icon but with custom + /// styles applied to various layers. The custom styles must be + /// specified using the additional `macos-icon`-prefixed configurations. + /// The `macos-icon-ghost-color` and `macos-icon-screen-color` + /// configurations are required for this style. + /// + /// WARNING: The `custom-style` option is _experimental_. We may change + /// the format of the custom styles in the future. We're still finalizing + /// the exact layers and customization options that will be available. + /// + /// Other caveats: + /// + /// * The icon in the update dialog will always be the official icon. + /// This is because the update dialog is managed through a + /// separate framework and cannot be customized without significant + /// effort. + "macos-icon" => macos_icon: MacAppIcon = MacAppIcon::Official, + + /// The absolute path to the custom icon file. + /// Supported formats include PNG, JPEG, and ICNS. + /// + /// Defaults to `~/.config/ghostty/Ghostty.icns` + "macos-custom-icon" => macos_custom_icon: Option = None, + + /// The material to use for the frame of the macOS app icon. + /// + /// Valid values: + /// + /// * `aluminum` - A brushed aluminum frame. This is the default. + /// * `beige` - A classic 90's computer beige frame. + /// * `plastic` - A glossy, dark plastic frame. + /// * `chrome` - A shiny chrome frame. + /// + /// Note: This configuration is required when `macos-icon` is set to + /// `custom-style`. + "macos-icon-frame" => macos_icon_frame: MacAppIconFrame = MacAppIconFrame::Aluminum, + + /// The color of the ghost in the macOS app icon. + /// + /// Note: This configuration is required when `macos-icon` is set to + /// `custom-style`. + /// + /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. + "macos-icon-ghost-color" => macos_icon_ghost_color: Option = None, + + /// The color of the screen in the macOS app icon. + /// + /// The screen is a linear gradient so you can specify multiple colors + /// that make up the gradient. Up to 64 comma-separated colors may be + /// specified as either hex (`#RRGGBB` or `RRGGBB`) or as named X11 + /// colors. The first color is the bottom of the gradient and the last + /// color is the top of the gradient. + /// + /// Note: This configuration is required when `macos-icon` is set to + /// `custom-style`. + "macos-icon-screen-color" => macos_icon_screen_color: Option = None, + + /// Whether macOS Shortcuts are allowed to control Ghostty. + /// + /// Ghostty exposes a number of actions that allow Shortcuts to + /// control and interact with Ghostty. This includes creating new + /// terminals, sending text to terminals, running commands, invoking + /// any keybind action, etc. + /// + /// This is a powerful feature but can be a security risk if a malicious + /// shortcut is able to be installed and executed. Therefore, this + /// configuration allows you to disable this feature. + /// + /// Valid values are: + /// + /// * `ask` - Ask the user whether for permission. Ghostty will remember + /// this choice and never ask again. This is similar to other macOS + /// permissions such as microphone access, camera access, etc. + /// + /// * `allow` - Allow Shortcuts to control Ghostty without asking. + /// + /// * `deny` - Deny Shortcuts from controlling Ghostty. + /// + /// Available since: 1.2.0 + "macos-shortcuts" => macos_shortcuts: MacShortcuts = MacShortcuts::Ask, + + /// Put every surface (tab, split, window) into a transient `systemd` scope. + /// + /// This allows per-surface resource management. For example, if a shell program + /// is using too much memory, only that shell will be killed by the oom monitor + /// instead of the entire Ghostty process. Similarly, if a shell program is + /// using too much CPU, only that surface will be CPU-throttled. + /// + /// This will cause startup times to be slower (a hundred milliseconds or so), + /// so the default value is "single-instance." In single-instance mode, only + /// one instance of Ghostty is running (see gtk-single-instance) so the startup + /// time is a one-time cost. Additionally, single instance Ghostty is much + /// more likely to have many windows, tabs, etc. so cgroup isolation is a + /// big benefit. + /// + /// This feature requires `systemd`. If `systemd` is unavailable, cgroup + /// initialization will fail. By default, this will not prevent Ghostty from + /// working (see `linux-cgroup-hard-fail`). + /// + /// Changing this value and reloading the config will not affect existing + /// surfaces. + /// + /// Valid values are: + /// + /// * `never` - Never use cgroups. + /// * `always` - Always use cgroups. + /// * `single-instance` - Enable cgroups only for Ghostty instances launched + /// as single-instance applications (see gtk-single-instance). + "linux-cgroup" => linux_cgroup: LinuxCgroup = LinuxCgroup::SingleInstance, + + /// Memory limit for any individual terminal process (tab, split, window, + /// etc.) in bytes. If this is unset then no memory limit will be set. + /// + /// Note that this sets the `MemoryHigh` setting on the transient `systemd` + /// scope, which is a soft limit. You should configure something like + /// `systemd-oom` to handle killing processes that have too much memory + /// pressure. + /// + /// Changing this value and reloading the config will not affect existing + /// surfaces. + /// + /// See the `systemd.resource-control` manual page for more information: + /// https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html + "linux-cgroup-memory-limit" => linux_cgroup_memory_limit: Option = None, + + /// Number of processes limit for any individual terminal process (tab, split, + /// window, etc.). If this is unset then no limit will be set. + /// + /// Note that this sets the `TasksMax` setting on the transient `systemd` scope, + /// which is a hard limit. + /// + /// Changing this value and reloading the config will not affect existing + /// surfaces. + /// + /// See the `systemd.resource-control` manual page for more information: + /// https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html + "linux-cgroup-processes-limit" => linux_cgroup_processes_limit: Option = None, + + /// If this is false, then creating a transient `systemd` scope (for + /// `linux-cgroup`) will be allowed to fail and the failure is ignored. This is + /// useful if you view cgroup isolation as a "nice to have" and not a critical + /// resource management feature, because surface creation will not fail if + /// `systemd` APIs fail. + /// + /// If this is true, then any transient `systemd` scope creation failure will + /// cause surface creation to fail. + /// + /// Changing this value and reloading the config will not affect existing + /// surfaces. + "linux-cgroup-hard-fail" => linux_cgroup_hard_fail: bool = false, + + /// Enable or disable GTK's OpenGL debugging logs. The default is `true` for + /// debug builds, `false` for all others. + /// + /// Available since: 1.1.0 + "gtk-opengl-debug" => gtk_opengl_debug: bool = cfg!(debug_assertions), + + /// If `true`, the Ghostty GTK application will run in single-instance mode: + /// each new `ghostty` process launched will result in a new window if there is + /// already a running process. + /// + /// If `false`, each new ghostty process will launch a separate application. + /// + /// If `detect`, Ghostty will assume true (single instance) unless one of + /// the following scenarios is found: + /// + /// 1. TERM_PROGRAM environment variable is a non-empty value. In this + /// case, we assume Ghostty is being launched from a graphical terminal + /// session and you want a dedicated instance. + /// + /// 2. Any CLI arguments exist. In this case, we assume you are passing + /// custom Ghostty configuration. Single instance mode inherits the + /// configuration from when it was launched, so we must disable single + /// instance to load the new configuration. + /// + /// If either of these scenarios is producing a false positive, you can + /// set this configuration explicitly to the behavior you want. + /// + /// The pre-1.2 option `desktop` has been deprecated. Please replace + /// this with `detect`. + /// + /// The default value is `detect`. + /// + /// Note that debug builds of Ghostty have a separate single-instance ID + /// so you can test single instance without conflicting with release builds. + "gtk-single-instance" => gtk_single_instance: GtkSingleInstance = GtkSingleInstance::DEFAULT, + + /// When enabled, the full GTK titlebar is displayed instead of your window + /// manager's simple titlebar. The behavior of this option will vary with your + /// window manager. + /// + /// This option does nothing when `window-decoration` is none or when running + /// under macOS. + "gtk-titlebar" => gtk_titlebar: bool = true, + + /// Determines the side of the screen that the GTK tab bar will stick to. + /// Top, bottom, and hidden are supported. The default is top. + /// + /// When `hidden` is set, a tab button displaying the number of tabs will appear + /// in the title bar. It has the ability to open a tab overview for displaying + /// tabs. Alternatively, you can use the `toggle_tab_overview` action in a + /// keybind if your window doesn't have a title bar, or you can switch tabs + /// with keybinds. + "gtk-tabs-location" => gtk_tabs_location: GtkTabsLocation = GtkTabsLocation::Top, + + /// If this is `true`, the titlebar will be hidden when the window is maximized, + /// and shown when the titlebar is unmaximized. GTK only. + /// + /// Available since: 1.1.0 + "gtk-titlebar-hide-when-maximized" => gtk_titlebar_hide_when_maximized: bool = false, + + /// Determines the appearance of the top and bottom bars tab bar. + /// + /// Valid values are: + /// + /// * `flat` - Top and bottom bars are flat with the terminal window. + /// * `raised` - Top and bottom bars cast a shadow on the terminal area. + /// * `raised-border` - Similar to `raised` but the shadow is replaced with a + /// more subtle border. + "gtk-toolbar-style" => gtk_toolbar_style: GtkToolbarStyle = GtkToolbarStyle::Raised, + + /// The style of the GTK titlebar. Available values are `native` and `tabs`. + /// + /// The `native` titlebar style is a traditional titlebar with a title, a few + /// buttons and window controls. A separate tab bar will show up below the + /// titlebar if you have multiple tabs open in the window. + /// + /// The `tabs` titlebar merges the tab bar and the traditional titlebar. + /// This frees up vertical space on your screen if you use multiple tabs. One + /// limitation of the `tabs` titlebar is that you cannot drag the titlebar + /// by the titles any longer (as they are tab titles now). Other areas of the + /// `tabs` title bar can be used to drag the window around. + /// + /// The default style is `native`. + "gtk-titlebar-style" => gtk_titlebar_style: GtkTitlebarStyle = GtkTitlebarStyle::Native, + + /// If `true` (default), then the Ghostty GTK tabs will be "wide." Wide tabs + /// are the new typical Gnome style where tabs fill their available space. + /// If you set this to `false` then tabs will only take up space they need, + /// which is the old style. + "gtk-wide-tabs" => gtk_wide_tabs: bool = true, + + /// Custom CSS files to be loaded. + /// + /// GTK CSS documentation can be found at the following links: + /// + /// * https://docs.gtk.org/gtk4/css-overview.html - An overview of GTK CSS. + /// * https://docs.gtk.org/gtk4/css-properties.html - A comprehensive list + /// of supported CSS properties. + /// + /// Launch Ghostty with `env GTK_DEBUG=interactive ghostty` to tweak Ghostty's + /// CSS in real time using the GTK Inspector. Errors in your CSS files would + /// also be reported in the terminal you started Ghostty from. See + /// https://developer.gnome.org/documentation/tools/inspector.html for more + /// information about the GTK Inspector. + /// + /// This configuration can be repeated multiple times to load multiple files. + /// Prepend a ? character to the file path to suppress errors if the file does + /// not exist. If you want to include a file that begins with a literal ? + /// character, surround the file path in double quotes ("). + /// The file size limit for a single stylesheet is 5MiB. + /// + /// Available since: 1.1.0 + "gtk-custom-css" => gtk_custom_css: RepeatablePath = RepeatablePath::default(), + + /// If `true` (default), applications running in the terminal can show desktop + /// notifications using certain escape sequences such as OSC 9 or OSC 777. + "desktop-notifications" => desktop_notifications: bool = true, + + /// If `true` (default), applications running in the terminal can show + /// graphical progress bars using the ConEmu OSC 9;4 escape sequence. + /// If `false`, progress bar sequences are silently ignored. + "progress-style" => progress_style: bool = true, + + /// Modifies the color used for bold text in the terminal. + /// + /// This can be set to a specific color, using the same format as + /// `background` or `foreground` (e.g. `#RRGGBB` but other formats + /// are also supported; see the aforementioned documentation). If a + /// specific color is set, this color will always be used for the default + /// bold text color. It will set the rest of the bold colors to `bright`. + /// + /// This can also be set to `bright`, which uses the bright color palette + /// for bold text. For example, if the text is red, then the bold will + /// use the bright red color. The terminal palette is set with `palette` + /// but can also be overridden by the terminal application itself using + /// escape sequences such as OSC 4. (Since Ghostty 1.2.0, the previous + /// configuration `bold-is-bright` is deprecated and replaced by this + /// usage). + /// + /// Available since Ghostty 1.2.0. + "bold-color" => bold_color: Option = None, + + /// The opacity level (opposite of transparency) of the faint text. A value of + /// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0 + /// or greater than 1 will be clamped to the nearest valid value. + /// + /// Available since Ghostty 1.2.0. + "faint-opacity" => faint_opacity: f64 = 0.5, + + /// This will be used to set the `TERM` environment variable. + /// HACK: We set this with an `xterm` prefix because vim uses that to enable key + /// protocols (specifically this will enable `modifyOtherKeys`), among other + /// features. An option exists in vim to modify this: `:set + /// keyprotocol=ghostty:kitty`, however a bug in the implementation prevents it + /// from working properly. https://github.com/vim/vim/pull/13211 fixes this. + "term" => term: String = "xterm-ghostty".to_string(), + + /// String to send when we receive `ENQ` (`0x05`) from the command that we are + /// running. Defaults to an empty string if not set. + "enquiry-response" => enquiry_response: String = String::new(), + + /// Configures the low-level API to use for async IO, eventing, etc. + /// + /// Most users should leave this set to `auto`. This will automatically detect + /// scenarios where APIs may not be available (for example `io_uring` on + /// certain hardened kernels) and fall back to a different API. However, if + /// you want to force a specific backend for any reason, you can set this + /// here. + /// + /// Based on various benchmarks, we haven't found a statistically significant + /// difference between the backends with regards to memory, CPU, or latency. + /// The choice of backend is more about compatibility and features. + /// + /// Available options: + /// + /// * `auto` - Automatically choose the best backend for the platform + /// based on available options. + /// * `epoll` - Use the `epoll` API + /// * `io_uring` - Use the `io_uring` API + /// + /// If the selected backend is not available on the platform, Ghostty will + /// fall back to an automatically chosen backend that is available. + /// + /// Changing this value requires a full application restart to take effect. + /// + /// This is only supported on Linux, since this is the only platform + /// where we have multiple options. On macOS, we always use `kqueue`. + /// + /// Available since: 1.2.0 + "async-backend" => async_backend: AsyncBackend = AsyncBackend::Auto, + + /// Control the auto-update functionality of Ghostty. This is only supported + /// on macOS currently, since Linux builds are distributed via package + /// managers that are not centrally controlled by Ghostty. + /// + /// Checking or downloading an update does not send any information to + /// the project beyond standard network information mandated by the + /// underlying protocols. To put it another way: Ghostty doesn't explicitly + /// add any tracking to the update process. The update process works by + /// downloading information about the latest version and comparing it + /// client-side to the current version. + /// + /// Valid values are: + /// + /// * `off` - Disable auto-updates. + /// * `check` - Check for updates and notify the user if an update is + /// available, but do not automatically download or install the update. + /// * `download` - Check for updates, automatically download the update, + /// notify the user, but do not automatically install the update. + /// + /// If unset, we defer to Sparkle's default behavior, which respects the + /// preference stored in the standard user defaults (`defaults(1)`). + /// + /// Changing this value at runtime works after a small delay. + "auto-update" => auto_update: Option = None, + + /// The release channel to use for auto-updates. + /// + /// The default value of this matches the release channel of the currently + /// running Ghostty version. If you download a pre-release version of Ghostty + /// then this will be set to `tip` and you will receive pre-release updates. + /// If you download a stable version of Ghostty then this will be set to + /// `stable` and you will receive stable updates. + /// + /// Valid values are: + /// + /// * `stable` - Stable, tagged releases such as "1.0.0". + /// * `tip` - Pre-release versions generated from each commit to the + /// main branch. This is the version that was in use during private + /// beta testing by thousands of people. It is generally stable but + /// will likely have more bugs than the stable channel. + /// + /// Changing this configuration requires a full restart of + /// Ghostty to take effect. + /// + /// This only works on macOS since only macOS has an auto-update feature. + "auto-update-channel" => auto_update_channel: Option = None, + } + extra { + /// List of diagnostics that were generated during the loading of + /// the configuration. + diagnostics: DiagnosticList = DiagnosticList::default(), + /// The conditional truths for the configuration. This is used to + /// determine if a conditional configuration matches or not. + conditional_state: conditional::State = conditional::State::default(), + /// The conditional keys that are used at any point during the + /// configuration loading. + conditional_set: HashSet = HashSet::new(), + /// The steps we can use to reload the configuration after it has + /// been loaded without reopening the files. + replay_steps: Vec = Vec::new(), + /// Set to true if Ghostty was executed as xdg-terminal-exec on Linux. + xdg_terminal_exec: bool = false, + } +} + +/// Errors from loading configuration. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0}")] + Io(#[from] std::io::Error), + #[error("{0}")] + Arg(#[from] ArgError), + #[error("{0}")] + Other(String), +} + +/// This is used to "replay" the configuration. See `load_theme` for details. +#[derive(Debug, Clone, PartialEq)] +pub enum ReplayStep { + /// An argument to parse as if it came from the CLI or file. + Arg(String), + + /// A base path to expand relative paths against. + Expand(String), + + /// A conditional argument. This arg is parsed only if all + /// conditions match (an "AND"). An "OR" can be achieved by + /// having multiple conditional arg entries. + ConditionalArg { + conditions: Vec, + arg: String, + }, + + /// A diagnostic to be added to the new configuration when + /// replayed. This should only be used for diagnostics that won't + /// be reproduced during playback. For example, `config-file` + /// errors are not reloaded so they should be added here. + /// + /// Diagnostics cannot be conditional. They are always present + /// even if the conditionals don't match. This helps users find + /// errors in their configuration. + Diagnostic(Diagnostic), + + /// The start of a "-e" argument. This marks the end of + /// traditional configuration and the beginning of the + /// "-e" initial command magic. This is separate from "arg" + /// because there are some behaviors unique to this (i.e. + /// we want to keep this at the end for config-file). + /// + /// Note: when "-e" is used, ONLY this is present and + /// not an additional "arg" with "-e" value. + DashE, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OptionalFileAction { + Loaded, + NotFound, + Error, +} + +impl Default for Config { + fn default() -> Self { + Config::new_default() + } +} + +impl Config { + /// Build the default configuration (Zig: `Config.default`). + #[must_use] + pub fn new_default() -> Config { + // Build up our basic config + let mut result = Config::raw_default(); + + // Add our default keybindings + result.keybind.init(); + + // Add our default command palette entries + result.command_palette_entry.init(); + + // Add our default link for URL detection + result.link.links.push(ghostty_input::Link { + regex: crate::url::REGEX.to_string(), + action: ghostty_input::link::Action::Open, + highlight: ghostty_input::link::Highlight::HoverMods(ghostty_input::ctrl_or_super( + ghostty_input::Mods::default(), + )), + }); + + result + } + + /// Load the configuration according to the default rules: + /// + /// 1. Defaults + /// 2. XDG config dir + /// 3. "Application Support" directory (macOS only) + /// 4. CLI flags + /// 5. Recursively defined configuration files + pub fn load() -> Result { + let mut result = Config::new_default(); + + // If we have a configuration file in our home directory, parse that first. + result.load_default_files()?; + + // Parse the config from the CLI args. + result.load_cli_args()?; + + // Parse the config files that were added from our file and CLI args. + result.load_recursive_files()?; + result.finalize()?; + + Ok(result) + } + + /// Load configuration from an iterator that yields values that look like + /// command-line arguments, i.e. `--key=value`. + pub fn load_iter(&mut self, iter: &mut dyn ArgIter) -> Result<(), ArgError> { + cli_args::parse(self, iter) + } + + /// Load configuration from the target config file at `path`. + /// + /// `path` must be resolved and absolute. + pub fn load_file(&mut self, path: &str) -> Result<(), Error> { + assert!(std::path::Path::new(path).is_absolute()); + let file = match crate::file_load::open(path) { + Ok(f) => f, + Err(crate::file_load::OpenFileError::NotAFile) => { + log::warn!("config-file {path}: not reading because it is not a file"); + return Ok(()); + } + Err(crate::file_load::OpenFileError::FileNotFound) => { + return Err(Error::Io(std::io::ErrorKind::NotFound.into())); + } + Err(e) => return Err(Error::Other(e.to_string())), + }; + + self.load_fs_file(file, path) + } + + /// Load config from the given File. + fn load_fs_file(&mut self, file: std::fs::File, path: &str) -> Result<(), Error> { + log::info!("reading configuration file path={path}"); + let mut reader = std::io::BufReader::with_capacity(2048, file); + self.load_reader(&mut reader, path) + } + + /// Load config from the given Reader. + fn load_reader( + &mut self, + reader: &mut R, + path: &str, + ) -> Result<(), Error> { + // If the file starts with a UTF-8 byte order mark, skip it. + // https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8 + if let Ok(buf) = reader.fill_buf() { + if buf.starts_with(&[0xef, 0xbb, 0xbf]) { + log::info!("skipping UTF-8 byte order mark"); + reader.consume(3); + } + } + let mut iter = cli_args::LineIterator::with_path(reader, path); + self.load_iter(&mut iter)?; + let dir = std::path::Path::new(path) + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + self.expand_paths(&dir)?; + Ok(()) + } + + /// Load optional configuration file from `path`. All errors are ignored. + /// + /// Returns the action that was taken. + pub fn load_optional_file(&mut self, path: &str) -> OptionalFileAction { + match self.load_file(path) { + Ok(()) => OptionalFileAction::Loaded, + Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => { + OptionalFileAction::NotFound + } + Err(err) => { + log::warn!("error reading optional config file, not loading err={err} path={path}"); + OptionalFileAction::Error + } + } + } + + fn write_config_template(path: &str) -> std::io::Result<()> { + log::info!("creating template config file: path={path}"); + if let Some(dir) = std::path::Path::new(path).parent() { + std::fs::create_dir_all(dir)?; + } + let template = include_str!("config-template").replace("{[path]s}", path); + std::fs::write(path, template) + } + + /// Load configurations from the default configuration files. The default + /// configuration file is at `$XDG_CONFIG_HOME/ghostty/config.ghostty`. + /// + /// The legacy `config` file (without extension) is first loaded, + /// then `config.ghostty`. + pub fn load_default_files(&mut self) -> Result<(), Error> { + // Load XDG first + let legacy_xdg_path = + crate::file_load::legacy_default_xdg_path().map_err(|e| Error::Other(e.to_string()))?; + let xdg_path = + crate::file_load::default_xdg_path().map_err(|e| Error::Other(e.to_string()))?; + let xdg_loaded = { + let legacy_xdg_action = self.load_optional_file(&legacy_xdg_path); + let xdg_action = self.load_optional_file(&xdg_path); + if xdg_action != OptionalFileAction::NotFound + && legacy_xdg_action != OptionalFileAction::NotFound + { + log::warn!("both config files `{legacy_xdg_path}` and `{xdg_path}` exist."); + log::warn!("loading them both in that order"); + true + } else { + xdg_action != OptionalFileAction::NotFound + || legacy_xdg_action != OptionalFileAction::NotFound + } + }; + + if !xdg_loaded { + if let Err(err) = Self::write_config_template(&xdg_path) { + log::warn!("error creating template config file err={err}"); + } + } + Ok(()) + } + + /// Load and parse the CLI args. + pub fn load_cli_args(&mut self) -> Result<(), Error> { + let argv: Vec = std::env::args_os().collect(); + + // Fast-path if we are Linux/BSD and have no args. + if argv.len() <= 1 { + return Ok(()); + } + + // On Linux, we have a special case where if the executing + // program is "xdg-terminal-exec" then we treat all CLI + // args as if they are a command to execute. + // + // In this mode, we also behave slightly differently: + // + // - The initial window title is set to the full command. This + // can be used with window managers to modify positioning, + // styling, etc. based on the command. + // + // See: https://github.com/Vladimir-csp/xdg-terminal-exec + { + use std::os::unix::ffi::OsStrExt; + let cargs: Vec = argv + .iter() + .filter_map(|a| std::ffi::CString::new(a.as_bytes()).ok()) + .collect(); + let crefs: Vec<&std::ffi::CStr> = cargs.iter().map(|c| c.as_c_str()).collect(); + if let Some(args) = ghostty_os::xdg::parse_terminal_exec(&crefs) { + // First, we add an artificial "-e" so that if we + // replay the inputs to rebuild the config (i.e. if + // a theme is set) then we will get the same behavior. + self.replay_steps.push(ReplayStep::DashE); + + // Next, take all remaining args and use that to build up + // a command to execute. + let mut builder: Vec = Vec::new(); + for arg in args { + let copy = arg.to_string_lossy().into_owned(); + self.replay_steps.push(ReplayStep::Arg(copy.clone())); + builder.push(copy); + } + + self.xdg_terminal_exec = true; + self.initial_command = Some(Command::Direct(builder)); + return Ok(()); + } + } + + // We set config-default-files to true here because this + // should always be reset so we can detect if it is set + // in the CLI since it is documented as having no affect + // from files. + self.config_default_files = true; + + // Keep track of the replay steps up to this point so we + // can replay if we are discarding the default files. + let replay_len_start = self.replay_steps.len(); + + // font-family settings set via the CLI overwrite any prior values + // rather than append. This avoids a UX oddity where you have to + // specify `font-family=""` to clear the font families. + self.font_family.overwrite_next = true; + self.font_family_bold.overwrite_next = true; + self.font_family_italic.overwrite_next = true; + self.font_family_bold_italic.overwrite_next = true; + + // Initialize our CLI iterator. + let mut iter = cli_args::args_iterator(); + let res = self.load_iter(&mut iter); + + // If we are not loading the default files, then we need to + // replay the steps up to this point so that we can rebuild + // the config without it. + if res.is_ok() && !self.config_default_files { + let replay_len_end = self.replay_steps.len(); + if replay_len_end != replay_len_start { + log::info!( + "config-default-files unset, discarding configuration from default files" + ); + + let mut new_config = self.clone_empty(); + let steps = self.replay_steps[replay_len_start..replay_len_end].to_vec(); + new_config.load_replay(&steps)?; + *self = new_config; + } + } + + self.font_family.overwrite_next = false; + self.font_family_bold.overwrite_next = false; + self.font_family_italic.overwrite_next = false; + self.font_family_bold_italic.overwrite_next = false; + res?; + + // Any paths referenced from the CLI are relative to the current working + // directory. + let cwd = std::fs::canonicalize(".")?; + self.expand_paths(&cwd.to_string_lossy())?; + Ok(()) + } + + /// Load and parse the config files that were added in the "config-file" key. + pub fn load_recursive_files(&mut self) -> Result<(), Error> { + if self.config_file.value.is_empty() { + return Ok(()); + } + + // Keeps track of loaded files to prevent cycles. + let mut loaded: HashSet = HashSet::new(); + + // We need to insert all of our loaded config-file values + // PRIOR to the "-e" in our replay steps, since everything + // after "-e" becomes an "initial-command". To do this, we + // dupe the values if we find it. + let mut replay_suffix: Vec = Vec::new(); + if let Some(i) = self + .replay_steps + .iter() + .position(|s| matches!(s, ReplayStep::DashE)) + { + replay_suffix = self.replay_steps.split_off(i); + } + + // We must use a while below and not a for(items) because we + // may add items to the list while iterating for recursive + // config-file entries. + let mut i = 0; + while i < self.config_file.value.len() { + let (path, optional) = match &self.config_file.value[i] { + Path::Optional(path) => (path.clone(), true), + Path::Required(path) => (path.clone(), false), + }; + i += 1; + + // Error paths + if path.is_empty() { + continue; + } + + // All paths should already be absolute at this point because + // they're fixed up after each load. + assert!(std::path::Path::new(&path).is_absolute()); + + // We must only load a unique file once + if !loaded.insert(path.clone()) { + let diag = Diagnostic { + message: format!("config-file {path}: cycle detected"), + ..Diagnostic::default() + }; + self.diagnostics.append(diag.clone()); + self.replay_steps.push(ReplayStep::Diagnostic(diag)); + continue; + } + + let file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(err) => { + if err.kind() != std::io::ErrorKind::NotFound || !optional { + let diag = Diagnostic { + message: format!( + "error opening config-file {path}: {}", + zig_io_error_name(&err) + ), + ..Diagnostic::default() + }; + self.diagnostics.append(diag.clone()); + self.replay_steps.push(ReplayStep::Diagnostic(diag)); + } + continue; + } + }; + + let meta = file.metadata()?; + if !meta.is_file() { + let diag = Diagnostic { + message: format!( + "config-file {path}: not reading because file type is {}", + file_kind_name(&meta) + ), + ..Diagnostic::default() + }; + self.diagnostics.append(diag.clone()); + self.replay_steps.push(ReplayStep::Diagnostic(diag)); + continue; + } + + self.load_fs_file(file, &path)?; + } + + // If we have a suffix, add that back. + self.replay_steps.extend(replay_suffix); + Ok(()) + } + + /// Change the state of conditionals and reload the configuration + /// based on the new state. This returns a new configuration based + /// on the new state. + /// + /// This returns `None` if the conditional state would result in no changes + /// to the configuration. In this case, the caller can continue to use + /// the existing configuration or clone if they want a copy. + /// + /// This doesn't re-read any files, it just re-applies the same + /// configuration with the new conditional state. Importantly, this means + /// that if you change the conditional state and the user in the interim + /// deleted a file that was referenced in the configuration, then the + /// configuration can still be reloaded. + pub fn change_conditional_state( + &self, + new: conditional::State, + ) -> Result, Error> { + // If the conditional state between the old and new is the same, + // then we don't need to do anything. + let relevant = (self.conditional_set.contains(&conditional::Key::Theme) + && self.conditional_state.theme != new.theme) + || (self.conditional_set.contains(&conditional::Key::Os) + && self.conditional_state.os != new.os); + if !relevant { + return Ok(None); + } + + // Create our new configuration + let mut new_config = self.clone_empty(); + + // Set our conditional state so the replay below can use it + new_config.conditional_state = new; + + // Replay all of our steps to rebuild the configuration + new_config.load_replay(&self.replay_steps)?; + new_config.finalize()?; + + Ok(Some(new_config)) + } + + /// Replay steps into this config (Zig: `Replay.iterator` + `loadIter`). + /// This handles intermediate steps such as expanding relative paths. + fn load_replay(&mut self, steps: &[ReplayStep]) -> Result<(), ArgError> { + let mut i = 0; + while i < steps.len() { + let step = &steps[i]; + i += 1; + match step { + ReplayStep::Expand(base) => { + // This shouldn't happen because to reach this step + // means that it succeeded before. Its possible since + // expanding paths is a side effect process that the + // world state changed and we can't expand anymore. + // In that really unfortunate case, we log a warning. + if let Err(err) = self.expand_paths(base) { + log::warn!("error expanding paths err={err}"); + } + } + + ReplayStep::Diagnostic(diag) => self.diagnostics.append(diag.clone()), + + ReplayStep::ConditionalArg { conditions, arg } => { + // All conditions must match. + if conditions.iter().all(|c| self.conditional_state.match_(c)) { + self.load_one_arg(arg)?; + } + } + + ReplayStep::Arg(arg) => self.load_one_arg(arg)?, + + ReplayStep::DashE => { + // The "-e" hook consumes every remaining argument. + let mut args = vec!["-e".to_string()]; + while i < steps.len() { + match &steps[i] { + ReplayStep::Expand(base) => { + if let Err(err) = self.expand_paths(base) { + log::warn!("error expanding paths err={err}"); + } + } + ReplayStep::Diagnostic(diag) => { + self.diagnostics.append(diag.clone()); + } + ReplayStep::ConditionalArg { conditions, arg } => { + if conditions.iter().all(|c| self.conditional_state.match_(c)) { + args.push(arg.clone()); + } + } + ReplayStep::Arg(arg) => args.push(arg.clone()), + ReplayStep::DashE => args.push("-e".to_string()), + } + i += 1; + } + let mut it = cli_args::VecIterator::new(args); + self.load_iter(&mut it)?; + } + } + } + Ok(()) + } + + fn load_one_arg(&mut self, arg: &str) -> Result<(), ArgError> { + let mut it = cli_args::VecIterator::new(vec![arg.to_string()]); + self.load_iter(&mut it) + } + + /// Expand the relative paths in config-files to be absolute paths + /// relative to the base directory. + fn expand_paths(&mut self, base: &str) -> Result<(), Error> { + // Keep track of this step for replays + self.replay_steps.push(ReplayStep::Expand(base.to_string())); + + // Expand all of our paths + if let Some(path) = &mut self.background_image { + path.expand(base, &mut self.diagnostics)?; + } + if let Some(path) = &mut self.bell_audio_path { + path.expand(base, &mut self.diagnostics)?; + } + self.config_file.expand(base, &mut self.diagnostics)?; + self.custom_shader.expand(base, &mut self.diagnostics)?; + self.gtk_custom_css.expand(base, &mut self.diagnostics)?; + Ok(()) + } + + fn load_theme(&mut self, theme: &Theme) -> Result<(), Error> { + // Load the correct theme depending on the conditional state. + // Dark/light themes were programmed prior to conditional configuration + // so when we introduce that we probably want to replace this. + let name: &str = match self.conditional_state.theme { + conditional::Theme::Light => &theme.light, + conditional::Theme::Dark => &theme.dark, + }; + + // Find our theme file and open it. See the open function for details. + let resources_dir = ghostty_os::resources_dir().ok(); + let Some(themefile) = crate::theme::open( + name, + resources_dir.as_ref().and_then(|r| r.app()), + &mut self.diagnostics, + ) else { + return Ok(()); + }; + let path = themefile.path; + let file = themefile.file; + + // From this point onwards, we load the theme and do a bit of a dance + // to achieve two goals: + // + // (1) We want the theme to be loaded and our existing config to + // override the theme. So we need to load the theme and apply + // our config on top of it. + // + // (2) We want to free existing memory that we aren't using anymore + // as a result of reloading the configuration. + + // Load into a new configuration so that we can free the existing memory. + let mut new_config = self.clone_empty(); + + // Load our theme + let mut reader = std::io::BufReader::with_capacity(2048, file); + let mut iter = cli_args::LineIterator::with_path(&mut reader, path); + new_config.load_iter(&mut iter)?; + + // Setup our replay to be conditional. + let theme_name = self.conditional_state.theme.as_str(); + for item in &mut new_config.replay_steps { + match item { + ReplayStep::Expand(_) | ReplayStep::Diagnostic(_) => {} + + // If we see "-e" then we do NOT make the following arguments + // conditional since they are supposed to be part of the + // initial command. + ReplayStep::DashE => break, + + // Change our arg to be conditional on our theme. + ReplayStep::Arg(v) => { + *item = ReplayStep::ConditionalArg { + conditions: vec![Conditional { + key: conditional::Key::Theme, + op: conditional::Op::Eq, + value: theme_name.to_string(), + }], + arg: std::mem::take(v), + }; + } + + ReplayStep::ConditionalArg { conditions, .. } => { + conditions.insert( + 0, + Conditional { + key: conditional::Key::Theme, + op: conditional::Op::Eq, + value: theme_name.to_string(), + }, + ); + } + } + } + + // Replay our previous inputs so that we can override values + // from the theme. + new_config.load_replay(&self.replay_steps)?; + + // Success, swap our new config in and free the old. + *self = new_config; + Ok(()) + } + + /// Call this once after you are done setting configuration. This + /// is idempotent but will waste memory if called multiple times. + pub fn finalize(&mut self) -> Result<(), Error> { + // We always load the theme first because it may set other fields + // in our config. + if let Some(theme) = self.theme.clone() { + let different = theme.light != theme.dark; + + // Warning: load_theme will replace our existing config with a + // new one, so all state from self prior to this point is replaced. + self.load_theme(&theme)?; + + // If we have different light vs dark mode themes, disable + // window-theme = auto since that breaks it. + if different { + // This setting doesn't make sense with different light/dark themes + // because it'll force the theme based on the Ghostty theme. + if self.window_theme == WindowTheme::Auto { + self.window_theme = WindowTheme::System; + } + + // Mark that we use a conditional theme + self.conditional_set.insert(conditional::Key::Theme); + } + } + + // Used for a variety of defaults. See the function docs as well the + // specific variable use sites for more details. + let probable_cli = probable_cli_environment(); + + // If we have a font-family set and don't set the others, default + // the others to the font family. This way, if someone does + // --font-family=foo, then we try to get the stylized versions of + // "foo" as well. + if self.font_family.count() > 0 { + if self.font_family_bold.count() == 0 { + self.font_family_bold = self.font_family.clone(); + } + if self.font_family_italic.count() == 0 { + self.font_family_italic = self.font_family.clone(); + } + if self.font_family_bold_italic.count() == 0 { + self.font_family_bold_italic = self.font_family.clone(); + } + } + + // Prevent setting TERM to an empty string + if self.term.is_empty() { + // HACK: See comment above at definition + self.term = "xterm-ghostty".to_string(); + } + + // The default for the working directory depends on the system. + let mut wd: WorkingDirectory = self.working_directory.clone().unwrap_or(if probable_cli { + WorkingDirectory::Inherit + } else { + WorkingDirectory::Home + }); + + // If we are missing either a command or home directory, we need + // to look up defaults which is kind of expensive. We only do this + // on desktop (and not in tests, like the Zig `builtin.is_test`). + if !cfg!(test) && (self.command.is_none() || wd == WorkingDirectory::Home) { + self.lookup_shell_defaults(probable_cli, &mut wd)?; + } + wd.finalize(); + self.working_directory = Some(wd); + + // Apprt-specific defaults (GTK) + match self.gtk_single_instance { + GtkSingleInstance::True | GtkSingleInstance::False => {} + + // For detection, we assume single instance unless we're + // in a CLI environment, then we disable single instance. + GtkSingleInstance::Detect => { + self.gtk_single_instance = if probable_cli { + GtkSingleInstance::False + } else { + GtkSingleInstance::True + }; + } + } + + // Default our click interval + if self.click_repeat_interval == 0 && !cfg!(test) { + self.click_repeat_interval = ghostty_os::click_interval().unwrap_or(500); + } + + // Clamp our mouse scroll multiplier + self.mouse_scroll_multiplier.precision = + self.mouse_scroll_multiplier.precision.clamp(0.01, 10_000.0); + self.mouse_scroll_multiplier.discrete = + self.mouse_scroll_multiplier.discrete.clamp(0.01, 10_000.0); + + // Clamp our split opacity + self.unfocused_split_opacity = self.unfocused_split_opacity.clamp(0.15, 1.0); + + // Clamp our contrast + self.minimum_contrast = self.minimum_contrast.clamp(1.0, 21.0); + + // Minimum window size + if self.window_width > 0 { + self.window_width = self.window_width.max(10); + } + if self.window_height > 0 { + self.window_height = self.window_height.max(4); + } + + // If URLs are disabled, cut off the first link. The first link is + // always the URL matcher. + if !self.link_url && !self.link.links.is_empty() { + self.link.links.remove(0); + } + + // We warn when the quit-after-last-window-closed-delay is set to a very + // short value because it can cause Ghostty to quit before the first + // window is even shown. + if let Some(duration) = &self.quit_after_last_window_closed_delay { + if duration.duration < 5 * NS_PER_S { + log::warn!( + "quit-after-last-window-closed-delay is set to a very short value ({duration}), which might cause problems" + ); + } + } + + // We can't set this as a struct default because our config is + // loaded in environments where a build config isn't available. + if self.auto_update_channel.is_none() { + self.auto_update_channel = Some(ReleaseChannel::Stable); + } + + self.faint_opacity = self.faint_opacity.clamp(0.0, 1.0); + + // Finalize key remapping set for efficient lookups + self.key_remap.finalize(); + Ok(()) + } + + /// Look up the default shell / working directory from the environment + /// and passwd database (the expensive part of `finalize`). + fn lookup_shell_defaults( + &mut self, + probable_cli: bool, + wd: &mut WorkingDirectory, + ) -> Result<(), Error> { + // First look up the command using the SHELL env var if needed. + // We don't do this in flatpak because SHELL in Flatpak is always + // set to /bin/sh. + if let Some(cmd) = &self.command { + log::info!("shell src=config value={cmd:?}"); + } else if !ghostty_os::is_flatpak() && probable_cli { + // If we were launched from the desktop, our SHELL env var + // will represent our SHELL at login time. We only want to + // read from SHELL if we're in a probable CLI environment. + if let Ok(value) = std::env::var("SHELL") { + log::info!("default shell source=env value={value}"); + self.command = Some(Command::Shell(value)); + + // If we don't need the working directory, then we can exit now. + if *wd != WorkingDirectory::Home { + return Ok(()); + } + } + } + + // We need the passwd entry for the remainder + let pw = ghostty_os::passwd::get()?; + if self.command.is_none() { + if let Some(sh) = pw.shell { + log::info!("default shell src=passwd value={sh}"); + self.command = Some(Command::Shell(sh)); + } + } + + if *wd == WorkingDirectory::Home { + if let Some(home) = pw.home { + log::info!("default working directory src=passwd value={home}"); + *wd = WorkingDirectory::Path(home); + } else { + *wd = WorkingDirectory::Inherit; + } + } + + if self.command.is_none() { + log::warn!("no default shell found, will default to using sh"); + } + Ok(()) + } + + /// Add a diagnostic message to the config with the given string. + /// This is always added with a location of "none". + pub fn add_diagnostic(&mut self, message: impl Into) { + self.diagnostics.append(Diagnostic { + message: message.into(), + ..Diagnostic::default() + }); + } + + /// Create a copy of the metadata of this configuration but without + /// the actual values. Metadata includes conditional state. + #[must_use] + pub fn clone_empty(&self) -> Config { + let mut result = Config::new_default(); + result.conditional_state = self.conditional_state; + result + } + + /// Returns an iterator that goes through each changed field from + /// old to new. The order of old or new do not matter. + #[must_use] + pub fn change_iterator<'a>(&'a self, new: &'a Config) -> ChangeIterator<'a> { + ChangeIterator { + old: self, + new, + i: 0, + } + } + + /// Returns true if the given key has changed from old to new. + #[must_use] + pub fn changed(&self, new: &Config, key: Key) -> bool { + match Config::FIELDS.iter().find(|f| f.name == key.0) { + Some(field) => !(field.eq)(self, new), + None => false, + } + } +} + +/// This yields a key for every changed field between old and new. +pub struct ChangeIterator<'a> { + old: &'a Config, + new: &'a Config, + i: usize, +} + +impl Iterator for ChangeIterator<'_> { + type Item = Key; + + fn next(&mut self) -> Option { + while self.i < Config::FIELDS.len() { + let field = &Config::FIELDS[self.i]; + self.i += 1; + if !(field.eq)(self.old, self.new) { + return Some(Key(field.name)); + } + } + None + } +} + +/// Name of an IO error like Zig's error names (best effort), used in +/// user-visible diagnostics. +fn zig_io_error_name(err: &std::io::Error) -> String { + use std::io::ErrorKind; + match err.kind() { + ErrorKind::NotFound => "FileNotFound".to_string(), + ErrorKind::PermissionDenied => "AccessDenied".to_string(), + _ => err.to_string(), + } +} + +fn file_kind_name(meta: &std::fs::Metadata) -> &'static str { + use std::os::unix::fs::FileTypeExt; + let ft = meta.file_type(); + if ft.is_dir() { + "directory" + } else if ft.is_symlink() { + "sym_link" + } else if ft.is_block_device() { + "block_device" + } else if ft.is_char_device() { + "character_device" + } else if ft.is_fifo() { + "named_pipe" + } else if ft.is_socket() { + "unix_domain_socket" + } else { + "unknown" + } +} + +/// This runs a heuristic to determine if we are likely running +/// Ghostty in a CLI environment. We need this to change some behaviors. +/// We should keep the set of behaviors that depend on this as small +/// as possible because magic sucks, but each place is well documented. +fn probable_cli_environment() -> bool { + // If we have TERM_PROGRAM set to a non-empty value, we assume + // a graphical terminal environment. + if let Some(v) = std::env::var_os("TERM_PROGRAM") { + if !v.is_empty() { + return true; + } + } + + // CLI arguments makes things probable + if std::env::args_os().len() > 1 { + return true; + } + + // Unlikely CLI environment + false +} + +impl CliTarget for Config { + fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { + self.parse_field(key, value) + } + + fn valid_values(&self, key: &str) -> Option<&'static [&'static str]> { + Config::valid_values_for(key) + } + + fn diagnostics_mut(&mut self) -> Option<&mut DiagnosticList> { + Some(&mut self.diagnostics) + } + + /// Callback for the arg parser to allow us to handle special cases + /// like `-e`. Returns `Ok(false)` if the CLI parsing should halt. + fn parse_manually_hook(&mut self, arg: &str, iter: &mut dyn ArgIter) -> Result { + if arg == "-e" { + // Add the special -e marker. This prevents: + // (1) config-file from adding args to the end (see #2908) + // (2) dark/light theme from making this conditional + self.replay_steps.push(ReplayStep::DashE); + + // Build up the command. + let mut command: Vec = Vec::new(); + while let Some(param) = iter.next_arg() { + self.replay_steps.push(ReplayStep::Arg(param.clone())); + command.push(param); + } + + if command.is_empty() { + self.diagnostics.append(Diagnostic { + location: iter.location().unwrap_or_default(), + message: format!("missing command after {arg}"), + ..Diagnostic::default() + }); + + return Ok(false); + } + + // See "command" docs for the implied configurations and why. + self.initial_command = Some(Command::Direct(command)); + self.gtk_single_instance = GtkSingleInstance::False; + self.quit_after_last_window_closed = true; + self.quit_after_last_window_closed_delay = None; + if self.shell_integration != ShellIntegration::None { + self.shell_integration = ShellIntegration::Detect; + } + + // Do not continue, we consumed everything. + return Ok(false); + } + + // Keep track of our input args for replay + self.replay_steps.push(ReplayStep::Arg(arg.to_string())); + + // If we didn't find a special case, continue parsing normally + Ok(true) + } + + fn compatibility(&mut self, key: &str, value: Option<&str>) -> bool { + match key { + // Ghostty 1.1 introduced background-blur support for Linux which + // doesn't support a specific radius value. The renaming is to let + // one field be used for both platforms (macOS retained the ability + // to set a radius). + "background-blur-radius" => { + cli_args::compatibility_renamed(self, "background-blur", value) + } + + // Ghostty 1.2 renamed all our adw options to gtk because we now have + // a hard dependency on libadwaita. + "adw-toolbar-style" => { + cli_args::compatibility_renamed(self, "gtk-toolbar-style", value) + } + + // Ghostty 1.2 removed the `hidden` value from `gtk-tabs-location` and + // moved it to `window-show-tab-bar`. + "gtk-tabs-location" => { + if value.unwrap_or("") == "hidden" { + self.window_show_tab_bar = WindowShowTabBar::Never; + return true; + } + false + } + + // Ghostty 1.2 lets you set `cell-foreground` and `cell-background` + // to match the cell foreground and background colors, respectively. + // This can be used with `cursor-color` and `cursor-text` to recreate + // this behavior. This applies to selection too. + "cursor-invert-fg-bg" => { + // We don't do anything if the value is unset, which is technically + // not EXACTLY the same as prior behavior since it would fallback + // to doing whatever cursor-color/cursor-text were set to, but + // I don't want to store what that is separately so this is close + // enough. + // + // Realistically, these fields were mutually exclusive so anyone + // relying on that behavior should just upgrade to the new + // cursor-color/cursor-text fields. + let Ok(set) = cli_args::parse_bool(value.unwrap_or("t")) else { + return false; + }; + if set { + self.cursor_color = Some(TerminalColor::CellForeground); + self.cursor_text = Some(TerminalColor::CellBackground); + } + true + } + "selection-invert-fg-bg" => { + let Ok(set) = cli_args::parse_bool(value.unwrap_or("t")) else { + return false; + }; + if set { + self.selection_foreground = Some(TerminalColor::CellBackground); + self.selection_background = Some(TerminalColor::CellForeground); + } + true + } + + // Ghostty 1.2 merged `bold-is-bright` into the new `bold-color` + // by setting the value to "bright". + "bold-is-bright" => { + let Ok(isset) = cli_args::parse_bool(value.unwrap_or("t")) else { + return false; + }; + if isset { + self.bold_color = Some(BoldColor::Bright); + } + true + } + + // Ghostty 1.2 removed the "desktop" option and renamed it to "detect". + // The semantics also changed slightly but this is the correct mapping. + "gtk-single-instance" => { + if value.unwrap_or("") == "desktop" { + self.gtk_single_instance = GtkSingleInstance::Detect; + return true; + } + false + } + + // Ghostty 1.3 rename the "window" option to "new-window". + // See: https://github.com/ghostty-org/ghostty/pull/9764 + "macos-dock-drop-behavior" => { + if value.unwrap_or("") == "window" { + self.macos_dock_drop_behavior = MacOSDockDropBehavior::NewWindow; + return true; + } + false + } + + _ => false, + } + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::field_reassign_with_default, + clippy::assert_is_empty +)] +mod tests { + use super::*; + use crate::cli_args::VecIterator; + use ghostty_os::TempDir; + use std::io::Cursor; + + fn it(args: &[&str]) -> VecIterator { + VecIterator::new(args.iter().map(|s| (*s).to_string()).collect()) + } + + fn write_theme(td: &TempDir, name: &str, data: &str) -> String { + let p = td.path().join(name); + std::fs::write(&p, data).unwrap(); + std::fs::canonicalize(&p) + .unwrap() + .to_string_lossy() + .into_owned() + } + + #[test] + fn handle_bom_in_config_files() { + { + let data = "\u{feff}abnormal-command-exit-runtime = 2500\n"; + let mut reader = Cursor::new(data.as_bytes().to_vec()); + let mut cfg = Config::default(); + cfg.load_reader(&mut reader, "/home/ghostty/.config/ghostty/config.ghostty") + .unwrap(); + cfg.finalize().unwrap(); + + assert!(cfg.diagnostics.empty()); + assert_eq!(cfg.abnormal_command_exit_runtime, 2500); + } + + { + let data = "abnormal-command-exit-runtime = 2500\n"; + let mut reader = Cursor::new(data.as_bytes().to_vec()); + let mut cfg = Config::default(); + cfg.load_reader(&mut reader, "/home/ghostty/.config/ghostty/config.ghostty") + .unwrap(); + cfg.finalize().unwrap(); + + assert!(cfg.diagnostics.empty()); + assert_eq!(cfg.abnormal_command_exit_runtime, 2500); + } + } + + #[test] + fn parse_hook_invalid_command() { + let mut cfg = Config::default(); + let mut i = it(&["foo"]); + assert!(cfg.parse_manually_hook("--command", &mut i).unwrap()); + assert!(cfg.command.is_none()); + } + + #[test] + fn parse_e_command_only() { + let mut cfg = Config::default(); + let mut i = it(&["foo"]); + assert!(!cfg.parse_manually_hook("-e", &mut i).unwrap()); + + let Some(Command::Direct(cmd)) = &cfg.initial_command else { + panic!("expected direct"); + }; + assert_eq!(cmd, &["foo"]); + } + + #[test] + fn parse_e_command_and_args() { + let mut cfg = Config::default(); + let mut i = it(&["echo", "foo", "bar baz"]); + assert!(!cfg.parse_manually_hook("-e", &mut i).unwrap()); + + let Some(Command::Direct(cmd)) = &cfg.initial_command else { + panic!("expected direct"); + }; + assert_eq!(cmd, &["echo", "foo", "bar baz"]); + } + + #[test] + fn clone_default() { + let source = Config::default(); + let dest = source.clone(); + + // Should have no changes + let mut changes = source.change_iterator(&dest); + assert_eq!(changes.next(), None); + } + + #[test] + fn clone_preserves_conditional_state() { + let mut a = Config::default(); + a.conditional_state.theme = conditional::Theme::Dark; + let dest = a.clone(); + + // Should have no changes + let mut changes = a.change_iterator(&dest); + assert_eq!(changes.next(), None); + + // Should have the same conditional state + assert_eq!(dest.conditional_state.theme, conditional::Theme::Dark); + } + + #[test] + fn clone_can_then_change_conditional_state() { + // This tests a particular bug sequence where: + // 1. Load light + // 2. Convert to dark + // 3. Clone dark + // 4. Convert to light + // 5. Config is still dark (bug) + let td = TempDir::init().unwrap(); + let light = write_theme(&td, "theme_light", include_str!("testdata/theme_light")); + let dark = write_theme(&td, "theme_dark", include_str!("testdata/theme_dark")); + + let mut cfg_light = Config::default(); + let mut i = it(&[&format!("--theme=light:{light},dark:{dark}")]); + cfg_light.load_iter(&mut i).unwrap(); + cfg_light.finalize().unwrap(); + + let cfg_dark = cfg_light + .change_conditional_state(conditional::State { + theme: conditional::Theme::Dark, + ..conditional::State::default() + }) + .unwrap() + .unwrap(); + + assert_eq!( + cfg_dark.background, + Color { + r: 0xEE, + g: 0xEE, + b: 0xEE + } + ); + + let cfg_clone = cfg_dark.clone(); + assert_eq!( + cfg_clone.background, + Color { + r: 0xEE, + g: 0xEE, + b: 0xEE + } + ); + + let cfg_light2 = cfg_clone + .change_conditional_state(conditional::State { + theme: conditional::Theme::Light, + ..conditional::State::default() + }) + .unwrap() + .unwrap(); + assert_eq!( + cfg_light2.background, + Color { + r: 0xFF, + g: 0xFF, + b: 0xFF + } + ); + } + + #[test] + fn clone_preserves_conditional_set() { + let mut cfg = Config::default(); + let mut i = it(&["--theme=light:foo,dark:bar", "--window-theme=auto"]); + cfg.load_iter(&mut i).unwrap(); + cfg.finalize().unwrap(); + + let clone1 = cfg.clone(); + assert!(clone1.conditional_set.contains(&conditional::Key::Theme)); + } + + #[test] + fn working_directory_expands_tilde() { + let mut cfg = Config::default(); + let mut i = it(&["--working-directory=~/projects/ghostty"]); + cfg.load_iter(&mut i).unwrap(); + cfg.finalize().unwrap(); + + let expected = ghostty_os::expand_home("~/projects/ghostty") + .unwrap_or_else(|_| "~/projects/ghostty".to_string()); + assert_eq!( + cfg.working_directory.as_ref().unwrap().value().unwrap(), + expected + ); + } + + #[test] + fn changed() { + let source = Config::default(); + let mut dest = source.clone(); + dest.font_thicken = true; + + assert!(source.changed(&dest, crate::keys::FONT_THICKEN)); + assert!(!source.changed(&dest, crate::keys::FONT_SIZE)); + } + + #[test] + fn change_conditional_state_ignores_irrelevant_changes() { + let mut cfg = Config::default(); + let mut i = it(&["--theme=foo"]); + cfg.load_iter(&mut i).unwrap(); + cfg.finalize().unwrap(); + + assert!( + cfg.change_conditional_state(conditional::State { + theme: conditional::Theme::Dark, + ..conditional::State::default() + }) + .unwrap() + .is_none() + ); + } + + #[test] + fn change_conditional_state_applies_relevant_changes() { + let mut cfg = Config::default(); + let mut i = it(&["--theme=light:foo,dark:bar"]); + cfg.load_iter(&mut i).unwrap(); + cfg.finalize().unwrap(); + + let cfg2 = cfg + .change_conditional_state(conditional::State { + theme: conditional::Theme::Dark, + ..conditional::State::default() + }) + .unwrap() + .unwrap(); + + assert!(cfg2.conditional_set.contains(&conditional::Key::Theme)); + } + + #[test] + fn theme_loading() { + let td = TempDir::init().unwrap(); + let path = write_theme(&td, "theme", include_str!("testdata/theme_simple")); + + let mut cfg = Config::default(); + let mut i = it(&[&format!("--theme={path}")]); + cfg.load_iter(&mut i).unwrap(); + cfg.finalize().unwrap(); + + assert_eq!( + cfg.background, + Color { + r: 0x12, + g: 0x3A, + b: 0xBC + } + ); + + // Not a conditional theme + assert!(!cfg.conditional_set.contains(&conditional::Key::Theme)); + } + + #[test] + fn theme_loading_preserves_conditional_state() { + let td = TempDir::init().unwrap(); + let path = write_theme(&td, "theme", include_str!("testdata/theme_simple")); + + let mut cfg = Config::default(); + cfg.conditional_state = conditional::State { + theme: conditional::Theme::Dark, + ..conditional::State::default() + }; + let mut i = it(&[&format!("--theme={path}")]); + cfg.load_iter(&mut i).unwrap(); + cfg.finalize().unwrap(); + + assert_eq!(cfg.conditional_state.theme, conditional::Theme::Dark); + } + + #[test] + fn theme_priority_is_lower_than_config() { + let td = TempDir::init().unwrap(); + let path = write_theme(&td, "theme", include_str!("testdata/theme_simple")); + + let mut cfg = Config::default(); + let mut i = it(&["--background=#ABCDEF", &format!("--theme={path}")]); + cfg.load_iter(&mut i).unwrap(); + cfg.finalize().unwrap(); + + assert_eq!( + cfg.background, + Color { + r: 0xAB, + g: 0xCD, + b: 0xEF + } + ); + } + + #[test] + fn theme_loading_correct_light_dark() { + let td = TempDir::init().unwrap(); + let light = write_theme(&td, "theme_light", include_str!("testdata/theme_light")); + let dark = write_theme(&td, "theme_dark", include_str!("testdata/theme_dark")); + let arg = format!("--theme=light:{light},dark:{dark}"); + + // Light + { + let mut cfg = Config::default(); + cfg.load_iter(&mut it(&[&arg])).unwrap(); + cfg.finalize().unwrap(); + assert_eq!( + cfg.background, + Color { + r: 0xFF, + g: 0xFF, + b: 0xFF + } + ); + } + + // Dark + { + let mut cfg = Config::default(); + cfg.conditional_state = conditional::State { + theme: conditional::Theme::Dark, + ..conditional::State::default() + }; + cfg.load_iter(&mut it(&[&arg])).unwrap(); + cfg.finalize().unwrap(); + assert_eq!( + cfg.background, + Color { + r: 0xEE, + g: 0xEE, + b: 0xEE + } + ); + } + + // Light to Dark + { + let mut cfg = Config::default(); + cfg.load_iter(&mut it(&[&arg])).unwrap(); + cfg.finalize().unwrap(); + + let new = cfg + .change_conditional_state(conditional::State { + theme: conditional::Theme::Dark, + ..conditional::State::default() + }) + .unwrap() + .unwrap(); + assert_eq!( + new.background, + Color { + r: 0xEE, + g: 0xEE, + b: 0xEE + } + ); + } + } + + #[test] + fn theme_specifying_light_dark_changes_window_theme_from_auto() { + let mut cfg = Config::default(); + let mut i = it(&["--theme=light:foo,dark:bar", "--window-theme=auto"]); + cfg.load_iter(&mut i).unwrap(); + cfg.finalize().unwrap(); + + assert_eq!(cfg.window_theme, WindowTheme::System); + } + + #[test] + fn theme_specifying_light_dark_sets_theme_usage_in_conditional_state() { + let mut cfg = Config::default(); + let mut i = it(&["--theme=light:foo,dark:bar", "--window-theme=auto"]); + cfg.load_iter(&mut i).unwrap(); + cfg.finalize().unwrap(); + + assert_eq!(cfg.window_theme, WindowTheme::System); + assert!(cfg.conditional_set.contains(&conditional::Key::Theme)); + } + + #[test] + fn compatibility_gtk_single_instance_desktop() { + let mut cfg = Config::default(); + cfg.load_iter(&mut it(&["--gtk-single-instance=desktop"])) + .unwrap(); + + // We need to test this BEFORE finalize, because finalize will + // convert our detect to a real value. + assert_eq!(cfg.gtk_single_instance, GtkSingleInstance::Detect); + } + + #[test] + fn compatibility_removed_cursor_invert_fg_bg() { + let mut cfg = Config::default(); + cfg.load_iter(&mut it(&["--cursor-invert-fg-bg"])).unwrap(); + cfg.finalize().unwrap(); + + assert_eq!(cfg.cursor_color, Some(TerminalColor::CellForeground)); + assert_eq!(cfg.cursor_text, Some(TerminalColor::CellBackground)); + } + + #[test] + fn compatibility_removed_selection_invert_fg_bg() { + let mut cfg = Config::default(); + cfg.load_iter(&mut it(&["--selection-invert-fg-bg"])) + .unwrap(); + cfg.finalize().unwrap(); + + assert_eq!( + cfg.selection_foreground, + Some(TerminalColor::CellBackground) + ); + assert_eq!( + cfg.selection_background, + Some(TerminalColor::CellForeground) + ); + } + + #[test] + fn compatibility_removed_bold_is_bright() { + let mut cfg = Config::default(); + cfg.load_iter(&mut it(&["--bold-is-bright"])).unwrap(); + cfg.finalize().unwrap(); + + assert_eq!(cfg.bold_color, Some(BoldColor::Bright)); + } + + #[test] + fn compatibility_window_new_window() { + let mut cfg = Config::default(); + cfg.load_iter(&mut it(&["--macos-dock-drop-behavior=window"])) + .unwrap(); + cfg.finalize().unwrap(); + assert_eq!( + cfg.macos_dock_drop_behavior, + MacOSDockDropBehavior::NewWindow + ); + } + + #[test] + fn value_key_types() { + // Zig test `Value` in key.zig: the key table has the expected + // fields with the expected types. + let c = Config::default(); + assert!(c.font_family.list.is_empty()); + assert!(c.cursor_style_blink.is_none()); + assert!(Config::FIELDS.iter().any(|f| f.name == "font-family")); + } + + #[test] + fn config_file_cycle_detected() { + let td = TempDir::init().unwrap(); + let a = td.path().join("a.conf"); + let b = td.path().join("b.conf"); + std::fs::write(&a, format!("config-file = {}\n", b.display())).unwrap(); + std::fs::write(&b, format!("config-file = {}\n", a.display())).unwrap(); + + let mut cfg = Config::default(); + cfg.load_iter(&mut it(&[&format!("--config-file={}", a.display())])) + .unwrap(); + cfg.expand_paths("/").unwrap(); + cfg.load_recursive_files().unwrap(); + assert!( + cfg.diagnostics + .items() + .iter() + .any(|d| d.message.contains("cycle detected")) + ); + } + + #[test] + fn config_file_optional_missing_is_silent() { + let mut cfg = Config::default(); + cfg.load_iter(&mut it(&["--config-file=?/nonexistent/ghostty/file"])) + .unwrap(); + cfg.expand_paths("/").unwrap(); + cfg.load_recursive_files().unwrap(); + assert!(cfg.diagnostics.empty()); + } + + #[test] + fn default_config_formats_with_docs() { + let cfg = Config::default(); + let f = crate::file_formatter::FileFormatter { + config: &cfg, + docs: true, + changed: false, + }; + let out = f.to_string(); + assert!(out.contains("# The font families to use.")); + assert!(out.contains("\nfont-size = 12\n")); + assert!(out.contains("\nbackground = #282c34\n")); + } +} diff --git a/crates/ghostty-config/src/diagnostics.rs b/crates/ghostty-config/src/diagnostics.rs new file mode 100644 index 0000000..69e1da7 --- /dev/null +++ b/crates/ghostty-config/src/diagnostics.rs @@ -0,0 +1,104 @@ +//! Diagnostic messages produced while parsing configuration / CLI args. + +use std::fmt; + +/// A diagnostic message from parsing. This is used to provide additional +/// human-friendly warnings and errors about the parsed data. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Diagnostic { + pub location: Location, + pub key: String, + pub message: String, +} + +impl Diagnostic { + /// Write the full user-friendly diagnostic message to the writer. + pub fn format(&self, writer: &mut dyn fmt::Write) -> fmt::Result { + match &self.location { + Location::None => {} + Location::Cli(index) => write!(writer, "cli:{index}:")?, + Location::File { path, line } => write!(writer, "{path}:{line}:")?, + } + + if !self.key.is_empty() { + write!(writer, "{}: ", self.key)?; + } else if self.location != Location::None { + write!(writer, " ")?; + } + + write!(writer, "{}", self.message) + } +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.format(f) + } +} + +/// The possible locations for a diagnostic message. This is used +/// to provide context for the message. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Location { + #[default] + None, + Cli(usize), + File { + path: String, + line: usize, + }, +} + +/// The tag of a [`Location`] (Zig: `Location.Key`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LocationKey { + None, + Cli, + File, +} + +impl Location { + #[must_use] + pub fn key(&self) -> LocationKey { + match self { + Location::None => LocationKey::None, + Location::Cli(_) => LocationKey::Cli, + Location::File { .. } => LocationKey::File, + } + } +} + +/// A list of diagnostics. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct DiagnosticList { + list: Vec, +} + +impl DiagnosticList { + pub fn append(&mut self, diag: Diagnostic) { + self.list.push(diag); + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.list.is_empty() + } + + /// Zig: `empty()`. + #[must_use] + pub fn empty(&self) -> bool { + self.list.is_empty() + } + + #[must_use] + pub fn items(&self) -> &[Diagnostic] { + &self.list + } + + /// Returns true if there are any diagnostics for the given + /// location type. + #[must_use] + pub fn contains_location(&self, location: LocationKey) -> bool { + self.list.iter().any(|d| d.location.key() == location) + } +} diff --git a/crates/ghostty-config/src/edit.rs b/crates/ghostty-config/src/edit.rs new file mode 100644 index 0000000..9317b14 --- /dev/null +++ b/crates/ghostty-config/src/edit.rs @@ -0,0 +1,88 @@ +//! Opening the config for editing (port of `config/edit.zig`). + +use crate::file_load; +use std::fs::OpenOptions; +use std::io; +use std::path::Path; + +/// The path to the configuration that should be opened for editing. +/// +/// On Linux, this will use the file at the XDG config path. This is the +/// only valid path for Linux so we don't need to check for other paths. +/// +/// (On macOS both XDG and AppSupport paths are valid; macOS is not +/// supported by this port.) +/// +/// The config file (and its directory) is created if it doesn't exist. +pub fn open_path() -> io::Result { + // Get the path we should open + let config_path = config_path()?; + + // Create config directory recursively. + if let Some(config_dir) = Path::new(&config_path).parent() { + std::fs::create_dir_all(config_dir)?; + } + + // Try to create file and go on if it already exists + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&config_path) + { + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e), + } + + Ok(config_path) +} + +fn xdg_err(e: ghostty_os::xdg::Error) -> io::Error { + io::Error::new(io::ErrorKind::NotFound, e) +} + +/// Returns the config path to use for open for the current OS. +fn config_path() -> io::Result { + let paths = config_path_candidates()?; + + // Find the first path that exists and is non-empty. If no paths are + // non-empty but at least one exists, we will return the first path that + // exists. + let mut exists: Option<&String> = None; + for path in &paths { + let meta = match std::fs::File::open(path).and_then(|f| f.metadata()) { + Ok(m) => m, + // File doesn't exist, continue. + Err(e) if e.kind() == io::ErrorKind::NotFound => continue, + // Some other error, assume it exists and return it. + Err(e) => return Err(e), + }; + + // If the file is non-empty, return it. + if meta.len() > 0 { + return Ok(path.clone()); + } + + // If the file is empty, remember it exists. + if exists.is_none() { + exists = Some(path); + } + } + + // No paths are non-empty, return the first path that exists. + if let Some(v) = exists { + return Ok(v.clone()); + } + + // No paths are non-empty or exist, return the first path. + Ok(paths[0].clone()) +} + +/// Returns a list of possible paths the main config file could be +/// in for the current OS. +fn config_path_candidates() -> io::Result> { + Ok(vec![ + file_load::default_xdg_path().map_err(xdg_err)?, + file_load::legacy_default_xdg_path().map_err(xdg_err)?, + ]) +} diff --git a/crates/ghostty-config/src/error_list.rs b/crates/ghostty-config/src/error_list.rs new file mode 100644 index 0000000..750da27 --- /dev/null +++ b/crates/ghostty-config/src/error_list.rs @@ -0,0 +1,25 @@ +//! Port of `config/ErrorList.zig`. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error { + pub message: String, +} + +/// The list of errors. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ErrorList { + pub list: Vec, +} + +impl ErrorList { + /// True if there are no errors. + #[must_use] + pub fn empty(&self) -> bool { + self.list.is_empty() + } + + /// Add a new error to the list. + pub fn add(&mut self, err: Error) { + self.list.push(err); + } +} diff --git a/crates/ghostty-config/src/file_formatter.rs b/crates/ghostty-config/src/file_formatter.rs new file mode 100644 index 0000000..fd30f8a --- /dev/null +++ b/crates/ghostty-config/src/file_formatter.rs @@ -0,0 +1,103 @@ +//! `FileFormatter` (part of `config/formatter.zig`): outputs the config in a +//! file-like format, identical to Ghostty's `+show-config`. + +use crate::config::Config; +use crate::key::Key; +use crate::macros::{ConfigStruct, help_string}; +use std::fmt; + +/// FileFormatter is a formatter implementation that outputs the +/// config in a file-like format. This uses more generous whitespace, +/// can include comments, etc. +pub struct FileFormatter<'a> { + pub config: &'a Config, + /// Include comments for documentation of each key + pub docs: bool, + /// Only include changed values from the default. + pub changed: bool, +} + +impl<'a> FileFormatter<'a> { + #[must_use] + pub fn new(config: &'a Config) -> Self { + Self { + config, + docs: false, + changed: false, + } + } +} + +impl fmt::Display for FileFormatter<'_> { + fn fmt(&self, writer: &mut fmt::Formatter<'_>) -> fmt::Result { + self.format(writer) + } +} + +impl FileFormatter<'_> { + pub fn format(&self, writer: &mut dyn fmt::Write) -> fmt::Result { + // If we're change-tracking then we need the default config to + // compare against. + let default = if self.changed { + Some(Config::default()) + } else { + None + }; + + for field in Config::FIELDS { + let do_format = match &default { + Some(d) => d.changed(self.config, Key(field.name)), + None => true, + }; + + if do_format { + let do_docs = self.docs && !field.doc.is_empty(); + if do_docs { + let help = help_string(field.doc); + for line in help.split('\n') { + writeln!(writer, "# {line}")?; + } + } + + (field.format)(self.config, field.name, writer)?; + + if do_docs { + writeln!(writer)?; + } + } + } + Ok(()) + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::field_reassign_with_default, + clippy::assert_is_empty +)] +mod tests { + use super::*; + + #[test] + fn format_default_config() { + let cfg = Config::default(); + let mut buf = String::new(); + FileFormatter::new(&cfg).format(&mut buf).unwrap(); + assert!(!buf.is_empty()); + } + + #[test] + fn format_default_config_changed() { + let mut cfg = Config::default(); + cfg.font_size = 42.0; + let mut buf = String::new(); + let f = FileFormatter { + config: &cfg, + docs: false, + changed: true, + }; + f.format(&mut buf).unwrap(); + assert!(buf.contains("font-size = 42\n")); + } +} diff --git a/crates/ghostty-config/src/file_load.rs b/crates/ghostty-config/src/file_load.rs new file mode 100644 index 0000000..8fee51f --- /dev/null +++ b/crates/ghostty-config/src/file_load.rs @@ -0,0 +1,132 @@ +//! Default config file locations and opening (port of `config/file_load.zig`). +//! +//! macOS Application Support paths are not supported (Linux only). + +use ghostty_os::xdg; +use std::fs::File; + +/// Default path for the XDG home configuration file. +pub fn default_xdg_path() -> Result { + xdg::config(&xdg::Options { + subdir: Some("ghostty/config.ghostty"), + home: None, + }) +} + +/// Ghostty <1.3.0 default path for the XDG home configuration file. +pub fn legacy_default_xdg_path() -> Result { + xdg::config(&xdg::Options { + subdir: Some("ghostty/config"), + home: None, + }) +} + +/// Preferred default path for the XDG home configuration file. +pub fn preferred_xdg_path() -> Result { + // If the XDG path exists, use that. + let xdg_path = default_xdg_path()?; + if open(&xdg_path).is_ok() { + return Ok(xdg_path); + } + + // Try the legacy path + let legacy_xdg_path = legacy_default_xdg_path()?; + if open(&legacy_xdg_path).is_ok() { + return Ok(legacy_xdg_path); + } + + // Legacy path and XDG path both don't exist. Return the new one. + Ok(xdg_path) +} + +/// Returns the path to the preferred default configuration file. +/// This is the file where users should place their configuration. +/// +/// This doesn't create or populate the file with any default +/// contents; downstream callers must handle this. +pub fn preferred_default_file_path() -> Result { + // All supported platforms use XDG only + preferred_xdg_path() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OpenFileError { + FileNotFound, + FileIsEmpty, + FileOpenFailed, + NotAFile, +} + +impl std::fmt::Display for OpenFileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + OpenFileError::FileNotFound => "FileNotFound", + OpenFileError::FileIsEmpty => "FileIsEmpty", + OpenFileError::FileOpenFailed => "FileOpenFailed", + OpenFileError::NotAFile => "NotAFile", + }) + } +} + +impl std::error::Error for OpenFileError {} + +/// Opens the file at the given path and returns the file handle +/// if it exists and is non-empty. This also constrains the possible +/// errors to a smaller set that we can explicitly handle. +pub fn open(path: &str) -> Result { + assert!(std::path::Path::new(path).is_absolute()); + + let file = File::open(path).map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + OpenFileError::FileNotFound + } else { + log::warn!("unexpected file open error path={path} err={err}"); + OpenFileError::FileOpenFailed + } + })?; + + let meta = file.metadata().map_err(|err| { + log::warn!("error getting file stat path={path} err={err}"); + OpenFileError::FileOpenFailed + })?; + if !meta.is_file() { + return Err(OpenFileError::NotAFile); + } + if meta.len() == 0 { + return Err(OpenFileError::FileIsEmpty); + } + + Ok(file) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn open_missing_dir_and_empty() { + assert_eq!( + open("/nonexistent/ghostty/file").unwrap_err(), + OpenFileError::FileNotFound + ); + assert_eq!(open("/tmp").unwrap_err(), OpenFileError::NotAFile); + let p = std::env::temp_dir().join(format!("ghostty-file-load-{}", std::process::id())); + std::fs::write(&p, "").unwrap(); + assert_eq!( + open(p.to_str().unwrap()).unwrap_err(), + OpenFileError::FileIsEmpty + ); + std::fs::write(&p, "a=b").unwrap(); + assert!(open(p.to_str().unwrap()).is_ok()); + std::fs::remove_file(&p).unwrap(); + } + + #[test] + fn default_paths_end_with_expected_names() { + if let (Ok(a), Ok(b)) = (default_xdg_path(), legacy_default_xdg_path()) { + assert!(a.ends_with("ghostty/config.ghostty")); + assert!(b.ends_with("ghostty/config")); + } + } +} diff --git a/crates/ghostty-config/src/font_types.rs b/crates/ghostty-config/src/font_types.rs new file mode 100644 index 0000000..111f834 --- /dev/null +++ b/crates/ghostty-config/src/font_types.rs @@ -0,0 +1,203 @@ +//! Local ports of the pieces of the Zig font package that the config needs +//! (`font/face.zig` `Variation`, `font/CodepointMap.zig`, and +//! `font/discovery.zig` `Descriptor`). `ghostty-font` depends on this crate, +//! so these live here and the font crate should re-use them. + +use std::hash::{Hash, Hasher}; + +/// A font variation axis setting. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Variation { + pub id: VariationId, + pub value: f64, +} + +/// A 4-byte variation axis tag (Zig: `Variation.Id`, a packed u32 where +/// `a` is the most significant byte). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct VariationId(pub u32); + +impl VariationId { + #[must_use] + pub fn init(v: &[u8; 4]) -> Self { + Self(u32::from_be_bytes(*v)) + } + + /// Converts the ID to its 4 byte string form. + #[must_use] + pub fn str(self) -> [u8; 4] { + self.0.to_be_bytes() + } +} + +/// Descriptor is used to search for fonts. The only required field +/// is "family". The rest are ignored unless they're set to a non-zero +/// value. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Descriptor { + /// Font family to search for. This can be a fully qualified font + /// name such as "Fira Code", "monospace", "serif", etc. + /// + /// On systems that use fontconfig (Linux), this can be a full + /// fontconfig pattern, such as "Fira Code-14:bold". + pub family: Option, + + /// Specific font style to search for. This will filter the style + /// string the font advertises. The "bold/italic" booleans later in this + /// struct filter by the style trait the font has, not the string, so + /// these can be used in conjunction or not. + pub style: Option, + + /// A codepoint that this font must be able to render. + pub codepoint: u32, + + /// Font size in points that the font should support. For conversion + /// to pixels, we will use 72 DPI for Mac and 96 DPI for everything else. + /// (If pixel conversion is necessary, i.e. emoji fonts) + pub size: f32, + + /// True if we want to search specifically for a font that supports + /// specific styles. + pub bold: bool, + pub italic: bool, + pub monospace: bool, + + /// Variation axes to apply to the font. This also impacts searching + /// for fonts since fonts with the ability to set these variations + /// will be preferred, but not guaranteed. + pub variations: Vec, +} + +impl Hash for Descriptor { + fn hash(&self, state: &mut H) { + self.family.hash(state); + self.style.hash(state); + self.codepoint.hash(state); + self.size.to_bits().hash(state); + self.bold.hash(state); + self.italic.hash(state); + self.monospace.hash(state); + self.variations.len().hash(state); + for v in &self.variations { + v.id.hash(state); + // Not exact, but we don't depend on the hash being different + // based on decimal values of variations (same as Zig). + (v.value as i64).hash(state); + } + } +} + +impl Descriptor { + /// A hash code that can be used to identify this descriptor. + #[must_use] + pub fn hashcode(&self) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.hash(&mut h); + h.finish() + } +} + +/// One entry of a [`CodepointMap`]. +#[derive(Debug, Clone, PartialEq)] +pub struct CodepointMapEntry { + /// Unicode codepoint range (inclusive). Invariant: range[0] <= range[1]. + pub range: [u32; 2], + /// The discovery descriptor of the font to use for this range. + pub descriptor: Descriptor, +} + +/// CodepointMap is a map of codepoints to a discovery descriptor of a font +/// to use for that codepoint. If the descriptor doesn't return any matching +/// font, the codepoint is rendered using the default font. +/// +/// Note: we just do a linear search because we expect to always have very +/// few entries. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct CodepointMap { + pub list: Vec, +} + +impl CodepointMap { + /// Add an entry to the map. + /// + /// For conflicting codepoints, entries added later take priority over + /// entries added earlier. + pub fn add(&mut self, entry: CodepointMapEntry) { + assert!(entry.range[0] <= entry.range[1]); + self.list.push(entry); + } + + /// Get a descriptor for a codepoint. + #[must_use] + pub fn get(&self, cp: u32) -> Option<&Descriptor> { + self.list + .iter() + .rev() + .find(|e| e.range[0] <= cp && cp <= e.range[1]) + .map(|e| &e.descriptor) + } + + /// Returns a hash code that can be used to uniquely identify this map. + #[must_use] + pub fn hashcode(&self) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.list.len().hash(&mut h); + for e in &self.list { + e.range.hash(&mut h); + e.descriptor.hash(&mut h); + } + h.finish() + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn variation_id_wght_should_be_2003265652() { + let id = VariationId::init(b"wght"); + assert_eq!(id.0, 2003265652); + assert_eq!(&id.str(), b"wght"); + } + + #[test] + fn variation_id_slnt_should_be_1936486004() { + let id = VariationId::init(b"slnt"); + assert_eq!(id.0, 1936486004); + assert_eq!(&id.str(), b"slnt"); + } + + fn e(a: u32, b: u32, fam: &str) -> CodepointMapEntry { + CodepointMapEntry { + range: [a, b], + descriptor: Descriptor { + family: Some(fam.into()), + ..Descriptor::default() + }, + } + } + + #[test] + fn codepointmap() { + let mut m = CodepointMap::default(); + assert!(m.get(1).is_none()); + m.add(e(1, 1, "A")); + assert_eq!(m.get(1).unwrap().family.as_deref(), Some("A")); + + // Later entry takes priority + m.add(e(1, 2, "B")); + assert_eq!(m.get(1).unwrap().family.as_deref(), Some("B")); + + // Non-matching + assert!(m.get(0).is_none()); + assert!(m.get(3).is_none()); + + m.add(e(3, 4, "C")); + m.add(e(5, 6, "D")); + assert_eq!(m.get(3).unwrap().family.as_deref(), Some("C")); + assert_eq!(m.get(6).unwrap().family.as_deref(), Some("D")); + assert_eq!(m.get(2).unwrap().family.as_deref(), Some("B")); + } +} diff --git a/crates/ghostty-config/src/formatter.rs b/crates/ghostty-config/src/formatter.rs new file mode 100644 index 0000000..36e35e0 --- /dev/null +++ b/crates/ghostty-config/src/formatter.rs @@ -0,0 +1,96 @@ +//! Config formatting (port of `config/formatter.zig`). Output matches +//! Ghostty's `+show-config`. + +use crate::cli_args::ConfigField; +use std::fmt; + +/// A single entry formatter for the given field name and writer. +pub struct EntryFormatter<'a> { + pub name: &'a str, + pub writer: &'a mut dyn fmt::Write, +} + +impl<'a> EntryFormatter<'a> { + pub fn new(name: &'a str, writer: &'a mut dyn fmt::Write) -> Self { + Self { name, writer } + } + + /// Format a value with this formatter's name. + pub fn format_entry(&mut self, value: &T) -> fmt::Result { + value.format_entry(self) + } +} + +/// Format a single value with the given name. +pub fn format_entry( + name: &str, + value: &T, + writer: &mut dyn fmt::Write, +) -> fmt::Result { + let mut f = EntryFormatter::new(name, writer); + value.format_entry(&mut f) +} + +/// `void` values (Zig) print as `name = \n`. +pub fn format_void(name: &str, writer: &mut dyn fmt::Write) -> fmt::Result { + writeln!(writer, "{name} = ") +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::config_packed; + + fn fe(name: &str, v: &T) -> String { + let mut s = String::new(); + format_entry(name, v, &mut s).unwrap(); + s + } + + #[test] + fn format_entry_bool() { + assert_eq!(fe("a", &true), "a = true\n"); + assert_eq!(fe("a", &false), "a = false\n"); + } + + #[test] + fn format_entry_int() { + assert_eq!(fe("a", &123u8), "a = 123\n"); + } + + #[test] + fn format_entry_float() { + assert_eq!(fe("a", &0.7f64), "a = 0.7\n"); + } + + #[test] + fn format_entry_enum() { + crate::config_enum! { enum E { One => "one", Two => "two", Three => "three" } } + assert_eq!(fe("a", &E::Two), "a = two\n"); + } + + #[test] + fn format_entry_void() { + let mut s = String::new(); + format_void("a", &mut s).unwrap(); + assert_eq!(s, "a = \n"); + } + + #[test] + fn format_entry_optional() { + assert_eq!(fe("a", &None::), "a = \n"); + assert_eq!(fe("a", &Some(false)), "a = false\n"); + } + + #[test] + fn format_entry_string() { + assert_eq!(fe("a", &"hello".to_string()), "a = hello\n"); + } + + #[test] + fn format_entry_packed_struct() { + config_packed! { struct V { "one" => one: true, "two" => two: false } } + assert_eq!(fe("a", &V::default()), "a = one,no-two\n"); + } +} diff --git a/crates/ghostty-config/src/input_fields.rs b/crates/ghostty-config/src/input_fields.rs new file mode 100644 index 0000000..9c2d650 --- /dev/null +++ b/crates/ghostty-config/src/input_fields.rs @@ -0,0 +1,83 @@ +//! `ConfigField` implementations for types owned by `ghostty-input` +//! (the input crate cannot depend on the config crate). + +use crate::cli_args::{ArgError, ConfigField}; +use crate::formatter::EntryFormatter; +use ghostty_input::{KeyRemapSet, OptionAsAlt}; +use std::fmt; + +impl ConfigField for KeyRemapSet { + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { + self.parse_cli(value).map_err(|_| ArgError::InvalidValue) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + let entries = self.format_entries(); + if entries.is_empty() { + return writeln!(f.writer, "{} = ", f.name); + } + for e in entries { + f.format_entry(&e)?; + } + Ok(()) + } +} + +impl ConfigField for OptionAsAlt { + const VALID_VALUES: Option<&'static [&'static str]> = None; + + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { + let v = value.ok_or(ArgError::ValueRequired)?; + *self = match v { + "false" => OptionAsAlt::False, + "true" => OptionAsAlt::True, + "left" => OptionAsAlt::Left, + "right" => OptionAsAlt::Right, + _ => return Err(ArgError::InvalidValue), + }; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + let s = match self { + OptionAsAlt::False => "false", + OptionAsAlt::True => "true", + OptionAsAlt::Left => "left", + OptionAsAlt::Right => "right", + }; + writeln!(f.writer, "{} = {}", f.name, s) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn fe(name: &str, v: &T) -> String { + let mut s = String::new(); + crate::formatter::format_entry(name, v, &mut s).unwrap(); + s + } + + #[test] + fn remap_set_format_entry_empty() { + assert_eq!(fe("key-remap", &KeyRemapSet::default()), "key-remap = \n"); + } + + #[test] + fn remap_set_format_entry_values() { + let mut set = KeyRemapSet::default(); + ConfigField::parse_cli(&mut set, Some("ctrl=super")).unwrap(); + let out = fe("key-remap", &set); + assert!(out.starts_with("key-remap = left_ctrl=left_super\n")); + } + + #[test] + fn option_as_alt_roundtrip() { + let mut v = OptionAsAlt::default(); + v.parse_cli(Some("left")).unwrap(); + assert_eq!(fe("a", &v), "a = left\n"); + assert_eq!(v.parse_cli(Some("x")), Err(ArgError::InvalidValue)); + } +} diff --git a/crates/ghostty-config/src/io.rs b/crates/ghostty-config/src/io.rs new file mode 100644 index 0000000..160015d --- /dev/null +++ b/crates/ghostty-config/src/io.rs @@ -0,0 +1,158 @@ +//! Readable IO config values (port of `config/io.zig`). + +use crate::cli_args::{ArgError, ConfigField, split_tagged_union}; +use crate::formatter::EntryFormatter; +use crate::string; +use std::fmt; + +/// ReadableIO is some kind of IO source that is readable. +/// +/// It can be either a direct string or a filepath. The filepath will +/// be deferred and read later, so it won't be checked for existence +/// or readability at configuration time. This allows using a path that +/// might be produced in an intermediate state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReadableIO { + Raw(String), + Path(String), +} + +impl Default for ReadableIO { + fn default() -> Self { + ReadableIO::Raw(String::new()) + } +} + +impl ReadableIO { + /// Same as clone but also parses the values as Zig strings in + /// the final resulting value all at once. + #[must_use] + pub fn clone_parsed(&self) -> Self { + // Parsing can't fail because we validate it in parse_cli + match self { + ReadableIO::Raw(v) => ReadableIO::Raw(string::parse(v).unwrap_or_default()), + ReadableIO::Path(v) => ReadableIO::Path(string::parse(v).unwrap_or_default()), + } + } +} + +impl ConfigField for ReadableIO { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let input = input.ok_or(ArgError::ValueRequired)?; + if input.is_empty() { + return Err(ArgError::ValueRequired); + } + + // We only validate that string parsing works. We store the value + // as raw so that our formatting can recreate it. + string::parse(input)?; + + // Next, parse the tagged union using normal rules. Invalid values in + // the tagged union are interpreted as raw values. + let (tag, rest) = split_tagged_union(input); + *self = match tag { + "raw" => ReadableIO::Raw(rest.to_string()), + "path" => ReadableIO::Path(rest.to_string()), + _ => ReadableIO::Raw(input.to_string()), + }; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + let (tag, v) = match self { + ReadableIO::Raw(v) => ("raw", v), + ReadableIO::Path(v) => ("path", v), + }; + writeln!(f.writer, "{} = {tag}:{v}", f.name) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RepeatableReadableIO { + pub list: Vec, +} + +impl RepeatableReadableIO { + /// See [`ReadableIO::clone_parsed`]. + #[must_use] + pub fn clone_parsed(&self) -> Self { + Self { + list: self.list.iter().map(ReadableIO::clone_parsed).collect(), + } + } +} + +impl ConfigField for RepeatableReadableIO { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let value = input.ok_or(ArgError::ValueRequired)?; + + // Empty value resets the list + if value.is_empty() { + self.list.clear(); + return Ok(()); + } + + let mut io = ReadableIO::default(); + io.parse_cli(Some(value))?; + self.list.push(io); + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + if self.list.is_empty() { + return writeln!(f.writer, "{} = ", f.name); + } + for value in &self.list { + value.format_entry(f)?; + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::formatter::format_entry; + + #[test] + fn readable_io_parse_cli() { + let mut io = ReadableIO::default(); + io.parse_cli(Some("foo")).unwrap(); + assert_eq!(io, ReadableIO::Raw("foo".into())); + io.parse_cli(Some("raw:foo")).unwrap(); + assert_eq!(io, ReadableIO::Raw("foo".into())); + io.parse_cli(Some("path:foo")).unwrap(); + assert_eq!(io, ReadableIO::Path("foo".into())); + } + + #[test] + fn readable_io_format_entry() { + let mut v = ReadableIO::default(); + v.parse_cli(Some("raw:foo")).unwrap(); + let mut s = String::new(); + format_entry("a", &v, &mut s).unwrap(); + assert_eq!(s, "a = raw:foo\n"); + } + + #[test] + fn repeatable_readable_io_parse_cli() { + let mut list = RepeatableReadableIO::default(); + list.parse_cli(Some("raw:A")).unwrap(); + list.parse_cli(Some("path:B")).unwrap(); + assert_eq!(list.list.len(), 2); + + list.parse_cli(Some("")).unwrap(); + assert_eq!(list.list.len(), 0); + } + + #[test] + fn readable_io_errors_and_clone_parsed() { + let mut io = ReadableIO::default(); + assert_eq!(io.parse_cli(None), Err(ArgError::ValueRequired)); + assert_eq!(io.parse_cli(Some("")), Err(ArgError::ValueRequired)); + assert!(io.parse_cli(Some("\\q")).is_err()); + io.parse_cli(Some("raw:a\\nb")).unwrap(); + assert_eq!(io.clone_parsed(), ReadableIO::Raw("a\nb".into())); + } +} diff --git a/crates/ghostty-config/src/key.rs b/crates/ghostty-config/src/key.rs new file mode 100644 index 0000000..3db4580 --- /dev/null +++ b/crates/ghostty-config/src/key.rs @@ -0,0 +1,17 @@ +//! Config keys (port of `config/key.zig`). +//! +//! Zig builds an enum of all config fields via comptime. In Rust a [`Key`] is +//! the (static) field name; constants for every field are in [`crate::keys`]. + +/// The name of a configuration field, e.g. `Key("font-size")`. Valid +/// instances are available as constants in [`crate::keys`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Key(pub &'static str); + +impl Key { + /// The user-facing name of this key. + #[must_use] + pub fn name(&self) -> &'static str { + self.0 + } +} diff --git a/crates/ghostty-config/src/keybinds.rs b/crates/ghostty-config/src/keybinds.rs new file mode 100644 index 0000000..c979ce8 --- /dev/null +++ b/crates/ghostty-config/src/keybinds.rs @@ -0,0 +1,835 @@ +//! Keybinds (port of `Config.Keybinds` from `config/Config.zig`). + +use crate::cli_args::{ArgError, ConfigField}; +use crate::formatter::EntryFormatter; +use ghostty_input::binding::set::EntryFormatter as BindingEntryFormatter; +use ghostty_input::binding::set::Value; +use ghostty_input::binding::{ + Action, AdjustSelection, CloseTabMode, CopyToClipboard, Flags, InspectorMode, Set, + SplitDirection, SplitFocusDirection, SplitResizeDirection, SplitResizeParameter, Trigger, + TriggerKey, WriteScreen, +}; +use ghostty_input::{Key, Mods, ctrl_or_super}; +use std::fmt; + +/// The most recent binding target for `chain=` additions. +/// +/// This is intentionally tracked at the Keybinds level so that chains can +/// apply across table boundaries according to parse order. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum ChainTarget { + #[default] + Root, + Table(String), +} + +#[derive(Debug, Clone, Default)] +pub struct Keybinds { + pub set: Set, + + /// Defined key tables. The default key table is always the root "set", + /// which allows all table names to be available without reservation. + /// Insertion ordered. + pub tables: Vec<(String, Set)>, + + /// The most recent binding target for `chain=` additions. + pub chain_target: ChainTarget, +} + +fn mods(ctrl: bool, shift: bool, alt: bool, super_: bool) -> Mods { + Mods { + ctrl, + shift, + alt, + super_, + ..Mods::default() + } +} + +fn uni(c: char, mods: Mods) -> Trigger { + Trigger { + key: TriggerKey::Unicode(c as u32), + mods, + } +} + +fn phys(key: Key, mods: Mods) -> Trigger { + Trigger { + key: TriggerKey::Physical(key), + mods, + } +} + +fn performable() -> Flags { + Flags { + performable: true, + ..Flags::default() + } +} + +fn to_arg_error(e: ghostty_input::binding::Error) -> ArgError { + match e { + ghostty_input::binding::Error::InvalidFormat => ArgError::other("InvalidFormat"), + ghostty_input::binding::Error::InvalidAction => ArgError::other("InvalidAction"), + } +} + +impl Keybinds { + /// Reset to the default keybinds (Zig: `init`). Linux/GTK variant. + #[allow(clippy::too_many_lines)] + pub fn init(&mut self) { + self.set = Set::default(); + self.tables = Vec::new(); + self.chain_target = ChainTarget::Root; + + let none = Mods::default(); + let cs = mods(true, true, false, false); + let set = &mut self.set; + + // keybinds for opening and reloading config + set.put( + uni(',', ctrl_or_super(mods(false, true, false, false))), + Action::ReloadConfig, + ); + set.put(uni(',', ctrl_or_super(none)), Action::OpenConfig); + + { + set.put( + phys(Key::Copy, none), + Action::CopyToClipboard(CopyToClipboard::Mixed), + ); + set.put(phys(Key::Paste, none), Action::PasteFromClipboard); + + // On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an + // alt keybinding for Copy and shift+ins is an alt keybinding for Paste + // + // The order of these blocks is important. The *last* added keybind for a given action is + // what will display in the menu. We want the more typical keybinds after this block to be + // the standard + set.put( + phys(Key::Insert, mods(true, false, false, false)), + Action::CopyToClipboard(CopyToClipboard::Mixed), + ); + set.put( + phys(Key::Insert, mods(false, true, false, false)), + Action::PasteFromClipboard, + ); + + // On macOS we default to super but Linux ctrl+shift since + // ctrl+c is to kill the process. + set.put_flags( + uni('c', cs), + Action::CopyToClipboard(CopyToClipboard::Mixed), + performable(), + ); + set.put_flags(uni('v', cs), Action::PasteFromClipboard, performable()); + } + + // Increase font size mapping for keyboards with dedicated plus keys (like german) + // Note: this order matters below because the C API will only return + // the last keybinding for a given action. The macOS app uses this to + // set the expected keybind for the menu. + set.put(uni('=', ctrl_or_super(none)), Action::IncreaseFontSize(1.0)); + set.put(uni('+', ctrl_or_super(none)), Action::IncreaseFontSize(1.0)); + set.put(uni('-', ctrl_or_super(none)), Action::DecreaseFontSize(1.0)); + set.put(uni('0', ctrl_or_super(none)), Action::ResetFontSize); + + set.put( + uni('j', mods(true, true, false, true)), + Action::WriteScreenFile(WriteScreen::COPY), + ); + set.put( + uni('j', ctrl_or_super(mods(false, true, false, false))), + Action::WriteScreenFile(WriteScreen::PASTE), + ); + set.put( + uni('j', ctrl_or_super(mods(false, true, true, false))), + Action::WriteScreenFile(WriteScreen::OPEN), + ); + + // Expand Selection + let shift = mods(false, true, false, false); + for (k, a) in [ + (Key::ArrowLeft, AdjustSelection::Left), + (Key::ArrowRight, AdjustSelection::Right), + (Key::ArrowUp, AdjustSelection::Up), + (Key::ArrowDown, AdjustSelection::Down), + (Key::PageUp, AdjustSelection::PageUp), + (Key::PageDown, AdjustSelection::PageDown), + (Key::Home, AdjustSelection::Home), + (Key::End, AdjustSelection::End), + ] { + set.put_flags(phys(k, shift), Action::AdjustSelection(a), performable()); + } + + // Tabs common to all platforms + set.put(phys(Key::Tab, cs), Action::PreviousTab); + set.put( + phys(Key::Tab, mods(true, false, false, false)), + Action::NextTab, + ); + + // Windowing + set.put(uni('n', cs), Action::NewWindow); + set.put(uni('w', cs), Action::CloseSurface); + set.put(uni('q', cs), Action::Quit); + set.put( + phys(Key::F4, mods(false, false, true, false)), + Action::CloseWindow, + ); + set.put(uni('t', cs), Action::NewTab); + set.put(uni('w', cs), Action::CloseTab(CloseTabMode::This)); + set.put_flags(phys(Key::ArrowLeft, cs), Action::PreviousTab, performable()); + set.put_flags(phys(Key::ArrowRight, cs), Action::NextTab, performable()); + let ctrl = mods(true, false, false, false); + set.put_flags(phys(Key::PageUp, ctrl), Action::PreviousTab, performable()); + set.put_flags(phys(Key::PageDown, ctrl), Action::NextTab, performable()); + set.put(uni('o', cs), Action::NewSplit(SplitDirection::Right)); + set.put(uni('e', cs), Action::NewSplit(SplitDirection::Down)); + let ctrl_super = mods(true, false, false, true); + set.put_flags( + uni('[', ctrl_super), + Action::GotoSplit(SplitFocusDirection::Previous), + performable(), + ); + set.put_flags( + uni(']', ctrl_super), + Action::GotoSplit(SplitFocusDirection::Next), + performable(), + ); + let ctrl_alt = mods(true, false, true, false); + for (k, d) in [ + (Key::ArrowUp, SplitFocusDirection::Up), + (Key::ArrowDown, SplitFocusDirection::Down), + (Key::ArrowLeft, SplitFocusDirection::Left), + (Key::ArrowRight, SplitFocusDirection::Right), + ] { + set.put_flags(phys(k, ctrl_alt), Action::GotoSplit(d), performable()); + } + + // Resizing splits + let super_ctrl_shift = mods(true, true, false, true); + for (k, d) in [ + (Key::ArrowUp, SplitResizeDirection::Up), + (Key::ArrowDown, SplitResizeDirection::Down), + (Key::ArrowLeft, SplitResizeDirection::Left), + (Key::ArrowRight, SplitResizeDirection::Right), + ] { + set.put_flags( + phys(k, super_ctrl_shift), + Action::ResizeSplit(SplitResizeParameter(d, 10)), + performable(), + ); + } + + // Viewport scrolling + set.put(phys(Key::Home, shift), Action::ScrollToTop); + set.put(phys(Key::End, shift), Action::ScrollToBottom); + set.put(phys(Key::PageUp, shift), Action::ScrollPageUp); + set.put(phys(Key::PageDown, shift), Action::ScrollPageDown); + + // Semantic prompts + set.put(phys(Key::PageUp, cs), Action::JumpToPrompt(-1)); + set.put(phys(Key::PageDown, cs), Action::JumpToPrompt(1)); + + // Search + set.put_flags(uni('f', cs), Action::StartSearch, performable()); + set.put_flags(phys(Key::Escape, none), Action::EndSearch, performable()); + + // Inspector, matching Chromium + set.put(uni('i', cs), Action::Inspector(InspectorMode::Toggle)); + + // Terminal + set.put(uni('a', cs), Action::SelectAll); + + // Selection clipboard paste + set.put(phys(Key::Insert, shift), Action::PasteFromSelection); + + { + // On macOS we default to super but everywhere else + // is alt. + let m = mods(false, false, true, false); + + // Cmd/Alt+N for goto tab N + let digits = [ + Key::Digit1, + Key::Digit2, + Key::Digit3, + Key::Digit4, + Key::Digit5, + Key::Digit6, + Key::Digit7, + Key::Digit8, + ]; + for (i, key) in digits.into_iter().enumerate() { + let n = i + 1; + let c = char::from(b'0' + n as u8); + // We register BOTH the physical `digit_N` key and the unicode + // `N` key. This allows most keyboard layouts to work with + // this shortcut. Namely, AZERTY doesn't produce unicode `N` + // for their digit keys (they're on shifted keys on the same + // physical keys). + set.put_flags(phys(key, m), Action::GotoTab(n), performable()); + // Important: this must be the LAST binding set so that the + // libghostty trigger API returns this one for the action, + // so that things like the macOS tab bar key equivalent label + // work properly. + set.put_flags(uni(c, m), Action::GotoTab(n), performable()); + } + set.put_flags(uni('9', m), Action::LastTab, performable()); + } + + // Toggle fullscreen + set.put( + phys(Key::Enter, ctrl_or_super(none)), + Action::ToggleFullscreen, + ); + + // Toggle zoom a split + set.put( + phys(Key::Enter, ctrl_or_super(shift)), + Action::ToggleSplitZoom, + ); + + // Toggle command palette, matches VSCode + set.put(uni('p', ctrl_or_super(shift)), Action::ToggleCommandPalette); + } + + fn table_index(&self, name: &str) -> Option { + self.tables.iter().position(|(n, _)| n == name) + } + + /// Get a table by name. + #[must_use] + pub fn table(&self, name: &str) -> Option<&Set> { + self.tables.iter().find(|(n, _)| n == name).map(|(_, s)| s) + } + + /// Like format_entry but has an option to include docs. + pub fn format_entry_docs(&self, f: &mut EntryFormatter<'_>, docs: bool) -> fmt::Result { + if self.set.bindings.is_empty() && self.tables.is_empty() { + return writeln!(f.writer, "{} = ", f.name); + } + + // Format root set bindings + for (k, v) in &self.set.bindings { + if docs { + // Zig looks up help by `@tagName(v)` of the set value + // (leader/leaf/leaf_chained), which never matches a keybind + // action name, so only the blank line is ever written. + f.writer.write_str("\n")?; + } + + let mut buf = format!("{}", k.0); + emit(v, &mut buf, f)?; + } + + // Format table bindings + for (table_name, table_set) in &self.tables { + for (k, v) in &table_set.bindings { + let mut buf = format!("{}/{}", table_name, k.0); + emit(v, &mut buf, f)?; + } + } + Ok(()) + } +} + +struct Adapter<'a, 'b> { + f: &'a mut EntryFormatter<'b>, + result: fmt::Result, +} + +impl BindingEntryFormatter for Adapter<'_, '_> { + fn format_entry(&mut self, value: &str) { + if self.result.is_ok() { + self.result = self.f.format_entry(&value.to_string()); + } + } +} + +fn emit(v: &Value, buf: &mut String, f: &mut EntryFormatter<'_>) -> fmt::Result { + let mut adapter = Adapter { f, result: Ok(()) }; + v.format_entries(buf, &mut adapter); + adapter.result +} + +fn equal_set(a: &Set, b: &Set) -> bool { + // Two keybinds are considered equal if their primary bindings + // are the same. We don't compare reverse mappings and such. + if a.bindings.len() != b.bindings.len() { + return false; + } + + for (k, av) in &a.bindings { + let Some(bv) = b.bindings.get(k) else { + return false; + }; + match (av, bv) { + (Value::Leader(x), Value::Leader(y)) => { + if !equal_set(x, y) { + return false; + } + } + (Value::Leaf(x), Value::Leaf(y)) => { + if x != y { + return false; + } + } + (Value::LeafChained(x), Value::LeafChained(y)) => { + if x.flags != y.flags || x.actions != y.actions { + return false; + } + } + _ => return false, + } + } + true +} + +impl PartialEq for Keybinds { + /// Compare if two of our value are equal (Zig: `equal`). + fn eq(&self, other: &Self) -> bool { + if !equal_set(&self.set, &other.set) { + return false; + } + + // Compare tables + if self.tables.len() != other.tables.len() { + return false; + } + for (name, set) in &self.tables { + let Some(other_set) = other.table(name) else { + return false; + }; + if !equal_set(set, other_set) { + return false; + } + } + true + } +} + +impl ConfigField for Keybinds { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let value = input.ok_or(ArgError::ValueRequired)?; + + // Check for special values + if value.is_empty() { + log::info!("config has 'keybind =', using default keybinds"); + self.init(); + return Ok(()); + } + + if value == "clear" { + log::info!("config has 'keybind = clear', all keybinds cleared"); + self.set = Set::default(); + self.tables = Vec::new(); + self.chain_target = ChainTarget::Root; + return Ok(()); + } + + // Check for table syntax: "name/" or "name/binding" + // We look for '/' only before the first '=' to avoid matching + // action arguments like "foo=text:/hello". + let eq_idx = value.find('=').unwrap_or(value.len()); + if let Some(slash_idx) = value[..eq_idx].find('/') { + let table_name = &value[..slash_idx]; + + // Length zero is valid, so you can set `/=action` for the slash key. + // Ignore '+', '>' because they can be part of sequences and + // triggers. This lets things like `ctrl+/=action` work. + if !table_name.is_empty() && !table_name.contains(['+', '>']) { + let binding = &value[slash_idx + 1..]; + + // Get or create the table + let idx = if let Some(i) = self.table_index(table_name) { + i + } else { + self.tables.push((table_name.to_string(), Set::default())); + self.tables.len() - 1 + }; + + // If there's no binding after the slash, this is a table + // definition/clear command + if binding.is_empty() { + log::debug!("config has 'keybind = {table_name}/', table cleared"); + self.tables[idx].1 = Set::default(); + self.chain_target = ChainTarget::Root; + return Ok(()); + } + + // Chains are only allowed at the root level. Their target is + // tracked globally by parse order in `self.chain_target`. + if binding.starts_with("chain=") { + return Err(ArgError::other("InvalidFormat")); + } + + // Parse and add the binding to the table + self.tables[idx] + .1 + .parse_and_put(binding) + .map_err(to_arg_error)?; + self.chain_target = ChainTarget::Table(table_name.to_string()); + return Ok(()); + } + } + + if value.starts_with("chain=") { + match self.chain_target.clone() { + ChainTarget::Root => self.set.parse_and_put(value).map_err(to_arg_error)?, + ChainTarget::Table(table_name) => { + let Some(idx) = self.table_index(&table_name) else { + self.chain_target = ChainTarget::Root; + return Err(ArgError::other("InvalidFormat")); + }; + self.tables[idx] + .1 + .parse_and_put(value) + .map_err(to_arg_error)?; + } + } + return Ok(()); + } + + // Parse into default set + self.set.parse_and_put(value).map_err(to_arg_error)?; + self.chain_target = ChainTarget::Root; + Ok(()) + } + + /// An empty value resets to the default keybinds (Zig: `init`). + fn reset(&mut self, _default: Self) { + self.init(); + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + self.format_entry_docs(f, false) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn fmt_kb(name: &str, kb: &Keybinds) -> String { + let mut s = String::new(); + crate::formatter::format_entry(name, kb, &mut s).unwrap(); + s + } + + fn parsed(inputs: &[&str]) -> Keybinds { + let mut kb = Keybinds::default(); + for i in inputs { + kb.parse_cli(Some(i)).unwrap(); + } + kb + } + + #[test] + fn parse_cli() { + let mut set = Keybinds::default(); + set.parse_cli(Some("shift+a=copy_to_clipboard")).unwrap(); + set.parse_cli(Some("shift+a=csi:hello")).unwrap(); + } + + #[test] + fn format_config_single() { + let list = parsed(&["shift+a=csi:hello"]); + assert_eq!(fmt_kb("a", &list), "a = shift+a=csi:hello\n"); + } + + // Regression test for https://github.com/ghostty-org/ghostty/issues/2734 + #[test] + fn format_config_multiple_items() { + let list = parsed(&["ctrl+z>1=goto_tab:1", "ctrl+z>2=goto_tab:2"]); + // Note they turn into translated keys because they match + // their ASCII mapping. + assert_eq!( + fmt_kb("keybind", &list), + "keybind = ctrl+z>1=goto_tab:1\nkeybind = ctrl+z>2=goto_tab:2\n" + ); + } + + #[test] + fn format_config_multiple_items_nested() { + let list = parsed(&[ + "ctrl+a>ctrl+b>n=new_window", + "ctrl+a>ctrl+b>w=close_window", + "ctrl+a>ctrl+c>t=new_tab", + "ctrl+b>ctrl+d>a=previous_tab", + ]); + // NB: This does not currently retain the order of the keybinds. + let want = "a = ctrl+a>ctrl+b>n=new_window\n\ + a = ctrl+a>ctrl+b>w=close_window\n\ + a = ctrl+a>ctrl+c>t=new_tab\n\ + a = ctrl+b>ctrl+d>a=previous_tab\n"; + assert_eq!(fmt_kb("a", &list), want); + } + + #[test] + fn parse_cli_table_definition() { + let kb = parsed(&["foo/shift+a=copy_to_clipboard"]); + assert_eq!(kb.tables.len(), 1); + assert!(kb.table("foo").is_some()); + assert_eq!(kb.table("foo").unwrap().bindings.len(), 1); + } + + #[test] + fn parse_cli_table_clear() { + let mut kb = parsed(&["foo/shift+a=copy_to_clipboard"]); + assert_eq!(kb.table("foo").unwrap().bindings.len(), 1); + kb.parse_cli(Some("foo/")).unwrap(); + assert_eq!(kb.table("foo").unwrap().bindings.len(), 0); + } + + #[test] + fn parse_cli_table_multiple_bindings() { + let kb = parsed(&[ + "foo/shift+a=copy_to_clipboard", + "foo/shift+b=paste_from_clipboard", + "bar/ctrl+c=close_window", + ]); + assert_eq!(kb.tables.len(), 2); + assert_eq!(kb.table("foo").unwrap().bindings.len(), 2); + assert_eq!(kb.table("bar").unwrap().bindings.len(), 1); + } + + #[test] + fn parse_cli_table_does_not_affect_root_set() { + let kb = parsed(&[ + "shift+a=copy_to_clipboard", + "foo/shift+b=paste_from_clipboard", + ]); + assert_eq!(kb.set.bindings.len(), 1); + assert_eq!(kb.table("foo").unwrap().bindings.len(), 1); + } + + #[test] + fn parse_cli_table_empty_name_is_invalid() { + let mut kb = Keybinds::default(); + assert_eq!( + kb.parse_cli(Some("/shift+a=copy_to_clipboard")), + Err(ArgError::other("InvalidFormat")) + ); + } + + #[test] + fn parse_cli_table_with_key_sequence() { + let kb = parsed(&["foo/ctrl+a>ctrl+b=new_window"]); + assert_eq!(kb.table("foo").unwrap().bindings.len(), 1); + } + + fn assert_root_only(input: &str) { + let kb = parsed(&[input]); + assert_eq!(kb.set.bindings.len(), 1); + assert_eq!(kb.tables.len(), 0); + } + + #[test] + fn parse_cli_slash_in_action_argument_is_not_a_table() { + assert_root_only("ctrl+a=text:/hello"); + } + + #[test] + fn parse_cli_slash_as_key_with_modifier_is_not_a_table() { + assert_root_only("ctrl+/=text:foo"); + } + + #[test] + fn parse_cli_shift_slash_as_key_is_not_a_table() { + assert_root_only("shift+/=ignore"); + } + + #[test] + fn parse_cli_bare_slash_as_key_is_not_a_table() { + assert_root_only("/=text:foo"); + } + + #[test] + fn parse_cli_slash_in_key_sequence_is_not_a_table() { + assert_root_only("ctrl+a>ctrl+/=new_window"); + } + + #[test] + fn parse_cli_table_with_slash_in_binding() { + let kb = parsed(&["mytable//=text:foo"]); + assert_eq!(kb.set.bindings.len(), 0); + assert_eq!(kb.tables.len(), 1); + assert_eq!(kb.table("mytable").unwrap().bindings.len(), 1); + } + + #[test] + fn parse_cli_table_with_sequence_containing_slash() { + let kb = parsed(&["mytable/a>/=new_window"]); + assert_eq!(kb.set.bindings.len(), 0); + assert_eq!(kb.tables.len(), 1); + assert!(kb.table("mytable").is_some()); + } + + #[test] + fn parse_cli_chain_without_prior_parsed_binding_is_invalid() { + let mut kb = Keybinds::default(); + assert_eq!( + kb.parse_cli(Some("chain=new_tab")), + Err(ArgError::other("InvalidFormat")) + ); + } + + #[test] + fn parse_cli_table_chain_syntax_is_invalid() { + let mut kb = Keybinds::default(); + kb.parse_cli(Some("foo/a=text:hello")).unwrap(); + assert_eq!( + kb.parse_cli(Some("foo/chain=deactivate_key_table")), + Err(ArgError::other("InvalidFormat")) + ); + } + + #[test] + fn parse_cli_chain_applies_to_most_recent_table_binding() { + let kb = parsed(&[ + "ctrl+n=activate_key_table:foo", + "foo/a=text:hello", + "chain=deactivate_key_table", + ]); + + let root = kb + .set + .get(Trigger { + mods: mods(true, false, false, false), + key: TriggerKey::Unicode('n' as u32), + }) + .unwrap(); + let leaf = root.as_leaf().unwrap(); + assert!(matches!(leaf.action, Action::ActivateKeyTable(_))); + + let foo = kb + .table("foo") + .unwrap() + .get(Trigger { + mods: Mods::default(), + key: TriggerKey::Unicode('a' as u32), + }) + .unwrap(); + let chained = foo.as_leaf_chained().unwrap(); + assert_eq!(chained.actions.len(), 2); + assert!(matches!(chained.actions[0], Action::Text(_))); + assert!(matches!(chained.actions[1], Action::DeactivateKeyTable)); + } + + #[test] + fn clone_with_tables() { + let kb = parsed(&[ + "shift+a=copy_to_clipboard", + "foo/shift+b=paste_from_clipboard", + "bar/ctrl+c=close_window", + ]); + let cloned = kb.clone(); + assert_eq!(kb.set.bindings.len(), cloned.set.bindings.len()); + assert_eq!(kb.tables.len(), cloned.tables.len()); + assert_eq!( + kb.table("foo").unwrap().bindings.len(), + cloned.table("foo").unwrap().bindings.len() + ); + assert_eq!( + kb.table("bar").unwrap().bindings.len(), + cloned.table("bar").unwrap().bindings.len() + ); + assert_eq!(kb, cloned); + } + + #[test] + fn equal_with_tables() { + let a = parsed(&["foo/shift+a=copy_to_clipboard"]); + let b = parsed(&["foo/shift+a=copy_to_clipboard"]); + assert_eq!(a, b); + } + + #[test] + fn equal_with_tables_different_table_count() { + let a = parsed(&["foo/shift+a=copy_to_clipboard"]); + let b = parsed(&[ + "foo/shift+a=copy_to_clipboard", + "bar/shift+b=paste_from_clipboard", + ]); + assert_ne!(a, b); + } + + #[test] + fn equal_with_tables_different_table_names() { + let a = parsed(&["foo/shift+a=copy_to_clipboard"]); + let b = parsed(&["bar/shift+a=copy_to_clipboard"]); + assert_ne!(a, b); + } + + #[test] + fn equal_with_tables_different_bindings() { + let a = parsed(&["foo/shift+a=copy_to_clipboard"]); + let b = parsed(&["foo/shift+b=paste_from_clipboard"]); + assert_ne!(a, b); + } + + #[test] + fn format_entry_with_tables() { + let kb = parsed(&["foo/shift+a=csi:hello"]); + assert_eq!(fmt_kb("keybind", &kb), "keybind = foo/shift+a=csi:hello\n"); + } + + #[test] + fn format_entry_with_tables_and_root_set() { + let kb = parsed(&["shift+b=csi:world", "foo/shift+a=csi:hello"]); + let out = fmt_kb("keybind", &kb); + assert!(out.contains("keybind = shift+b=csi:world\n")); + assert!(out.contains("keybind = foo/shift+a=csi:hello\n")); + } + + #[test] + fn parse_cli_clear_clears_tables() { + let mut kb = parsed(&[ + "shift+a=copy_to_clipboard", + "foo/shift+b=paste_from_clipboard", + "bar/ctrl+c=close_window", + ]); + assert_eq!(kb.set.bindings.len(), 1); + assert_eq!(kb.tables.len(), 2); + kb.parse_cli(Some("clear")).unwrap(); + assert_eq!(kb.set.bindings.len(), 0); + assert_eq!(kb.tables.len(), 0); + } + + #[test] + fn parse_cli_reset_clears_tables() { + let mut kb = parsed(&[ + "foo/shift+a=copy_to_clipboard", + "bar/shift+b=paste_from_clipboard", + ]); + assert_eq!(kb.tables.len(), 2); + kb.parse_cli(Some("")).unwrap(); + assert_eq!(kb.tables.len(), 0); + assert!(!kb.set.bindings.is_empty()); + } + + #[test] + fn reset_calls_init() { + let mut kb = parsed(&["foo/shift+a=copy_to_clipboard"]); + ConfigField::reset(&mut kb, Keybinds::default()); + assert_eq!(kb.tables.len(), 0); + assert!(!kb.set.bindings.is_empty()); + } + + #[test] + fn default_keybinds_format_roundtrip() { + // Every default binding must format and re-parse to an equal set. + let mut kb = Keybinds::default(); + kb.init(); + let out = fmt_kb("keybind", &kb); + let mut again = Keybinds::default(); + for line in out.lines() { + let v = line.strip_prefix("keybind = ").unwrap(); + again.parse_cli(Some(v)).unwrap(); + } + assert_eq!(kb.set.bindings.len(), again.set.bindings.len()); + } +} diff --git a/crates/ghostty-config/src/keys.rs b/crates/ghostty-config/src/keys.rs new file mode 100644 index 0000000..dbf718e --- /dev/null +++ b/crates/ghostty-config/src/keys.rs @@ -0,0 +1,206 @@ +//! Constants for every config key (generated from the field list). + +use crate::key::Key; + +pub const LANGUAGE: Key = Key("language"); +pub const FONT_FAMILY: Key = Key("font-family"); +pub const FONT_FAMILY_BOLD: Key = Key("font-family-bold"); +pub const FONT_FAMILY_ITALIC: Key = Key("font-family-italic"); +pub const FONT_FAMILY_BOLD_ITALIC: Key = Key("font-family-bold-italic"); +pub const FONT_STYLE: Key = Key("font-style"); +pub const FONT_STYLE_BOLD: Key = Key("font-style-bold"); +pub const FONT_STYLE_ITALIC: Key = Key("font-style-italic"); +pub const FONT_STYLE_BOLD_ITALIC: Key = Key("font-style-bold-italic"); +pub const FONT_SYNTHETIC_STYLE: Key = Key("font-synthetic-style"); +pub const FONT_FEATURE: Key = Key("font-feature"); +pub const FONT_SIZE: Key = Key("font-size"); +pub const FONT_VARIATION: Key = Key("font-variation"); +pub const FONT_VARIATION_BOLD: Key = Key("font-variation-bold"); +pub const FONT_VARIATION_ITALIC: Key = Key("font-variation-italic"); +pub const FONT_VARIATION_BOLD_ITALIC: Key = Key("font-variation-bold-italic"); +pub const FONT_CODEPOINT_MAP: Key = Key("font-codepoint-map"); +pub const CLIPBOARD_CODEPOINT_MAP: Key = Key("clipboard-codepoint-map"); +pub const FONT_THICKEN: Key = Key("font-thicken"); +pub const FONT_THICKEN_STRENGTH: Key = Key("font-thicken-strength"); +pub const FONT_SHAPING_BREAK: Key = Key("font-shaping-break"); +pub const ALPHA_BLENDING: Key = Key("alpha-blending"); +pub const ADJUST_CELL_WIDTH: Key = Key("adjust-cell-width"); +pub const ADJUST_CELL_HEIGHT: Key = Key("adjust-cell-height"); +pub const ADJUST_FONT_BASELINE: Key = Key("adjust-font-baseline"); +pub const ADJUST_UNDERLINE_POSITION: Key = Key("adjust-underline-position"); +pub const ADJUST_UNDERLINE_THICKNESS: Key = Key("adjust-underline-thickness"); +pub const ADJUST_STRIKETHROUGH_POSITION: Key = Key("adjust-strikethrough-position"); +pub const ADJUST_STRIKETHROUGH_THICKNESS: Key = Key("adjust-strikethrough-thickness"); +pub const ADJUST_OVERLINE_POSITION: Key = Key("adjust-overline-position"); +pub const ADJUST_OVERLINE_THICKNESS: Key = Key("adjust-overline-thickness"); +pub const ADJUST_CURSOR_THICKNESS: Key = Key("adjust-cursor-thickness"); +pub const ADJUST_CURSOR_HEIGHT: Key = Key("adjust-cursor-height"); +pub const ADJUST_BOX_THICKNESS: Key = Key("adjust-box-thickness"); +pub const ADJUST_ICON_HEIGHT: Key = Key("adjust-icon-height"); +pub const GRAPHEME_WIDTH_METHOD: Key = Key("grapheme-width-method"); +pub const FREETYPE_LOAD_FLAGS: Key = Key("freetype-load-flags"); +pub const THEME: Key = Key("theme"); +pub const BACKGROUND: Key = Key("background"); +pub const FOREGROUND: Key = Key("foreground"); +pub const BACKGROUND_IMAGE: Key = Key("background-image"); +pub const BACKGROUND_IMAGE_OPACITY: Key = Key("background-image-opacity"); +pub const BACKGROUND_IMAGE_POSITION: Key = Key("background-image-position"); +pub const BACKGROUND_IMAGE_FIT: Key = Key("background-image-fit"); +pub const BACKGROUND_IMAGE_REPEAT: Key = Key("background-image-repeat"); +pub const SELECTION_FOREGROUND: Key = Key("selection-foreground"); +pub const SELECTION_BACKGROUND: Key = Key("selection-background"); +pub const SELECTION_CLEAR_ON_TYPING: Key = Key("selection-clear-on-typing"); +pub const SELECTION_CLEAR_ON_COPY: Key = Key("selection-clear-on-copy"); +pub const SELECTION_WORD_CHARS: Key = Key("selection-word-chars"); +pub const MINIMUM_CONTRAST: Key = Key("minimum-contrast"); +pub const PALETTE: Key = Key("palette"); +pub const PALETTE_GENERATE: Key = Key("palette-generate"); +pub const PALETTE_HARMONIOUS: Key = Key("palette-harmonious"); +pub const CURSOR_COLOR: Key = Key("cursor-color"); +pub const CURSOR_OPACITY: Key = Key("cursor-opacity"); +pub const CURSOR_STYLE: Key = Key("cursor-style"); +pub const CURSOR_STYLE_BLINK: Key = Key("cursor-style-blink"); +pub const CURSOR_TEXT: Key = Key("cursor-text"); +pub const CURSOR_CLICK_TO_MOVE: Key = Key("cursor-click-to-move"); +pub const MOUSE_HIDE_WHILE_TYPING: Key = Key("mouse-hide-while-typing"); +pub const SCROLL_TO_BOTTOM: Key = Key("scroll-to-bottom"); +pub const MOUSE_SHIFT_CAPTURE: Key = Key("mouse-shift-capture"); +pub const MOUSE_REPORTING: Key = Key("mouse-reporting"); +pub const MOUSE_SCROLL_MULTIPLIER: Key = Key("mouse-scroll-multiplier"); +pub const BACKGROUND_OPACITY: Key = Key("background-opacity"); +pub const BACKGROUND_OPACITY_CELLS: Key = Key("background-opacity-cells"); +pub const BACKGROUND_BLUR: Key = Key("background-blur"); +pub const UNFOCUSED_SPLIT_OPACITY: Key = Key("unfocused-split-opacity"); +pub const UNFOCUSED_SPLIT_FILL: Key = Key("unfocused-split-fill"); +pub const SPLIT_DIVIDER_COLOR: Key = Key("split-divider-color"); +pub const SPLIT_PRESERVE_ZOOM: Key = Key("split-preserve-zoom"); +pub const SEARCH_FOREGROUND: Key = Key("search-foreground"); +pub const SEARCH_BACKGROUND: Key = Key("search-background"); +pub const SEARCH_SELECTED_FOREGROUND: Key = Key("search-selected-foreground"); +pub const SEARCH_SELECTED_BACKGROUND: Key = Key("search-selected-background"); +pub const COMMAND: Key = Key("command"); +pub const INITIAL_COMMAND: Key = Key("initial-command"); +pub const NOTIFY_ON_COMMAND_FINISH: Key = Key("notify-on-command-finish"); +pub const NOTIFY_ON_COMMAND_FINISH_ACTION: Key = Key("notify-on-command-finish-action"); +pub const NOTIFY_ON_COMMAND_FINISH_AFTER: Key = Key("notify-on-command-finish-after"); +pub const ENV: Key = Key("env"); +pub const INPUT: Key = Key("input"); +pub const WAIT_AFTER_COMMAND: Key = Key("wait-after-command"); +pub const ABNORMAL_COMMAND_EXIT_RUNTIME: Key = Key("abnormal-command-exit-runtime"); +pub const SCROLLBACK_LIMIT: Key = Key("scrollback-limit"); +pub const SCROLLBAR: Key = Key("scrollbar"); +pub const LINK: Key = Key("link"); +pub const LINK_URL: Key = Key("link-url"); +pub const LINK_PREVIEWS: Key = Key("link-previews"); +pub const MAXIMIZE: Key = Key("maximize"); +pub const FULLSCREEN: Key = Key("fullscreen"); +pub const TITLE: Key = Key("title"); +pub const CLASS: Key = Key("class"); +pub const X11_INSTANCE_NAME: Key = Key("x11-instance-name"); +pub const WORKING_DIRECTORY: Key = Key("working-directory"); +pub const KEYBIND: Key = Key("keybind"); +pub const KEY_REMAP: Key = Key("key-remap"); +pub const WINDOW_PADDING_X: Key = Key("window-padding-x"); +pub const WINDOW_PADDING_Y: Key = Key("window-padding-y"); +pub const WINDOW_PADDING_BALANCE: Key = Key("window-padding-balance"); +pub const WINDOW_PADDING_COLOR: Key = Key("window-padding-color"); +pub const WINDOW_VSYNC: Key = Key("window-vsync"); +pub const WINDOW_INHERIT_WORKING_DIRECTORY: Key = Key("window-inherit-working-directory"); +pub const TAB_INHERIT_WORKING_DIRECTORY: Key = Key("tab-inherit-working-directory"); +pub const SPLIT_INHERIT_WORKING_DIRECTORY: Key = Key("split-inherit-working-directory"); +pub const WINDOW_INHERIT_FONT_SIZE: Key = Key("window-inherit-font-size"); +pub const WINDOW_DECORATION: Key = Key("window-decoration"); +pub const WINDOW_TITLE_FONT_FAMILY: Key = Key("window-title-font-family"); +pub const WINDOW_SUBTITLE: Key = Key("window-subtitle"); +pub const WINDOW_THEME: Key = Key("window-theme"); +pub const WINDOW_COLORSPACE: Key = Key("window-colorspace"); +pub const WINDOW_HEIGHT: Key = Key("window-height"); +pub const WINDOW_WIDTH: Key = Key("window-width"); +pub const WINDOW_POSITION_X: Key = Key("window-position-x"); +pub const WINDOW_POSITION_Y: Key = Key("window-position-y"); +pub const WINDOW_SAVE_STATE: Key = Key("window-save-state"); +pub const WINDOW_STEP_RESIZE: Key = Key("window-step-resize"); +pub const WINDOW_NEW_TAB_POSITION: Key = Key("window-new-tab-position"); +pub const WINDOW_SHOW_TAB_BAR: Key = Key("window-show-tab-bar"); +pub const WINDOW_TITLEBAR_BACKGROUND: Key = Key("window-titlebar-background"); +pub const WINDOW_TITLEBAR_FOREGROUND: Key = Key("window-titlebar-foreground"); +pub const RESIZE_OVERLAY: Key = Key("resize-overlay"); +pub const RESIZE_OVERLAY_POSITION: Key = Key("resize-overlay-position"); +pub const RESIZE_OVERLAY_DURATION: Key = Key("resize-overlay-duration"); +pub const FOCUS_FOLLOWS_MOUSE: Key = Key("focus-follows-mouse"); +pub const CLIPBOARD_READ: Key = Key("clipboard-read"); +pub const CLIPBOARD_WRITE: Key = Key("clipboard-write"); +pub const CLIPBOARD_TRIM_TRAILING_SPACES: Key = Key("clipboard-trim-trailing-spaces"); +pub const CLIPBOARD_PASTE_PROTECTION: Key = Key("clipboard-paste-protection"); +pub const CLIPBOARD_PASTE_BRACKETED_SAFE: Key = Key("clipboard-paste-bracketed-safe"); +pub const TITLE_REPORT: Key = Key("title-report"); +pub const IMAGE_STORAGE_LIMIT: Key = Key("image-storage-limit"); +pub const COPY_ON_SELECT: Key = Key("copy-on-select"); +pub const RIGHT_CLICK_ACTION: Key = Key("right-click-action"); +pub const CLICK_REPEAT_INTERVAL: Key = Key("click-repeat-interval"); +pub const CONFIG_FILE: Key = Key("config-file"); +pub const CONFIG_DEFAULT_FILES: Key = Key("config-default-files"); +pub const CONFIRM_CLOSE_SURFACE: Key = Key("confirm-close-surface"); +pub const QUIT_AFTER_LAST_WINDOW_CLOSED: Key = Key("quit-after-last-window-closed"); +pub const QUIT_AFTER_LAST_WINDOW_CLOSED_DELAY: Key = Key("quit-after-last-window-closed-delay"); +pub const INITIAL_WINDOW: Key = Key("initial-window"); +pub const UNDO_TIMEOUT: Key = Key("undo-timeout"); +pub const QUICK_TERMINAL_POSITION: Key = Key("quick-terminal-position"); +pub const QUICK_TERMINAL_SIZE: Key = Key("quick-terminal-size"); +pub const GTK_QUICK_TERMINAL_LAYER: Key = Key("gtk-quick-terminal-layer"); +pub const GTK_QUICK_TERMINAL_NAMESPACE: Key = Key("gtk-quick-terminal-namespace"); +pub const QUICK_TERMINAL_SCREEN: Key = Key("quick-terminal-screen"); +pub const QUICK_TERMINAL_ANIMATION_DURATION: Key = Key("quick-terminal-animation-duration"); +pub const QUICK_TERMINAL_AUTOHIDE: Key = Key("quick-terminal-autohide"); +pub const QUICK_TERMINAL_SPACE_BEHAVIOR: Key = Key("quick-terminal-space-behavior"); +pub const QUICK_TERMINAL_KEYBOARD_INTERACTIVITY: Key = Key("quick-terminal-keyboard-interactivity"); +pub const SHELL_INTEGRATION: Key = Key("shell-integration"); +pub const SHELL_INTEGRATION_FEATURES: Key = Key("shell-integration-features"); +pub const COMMAND_PALETTE_ENTRY: Key = Key("command-palette-entry"); +pub const OSC_COLOR_REPORT_FORMAT: Key = Key("osc-color-report-format"); +pub const VT_KAM_ALLOWED: Key = Key("vt-kam-allowed"); +pub const CUSTOM_SHADER: Key = Key("custom-shader"); +pub const CUSTOM_SHADER_ANIMATION: Key = Key("custom-shader-animation"); +pub const BELL_FEATURES: Key = Key("bell-features"); +pub const BELL_AUDIO_PATH: Key = Key("bell-audio-path"); +pub const BELL_AUDIO_VOLUME: Key = Key("bell-audio-volume"); +pub const APP_NOTIFICATIONS: Key = Key("app-notifications"); +pub const MACOS_NON_NATIVE_FULLSCREEN: Key = Key("macos-non-native-fullscreen"); +pub const MACOS_WINDOW_BUTTONS: Key = Key("macos-window-buttons"); +pub const MACOS_TITLEBAR_STYLE: Key = Key("macos-titlebar-style"); +pub const MACOS_TITLEBAR_PROXY_ICON: Key = Key("macos-titlebar-proxy-icon"); +pub const MACOS_DOCK_DROP_BEHAVIOR: Key = Key("macos-dock-drop-behavior"); +pub const MACOS_OPTION_AS_ALT: Key = Key("macos-option-as-alt"); +pub const MACOS_WINDOW_SHADOW: Key = Key("macos-window-shadow"); +pub const MACOS_HIDDEN: Key = Key("macos-hidden"); +pub const MACOS_AUTO_SECURE_INPUT: Key = Key("macos-auto-secure-input"); +pub const MACOS_SECURE_INPUT_INDICATION: Key = Key("macos-secure-input-indication"); +pub const MACOS_APPLESCRIPT: Key = Key("macos-applescript"); +pub const MACOS_ICON: Key = Key("macos-icon"); +pub const MACOS_CUSTOM_ICON: Key = Key("macos-custom-icon"); +pub const MACOS_ICON_FRAME: Key = Key("macos-icon-frame"); +pub const MACOS_ICON_GHOST_COLOR: Key = Key("macos-icon-ghost-color"); +pub const MACOS_ICON_SCREEN_COLOR: Key = Key("macos-icon-screen-color"); +pub const MACOS_SHORTCUTS: Key = Key("macos-shortcuts"); +pub const LINUX_CGROUP: Key = Key("linux-cgroup"); +pub const LINUX_CGROUP_MEMORY_LIMIT: Key = Key("linux-cgroup-memory-limit"); +pub const LINUX_CGROUP_PROCESSES_LIMIT: Key = Key("linux-cgroup-processes-limit"); +pub const LINUX_CGROUP_HARD_FAIL: Key = Key("linux-cgroup-hard-fail"); +pub const GTK_OPENGL_DEBUG: Key = Key("gtk-opengl-debug"); +pub const GTK_SINGLE_INSTANCE: Key = Key("gtk-single-instance"); +pub const GTK_TITLEBAR: Key = Key("gtk-titlebar"); +pub const GTK_TABS_LOCATION: Key = Key("gtk-tabs-location"); +pub const GTK_TITLEBAR_HIDE_WHEN_MAXIMIZED: Key = Key("gtk-titlebar-hide-when-maximized"); +pub const GTK_TOOLBAR_STYLE: Key = Key("gtk-toolbar-style"); +pub const GTK_TITLEBAR_STYLE: Key = Key("gtk-titlebar-style"); +pub const GTK_WIDE_TABS: Key = Key("gtk-wide-tabs"); +pub const GTK_CUSTOM_CSS: Key = Key("gtk-custom-css"); +pub const DESKTOP_NOTIFICATIONS: Key = Key("desktop-notifications"); +pub const PROGRESS_STYLE: Key = Key("progress-style"); +pub const BOLD_COLOR: Key = Key("bold-color"); +pub const FAINT_OPACITY: Key = Key("faint-opacity"); +pub const TERM: Key = Key("term"); +pub const ENQUIRY_RESPONSE: Key = Key("enquiry-response"); +pub const ASYNC_BACKEND: Key = Key("async-backend"); +pub const AUTO_UPDATE: Key = Key("auto-update"); +pub const AUTO_UPDATE_CHANNEL: Key = Key("auto-update-channel"); diff --git a/crates/ghostty-config/src/lib.rs b/crates/ghostty-config/src/lib.rs index 0fb79b7..8cef046 100644 --- a/crates/ghostty-config/src/lib.rs +++ b/crates/ghostty-config/src/lib.rs @@ -1 +1,48 @@ -//! ghostty-config: see docs/rewrite/ARCHITECTURE.md for the module map. +//! ghostty-config: configuration parsing, loading and formatting +//! (ZIG/src/config/*, cli/args.zig, cli/CommaSplitter.zig, +//! cli/diagnostics.zig). See docs/rewrite/ARCHITECTURE.md. + +#![allow( + clippy::wildcard_imports, + clippy::should_implement_trait, + clippy::float_cmp, + clippy::collapsible_if, + clippy::redundant_closure_for_method_calls, + clippy::single_match_else, + clippy::manual_let_else +)] + +// Generic CLI parsing infrastructure. +pub mod cli_args; +pub mod comma_splitter; +pub mod diagnostics; +pub mod macros; + +// Config support modules. +pub mod clipboard_codepoint_map; +pub mod command; +pub mod conditional; +pub mod edit; +pub mod error_list; +pub mod file_load; +pub mod formatter; +pub mod io; +pub mod key; +pub mod path; +pub mod repeatable_string_map; +pub mod string; +pub mod theme; +pub mod url; + +// Types that were nested in Config.zig. +pub mod font_types; +pub mod input_fields; +pub mod keybinds; +pub mod metric_modifier; +pub mod types_a; +pub mod types_b; + +// The Config struct itself. +pub mod config; +pub mod file_formatter; +pub mod keys; diff --git a/crates/ghostty-config/src/macros.rs b/crates/ghostty-config/src/macros.rs new file mode 100644 index 0000000..e0d0d5a --- /dev/null +++ b/crates/ghostty-config/src/macros.rs @@ -0,0 +1,299 @@ +//! Declarative macros used to define config structs, enums and packed +//! (bitfield-like) structs with Zig-compatible names and parsing. + +use std::fmt; + +/// Compute the help string for a field from its raw doc lines +/// (Zig: `helpgen.zig` `extractDocComments`). The `///` prefix has already +/// been removed (rustc's `#[doc]` value keeps the leading space just like the +/// Zig token slice `[3..]`). The common leading-space prefix is stripped and +/// the lines are joined with `\n`. +#[must_use] +pub fn help_string(lines: &[&str]) -> String { + let mut m = usize::MAX; + for line in lines { + let n = line.bytes().position(|c| c != b' ').unwrap_or(usize::MAX); + m = m.min(n); + } + let mut out = String::new(); + for (i, line) in lines.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str(&line[m.min(line.len())..]); + } + out +} + +/// Metadata and accessors for a single field of a config struct. +pub struct FieldInfo { + /// The field name as used in config files (e.g. `font-family`). + pub name: &'static str, + /// Raw doc comment lines. + pub doc: &'static [&'static str], + /// Deep equality of this field between two structs. + pub eq: fn(&T, &T) -> bool, + /// Format this field as in `+show-config`. + pub format: fn(&T, &str, &mut dyn fmt::Write) -> fmt::Result, +} + +impl FieldInfo { + /// The help text for this field (empty string if undocumented). + #[must_use] + pub fn help(&self) -> String { + help_string(self.doc) + } +} + +/// Implemented by structs declared with [`config_struct!`](crate::config_struct). +pub trait ConfigStruct: Sized + 'static { + const FIELDS: &'static [FieldInfo]; +} + +/// Declare a configuration struct. +/// +/// ```ignore +/// config_struct! { +/// pub struct Foo { +/// /// doc comment +/// "some-key" => some_key: bool = false, +/// } +/// extra { diags: DiagnosticList = DiagnosticList::default(), } +/// } +/// ``` +/// +/// Generates the struct (all fields `pub`), `raw_default()`, +/// `parse_field(key, value)` (Zig: `parseIntoField`), `valid_values_for(key)`, +/// and a [`ConfigStruct`] impl with per-field metadata. +#[macro_export] +macro_rules! config_struct { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident { + $( + $(#[doc = $doc:literal])* + $key:literal => $field:ident : $ty:ty = $default:expr + ),* $(,)? + } + $( + extra { + $( $(#[$emeta:meta])* $efield:ident : $ety:ty = $edefault:expr ),* $(,)? + } + )? + ) => { + $(#[$meta])* + #[derive(Clone, Debug)] + #[allow(dead_code)] + $vis struct $name { + $( pub $field: $ty, )* + $( $( $(#[$emeta])* pub $efield: $ety, )* )? + } + + impl $name { + /// The struct with every field set to its declared default. + #[allow(clippy::too_many_lines, dead_code)] + pub fn raw_default() -> Self { + Self { + $( $field: $default, )* + $( $( $efield: $edefault, )* )? + } + } + + #[allow(dead_code)] + /// Parse a single key/value into the matching field. Empty + /// values reset the field to its default. + pub fn parse_field( + &mut self, + key: &str, + value: ::core::option::Option<&str>, + ) -> ::core::result::Result<(), $crate::cli_args::ArgError> { + match key { + $( + $key => { + if value == ::core::option::Option::Some("") { + $crate::cli_args::ConfigField::reset(&mut self.$field, $default); + return ::core::result::Result::Ok(()); + } + $crate::cli_args::ConfigField::parse_cli(&mut self.$field, value) + } + )* + _ => ::core::result::Result::Err($crate::cli_args::ArgError::InvalidField), + } + } + + /// For enum-typed fields, the valid values. + #[allow(dead_code)] + pub fn valid_values_for(key: &str) -> ::core::option::Option<&'static [&'static str]> { + match key { + $( + $key => <$ty as $crate::cli_args::ConfigField>::VALID_VALUES, + )* + _ => ::core::option::Option::None, + } + } + } + + impl $crate::macros::ConfigStruct for $name { + const FIELDS: &'static [$crate::macros::FieldInfo<$name>] = &[ + $( + $crate::macros::FieldInfo { + name: $key, + doc: &[$($doc),*], + eq: |a: &$name, b: &$name| a.$field == b.$field, + format: |a: &$name, name: &str, w: &mut dyn ::core::fmt::Write| { + let mut f = $crate::formatter::EntryFormatter::new(name, w); + $crate::cli_args::ConfigField::format_entry(&a.$field, &mut f) + }, + }, + )* + ]; + } + }; +} + +/// Declare a Zig-style enum whose variants parse from / print as the given +/// strings. +#[macro_export] +macro_rules! config_enum { + ( + $(#[$meta:meta])* + $vis:vis enum $name:ident { + $( + $(#[doc = $doc:literal])* + $variant:ident => $str:literal + ),* $(,)? + } + ) => { + $(#[$meta])* + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[allow(clippy::enum_variant_names)] + $vis enum $name { + $( $(#[doc = $doc])* $variant, )* + } + + impl $name { + /// All variants in declaration order. + pub const ALL: &'static [$name] = &[ $( $name::$variant, )* ]; + /// All variant names in declaration order. + pub const NAMES: &'static [&'static str] = &[ $( $str, )* ]; + + /// The user-facing name of this variant. + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { $( $name::$variant => $str, )* } + } + + /// Zig: `std.meta.stringToEnum`. + #[must_use] + pub fn from_name(s: &str) -> ::core::option::Option { + match s { + $( $str => ::core::option::Option::Some($name::$variant), )* + _ => ::core::option::Option::None, + } + } + } + + impl ::core::default::Default for $name { + fn default() -> Self { + Self::ALL[0] + } + } + + impl ::core::fmt::Display for $name { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + f.write_str(self.as_str()) + } + } + + impl $crate::cli_args::ConfigField for $name { + const VALID_VALUES: ::core::option::Option<&'static [&'static str]> = + ::core::option::Option::Some(Self::NAMES); + + fn parse_cli( + &mut self, + value: ::core::option::Option<&str>, + ) -> ::core::result::Result<(), $crate::cli_args::ArgError> { + let v = value.ok_or($crate::cli_args::ArgError::ValueRequired)?; + *self = Self::from_name(v).ok_or($crate::cli_args::ArgError::InvalidValue)?; + ::core::result::Result::Ok(()) + } + + fn format_entry( + &self, + f: &mut $crate::formatter::EntryFormatter<'_>, + ) -> ::core::fmt::Result { + ::core::writeln!(f.writer, "{} = {}", f.name, self.as_str()) + } + } + }; +} + +/// Declare a Zig `packed struct` of bools: parsed from a comma separated +/// list of names (optionally prefixed with `no-`) or a plain boolean. +#[macro_export] +macro_rules! config_packed { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident { + $( + $(#[doc = $doc:literal])* + $key:literal => $field:ident : $default:expr + ),* $(,)? + } + ) => { + $(#[$meta])* + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + $vis struct $name { + $( $(#[doc = $doc])* pub $field: bool, )* + } + + impl $name { + /// Field names (user-facing) in declaration order. + pub const NAMES: &'static [&'static str] = &[ $( $key, )* ]; + + fn set_index(&mut self, idx: usize, value: bool) { + let mut i = 0usize; + $( + if idx == i { self.$field = value; return; } + i += 1; + )* + let _ = i; + } + } + + impl ::core::default::Default for $name { + fn default() -> Self { + Self { $( $field: $default, )* } + } + } + + impl $crate::cli_args::ConfigField for $name { + fn parse_cli( + &mut self, + value: ::core::option::Option<&str>, + ) -> ::core::result::Result<(), $crate::cli_args::ArgError> { + let v = value.ok_or($crate::cli_args::ArgError::ValueRequired)?; + let mut result = <$name as ::core::default::Default>::default(); + $crate::cli_args::parse_packed_struct_with(v, Self::NAMES, |i, b| { + result.set_index(i, b); + })?; + *self = result; + ::core::result::Result::Ok(()) + } + + fn format_entry( + &self, + f: &mut $crate::formatter::EntryFormatter<'_>, + ) -> ::core::fmt::Result { + ::core::write!(f.writer, "{} = ", f.name)?; + let mut _first = true; + $( + if !_first { ::core::write!(f.writer, ",")?; } + _first = false; + ::core::write!(f.writer, "{}{}", if self.$field { "" } else { "no-" }, $key)?; + )* + ::core::writeln!(f.writer) + } + } + }; +} diff --git a/crates/ghostty-config/src/metric_modifier.rs b/crates/ghostty-config/src/metric_modifier.rs new file mode 100644 index 0000000..24e54bb --- /dev/null +++ b/crates/ghostty-config/src/metric_modifier.rs @@ -0,0 +1,179 @@ +//! Port of `Metrics.Modifier` from `font/Metrics.zig`. +//! +//! The font crate depends on this crate (not the other way around), so the +//! modifier type used by the `adjust-*` config options lives here. + +use crate::cli_args::{ArgError, ConfigField}; +use crate::formatter::EntryFormatter; +use std::fmt; + +/// A modifier to apply to a metrics value. The modifier value represents +/// a delta, so percent is a percentage to change, not a percentage of. +/// For example, "20%" is 20% larger, not 20% of the value. Likewise, +/// an absolute value of "20" is 20 larger, not literally 20. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum MetricModifier { + Percent(f64), + Absolute(i32), +} + +impl Default for MetricModifier { + fn default() -> Self { + MetricModifier::Absolute(0) + } +} + +impl MetricModifier { + /// Parses the modifier value. If the value ends in "%" it is assumed + /// to be a percent, otherwise the value is parsed as an integer. + pub fn parse(input: &str) -> Result { + if input.is_empty() { + return Err(ArgError::other("InvalidFormat")); + } + + if let Some(num) = input.strip_suffix('%') { + let mut percent = num + .parse::() + .map_err(|_| ArgError::other("InvalidFormat"))?; + percent /= 100.0; + + if percent <= -1.0 { + return Ok(MetricModifier::Percent(0.0)); + } + return Ok(MetricModifier::Percent(1.0 + percent)); + } + + input + .parse::() + .map(MetricModifier::Absolute) + .map_err(|_| ArgError::other("InvalidFormat")) + } + + /// So it works with the config framework. + pub fn parse_cli_value(input: Option<&str>) -> Result { + Self::parse(input.ok_or(ArgError::ValueRequired)?) + } + + /// Apply a modifier to an integer value (saturating, clamped at zero for + /// unsigned results, like the Zig implementation for unsigned types). + #[must_use] + pub fn apply_i64(&self, v: i64, unsigned: bool) -> i64 { + match *self { + MetricModifier::Percent(p) => { + let p = p.max(0.0); + (v as f64 * p).round() as i64 + } + MetricModifier::Absolute(abs) => { + let applied = v.saturating_add(i64::from(abs)); + if unsigned { applied.max(0) } else { applied } + } + } + } + + /// Apply to a `u32`. + #[must_use] + pub fn apply_u32(&self, v: u32) -> u32 { + let r = self.apply_i64(i64::from(v), true); + u32::try_from(r).unwrap_or(u32::MAX) + } + + /// Apply to an `i32`. + #[must_use] + pub fn apply_i32(&self, v: i32) -> i32 { + let r = self.apply_i64(i64::from(v), false); + i32::try_from(r).unwrap_or(if r < 0 { i32::MIN } else { i32::MAX }) + } + + /// Apply to a float value. + #[must_use] + pub fn apply_f64(&self, v: f64) -> f64 { + match *self { + MetricModifier::Percent(p) => v * p.max(0.0), + MetricModifier::Absolute(abs) => v + f64::from(abs), + } + } +} + +impl ConfigField for MetricModifier { + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { + *self = Self::parse_cli_value(value)?; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + let s = match self { + MetricModifier::Percent(v) => format!("{}%", (v - 1.0) * 100.0), + MetricModifier::Absolute(v) => format!("{v}"), + }; + f.format_entry(&s) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn fmt1(m: MetricModifier) -> String { + let mut s = String::new(); + crate::formatter::format_entry("a", &m, &mut s).unwrap(); + s + } + + #[test] + fn modifier_parse_absolute() { + assert_eq!( + MetricModifier::parse("100").unwrap(), + MetricModifier::Absolute(100) + ); + assert_eq!( + MetricModifier::parse("-100").unwrap(), + MetricModifier::Absolute(-100) + ); + } + + #[test] + fn modifier_parse_percent() { + assert_eq!( + MetricModifier::parse("20%").unwrap(), + MetricModifier::Percent(1.2) + ); + assert_eq!( + MetricModifier::parse("-20%").unwrap(), + MetricModifier::Percent(0.8) + ); + assert_eq!( + MetricModifier::parse("0%").unwrap(), + MetricModifier::Percent(1.0) + ); + } + + #[test] + fn modifier_percent() { + assert_eq!(MetricModifier::Percent(0.8).apply_u32(100), 80); + assert_eq!(MetricModifier::Percent(1.8).apply_u32(100), 180); + } + + #[test] + fn modifier_absolute() { + assert_eq!(MetricModifier::Absolute(-100).apply_u32(100), 0); + assert_eq!(MetricModifier::Absolute(-120).apply_u32(100), 0); + assert_eq!(MetricModifier::Absolute(100).apply_u32(100), 200); + assert_eq!(MetricModifier::Absolute(-100).apply_i32(100), 0); + assert_eq!(MetricModifier::Absolute(-120).apply_i32(100), -20); + } + + #[test] + fn format_config_percent() { + let mut m = MetricModifier::default(); + m.parse_cli(Some("24%")).unwrap(); + assert_eq!(fmt1(m), "a = 24%\n"); + } + + #[test] + fn format_config_absolute() { + let mut m = MetricModifier::default(); + m.parse_cli(Some("-30")).unwrap(); + assert_eq!(fmt1(m), "a = -30\n"); + } +} diff --git a/crates/ghostty-config/src/path.rs b/crates/ghostty-config/src/path.rs new file mode 100644 index 0000000..582f951 --- /dev/null +++ b/crates/ghostty-config/src/path.rs @@ -0,0 +1,355 @@ +//! Path config values (port of `config/path.zig`). + +use crate::cli_args::{ArgError, ConfigField}; +use crate::diagnostics::{Diagnostic, DiagnosticList}; +use crate::formatter::EntryFormatter; +use std::fmt; +use std::path::{Component, Path as StdPath, PathBuf}; + +/// Path is like a string that represents a path value. The difference is that +/// when loading the configuration the value for this will be automatically +/// expanded relative to the path of the config file (or the home directory). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Path { + /// No error if the file does not exist. + Optional(String), + + /// The file is required to exist. + Required(String), +} + +impl Default for Path { + fn default() -> Self { + Path::Required(String::new()) + } +} + +/// Lexically resolve `base/path` like `std.fs.path.resolve`. +fn resolve_lexical(base: &str, path: &str) -> String { + let joined = PathBuf::from(base).join(path); + let mut out = PathBuf::new(); + for c in joined.components() { + match c { + Component::ParentDir => { + out.pop(); + } + Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out.to_string_lossy().into_owned() +} + +impl Path { + /// The inner path string. + #[must_use] + pub fn path(&self) -> &str { + match self { + Path::Optional(p) | Path::Required(p) => p, + } + } + + fn path_mut(&mut self) -> &mut String { + match self { + Path::Optional(p) | Path::Required(p) => p, + } + } + + #[must_use] + pub fn len(&self) -> usize { + self.path().len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.path().is_empty() + } + + /// Parse the input and return a Path. A leading `?` indicates that the path + /// is _optional_ and an error should not be logged or displayed to the user + /// if that path does not exist. Otherwise the path is required and an error + /// should be logged if the path does not exist. + /// + /// Returns `Ok(None)` for an empty input. + pub fn parse(input: Option<&str>) -> Result, ArgError> { + let mut value = input.ok_or(ArgError::ValueRequired)?; + + if value.is_empty() { + return Ok(None); + } + + let optional = if let Some(rest) = value.strip_prefix('?') { + value = rest; + true + } else { + false + }; + + if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + value = &value[1..value.len() - 1]; + } + + Ok(Some(if optional { + Path::Optional(value.to_string()) + } else { + Path::Required(value.to_string()) + })) + } + + /// Expand relative paths or paths prefixed with `~/`. The path will be + /// overwritten. + /// + /// `base` is the base directory to expand relative paths; it must be an + /// absolute path. Errors are added to `diags` if they occur; an `Err` is + /// only returned if the base directory cannot be opened. + pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> { + assert!(StdPath::new(base).is_absolute()); + + let path = self.path().to_string(); + + // If it is already absolute we can ignore it. + if path.is_empty() || StdPath::new(&path).is_absolute() { + return Ok(()); + } + + // Check if the path starts with a tilde and expand it to the + // home directory. We explicitly look for "~/" because we don't + // support alternate users such as "~alice/" + if path.starts_with("~/") { + match ghostty_os::expand_home(&path) { + Ok(expanded) => { + log::debug!("expanding file path from home directory: path={expanded}"); + *self.path_mut() = expanded; + } + Err(err) => { + diags.append(Diagnostic { + message: format!("error expanding home directory for path {path}: {err}"), + ..Diagnostic::default() + }); + + // Blank this path so that we don't attempt to resolve it + // again + *self = Path::Required(String::new()); + } + } + return Ok(()); + } + + // Make sure the base directory can be opened. + std::fs::read_dir(base)?; + + let full = StdPath::new(base).join(&path); + let abs = match std::fs::canonicalize(&full) { + Ok(p) => p.to_string_lossy().into_owned(), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + // The file doesn't exist. Try to resolve the relative path + // another way. + resolve_lexical(base, &path) + } + Err(err) => { + diags.append(Diagnostic { + message: format!("error resolving file path {path}: {err}"), + ..Diagnostic::default() + }); + + // Blank this path so that we don't attempt to resolve it again + *self = Path::Required(String::new()); + return Ok(()); + } + }; + + log::debug!("expanding file path relative={path} abs={abs}"); + *self.path_mut() = abs; + Ok(()) + } +} + +impl ConfigField for Path { + /// Parse CLI option. The value will be overwritten. + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let Some(item) = Path::parse(input)? else { + return Ok(()); + }; + if item.is_empty() { + return Ok(()); + } + *self = item; + Ok(()) + } + + /// Used by formatter. + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + match self { + Path::Optional(p) => writeln!(f.writer, "{} = ?{p}", f.name), + Path::Required(p) => writeln!(f.writer, "{} = {p}", f.name), + } + } +} + +/// RepeatablePath is like repeatable string but represents a path value. The +/// difference is that when loading the configuration any values for this will +/// be automatically expanded relative to the path of the config file (or the home +/// directory). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RepeatablePath { + pub value: Vec, +} + +impl RepeatablePath { + /// Expand all the paths relative to the base directory. + pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> { + for path in &mut self.value { + path.expand(base, diags)?; + } + Ok(()) + } +} + +impl ConfigField for RepeatablePath { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let Some(item) = Path::parse(input)? else { + self.value.clear(); + return Ok(()); + }; + + if item.is_empty() { + // This handles the case of zero length paths after removing any ? + // prefixes or surrounding quotes. In this case, we don't reset the + // list. + return Ok(()); + } + + self.value.push(item); + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + if self.value.is_empty() { + return writeln!(f.writer, "{} = ", f.name); + } + for item in &self.value { + item.format_entry(f)?; + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::formatter::format_entry; + + fn fmt(v: &T) -> String { + let mut s = String::new(); + format_entry("a", v, &mut s).unwrap(); + s + } + + #[test] + fn path_parse() { + let p = |s| Path::parse(Some(s)).unwrap().unwrap(); + assert_eq!(p("config.1"), Path::Required("config.1".into())); + assert_eq!(p("?config.2"), Path::Optional("config.2".into())); + assert_eq!(p("\"?config.3\""), Path::Required("?config.3".into())); + assert_eq!(p("?\"config.4\""), Path::Optional("config.4".into())); + assert_eq!(p("?"), Path::Optional(String::new())); + assert_eq!(p("\"\""), Path::Required(String::new())); + assert_eq!(p("?\"\""), Path::Optional(String::new())); + assert_eq!(Path::parse(None), Err(ArgError::ValueRequired)); + assert_eq!(Path::parse(Some("")), Ok(None)); + } + + #[test] + fn path_parse_cli() { + let mut item = Path::default(); + + item.parse_cli(Some("config.1")).unwrap(); + assert_eq!(item, Path::Required("config.1".into())); + + item.parse_cli(Some("?config.2")).unwrap(); + assert_eq!(item, Path::Optional("config.2".into())); + + item.parse_cli(Some("\"?config.3\"")).unwrap(); + assert_eq!(item, Path::Required("?config.3".into())); + + // Zero-length values, ignored + item.parse_cli(Some("?")).unwrap(); + assert_eq!(item, Path::Required("?config.3".into())); + item.parse_cli(Some("\"\"")).unwrap(); + assert_eq!(item, Path::Required("?config.3".into())); + item.parse_cli(Some("?\"\"")).unwrap(); + assert_eq!(item, Path::Required("?config.3".into())); + } + + #[test] + fn path_format_config_single_item() { + let mut item = Path::default(); + item.parse_cli(Some("A")).unwrap(); + assert_eq!(fmt(&item), "a = A\n"); + } + + #[test] + fn path_format_config_multiple_items() { + let mut item = Path::default(); + item.parse_cli(Some("A")).unwrap(); + item.parse_cli(Some("?B")).unwrap(); + assert_eq!(fmt(&item), "a = ?B\n"); + } + + #[test] + fn repeatable_path_parse_cli() { + let mut list = RepeatablePath::default(); + list.parse_cli(Some("config.1")).unwrap(); + list.parse_cli(Some("?config.2")).unwrap(); + list.parse_cli(Some("\"?config.3\"")).unwrap(); + + assert_eq!(list.value.len(), 3); + + // Zero-length values, ignored + list.parse_cli(Some("?")).unwrap(); + list.parse_cli(Some("\"\"")).unwrap(); + + assert_eq!(list.value.len(), 3); + + assert_eq!(list.value[0], Path::Required("config.1".into())); + assert_eq!(list.value[1], Path::Optional("config.2".into())); + assert_eq!(list.value[2], Path::Required("?config.3".into())); + + list.parse_cli(Some("")).unwrap(); + assert_eq!(list.value.len(), 0); + } + + #[test] + fn repeatable_path_format_config_empty() { + assert_eq!(fmt(&RepeatablePath::default()), "a = \n"); + } + + #[test] + fn repeatable_path_format_config_single_item() { + let mut list = RepeatablePath::default(); + list.parse_cli(Some("A")).unwrap(); + assert_eq!(fmt(&list), "a = A\n"); + } + + #[test] + fn repeatable_path_format_config_multiple_items() { + let mut list = RepeatablePath::default(); + list.parse_cli(Some("A")).unwrap(); + list.parse_cli(Some("?B")).unwrap(); + assert_eq!(fmt(&list), "a = A\na = ?B\n"); + } + + #[test] + fn path_expand_relative_nonexistent_and_absolute() { + let mut diags = DiagnosticList::default(); + let mut p = Path::Required("../nope/x.conf".into()); + p.expand("/tmp", &mut diags).unwrap(); + assert_eq!(p, Path::Required("/nope/x.conf".into())); + + let mut p = Path::Optional("/abs/path".into()); + p.expand("/tmp", &mut diags).unwrap(); + assert_eq!(p, Path::Optional("/abs/path".into())); + assert!(diags.is_empty()); + } +} diff --git a/crates/ghostty-config/src/repeatable_string_map.rs b/crates/ghostty-config/src/repeatable_string_map.rs new file mode 100644 index 0000000..1a50495 --- /dev/null +++ b/crates/ghostty-config/src/repeatable_string_map.rs @@ -0,0 +1,163 @@ +//! RepeatableStringMap is a key/value that can be repeated to accumulate a +//! string map. This isn't called "StringMap" because I find that sometimes +//! leads to confusion that it _accepts_ a map such as JSON dict. + +use crate::cli_args::{ArgError, ConfigField}; +use crate::formatter::EntryFormatter; +use std::fmt; + +/// std.ascii.whitespace +fn is_ascii_ws(c: char) -> bool { + matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c') +} + +/// An insertion-ordered string map. +#[derive(Debug, Clone, Default)] +pub struct RepeatableStringMap { + map: Vec<(String, String)>, +} + +impl PartialEq for RepeatableStringMap { + /// Order-insensitive comparison (Zig: `equal`). + fn eq(&self, other: &Self) -> bool { + self.map.len() == other.map.len() + && self + .map + .iter() + .all(|(k, v)| other.get(k).is_some_and(|ov| ov == v)) + } +} + +impl RepeatableStringMap { + /// The number of items in the map + #[must_use] + pub fn count(&self) -> usize { + self.map.len() + } + + #[must_use] + pub fn get(&self, key: &str) -> Option<&str> { + self.map + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } + + /// Iterator over the entries in the map (insertion order). + pub fn iter(&self) -> impl Iterator { + self.map.iter().map(|(k, v)| (k.as_str(), v.as_str())) + } +} + +impl ConfigField for RepeatableStringMap { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let value = input.ok_or(ArgError::ValueRequired)?; + + // Empty value resets the list. + if value.is_empty() { + self.map.clear(); + return Ok(()); + } + + let index = value.find('=').ok_or(ArgError::ValueRequired)?; + let key = value[..index].trim_matches(is_ascii_ws); + let val = value[index + 1..].trim_matches(is_ascii_ws); + + // Empty value removes the key from the map. + if val.is_empty() { + if let Some(pos) = self.map.iter().position(|(k, _)| k == key) { + self.map.remove(pos); + } + return Ok(()); + } + + match self.map.iter_mut().find(|(k, _)| k == key) { + Some(entry) => entry.1 = val.to_string(), + None => self.map.push((key.to_string(), val.to_string())), + } + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + // If no items, we want to render an empty field. + if self.map.is_empty() { + return writeln!(f.writer, "{} = ", f.name); + } + for (k, v) in &self.map { + writeln!(f.writer, "{} = {k}={v}", f.name)?; + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::formatter::format_entry; + + fn fmt(m: &RepeatableStringMap) -> String { + let mut s = String::new(); + format_entry("a", m, &mut s).unwrap(); + s + } + + #[test] + fn repeatable_string_map_parse_cli() { + let mut map = RepeatableStringMap::default(); + + assert_eq!(map.parse_cli(Some("A")), Err(ArgError::ValueRequired)); + + map.parse_cli(Some("A=B")).unwrap(); + map.parse_cli(Some("B=C")).unwrap(); + assert_eq!(map.count(), 2); + + map.parse_cli(Some("")).unwrap(); + assert_eq!(map.count(), 0); + + map.parse_cli(Some("A=B")).unwrap(); + assert_eq!(map.count(), 1); + map.parse_cli(Some("A=C")).unwrap(); + assert_eq!(map.count(), 1); + assert_eq!(map.get("A"), Some("C")); + + // Empty value removes the key + map.parse_cli(Some("A=")).unwrap(); + assert_eq!(map.count(), 0); + } + + #[test] + fn repeatable_string_map_format_config_empty() { + assert_eq!(fmt(&RepeatableStringMap::default()), "a = \n"); + } + + #[test] + fn repeatable_string_map_format_config_single_item() { + let mut map = RepeatableStringMap::default(); + map.parse_cli(Some("A=B")).unwrap(); + assert_eq!(fmt(&map), "a = A=B\n"); + + let mut map = RepeatableStringMap::default(); + map.parse_cli(Some(" A = B ")).unwrap(); + assert_eq!(fmt(&map), "a = A=B\n"); + } + + #[test] + fn repeatable_string_map_format_config_multiple_items() { + let mut list = RepeatableStringMap::default(); + list.parse_cli(Some("A=B")).unwrap(); + list.parse_cli(Some("B = C")).unwrap(); + assert_eq!(fmt(&list), "a = A=B\na = B=C\n"); + } + + #[test] + fn repeatable_string_map_equal_is_order_insensitive() { + let mut a = RepeatableStringMap::default(); + a.parse_cli(Some("A=B")).unwrap(); + a.parse_cli(Some("C=D")).unwrap(); + let mut b = RepeatableStringMap::default(); + b.parse_cli(Some("C=D")).unwrap(); + b.parse_cli(Some("A=B")).unwrap(); + assert_eq!(a, b); + } +} diff --git a/crates/ghostty-config/src/string.rs b/crates/ghostty-config/src/string.rs new file mode 100644 index 0000000..6944fa0 --- /dev/null +++ b/crates/ghostty-config/src/string.rs @@ -0,0 +1,221 @@ +//! String-literal escape parsing (port of `config/string.zig`). + +/// Errors from string parsing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StringError { + InvalidString, +} + +impl std::fmt::Display for StringError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("InvalidString") + } +} + +impl std::error::Error for StringError {} + +impl From for crate::cli_args::ArgError { + fn from(e: StringError) -> Self { + crate::cli_args::ArgError::Other(e.to_string()) + } +} + +/// Parse a Zig escape sequence starting at `bytes[*i]` (which must be a +/// backslash). On success advances `*i` past the sequence and returns the +/// codepoint. +fn parse_escape_sequence(bytes: &[u8], i: &mut usize) -> Option { + let mut idx = *i; + debug_assert_eq!(bytes[idx], b'\\'); + idx += 1; + let c = *bytes.get(idx)?; + idx += 1; + let cp = match c { + b'n' => u32::from(b'\n'), + b'r' => u32::from(b'\r'), + b'\\' => u32::from(b'\\'), + b't' => u32::from(b'\t'), + b'\'' => u32::from(b'\''), + b'"' => u32::from(b'"'), + b'x' => { + let h = bytes.get(idx..idx + 2)?; + let h = std::str::from_utf8(h).ok()?; + if !h.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + idx += 2; + u32::from_str_radix(h, 16).ok()? + } + b'u' => { + if *bytes.get(idx)? != b'{' { + return None; + } + idx += 1; + let start = idx; + let mut v: u32 = 0; + loop { + let b = *bytes.get(idx)?; + idx += 1; + if b == b'}' { + if idx - 1 == start { + return None; + } + break; + } + let d = (b as char).to_digit(16)?; + v = v.checked_mul(16)?.checked_add(d)?; + if v > 0x10ffff { + return None; + } + } + v + } + _ => return None, + }; + *i = idx; + Some(cp) +} + +/// Parse a string literal into a string. The string can contain +/// any valid Zig string literal escape sequences. +pub fn parse(bytes: &str) -> Result { + let b = bytes.as_bytes(); + let mut out = String::with_capacity(bytes.len()); + let mut i = 0; + while i < b.len() { + if b[i] != b'\\' { + // Copy a whole UTF-8 character. + let ch = bytes[i..] + .chars() + .next() + .ok_or(StringError::InvalidString)?; + out.push(ch); + i += ch.len_utf8(); + continue; + } + + let cp = parse_escape_sequence(b, &mut i).ok_or(StringError::InvalidString)?; + // Surrogates cannot be encoded. + out.push(char::from_u32(cp).ok_or(StringError::InvalidString)?); + } + Ok(out) +} + +/// Creates an iterator that requires no allocation to extract codepoints +/// from the string literal, parsing escape sequences as it goes. +#[must_use] +pub fn codepoint_iterator(bytes: &str) -> CodepointIterator<'_> { + CodepointIterator { bytes, i: 0 } +} + +pub struct CodepointIterator<'a> { + bytes: &'a str, + i: usize, +} + +impl CodepointIterator<'_> { + #[allow(clippy::should_implement_trait)] + pub fn next(&mut self) -> Result, StringError> { + if self.i >= self.bytes.len() { + return Ok(None); + } + if self.bytes.as_bytes()[self.i] == b'\\' { + // An escape sequence + return parse_escape_sequence(self.bytes.as_bytes(), &mut self.i) + .map(Some) + .ok_or(StringError::InvalidString); + } + + // Not an escape, parse as UTF-8 + let ch = self.bytes[self.i..] + .chars() + .next() + .ok_or(StringError::InvalidString)?; + self.i += ch.len_utf8(); + Ok(Some(u32::from(ch))) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn parse_empty() { + assert_eq!(parse("").unwrap(), ""); + } + + #[test] + fn parse_no_escapes() { + assert_eq!(parse("hello world").unwrap(), "hello world"); + } + + #[test] + fn parse_escapes() { + assert_eq!(parse("hello\\nworld").unwrap(), "hello\nworld"); + assert_eq!( + parse("hello\\u{1F601}world").unwrap(), + "hello\u{1F601}world" + ); + } + + #[test] + fn parse_invalid() { + assert_eq!(parse("\\q"), Err(StringError::InvalidString)); + assert_eq!(parse("\\"), Err(StringError::InvalidString)); + assert_eq!(parse("\\u{}"), Err(StringError::InvalidString)); + assert_eq!(parse("\\u{110000}"), Err(StringError::InvalidString)); + assert_eq!(parse("\\x4"), Err(StringError::InvalidString)); + } + + #[test] + fn codepoint_iterator_empty() { + let mut it = codepoint_iterator(""); + assert_eq!(it.next().unwrap(), None); + } + + #[test] + fn codepoint_iterator_ascii_no_escapes() { + let mut it = codepoint_iterator("abc"); + assert_eq!(it.next().unwrap(), Some('a' as u32)); + assert_eq!(it.next().unwrap(), Some('b' as u32)); + assert_eq!(it.next().unwrap(), Some('c' as u32)); + assert_eq!(it.next().unwrap(), None); + } + + #[test] + fn codepoint_iterator_multibyte_utf8() { + // │ is U+2502 (3 bytes in UTF-8) + let mut it = codepoint_iterator("a│b"); + assert_eq!(it.next().unwrap(), Some('a' as u32)); + assert_eq!(it.next().unwrap(), Some('│' as u32)); + assert_eq!(it.next().unwrap(), Some('b' as u32)); + assert_eq!(it.next().unwrap(), None); + } + + #[test] + fn codepoint_iterator_escape_sequences() { + let mut it = codepoint_iterator("a\\tb\\n\\\\"); + assert_eq!(it.next().unwrap(), Some('a' as u32)); + assert_eq!(it.next().unwrap(), Some('\t' as u32)); + assert_eq!(it.next().unwrap(), Some('b' as u32)); + assert_eq!(it.next().unwrap(), Some('\n' as u32)); + assert_eq!(it.next().unwrap(), Some('\\' as u32)); + assert_eq!(it.next().unwrap(), None); + } + + #[test] + fn codepoint_iterator_unicode_escape() { + let mut it = codepoint_iterator("\\u{2502}x"); + assert_eq!(it.next().unwrap(), Some('│' as u32)); + assert_eq!(it.next().unwrap(), Some('x' as u32)); + assert_eq!(it.next().unwrap(), None); + } + + #[test] + fn codepoint_iterator_emoji_unicode_escape() { + let mut it = codepoint_iterator("\\u{1F601}"); + assert_eq!(it.next().unwrap(), Some(0x1F601)); + assert_eq!(it.next().unwrap(), None); + } +} diff --git a/crates/ghostty-config/src/testdata/theme_dark b/crates/ghostty-config/src/testdata/theme_dark new file mode 100644 index 0000000..e07a5ad --- /dev/null +++ b/crates/ghostty-config/src/testdata/theme_dark @@ -0,0 +1 @@ +background = #EEEEEE diff --git a/crates/ghostty-config/src/testdata/theme_light b/crates/ghostty-config/src/testdata/theme_light new file mode 100644 index 0000000..3b31150 --- /dev/null +++ b/crates/ghostty-config/src/testdata/theme_light @@ -0,0 +1 @@ +background = #FFFFFF diff --git a/crates/ghostty-config/src/testdata/theme_simple b/crates/ghostty-config/src/testdata/theme_simple new file mode 100644 index 0000000..def3337 --- /dev/null +++ b/crates/ghostty-config/src/testdata/theme_simple @@ -0,0 +1,2 @@ +# A simple theme +background = #123ABC diff --git a/crates/ghostty-config/src/theme.rs b/crates/ghostty-config/src/theme.rs new file mode 100644 index 0000000..423cdff --- /dev/null +++ b/crates/ghostty-config/src/theme.rs @@ -0,0 +1,289 @@ +//! Theme file lookup (port of `config/theme.zig`). +//! +//! Zig reads the resources dir from global state; here it is passed +//! explicitly (`resources_dir`, the value of `ResourcesDir.app()`). + +use crate::diagnostics::{Diagnostic, DiagnosticList}; +use std::fs::File; +use std::path::Path; + +/// Location of possible themes. The order of this enum matters because it +/// defines the priority of theme search (from top to bottom). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Location { + /// XDG config dir + User, + /// Ghostty resources dir + Resources, +} + +impl Location { + const ALL: [Location; 2] = [Location::User, Location::Resources]; + + /// Returns the directory for the given theme based on this location type. + /// + /// This will return None if the directory type doesn't exist or is + /// invalid for any reason. For example, it is perfectly valid to + /// install and run Ghostty without the resources directory. + #[must_use] + pub fn dir(self, resources_dir: Option<&str>) -> Option { + match self { + Location::User => ghostty_os::xdg::config(&ghostty_os::xdg::Options { + subdir: Some("ghostty/themes"), + home: None, + }) + .ok(), + Location::Resources => { + let base = resources_dir?; + Some( + Path::new(base) + .join("themes") + .to_string_lossy() + .into_owned(), + ) + } + } + } +} + +/// An iterator that returns all possible directories for finding themes in +/// order of priority. +#[derive(Debug, Clone)] +pub struct LocationIterator { + pub resources_dir: Option, + pub i: usize, +} + +impl LocationIterator { + #[must_use] + pub fn new(resources_dir: Option<&str>) -> Self { + Self { + resources_dir: resources_dir.map(str::to_string), + i: 0, + } + } + + pub fn next_location(&mut self) -> Option<(Location, String)> { + while self.i < Location::ALL.len() { + let location = Location::ALL[self.i]; + self.i += 1; + if let Some(dir) = location.dir(self.resources_dir.as_deref()) { + return Some((location, dir)); + } + } + None + } + + pub fn reset(&mut self) { + self.i = 0; + } +} + +/// An opened theme. +#[derive(Debug)] +pub struct OpenedTheme { + pub path: String, + pub file: File, +} + +fn diag(diags: &mut DiagnosticList, message: String) { + diags.append(Diagnostic { + message, + ..Diagnostic::default() + }); +} + +fn kind_name(ft: std::fs::FileType) -> &'static str { + use std::os::unix::fs::FileTypeExt; + if ft.is_file() { + "file" + } else if ft.is_dir() { + "directory" + } else if ft.is_symlink() { + "sym_link" + } else if ft.is_block_device() { + "block_device" + } else if ft.is_char_device() { + "character_device" + } else if ft.is_fifo() { + "named_pipe" + } else if ft.is_socket() { + "unix_domain_socket" + } else { + "unknown" + } +} + +/// Verify that the file is a regular file, adding a diagnostic otherwise. +fn check_regular(file: &File, theme: &str, diags: &mut DiagnosticList) -> bool { + match file.metadata() { + Err(err) => { + diag(diags, format!("not reading theme from \"{theme}\": {err}")); + false + } + Ok(m) if !m.is_file() => { + diag( + diags, + format!( + "not reading theme from \"{theme}\": it is a {}", + kind_name(m.file_type()) + ), + ); + false + } + Ok(_) => true, + } +} + +/// Open the given named theme. If there are any errors then messages +/// will be appended to the given error list and None is returned. If +/// a Some return value is returned, there are never any errors added. +/// +/// This will never return anything other than a handle to a regular file. If +/// the theme resolves to something other than a regular file a diagnostic entry +/// will be added to the list and None will be returned. +pub fn open( + theme: &str, + resources_dir: Option<&str>, + diags: &mut DiagnosticList, +) -> Option { + // Absolute themes are loaded a different path. + if Path::new(theme).is_absolute() { + let file = open_absolute(theme, diags)?; + if !check_regular(&file, theme, diags) { + return None; + } + return Some(OpenedTheme { + path: theme.to_string(), + file, + }); + } + + let basename = Path::new(theme) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + if theme != basename { + diag( + diags, + format!( + "theme \"{theme}\" cannot include path separators unless it is an absolute path" + ), + ); + return None; + } + + // Iterate over the possible locations to try to find the + // one that exists. + let mut it = LocationIterator::new(resources_dir); + while let Some((_, dir)) = it.next_location() { + let path = Path::new(&dir).join(theme).to_string_lossy().into_owned(); + match File::open(&path) { + Ok(file) => { + if !check_regular(&file, theme, diags) { + return None; + } + return Some(OpenedTheme { path, file }); + } + // Not an error, just continue to the next location. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + // Anything else is an error we log and give up on. + Err(err) => { + diag( + diags, + format!("failed to load theme \"{theme}\" from the file \"{path}\": {err}"), + ); + return None; + } + } + } + + // Unlikely scenario: the theme doesn't exist. In this case, we reset + // our iterator, reiterate over in order to build a better error message. + it.reset(); + while let Some((_, dir)) = it.next_location() { + let path = Path::new(&dir).join(theme).to_string_lossy().into_owned(); + diag( + diags, + format!("theme \"{theme}\" not found, tried path \"{path}\""), + ); + } + + None +} + +/// Open the given theme from an absolute path. If there are any errors +/// then messages will be appended to the given error list and None is +/// returned. +pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option { + match File::open(theme) { + Ok(f) => Some(f), + Err(err) => { + if err.kind() == std::io::ErrorKind::NotFound { + diag( + diags, + format!("failed to load theme from the path \"{theme}\""), + ); + } else { + diag( + diags, + format!("failed to load theme from the path \"{theme}\": {err}"), + ); + } + None + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn theme_path_separators_rejected() { + let mut d = DiagnosticList::default(); + assert!(open("a/b", None, &mut d).is_none()); + assert!( + d.items()[0] + .message + .contains("cannot include path separators") + ); + } + + #[test] + fn theme_absolute_missing_and_dir() { + let mut d = DiagnosticList::default(); + assert!(open("/nonexistent/ghostty/theme", None, &mut d).is_none()); + assert_eq!( + d.items()[0].message, + "failed to load theme from the path \"/nonexistent/ghostty/theme\"" + ); + let mut d = DiagnosticList::default(); + assert!(open("/tmp", None, &mut d).is_none()); + assert_eq!( + d.items()[0].message, + "not reading theme from \"/tmp\": it is a directory" + ); + } + + #[test] + fn theme_found_in_resources() { + let dir = std::env::temp_dir().join(format!("ghostty-theme-test-{}", std::process::id())); + std::fs::create_dir_all(dir.join("themes")).unwrap(); + std::fs::write(dir.join("themes/mytheme-zzz"), "background = #000000\n").unwrap(); + let mut d = DiagnosticList::default(); + let t = open("mytheme-zzz", Some(dir.to_str().unwrap()), &mut d).unwrap(); + assert!(t.path.ends_with("themes/mytheme-zzz")); + assert!(d.is_empty()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn theme_not_found_lists_tried_paths() { + let mut d = DiagnosticList::default(); + assert!(open("nope-zzz", Some("/nonexistent-res"), &mut d).is_none()); + assert!(d.items().iter().any(|x| x.message + == "theme \"nope-zzz\" not found, tried path \"/nonexistent-res/themes/nope-zzz\"")); + } +} diff --git a/crates/ghostty-config/src/types_a.rs b/crates/ghostty-config/src/types_a.rs new file mode 100644 index 0000000..bdeb500 --- /dev/null +++ b/crates/ghostty-config/src/types_a.rs @@ -0,0 +1,1577 @@ +//! Config field types that were nested in `Config.zig` (part A): simple +//! enums, working directory, colors, palette, repeatable strings, word +//! chars, font variation / codepoint maps, font style, links. + +use crate::cli_args::{ArgError, ConfigField, parse_int_base0, trim_ws}; +use crate::clipboard_codepoint_map::{ClipboardCodepointMap, Entry as ClipboardEntry, Replacement}; +use crate::font_types::{CodepointMap, CodepointMapEntry, Descriptor, Variation, VariationId}; +use crate::formatter::EntryFormatter; +use crate::{config_enum, config_packed}; +use ghostty_vt::color::{DEFAULT as DEFAULT_PALETTE, Palette as TermPalette, PaletteMask, Rgb}; +use std::fmt; + +/// Write `name = value\n` for a string-ish value. +fn write_str(f: &mut EntryFormatter<'_>, value: &str) -> fmt::Result { + writeln!(f.writer, "{} = {}", f.name, value) +} + +/// Write the `void` form of an entry (`name = \n`). +fn write_void(f: &mut EntryFormatter<'_>) -> fmt::Result { + writeln!(f.writer, "{} = ", f.name) +} + +// --------------------------------------------------------------------- +// Cursor style (ZIG/src/terminal/cursor.zig `Style`), duplicated here +// because config cannot depend on ghostty-terminal. + +config_enum! { + /// The visual style of the cursor. Whether or not it blinks + /// is determined by mode 12 (modes.zig). This mode is synchronized + /// with CSI q, the same as xterm. + pub enum CursorStyle { + /// DECSCUSR 5, 6 + Bar => "bar", + /// DECSCUSR 1, 2 + Block => "block", + /// DECSCUSR 3, 4 + Underline => "underline", + /// Hollow block cursor. This is a block cursor with the center empty. + /// Reported as DECSCUSR 1 or 2 (block). + BlockHollow => "block_hollow", + } +} + +// --------------------------------------------------------------------- +// Simple enums + +config_enum! { + pub enum ConfirmCloseSurface { + False => "false", + True => "true", + Always => "always", + } +} + +config_enum! { + /// Valid values for custom-shader-animation + pub enum CustomShaderAnimation { + False => "false", + True => "true", + Always => "always", + } +} + +config_enum! { + /// Valid values for macos-non-native-fullscreen + pub enum NonNativeFullscreen { + False => "false", + True => "true", + VisibleMenu => "visible-menu", + PaddedNotch => "padded-notch", + } +} + +config_enum! { + /// Valid values for fullscreen config option + pub enum Fullscreen { + False => "false", + True => "true", + NonNative => "non-native", + NonNativeVisibleMenu => "non-native-visible-menu", + NonNativePaddedNotch => "non-native-padded-notch", + } +} + +config_enum! { + pub enum WindowPaddingColor { + Background => "background", + Extend => "extend", + ExtendAlways => "extend-always", + } +} + +config_enum! { + pub enum WindowSubtitle { + False => "false", + WorkingDirectory => "working-directory", + } +} + +config_enum! { + pub enum LinkPreviews { + False => "false", + True => "true", + Osc8 => "osc8", + } +} + +// --------------------------------------------------------------------- +// WorkingDirectory + +/// See working-directory +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum WorkingDirectory { + /// Resolve to the current user's home directory during config finalize. + Home, + + /// Inherit the working directory from the launching process. + #[default] + Inherit, + + /// Use an explicit working directory path. This may be not be + /// expanded until finalize is called. + Path(String), +} + +impl ConfigField for WorkingDirectory { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let input = input.ok_or(ArgError::ValueRequired)?; + let mut input = input.trim_matches(|c: char| { + // std.ascii.whitespace + matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c') + }); + if input.is_empty() { + return Err(ArgError::ValueRequired); + } + + // Match path.zig behavior for quoted values. + if input.len() >= 2 && input.starts_with('"') && input.ends_with('"') { + input = &input[1..input.len() - 1]; + } + + *self = match input { + "home" => WorkingDirectory::Home, + "inherit" => WorkingDirectory::Inherit, + _ => WorkingDirectory::Path(input.to_string()), + }; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + match self { + WorkingDirectory::Home => write_str(f, "home"), + WorkingDirectory::Inherit => write_str(f, "inherit"), + WorkingDirectory::Path(p) => write_str(f, p), + } + } +} + +impl WorkingDirectory { + /// Expand tilde paths in .path values. + pub fn finalize(&mut self) { + let WorkingDirectory::Path(path) = self else { + return; + }; + + if !path.starts_with("~/") { + return; + } + + let expanded = match ghostty_os::expand_home(path) { + Ok(e) => e, + Err(err) => { + log::warn!( + "error expanding home directory for working-directory path={path}: {err}" + ); + return; + } + }; + + if expanded == *path { + return; + } + *self = WorkingDirectory::Path(expanded); + } + + #[must_use] + pub fn value(&self) -> Option<&str> { + match self { + WorkingDirectory::Path(p) => Some(p), + WorkingDirectory::Home | WorkingDirectory::Inherit => None, + } + } +} + +// --------------------------------------------------------------------- +// Colors + +/// Color represents a color using RGB. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct Color { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl Color { + /// Convert this to the terminal RGB struct + #[must_use] + pub fn to_terminal_rgb(self) -> Rgb { + Rgb { + r: self.r, + g: self.g, + b: self.b, + } + } + + /// Zig: `Color.parseCLI(input)` (static form returning a new value). + pub fn from_cli(input: Option<&str>) -> Result { + let input = input.ok_or(ArgError::ValueRequired)?; + // Trim any whitespace before processing + let trimmed = trim_ws(input); + + if let Some(rgb) = ghostty_vt::x11_color::get(trimmed) { + return Ok(Color { + r: rgb.r, + g: rgb.g, + b: rgb.b, + }); + } + + Color::from_hex(trimmed) + } + + /// Format the color as a string. + #[must_use] + pub fn format_buf(self) -> String { + format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b) + } + + /// fromHex parses a color from a hex value such as #RRGGBB. The "#" + /// is optional. + pub fn from_hex(input: &str) -> Result { + // Trim the beginning '#' if it exists + let trimmed = input.strip_prefix('#').unwrap_or(input).as_bytes(); + if trimmed.len() != 6 && trimmed.len() != 3 { + return Err(ArgError::InvalidValue); + } + + // Expand short hex values to full hex values + let rgb: Vec = if trimmed.len() == 3 { + vec![ + trimmed[0], trimmed[0], trimmed[1], trimmed[1], trimmed[2], trimmed[2], + ] + } else { + trimmed.to_vec() + }; + + let digit = |c: u8| -> Result { + (c as char) + .to_digit(16) + .map(|d| d as u8) + .ok_or_else(|| ArgError::other("InvalidCharacter")) + }; + let byte = + |i: usize| -> Result { Ok(digit(rgb[i])? * 16 + digit(rgb[i + 1])?) }; + + Ok(Color { + r: byte(0)?, + g: byte(2)?, + b: byte(4)?, + }) + } +} + +impl ConfigField for Color { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + *self = Color::from_cli(input)?; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + write_str(f, &self.format_buf()) + } +} + +/// Represents color values that can also reference special color +/// values such as "cell-foreground" or "cell-background". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalColor { + Color(Color), + CellForeground, + CellBackground, +} + +impl Default for TerminalColor { + fn default() -> Self { + TerminalColor::Color(Color::default()) + } +} + +impl TerminalColor { + /// Zig: `TerminalColor.parseCLI(input)`. + pub fn from_cli(input: Option<&str>) -> Result { + let input = input.ok_or(ArgError::ValueRequired)?; + match input { + "cell-foreground" => Ok(TerminalColor::CellForeground), + "cell-background" => Ok(TerminalColor::CellBackground), + _ => Ok(TerminalColor::Color(Color::from_cli(Some(input))?)), + } + } + + #[must_use] + pub fn to_terminal_rgb(self) -> Option { + match self { + TerminalColor::Color(v) => Some(v.to_terminal_rgb()), + TerminalColor::CellForeground | TerminalColor::CellBackground => None, + } + } +} + +impl ConfigField for TerminalColor { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + *self = TerminalColor::from_cli(input)?; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + match self { + TerminalColor::Color(c) => c.format_entry(f), + TerminalColor::CellForeground => write_str(f, "cell-foreground"), + TerminalColor::CellBackground => write_str(f, "cell-background"), + } + } +} + +/// Represents color values that can be used for bold. See `bold-color`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoldColor { + Color(Color), + Bright, +} + +impl Default for BoldColor { + fn default() -> Self { + BoldColor::Color(Color::default()) + } +} + +impl BoldColor { + /// Zig: `BoldColor.parseCLI(input)`. + pub fn from_cli(input: Option<&str>) -> Result { + let input = input.ok_or(ArgError::ValueRequired)?; + if input == "bright" { + return Ok(BoldColor::Bright); + } + Ok(BoldColor::Color(Color::from_cli(Some(input))?)) + } +} + +impl ConfigField for BoldColor { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + *self = BoldColor::from_cli(input)?; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + match self { + BoldColor::Color(c) => c.format_entry(f), + BoldColor::Bright => write_str(f, "bright"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ColorList { + pub colors: Vec, +} + +impl ConfigField for ColorList { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let input = input.ok_or(ArgError::ValueRequired)?; + if input.is_empty() { + return Err(ArgError::ValueRequired); + } + + // Always reset on parse + *self = ColorList::default(); + + // Split the input by commas and parse each color + let mut count = 0usize; + for raw in input.split(',').filter(|s| !s.is_empty()) { + count += 1; + if count > 64 { + return Err(ArgError::InvalidValue); + } + + // Trim whitespace from each color value + let trimmed = trim_ws(raw); + let color = Color::from_cli(Some(trimmed))?; + self.colors.push(color); + } + + // If no colors were parsed, we need to return an error + if self.colors.is_empty() { + return Err(ArgError::InvalidValue); + } + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + // If no items, we want to render an empty field. + if self.colors.is_empty() { + return write_void(f); + } + let parts: Vec = self.colors.iter().map(|c| c.format_buf()).collect(); + write_str(f, &parts.join(",")) + } +} + +/// Palette is the 256 color palette for 256-color mode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Palette { + /// The actual value that is updated as we parse. + pub value: TermPalette, + + /// Keep track of which indexes were manually set by the user. + pub mask: PaletteMask, +} + +impl Default for Palette { + fn default() -> Self { + Self { + value: DEFAULT_PALETTE, + mask: PaletteMask::init_empty(), + } + } +} + +impl ConfigField for Palette { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let value = input.ok_or(ArgError::ValueRequired)?; + let eql_idx = value.find('=').ok_or(ArgError::InvalidValue)?; + + // Parse the key part (trim whitespace) + let key_str = trim_ws(&value[..eql_idx]); + let key = match parse_int_base0(key_str) { + None => return Err(ArgError::other("InvalidCharacter")), + Some(n) if n < 0 => return Err(ArgError::other("InvalidCharacter")), + Some(n) if n > 255 => return Err(ArgError::other("Overflow")), + Some(n) => n as usize, + }; + + // Parse the color part (Color parsing will handle whitespace) + let rgb = Color::from_cli(Some(&value[eql_idx + 1..]))?; + self.value[key] = rgb.to_terminal_rgb(); + self.mask.set(key); + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + for (k, v) in self.value.iter().enumerate() { + write_str(f, &format!("{}=#{:02x}{:02x}{:02x}", k, v.r, v.g, v.b))?; + } + Ok(()) + } +} + +// --------------------------------------------------------------------- +// RepeatableString + +/// RepeatableString is a string value that can be repeated to accumulate +/// a list of strings. This isn't called "StringList" because I find that +/// sometimes leads to confusion that it _accepts_ a list such as +/// comma-separated values. +#[derive(Debug, Clone, Default)] +pub struct RepeatableString { + pub list: Vec, + + /// If true, then the next value will clear the list and start over + /// rather than append. This is a bit of a hack but is here to make + /// the font-family set of configurations work with CLI parsing. + pub overwrite_next: bool, +} + +/// Compare if two of our value are equal (Zig `equal`: only the items +/// are compared, not `overwrite_next`). +impl PartialEq for RepeatableString { + fn eq(&self, other: &Self) -> bool { + self.list == other.list + } +} + +impl RepeatableString { + /// The number of items in the list + #[must_use] + pub fn count(&self) -> usize { + self.list.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.list.is_empty() + } +} + +impl ConfigField for RepeatableString { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let value = input.ok_or(ArgError::ValueRequired)?; + + // Empty value resets the list + if value.is_empty() { + self.list.clear(); + return Ok(()); + } + + // If we're overwriting then we clear before appending + if self.overwrite_next { + self.list.clear(); + self.overwrite_next = false; + } + + self.list.push(value.to_string()); + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + // If no items, we want to render an empty field. + if self.list.is_empty() { + return write_void(f); + } + for v in &self.list { + write_str(f, v)?; + } + Ok(()) + } +} + +// --------------------------------------------------------------------- +// SelectionWordChars + +/// SelectionWordChars stores the parsed codepoints for word boundary +/// characters used during text selection. The string is parsed once +/// during configuration and stored as codepoints for efficient +/// lookup during selection operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectionWordChars { + /// The parsed codepoints. Always includes null (U+0000) at index 0. + pub codepoints: Vec, +} + +impl SelectionWordChars { + /// Default boundary characters: ` \t'"│`|:;,()[]{}<>$` + pub const DEFAULT_CODEPOINTS: [u32; 20] = [ + 0, // null + ' ' as u32, // space + '\t' as u32, // tab + '\'' as u32, // single quote + '"' as u32, // double quote + '│' as u32, // U+2502 box drawing + '`' as u32, // backtick + '|' as u32, // pipe + ':' as u32, // colon + ';' as u32, // semicolon + ',' as u32, // comma + '(' as u32, // left paren + ')' as u32, // right paren + '[' as u32, // left bracket + ']' as u32, // right bracket + '{' as u32, // left brace + '}' as u32, // right brace + '<' as u32, // less than + '>' as u32, // greater than + '$' as u32, // dollar + ]; +} + +impl Default for SelectionWordChars { + fn default() -> Self { + Self { + codepoints: Self::DEFAULT_CODEPOINTS.to_vec(), + } + } +} + +impl ConfigField for SelectionWordChars { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let value = input.ok_or(ArgError::ValueRequired)?; + + // Always include null as first boundary + let mut list = vec![0u32]; + let mut it = crate::string::codepoint_iterator(value); + while let Some(cp) = it.next().map_err(|_| ArgError::InvalidValue)? { + list.push(cp); + } + self.codepoints = list; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + // Convert codepoints back to UTF-8 string for display, skipping the + // null character at index 0. + let mut s = String::new(); + for &cp in self.codepoints.iter().skip(1) { + let Some(ch) = char::from_u32(cp) else { + continue; + }; + if s.len() + ch.len_utf8() > 4096 { + break; + } + s.push(ch); + } + write_str(f, &s) + } +} + +// --------------------------------------------------------------------- +// RepeatableFontVariation + +/// FontVariation is a repeatable configuration value that sets a single +/// font variation value. Font variations are configurations for what +/// are often called "variable fonts." The font files usually end in +/// "-VF.ttf." +/// +/// The value for this is in the format of `id=value` where `id` is the +/// 4-character font variation axis identifier and `value` is the +/// floating point value for that axis. For more details on font variations +/// see the MDN font-variation-settings documentation since this copies that +/// behavior almost exactly: +/// +/// https://developer.mozilla.org/en-US/docs/Web/CSS/font-variation-settings +#[derive(Debug, Clone, PartialEq, Default)] +pub struct RepeatableFontVariation { + pub list: Vec, +} + +impl ConfigField for RepeatableFontVariation { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let input = input.ok_or(ArgError::ValueRequired)?; + let eql_idx = input.find('=').ok_or(ArgError::InvalidValue)?; + let key = trim_ws(&input[..eql_idx]); + let value = trim_ws(&input[eql_idx + 1..]); + let key: &[u8; 4] = key + .as_bytes() + .try_into() + .map_err(|_| ArgError::InvalidValue)?; + self.list.push(Variation { + id: VariationId::init(key), + value: value.parse::().map_err(|_| ArgError::InvalidValue)?, + }); + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + if self.list.is_empty() { + return write_void(f); + } + for v in &self.list { + let id = v.id.str(); + write_str(f, &format!("{}={}", String::from_utf8_lossy(&id), v.value))?; + } + Ok(()) + } +} + +// --------------------------------------------------------------------- +// Codepoint maps + +/// Parses the list of Unicode codepoint ranges. Valid syntax: +/// +/// "" (empty returns null) +/// U+1234 +/// U+1234-5678 +/// U+1234,U+5678 +/// U+1234,U+5678-U+9ABC +/// U+1234-5678,U+5678 +/// +/// etc. +pub(crate) struct UnicodeRangeParser<'a> { + input: &'a [u8], + i: usize, +} + +impl<'a> UnicodeRangeParser<'a> { + pub(crate) fn new(input: &'a str) -> Self { + Self { + input: input.as_bytes(), + i: 0, + } + } + + pub(crate) fn next_range(&mut self) -> Result, ArgError> { + // Once we're EOF then we're done without an error. + if self.eof() { + return Ok(None); + } + + // One codepoint no matter what + let start = self.parse_codepoint()?; + if self.eof() { + return Ok(Some([start, start])); + } + + // We're allowed to have any whitespace here + self.consume_whitespace(); + + // Otherwise we expect either a range or a comma + match self.input.get(self.i) { + // Comma means we have another codepoint but in a different + // range so we return our current codepoint. + Some(b',') => { + self.advance(); + self.consume_whitespace(); + if self.eof() { + return Err(ArgError::InvalidValue); + } + Ok(Some([start, start])) + } + + // Hyphen means we have a range. + Some(b'-') => { + self.advance(); + self.consume_whitespace(); + if self.eof() { + return Err(ArgError::InvalidValue); + } + let end = self.parse_codepoint()?; + self.consume_whitespace(); + if !self.eof() && self.input[self.i] != b',' { + return Err(ArgError::InvalidValue); + } + self.advance(); + self.consume_whitespace(); + if start > end { + return Err(ArgError::InvalidValue); + } + Ok(Some([start, end])) + } + + // Note: the Zig code indexes input[i] here, which can be past + // the end after trailing whitespace; that is safety-checked UB + // in Zig, we report an invalid value instead. + _ => Err(ArgError::InvalidValue), + } + } + + fn consume_whitespace(&mut self) { + while !self.eof() { + match self.input[self.i] { + b' ' | b'\t' => self.advance(), + _ => return, + } + } + } + + fn parse_codepoint(&mut self) -> Result { + if self.input.get(self.i) != Some(&b'U') { + return Err(ArgError::InvalidValue); + } + self.advance(); + if self.eof() { + return Err(ArgError::InvalidValue); + } + if self.input[self.i] != b'+' { + return Err(ArgError::InvalidValue); + } + self.advance(); + if self.eof() { + return Err(ArgError::InvalidValue); + } + + let start_i = self.i; + loop { + let current = self.input[self.i]; + if !current.is_ascii_hexdigit() { + break; + } + + // Advance but break on EOF + self.advance(); + if self.eof() { + break; + } + } + + // If we didn't consume a single character, we have an error. + if start_i == self.i { + return Err(ArgError::InvalidValue); + } + + parse_u21_hex(&self.input[start_i..self.i]).ok_or(ArgError::InvalidValue) + } + + fn advance(&mut self) { + self.i += 1; + } + + fn eof(&self) -> bool { + self.i >= self.input.len() + } +} + +/// `std.fmt.parseInt(u21, s, 16)` for plain hex digit strings. +fn parse_u21_hex(s: &[u8]) -> Option { + let s = std::str::from_utf8(s).ok()?; + let v = u32::from_str_radix(s, 16).ok()?; + (v <= 0x1f_ffff).then_some(v) +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct RepeatableCodepointMap { + pub map: CodepointMap, +} + +impl ConfigField for RepeatableCodepointMap { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let input = input.ok_or(ArgError::ValueRequired)?; + let eql_idx = input.find('=').ok_or(ArgError::InvalidValue)?; + let key = trim_ws(&input[..eql_idx]); + let value = trim_ws(&input[eql_idx + 1..]); + + let mut p = UnicodeRangeParser::new(key); + while let Some(range) = p.next_range()? { + self.map.add(CodepointMapEntry { + range, + descriptor: Descriptor { + family: Some(value.to_string()), + monospace: false, // we allow any font + ..Descriptor::default() + }, + }); + } + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + if self.map.list.is_empty() { + return write_void(f); + } + + for e in &self.map.list { + let family = e.descriptor.family.as_deref().unwrap_or(""); + if e.range[0] == e.range[1] { + write_str(f, &format!("U+{:04X}={}", e.range[0], family))?; + } else { + write_str( + f, + &format!("U+{:04X}-U+{:04X}={}", e.range[0], e.range[1], family), + )?; + } + } + Ok(()) + } +} + +/// See "clipboard-codepoint-map" for documentation. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct RepeatableClipboardCodepointMap { + pub map: ClipboardCodepointMap, +} + +impl ConfigField for RepeatableClipboardCodepointMap { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let input = input.ok_or(ArgError::ValueRequired)?; + let eql_idx = input.find('=').ok_or(ArgError::InvalidValue)?; + let key = trim_ws(&input[..eql_idx]); + let value = trim_ws(&input[eql_idx + 1..]); + + // Parse the replacement value - either a codepoint or string + let replacement = if let Some(cp_str) = value.strip_prefix("U+") { + // Parse as codepoint + let cp = parse_u21_hex(cp_str.as_bytes()).ok_or(ArgError::InvalidValue)?; + Replacement::Codepoint(cp) + } else { + // `value` is a &str so it is already valid UTF-8. + Replacement::String(value.to_string()) + }; + + let mut p = UnicodeRangeParser::new(key); + while let Some(range) = p.next_range()? { + self.map.add(ClipboardEntry { + range, + replacement: replacement.clone(), + }); + } + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + if self.map.list.is_empty() { + return write_void(f); + } + + for e in &self.map.list { + let value_str = match &e.replacement { + Replacement::Codepoint(cp) => format!("U+{cp:04X}"), + Replacement::String(s) => s.clone(), + }; + + if e.range[0] == e.range[1] { + write_str(f, &format!("U+{:04X}={}", e.range[0], value_str))?; + } else { + write_str( + f, + &format!("U+{:04X}-U+{:04X}={}", e.range[0], e.range[1], value_str), + )?; + } + } + Ok(()) + } +} + +// --------------------------------------------------------------------- +// Font style + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum FontStyle { + /// Use the default font style that font discovery finds. + #[default] + Default, + + /// Disable this font style completely. This will fall back to using + /// the regular font when this style is encountered. + False, + + /// A specific named font style to use for this style. + Name(String), +} + +impl FontStyle { + /// Returns the string name value that can be used with a font + /// descriptor. + #[must_use] + pub fn name_value(&self) -> Option<&str> { + match self { + FontStyle::Default | FontStyle::False => None, + FontStyle::Name(n) => Some(n), + } + } +} + +impl ConfigField for FontStyle { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let value = input.ok_or(ArgError::ValueRequired)?; + *self = match value { + "default" => FontStyle::Default, + "false" => FontStyle::False, + _ => FontStyle::Name(value.to_string()), + }; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + match self { + FontStyle::Default => write_str(f, "default"), + FontStyle::False => write_str(f, "false"), + FontStyle::Name(n) => write_str(f, n), + } + } +} + +config_packed! { + /// See `font-synthetic-style` for documentation. + pub struct FontSyntheticStyle { + "bold" => bold: true, + "italic" => italic: true, + "bold-italic" => bold_italic: true, + } +} + +config_packed! { + /// See "font-shaping-break" for documentation + pub struct FontShapingBreak { + "cursor" => cursor: true, + } +} + +// --------------------------------------------------------------------- +// RepeatableLink + +/// See "link" for documentation. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct RepeatableLink { + pub links: Vec, +} + +impl ConfigField for RepeatableLink { + fn parse_cli(&mut self, _input: Option<&str>) -> Result<(), ArgError> { + Err(ArgError::other("NotImplemented")) + } + + fn format_entry(&self, _f: &mut EntryFormatter<'_>) -> fmt::Result { + // This currently can't be set so we don't format anything. + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::cli_args::ConfigField; + + fn fe(v: &T) -> String { + let mut s = String::new(); + let mut f = EntryFormatter::new("a", &mut s); + v.format_entry(&mut f).unwrap(); + s + } + + fn parse(v: &mut T, s: &str) -> Result<(), ArgError> { + v.parse_cli(Some(s)) + } + + mod working_directory { + use super::*; + + #[test] + fn working_directory_parse_cli() { + let mut wd = WorkingDirectory::Inherit; + parse(&mut wd, "inherit").unwrap(); + assert_eq!(wd, WorkingDirectory::Inherit); + parse(&mut wd, "home").unwrap(); + assert_eq!(wd, WorkingDirectory::Home); + parse(&mut wd, "~/projects/ghostty").unwrap(); + assert_eq!(wd, WorkingDirectory::Path("~/projects/ghostty".into())); + parse(&mut wd, "\"/tmp path\"").unwrap(); + assert_eq!(wd, WorkingDirectory::Path("/tmp path".into())); + } + + #[test] + fn working_directory_finalize() { + let mut wd = WorkingDirectory::Path("~/projects/ghostty".into()); + wd.finalize(); + let expected = ghostty_os::expand_home("~/projects/ghostty") + .unwrap_or_else(|_| "~/projects/ghostty".to_string()); + assert_eq!(wd.value().unwrap(), expected); + } + } + + mod color { + use super::*; + + #[test] + fn from_hex() { + let c = |r, g, b| Color { r, g, b }; + assert_eq!(Color::from_hex("#000000").unwrap(), c(0, 0, 0)); + assert_eq!(Color::from_hex("#0A0B0C").unwrap(), c(10, 11, 12)); + assert_eq!(Color::from_hex("0A0B0C").unwrap(), c(10, 11, 12)); + assert_eq!(Color::from_hex("FFFFFF").unwrap(), c(255, 255, 255)); + assert_eq!(Color::from_hex("FFF").unwrap(), c(255, 255, 255)); + assert_eq!(Color::from_hex("#345").unwrap(), c(51, 68, 85)); + } + + #[test] + fn parse_cli_from_name() { + assert_eq!( + Color::from_cli(Some("black")).unwrap(), + Color { r: 0, g: 0, b: 0 } + ); + } + + #[test] + fn format_config() { + let color = Color { + r: 10, + g: 11, + b: 12, + }; + assert_eq!(fe(&color), "a = #0a0b0c\n"); + } + + #[test] + fn parse_cli_with_whitespace() { + assert_eq!( + Color::from_cli(Some(" #AABBCC ")).unwrap(), + Color { + r: 0xAA, + g: 0xBB, + b: 0xCC + } + ); + assert_eq!( + Color::from_cli(Some(" black ")).unwrap(), + Color { r: 0, g: 0, b: 0 } + ); + } + } + + mod terminal_color { + use super::*; + + #[test] + fn parse_cli() { + assert_eq!( + TerminalColor::from_cli(Some("#4e2a84")).unwrap(), + TerminalColor::Color(Color { + r: 78, + g: 42, + b: 132 + }) + ); + assert_eq!( + TerminalColor::from_cli(Some("black")).unwrap(), + TerminalColor::Color(Color { r: 0, g: 0, b: 0 }) + ); + assert_eq!( + TerminalColor::from_cli(Some("cell-foreground")).unwrap(), + TerminalColor::CellForeground + ); + assert_eq!( + TerminalColor::from_cli(Some("cell-background")).unwrap(), + TerminalColor::CellBackground + ); + assert_eq!( + TerminalColor::from_cli(Some("a")), + Err(ArgError::InvalidValue) + ); + } + + #[test] + fn format_config() { + assert_eq!(fe(&TerminalColor::CellForeground), "a = cell-foreground\n"); + } + } + + mod bold_color { + use super::*; + + #[test] + fn parse_cli() { + assert_eq!( + BoldColor::from_cli(Some("#4e2a84")).unwrap(), + BoldColor::Color(Color { + r: 78, + g: 42, + b: 132 + }) + ); + assert_eq!( + BoldColor::from_cli(Some("black")).unwrap(), + BoldColor::Color(Color { r: 0, g: 0, b: 0 }) + ); + assert_eq!( + BoldColor::from_cli(Some("bright")).unwrap(), + BoldColor::Bright + ); + assert_eq!(BoldColor::from_cli(Some("a")), Err(ArgError::InvalidValue)); + } + + #[test] + fn format_config() { + assert_eq!(fe(&BoldColor::Bright), "a = bright\n"); + } + } + + mod color_list { + use super::*; + + #[test] + fn parse_cli() { + let mut p = ColorList::default(); + parse(&mut p, "black,white").unwrap(); + assert_eq!(p.colors.len(), 2); + + // Test whitespace handling + parse(&mut p, "black, white").unwrap(); + assert_eq!(p.colors.len(), 2); + parse(&mut p, "black , white").unwrap(); + assert_eq!(p.colors.len(), 2); + parse(&mut p, " black , white ").unwrap(); + assert_eq!(p.colors.len(), 2); + + // Error cases + assert_eq!(p.parse_cli(None), Err(ArgError::ValueRequired)); + assert_eq!(parse(&mut p, " "), Err(ArgError::InvalidValue)); + } + + #[test] + fn format() { + let mut p = ColorList::default(); + parse(&mut p, "black,white").unwrap(); + assert_eq!(fe(&p), "a = #000000,#ffffff\n"); + } + + #[test] + fn clone() { + let mut source = ColorList::default(); + parse(&mut source, "#ff0000,#00ff00,#0000ff").unwrap(); + let cloned = source.clone(); + assert_eq!(source, cloned); + } + } + + mod palette { + use super::*; + + #[test] + fn parse_cli() { + let mut p = Palette::default(); + parse(&mut p, "0=#AABBCC").unwrap(); + assert_eq!(p.value[0].r, 0xAA); + assert_eq!(p.value[0].g, 0xBB); + assert_eq!(p.value[0].b, 0xCC); + assert!(p.mask.is_set(0)); + assert!(!p.mask.is_set(1)); + } + + #[test] + fn parse_cli_base() { + let mut p = Palette::default(); + parse(&mut p, "0b1=#014589").unwrap(); + parse(&mut p, "0o7=#234567").unwrap(); + parse(&mut p, "0xF=#ABCDEF").unwrap(); + + assert_eq!( + (p.value[1].r, p.value[1].g, p.value[1].b), + (0x01, 0x45, 0x89) + ); + assert_eq!( + (p.value[7].r, p.value[7].g, p.value[7].b), + (0x23, 0x45, 0x67) + ); + assert_eq!( + (p.value[15].r, p.value[15].g, p.value[15].b), + (0xAB, 0xCD, 0xEF) + ); + + assert!(p.mask.is_set(1)); + assert!(p.mask.is_set(7)); + assert!(p.mask.is_set(15)); + assert!(!p.mask.is_set(0)); + assert!(!p.mask.is_set(2)); + } + + #[test] + fn parse_cli_overflow() { + let mut p = Palette::default(); + assert_eq!( + parse(&mut p, "256=#AABBCC"), + Err(ArgError::other("Overflow")) + ); + // Mask should remain empty since parsing failed. + assert_eq!(p.mask.count(), 0); + } + + #[test] + fn format_config() { + let list = Palette::default(); + assert_eq!(&fe(&list)[0..14], "a = 0=#1d1f21\n"); + } + + #[test] + fn parse_cli_with_whitespace() { + let mut p = Palette::default(); + parse(&mut p, "0 = #AABBCC").unwrap(); + parse(&mut p, " 1= #DDEEFF ").unwrap(); + parse(&mut p, " 2 = #123456 ").unwrap(); + + assert_eq!( + (p.value[0].r, p.value[0].g, p.value[0].b), + (0xAA, 0xBB, 0xCC) + ); + assert_eq!( + (p.value[1].r, p.value[1].g, p.value[1].b), + (0xDD, 0xEE, 0xFF) + ); + assert_eq!( + (p.value[2].r, p.value[2].g, p.value[2].b), + (0x12, 0x34, 0x56) + ); + + assert!(p.mask.is_set(0)); + assert!(p.mask.is_set(1)); + assert!(p.mask.is_set(2)); + assert!(!p.mask.is_set(3)); + } + } + + mod repeatable_string { + use super::*; + + #[test] + fn parse_cli() { + let mut list = RepeatableString::default(); + parse(&mut list, "A").unwrap(); + parse(&mut list, "B").unwrap(); + assert_eq!(list.list.len(), 2); + + parse(&mut list, "").unwrap(); + assert_eq!(list.list.len(), 0); + } + + #[test] + fn parse_cli_overwrite() { + let mut list = RepeatableString::default(); + parse(&mut list, "A").unwrap(); + + // Set our overwrite flag + list.overwrite_next = true; + + parse(&mut list, "B").unwrap(); + assert_eq!(list.list.len(), 1); + parse(&mut list, "C").unwrap(); + assert_eq!(list.list.len(), 2); + } + + #[test] + fn format_config_empty() { + assert_eq!(fe(&RepeatableString::default()), "a = \n"); + } + + #[test] + fn format_config_single_item() { + let mut list = RepeatableString::default(); + parse(&mut list, "A").unwrap(); + assert_eq!(fe(&list), "a = A\n"); + } + + #[test] + fn format_config_multiple_items() { + let mut list = RepeatableString::default(); + parse(&mut list, "A").unwrap(); + parse(&mut list, "B").unwrap(); + assert_eq!(fe(&list), "a = A\na = B\n"); + } + + #[test] + fn equality_ignores_overwrite_next() { + let mut a = RepeatableString::default(); + let b = RepeatableString::default(); + a.overwrite_next = true; + assert_eq!(a, b); + } + } + + mod selection_word_chars { + use super::*; + + #[test] + fn parse_cli() { + let mut chars = SelectionWordChars::default(); + parse(&mut chars, " \t;,").unwrap(); + assert_eq!( + chars.codepoints, + vec![0, ' ' as u32, '\t' as u32, ';' as u32, ',' as u32] + ); + } + + #[test] + fn parse_cli_escape_sequences() { + let mut chars = SelectionWordChars::default(); + parse(&mut chars, " \\t;,").unwrap(); + assert_eq!( + chars.codepoints, + vec![0, ' ' as u32, '\t' as u32, ';' as u32, ',' as u32] + ); + } + + #[test] + fn parse_cli_backslash_escape() { + let mut chars = SelectionWordChars::default(); + parse(&mut chars, "\\\\;").unwrap(); + assert_eq!(chars.codepoints, vec![0, '\\' as u32, ';' as u32]); + } + + #[test] + fn parse_cli_unicode_escape() { + let mut chars = SelectionWordChars::default(); + parse(&mut chars, "\\u{2502};").unwrap(); + assert_eq!(chars.codepoints, vec![0, '│' as u32, ';' as u32]); + } + + #[test] + fn format_roundtrip() { + let chars = SelectionWordChars::default(); + assert_eq!(fe(&chars), "a = \t'\"│`|:;,()[]{}<>$\n"); + } + } + + mod font_variation { + use super::*; + + #[allow(clippy::trivially_copy_pass_by_ref)] + fn v(id: &[u8; 4], value: f64) -> Variation { + Variation { + id: VariationId::init(id), + value, + } + } + + #[test] + fn parse_cli() { + let mut list = RepeatableFontVariation::default(); + parse(&mut list, "wght=200").unwrap(); + parse(&mut list, "slnt=-15").unwrap(); + assert_eq!(list.list, vec![v(b"wght", 200.0), v(b"slnt", -15.0)]); + } + + #[test] + fn parse_cli_with_whitespace() { + let mut list = RepeatableFontVariation::default(); + parse(&mut list, "wght =200").unwrap(); + parse(&mut list, "slnt= -15").unwrap(); + assert_eq!(list.list, vec![v(b"wght", 200.0), v(b"slnt", -15.0)]); + } + + #[test] + fn format_config_single() { + let mut list = RepeatableFontVariation::default(); + parse(&mut list, "wght = 200").unwrap(); + assert_eq!(fe(&list), "a = wght=200\n"); + } + } + + mod codepoint_map { + use super::*; + + fn fam(map: &RepeatableCodepointMap, i: usize) -> (&[u32; 2], &str) { + let e = &map.map.list[i]; + (&e.range, e.descriptor.family.as_deref().unwrap()) + } + + #[test] + fn parse_cli() { + let mut list = RepeatableCodepointMap::default(); + parse(&mut list, "U+ABCD=Comic Sans").unwrap(); + parse(&mut list, "U+0001 - U+0005=Verdana").unwrap(); + parse(&mut list, "U+0006-U+0009, U+ABCD=Courier").unwrap(); + + assert_eq!(list.map.list.len(), 4); + assert_eq!(fam(&list, 0), (&[0xABCD, 0xABCD], "Comic Sans")); + assert_eq!(fam(&list, 1), (&[1, 5], "Verdana")); + assert_eq!(fam(&list, 2), (&[6, 9], "Courier")); + assert_eq!(fam(&list, 3), (&[0xABCD, 0xABCD], "Courier")); + } + + #[test] + fn format_config_single() { + let mut list = RepeatableCodepointMap::default(); + parse(&mut list, "U+ABCD=Comic Sans").unwrap(); + assert_eq!(fe(&list), "a = U+ABCD=Comic Sans\n"); + } + + #[test] + fn format_config_range() { + let mut list = RepeatableCodepointMap::default(); + parse(&mut list, "U+0001 - U+0005=Verdana").unwrap(); + assert_eq!(fe(&list), "a = U+0001-U+0005=Verdana\n"); + } + + #[test] + fn format_config_multiple() { + let mut list = RepeatableCodepointMap::default(); + parse(&mut list, "U+0006-U+0009, U+ABCD=Courier").unwrap(); + assert_eq!(fe(&list), "a = U+0006-U+0009=Courier\na = U+ABCD=Courier\n"); + } + + #[test] + fn invalid_ranges() { + let mut list = RepeatableCodepointMap::default(); + assert_eq!(parse(&mut list, "U+5-U+1=X"), Err(ArgError::InvalidValue)); + assert_eq!(parse(&mut list, "U+1,=X"), Err(ArgError::InvalidValue)); + assert_eq!(parse(&mut list, "U+=X"), Err(ArgError::InvalidValue)); + assert_eq!(parse(&mut list, "nope"), Err(ArgError::InvalidValue)); + } + } + + mod clipboard_codepoint_map { + use super::*; + + #[test] + fn parse_cli_codepoint_replacement() { + let mut list = RepeatableClipboardCodepointMap::default(); + parse(&mut list, "U+2500=U+002D").unwrap(); + assert_eq!(list.map.list.len(), 1); + let e = &list.map.list[0]; + assert_eq!(e.range, [0x2500, 0x2500]); + assert_eq!(e.replacement, Replacement::Codepoint(0x002D)); + } + + #[test] + fn parse_cli_string_replacement() { + let mut list = RepeatableClipboardCodepointMap::default(); + parse(&mut list, "U+03A3=SUM").unwrap(); + assert_eq!(list.map.list.len(), 1); + let e = &list.map.list[0]; + assert_eq!(e.range, [0x03A3, 0x03A3]); + assert_eq!(e.replacement, Replacement::String("SUM".into())); + } + + #[test] + fn parse_cli_range_replacement() { + let mut list = RepeatableClipboardCodepointMap::default(); + parse(&mut list, "U+2500-U+2503=|").unwrap(); + assert_eq!(list.map.list.len(), 1); + let e = &list.map.list[0]; + assert_eq!(e.range, [0x2500, 0x2503]); + assert_eq!(e.replacement, Replacement::String("|".into())); + } + + #[test] + fn format_config_codepoint() { + let mut list = RepeatableClipboardCodepointMap::default(); + parse(&mut list, "U+2500=U+002D").unwrap(); + assert_eq!(fe(&list), "a = U+2500=U+002D\n"); + } + + #[test] + fn format_config_string() { + let mut list = RepeatableClipboardCodepointMap::default(); + parse(&mut list, "U+03A3=SUM").unwrap(); + assert_eq!(fe(&list), "a = U+03A3=SUM\n"); + } + } + + mod font_style { + use super::*; + + #[test] + fn parse_cli() { + let mut p = FontStyle::Default; + parse(&mut p, "default").unwrap(); + assert_eq!(p, FontStyle::Default); + parse(&mut p, "false").unwrap(); + assert_eq!(p, FontStyle::False); + parse(&mut p, "bold").unwrap(); + assert_eq!(p, FontStyle::Name("bold".into())); + } + + #[test] + fn format_config_default() { + let mut p = FontStyle::Default; + parse(&mut p, "default").unwrap(); + assert_eq!(fe(&p), "a = default\n"); + } + + #[test] + fn format_config_false() { + let mut p = FontStyle::Default; + parse(&mut p, "false").unwrap(); + assert_eq!(fe(&p), "a = false\n"); + } + + #[test] + fn format_config_named() { + let mut p = FontStyle::Default; + parse(&mut p, "bold").unwrap(); + assert_eq!(fe(&p), "a = bold\n"); + } + } + + #[test] + fn packed_and_enum_formats() { + assert_eq!( + fe(&FontSyntheticStyle::default()), + "a = bold,italic,bold-italic\n" + ); + let mut s = FontSyntheticStyle::default(); + parse(&mut s, "no-bold-italic").unwrap(); + assert!(!s.bold_italic && s.bold); + assert_eq!(fe(&FontShapingBreak::default()), "a = cursor\n"); + assert_eq!( + fe(&Fullscreen::NonNativePaddedNotch), + "a = non-native-padded-notch\n" + ); + let mut c = CursorStyle::default(); + parse(&mut c, "block_hollow").unwrap(); + assert_eq!(c, CursorStyle::BlockHollow); + } + + #[test] + fn repeatable_link_not_implemented() { + let mut l = RepeatableLink::default(); + assert_eq!(parse(&mut l, "x"), Err(ArgError::other("NotImplemented"))); + assert_eq!(fe(&l), ""); + } +} diff --git a/crates/ghostty-config/src/types_b.rs b/crates/ghostty-config/src/types_b.rs new file mode 100644 index 0000000..f30a69d --- /dev/null +++ b/crates/ghostty-config/src/types_b.rs @@ -0,0 +1,1640 @@ +//! Types nested in `Config.zig` (lines ~8566-10146): enums, packed structs +//! and small structs used as config field values. + +#![allow(clippy::format_push_string, clippy::single_match_else)] + +use crate::cli_args::{ArgError, ConfigField, parse_auto_struct_with, parse_bool, trim_ws}; +use crate::formatter::EntryFormatter; +use crate::{config_enum, config_packed}; +use ghostty_input::binding::Action; +use std::fmt; + +/// Write `name = \n` (Zig: `formatEntry([]const u8, s)`). +fn fmt_str(f: &mut EntryFormatter<'_>, s: &str) -> fmt::Result { + writeln!(f.writer, "{} = {}", f.name, s) +} + +config_enum! { + /// Options for copy on select behavior. + pub enum CopyOnSelect { + /// Disables copy on select entirely. + False => "false", + + /// Copy on select is enabled, but goes to the selection clipboard. + /// This is not supported on platforms such as macOS. This is the default. + True => "true", + + /// Copy on select is enabled and goes to both the system clipboard + /// and the selection clipboard (for Linux). + Clipboard => "clipboard", + } +} + +config_enum! { + /// Options for right-click actions. + pub enum RightClickAction { + /// No action is taken on right-click. + Ignore => "ignore", + + /// Pastes from the system clipboard. + Paste => "paste", + + /// Copies the selected text to the system clipboard. + Copy => "copy", + + /// Copies the selected text to the system clipboard and + /// pastes the clipboard if no text is selected. + CopyOrPaste => "copy-or-paste", + + /// Shows a context menu with options. + ContextMenu => "context-menu", + } +} + +config_enum! { + /// Shell integration values + pub enum ShellIntegration { + None => "none", + Detect => "detect", + Bash => "bash", + Elvish => "elvish", + Fish => "fish", + Nushell => "nushell", + Zsh => "zsh", + } +} + +config_packed! { + /// Shell integration features + pub struct ShellIntegrationFeatures { + "cursor" => cursor: true, + "sudo" => sudo: false, + "title" => title: true, + "ssh-env" => ssh_env: false, + "ssh-terminfo" => ssh_terminfo: false, + "path" => path: true, + } +} + +config_packed! { + pub struct SplitPreserveZoom { + "navigation" => navigation: false, + } +} + +use ghostty_input::command::Command; + +/// Parse a command from `title:...,description:...,action:...` +/// (Zig: `parseAutoStruct(inputpkg.Command, alloc, input, null)`). +pub fn parse_command_auto(input: &str) -> Result { + let mut action: Option = None; + let mut title: Option = None; + let mut description = String::new(); + parse_auto_struct_with(input, |k, v| { + match k { + "action" => { + action = Some(Action::parse(v).map_err(|e| ArgError::Other(format!("{e:?}")))?); + } + "title" => title = Some(v.to_string()), + "description" => description = v.to_string(), + _ => return Ok(false), + } + Ok(true) + })?; + Ok(Command { + action: action.ok_or(ArgError::InvalidValue)?, + title: title.ok_or(ArgError::InvalidValue)?, + description, + }) +} + +fn default_commands() -> Vec { + ghostty_input::command::defaults().to_vec() +} + +/// Escape a string like Zig's `std.zig.fmtString`: `\n \r \t \\ \"` are +/// escaped by name and every other byte outside printable ASCII is written +/// as `\xNN` (so multi-byte UTF-8 is escaped per byte). +fn zig_fmt_string(s: &str) -> String { + use std::fmt::Write; + let mut out = String::new(); + for &b in s.as_bytes() { + match b { + b'\n' => out.push_str("\\n"), + b'\r' => out.push_str("\\r"), + b'\t' => out.push_str("\\t"), + b'\\' => out.push_str("\\\\"), + b'"' => out.push_str("\\\""), + b' '..=b'~' => out.push(b as char), + _ => { + let _ = write!(out, "\\x{b:02x}"); + } + } + } + out +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct RepeatableCommand { + pub value: Vec, +} + +impl RepeatableCommand { + /// Reset to the default command list (Zig: `init`). + pub fn init(&mut self) { + self.value = default_commands(); + } +} + +impl ConfigField for RepeatableCommand { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + // Unset or empty input clears the list + let input = input.unwrap_or(""); + if input.is_empty() { + self.value.clear(); + return Ok(()); + } + + let cmd = parse_command_auto(input)?; + self.value.push(cmd); + Ok(()) + } + + /// Zig: `init` -- resets to the default command list. + fn reset(&mut self, _default: Self) { + self.value = default_commands(); + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + if self.value.is_empty() { + return writeln!(f.writer, "{} = ", f.name); + } + + for item in &self.value { + let mut s = format!("title:\"{}\"", zig_fmt_string(&item.title)); + if !item.description.is_empty() { + s.push_str(&format!( + ",description:\"{}\"", + zig_fmt_string(&item.description) + )); + } + s.push_str(&format!(",action:\"{}\"", item.action)); + fmt_str(f, &s)?; + } + Ok(()) + } +} + +config_enum! { + /// OSC 4, 10, 11, and 12 default color reporting format. + pub enum OSCColorReportFormat { + None => "none", + Bit8 => "8-bit", + Bit16 => "16-bit", + } +} + +config_enum! { + /// The default window theme. + pub enum WindowTheme { + Auto => "auto", + System => "system", + Light => "light", + Dark => "dark", + Ghostty => "ghostty", + } +} + +config_enum! { + /// See window-colorspace + pub enum WindowColorspace { + Srgb => "srgb", + DisplayP3 => "display-p3", + } +} + +config_enum! { + /// See macos-window-buttons + pub enum MacWindowButtons { + Visible => "visible", + Hidden => "hidden", + } +} + +config_enum! { + /// See macos-titlebar-style + pub enum MacTitlebarStyle { + Native => "native", + Transparent => "transparent", + Tabs => "tabs", + Hidden => "hidden", + } +} + +config_enum! { + /// See macos-titlebar-proxy-icon + pub enum MacTitlebarProxyIcon { + Visible => "visible", + Hidden => "hidden", + } +} + +config_enum! { + /// See macos-hidden + pub enum MacHidden { + Never => "never", + Always => "always", + } +} + +config_enum! { + /// See macos-icon + /// + /// Note: future versions of Ghostty can support a custom icon with + /// path by changing this to a tagged union, which doesn't change our + /// format at all. + pub enum MacAppIcon { + Official => "official", + Blueprint => "blueprint", + Chalkboard => "chalkboard", + Microchip => "microchip", + Glass => "glass", + Holographic => "holographic", + Paper => "paper", + Retro => "retro", + Xray => "xray", + Custom => "custom", + CustomStyle => "custom-style", + } +} + +config_enum! { + /// See macos-icon-frame + pub enum MacAppIconFrame { + Aluminum => "aluminum", + Beige => "beige", + Plastic => "plastic", + Chrome => "chrome", + } +} + +config_enum! { + /// See macos-shortcuts + pub enum MacShortcuts { + Allow => "allow", + Deny => "deny", + Ask => "ask", + } +} + +config_enum! { + /// See gtk-single-instance + pub enum GtkSingleInstance { + False => "false", + True => "true", + Detect => "detect", + } +} + +impl GtkSingleInstance { + /// Zig: `pub const default: GtkSingleInstance = .detect;` + pub const DEFAULT: GtkSingleInstance = GtkSingleInstance::Detect; +} + +config_enum! { + /// See gtk-tabs-location + pub enum GtkTabsLocation { + Top => "top", + Bottom => "bottom", + } +} + +config_enum! { + /// See gtk-toolbar-style + pub enum GtkToolbarStyle { + Flat => "flat", + Raised => "raised", + RaisedBorder => "raised-border", + } +} + +config_enum! { + /// See gtk-titlebar-style + pub enum GtkTitlebarStyle { + Native => "native", + Tabs => "tabs", + } +} + +config_packed! { + /// See app-notifications + pub struct AppNotifications { + "clipboard-copy" => clipboard_copy: true, + "config-reload" => config_reload: true, + } +} + +config_packed! { + /// See bell-features + pub struct BellFeatures { + "system" => system: false, + "audio" => audio: false, + "attention" => attention: true, + "title" => title: true, + "border" => border: false, + } +} + +config_enum! { + /// See mouse-shift-capture + pub enum MouseShiftCapture { + False => "false", + True => "true", + Always => "always", + Never => "never", + } +} + +/// See mouse-scroll-multiplier +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MouseScrollMultiplier { + pub precision: f64, + pub discrete: f64, +} + +impl Default for MouseScrollMultiplier { + fn default() -> Self { + Self { + precision: 1.0, + discrete: 3.0, + } + } +} + +impl MouseScrollMultiplier { + /// Zig: `pub const default`. + pub const DEFAULT: MouseScrollMultiplier = MouseScrollMultiplier { + precision: 1.0, + discrete: 3.0, + }; +} + +impl ConfigField for MouseScrollMultiplier { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let input = input.ok_or(ArgError::ValueRequired)?; + // Unset fields inherit from the current value. + let mut result = *self; + let r = parse_auto_struct_with(input, |k, v| { + match k { + "precision" => result.precision.parse_cli(Some(v))?, + "discrete" => result.discrete.parse_cli(Some(v))?, + _ => return Ok(false), + } + Ok(true) + }); + match r { + Ok(()) => *self = result, + Err(ArgError::InvalidValue) => { + let v: f64 = input.parse().map_err(|_| ArgError::InvalidValue)?; + *self = MouseScrollMultiplier { + precision: v, + discrete: v, + }; + } + Err(e) => return Err(e), + } + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + fmt_str( + f, + &format!("precision:{},discrete:{}", self.precision, self.discrete), + ) + } +} + +config_enum! { + /// How to treat requests to write to or read from the clipboard + pub enum ClipboardAccess { + Allow => "allow", + Deny => "deny", + Ask => "ask", + } +} + +config_enum! { + /// See window-save-state + pub enum WindowSaveState { + Default => "default", + Never => "never", + Always => "always", + } +} + +config_enum! { + /// See window-new-tab-position + pub enum WindowNewTabPosition { + Current => "current", + End => "end", + } +} + +config_enum! { + /// See macos-dock-drop-behavior + pub enum MacOSDockDropBehavior { + NewTab => "new-tab", + NewWindow => "new-window", + } +} + +config_enum! { + /// See window-show-tab-bar + pub enum WindowShowTabBar { + Always => "always", + Auto => "auto", + Never => "never", + } +} + +config_enum! { + /// See resize-overlay + pub enum ResizeOverlay { + Always => "always", + Never => "never", + AfterFirst => "after-first", + } +} + +config_enum! { + /// See resize-overlay-position + pub enum ResizeOverlayPosition { + Center => "center", + TopLeft => "top-left", + TopCenter => "top-center", + TopRight => "top-right", + BottomLeft => "bottom-left", + BottomCenter => "bottom-center", + BottomRight => "bottom-right", + } +} + +config_enum! { + /// See quick-terminal-position + pub enum QuickTerminalPosition { + Top => "top", + Bottom => "bottom", + Left => "left", + Right => "right", + Center => "center", + } +} + +config_enum! { + /// See quick-terminal-layer + pub enum QuickTerminalLayer { + Overlay => "overlay", + Top => "top", + Bottom => "bottom", + Background => "background", + } +} + +/// A size of the quick terminal along one axis. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum QuickTerminalSizeValue { + Percentage(f32), + Pixels(u32), +} + +impl QuickTerminalSizeValue { + #[must_use] + pub fn to_pixels(self, parent_dimensions: u32) -> u32 { + match self { + Self::Percentage(v) => { + let dim = parent_dimensions as f32; + (v / 100.0 * dim) as u32 + } + Self::Pixels(v) => v, + } + } + + pub fn parse(input: &str) -> Result { + if input.is_empty() { + return Err(ArgError::ValueRequired); + } + + if let Some(num) = input.strip_suffix("px") { + return Ok(Self::Pixels( + num.parse::().map_err(|_| ArgError::InvalidValue)?, + )); + } + + if let Some(num) = input.strip_suffix('%') { + let percentage = num.parse::().map_err(|_| ArgError::InvalidValue)?; + if percentage < 0.0 { + return Err(ArgError::InvalidValue); + } + return Ok(Self::Percentage(percentage)); + } + + Err(ArgError::other("MissingUnit")) + } + + fn format(self, out: &mut String) { + match self { + Self::Percentage(v) => out.push_str(&format!("{v}%")), + Self::Pixels(v) => out.push_str(&format!("{v}px")), + } + } +} + +/// See quick-terminal-size +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct QuickTerminalSize { + pub primary: Option, + pub secondary: Option, +} + +/// Dimensions in pixels (Zig: `QuickTerminalSize.Dimensions`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QuickTerminalDimensions { + pub width: u32, + pub height: u32, +} + +impl QuickTerminalSize { + #[must_use] + pub fn calculate( + &self, + position: QuickTerminalPosition, + dims: QuickTerminalDimensions, + ) -> QuickTerminalDimensions { + let p = self.primary; + let s = self.secondary; + match position { + QuickTerminalPosition::Left | QuickTerminalPosition::Right => QuickTerminalDimensions { + width: p.map_or(400, |v| v.to_pixels(dims.width)), + height: s.map_or(dims.height, |v| v.to_pixels(dims.height)), + }, + QuickTerminalPosition::Top | QuickTerminalPosition::Bottom => QuickTerminalDimensions { + width: s.map_or(dims.width, |v| v.to_pixels(dims.width)), + height: p.map_or(400, |v| v.to_pixels(dims.height)), + }, + QuickTerminalPosition::Center => { + if dims.width >= dims.height { + QuickTerminalDimensions { + width: p.map_or(800, |v| v.to_pixels(dims.width)), + height: s.map_or(400, |v| v.to_pixels(dims.height)), + } + } else { + QuickTerminalDimensions { + width: s.map_or(400, |v| v.to_pixels(dims.width)), + height: p.map_or(800, |v| v.to_pixels(dims.height)), + } + } + } + } + } +} + +impl ConfigField for QuickTerminalSize { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let input = input.ok_or(ArgError::ValueRequired)?; + let mut it = input.split(','); + + let primary = trim_ws(it.next().ok_or(ArgError::ValueRequired)?); + self.primary = Some(QuickTerminalSizeValue::parse(primary)?); + + self.secondary = match it.next() { + Some(s) => Some(QuickTerminalSizeValue::parse(trim_ws(s))?), + None => None, + }; + + if it.next().is_some() { + return Err(ArgError::other("TooManyArguments")); + } + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + let Some(primary) = self.primary else { + return Ok(()); + }; + let mut s = String::new(); + primary.format(&mut s); + if let Some(secondary) = self.secondary { + s.push(','); + secondary.format(&mut s); + } + fmt_str(f, &s) + } +} + +config_enum! { + /// See quick-terminal-screen + pub enum QuickTerminalScreen { + Main => "main", + Mouse => "mouse", + MacosMenuBar => "macos-menu-bar", + } +} + +config_enum! { + /// See quick-terminal-space-behavior + pub enum QuickTerminalSpaceBehavior { + Remain => "remain", + Move => "move", + } +} + +config_enum! { + /// See quick-terminal-keyboard-interactivity + pub enum QuickTerminalKeyboardInteractivity { + None => "none", + OnDemand => "on-demand", + Exclusive => "exclusive", + } +} + +config_enum! { + /// See grapheme-width-method + pub enum GraphemeWidthMethod { + Legacy => "legacy", + Unicode => "unicode", + } +} + +config_enum! { + /// See alpha-blending + pub enum AlphaBlending { + Native => "native", + Linear => "linear", + LinearCorrected => "linear-corrected", + } +} + +impl AlphaBlending { + #[must_use] + pub fn is_linear(&self) -> bool { + match self { + AlphaBlending::Native => false, + AlphaBlending::Linear | AlphaBlending::LinearCorrected => true, + } + } +} + +config_enum! { + /// See background-image-position + pub enum BackgroundImagePosition { + TopLeft => "top-left", + TopCenter => "top-center", + TopRight => "top-right", + CenterLeft => "center-left", + CenterCenter => "center-center", + CenterRight => "center-right", + BottomLeft => "bottom-left", + BottomCenter => "bottom-center", + BottomRight => "bottom-right", + Center => "center", + } +} + +config_enum! { + /// See background-image-fit + pub enum BackgroundImageFit { + Contain => "contain", + Cover => "cover", + Stretch => "stretch", + None => "none", + } +} + +config_packed! { + /// See freetype-load-flag + pub struct FreetypeLoadFlags { + // The defaults here at the time of writing this match the defaults + // for Freetype itself. Ghostty hasn't made any opinionated changes + // to these defaults. (Strictly speaking, `light` isn't FreeType's + // own default, but appears to be the effective default with most + // Fontconfig-aware software using FreeType, so until Ghostty + // implements Fontconfig support we default to `light`.) + "hinting" => hinting: true, + "force-autohint" => force_autohint: false, + "monochrome" => monochrome: false, + "autohint" => autohint: true, + "light" => light: true, + } +} + +config_enum! { + /// See linux-cgroup + pub enum LinuxCgroup { + Never => "never", + Always => "always", + SingleInstance => "single-instance", + } +} + +config_enum! { + /// See async-backend + pub enum AsyncBackend { + Auto => "auto", + Epoll => "epoll", + IoUring => "io_uring", + } +} + +config_enum! { + /// See auto-updates + pub enum AutoUpdate { + Off => "off", + Check => "check", + Download => "download", + } +} + +/// See background-blur +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BackgroundBlur { + #[default] + False, + True, + MacosGlassRegular, + MacosGlassClear, + Radius(u8), +} + +impl BackgroundBlur { + #[must_use] + pub fn enabled(&self) -> bool { + match self { + BackgroundBlur::False => false, + BackgroundBlur::True => true, + BackgroundBlur::Radius(v) => *v > 0, + // We treat these as true because they both imply some blur! + // This has the effect of making the standard blur happen on + // Linux. + BackgroundBlur::MacosGlassRegular | BackgroundBlur::MacosGlassClear => true, + } + } +} + +impl ConfigField for BackgroundBlur { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let Some(input) = input else { + // Emulate behavior for bools + *self = BackgroundBlur::True; + return Ok(()); + }; + + // Try to parse normal bools + if let Ok(b) = parse_bool(input) { + *self = if b { + BackgroundBlur::True + } else { + BackgroundBlur::False + }; + return Ok(()); + } + + // Try to parse enums (only void variants can be parsed this way) + match input { + "macos-glass-regular" => { + *self = BackgroundBlur::MacosGlassRegular; + return Ok(()); + } + "macos-glass-clear" => { + *self = BackgroundBlur::MacosGlassClear; + return Ok(()); + } + _ => {} + } + + let mut r = 0u8; + r.parse_cli(Some(input)) + .map_err(|_| ArgError::InvalidValue)?; + *self = BackgroundBlur::Radius(r); + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + match self { + BackgroundBlur::False => f.format_entry(&false), + BackgroundBlur::True => f.format_entry(&true), + BackgroundBlur::Radius(v) => f.format_entry(v), + BackgroundBlur::MacosGlassRegular => fmt_str(f, "macos-glass-regular"), + BackgroundBlur::MacosGlassClear => fmt_str(f, "macos-glass-clear"), + } + } +} + +/// See window-decoration +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)] +pub enum WindowDecoration { + #[default] + Auto, + Client, + Server, + None, +} + +impl WindowDecoration { + pub const NAMES: &'static [&'static str] = &["auto", "client", "server", "none"]; + + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + WindowDecoration::Auto => "auto", + WindowDecoration::Client => "client", + WindowDecoration::Server => "server", + WindowDecoration::None => "none", + } + } + + pub fn parse(input: Option<&str>) -> Result { + let Some(input) = input else { + return Ok(WindowDecoration::Auto); + }; + + if let Ok(b) = parse_bool(input) { + return Ok(if b { + WindowDecoration::Auto + } else { + WindowDecoration::None + }); + } + match input { + "auto" => Ok(WindowDecoration::Auto), + "client" => Ok(WindowDecoration::Client), + "server" => Ok(WindowDecoration::Server), + "none" => Ok(WindowDecoration::None), + _ => Err(ArgError::InvalidValue), + } + } +} + +impl ConfigField for WindowDecoration { + const VALID_VALUES: Option<&'static [&'static str]> = Some(Self::NAMES); + + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + *self = Self::parse(input)?; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + fmt_str(f, self.as_str()) + } +} + +/// See theme +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Theme { + pub light: String, + pub dark: String, +} + +impl ConfigField for Theme { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + let input = input.ok_or(ArgError::ValueRequired)?; + if input.is_empty() { + return Err(ArgError::ValueRequired); + } + + // If there is a comma, equal sign, or colon, then we assume that + // we're parsing a light/dark mode theme pair. Note that "=" isn't + // actually valid for setting a light/dark mode pair but I anticipate + // it'll be a common typo. + if input.contains([',', '=', ':']) { + let mut light: Option = None; + let mut dark: Option = None; + parse_auto_struct_with(input, |k, v| { + match k { + "light" => light = Some(v.to_string()), + "dark" => dark = Some(v.to_string()), + _ => return Ok(false), + } + Ok(true) + })?; + // Both fields are required. + *self = Theme { + light: light.ok_or(ArgError::InvalidValue)?, + dark: dark.ok_or(ArgError::InvalidValue)?, + }; + return Ok(()); + } + + // Trim our value and set the value to the specified value directly. + let trimmed = trim_ws(input); + *self = Theme { + light: trimmed.to_string(), + dark: trimmed.to_string(), + }; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + if self.light == self.dark { + return fmt_str(f, &self.light); + } + fmt_str(f, &format!("light:{},dark:{}", self.light, self.dark)) + } +} + +/// A duration, stored in nanoseconds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Duration { + /// Duration in nanoseconds + pub duration: u64, +} + +const NS_PER_US: u64 = 1000; +const NS_PER_MS: u64 = 1000 * NS_PER_US; +const NS_PER_S: u64 = 1000 * NS_PER_MS; +const NS_PER_MIN: u64 = 60 * NS_PER_S; +const NS_PER_HOUR: u64 = 60 * NS_PER_MIN; +const NS_PER_DAY: u64 = 24 * NS_PER_HOUR; +const NS_PER_WEEK: u64 = 7 * NS_PER_DAY; + +/// Duration units. The order is important as the first factor that matches +/// will be the default unit that is used for formatting. +pub const DURATION_UNITS: &[(&str, u64)] = &[ + ("y", 365 * NS_PER_DAY), + ("w", NS_PER_WEEK), + ("d", NS_PER_DAY), + ("h", NS_PER_HOUR), + ("m", NS_PER_MIN), + ("s", NS_PER_S), + ("ms", NS_PER_MS), + ("µs", NS_PER_US), + ("us", NS_PER_US), + ("ns", 1), +]; + +/// `std.fmt.parseUnsigned(u64, s, 10)`. +fn parse_unsigned_u64(s: &[u8]) -> Option { + if s.is_empty() || !s.iter().all(u8::is_ascii_digit) { + return None; + } + let mut acc: u64 = 0; + for &c in s { + acc = acc.checked_mul(10)?.checked_add(u64::from(c - b'0'))?; + } + Some(acc) +} + +impl Duration { + #[must_use] + pub fn round(self, to: u64) -> Duration { + Duration { + duration: self.duration / to * to, + } + } + + #[must_use] + pub fn lte(self, other: Duration) -> bool { + self.duration <= other.duration + } + + pub fn parse(input: Option<&str>) -> Result { + let mut remaining = input.ok_or(ArgError::ValueRequired)?.as_bytes(); + + let mut value: Option = None; + while !remaining.is_empty() { + // Skip over whitespace before the number + while let Some(&c) = remaining.first() { + if matches!(c, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c) { + remaining = &remaining[1..]; + } else { + break; + } + } + + // There was whitespace at the end, that's OK + if remaining.is_empty() { + break; + } + + // Find the longest number + let number: u64 = { + let mut prev_number: Option = None; + let mut prev_remaining: Option<&[u8]> = None; + let mut result: Option> = None; + for index in 1..=remaining.len() { + if let Some(n) = parse_unsigned_u64(&remaining[..index]) { + prev_number = Some(n); + prev_remaining = Some(&remaining[index..]); + } else { + if let Some(prev) = prev_remaining { + remaining = prev; + } + result = Some(prev_number); + break; + } + } + match result { + Some(r) => r, + None => { + if let Some(prev) = prev_remaining { + remaining = prev; + } + prev_number + } + } + } + .ok_or(ArgError::InvalidValue)?; + + // A number without a unit is invalid unless the number is + // exactly zero. In that case, the unit is unambiguous since + // its all the same. + if remaining.is_empty() { + if number == 0 { + value = Some(0); + break; + } + return Err(ArgError::InvalidValue); + } + + // Find the longest matching unit. Needs to be the longest matching + // to distinguish 'm' from 'ms'. + let factor: u64 = { + let mut prev_factor: Option = None; + let mut prev_index: Option = None; + for index in 1..=remaining.len() { + let next = DURATION_UNITS + .iter() + .find(|(name, _)| name.as_bytes() == &remaining[..index]) + .map(|(_, f)| *f); + if let Some(next) = next { + prev_factor = Some(next); + prev_index = Some(index); + } + } + if let Some(index) = prev_index { + remaining = &remaining[index..]; + } + prev_factor + } + .ok_or(ArgError::InvalidValue)?; + + // Add our time value to the total. Avoid overflow with saturating math. + let diff = number.saturating_mul(factor); + value = Some(value.unwrap_or(0).saturating_add(diff)); + } + + match value { + Some(v) => Ok(Duration { duration: v }), + None => Err(ArgError::ValueRequired), + } + } + + /// Format like Zig's `Duration.format`, e.g. `1m 30s`. + pub fn format(&self, writer: &mut dyn fmt::Write) -> fmt::Result { + let mut value = self.duration; + let mut i = 0usize; + for (name, factor) in DURATION_UNITS { + if value >= *factor { + if i > 0 { + writer.write_str(" ")?; + } + let remainder = value % factor; + let quotient = (value - remainder) / factor; + write!(writer, "{quotient}{name}")?; + value = remainder; + i += 1; + } + } + Ok(()) + } + + /// Convenience function to convert to milliseconds since many OS and + /// library timing functions operate on that timescale. + #[must_use] + pub fn as_milliseconds(&self) -> u32 { + u32::try_from(self.duration / NS_PER_MS).unwrap_or(u32::MAX) + } +} + +impl fmt::Display for Duration { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.format(f) + } +} + +impl ConfigField for Duration { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + *self = Duration::parse(input)?; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + fmt_str(f, &self.to_string()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct WindowPadding { + pub top_left: u32, + pub bottom_right: u32, +} + +impl WindowPadding { + pub fn parse(input: Option<&str>) -> Result { + let input = input.ok_or(ArgError::ValueRequired)?; + let p = |s: &str| s.parse::().map_err(|_| ArgError::InvalidValue); + + if let Some(idx) = input.find(',') { + let left = p(trim_ws(&input[..idx]))?; + let right = p(trim_ws(&input[idx + 1..]))?; + Ok(WindowPadding { + top_left: left, + bottom_right: right, + }) + } else { + let value = p(trim_ws(input))?; + Ok(WindowPadding { + top_left: value, + bottom_right: value, + }) + } + } +} + +impl ConfigField for WindowPadding { + fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { + *self = WindowPadding::parse(input)?; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + if self.top_left == self.bottom_right { + fmt_str(f, &self.top_left.to_string()) + } else { + fmt_str(f, &format!("{},{}", self.top_left, self.bottom_right)) + } + } +} + +config_enum! { + /// See scrollbar + pub enum Scrollbar { + System => "system", + Never => "never", + } +} + +config_packed! { + /// See scroll-to-bottom + pub struct ScrollToBottom { + "keystroke" => keystroke: true, + "output" => output: false, + } +} + +config_enum! { + /// See notify-on-command-finish + pub enum NotifyOnCommandFinish { + Never => "never", + Unfocused => "unfocused", + Always => "always", + } +} + +config_packed! { + /// See notify-on-command-finish-action + pub struct NotifyOnCommandFinishAction { + "bell" => bell: true, + "notify" => notify: false, + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::approx_constant)] +mod tests { + use super::*; + use crate::formatter::format_entry; + + fn fe(name: &str, v: &T) -> String { + let mut s = String::new(); + format_entry(name, v, &mut s).unwrap(); + s + } + + fn parse_rc(list: &mut RepeatableCommand, s: &str) { + list.parse_cli(Some(s)).unwrap(); + } + + #[test] + fn repeatable_command_parse_cli() { + let mut list = RepeatableCommand::default(); + parse_rc(&mut list, "title:Foo,action:ignore"); + parse_rc( + &mut list, + "title:Bar,description:bobr,action:text:ale bydle", + ); + parse_rc( + &mut list, + "title:Quux,description:boo,action:increase_font_size:2.5", + ); + parse_rc( + &mut list, + "title:Baz,description:Raspberry Pie,action:set_font_size:3.14", + ); + + assert_eq!(list.value.len(), 4); + + assert_eq!(list.value[0].action, Action::Ignore); + assert_eq!(list.value[0].title, "Foo"); + + assert_eq!(list.value[1].action, Action::Text("ale bydle".into())); + assert_eq!(list.value[1].title, "Bar"); + assert_eq!(list.value[1].description, "bobr"); + + assert_eq!(list.value[2].action, Action::IncreaseFontSize(2.5)); + assert_eq!(list.value[2].title, "Quux"); + assert_eq!(list.value[2].description, "boo"); + + assert_eq!(list.value[3].action, Action::SetFontSize(3.14)); + assert_eq!(list.value[3].title, "Baz"); + assert_eq!(list.value[3].description, "Raspberry Pie"); + + list.parse_cli(Some("")).unwrap(); + assert_eq!(list.value.len(), 0); + } + + #[test] + fn repeatable_command_format_config_empty() { + let list = RepeatableCommand::default(); + assert_eq!(fe("a", &list), "a = \n"); + } + + #[test] + fn repeatable_command_format_config_single_item() { + let mut list = RepeatableCommand::default(); + parse_rc(&mut list, "title:Bobr, action:text:Bober"); + assert_eq!(fe("a", &list), "a = title:\"Bobr\",action:\"text:Bober\"\n"); + } + + #[test] + fn repeatable_command_format_config_multiple_items() { + let mut list = RepeatableCommand::default(); + parse_rc(&mut list, "title:Bobr, action:text:kurwa"); + parse_rc( + &mut list, + "title:Ja, description: pierdole, action:text:jakie bydle", + ); + assert_eq!( + fe("a", &list), + "a = title:\"Bobr\",action:\"text:kurwa\"\na = title:\"Ja\",description:\"pierdole\",action:\"text:jakie bydle\"\n" + ); + } + + #[test] + fn repeatable_command_parse_cli_commas() { + { + let mut list = RepeatableCommand::default(); + parse_rc(&mut list, "title:\"Bo,br\",action:\"text:kur,wa\""); + assert_eq!(list.value.len(), 1); + let item = &list.value[0]; + assert_eq!(item.title, "Bo,br"); + assert_eq!(item.description, ""); + assert_eq!(item.action, Action::Text("kur,wa".into())); + } + { + let mut list = RepeatableCommand::default(); + parse_rc( + &mut list, + "title:\"Bo,br\",description:\"abc,def\",action:text:kurwa", + ); + assert_eq!(list.value.len(), 1); + let item = &list.value[0]; + assert_eq!(item.title, "Bo,br"); + assert_eq!(item.description, "abc,def"); + assert_eq!(item.action, Action::Text("kurwa".into())); + } + } + + #[test] + fn mouse_scroll_multiplier_parse() { + let eps = 0.00001; + let base = MouseScrollMultiplier { + precision: 0.1, + discrete: 3.0, + }; + let near = |a: f64, b: f64| (a - b).abs() < eps; + + let mut args = base; + args.parse_cli(Some("3")).unwrap(); + assert!(near(args.precision, 3.0) && near(args.discrete, 3.0)); + + args = base; + args.parse_cli(Some("precision:1")).unwrap(); + assert!(near(args.precision, 1.0) && near(args.discrete, 3.0)); + + args = base; + args.parse_cli(Some("discrete:5")).unwrap(); + assert!(near(args.precision, 0.1) && near(args.discrete, 5.0)); + + args = base; + args.parse_cli(Some("precision:3,discrete:7")).unwrap(); + assert!(near(args.precision, 3.0) && near(args.discrete, 7.0)); + + args = base; + args.parse_cli(Some("discrete:8,precision:6")).unwrap(); + assert!(near(args.precision, 6.0) && near(args.discrete, 8.0)); + + args = MouseScrollMultiplier::DEFAULT; + for bad in [ + "foo:1", + "precision:bar", + "precision:1,discrete:3,foo:5", + "precision:1,,discrete:3", + ",precision:1,discrete:3", + ] { + assert_eq!( + args.parse_cli(Some(bad)), + Err(ArgError::InvalidValue), + "{bad}" + ); + } + } + + #[test] + fn format_entry_mouse_scroll_multiplier() { + let args = MouseScrollMultiplier { + precision: 1.5, + discrete: 2.5, + }; + assert_eq!( + fe("mouse-scroll-multiplier", &args), + "mouse-scroll-multiplier = precision:1.5,discrete:2.5\n" + ); + } + + #[test] + fn parse_quick_terminal_size() { + use QuickTerminalSizeValue::{Percentage, Pixels}; + let mut v = QuickTerminalSize::default(); + + v.parse_cli(Some("50%")).unwrap(); + assert_eq!(v.primary, Some(Percentage(50.0))); + assert_eq!(v.secondary, None); + + v.parse_cli(Some("200px")).unwrap(); + assert_eq!(v.primary, Some(Pixels(200))); + assert_eq!(v.secondary, None); + + v.parse_cli(Some("50%,200px")).unwrap(); + assert_eq!(v.primary, Some(Percentage(50.0))); + assert_eq!(v.secondary, Some(Pixels(200))); + + assert_eq!(v.parse_cli(None), Err(ArgError::ValueRequired)); + assert_eq!(v.parse_cli(Some("")), Err(ArgError::ValueRequired)); + assert_eq!(v.parse_cli(Some("69px,")), Err(ArgError::ValueRequired)); + assert_eq!( + v.parse_cli(Some("69px,42%,69px")), + Err(ArgError::other("TooManyArguments")) + ); + + assert_eq!( + v.parse_cli(Some("420")), + Err(ArgError::other("MissingUnit")) + ); + assert_eq!( + v.parse_cli(Some("bobr")), + Err(ArgError::other("MissingUnit")) + ); + assert_eq!(v.parse_cli(Some("bobr%")), Err(ArgError::InvalidValue)); + assert_eq!(v.parse_cli(Some("-32%")), Err(ArgError::InvalidValue)); + assert_eq!(v.parse_cli(Some("-69px")), Err(ArgError::InvalidValue)); + } + + #[test] + fn calculate_quick_terminal_size() { + use QuickTerminalPosition::{Center, Left, Top}; + use QuickTerminalSizeValue::{Percentage, Pixels}; + let d = |width, height| QuickTerminalDimensions { width, height }; + let land = d(2560, 1600); + let port = d(1600, 2560); + + let size = QuickTerminalSize::default(); + assert_eq!(size.calculate(Top, land), d(2560, 400)); + assert_eq!(size.calculate(Left, land), d(400, 1600)); + assert_eq!(size.calculate(Center, land), d(800, 400)); + assert_eq!(size.calculate(Center, port), d(400, 800)); + + let size = QuickTerminalSize { + primary: Some(Percentage(20.0)), + secondary: None, + }; + assert_eq!(size.calculate(Top, land), d(2560, 320)); + assert_eq!(size.calculate(Left, land), d(512, 1600)); + assert_eq!(size.calculate(Center, land), d(512, 400)); + assert_eq!(size.calculate(Center, port), d(400, 512)); + + let size = QuickTerminalSize { + primary: Some(Pixels(600)), + secondary: None, + }; + assert_eq!(size.calculate(Top, land), d(2560, 600)); + assert_eq!(size.calculate(Left, land), d(600, 1600)); + assert_eq!(size.calculate(Center, land), d(600, 400)); + assert_eq!(size.calculate(Center, port), d(400, 600)); + + let size = QuickTerminalSize { + primary: Some(Percentage(69.0)), + secondary: Some(Pixels(420)), + }; + assert_eq!(size.calculate(Top, land), d(420, 1104)); + assert_eq!(size.calculate(Left, land), d(1766, 420)); + assert_eq!(size.calculate(Center, land), d(1766, 420)); + assert_eq!(size.calculate(Center, port), d(420, 1766)); + } + + #[test] + fn parse_background_blur() { + let mut v = BackgroundBlur::default(); + + v.parse_cli(None).unwrap(); + assert_eq!(v, BackgroundBlur::True); + v.parse_cli(Some("true")).unwrap(); + assert_eq!(v, BackgroundBlur::True); + v.parse_cli(Some("false")).unwrap(); + assert_eq!(v, BackgroundBlur::False); + v.parse_cli(Some("42")).unwrap(); + assert_eq!(v, BackgroundBlur::Radius(42)); + v.parse_cli(Some("macos-glass-regular")).unwrap(); + assert_eq!(v, BackgroundBlur::MacosGlassRegular); + v.parse_cli(Some("macos-glass-clear")).unwrap(); + assert_eq!(v, BackgroundBlur::MacosGlassClear); + + assert_eq!(v.parse_cli(Some("")), Err(ArgError::InvalidValue)); + assert_eq!(v.parse_cli(Some("aaaa")), Err(ArgError::InvalidValue)); + assert_eq!(v.parse_cli(Some("420")), Err(ArgError::InvalidValue)); + } + + #[test] + fn parse_window_decoration() { + assert_eq!(WindowDecoration::parse(None), Ok(WindowDecoration::Auto)); + assert_eq!( + WindowDecoration::parse(Some("true")), + Ok(WindowDecoration::Auto) + ); + assert_eq!( + WindowDecoration::parse(Some("false")), + Ok(WindowDecoration::None) + ); + assert_eq!( + WindowDecoration::parse(Some("server")), + Ok(WindowDecoration::Server) + ); + assert_eq!( + WindowDecoration::parse(Some("client")), + Ok(WindowDecoration::Client) + ); + assert_eq!( + WindowDecoration::parse(Some("auto")), + Ok(WindowDecoration::Auto) + ); + assert_eq!( + WindowDecoration::parse(Some("none")), + Ok(WindowDecoration::None) + ); + assert_eq!( + WindowDecoration::parse(Some("")), + Err(ArgError::InvalidValue) + ); + assert_eq!( + WindowDecoration::parse(Some("aaaa")), + Err(ArgError::InvalidValue) + ); + } + + #[test] + fn parse_theme() { + // Single + let mut v = Theme::default(); + v.parse_cli(Some("foo")).unwrap(); + assert_eq!((v.light.as_str(), v.dark.as_str()), ("foo", "foo")); + + // Single whitespace + let mut v = Theme::default(); + v.parse_cli(Some(" foo ")).unwrap(); + assert_eq!((v.light.as_str(), v.dark.as_str()), ("foo", "foo")); + + // Light/dark + let mut v = Theme::default(); + v.parse_cli(Some(" light:foo, dark : bar ")).unwrap(); + assert_eq!((v.light.as_str(), v.dark.as_str()), ("foo", "bar")); + + let mut v = Theme::default(); + assert_eq!(v.parse_cli(None), Err(ArgError::ValueRequired)); + assert_eq!(v.parse_cli(Some("")), Err(ArgError::ValueRequired)); + assert_eq!(v.parse_cli(Some("light:foo")), Err(ArgError::InvalidValue)); + assert_eq!(v.parse_cli(Some("dark:foo")), Err(ArgError::InvalidValue)); + } + + #[test] + fn parse_window_padding() { + let wp = |a, b| WindowPadding { + top_left: a, + bottom_right: b, + }; + assert_eq!(WindowPadding::parse(Some("100")), Ok(wp(100, 100))); + assert_eq!(WindowPadding::parse(Some("100,200")), Ok(wp(100, 200))); + // Trim whitespace + assert_eq!(WindowPadding::parse(Some(" 100 , 200 ")), Ok(wp(100, 200))); + + assert_eq!(WindowPadding::parse(None), Err(ArgError::ValueRequired)); + assert_eq!(WindowPadding::parse(Some("")), Err(ArgError::InvalidValue)); + assert_eq!(WindowPadding::parse(Some("a")), Err(ArgError::InvalidValue)); + } + + fn pd(s: &str) -> Result { + Duration::parse(Some(s)).map(|d| d.duration) + } + + #[test] + fn parse_duration() { + for (name, _) in DURATION_UNITS { + assert_eq!(pd(&format!("0{name}")), Ok(0)); + } + for (name, factor) in DURATION_UNITS { + assert_eq!(pd(&format!("1{name}")), Ok(*factor)); + } + + assert_eq!(pd("0"), Ok(0)); + assert_eq!(pd("100ns"), Ok(100)); + assert_eq!(pd("1µs"), Ok(1000)); + assert_eq!(pd("1µs1ns"), Ok(1001)); + assert_eq!(pd("1µs 1ns"), Ok(1001)); + assert_eq!(pd(" 1µs1ns"), Ok(1001)); + assert_eq!(pd("1µs1ns "), Ok(1001)); + assert_eq!(pd("30s"), Ok(30 * NS_PER_S)); + assert_eq!(pd("584y 49w 23h 34m 33s 709ms 551µs 615ns"), Ok(u64::MAX)); + + // Overflow + assert_eq!(pd("600y"), Ok(u64::MAX)); + + // Repeated units + assert_eq!(pd("100ns100ns"), Ok(200)); + + assert_eq!(Duration::parse(None), Err(ArgError::ValueRequired)); + assert_eq!(pd(""), Err(ArgError::ValueRequired)); + assert_eq!(pd("1"), Err(ArgError::InvalidValue)); + assert_eq!(pd("s"), Err(ArgError::InvalidValue)); + assert_eq!(pd("1x"), Err(ArgError::InvalidValue)); + assert_eq!(pd("1 "), Err(ArgError::InvalidValue)); + } + + #[test] + fn test_format() { + for (name, factor) in DURATION_UNITS { + let d = Duration { duration: *factor }; + let expected = if *name == "us" { + "1µs".to_string() + } else { + format!("1{name}") + }; + assert_eq!(d.to_string(), expected); + } + } + + #[test] + fn test_entry_formatter() { + let p = Duration { duration: u64::MAX }; + assert_eq!(fe("a", &p), "a = 584y 49w 23h 34m 33s 709ms 551µs 615ns\n"); + } + + #[test] + fn extra_enum_and_packed_formatting() { + assert_eq!(fe("a", &BackgroundBlur::Radius(5)), "a = 5\n"); + assert_eq!(fe("a", &BackgroundBlur::True), "a = true\n"); + assert_eq!( + fe("a", &BackgroundBlur::MacosGlassClear), + "a = macos-glass-clear\n" + ); + assert_eq!( + fe("a", &ScrollToBottom::default()), + "a = keystroke,no-output\n" + ); + assert_eq!(fe("a", &QuickTerminalSize::default()), ""); + let mut q = QuickTerminalSize::default(); + q.parse_cli(Some("50%, 200px")).unwrap(); + assert_eq!(fe("a", &q), "a = 50%,200px\n"); + assert_eq!( + fe( + "a", + &WindowPadding { + top_left: 1, + bottom_right: 2 + } + ), + "a = 1,2\n" + ); + let mut c = CopyOnSelect::default(); + assert_eq!(c.parse_cli(Some("x")), Err(ArgError::InvalidValue)); + c.parse_cli(Some("clipboard")).unwrap(); + assert_eq!(c, CopyOnSelect::Clipboard); + } +} diff --git a/crates/ghostty-config/src/url.rs b/crates/ghostty-config/src/url.rs new file mode 100644 index 0000000..37d330f --- /dev/null +++ b/crates/ghostty-config/src/url.rs @@ -0,0 +1,362 @@ +//! Default URL/path regex (port of `config/url.zig`). +//! +//! The pattern uses Oniguruma (Ruby syntax) features such as variable-length +//! lookbehind; use the `onig` crate to compile it. + +use const_format::concatcp; + +const URL_SCHEMES: &str = r"https?://|mailto:|ftp://|file:|ssh:|git://|ssh://|tel:|magnet:|ipfs://|ipns://|gemini://|gopher://|news:"; + +const IPV6_URL_PATTERN: &str = r"(?:\[[:0-9a-fA-F]+(?:[:0-9a-fA-F]*)+\](?::[0-9]+)?)"; + +const SCHEME_URL_CHARS: &str = r"[\w\-.~:/?#@!$&*+,;=%]"; + +const PATH_CHARS: &str = r"[\w\-.~:\/?#@!$&*+;=%]"; + +const OPTIONAL_BRACKETED_WORD_SUFFIX: &str = r"(?:[\(\[]\w*[\)\]])?"; + +const NO_TRAILING_PUNCTUATION: &str = r"(? Rust, all in `crates/ghostty-config/src/`) + +| Zig | Rust | +|---|---| +| cli/args.zig | `cli_args.rs` (`ConfigField` + `CliTarget` traits, `parse`, `LineIterator`, `ArgsIterator`, `parse_auto_struct_with`, `parse_packed_struct_with`, `split_tagged_union`, `compatibility_renamed`) | +| cli/CommaSplitter.zig | `comma_splitter.rs` | +| cli/diagnostics.zig | `diagnostics.rs` | +| config/Config.zig (fields + methods) | `config.rs` (fields declared in a single `config_struct!` invocation: name, type, default, verbatim doc comment), `keys.rs` (one `Key` const per option) | +| Config.zig nested types | `types_a.rs`, `types_b.rs`, `keybinds.rs`, `font_types.rs`, `metric_modifier.rs`, `input_fields.rs` (ConfigField impls for ghostty-input types) | +| conditional.zig, key.zig, ErrorList.zig | `conditional.rs`, `key.rs`, `error_list.rs` | +| formatter.zig | `formatter.rs` (EntryFormatter) + `file_formatter.rs` (FileFormatter) | +| command/path/url/string/io/theme/edit/file_load/RepeatableStringMap/ClipboardCodepointMap | same names, snake_case | +| config/config-template, config/testdata | `src/config-template`, `src/testdata/` | +| helpgen.zig (config part) | `macros::help_string` (computed at runtime from the doc attributes) | + +Skipped: CApi.zig, c_get.zig, Wasm.zig, all `cval`/`*_c` members, `enum(c_int)` +discriminants, `getGObjectType`. + +## Design + +* No comptime reflection: `config_struct!` (macros.rs) generates the struct, + `parse_field` (== `parseIntoField`, incl. "empty value resets to default"), + per-field `FieldInfo` (name, doc lines, deep-eq fn, format fn) used by + `FileFormatter`, `Config::changed`, `ChangeIterator`. +* `config_enum!` / `config_packed!` define Zig-style enums / packed bool structs. +* Memory: all values are owned (`String`, `Vec`); there is no arena. `clone` is + `#[derive(Clone)]`; Zig `equal` == `PartialEq` (custom where Zig's differs). +* Replay (theme / conditional state): `Config::load_replay` replaces the Zig + `Replay.Iterator` (which aliased the config); behaviour is the same. +* `Config::default()` == Zig `Config.default` (default keybinds, command palette + entries and the URL link are installed). `Config::raw_default()` == bare `.{}`. +* `Config::load()` == Zig `Config.load` (default files, CLI, recursive files, + finalize). `finalize` skips the shell/passwd lookup under `cfg(test)` like Zig's + `builtin.is_test`. +* Linux values are baked in for `builtin.os.tag` switches (e.g. `font-size = 12`, + `copy-on-select = true`, `quit-after-last-window-closed = true`, + `linux-cgroup = single-instance`, `alpha-blending = linear-corrected`). + `gtk-opengl-debug` follows `cfg!(debug_assertions)` (Zig: `mode == .Debug`). + `auto-update-channel` finalizes to `stable` (upstream release build value). +* macOS-only options (`macos-*`) are parsed and stored but otherwise ignored. + +## Verification against the real binary + +`ghostty +show-config --default` and `--default --docs` (Ghostty 1.3.1 installed +at /usr/bin/ghostty) were diffed against `FileFormatter` output of +`Config::default()`: identical except `gtk-opengl-debug` (debug vs release build). +A loaded config (themes light/dark, config-file includes incl. `?` optional +and missing, keybinds, palette, font-codepoint-map, errors/diagnostics) was also +diffed against `ghostty +show-config`; identical except `auto-update-channel`. + +## Tests + +Every Zig `test` in the ported files is ported (names snake_cased), except: +* args.zig: none skipped (36 Zig tests -> 40+ Rust tests incl. extras) +* Config.zig: C-API tests ("RepeatableCommand cval", "cval cleared") skipped +* key.zig `Value` (comptime type check) replaced by `value_key_types` +* Allocator-failure tests: none existed. +Extra tests added for config-file cycles / optional includes, docs formatting. + +## Deviations / notes for integration + +* `ArgError::Other(name)` renders as `unknown error error.` like Zig's `{}` of an error. +* Error text for IO failures in `config-file` diagnostics uses Rust wording except + `FileNotFound`/`AccessDenied`. +* `theme::open` takes the resources dir explicitly (`ghostty_os::resources_dir().app()`). +* URL regex (`url::REGEX`) needs Oniguruma-style lookbehind: consumers (renderer + link matching) must use the `onig` crate (added as dev-dependency only here). +* `font_types.rs` holds local `Variation`, `Descriptor`, `CodepointMap`; + `clipboard_codepoint_map::Entry` mirrors terminal/formatter's CodepointMap + (TODO(integration): ghostty-font / ghostty-terminal should re-use these). +* `metric_modifier::MetricModifier` is the font `Metrics.Modifier` (font crate depends on config). +* `CursorStyle` is defined in `types_a.rs` (cannot depend on ghostty-terminal). +* macOS app-support config paths are not implemented (Linux only). +* `KeyRemapSet::PartialEq` is input's derived equality (Zig `equal` is order independent).