Files
ghostty-rs/crates/ghostty-config/src/theme.rs
T
ddidderr a8d0d624da test(windows): run the Windows code under Wine; theme preview parity
Windows was previously only compile-checked. With Wine 11 (staging)
and mingw available, scripts/windows-wine-test.sh (`just
test-windows-wine`) builds expat/FreeType/fontconfig for mingw under
target/wine-deps and runs the test suites of ghostty-vt, unicode,
datastruct, input, os, config, terminal, crash, termio and cli under
Wine (~2,700 tests). New termio/tests/exec_windows.rs drives real
cmd.exe children over ConPTY through Termio (output, exit codes,
typing, 120 KiB writes, stop, start failure). Fixes found: Windows
paths from fs::canonicalize leaked the \\?\ prefix (new
ghostty_os::path::realpath strips it like Zig); ghostty-font compiles
hb-ft for bundled HarfBuzz on Windows; ~25 tests had Unix assumptions.
Wine limitations are ignored with reasons (ResizePseudoConsole stub,
no POSIX rename over existing files) - docs/rewrite/notes/windows-wine.md.

+list-themes preview parity (former KNOWN_DIFFERENCES rows 5-9): the
search box ports vaxis TextInput (word navigation and editing bindings,
grapheme-wise editing), ASCII-only case folding, light initial colors
until terminal reports arrive (no COLORFGBG), and load errors end the
preview with exit 1; PTY tests compare with /usr/bin/ghostty.

Test Plan:
- just test-windows-wine
- cargo test -p ghostty-cli --test list_themes_pty
- cargo test --workspace --no-fail-fast (3601 passed)
2026-10-12 17:23:24 +02:00

518 lines
16 KiB
Rust

//! 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 std::fs::File;
use ghostty_os::osbytes::EncodedOs;
use crate::diagnostics::{Diagnostic, DiagnosticList};
/// 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<&EncodedOs>) -> Option<EncodedOs> {
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(EncodedOs::from_path(&base.to_path().ok()?.join("themes")))
}
}
}
}
/// An iterator that returns all possible directories for finding themes in
/// order of priority.
#[derive(Debug, Clone)]
pub struct LocationIterator {
pub resources_dir: Option<EncodedOs>,
pub i: usize,
}
impl LocationIterator {
#[must_use]
pub fn new(resources_dir: Option<&EncodedOs>) -> Self {
Self {
resources_dir: resources_dir.cloned(),
i: 0,
}
}
pub fn next_location(&mut self) -> Option<(Location, EncodedOs)> {
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_ref()) {
return Some((location, dir));
}
}
None
}
pub fn reset(&mut self) {
self.i = 0;
}
}
/// An opened theme.
#[derive(Debug)]
pub struct OpenedTheme {
pub path: EncodedOs,
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 {
if ft.is_file() {
"file"
} else if ft.is_dir() {
"directory"
} else if ft.is_symlink() {
"sym_link"
} else {
special_kind_name(ft)
}
}
#[cfg(unix)]
pub(crate) fn special_kind_name(ft: std::fs::FileType) -> &'static str {
use std::os::unix::fs::FileTypeExt;
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"
}
}
/// wasm32 has no special file types (`std::os::unix` is unavailable).
#[cfg(not(unix))]
pub(crate) fn special_kind_name(_: std::fs::FileType) -> &'static str {
"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}\": {}",
ghostty_os::zig_error::io_error_name(&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,
}
}
/// `std.fs.path.basename` for the host: `basenamePosix` or `basenameWindows`.
fn zig_basename(path: &str) -> &str {
if cfg!(windows) {
basename_windows(path)
} else {
basename_posix(path)
}
}
/// `std.fs.path.basenamePosix` (Zig 0.15.2). `.` and `..` are ordinary names.
fn basename_posix(path: &str) -> &str {
basename_impl(path, |b, _| b == b'/')
}
/// `std.fs.path.basenameWindows` (Zig 0.15.2): `/` and `\\` separate, and a
/// `:` at byte index 1 ends a drive designator of any first byte (`1:theme`
/// is `theme`).
fn basename_windows(path: &str) -> &str {
basename_impl(path, |b, i| {
b == b'/' || b == b'\\' || (b == b':' && i == 1)
})
}
/// The shared loop of both Zig functions. `is_stop(byte, index)` is true for
/// separators (and, on Windows, the drive colon).
fn basename_impl(path: &str, is_stop: impl Fn(u8, usize) -> bool) -> &str {
let bytes = path.as_bytes();
if bytes.is_empty() {
return "";
}
// Trailing separators (a drive colon at index 1 ends the path: empty).
let mut end_index = bytes.len() - 1;
loop {
let byte = bytes[end_index];
if is_stop(byte, end_index) {
// Zig's Windows loop only skips real separators; the colon case
// returns empty at once, which is the same as skipping to 0.
if byte == b':' || end_index == 0 {
return "";
}
end_index -= 1;
continue;
}
break;
}
let mut start_index = end_index;
end_index += 1;
while !is_stop(bytes[start_index], start_index) {
if start_index == 0 {
return &path[..end_index];
}
start_index -= 1;
}
&path[start_index + 1..end_index]
}
/// 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: &EncodedOs,
resources_dir: Option<&EncodedOs>,
diags: &mut DiagnosticList,
) -> Option<OpenedTheme> {
let theme_path = match theme.to_path() {
Ok(path) => path,
Err(error) => {
diag(diags, format!("failed to load theme: {error}"));
return None;
}
};
let theme_text = theme.as_encoded();
// Absolute themes are loaded a different path.
if theme.is_zig_absolute() {
let file = open_absolute(theme, diags)?;
if !check_regular(&file, theme_text, diags) {
return None;
}
return Some(OpenedTheme {
path: theme.clone(),
file,
});
}
if theme_text != zig_basename(theme_text) {
diag(
diags,
format!(
"theme \"{theme_text}\" 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 = EncodedOs::from_path(&dir.to_path().ok()?.join(&theme_path));
match path.to_syscall_path().and_then(File::open) {
Ok(file) => {
if !check_regular(&file, theme_text, 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_text}\" from the file \"{}\": {}",
path.as_encoded(),
ghostty_os::zig_error::io_error_name(&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 = EncodedOs::from_path(&dir.to_path().ok()?.join(&theme_path));
diag(
diags,
format!(
"theme \"{theme_text}\" not found, tried path \"{}\"",
path.as_encoded()
),
);
}
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: &EncodedOs, diags: &mut DiagnosticList) -> Option<File> {
let path = theme.to_syscall_path();
let theme = theme.as_encoded();
match path.and_then(File::open) {
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}\": {}",
ghostty_os::zig_error::io_error_name(&err)
),
);
}
None
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn e(s: &str) -> EncodedOs {
EncodedOs::from_encoded(s)
}
#[test]
fn theme_path_separators_rejected() {
let mut d = DiagnosticList::default();
assert!(open(&e("a/b"), None, &mut d).is_none());
assert!(
d.items()[0]
.message
.contains("cannot include path separators")
);
}
#[cfg(unix)]
#[test]
fn theme_path_errors_use_zig_names() {
let mut d = DiagnosticList::default();
assert!(open(&e("/etc/passwd/x"), None, &mut d).is_none());
assert_eq!(
d.items()[0].message,
"failed to load theme from the path \"/etc/passwd/x\": error.NotDir"
);
}
#[cfg(unix)]
#[test]
fn theme_absolute_interior_nul_opens_prefix() {
let dir = std::env::temp_dir().join(format!("gtheme-nul-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let f = dir.join("t");
std::fs::write(&f, "background = #112233\n").unwrap();
let mut bytes = f.as_os_str().as_encoded_bytes().to_vec();
bytes.extend_from_slice(b"\0tail");
let t = EncodedOs::from_bytes(&bytes);
let mut d = DiagnosticList::default();
assert!(open(&t, None, &mut d).is_some());
assert_eq!(d.items().len(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn theme_absolute_missing_and_dir() {
let mut d = DiagnosticList::default();
assert!(open(&e("/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(&e("/tmp"), None, &mut d).is_none());
// Windows cannot open a directory as a file (AccessDenied).
assert_eq!(
d.items()[0].message,
if cfg!(windows) {
"failed to load theme from the path \"/tmp\": error.AccessDenied"
} else {
"not reading theme from \"/tmp\": it is a directory"
}
);
}
#[test]
fn basename_windows_matches_zig_tests() {
// The `testBasenameWindows` cases of `std/fs/path.zig`.
for (input, expected) in [
("\\dir\\basename.ext", "basename.ext"),
("\\basename.ext", "basename.ext"),
("basename.ext", "basename.ext"),
("basename.ext\\", "basename.ext"),
("basename.ext\\\\", "basename.ext"),
("foo", "foo"),
("C:", ""),
("C:.", "."),
("C:\\", ""),
("C:\\dir\\base.ext", "base.ext"),
("C:\\basename.ext", "basename.ext"),
("C:basename.ext", "basename.ext"),
("C:basename.ext\\", "basename.ext"),
("C:basename.ext\\\\", "basename.ext"),
("C:foo", "foo"),
("file:stream", "file:stream"),
// Non-letter drive designators are drive roots too (the
// RV13 finding): `1:theme` is `theme`, not the whole name.
("1:theme", "theme"),
("1:", ""),
] {
assert_eq!(basename_windows(input), expected, "{input:?}");
}
}
#[test]
fn basename_posix_matches_zig_tests() {
for (input, expected) in [
("", ""),
("/", ""),
("/dir/basename.ext", "basename.ext"),
("/basename.ext", "basename.ext"),
("basename.ext/", "basename.ext"),
("basename.ext//", "basename.ext"),
("/aaa/", "aaa"),
("//a", "a"),
("\\dir\\basename.ext", "\\dir\\basename.ext"),
("basename.ext\\\\", "basename.ext\\\\"),
("1:theme", "1:theme"),
] {
assert_eq!(basename_posix(input), expected, "{input:?}");
}
}
#[test]
fn zig_basename_matches_std() {
assert_eq!(zig_basename(""), "");
assert_eq!(zig_basename("."), ".");
assert_eq!(zig_basename(".."), "..");
assert_eq!(zig_basename("a/"), "a");
assert_eq!(zig_basename("a/b"), "b");
assert_eq!(zig_basename("a//"), "a");
assert_eq!(zig_basename("./"), ".");
}
#[test]
fn windows_basename_matches_zig_0_15_2() {
// The drive colon is recognized only at byte index 1.
assert_eq!(basename_windows("\u{3bb}:theme"), "\u{3bb}:theme");
assert_eq!(
basename_windows("light:\u{3bb}:theme"),
"light:\u{3bb}:theme"
);
assert_eq!(basename_windows("1:theme"), "theme");
assert_eq!(basename_windows("c:"), "");
assert_eq!(basename_windows("a\\b/c\\"), "c");
assert_eq!(basename_windows("\\"), "");
assert_eq!(basename_windows("theme"), "theme");
}
#[test]
fn dot_theme_names_are_directories() {
let dir = std::env::temp_dir().join(format!("ghostty-theme-dot-{}", std::process::id()));
std::fs::create_dir_all(dir.join("themes")).unwrap();
for name in [".", ".."] {
let mut d = DiagnosticList::default();
assert!(open(&e(name), Some(&EncodedOs::from_path(&dir)), &mut d).is_none());
assert!(
d.items()[0].message.ends_with(if cfg!(windows) {
"error.AccessDenied"
} else {
"it is a directory"
}),
"{}",
d.items()[0].message
);
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[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(&e("mytheme-zzz"), Some(&EncodedOs::from_path(&dir)), &mut d).unwrap();
assert!(
t.path
.ends_with_plain(&format!("themes{}mytheme-zzz", std::path::MAIN_SEPARATOR))
);
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(&e("nope-zzz"), Some(&e("/nonexistent-res")), &mut d).is_none());
let tried = std::path::Path::new("/nonexistent-res")
.join("themes")
.join("nope-zzz");
let want = format!(
"theme \"nope-zzz\" not found, tried path \"{}\"",
tried.display()
);
assert!(d.items().iter().any(|x| x.message == want));
}
}