feat(config): port the configuration system and CLI argument parser
Ports ZIG/src/config (minus C/WASM APIs) and cli/{args,CommaSplitter,
diagnostics}.zig into ghostty-config:
- all 202 options with exact names, types, defaults and verbatim docs,
declared through one config_struct! macro; Zig's comptime reflection is
replaced by the ConfigField/CliTarget traits
- loading: default XDG files, -e, config-file includes (optional '?',
cycle detection), themes incl. light:/dark: pairs, conditional config,
replay, finalize() derived defaults, clone/changed diffing for reload
- Linux/GTK default keybinds built on ghostty_input::binding::Set
- formatter producing +show-config output
Verified against the installed Ghostty 1.3.1: `+show-config --default`
and `--default --docs` match byte for byte (except gtk-opengl-debug,
which differs between debug/release builds), as do two complex loaded
configs.
Open items (docs/rewrite/notes/ghostty-config.md): url regex needs an
Oniguruma-compatible engine (onig) in the renderer; some font/terminal
types are local copies awaiting integration; KeyRemapSet equality is
order sensitive unlike Zig.
Test Plan:
- cargo test -p ghostty-config (255 tests)
- compare `ghostty +show-config --default --docs` with our formatter
This commit is contained in:
1 parent
47b479dcfb
commit
67367dcaf5
34 files changed
+14894
-1
No files matched your search
@@ -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"
|
||||
@@ -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 <name>`.
|
||||
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<crate::comma_splitter::Error> 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<bool, ArgError> {
|
||||
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<String>;
|
||||
|
||||
/// Location for diagnostics.
|
||||
fn location(&self) -> Option<Location> {
|
||||
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<T: CliTarget + ?Sized>(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<T: CliTarget + ?Sized>(
|
||||
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<bool, ArgError>,
|
||||
) -> 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<String> {
|
||||
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<u8> = 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<bool, ArgError> {
|
||||
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<i128> {
|
||||
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<T: ConfigField> ConfigField for Option<T> {
|
||||
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<I: Iterator<Item = String>> {
|
||||
/// 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<I: Iterator<Item = String>> ArgsIterator<I> {
|
||||
pub fn new(iterator: I) -> Self {
|
||||
Self { iterator, index: 0 }
|
||||
}
|
||||
|
||||
pub fn next(&mut self) -> Option<String> {
|
||||
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<I: Iterator<Item = String>> ArgIter for ArgsIterator<I> {
|
||||
fn next_arg(&mut self) -> Option<String> {
|
||||
self.next()
|
||||
}
|
||||
|
||||
fn location(&self) -> Option<Location> {
|
||||
Some(Location::Cli(self.index))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an args iterator for the process args. This will skip argv0.
|
||||
#[must_use]
|
||||
pub fn args_iterator() -> ArgsIterator<std::iter::Skip<std::env::Args>> {
|
||||
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<String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
impl VecIterator {
|
||||
#[must_use]
|
||||
pub fn new(items: Vec<String>) -> 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<String> {
|
||||
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<String> {
|
||||
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<R: BufRead> {
|
||||
/// 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<R: BufRead> LineIterator<R> {
|
||||
/// 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<String>) -> Self {
|
||||
Self {
|
||||
r,
|
||||
filepath: filepath.into(),
|
||||
line: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&mut self) -> Option<String> {
|
||||
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<R: BufRead> ArgIter for LineIterator<R> {
|
||||
fn next_arg(&mut self) -> Option<String> {
|
||||
self.next()
|
||||
}
|
||||
|
||||
fn location(&self) -> Option<Location> {
|
||||
// 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<T: CliTarget>(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<bool>, 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<WithParse>, 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<Cursor<Vec<u8>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Entry>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Option<&'a str>, 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<Vec<&str>, 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));
|
||||
}
|
||||
}
|
||||
@@ -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<String>),
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
impl Iterator for ArgIterator {
|
||||
type Item = String;
|
||||
|
||||
fn next(&mut self) -> Option<String> {
|
||||
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<String> {
|
||||
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<u8> = 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<String> = 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");
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<String> = 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<MetricModifier> = None,
|
||||
|
||||
"adjust-cell-height" => adjust_cell_height: Option<MetricModifier> = 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<MetricModifier> = 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<MetricModifier> = None,
|
||||
|
||||
/// Thickness in pixels of the underline.
|
||||
/// See the notes about adjustments in `adjust-cell-width`.
|
||||
"adjust-underline-thickness" => adjust_underline_thickness: Option<MetricModifier> = 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<MetricModifier> = 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<MetricModifier> = 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<MetricModifier> = 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<MetricModifier> = 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<MetricModifier> = 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<MetricModifier> = 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<MetricModifier> = 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<MetricModifier> = 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<Theme> = 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<Path> = 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<TerminalColor> = None,
|
||||
|
||||
"selection-background" => selection_background: Option<TerminalColor> = 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<TerminalColor> = 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<bool> = 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<TerminalColor> = 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<Color> = 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<Color> = 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<Command> = 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<Command> = 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:<string>` - Send raw text as-is. This uses Zig string literal
|
||||
/// syntax so you can specify control characters and other standard
|
||||
/// escapes.
|
||||
///
|
||||
/// * `path:<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<String> = 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<String> = 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<String> = 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<WorkingDirectory> = 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 `<table>/<binding>`. The
|
||||
/// `<binding>` value is everything documented above for keybinds. The
|
||||
/// `<table>` 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:<name>` 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:
|
||||
///
|
||||
/// * `<name>/` (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:<name>`. 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<String> = 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<i16> = None,
|
||||
|
||||
"window-position-y" => window_position_y: Option<i16> = 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<Color> = 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<Color> = 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<Duration> = 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<Path> = 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<ghostty_input::OptionAsAlt> = 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<String> = 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<Color> = 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<ColorList> = 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<u64> = 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<u64> = 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<BoldColor> = 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<AutoUpdate> = 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<ReleaseChannel> = 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<conditional::Key> = HashSet::new(),
|
||||
/// The steps we can use to reload the configuration after it has
|
||||
/// been loaded without reopening the files.
|
||||
replay_steps: Vec<ReplayStep> = 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<Conditional>,
|
||||
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<Config, Error> {
|
||||
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<R: std::io::BufRead>(
|
||||
&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::ffi::OsString> = 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<std::ffi::CString> = 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<String> = 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<String> = 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<ReplayStep> = 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<Option<Config>, 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<String>) {
|
||||
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<Key> {
|
||||
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<bool, ArgError> {
|
||||
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<String> = 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Diagnostic>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
// 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<String> {
|
||||
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<Vec<String>> {
|
||||
Ok(vec![
|
||||
file_load::default_xdg_path().map_err(xdg_err)?,
|
||||
file_load::legacy_default_xdg_path().map_err(xdg_err)?,
|
||||
])
|
||||
}
|
||||
@@ -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<Error>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String, xdg::Error> {
|
||||
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<String, xdg::Error> {
|
||||
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<String, xdg::Error> {
|
||||
// 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<String, xdg::Error> {
|
||||
// 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<File, OpenFileError> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<Variation>,
|
||||
}
|
||||
|
||||
impl Hash for Descriptor {
|
||||
fn hash<H: Hasher>(&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<CodepointMapEntry>,
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<T: ConfigField>(&mut self, value: &T) -> fmt::Result {
|
||||
value.format_entry(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a single value with the given name.
|
||||
pub fn format_entry<T: ConfigField>(
|
||||
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<T: ConfigField>(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::<bool>), "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");
|
||||
}
|
||||
}
|
||||
@@ -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<T: ConfigField>(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));
|
||||
}
|
||||
}
|
||||
@@ -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<ReadableIO>,
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<usize> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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<T> {
|
||||
/// 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<T> FieldInfo<T> {
|
||||
/// 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<Self>];
|
||||
}
|
||||
|
||||
/// 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<Self> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<MetricModifier, ArgError> {
|
||||
if input.is_empty() {
|
||||
return Err(ArgError::other("InvalidFormat"));
|
||||
}
|
||||
|
||||
if let Some(num) = input.strip_suffix('%') {
|
||||
let mut percent = num
|
||||
.parse::<f64>()
|
||||
.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::<i32>()
|
||||
.map(MetricModifier::Absolute)
|
||||
.map_err(|_| ArgError::other("InvalidFormat"))
|
||||
}
|
||||
|
||||
/// So it works with the config framework.
|
||||
pub fn parse_cli_value(input: Option<&str>) -> Result<MetricModifier, ArgError> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<Option<Path>, 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<Path>,
|
||||
}
|
||||
|
||||
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<T: ConfigField>(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());
|
||||
}
|
||||
}
|
||||
@@ -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<Item = (&str, &str)> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<StringError> 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<u32> {
|
||||
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<String, StringError> {
|
||||
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<Option<u32>, 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
background = #EEEEEE
|
||||
@@ -0,0 +1 @@
|
||||
background = #FFFFFF
|
||||
@@ -0,0 +1,2 @@
|
||||
# A simple theme
|
||||
background = #123ABC
|
||||
@@ -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<String> {
|
||||
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<String>,
|
||||
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<OpenedTheme> {
|
||||
// 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<File> {
|
||||
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\""));
|
||||
}
|
||||
}
|
||||
@@ -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<Color, ArgError> {
|
||||
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<Color, ArgError> {
|
||||
// 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<u8> = 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<u8, ArgError> {
|
||||
(c as char)
|
||||
.to_digit(16)
|
||||
.map(|d| d as u8)
|
||||
.ok_or_else(|| ArgError::other("InvalidCharacter"))
|
||||
};
|
||||
let byte =
|
||||
|i: usize| -> Result<u8, ArgError> { 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<TerminalColor, ArgError> {
|
||||
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<Rgb> {
|
||||
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<BoldColor, ArgError> {
|
||||
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<Color>,
|
||||
}
|
||||
|
||||
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<String> = 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<String>,
|
||||
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
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<Variation>,
|
||||
}
|
||||
|
||||
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::<f64>().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<Option<[u32; 2]>, 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<u32, ArgError> {
|
||||
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<u32> {
|
||||
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<ghostty_input::Link>,
|
||||
}
|
||||
|
||||
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<T: ConfigField>(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<T: ConfigField>(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), "");
|
||||
}
|
||||
}
|
||||
@@ -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 = <s>\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<Command, ArgError> {
|
||||
let mut action: Option<Action> = None;
|
||||
let mut title: Option<String> = 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<Command> {
|
||||
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<Command>,
|
||||
}
|
||||
|
||||
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<Self, ArgError> {
|
||||
if input.is_empty() {
|
||||
return Err(ArgError::ValueRequired);
|
||||
}
|
||||
|
||||
if let Some(num) = input.strip_suffix("px") {
|
||||
return Ok(Self::Pixels(
|
||||
num.parse::<u32>().map_err(|_| ArgError::InvalidValue)?,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(num) = input.strip_suffix('%') {
|
||||
let percentage = num.parse::<f32>().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<QuickTerminalSizeValue>,
|
||||
pub secondary: Option<QuickTerminalSizeValue>,
|
||||
}
|
||||
|
||||
/// 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<WindowDecoration, ArgError> {
|
||||
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<String> = None;
|
||||
let mut dark: Option<String> = 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<u64> {
|
||||
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<Duration, ArgError> {
|
||||
let mut remaining = input.ok_or(ArgError::ValueRequired)?.as_bytes();
|
||||
|
||||
let mut value: Option<u64> = 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<u64> = None;
|
||||
let mut prev_remaining: Option<&[u8]> = None;
|
||||
let mut result: Option<Option<u64>> = 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<u64> = None;
|
||||
let mut prev_index: Option<usize> = 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<WindowPadding, ArgError> {
|
||||
let input = input.ok_or(ArgError::ValueRequired)?;
|
||||
let p = |s: &str| s.parse::<u32>().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<T: ConfigField>(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<u64, ArgError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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"(?<![,.])";
|
||||
|
||||
const NO_TRAILING_COLON: &str = r"(?<!:)";
|
||||
|
||||
const TRAILING_SPACES_AT_EOL: &str = r"(?: +(?= *$))?";
|
||||
|
||||
const DOTTED_PATH_LOOKAHEAD: &str = r"(?=[\w\-.~:\/?#@!$&*+;=%]*\.)";
|
||||
|
||||
const NON_DOTTED_PATH_LOOKAHEAD: &str = r"(?![\w\-.~:\/?#@!$&*+;=%]*\.)";
|
||||
|
||||
const DOTTED_PATH_SPACE_SEGMENTS: &str =
|
||||
r"(?:(?<!:) (?!\w+:\/\/)(?!\.{0,2}\/)(?!~\/)[\w\-.~:\/?#@!$&*+;=%]*[\/.])*";
|
||||
|
||||
const ANY_PATH_SPACE_SEGMENTS: &str =
|
||||
r"(?:(?<!:) (?!\w+:\/\/)(?!\.{0,2}\/)(?!~\/)[\w\-.~:\/?#@!$&*+;=%]+)*";
|
||||
|
||||
const ROOTED_OR_RELATIVE_PATH_PREFIX: &str = r"(?:\.\.\/|\.\/|(?<!\w)~\/|(?:[\w][\w\-.]*\/)*(?<!\w)\$[A-Za-z_]\w*\/|\.[\w][\w\-.]*\/|(?<![\w~\/])\/(?!\/))";
|
||||
|
||||
const BARE_RELATIVE_PATH_PREFIX: &str = r"(?<!\$\d*)(?<!\w)[\w][\w\-.]*\/";
|
||||
|
||||
// Branch 1: URLs with explicit schemes (http, mailto, ftp, etc.).
|
||||
const SCHEME_URL_BRANCH: &str = concatcp!(
|
||||
"(?:",
|
||||
URL_SCHEMES,
|
||||
")",
|
||||
"(?:",
|
||||
IPV6_URL_PATTERN,
|
||||
"|",
|
||||
SCHEME_URL_CHARS,
|
||||
"+",
|
||||
OPTIONAL_BRACKETED_WORD_SUFFIX,
|
||||
")+",
|
||||
NO_TRAILING_PUNCTUATION
|
||||
);
|
||||
|
||||
// Branch 2: Absolute paths and dot-relative paths (/, ./, ../).
|
||||
// A dotted segment is treated as file-like, while the undotted case stays
|
||||
// broad to capture directory-like paths with spaces.
|
||||
const ROOTED_OR_RELATIVE_PATH_BRANCH: &str = concatcp!(
|
||||
ROOTED_OR_RELATIVE_PATH_PREFIX,
|
||||
"(?:",
|
||||
DOTTED_PATH_LOOKAHEAD,
|
||||
PATH_CHARS,
|
||||
"+",
|
||||
DOTTED_PATH_SPACE_SEGMENTS,
|
||||
NO_TRAILING_COLON,
|
||||
TRAILING_SPACES_AT_EOL,
|
||||
"|",
|
||||
NON_DOTTED_PATH_LOOKAHEAD,
|
||||
PATH_CHARS,
|
||||
"+",
|
||||
ANY_PATH_SPACE_SEGMENTS,
|
||||
NO_TRAILING_COLON,
|
||||
TRAILING_SPACES_AT_EOL,
|
||||
")"
|
||||
);
|
||||
|
||||
// Branch 3: Bare relative paths such as src/config/url.zig.
|
||||
const BARE_RELATIVE_PATH_BRANCH: &str = concatcp!(
|
||||
DOTTED_PATH_LOOKAHEAD,
|
||||
BARE_RELATIVE_PATH_PREFIX,
|
||||
PATH_CHARS,
|
||||
"+",
|
||||
NO_TRAILING_COLON,
|
||||
TRAILING_SPACES_AT_EOL
|
||||
);
|
||||
|
||||
/// Default URL/path regex. This is used to detect URLs and file paths in
|
||||
/// terminal output.
|
||||
///
|
||||
/// This is here in the config package because one day the matchers will be
|
||||
/// configurable and this will be a default.
|
||||
///
|
||||
/// For scheme URLs, this regex is liberal in what it accepts after the scheme,
|
||||
/// with exceptions for URLs ending with . or ). Although such URLs are
|
||||
/// perfectly valid, it is common for text to contain URLs surrounded by
|
||||
/// parentheses (such as in Markdown links) or at the end of sentences.
|
||||
/// Therefore, this regex excludes them as follows:
|
||||
///
|
||||
/// 1. Do not match regexes ending with .
|
||||
/// 2. Do not match regexes ending with ), except for ones which contain a (
|
||||
/// without a subsequent )
|
||||
///
|
||||
/// Rule 2 means that that we handle the following two cases:
|
||||
///
|
||||
/// "https://en.wikipedia.org/wiki/Rust_(video_game)" (include parens)
|
||||
/// "(https://example.com)" (do not include the parens)
|
||||
///
|
||||
/// There are many complicated cases where these heuristics break down, but
|
||||
/// handling them well requires a non-regex approach.
|
||||
pub const REGEX: &str = concatcp!(
|
||||
SCHEME_URL_BRANCH,
|
||||
"|",
|
||||
ROOTED_OR_RELATIVE_PATH_BRANCH,
|
||||
"|",
|
||||
BARE_RELATIVE_PATH_BRANCH
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use onig::Regex;
|
||||
|
||||
#[test]
|
||||
fn url_regex() {
|
||||
let re = Regex::new(REGEX).unwrap();
|
||||
|
||||
let cases: &[(&str, &str)] = &[
|
||||
("hello https://example.com world", "https://example.com"),
|
||||
(
|
||||
"https://example.com/foo(bar) more",
|
||||
"https://example.com/foo(bar)",
|
||||
),
|
||||
(
|
||||
"https://example.com/foo(bar)baz more",
|
||||
"https://example.com/foo(bar)baz",
|
||||
),
|
||||
(
|
||||
"Link inside (https://example.com) parens",
|
||||
"https://example.com",
|
||||
),
|
||||
(
|
||||
"Link period https://example.com. More text.",
|
||||
"https://example.com",
|
||||
),
|
||||
(
|
||||
"Link trailing comma https://example.com, more text.",
|
||||
"https://example.com",
|
||||
),
|
||||
(
|
||||
"Link in double quotes \"https://example.com\" and more",
|
||||
"https://example.com",
|
||||
),
|
||||
(
|
||||
"Link in single quotes 'https://example.com' and more",
|
||||
"https://example.com",
|
||||
),
|
||||
(
|
||||
"some file with https://google.com https://duckduckgo.com links.",
|
||||
"https://google.com",
|
||||
),
|
||||
(
|
||||
"and links in it. links https://yahoo.com mailto:test@example.com ssh://1.2.3.4",
|
||||
"https://yahoo.com",
|
||||
),
|
||||
(
|
||||
"also match http://example.com non-secure links",
|
||||
"http://example.com",
|
||||
),
|
||||
(
|
||||
"match tel://+12123456789 phone numbers",
|
||||
"tel://+12123456789",
|
||||
),
|
||||
(
|
||||
"match with query url https://example.com?query=1&other=2 and more text.",
|
||||
"https://example.com?query=1&other=2",
|
||||
),
|
||||
(
|
||||
"url with dashes [mode 2027](https://github.com/contour-terminal/terminal-unicode-core) for better unicode support",
|
||||
"https://github.com/contour-terminal/terminal-unicode-core",
|
||||
),
|
||||
("dot.http://example.com", "http://example.com"),
|
||||
(
|
||||
"weird characters https://example.com/~user/?query=1&other=2#hash and more",
|
||||
"https://example.com/~user/?query=1&other=2#hash",
|
||||
),
|
||||
(
|
||||
"square brackets https://example.com/[foo] and more",
|
||||
"https://example.com/[foo]",
|
||||
),
|
||||
(
|
||||
"[13]:TooManyStatements: TempFile#assign_temp_file_to_entity has approx 7 statements [https://example.com/docs/Too-Many-Statements.md]",
|
||||
"https://example.com/docs/Too-Many-Statements.md",
|
||||
),
|
||||
("match ftp://example.com ftp links", "ftp://example.com"),
|
||||
("match file://example.com file links", "file://example.com"),
|
||||
("match ssh://example.com ssh links", "ssh://example.com"),
|
||||
("match git://example.com git links", "git://example.com"),
|
||||
("/tmp/test.txt http://www.google.com", "/tmp/test.txt"),
|
||||
("match tel:+18005551234 tel links", "tel:+18005551234"),
|
||||
(
|
||||
"match magnet:?xt=urn:btih:1234567890 magnet links",
|
||||
"magnet:?xt=urn:btih:1234567890",
|
||||
),
|
||||
(
|
||||
"match ipfs://QmSomeHashValue ipfs links",
|
||||
"ipfs://QmSomeHashValue",
|
||||
),
|
||||
(
|
||||
"match ipns://QmSomeHashValue ipns links",
|
||||
"ipns://QmSomeHashValue",
|
||||
),
|
||||
(
|
||||
"match gemini://example.com gemini links",
|
||||
"gemini://example.com",
|
||||
),
|
||||
(
|
||||
"match gopher://example.com gopher links",
|
||||
"gopher://example.com",
|
||||
),
|
||||
(
|
||||
"match news:comp.infosystems.www.servers.unix news links",
|
||||
"news:comp.infosystems.www.servers.unix",
|
||||
),
|
||||
(
|
||||
"/Users/ghostty.user/code/example.py",
|
||||
"/Users/ghostty.user/code/example.py",
|
||||
),
|
||||
(
|
||||
"/Users/ghostty.user/code/../example.py",
|
||||
"/Users/ghostty.user/code/../example.py",
|
||||
),
|
||||
(
|
||||
"/Users/ghostty.user/code/../example.py hello world",
|
||||
"/Users/ghostty.user/code/../example.py",
|
||||
),
|
||||
("../example.py", "../example.py"),
|
||||
("../example.py ", "../example.py "),
|
||||
("first time ../example.py contributor ", "../example.py"),
|
||||
(
|
||||
"[link](/home/user/ghostty.user/example)",
|
||||
"/home/user/ghostty.user/example",
|
||||
),
|
||||
(
|
||||
"Serving HTTP on :: port 8000 (http://[::]:8000/)",
|
||||
"http://[::]:8000/",
|
||||
),
|
||||
(
|
||||
"IPv6 address https://[2001:db8::1]:8080/path",
|
||||
"https://[2001:db8::1]:8080/path",
|
||||
),
|
||||
("IPv6 localhost http://[::1]:3000", "http://[::1]:3000"),
|
||||
(
|
||||
"Complex IPv6 https://[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443/",
|
||||
"https://[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443/",
|
||||
),
|
||||
(
|
||||
"IPv6 with path https://[2001:db8::1]/path/to/resource",
|
||||
"https://[2001:db8::1]/path/to/resource",
|
||||
),
|
||||
(
|
||||
"IPv6 with query https://[2001:db8::1]:8080/api?param=value&other=123",
|
||||
"https://[2001:db8::1]:8080/api?param=value&other=123",
|
||||
),
|
||||
(
|
||||
"IPv6 compressed http://[2001:db8::]:80/",
|
||||
"http://[2001:db8::]:80/",
|
||||
),
|
||||
(
|
||||
"IPv6 multiple zeros http://[2001:0:0:0:0:0:0:1]",
|
||||
"http://[2001:0:0:0:0:0:0:1]",
|
||||
),
|
||||
(
|
||||
"IPv6 link-local https://[fe80::1234:5678:9abc]",
|
||||
"https://[fe80::1234:5678:9abc]",
|
||||
),
|
||||
(
|
||||
"IPv6 multicast http://[ff02::1]/stream",
|
||||
"http://[ff02::1]/stream",
|
||||
),
|
||||
(
|
||||
"IPv6 in markdown [link](http://[2001:db8::1]/docs)",
|
||||
"http://[2001:db8::1]/docs",
|
||||
),
|
||||
("./spaces-end. ", "./spaces-end. "),
|
||||
("./space middle", "./space middle"),
|
||||
("../test folder/file.txt", "../test folder/file.txt"),
|
||||
("/tmp/test folder/file.txt", "/tmp/test folder/file.txt"),
|
||||
("/tmp/test folder/file.txt", "/tmp/test"),
|
||||
(
|
||||
"diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig",
|
||||
"a/src/font/shaper/harfbuzz.zig",
|
||||
),
|
||||
("/tmp/foo /tmp/bar", "/tmp/foo"),
|
||||
("/tmp/foo.txt /tmp/bar.txt", "/tmp/foo.txt"),
|
||||
("src/config/url.zig", "src/config/url.zig"),
|
||||
("app/folder/file.rb:1", "app/folder/file.rb:1"),
|
||||
("modified: src/config/url.zig", "src/config/url.zig"),
|
||||
(
|
||||
"lib/ghostty/terminal.zig:42:10",
|
||||
"lib/ghostty/terminal.zig:42:10",
|
||||
),
|
||||
("some-pkg/src/file.txt more text", "some-pkg/src/file.txt"),
|
||||
("src/foo.c,baz.txt", "src/foo.c"),
|
||||
("~/foo/bar.txt", "~/foo/bar.txt"),
|
||||
("open ~/Documents/notes.md please", "~/Documents/notes.md"),
|
||||
("~/.config/ghostty/config", "~/.config/ghostty/config"),
|
||||
(
|
||||
"directory: ~/src/ghostty-org/ghostty",
|
||||
"~/src/ghostty-org/ghostty",
|
||||
),
|
||||
("$HOME/src/config/url.zig", "$HOME/src/config/url.zig"),
|
||||
(
|
||||
"project dir: $PWD/src/ghostty/main.zig",
|
||||
"$PWD/src/ghostty/main.zig",
|
||||
),
|
||||
("foo/$BAR/baz", "foo/$BAR/baz"),
|
||||
(".foo/bar/$VAR", ".foo/bar/$VAR"),
|
||||
(".config/ghostty/config", ".config/ghostty/config"),
|
||||
(
|
||||
"loaded from .local/share/ghostty/state.db now",
|
||||
".local/share/ghostty/state.db",
|
||||
),
|
||||
("../some/where", "../some/where"),
|
||||
(
|
||||
" - shared/src/foo/SomeItem.m:12, shared/src/",
|
||||
"shared/src/foo/SomeItem.m:12",
|
||||
),
|
||||
("foo.local/share", "foo.local/share"),
|
||||
("2024/report.txt", "2024/report.txt"),
|
||||
("./foo bar,baz", "./foo bar"),
|
||||
("/tmp/foo bar,baz", "/tmp/foo bar"),
|
||||
(
|
||||
"./.config/ghostty: Needs upstream (main)",
|
||||
"./.config/ghostty",
|
||||
),
|
||||
("./Downloads: Operation not permitted", "./Downloads"),
|
||||
];
|
||||
|
||||
for (input, expect) in cases {
|
||||
let mut it = re.find_iter(input);
|
||||
let (s, e) = it
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no match for {input:?}"));
|
||||
assert_eq!(&input[s..e], *expect, "input: {input:?}");
|
||||
}
|
||||
|
||||
let no_match_cases: &[&str] = &[
|
||||
"input/output",
|
||||
"foo/bar",
|
||||
"$10/bar",
|
||||
"$10/$20",
|
||||
"$10/bar.txt",
|
||||
"foo/bar,baz.txt",
|
||||
"foo$BAR/baz.txt",
|
||||
"foo~/bar.txt",
|
||||
"// foo bar",
|
||||
"//foo",
|
||||
];
|
||||
for input in no_match_cases {
|
||||
assert!(re.find(input).is_none(), "unexpected match for {input:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# ghostty-config port notes (W6)
|
||||
|
||||
Sub-notes from the parallel forks live in `ghostty-config/` (`files-a.md`,
|
||||
`types-a.md`, `types-b.md`, `keybinds.md`).
|
||||
|
||||
## What was ported (Zig -> 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.<name>` 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).
|
||||
Reference in new issue
Block a user