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)
This commit is contained in:
ddidderr committed 2026-10-12 17:23:24 +02:00
1 parent d1eb32b4f7
commit 1431a8a1d8
30 files changed
+1655 -227

No files matched your search

Generated
+4
View File
@@ -1443,6 +1443,7 @@ dependencies = [
"tempfile", "tempfile",
"termina", "termina",
"thiserror 2.0.21", "thiserror 2.0.21",
"unicode-segmentation",
"unicode-width", "unicode-width",
] ]
@@ -1530,6 +1531,7 @@ name = "ghostty-font"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"cc",
"freetype-sys", "freetype-sys",
"ghostty-config", "ghostty-config",
"ghostty-datastruct", "ghostty-datastruct",
@@ -1686,6 +1688,8 @@ dependencies = [
"ghostty-core", "ghostty-core",
"ghostty-crash", "ghostty-crash",
"ghostty-os", "ghostty-os",
"ghostty-terminal",
"ghostty-vt",
"log", "log",
"rustix", "rustix",
"serde_json", "serde_json",
+5 -1
View File
@@ -29,7 +29,9 @@ ghostty-os.workspace = true
log = "0.4" log = "0.4"
[dev-dependencies] [dev-dependencies]
rustix = { version = "1", features = ["process"] } ghostty-terminal.workspace = true
ghostty-vt.workspace = true
rustix = { version = "1", features = ["process", "pty", "termios", "fs", "stdio"] }
serde_json = "1" serde_json = "1"
tempfile = "3" tempfile = "3"
@@ -78,6 +80,8 @@ ghostty-lib = { path = "crates/ghostty-lib" }
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"
# `--cfg wine` is set by scripts/windows-wine-test.sh to ignore Wine limitations.
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(wine)"] }
[workspace.lints.clippy] [workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 }
+4 -1
View File
@@ -14,7 +14,8 @@ All of Ghostty's Zig code is ported: the Linux GTK app (verified at runtime
and pixel-identical to Ghostty 1.3.1), the `libghostty` and `libghostty-vt` and pixel-identical to Ghostty 1.3.1), the `libghostty` and `libghostty-vt`
C APIs (same headers/ABI), WASM, crash reporting, and the macOS C APIs (same headers/ABI), WASM, crash reporting, and the macOS
(Metal/CoreText) and Windows (ConPTY) code paths. macOS and Windows are (Metal/CoreText) and Windows (ConPTY) code paths. macOS and Windows are
compile-checked on Linux (`just check-macos`, `just check-windows`) but have compile-checked on Linux (`just check-macos`, `just check-windows`); the
Windows test suites also run under Wine (`just test-windows-wine`), macOS has
not been run natively. The Swift macOS app is not part of the port; it is a not been run natively. The Swift macOS app is not part of the port; it is a
client of the libghostty C API. client of the libghostty C API.
@@ -63,6 +64,8 @@ build (see `docs/rewrite/notes/perf-P1.md`).
Ghostty's own Zig code (sets up Zig 0.15.2 under `target/`). Ghostty's own Zig code (sets up Zig 0.15.2 under `target/`).
- `just visual-parity` — pixel comparison against the installed Ghostty in a - `just visual-parity` — pixel comparison against the installed Ghostty in a
headless nested weston. headless nested weston.
- `just test-windows-wine` — runs the Windows test suites (incl. ConPTY /
`cmd.exe` end-to-end tests) under Wine with mingw-w64.
- `just check-all` — native gate plus macOS/Windows/WASM checks. - `just check-all` — native gate plus macOS/Windows/WASM checks.
## Documentation ## Documentation
+1
View File
@@ -24,6 +24,7 @@ ratatui = "0.30.2"
termina = "0.3.3" termina = "0.3.3"
thiserror = "2.0.21" thiserror = "2.0.21"
unicode-width = "0.2.2" unicode-width = "0.2.2"
unicode-segmentation = "1.13.3"
[build-dependencies] [build-dependencies]
ghostty-helpgen.workspace = true ghostty-helpgen.workspace = true
+7 -1
View File
@@ -387,7 +387,13 @@ mod tests {
); );
assert_eq!("Zeta (resources)\n", list(ColorScheme::Light, false)); assert_eq!("Zeta (resources)\n", list(ColorScheme::Light, false));
assert_eq!( assert_eq!(
format!("Zeta (resources) {}/themes/Zeta\n", res.as_encoded()), format!(
"Zeta (resources) {}\n",
std::path::Path::new(res.as_encoded())
.join("themes")
.join("Zeta")
.display()
),
list(ColorScheme::Light, true) list(ColorScheme::Light, true)
); );
} }
+362 -176
View File
@@ -32,6 +32,7 @@ use ratatui::{
layout::{Position, Rect}, layout::{Position, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
}; };
use unicode_segmentation::UnicodeSegmentation as _;
use super::{ use super::{
ColorScheme, ColorScheme,
@@ -62,102 +63,137 @@ pub enum Scheme {
Dark, Dark,
} }
/// A minimal single line text input (`vaxis.widgets.TextInput`). /// A single line text input (`vaxis.widgets.TextInput`). The cursor is a
/// byte offset (always on a grapheme boundary) and all editing follows
/// vaxis: grapheme-wise left/right/delete, byte-wise (ASCII space) words.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct TextInput { pub struct TextInput {
pub text: String, pub text: String,
/// Cursor position in chars. /// Cursor position in bytes.
pub cursor: usize, pub cursor: usize,
} }
/// Byte length of the first grapheme of `s` (0 when empty).
fn first_grapheme_len(s: &str) -> usize {
s.graphemes(true).next().map_or(0, str::len)
}
/// Byte length of the last grapheme of `s` (0 when empty).
fn last_grapheme_len(s: &str) -> usize {
s.graphemes(true).next_back().map_or(0, str::len)
}
impl TextInput { impl TextInput {
fn byte_idx(&self, chars: usize) -> usize {
self.text
.char_indices()
.nth(chars)
.map_or(self.text.len(), |(i, _)| i)
}
fn len(&self) -> usize {
self.text.chars().count()
}
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.text.clear(); self.text.clear();
self.cursor = 0; self.cursor = 0;
} }
/// Handle an editing key. Returns true if it was consumed. fn move_left(&mut self, n: usize) {
self.cursor = self.cursor.saturating_sub(n);
}
fn move_right(&mut self, n: usize) {
self.cursor = (self.cursor + n).min(self.text.len());
}
/// Delete `n` bytes before the cursor (`growGapLeft`).
fn delete_left(&mut self, n: usize) {
let start = self.cursor.saturating_sub(n);
self.text.drain(start..self.cursor);
self.cursor = start;
}
/// Delete `n` bytes after the cursor (`growGapRight`).
fn delete_right(&mut self, n: usize) {
let end = (self.cursor + n).min(self.text.len());
self.text.drain(self.cursor..end);
}
/// Bytes from the cursor to the end of the next word (`moveForwardWordwise`).
fn forward_word_len(&self) -> usize {
let second = &self.text.as_bytes()[self.cursor..];
let start = second.iter().take_while(|&&b| b == b' ').count();
second[start..]
.iter()
.position(|&b| b == b' ')
.map_or(second.len(), |p| start + p)
}
/// Bytes from the cursor back to the start of the previous word
/// (`moveBackwardWordwise`).
fn backward_word_len(&self) -> usize {
let first = self.text[..self.cursor].trim_end_matches(' ');
let idx = first.rfind(' ').map_or(0, |last| last + 1);
self.cursor - idx
}
/// Handle an editing key (`TextInput.update`). Returns true if it was
/// consumed.
pub fn update(&mut self, key: &KeyEvent) -> bool { pub fn update(&mut self, key: &KeyEvent) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); // vaxis `Key.matches` compares the modifiers exactly (lock keys
let alt = key.modifiers.contains(KeyModifiers::ALT); // aside), except that text input tolerates Shift.
match key.code { let m = key.modifiers;
KeyCode::Char(c) if !ctrl && !alt => { let none = m.is_empty();
let at = self.byte_idx(self.cursor); let only = |x: KeyModifiers| m == x;
self.text.insert(at, c); let ctrl = only(KeyModifiers::CONTROL);
self.cursor += 1; let alt = only(KeyModifiers::ALT);
} let code = key.code;
KeyCode::Backspace => { let c = |ch: char| code == KeyCode::Char(ch);
if self.cursor > 0 { if none && code == KeyCode::Backspace {
let at = self.byte_idx(self.cursor - 1); self.delete_left(last_grapheme_len(&self.text[..self.cursor]));
self.text.remove(at); } else if (none && code == KeyCode::Delete) || (ctrl && c('d')) {
self.cursor -= 1; self.delete_right(first_grapheme_len(&self.text[self.cursor..]));
} } else if (none && code == KeyCode::Left) || (ctrl && c('b')) {
} self.move_left(last_grapheme_len(&self.text[..self.cursor]));
KeyCode::Delete | KeyCode::Char('d') if key.code == KeyCode::Delete || ctrl => { } else if (none && code == KeyCode::Right) || (ctrl && c('f')) {
if self.cursor < self.len() { self.move_right(first_grapheme_len(&self.text[self.cursor..]));
let at = self.byte_idx(self.cursor); } else if (ctrl && c('a')) || (none && code == KeyCode::Home) {
self.text.remove(at);
}
}
KeyCode::Left | KeyCode::Char('b') if key.code == KeyCode::Left || ctrl => {
self.cursor = self.cursor.saturating_sub(1);
}
KeyCode::Right | KeyCode::Char('f') if key.code == KeyCode::Right || ctrl => {
self.cursor = (self.cursor + 1).min(self.len());
}
KeyCode::Home => self.cursor = 0,
KeyCode::Char('a') if ctrl => self.cursor = 0,
KeyCode::End => self.cursor = self.len(),
KeyCode::Char('e') if ctrl => self.cursor = self.len(),
KeyCode::Char('u') if ctrl => {
let at = self.byte_idx(self.cursor);
self.text.drain(..at);
self.cursor = 0; self.cursor = 0;
} } else if (ctrl && c('e')) || (none && code == KeyCode::End) {
KeyCode::Char('k') if ctrl => { self.cursor = self.text.len();
let at = self.byte_idx(self.cursor); } else if ctrl && c('k') {
self.text.truncate(at); self.text.truncate(self.cursor);
} } else if ctrl && c('u') {
KeyCode::Char('w') if ctrl => { self.delete_left(self.cursor);
let upto = self.byte_idx(self.cursor); } else if alt && (c('b') || code == KeyCode::Left) {
let head = self.text[..upto].trim_end_matches(' '); self.move_left(self.backward_word_len());
let new_end = head.rfind(' ').map_or(0, |i| i + 1); } else if alt && (c('f') || code == KeyCode::Right) {
self.text.drain(new_end..upto); self.move_right(self.forward_word_len());
self.cursor = self.text[..new_end].chars().count(); } else if (ctrl && c('w')) || (alt && code == KeyCode::Backspace) {
} // Move one word backward, then delete what was moved over.
_ => return false, let n = self.backward_word_len();
self.move_left(n);
self.delete_right(n);
} else if alt && c('d') {
self.delete_right(self.forward_word_len());
} else if let KeyCode::Char(ch) = code
&& (none || only(KeyModifiers::SHIFT))
{
self.text.insert(self.cursor, ch);
self.cursor += ch.len_utf8();
} else {
return false;
} }
true true
} }
} }
/// `zf.rank` with `.plain = true, .to_lower = true`: every token must match /// `zf.rank` with `.plain = true, .to_lower = true`: every token must match
/// the (lowercased) name as a subsequence. Only whether a rank exists is used /// the (ASCII-lowercased) name as a subsequence of bytes. Only whether a
/// by the preview, so the score is a simple span length (lower is better). /// rank exists is used by the preview, so the score is a simple span length
/// (lower is better).
#[must_use] #[must_use]
pub fn rank(name: &str, tokens: &[&str]) -> Option<f64> { pub fn rank(name: &str, tokens: &[&str]) -> Option<f64> {
let name: Vec<char> = name.to_lowercase().chars().collect(); let name: Vec<u8> = name.to_ascii_lowercase().into_bytes();
let mut total = 0.0; let mut total = 0.0;
for token in tokens { for token in tokens {
let token: Vec<char> = token.chars().collect(); let token = token.as_bytes();
if token.is_empty() { if token.is_empty() {
continue; continue;
} }
// Prefer a contiguous match. // Prefer a contiguous match.
let contiguous = name let contiguous = name.windows(token.len()).position(|w| w == token);
.windows(token.len())
.position(|w| w == token.as_slice());
if let Some(start) = contiguous { if let Some(start) = contiguous {
total += start as f64; total += start as f64;
continue; continue;
@@ -380,23 +416,8 @@ pub struct Preview {
overlays: Vec<Overlay>, overlays: Vec<Overlay>,
} }
fn initial_scheme() -> Scheme {
// COLORFGBG is "fg;bg" (sometimes "fg;default;bg"); bg 0-6 and 8 are dark.
if let Ok(v) = std::env::var("COLORFGBG") {
if let Some(bg) = v.rsplit(';').next().and_then(|s| s.parse::<u8>().ok()) {
return if bg <= 6 || bg == 8 {
Scheme::Dark
} else {
Scheme::Light
};
}
}
Scheme::Light
}
impl Preview { impl Preview {
#[must_use] pub fn new(themes: Vec<ThemeListElement>, theme_filter: ColorScheme) -> Result<Preview, Error> {
pub fn new(themes: Vec<ThemeListElement>, theme_filter: ColorScheme) -> Preview {
let mut p = Preview { let mut p = Preview {
should_quit: false, should_quit: false,
themes, themes,
@@ -405,7 +426,7 @@ impl Preview {
window: 0, window: 0,
hex: false, hex: false,
mode: Mode::Normal, mode: Mode::Normal,
color_scheme: initial_scheme(), color_scheme: Scheme::Light,
text_input: TextInput::default(), text_input: TextInput::default(),
theme_filter, theme_filter,
mouse: None, mouse: None,
@@ -415,12 +436,12 @@ impl Preview {
effects: vec![], effects: vec![],
overlays: vec![], overlays: vec![],
}; };
p.update_filtered(); p.update_filtered()?;
p Ok(p)
} }
/// Recompute the filtered theme list (Zig: `updateFiltered`). /// Recompute the filtered theme list (Zig: `updateFiltered`).
pub fn update_filtered(&mut self) { pub fn update_filtered(&mut self) -> Result<(), Error> {
let relative = self.current.saturating_sub(self.window); let relative = self.current.saturating_sub(self.window);
let selected: Option<String> = self let selected: Option<String> = self
.filtered .filtered
@@ -436,8 +457,7 @@ impl Preview {
let searching = !self.text_input.text.is_empty(); let searching = !self.text_input.text.is_empty();
for (i, theme) in self.themes.iter_mut().enumerate() { for (i, theme) in self.themes.iter_mut().enumerate() {
// Errors are ignored here (Zig would abort the whole preview). theme_config.load_file(&absolute_path(&theme.path))?;
let _ = theme_config.load_file(&absolute_path(&theme.path));
if !should_include_theme(self.theme_filter, &theme_config) { if !should_include_theme(self.theme_filter, &theme_config) {
continue; continue;
} }
@@ -453,13 +473,13 @@ impl Preview {
} }
if old == self.filtered { if old == self.filtered {
return; return Ok(());
} }
if self.filtered.is_empty() { if self.filtered.is_empty() {
self.current = 0; self.current = 0;
self.window = 0; self.window = 0;
return; return Ok(());
} }
let (current, window) = (|| { let (current, window) = (|| {
@@ -485,6 +505,7 @@ impl Preview {
})(); })();
self.current = current; self.current = current;
self.window = window; self.window = window;
Ok(())
} }
pub fn up(&mut self, count: usize) { pub fn up(&mut self, count: usize) {
@@ -516,15 +537,16 @@ impl Preview {
} }
/// Handle a terminal event (Zig: `update`). /// Handle a terminal event (Zig: `update`).
pub fn update(&mut self, event: &Event) { pub fn update(&mut self, event: &Event) -> Result<(), Error> {
match event { match event {
Event::Key(key) if key.kind != KeyEventKind::Release => self.update_key(key), Event::Key(key) if key.kind != KeyEventKind::Release => self.update_key(key)?,
Event::Mouse(m) => self.mouse = Some(*m), Event::Mouse(m) => self.mouse = Some(*m),
_ => {} _ => {}
} }
Ok(())
} }
fn update_key(&mut self, key: &KeyEvent) { fn update_key(&mut self, key: &KeyEvent) -> Result<(), Error> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT); let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let plain = key.modifiers.difference(KeyModifiers::SHIFT).is_empty(); let plain = key.modifiers.difference(KeyModifiers::SHIFT).is_empty();
@@ -553,7 +575,7 @@ impl Preview {
} }
if ctrl && matches!(code, KeyCode::Char('x' | '/' | '_')) { if ctrl && matches!(code, KeyCode::Char('x' | '/' | '_')) {
self.text_input.clear(); self.text_input.clear();
self.update_filtered(); self.update_filtered()?;
} }
if plain && code == KeyCode::Home { if plain && code == KeyCode::Home {
self.current = 0; self.current = 0;
@@ -599,7 +621,7 @@ impl Preview {
ColorScheme::Dark => ColorScheme::Light, ColorScheme::Dark => ColorScheme::Light,
ColorScheme::Light => ColorScheme::All, ColorScheme::Light => ColorScheme::All,
}; };
self.update_filtered(); self.update_filtered()?;
} }
} }
Mode::Help => { Mode::Help => {
@@ -619,15 +641,15 @@ impl Preview {
Mode::Search => { Mode::Search => {
if plain && matches!(code, KeyCode::Esc | KeyCode::Enter) { if plain && matches!(code, KeyCode::Esc | KeyCode::Enter) {
self.mode = Mode::Normal; self.mode = Mode::Normal;
return; return Ok(());
} }
if ctrl && matches!(code, KeyCode::Char('x' | '/' | '_')) { if ctrl && matches!(code, KeyCode::Char('x' | '/' | '_')) {
self.text_input.clear(); self.text_input.clear();
self.update_filtered(); self.update_filtered()?;
return; return Ok(());
} }
self.text_input.update(key); self.text_input.update(key);
self.update_filtered(); self.update_filtered()?;
} }
Mode::Save => { Mode::Save => {
if is_char('q') { if is_char('q') {
@@ -641,6 +663,7 @@ impl Preview {
} }
} }
} }
Ok(())
} }
fn save_selected_theme(&self) { fn save_selected_theme(&self) {
@@ -849,12 +872,7 @@ impl Preview {
self.ui_standard(), self.ui_standard(),
); );
child.print(&self.text_input.text, self.ui_standard(), 0, 0); child.print(&self.text_input.text, self.ui_standard(), 0, 0);
let col = gw(&self let col = gw(&self.text_input.text[..self.text_input.cursor]);
.text_input
.text
.chars()
.take(self.text_input.cursor)
.collect::<String>());
if col < child.area.width { if col < child.area.width {
cursor = Some(Position::new(child.area.x + col, child.area.y)); cursor = Some(Position::new(child.area.x + col, child.area.y));
} }
@@ -1653,15 +1671,15 @@ fn convert_mouse(m: termina::event::MouseEvent) -> MouseEvent {
/// color-scheme report (`CSI ? 997 ; 1|2 n`, sent in answer to /// color-scheme report (`CSI ? 997 ; 1|2 n`, sent in answer to
/// `CSI ? 996 n` and on every change while `CSI ? 2031 h` is active), which /// `CSI ? 996 n` and on every change while `CSI ? 2031 h` is active), which
/// vaxis delivers as its `color_scheme` event. /// vaxis delivers as its `color_scheme` event.
fn handle_terminal_event(app: &mut Preview, ev: termina::Event) { fn handle_terminal_event(app: &mut Preview, ev: termina::Event) -> Result<(), Error> {
use termina::escape::csi::{Csi, Mode, ThemeMode}; use termina::escape::csi::{Csi, Mode, ThemeMode};
match ev { match ev {
termina::Event::Key(k) => { termina::Event::Key(k) => {
if let Some(k) = convert_key(k) { if let Some(k) = convert_key(k) {
app.update(&Event::Key(k)); app.update(&Event::Key(k))?;
} }
} }
termina::Event::Mouse(m) => app.update(&Event::Mouse(convert_mouse(m))), termina::Event::Mouse(m) => app.update(&Event::Mouse(convert_mouse(m)))?,
termina::Event::Csi(Csi::Mode(Mode::ReportTheme(t))) => { termina::Event::Csi(Csi::Mode(Mode::ReportTheme(t))) => {
app.color_scheme = match t { app.color_scheme = match t {
ThemeMode::Dark => Scheme::Dark, ThemeMode::Dark => Scheme::Dark,
@@ -1670,11 +1688,12 @@ fn handle_terminal_event(app: &mut Preview, ev: termina::Event) {
} }
_ => {} _ => {}
} }
Ok(())
} }
/// Run the interactive preview (Zig: `preview`). Blocks until the user quits. /// Run the interactive preview (Zig: `preview`). Blocks until the user quits.
pub fn preview(themes: Vec<ThemeListElement>, theme_filter: ColorScheme) -> Result<(), Error> { pub fn preview(themes: Vec<ThemeListElement>, theme_filter: ColorScheme) -> Result<(), Error> {
let mut app = Preview::new(themes, theme_filter); let mut app = Preview::new(themes, theme_filter)?;
run_loop(&mut app) run_loop(&mut app)
} }
@@ -1727,10 +1746,10 @@ fn run_loop(app: &mut Preview) -> Result<(), Error> {
// Block for an event, then drain everything that is pending. // Block for an event, then drain everything that is pending.
let first = tty.read(|_| true)?; let first = tty.read(|_| true)?;
handle_terminal_event(app, first); handle_terminal_event(app, first)?;
while tty.poll(|_| true, Some(std::time::Duration::ZERO))? { while tty.poll(|_| true, Some(std::time::Duration::ZERO))? {
let ev = tty.read(|_| true)?; let ev = tty.read(|_| true)?;
handle_terminal_event(app, ev); handle_terminal_event(app, ev)?;
} }
for effect in app.take_effects() { for effect in app.take_effects() {
match effect { match effect {
@@ -1811,7 +1830,7 @@ mod tests {
("delta-light", "#f0f0f0"), ("delta-light", "#f0f0f0"),
], ],
); );
let mut p = Preview::new(themes, ColorScheme::All); let mut p = Preview::new(themes, ColorScheme::All).unwrap();
p.color_scheme = Scheme::Light; p.color_scheme = Scheme::Light;
p p
} }
@@ -1833,28 +1852,28 @@ mod tests {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp); let mut p = app(&tmp);
assert_eq!(4, p.filtered.len()); assert_eq!(4, p.filtered.len());
p.update(&ch('j')); p.update(&ch('j')).unwrap();
p.update(&key(KeyCode::Down)); p.update(&key(KeyCode::Down)).unwrap();
assert_eq!(2, p.current); assert_eq!(2, p.current);
p.update(&key(KeyCode::PageDown)); p.update(&key(KeyCode::PageDown)).unwrap();
assert_eq!(3, p.current); assert_eq!(3, p.current);
p.update(&ch('k')); p.update(&ch('k')).unwrap();
assert_eq!(2, p.current); assert_eq!(2, p.current);
p.update(&key(KeyCode::PageUp)); p.update(&key(KeyCode::PageUp)).unwrap();
assert_eq!(0, p.current); assert_eq!(0, p.current);
p.update(&key(KeyCode::End)); p.update(&key(KeyCode::End)).unwrap();
assert_eq!(3, p.current); assert_eq!(3, p.current);
p.update(&key(KeyCode::Home)); p.update(&key(KeyCode::Home)).unwrap();
assert_eq!(0, p.current); assert_eq!(0, p.current);
assert!(!p.should_quit); assert!(!p.should_quit);
p.update(&ch('q')); p.update(&ch('q')).unwrap();
assert!(p.should_quit); assert!(p.should_quit);
let mut p = app(&tmp); let mut p = app(&tmp);
p.update(&ctrl('c')); p.update(&ctrl('c')).unwrap();
assert!(p.should_quit); assert!(p.should_quit);
let mut p = app(&tmp); let mut p = app(&tmp);
p.update(&key(KeyCode::Esc)); p.update(&key(KeyCode::Esc)).unwrap();
assert!(p.should_quit); assert!(p.should_quit);
} }
@@ -1862,7 +1881,7 @@ mod tests {
fn filter_cycles_and_filters() { fn filter_cycles_and_filters() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp); let mut p = app(&tmp);
p.update(&ch('f')); p.update(&ch('f')).unwrap();
assert_eq!(ColorScheme::Dark, p.theme_filter); assert_eq!(ColorScheme::Dark, p.theme_filter);
let names: Vec<_> = p let names: Vec<_> = p
.filtered .filtered
@@ -1870,10 +1889,10 @@ mod tests {
.map(|&i| p.themes[i].theme.as_str()) .map(|&i| p.themes[i].theme.as_str())
.collect(); .collect();
assert_eq!(vec!["alpha", "gamma-dark"], names); assert_eq!(vec!["alpha", "gamma-dark"], names);
p.update(&ch('f')); p.update(&ch('f')).unwrap();
assert_eq!(ColorScheme::Light, p.theme_filter); assert_eq!(ColorScheme::Light, p.theme_filter);
assert_eq!(2, p.filtered.len()); assert_eq!(2, p.filtered.len());
p.update(&ch('f')); p.update(&ch('f')).unwrap();
assert_eq!(ColorScheme::All, p.theme_filter); assert_eq!(ColorScheme::All, p.theme_filter);
assert_eq!(4, p.filtered.len()); assert_eq!(4, p.filtered.len());
} }
@@ -1882,25 +1901,25 @@ mod tests {
fn search_mode() { fn search_mode() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp); let mut p = app(&tmp);
p.update(&ch('/')); p.update(&ch('/')).unwrap();
assert_eq!(Mode::Search, p.mode); assert_eq!(Mode::Search, p.mode);
p.update(&ch('G')); p.update(&ch('G')).unwrap();
p.update(&ch('a')); p.update(&ch('a')).unwrap();
p.update(&ch('m')); p.update(&ch('m')).unwrap();
assert_eq!("Gam", p.text_input.text); assert_eq!("Gam", p.text_input.text);
assert_eq!(1, p.filtered.len()); assert_eq!(1, p.filtered.len());
assert_eq!("gamma-dark", p.themes[p.filtered[0]].theme); assert_eq!("gamma-dark", p.themes[p.filtered[0]].theme);
p.update(&key(KeyCode::Backspace)); p.update(&key(KeyCode::Backspace)).unwrap();
p.update(&key(KeyCode::Backspace)); p.update(&key(KeyCode::Backspace)).unwrap();
p.update(&key(KeyCode::Backspace)); p.update(&key(KeyCode::Backspace)).unwrap();
assert_eq!(4, p.filtered.len()); assert_eq!(4, p.filtered.len());
p.update(&ch('l')); p.update(&ch('l')).unwrap();
p.update(&ch('t')); p.update(&ch('t')).unwrap();
assert_eq!(1, p.filtered.len()); // "delta-light" is the only match for "lt" assert_eq!(1, p.filtered.len()); // "delta-light" is the only match for "lt"
p.update(&ctrl('x')); p.update(&ctrl('x')).unwrap();
assert_eq!("", p.text_input.text); assert_eq!("", p.text_input.text);
assert_eq!(4, p.filtered.len()); assert_eq!(4, p.filtered.len());
p.update(&key(KeyCode::Enter)); p.update(&key(KeyCode::Enter)).unwrap();
assert_eq!(Mode::Normal, p.mode); assert_eq!(Mode::Normal, p.mode);
} }
@@ -1908,19 +1927,19 @@ mod tests {
fn hex_toggle_and_help_mode() { fn hex_toggle_and_help_mode() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp); let mut p = app(&tmp);
p.update(&ch('x')); p.update(&ch('x')).unwrap();
assert!(p.hex); assert!(p.hex);
p.update(&ch('d')); p.update(&ch('d')).unwrap();
assert!(!p.hex); assert!(!p.hex);
p.update(&key(KeyCode::F(1))); p.update(&key(KeyCode::F(1))).unwrap();
assert_eq!(Mode::Help, p.mode); assert_eq!(Mode::Help, p.mode);
// Keys are modal: 'j' does nothing in help. // Keys are modal: 'j' does nothing in help.
p.update(&ch('j')); p.update(&ch('j')).unwrap();
assert_eq!(0, p.current); assert_eq!(0, p.current);
p.update(&key(KeyCode::Esc)); p.update(&key(KeyCode::Esc)).unwrap();
assert_eq!(Mode::Normal, p.mode); assert_eq!(Mode::Normal, p.mode);
assert!(!p.should_quit); assert!(!p.should_quit);
p.update(&ch('?')); p.update(&ch('?')).unwrap();
assert_eq!(Mode::Help, p.mode); assert_eq!(Mode::Help, p.mode);
} }
@@ -1928,11 +1947,12 @@ mod tests {
fn copy_and_save() { fn copy_and_save() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp); let mut p = app(&tmp);
p.update(&ch('c')); p.update(&ch('c')).unwrap();
p.update(&Event::Key(KeyEvent::new( p.update(&Event::Key(KeyEvent::new(
KeyCode::Char('C'), KeyCode::Char('C'),
KeyModifiers::SHIFT, KeyModifiers::SHIFT,
))); )))
.unwrap();
let effects = p.take_effects(); let effects = p.take_effects();
assert_eq!( assert_eq!(
Effect::Copy(ghostty_os::osbytes::EncodedOs::from_encoded("alpha")), Effect::Copy(ghostty_os::osbytes::EncodedOs::from_encoded("alpha")),
@@ -1947,11 +1967,11 @@ mod tests {
let auto = tmp.path().join("cfg/auto/theme.ghostty"); let auto = tmp.path().join("cfg/auto/theme.ghostty");
p.auto_theme_path = Some(auto.clone()); p.auto_theme_path = Some(auto.clone());
p.update(&key(KeyCode::Enter)); p.update(&key(KeyCode::Enter)).unwrap();
assert_eq!(Mode::Save, p.mode); assert_eq!(Mode::Save, p.mode);
p.update(&ch('w')); p.update(&ch('w')).unwrap();
assert_eq!("theme = alpha\n", std::fs::read_to_string(&auto).unwrap()); assert_eq!("theme = alpha\n", std::fs::read_to_string(&auto).unwrap());
p.update(&key(KeyCode::Esc)); p.update(&key(KeyCode::Esc)).unwrap();
assert_eq!(Mode::Normal, p.mode); assert_eq!(Mode::Normal, p.mode);
} }
@@ -1974,7 +1994,7 @@ mod tests {
fn draw_layout() { fn draw_layout() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp); let mut p = app(&tmp);
p.update(&ch('j')); p.update(&ch('j')).unwrap();
let buf = render(&mut p, 120, 60); let buf = render(&mut p, 120, 60);
// List on the left: selected item has the marker and the arrow at col 30. // List on the left: selected item has the marker and the arrow at col 30.
let r1 = row_text(&buf, 1); let r1 = row_text(&buf, 1);
@@ -2014,8 +2034,15 @@ mod tests {
.iter() .iter()
.find(|o| o.text == "alpha" && o.x == 2) .find(|o| o.text == "alpha" && o.x == 2)
.unwrap(); .unwrap();
assert!(alpha.link.as_deref().unwrap().starts_with("file:///")); // (On Windows the path is `C:\...`; Zig's `toUri` does not rewrite it,
assert!(alpha.link.as_deref().unwrap().ends_with("/alpha")); // the backslashes are percent-encoded.)
let (prefix, suffix) = if cfg!(windows) {
("file://", "%5Calpha")
} else {
("file:///", "/alpha")
};
assert!(alpha.link.as_deref().unwrap().starts_with(prefix));
assert!(alpha.link.as_deref().unwrap().ends_with(suffix));
// The five lorem ipsum words get their own underline overlays. // The five lorem ipsum words get their own underline overlays.
let uls: Vec<(String, Ul)> = ov let uls: Vec<(String, Ul)> = ov
.iter() .iter()
@@ -2031,7 +2058,7 @@ mod tests {
} }
// And the raw escapes carry OSC 8 and the 4:N styles. // And the raw escapes carry OSC 8 and the 4:N styles.
let raw = render_overlays(&ov); let raw = render_overlays(&ov);
assert!(raw.contains("\x1b]8;;file:///")); assert!(raw.contains(&format!("\x1b]8;;{prefix}")));
assert!(raw.contains("\x1b]8;;\x1b\\")); assert!(raw.contains("\x1b]8;;\x1b\\"));
for sgr in ["4:2", "4:3", "4:4", "4:5"] { for sgr in ["4:2", "4:3", "4:4", "4:5"] {
assert!(raw.contains(sgr), "{sgr}"); assert!(raw.contains(sgr), "{sgr}");
@@ -2042,7 +2069,7 @@ mod tests {
fn popup_drops_covered_overlays() { fn popup_drops_covered_overlays() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp); let mut p = app(&tmp);
p.update(&ch('?')); p.update(&ch('?')).unwrap();
let _ = render(&mut p, 120, 40); let _ = render(&mut p, 120, 40);
// The help window covers the middle of the screen: no overlay may // The help window covers the middle of the screen: no overlay may
// intersect it. // intersect it.
@@ -2060,10 +2087,10 @@ mod tests {
let mut p = app(&tmp); let mut p = app(&tmp);
let mut parser = termina::Parser::default(); let mut parser = termina::Parser::default();
parser.parse(b"\x1b[?997;1n", false); parser.parse(b"\x1b[?997;1n", false);
handle_terminal_event(&mut p, parser.pop().unwrap()); handle_terminal_event(&mut p, parser.pop().unwrap()).unwrap();
assert_eq!(Scheme::Dark, p.color_scheme); assert_eq!(Scheme::Dark, p.color_scheme);
parser.parse(b"\x1b[?997;2n", false); parser.parse(b"\x1b[?997;2n", false);
handle_terminal_event(&mut p, parser.pop().unwrap()); handle_terminal_event(&mut p, parser.pop().unwrap()).unwrap();
assert_eq!(Scheme::Light, p.color_scheme); assert_eq!(Scheme::Light, p.color_scheme);
} }
@@ -2085,7 +2112,7 @@ mod tests {
fn draw_help_and_save_and_search_overlays() { fn draw_help_and_save_and_search_overlays() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp); let mut p = app(&tmp);
p.update(&ch('?')); p.update(&ch('?')).unwrap();
let buf = render(&mut p, 120, 40); let buf = render(&mut p, 120, 40);
let all: String = (0..40) let all: String = (0..40)
.map(|y| row_text(&buf, y)) .map(|y| row_text(&buf, y))
@@ -2094,8 +2121,8 @@ mod tests {
assert!(all.contains("Cycle through theme filters.")); assert!(all.contains("Cycle through theme filters."));
assert!(all.contains('\u{256d}') && all.contains('\u{256f}')); assert!(all.contains('\u{256d}') && all.contains('\u{256f}'));
p.update(&key(KeyCode::Esc)); p.update(&key(KeyCode::Esc)).unwrap();
p.update(&key(KeyCode::Enter)); p.update(&key(KeyCode::Enter)).unwrap();
let buf = render(&mut p, 120, 40); let buf = render(&mut p, 120, 40);
let all: String = (0..40) let all: String = (0..40)
.map(|y| row_text(&buf, y)) .map(|y| row_text(&buf, y))
@@ -2104,9 +2131,9 @@ mod tests {
assert!(all.contains("theme = alpha")); assert!(all.contains("theme = alpha"));
assert!(all.contains("config-file = ?auto/theme.ghostty")); assert!(all.contains("config-file = ?auto/theme.ghostty"));
p.update(&key(KeyCode::Esc)); p.update(&key(KeyCode::Esc)).unwrap();
p.update(&ch('/')); p.update(&ch('/')).unwrap();
p.update(&ch('b')); p.update(&ch('b')).unwrap();
let mut buf = Buffer::empty(Rect::new(0, 0, 120, 40)); let mut buf = Buffer::empty(Rect::new(0, 0, 120, 40));
let cursor = p.draw(&mut buf); let cursor = p.draw(&mut buf);
assert!(cursor.is_some()); assert!(cursor.is_some());
@@ -2116,18 +2143,18 @@ mod tests {
fn empty_filter_shows_no_theme_found() { fn empty_filter_shows_no_theme_found() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp); let mut p = app(&tmp);
p.update(&ch('/')); p.update(&ch('/')).unwrap();
p.update(&ch('z')); p.update(&ch('z')).unwrap();
p.update(&ch('z')); p.update(&ch('z')).unwrap();
assert_eq!(0, p.filtered.len()); assert_eq!(0, p.filtered.len());
let buf = render(&mut p, 80, 20); let buf = render(&mut p, 80, 20);
assert!(row_text(&buf, 9).contains("No theme found!")); assert!(row_text(&buf, 9).contains("No theme found!"));
// Navigation does not panic with an empty list. // Navigation does not panic with an empty list.
p.update(&key(KeyCode::Enter)); p.update(&key(KeyCode::Enter)).unwrap();
p.update(&key(KeyCode::Esc)); p.update(&key(KeyCode::Esc)).unwrap();
p.update(&key(KeyCode::End)); p.update(&key(KeyCode::End)).unwrap();
p.update(&ch('j')); p.update(&ch('j')).unwrap();
p.update(&ch('c')); p.update(&ch('c')).unwrap();
assert_eq!(0, p.current); assert_eq!(0, p.current);
} }
@@ -2157,17 +2184,19 @@ mod tests {
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
}) })
}; };
p.update(&mouse(MouseEventKind::Up(MouseButton::Left), 2)); p.update(&mouse(MouseEventKind::Up(MouseButton::Left), 2))
.unwrap();
render(&mut p, 120, 30); render(&mut p, 120, 30);
assert_eq!(2, p.current); assert_eq!(2, p.current);
p.update(&mouse(MouseEventKind::ScrollUp, 0)); p.update(&mouse(MouseEventKind::ScrollUp, 0)).unwrap();
render(&mut p, 120, 30); render(&mut p, 120, 30);
assert_eq!(1, p.current); assert_eq!(1, p.current);
p.update(&mouse(MouseEventKind::ScrollDown, 0)); p.update(&mouse(MouseEventKind::ScrollDown, 0)).unwrap();
render(&mut p, 120, 30); render(&mut p, 120, 30);
assert_eq!(2, p.current); assert_eq!(2, p.current);
// Click below the last theme does nothing. // Click below the last theme does nothing.
p.update(&mouse(MouseEventKind::Up(MouseButton::Left), 20)); p.update(&mouse(MouseEventKind::Up(MouseButton::Left), 20))
.unwrap();
render(&mut p, 120, 30); render(&mut p, 120, 30);
assert_eq!(2, p.current); assert_eq!(2, p.current);
} }
@@ -2178,9 +2207,9 @@ mod tests {
let names: Vec<String> = (0..40).map(|i| format!("theme{i:02}")).collect(); let names: Vec<String> = (0..40).map(|i| format!("theme{i:02}")).collect();
let refs: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), "#000000")).collect(); let refs: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), "#000000")).collect();
let themes = make_themes(tmp.path(), &refs); let themes = make_themes(tmp.path(), &refs);
let mut p = Preview::new(themes, ColorScheme::All); let mut p = Preview::new(themes, ColorScheme::All).unwrap();
render(&mut p, 100, 10); render(&mut p, 100, 10);
p.update(&key(KeyCode::PageDown)); p.update(&key(KeyCode::PageDown)).unwrap();
let buf = render(&mut p, 100, 10); let buf = render(&mut p, 100, 10);
assert_eq!(20, p.current); assert_eq!(20, p.current);
assert_eq!(11, p.window); assert_eq!(11, p.window);
@@ -2215,6 +2244,163 @@ mod tests {
assert_eq!("", t.text); assert_eq!("", t.text);
} }
fn typed(t: &mut TextInput, text: &str) {
for c in text.chars() {
t.update(&KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
}
fn press(t: &mut TextInput, code: KeyCode, m: KeyModifiers) -> bool {
t.update(&KeyEvent::new(code, m))
}
#[test]
fn text_input_word_navigation_matches_vaxis() {
let alt = KeyModifiers::ALT;
let mut t = TextInput::default();
typed(&mut t, "blue red");
press(&mut t, KeyCode::Char('b'), alt);
typed(&mut t, "X");
assert_eq!("blue Xred", t.text);
// Alt+B skips trailing spaces; Alt+Left is the same.
let mut t = TextInput::default();
typed(&mut t, "one two ");
press(&mut t, KeyCode::Left, alt);
assert_eq!(4, t.cursor);
press(&mut t, KeyCode::Char('b'), alt);
assert_eq!(0, t.cursor);
// Alt+F: skips leading spaces then stops before the next space.
press(&mut t, KeyCode::Char('f'), alt);
assert_eq!(3, t.cursor);
press(&mut t, KeyCode::Right, alt);
assert_eq!(7, t.cursor);
press(&mut t, KeyCode::Char('f'), alt);
assert_eq!(10, t.cursor);
// Alt+D deletes forward to the end of the next word.
let mut t = TextInput::default();
typed(&mut t, "one two three");
press(&mut t, KeyCode::Home, KeyModifiers::NONE);
press(&mut t, KeyCode::Char('d'), alt);
assert_eq!(" two three", t.text);
press(&mut t, KeyCode::Char('d'), alt);
assert_eq!(" three", t.text);
// Ctrl+W and Alt+Backspace delete the previous word.
let mut t = TextInput::default();
typed(&mut t, "one two three");
press(&mut t, KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!("one two ", t.text);
press(&mut t, KeyCode::Backspace, alt);
assert_eq!("one ", t.text);
assert_eq!(4, t.cursor);
// Ctrl+D / Ctrl+B / Ctrl+F / Ctrl+A / Ctrl+E / Ctrl+U / Ctrl+K.
let ctrl = KeyModifiers::CONTROL;
let mut t = TextInput::default();
typed(&mut t, "abcdef");
press(&mut t, KeyCode::Char('b'), ctrl);
press(&mut t, KeyCode::Char('b'), ctrl);
press(&mut t, KeyCode::Char('d'), ctrl);
assert_eq!("abcdf", t.text);
press(&mut t, KeyCode::Char('f'), ctrl);
assert_eq!(5, t.cursor);
press(&mut t, KeyCode::Char('a'), ctrl);
press(&mut t, KeyCode::Char('f'), ctrl);
press(&mut t, KeyCode::Char('k'), ctrl);
assert_eq!("a", t.text);
typed(&mut t, "bc");
press(&mut t, KeyCode::Char('b'), ctrl);
press(&mut t, KeyCode::Char('u'), ctrl);
assert_eq!("c", t.text);
assert_eq!(0, t.cursor);
press(&mut t, KeyCode::Char('e'), ctrl);
assert_eq!(1, t.cursor);
// Modifiers must match exactly: Ctrl+Left is not a plain Left and
// Alt+x is not text.
let mut t = TextInput::default();
typed(&mut t, "ab");
assert!(!press(&mut t, KeyCode::Left, ctrl));
assert!(!press(&mut t, KeyCode::Char('x'), alt));
assert_eq!("ab", t.text);
assert_eq!(2, t.cursor);
}
#[test]
fn text_input_is_grapheme_aware() {
let none = KeyModifiers::NONE;
// Backspace removes the whole cluster (e + combining acute).
let mut t = TextInput::default();
typed(&mut t, "xe\u{301}");
press(&mut t, KeyCode::Backspace, none);
assert_eq!("x", t.text);
// Left/Right/Delete move over a whole ZWJ emoji sequence.
let astronaut = "\u{1f469}\u{200d}\u{1f680}";
let mut t = TextInput::default();
typed(&mut t, &format!("a{astronaut}b"));
press(&mut t, KeyCode::Left, none);
press(&mut t, KeyCode::Left, none);
assert_eq!(1, t.cursor);
press(&mut t, KeyCode::Delete, none);
assert_eq!("ab", t.text);
let mut t = TextInput::default();
typed(&mut t, &format!("{astronaut}{astronaut}"));
press(&mut t, KeyCode::Home, none);
press(&mut t, KeyCode::Right, none);
assert_eq!(astronaut.len(), t.cursor);
}
#[test]
fn rank_folds_ascii_only() {
// A non-ASCII capital is not folded on either side.
assert!(rank("\u{c4}", &["\u{c4}"]).is_some());
assert!(rank("\u{c4}", &["\u{e4}"]).is_none());
assert!(rank("\u{e4}", &["\u{c4}"]).is_none());
// ASCII is folded in the name (the query is lowered by the caller).
assert!(rank("Catppuccin", &["cat"]).is_some());
}
#[test]
fn search_with_non_ascii_query_matches_like_zig() {
let tmp = tempfile::tempdir().unwrap();
let themes = make_themes(
tmp.path(),
&[("\u{c4}rger", "#000000"), ("plain", "#000000")],
);
let mut p = Preview::new(themes, ColorScheme::All).unwrap();
p.update(&ch('/')).unwrap();
p.update(&ch('\u{c4}')).unwrap();
assert_eq!(1, p.filtered.len());
p.update(&key(KeyCode::Backspace)).unwrap();
p.update(&ch('\u{e4}')).unwrap();
assert_eq!(0, p.filtered.len());
}
#[test]
fn initial_scheme_is_light_regardless_of_colorfgbg() {
let tmp = tempfile::tempdir().unwrap();
let themes = make_themes(tmp.path(), &[("a", "#000000")]);
let p = temp_env::with_var("COLORFGBG", Some("15;0"), || {
Preview::new(themes, ColorScheme::All).unwrap()
});
assert_eq!(Scheme::Light, p.color_scheme);
}
#[test]
fn load_file_error_propagates() {
let tmp = tempfile::tempdir().unwrap();
let mut themes = make_themes(tmp.path(), &[("a", "#000000")]);
// A theme file with an unreadable path: a path below a regular file.
let bad = tmp.path().join("a").join("nope");
themes[0].path = ghostty_os::osbytes::EncodedOs::from_path(&bad);
let result = Preview::new(themes, ColorScheme::All);
assert!(result.is_err());
}
#[test] #[test]
fn base64_encoding() { fn base64_encoding() {
assert_eq!("", base64(b"")); assert_eq!("", base64(b""));
+8 -3
View File
@@ -62,7 +62,7 @@ impl Options {
.map_err(|e| ArgError::Other(e.to_string()))?; .map_err(|e| ArgError::Other(e.to_string()))?;
let realpath = expanded let realpath = expanded
.to_path() .to_path()
.and_then(std::fs::canonicalize) .and_then(ghostty_os::path::realpath)
.map_err(|e| ArgError::Other(e.to_string()))?; .map_err(|e| ArgError::Other(e.to_string()))?;
self.working_directory_seen = true; self.working_directory_seen = true;
return Ok(Some( return Ok(Some(
@@ -210,7 +210,7 @@ fn run_args(args: &[impl AsRef<[u8]>], stderr: &mut dyn std::io::Write) -> Resul
if !opts.working_directory_seen { if !opts.working_directory_seen {
// This should be inserted at the beginning of the list, just in case `-e` was used. // This should be inserted at the beginning of the list, just in case `-e` was used.
let wd = std::fs::canonicalize(".")?; let wd = ghostty_os::path::realpath(".")?;
opts.arguments.insert( opts.arguments.insert(
0, 0,
[ [
@@ -309,8 +309,13 @@ mod tests {
fn working_directory_is_resolved() { fn working_directory_is_resolved() {
let o = parse_str("--working-directory=/"); let o = parse_str("--working-directory=/");
assert!(o.working_directory_seen); assert!(o.working_directory_seen);
// The root resolves to `Z:\` under Wine / `C:\` on Windows.
let root = format!(
"--working-directory={}",
ghostty_os::path::realpath("/").unwrap().display()
);
assert_eq!( assert_eq!(
vec!["--working-directory=/"], vec![root.as_str()],
o.arguments o.arguments
.iter() .iter()
.map(|v| std::str::from_utf8(v).unwrap()) .map(|v| std::str::from_utf8(v).unwrap())
+4
View File
@@ -216,6 +216,10 @@ mod tests {
} }
#[test] #[test]
#[cfg_attr(
wine,
ignore = "wine: ntdll NtSetInformationFile(FileRenameInformationEx, REPLACE_IF_EXISTS|POSIX_SEMANTICS) is unsupported (\"unsupported flags: 0x3\"), so std::fs::rename over an existing file fails with ERROR_ACCESS_DENIED; real Windows supports it"
)]
fn add_list_host_remove_clear() { fn add_list_host_remove_clear() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let cache = DiskCache { let cache = DiskCache {
@@ -416,6 +416,10 @@ mod tests {
} }
#[test] #[test]
#[cfg_attr(
wine,
ignore = "wine: ntdll NtSetInformationFile(FileRenameInformationEx, REPLACE_IF_EXISTS|POSIX_SEMANTICS) is unsupported (\"unsupported flags: 0x3\"), so std::fs::rename over an existing file fails with ERROR_ACCESS_DENIED; real Windows supports it"
)]
fn disk_cache_operations() { fn disk_cache_operations() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("cache"); let path = tmp.path().join("cache");
@@ -438,6 +442,10 @@ mod tests {
} }
#[test] #[test]
#[cfg_attr(
wine,
ignore = "wine: ntdll NtSetInformationFile(FileRenameInformationEx, REPLACE_IF_EXISTS|POSIX_SEMANTICS) is unsupported (\"unsupported flags: 0x3\"), so std::fs::rename over an existing file fails with ERROR_ACCESS_DENIED; real Windows supports it"
)]
fn disk_cache_cleans_up_temp_files() { fn disk_cache_cleans_up_temp_files() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let cache = DiskCache { let cache = DiskCache {
@@ -467,6 +475,10 @@ mod tests {
} }
#[test] #[test]
#[cfg_attr(
wine,
ignore = "wine: ntdll NtSetInformationFile(FileRenameInformationEx, REPLACE_IF_EXISTS|POSIX_SEMANTICS) is unsupported (\"unsupported flags: 0x3\"), so std::fs::rename over an existing file fails with ERROR_ACCESS_DENIED; real Windows supports it"
)]
fn disk_cache_invalid_host() { fn disk_cache_invalid_host() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let cache = DiskCache { let cache = DiskCache {
+1 -1
View File
@@ -42,7 +42,7 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result<u8, Error> {
if let Some(config_path) = &opts.config_file { if let Some(config_path) = &opts.config_file {
// Byte-preserving: the option string carries non-UTF-8 bytes via the // Byte-preserving: the option string carries non-UTF-8 bytes via the
// reversible `osbytes` escape and the canonical path may too. // reversible `osbytes` escape and the canonical path may too.
let abs_path = std::fs::canonicalize(config_path.to_path()?)?; let abs_path = ghostty_os::path::realpath(config_path.to_path()?)?;
cfg.load_file(&EncodedOs::from_path(&abs_path))?; cfg.load_file(&EncodedOs::from_path(&abs_path))?;
cfg.load_recursive_files()?; cfg.load_recursive_files()?;
} else { } else {
+22 -4
View File
@@ -4216,7 +4216,7 @@ impl Config {
// Any paths referenced from the CLI are relative to the current working // Any paths referenced from the CLI are relative to the current working
// directory. // directory.
let cwd = std::fs::canonicalize(".")?; let cwd = crate::path::realpath(".")?;
self.expand_paths(&EncodedOs::from_path(&cwd))?; self.expand_paths(&EncodedOs::from_path(&cwd))?;
Ok(()) Ok(())
} }
@@ -5115,12 +5115,22 @@ mod tests {
fn write_theme(td: &TempDir, name: &str, data: &str) -> String { fn write_theme(td: &TempDir, name: &str, data: &str) -> String {
let p = td.path().join(name); let p = td.path().join(name);
std::fs::write(&p, data).unwrap(); std::fs::write(&p, data).unwrap();
std::fs::canonicalize(&p) crate::path::realpath(&p)
.unwrap() .unwrap()
.to_string_lossy() .to_string_lossy()
.into_owned() .into_owned()
} }
/// A path as a `light:`/`dark:` pair value. Backslashes are escapes in
/// the pair syntax (as in Zig), so Windows paths must be quoted.
fn pair_value(path: &str) -> String {
if cfg!(windows) {
format!("\"{}\"", path.replace('\\', "\\\\"))
} else {
path.to_owned()
}
}
#[test] #[test]
fn handle_bom_in_config_files() { fn handle_bom_in_config_files() {
{ {
@@ -5223,7 +5233,11 @@ mod tests {
let dark = write_theme(&td, "theme_dark", include_str!("testdata/theme_dark")); let dark = write_theme(&td, "theme_dark", include_str!("testdata/theme_dark"));
let mut cfg_light = Config::default(); let mut cfg_light = Config::default();
let mut i = it(&[&format!("--theme=light:{light},dark:{dark}")]); let mut i = it(&[&format!(
"--theme=light:{},dark:{}",
pair_value(&light),
pair_value(&dark)
)]);
cfg_light.load_iter(&mut i).unwrap(); cfg_light.load_iter(&mut i).unwrap();
cfg_light.finalize().unwrap(); cfg_light.finalize().unwrap();
@@ -5407,7 +5421,11 @@ mod tests {
let td = TempDir::init().unwrap(); let td = TempDir::init().unwrap();
let light = write_theme(&td, "theme_light", include_str!("testdata/theme_light")); 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 dark = write_theme(&td, "theme_dark", include_str!("testdata/theme_dark"));
let arg = format!("--theme=light:{light},dark:{dark}"); let arg = format!(
"--theme=light:{},dark:{}",
pair_value(&light),
pair_value(&dark)
);
// Light // Light
{ {
+9 -1
View File
@@ -211,7 +211,15 @@ mod tests {
open(&e("/nonexistent/ghostty/file")).unwrap_err(), open(&e("/nonexistent/ghostty/file")).unwrap_err(),
OpenFileError::FileNotFound OpenFileError::FileNotFound
); );
assert_eq!(open(&e("/tmp")).unwrap_err(), OpenFileError::NotAFile); // Windows opens a directory with `ERROR_ACCESS_DENIED` (Zig 0.15's
// `OpenFile` maps FILE_IS_A_DIRECTORY to AccessDenied as well), which
// is an "unexpected" open error there.
let dir_err = if cfg!(windows) {
OpenFileError::FileOpenFailed
} else {
OpenFileError::NotAFile
};
assert_eq!(open(&e("/tmp")).unwrap_err(), dir_err);
let p = std::env::temp_dir().join(format!("ghostty-file-load-{}", std::process::id())); let p = std::env::temp_dir().join(format!("ghostty-file-load-{}", std::process::id()));
std::fs::write(&p, "").unwrap(); std::fs::write(&p, "").unwrap();
assert_eq!( assert_eq!(
+23 -4
View File
@@ -158,7 +158,7 @@ impl Path {
std::fs::read_dir(&base_path)?; std::fs::read_dir(&base_path)?;
let full = base_path.join(path.to_syscall_path()?); let full = base_path.join(path.to_syscall_path()?);
let abs = match std::fs::canonicalize(&full) { let abs = match realpath(&full) {
Ok(p) => EncodedOs::from_path(&p), Ok(p) => EncodedOs::from_path(&p),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
// The file doesn't exist. Try to resolve the relative path // The file doesn't exist. Try to resolve the relative path
@@ -276,6 +276,18 @@ impl ConfigField for RepeatablePath {
} }
} }
/// `posix.realpath`: the canonical path without the `\\?\` verbatim prefix
/// `std::fs::canonicalize` adds on Windows (Zig's `realpath` has none).
#[cfg(windows)]
pub(crate) fn realpath(path: impl AsRef<std::path::Path>) -> std::io::Result<std::path::PathBuf> {
ghostty_os::path::realpath(path)
}
#[cfg(not(windows))]
pub(crate) fn realpath(path: impl AsRef<std::path::Path>) -> std::io::Result<std::path::PathBuf> {
std::fs::canonicalize(path)
}
#[cfg(test)] #[cfg(test)]
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
mod tests { mod tests {
@@ -391,7 +403,14 @@ mod tests {
let mut diags = DiagnosticList::default(); let mut diags = DiagnosticList::default();
let mut p = Path::Required(e("../nope/x.conf")); let mut p = Path::Required(e("../nope/x.conf"));
p.expand(&e("/tmp"), &mut diags).unwrap(); p.expand(&e("/tmp"), &mut diags).unwrap();
assert_eq!(p, Path::Required(e("/nope/x.conf"))); // Resolved lexically with the platform's separator (`\nope\x.conf`
// on Windows, like Zig's `std.fs.path.resolve`).
let expected = if cfg!(windows) {
"\\nope\\x.conf"
} else {
"/nope/x.conf"
};
assert_eq!(p, Path::Required(e(expected)));
let mut p = Path::Optional(e("/abs/path")); let mut p = Path::Optional(e("/abs/path"));
p.expand(&e("/tmp"), &mut diags).unwrap(); p.expand(&e("/tmp"), &mut diags).unwrap();
@@ -403,7 +422,7 @@ mod tests {
#[test] #[test]
fn expand_interior_nul_resolves_the_c_string_prefix_like_zig() { fn expand_interior_nul_resolves_the_c_string_prefix_like_zig() {
let tmp = ghostty_os::TempDir::init().unwrap(); let tmp = ghostty_os::TempDir::init().unwrap();
let base = std::fs::canonicalize(tmp.path()).unwrap(); let base = realpath(tmp.path()).unwrap();
std::fs::write(base.join("exists"), b"x").unwrap(); std::fs::write(base.join("exists"), b"x").unwrap();
// The syscall sees only the prefix, which exists: the real path of // The syscall sees only the prefix, which exists: the real path of
@@ -429,7 +448,7 @@ mod tests {
fn expand_symlink_to_non_utf8_target_round_trips() { fn expand_symlink_to_non_utf8_target_round_trips() {
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
let tmp = ghostty_os::TempDir::init().unwrap(); let tmp = ghostty_os::TempDir::init().unwrap();
let base = std::fs::canonicalize(tmp.path()).unwrap(); let base = realpath(tmp.path()).unwrap();
let target = base.join(std::ffi::OsStr::from_bytes(b"t\xffarget")); let target = base.join(std::ffi::OsStr::from_bytes(b"t\xffarget"));
std::fs::write(&target, b"x").unwrap(); std::fs::write(&target, b"x").unwrap();
std::os::unix::fs::symlink(&target, base.join("link")).unwrap(); std::os::unix::fs::symlink(&target, base.join("link")).unwrap();
+22 -4
View File
@@ -381,9 +381,14 @@ mod tests {
); );
let mut d = DiagnosticList::default(); let mut d = DiagnosticList::default();
assert!(open(&e("/tmp"), None, &mut d).is_none()); assert!(open(&e("/tmp"), None, &mut d).is_none());
// Windows cannot open a directory as a file (AccessDenied).
assert_eq!( assert_eq!(
d.items()[0].message, 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" "not reading theme from \"/tmp\": it is a directory"
}
); );
} }
@@ -469,7 +474,11 @@ mod tests {
let mut d = DiagnosticList::default(); let mut d = DiagnosticList::default();
assert!(open(&e(name), Some(&EncodedOs::from_path(&dir)), &mut d).is_none()); assert!(open(&e(name), Some(&EncodedOs::from_path(&dir)), &mut d).is_none());
assert!( assert!(
d.items()[0].message.ends_with("it is a directory"), d.items()[0].message.ends_with(if cfg!(windows) {
"error.AccessDenied"
} else {
"it is a directory"
}),
"{}", "{}",
d.items()[0].message d.items()[0].message
); );
@@ -484,7 +493,10 @@ mod tests {
std::fs::write(dir.join("themes/mytheme-zzz"), "background = #000000\n").unwrap(); std::fs::write(dir.join("themes/mytheme-zzz"), "background = #000000\n").unwrap();
let mut d = DiagnosticList::default(); let mut d = DiagnosticList::default();
let t = open(&e("mytheme-zzz"), Some(&EncodedOs::from_path(&dir)), &mut d).unwrap(); let t = open(&e("mytheme-zzz"), Some(&EncodedOs::from_path(&dir)), &mut d).unwrap();
assert!(t.path.ends_with_plain("themes/mytheme-zzz")); assert!(
t.path
.ends_with_plain(&format!("themes{}mytheme-zzz", std::path::MAIN_SEPARATOR))
);
assert!(d.is_empty()); assert!(d.is_empty());
std::fs::remove_dir_all(&dir).unwrap(); std::fs::remove_dir_all(&dir).unwrap();
} }
@@ -493,7 +505,13 @@ mod tests {
fn theme_not_found_lists_tried_paths() { fn theme_not_found_lists_tried_paths() {
let mut d = DiagnosticList::default(); let mut d = DiagnosticList::default();
assert!(open(&e("nope-zzz"), Some(&e("/nonexistent-res")), &mut d).is_none()); assert!(open(&e("nope-zzz"), Some(&e("/nonexistent-res")), &mut d).is_none());
assert!(d.items().iter().any(|x| x.message let tried = std::path::Path::new("/nonexistent-res")
== "theme \"nope-zzz\" not found, tried path \"/nonexistent-res/themes/nope-zzz\"")); .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));
} }
} }
+1 -1
View File
@@ -445,7 +445,7 @@ pub fn write_screen_temp_file(
let mut file = opts.open(&file_path)?; let mut file = opts.open(&file_path)?;
file.write_all(contents.as_bytes())?; file.write_all(contents.as_bytes())?;
file.flush()?; file.flush()?;
Ok(std::fs::canonicalize(&file_path)?) Ok(ghostty_os::path::realpath(&file_path)?)
})(); })();
match result { match result {
+4
View File
@@ -47,6 +47,10 @@ objc2-core-graphics = "0.3.2"
objc2-core-text = "0.3.2" objc2-core-text = "0.3.2"
objc2-foundation = "0.3.2" objc2-foundation = "0.3.2"
# Compiles `hb-ft.cc` for the bundled HarfBuzz on Windows (see build.rs).
[build-dependencies]
cc = "1"
[features] [features]
default = [] default = []
# CoreText for discovery, FreeType for rendering, HarfBuzz for shaping # CoreText for discovery, FreeType for rendering, HarfBuzz for shaping
+48
View File
@@ -24,6 +24,7 @@
fn main() { fn main() {
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
build_harfbuzz_freetype_glue();
for c in [ for c in [
"font_freetype", "font_freetype",
"font_fontconfig", "font_fontconfig",
@@ -77,3 +78,50 @@ fn main() {
on("font_coretext_shaper", coretext && !harfbuzz && !ct_noshape); on("font_coretext_shaper", coretext && !harfbuzz && !ct_noshape);
on("font_noshape", coretext && !harfbuzz && ct_noshape); on("font_noshape", coretext && !harfbuzz && ct_noshape);
} }
/// The bundled HarfBuzz (`harfbuzz-sys` `bundled`, used on Windows where there
/// is no system copy) is compiled without `HAVE_FREETYPE`, so `hb_ft_*` is
/// missing. Zig builds HarfBuzz with FreeType enabled (`pkg/harfbuzz`
/// `-DHAVE_FREETYPE=1`), so compile `hb-ft.cc` against the bundled FreeType
/// headers here.
fn build_harfbuzz_freetype_glue() {
use std::path::PathBuf;
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let Some(hb_src) = std::env::var_os("DEP_HARFBUZZ_INCLUDE").map(PathBuf::from) else {
return;
};
// `<registry>/harfbuzz-sys-X/harfbuzz/src` -> `<registry>/freetype-sys-*/freetype2/include`.
let Some(registry) = hb_src.ancestors().nth(3) else {
return;
};
let ft_include = std::fs::read_dir(registry)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("freetype-sys-"))
})
.map(|p| p.join("freetype2").join("include"))
.find(|p| p.join("ft2build.h").exists());
let Some(ft_include) = ft_include else {
println!("cargo:warning=bundled FreeType headers not found; hb_ft_* will be missing");
return;
};
let mut cfg = cc::Build::new();
cfg.cpp(true)
.flag_if_supported("-std=c++11")
.warnings(false)
.define("HAVE_FREETYPE", "1")
.include(&hb_src)
.include(&ft_include)
.file(hb_src.join("hb-ft.cc"));
if std::env::var("TARGET").is_ok_and(|t| t.contains("windows-gnu")) {
cfg.flag("-Wa,-mbig-obj");
}
cfg.compile("ghostty_hb_ft");
}
+9
View File
@@ -245,6 +245,15 @@ mod tests {
} }
#[test] #[test]
#[cfg(windows)]
fn init_is_a_noop_on_windows() {
// i18n is unsupported on Windows (Zig returns before looking at it).
assert!(init(std::path::Path::new("ghostty")).is_ok());
assert!(init(std::path::Path::new("/")).is_ok());
}
#[test]
#[cfg(not(windows))]
fn init_rejects_dirless_resources_dir() { fn init_rejects_dirless_resources_dir() {
assert!(matches!( assert!(matches!(
init(std::path::Path::new("ghostty")), init(std::path::Path::new("ghostty")),
+51
View File
@@ -25,6 +25,39 @@ pub enum Error {
Io(#[from] io::Error), Io(#[from] io::Error),
} }
/// `std.fs.Dir.realpath` / `posix.realpath`: the canonical absolute path.
///
/// `std::fs::canonicalize` returns an extended-length `\\?\C:\...` path on
/// Windows, while Zig's `realpath` (`GetFinalPathNameByHandle` through the NT
/// object name) yields the plain DOS path `C:\...`. The verbatim form rejects
/// `/` separators and is not accepted as a working directory by `cmd.exe`, so
/// it must not leak into config values or child processes.
pub fn realpath(path: impl AsRef<std::path::Path>) -> io::Result<PathBuf> {
let real = std::fs::canonicalize(path)?;
#[cfg(windows)]
let real = strip_verbatim_prefix(real);
Ok(real)
}
/// Turn `\\?\C:\x` into `C:\x` and `\\?\UNC\srv\share\x` into
/// `\\srv\share\x`; other paths are returned unchanged.
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn strip_verbatim_prefix(path: PathBuf) -> PathBuf {
let Some(s) = path.to_str() else {
return path;
};
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
if let Some(rest) = s.strip_prefix(r"\\?\") {
let b = rest.as_bytes();
if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
return PathBuf::from(rest);
}
}
path
}
/// Search for "cmd" in the PATH and return the absolute path. Non-UTF-8 /// Search for "cmd" in the PATH and return the absolute path. Non-UTF-8
/// bytes are carried via [`crate::osbytes`]; see [`expand_os`]. /// bytes are carried via [`crate::osbytes`]; see [`expand_os`].
pub fn expand(cmd: &crate::osbytes::EncodedOs) -> Result<Option<crate::osbytes::EncodedOs>, Error> { pub fn expand(cmd: &crate::osbytes::EncodedOs) -> Result<Option<crate::osbytes::EncodedOs>, Error> {
@@ -104,6 +137,24 @@ fn is_executable(_meta: &std::fs::Metadata) -> bool {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn strip_verbatim_prefix_cases() {
let t = |a: &str| strip_verbatim_prefix(PathBuf::from(a));
assert_eq!(t(r"\\?\C:\a\b"), PathBuf::from(r"C:\a\b"));
assert_eq!(t(r"\\?\UNC\srv\share\x"), PathBuf::from(r"\\srv\share\x"));
assert_eq!(t(r"\\?\GLOBALROOT\x"), PathBuf::from(r"\\?\GLOBALROOT\x"));
assert_eq!(t(r"C:\a"), PathBuf::from(r"C:\a"));
assert_eq!(t("/usr/bin"), PathBuf::from("/usr/bin"));
}
#[test]
fn realpath_has_no_verbatim_prefix() {
let tmp = crate::TempDir::init().unwrap();
let p = realpath(tmp.path()).unwrap();
assert!(!p.to_string_lossy().starts_with(r"\\?\"), "{p:?}");
assert!(p.is_dir());
}
/// Plain-text wrapper over [`expand`] for the literals used below. /// Plain-text wrapper over [`expand`] for the literals used below.
fn expand(cmd: &str) -> Result<Option<String>, Error> { fn expand(cmd: &str) -> Result<Option<String>, Error> {
super::expand(&crate::osbytes::EncodedOs::from_plain_text(cmd)) super::expand(&crate::osbytes::EncodedOs::from_plain_text(cmd))
+7 -1
View File
@@ -180,7 +180,13 @@ mod tests {
let _g = LOCK.lock().unwrap(); let _g = LOCK.lock().unwrap();
detect().unwrap(); detect().unwrap();
let before = backend(); let before = backend();
assert!(!prefer(Backend::Iocp)); // Iocp is the Windows candidate; Epoll is never one there.
let non_candidate = if cfg!(windows) {
Backend::Epoll
} else {
Backend::Iocp
};
assert!(!prefer(non_candidate));
assert_eq!(backend(), before); assert_eq!(backend(), before);
} }
@@ -45,6 +45,17 @@ fn os_path_from_bytes(bytes: &[u8]) -> Option<std::ffi::OsString> {
std::str::from_utf8(bytes).ok().map(Into::into) std::str::from_utf8(bytes).ok().map(Into::into)
} }
/// `posix.realpath` (no `\\?\` verbatim prefix on Windows, like Zig).
#[cfg(windows)]
fn realpath(path: impl AsRef<Path>) -> std::io::Result<std::path::PathBuf> {
ghostty_os::path::realpath(path)
}
#[cfg(not(windows))]
fn realpath(path: impl AsRef<Path>) -> std::io::Result<std::path::PathBuf> {
std::fs::canonicalize(path)
}
use ghostty_vt::kitty::graphics_command::{ use ghostty_vt::kitty::graphics_command::{
Command, Command,
Display, Display,
@@ -157,7 +168,7 @@ impl LoadingImage {
log::warn!("failed to get absolute path: BadPathName"); log::warn!("failed to get absolute path: BadPathName");
return Err(Error::InvalidData); return Err(Error::InvalidData);
}; };
let path = match std::fs::canonicalize(Path::new(&os_path)) { let path = match realpath(Path::new(&os_path)) {
Ok(p) => p, Ok(p) => p,
Err(err) => { Err(err) => {
log::warn!("failed to get absolute path: {err}"); log::warn!("failed to get absolute path: {err}");
@@ -600,7 +611,7 @@ fn is_path_in_temp_dir_with(path: &[u8], tmp_dir: Option<std::ffi::OsString>) ->
// The temporary dir is sometimes a symlink. On macOS for // The temporary dir is sometimes a symlink. On macOS for
// example /tmp is /private/var/... // example /tmp is /private/var/...
if let Ok(real_dir) = std::fs::canonicalize(&dir) if let Ok(real_dir) = realpath(&dir)
&& path.starts_with(real_dir.as_os_str().as_bytes()) && path.starts_with(real_dir.as_os_str().as_bytes())
{ {
return true; return true;
@@ -779,11 +790,7 @@ mod tests {
fn write(&self, name: &str, data: &[u8]) -> String { fn write(&self, name: &str, data: &[u8]) -> String {
let p = self.0.join(name); let p = self.0.join(name);
std::fs::write(&p, data).unwrap(); std::fs::write(&p, data).unwrap();
std::fs::canonicalize(p) realpath(p).unwrap().to_str().unwrap().to_owned()
.unwrap()
.to_str()
.unwrap()
.to_owned()
} }
} }
impl Drop for TmpDir { impl Drop for TmpDir {
@@ -1032,6 +1039,11 @@ mod tests {
#[test] #[test]
fn image_load_rejects_proc_and_nul_paths() { fn image_load_rejects_proc_and_nul_paths() {
// The `/proc/` guard is a prefix check on the canonical path, which
// on Windows is a drive path (Wine even maps `/proc` through `Z:`),
// so it only applies to POSIX.
#[cfg(unix)]
{
let c = cmd( let c = cmd(
Transmission { Transmission {
format: TransmissionFormat::Rgb, format: TransmissionFormat::Rgb,
@@ -1043,6 +1055,7 @@ mod tests {
b"/proc/self/status", b"/proc/self/status",
); );
assert_eq!(LoadingImage::init(&c).unwrap_err(), Error::InvalidData); assert_eq!(LoadingImage::init(&c).unwrap_err(), Error::InvalidData);
}
let c = cmd( let c = cmd(
Transmission { Transmission {
medium: TransmissionMedium::File, medium: TransmissionMedium::File,
+1
View File
@@ -44,6 +44,7 @@ windows-sys = { version = "0.61", features = [
# and only allowed on minimal, SAFETY-commented items. # and only allowed on minimal, SAFETY-commented items.
[lints.rust] [lints.rust]
unsafe_code = "deny" unsafe_code = "deny"
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(wine)"] }
[lints.clippy] [lints.clippy]
pedantic = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 }
+17 -7
View File
@@ -452,7 +452,9 @@ mod tests {
fn new(shell: Shell) -> Self { fn new(shell: Shell) -> Self {
let tmp = TempDir::init().unwrap(); let tmp = TempDir::init().unwrap();
let path = std::fs::canonicalize(tmp.path()) // (`canonicalize` would give a `\\?\` verbatim path on Windows,
// which rejects the `/` separators used below.)
let path = ghostty_os::path::realpath(tmp.path())
.unwrap() .unwrap()
.to_string_lossy() .to_string_lossy()
.into_owned(); .into_owned();
@@ -814,7 +816,7 @@ mod tests {
#[test] #[test]
fn bash_missing_resources() { fn bash_missing_resources() {
let tmp = TempDir::init().unwrap(); let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap(); let dir = ghostty_os::path::realpath(tmp.path()).unwrap();
let mut env = EnvMap::new(); let mut env = EnvMap::new();
assert!( assert!(
setup_bash(&sh("bash"), &EncodedOs::from_path(&dir), &mut env) setup_bash(&sh("bash"), &EncodedOs::from_path(&dir), &mut env)
@@ -838,7 +840,11 @@ mod tests {
.unwrap() .unwrap()
); );
assert_eq!( assert_eq!(
format!("{}/shell-integration:/usr/local/share:/usr/share", res.path), format!(
"{}/shell-integration{}/usr/local/share:/usr/share",
res.path,
if cfg!(windows) { ';' } else { ':' }
),
env.get("XDG_DATA_DIRS").unwrap().to_str().unwrap() env.get("XDG_DATA_DIRS").unwrap().to_str().unwrap()
); );
} }
@@ -858,7 +864,11 @@ mod tests {
.unwrap() .unwrap()
); );
assert_eq!( assert_eq!(
format!("{}/shell-integration:/opt/share", res.path), format!(
"{}/shell-integration{}/opt/share",
res.path,
if cfg!(windows) { ';' } else { ':' }
),
env.get("XDG_DATA_DIRS").unwrap().to_str().unwrap() env.get("XDG_DATA_DIRS").unwrap().to_str().unwrap()
); );
} }
@@ -866,7 +876,7 @@ mod tests {
#[test] #[test]
fn xdg_missing_resources() { fn xdg_missing_resources() {
let tmp = TempDir::init().unwrap(); let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap(); let dir = ghostty_os::path::realpath(tmp.path()).unwrap();
let mut env = EnvMap::new(); let mut env = EnvMap::new();
assert!(!setup_xdg_data_dirs(&EncodedOs::from_path(&dir), &mut env)); assert!(!setup_xdg_data_dirs(&EncodedOs::from_path(&dir), &mut env));
assert_eq!(0, env.len()); assert_eq!(0, env.len());
@@ -914,7 +924,7 @@ mod tests {
#[test] #[test]
fn nushell_missing_resources() { fn nushell_missing_resources() {
let tmp = TempDir::init().unwrap(); let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap(); let dir = ghostty_os::path::realpath(tmp.path()).unwrap();
let mut env = EnvMap::new(); let mut env = EnvMap::new();
assert!(setup_nushell(&sh("nu"), &EncodedOs::from_path(&dir), &mut env).is_none()); assert!(setup_nushell(&sh("nu"), &EncodedOs::from_path(&dir), &mut env).is_none());
assert_eq!(0, env.len()); assert_eq!(0, env.len());
@@ -955,7 +965,7 @@ mod tests {
#[test] #[test]
fn zsh_missing_resources() { fn zsh_missing_resources() {
let tmp = TempDir::init().unwrap(); let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap(); let dir = ghostty_os::path::realpath(tmp.path()).unwrap();
let mut env = EnvMap::new(); let mut env = EnvMap::new();
assert!(setup_zsh(&sh("zsh"), &EncodedOs::from_path(&dir), &mut env).is_none()); assert!(setup_zsh(&sh("zsh"), &EncodedOs::from_path(&dir), &mut env).is_none());
assert_eq!(0, env.len()); assert_eq!(0, env.len());
+423
View File
@@ -0,0 +1,423 @@
#![cfg(windows)]
#![allow(clippy::unwrap_used, unsafe_code)]
//! End-to-end tests of the Windows backend: real `cmd.exe` children on a real
//! ConPTY, driven through `Termio` + `Thread` (IO thread: `WaitForMultipleObjects`
//! loop) + the pipe read thread, against a real `Terminal`. Counterpart of
//! `exec_integration` (Unix). Runs under Wine via `scripts/windows-wine-test.sh`.
use std::{
sync::{
Arc,
Mutex,
atomic::{AtomicUsize, Ordering},
},
thread::JoinHandle,
time::{Duration, Instant},
};
use crossbeam_channel::{Receiver, Sender, unbounded};
use ghostty_apprt::surface::Message as SurfaceMessage;
use ghostty_config::{
command::Command as ConfigCommand,
config::Config,
types_b::ShellIntegration,
};
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_renderer::{
message::Message as RendererMessage,
size::{CellSize, Padding, ScreenSize, Size},
};
use ghostty_termio::{
Mailbox,
Options,
RendererMailbox,
SharedState,
SharedStateExt,
SurfaceMailbox,
Termio,
Thread,
Wakeup,
backend::{self, Backend},
exec,
message::Message,
termio::DerivedConfig,
};
struct SurfaceRec(Sender<SurfaceMessage>);
impl SurfaceMailbox for SurfaceRec {
fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize {
let _ = self.0.send(msg);
1
}
}
#[derive(Default)]
struct RendererRec {
resizes: Mutex<Vec<Size>>,
wakeups: AtomicUsize,
}
impl RendererMailbox for RendererRec {
fn push(&self, msg: RendererMessage, _t: Timeout) -> usize {
match msg {
RendererMessage::Resize(s) => self.resizes.lock().unwrap().push(s),
RendererMessage::ResetCursorBlink => {
self.wakeups.fetch_add(1, Ordering::SeqCst);
}
_ => {}
}
1
}
}
fn size(cols: u32, rows: u32) -> Size {
Size {
screen: ScreenSize {
width: cols * 10,
height: rows * 20,
},
cell: CellSize {
width: 10,
height: 20,
},
padding: Padding::default(),
}
}
struct Fixture {
state: SharedState,
mailbox: Mailbox,
stop: Wakeup,
thread: Option<JoinHandle<()>>,
surface: Receiver<SurfaceMessage>,
renderer: Arc<RendererRec>,
}
fn start(command: ConfigCommand) -> Fixture {
start_with(command, |_| {})
}
fn start_with(command: ConfigCommand, tweak: impl FnOnce(&mut exec::Config)) -> Fixture {
let config = Config::default();
let mut ecfg = exec::Config::new(
ghostty_os::env::get_env_map(),
ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"),
);
ecfg.command = Some(command);
ecfg.shell_integration = ShellIntegration::None;
// A short cwd keeps the cmd.exe prompt from wrapping at 80 columns.
ecfg.working_directory = Some(ghostty_os::osbytes::EncodedOs::from_plain_text(r"C:\"));
tweak(&mut ecfg);
let mailbox = Mailbox::new().unwrap();
let (tx, surface) = unbounded();
let renderer = Arc::new(RendererRec::default());
let termio = Termio::init(Options {
size: size(80, 30),
full_config: &config,
config: DerivedConfig::new(&config),
backend: Backend::new(backend::Config::Exec(ecfg)).unwrap(),
mailbox: mailbox.clone(),
renderer_wakeup: Wakeup::new().unwrap(),
renderer_mailbox: renderer.clone(),
surface_mailbox: Arc::new(SurfaceRec(tx)),
})
.unwrap();
let state = Arc::clone(termio.renderer_state());
let thread = Thread::new().unwrap();
let stop = thread.stop_handle();
let handle = std::thread::Builder::new()
.name("io".into())
.spawn(move || thread.thread_main(termio))
.unwrap();
Fixture {
state,
mailbox,
stop,
thread: Some(handle),
surface,
renderer,
}
}
fn cmd(args: &[&str]) -> ConfigCommand {
let mut v = vec!["cmd.exe".into()];
v.extend(args.iter().map(|s| (*s).into()));
ConfigCommand::Direct(v)
}
impl Fixture {
fn text(&self) -> String {
self.state.lock_terminal().plain_string().unwrap()
}
fn send(&self, msg: Message) {
self.mailbox.send(msg);
self.mailbox.notify();
}
fn write(&self, data: &[u8]) {
self.send(Message::write_req(data));
}
fn wait_for<T>(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option<T>) -> T {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Some(v) = f(self) {
return v;
}
assert!(
Instant::now() < deadline,
"timed out waiting for {what}; terminal text = {:?}",
self.text()
);
std::thread::sleep(Duration::from_millis(20));
}
}
fn wait_text(&self, needle: &str) {
self.wait_for(&format!("text {needle:?}"), |f| {
f.text().contains(needle).then_some(())
});
}
/// Wait for the ChildExited surface message: (exit code, runtime ms).
fn wait_exit(&self) -> (u32, u64) {
let deadline = Instant::now() + Duration::from_secs(30);
let mut seen = Vec::new();
loop {
let left = deadline.saturating_duration_since(Instant::now());
let msg = self.surface.recv_timeout(left).unwrap_or_else(|_| {
panic!(
"timed out waiting for child exit; seen={seen:?}; text={:?}",
self.text()
)
});
if let SurfaceMessage::ChildExited(c) = msg {
return (c.exit_code, c.runtime_ms);
}
seen.push(msg);
}
}
fn stop(&mut self) {
self.stop.notify().unwrap();
if let Some(t) = self.thread.take() {
t.join().unwrap();
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = self.stop.notify();
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
#[test]
fn echo_output_reaches_terminal_and_exit_is_reported() {
let mut f = start(cmd(&["/c", "echo hi"]));
let (code, runtime_ms) = f.wait_exit();
assert_eq!(code, 0);
assert!(runtime_ms < 30_000);
// The reader thread may still be draining the pipe after the exit event.
f.wait_text("hi");
assert!(f.renderer.wakeups.load(Ordering::SeqCst) >= 1);
f.stop();
}
#[test]
fn nonzero_exit_code_is_reported() {
let mut f = start(cmd(&["/c", "exit 7"]));
let (code, _) = f.wait_exit();
assert_eq!(code, 7);
f.stop();
}
#[test]
fn exec_failure_is_reported_in_the_terminal() {
// Unlike the Unix fork+exec path (child exits with 1), `CreateProcessW`
// failing is a start error: the IO thread prints the error into the
// terminal and goes into drain mode (same as Zig's `threadEnter`).
let mut f = start(ConfigCommand::Direct(vec![
r"C:\definitely\not\here.exe".into(),
]));
f.wait_text("error starting IO thread");
f.wait_text("This terminal is non-functional");
// Messages are swallowed in drain mode and stop still works.
f.write(b"ignored");
f.stop();
}
#[test]
fn shell_command_runs_through_windir_cmd() {
// `ConfigCommand::Shell` => `%WINDIR%\System32\cmd.exe /C <command>`.
let mut f = start(ConfigCommand::Shell("echo shell-cmd-ok & exit 4".into()));
let (code, _) = f.wait_exit();
assert_eq!(code, 4);
f.wait_text("shell-cmd-ok");
f.stop();
}
#[test]
fn writes_reach_the_child_and_exit_code_follows() {
// Interactive cmd.exe: type a command, then exit with a code.
let mut f = start(cmd(&[]));
f.write(b"echo mk1\r\n");
f.wait_for("command output", |f| {
// Once echoed by conhost with the prompt, once printed by `echo`.
(f.text().matches("mk1").count() >= 2).then_some(())
});
f.write(b"exit 5\r\n");
let (code, _) = f.wait_exit();
assert_eq!(code, 5);
f.stop();
}
#[test]
fn large_write_is_delivered_in_order() {
// ~120 KiB typed into an interactive cmd.exe: exercises the writer
// thread's overlapped WriteFile queue together with the reader.
let mut f = start(cmd(&[]));
f.wait_text(">");
let mut data = Vec::new();
for i in 0..2000 {
data.extend_from_slice(format!("rem {i:05} {}\r\n", "x".repeat(50)).as_bytes());
}
data.extend_from_slice(b"echo done-%OS:~0,1%-end\r\n");
for chunk in data.chunks(4096) {
f.write(chunk);
}
// `%OS%` is Windows_NT: the expansion proves cmd.exe executed the line
// after consuming every preceding byte.
f.wait_text("done-W-end");
f.write(b"exit 0\r\n");
f.wait_exit();
f.stop();
}
/// Not a test by itself: the child process of `resize_is_applied_to_the_pseudo_console`
/// (this test binary re-run on the pseudo console). Prints the console window
/// size on startup and after every line read from stdin; `q` quits.
#[test]
fn child_console_size() {
use std::io::BufRead;
use windows_sys::Win32::System::Console::{
CONSOLE_SCREEN_BUFFER_INFO,
GetConsoleScreenBufferInfo,
GetStdHandle,
STD_OUTPUT_HANDLE,
};
if std::env::var_os("GHOSTTY_WINE_CHILD").is_none() {
return;
}
let report = || {
let mut info: CONSOLE_SCREEN_BUFFER_INFO = unsafe { std::mem::zeroed() };
let ok =
unsafe { GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &raw mut info) };
let w = i32::from(info.srWindow.Right) - i32::from(info.srWindow.Left) + 1;
let h = i32::from(info.srWindow.Bottom) - i32::from(info.srWindow.Top) + 1;
println!("SIZE ok={ok} {w}x{h} END");
};
report();
for line in std::io::stdin().lock().lines() {
if line.unwrap_or_default().trim() == "q" {
break;
}
report();
}
}
#[test]
fn resize_message_resizes_terminal_and_pseudo_console_call_succeeds() {
let mut f = start(cmd(&[]));
f.wait_text(">");
for i in 0..10u32 {
f.send(Message::Resize(size(60 + i, 20 + i)));
}
f.send(Message::Resize(size(100, 40)));
f.wait_for("terminal resize", |f| {
let t = f.state.lock_terminal();
((t.cols, t.rows) == (100, 40)).then_some(())
});
assert_eq!(
*f.renderer.resizes.lock().unwrap().last().unwrap(),
size(100, 40)
);
// The child is still alive and answering after ResizePseudoConsole.
f.write(b"echo after-resize\r\n");
f.wait_for("echo after resize", |f| {
(f.text().matches("after-resize").count() >= 2).then_some(())
});
f.write(b"exit 0\r\n");
f.wait_exit();
f.stop();
}
#[test]
#[cfg_attr(
wine,
ignore = "wine: ResizePseudoConsole is a stub (\"fixme:console:ResizePseudoConsole (100,40) stub, faking success\"), the child never sees the new size"
)]
fn resize_is_applied_to_the_pseudo_console() {
let exe = std::env::current_exe().unwrap().display().to_string();
let mut f = start_with(
ConfigCommand::Direct(vec![
exe.into(),
"--exact".into(),
"child_console_size".into(),
"--nocapture".into(),
]),
|c| {
c.env.insert("GHOSTTY_WINE_CHILD", "1");
},
);
// size(80, 30) is what the pty was created with.
f.wait_text("SIZE ok=1 80x30 END");
f.send(Message::Resize(size(100, 40)));
f.wait_for("terminal resize", |f| {
let t = f.state.lock_terminal();
((t.cols, t.rows) == (100, 40)).then_some(())
});
// ResizePseudoConsole is synchronous with the message; ask the child.
f.write(b"\r\n");
f.wait_text("SIZE ok=1 100x40 END");
f.write(b"q\r\n");
let (code, _) = f.wait_exit();
assert_eq!(code, 0);
f.stop();
}
#[test]
fn stop_terminates_a_running_child() {
let mut f = start(cmd(&[]));
f.wait_text(">");
let started = Instant::now();
f.stop();
assert!(
started.elapsed() < Duration::from_secs(10),
"stop took {:?}",
started.elapsed()
);
}
#[test]
fn writes_after_exit_are_ignored_and_thread_stays_alive_until_stopped() {
let mut f = start(cmd(&["/c", "exit 0"]));
let (code, _) = f.wait_exit();
assert_eq!(code, 0);
f.write(b"data nobody reads");
f.send(Message::Focused(true));
f.send(Message::Resize(size(90, 25)));
f.wait_for("resize after exit", |f| {
(f.state.lock_terminal().cols == 90).then_some(())
});
f.stop();
}
+6 -15
View File
@@ -1,24 +1,15 @@
# Known differences (left by the stopping criterion) # Known differences (left by the stopping criterion)
These are known minor differences between the Rust port and Ghostty 1.3.1 All previously listed differences (rows 5-10: theme-search word navigation,
found in the final audit (RV18, `notes/review-rv18-codex.md`). They were grapheme backspace, ASCII-only case folding, initial preview colors, load_file
recorded, not fixed, because the stopping criterion in `PLAN.md` does not error propagation, subprocess cleanup wait) have been fixed; see
require chasing P3 items. All are severity P3. Each was verified from source `notes/final-P3.md` (row 10 is registered as a deviant fix in
and, where noted, by PTY reproduction. `DEVIANT_FIXES.md`). No known differences are currently recorded here.
| # | Area | Rust | Zig | Observable effect | Sev |
|---|------|------|-----|-------------------|-----|
| 5 | Theme search: word navigation | `ghostty-cli/src/list_themes/preview.rs:113` has no word-navigation editing | upstream list-themes search has word navigation | Enter `blue red`, Alt+B, `X`: Zig gives `blue Xred`, Rust `blue redX` | P3 |
| 6 | Theme search: grapheme backspace | `preview.rs:100` deletes one Unicode scalar | deletes one grapheme | `e` + U+0301 then Backspace: Zig removes both, Rust leaves `e` | P3 |
| 7 | Theme search: non-ASCII case | `preview.rs:150` Unicode-lowercases theme names but ASCII-lowercases the query | ASCII-only on both sides | Query `Ä` does not match theme `Ä` in Rust, matches in Zig | P3 |
| 8 | Theme preview: initial colors | `preview.rs:385` consults `COLORFGBG` | starts light until terminal color reports arrive | Without terminal replies, changing `COLORFGBG` changes Rust's colors only | P3 |
| 9 | Theme preview: load errors | `preview.rs:440` ignores `load_file` errors and keeps filtering with the retained config | the error propagates and the preview exits | A config load error is silently swallowed in Rust | P3 |
| 10 | Subprocess cleanup cap | `ghostty-termio/src/exec.rs:1348` waits at most about 5 s for the child's new process group | keeps waiting | If the child is delayed before `setsid` for longer, Rust returns without sending SIGHUP | P3 |
## Not verifiable on this machine ## Not verifiable on this machine
- Native Apple execution: Metal, IOSurface, CoreText, Apple input APIs, Mach handling and Apple packaging (compile-checked only, `just check-macos`). - Native Apple execution: Metal, IOSurface, CoreText, Apple input APIs, Mach handling and Apple packaging (compile-checked only, `just check-macos`).
- Windows ConPTY and Win32 behavior (compile-checked only, `just check-windows`). - Native Windows (real Windows console host, Windows-only APIs Wine stubs: `ResizePseudoConsole` is a Wine stub, POSIX-semantics rename over an existing file is unsupported by Wine). The Windows code paths are otherwise run under Wine (`just test-windows-wine`, see `notes/windows-wine.md`): ConPTY, `CreateProcessW`, pipes, the IO thread and `cmd.exe` children are runtime-tested; `just check-windows` stays the clippy gate.
- Native FreeBSD execution (cross `cargo check` only, if the target could be added). - Native FreeBSD execution (cross `cargo check` only, if the target could be added).
- Live portal / D-Bus integration. - Live portal / D-Bus integration.
- Real-browser rendering (wasm is checked with Node only). - Real-browser rendering (wasm is checked with Node only).
+22
View File
@@ -0,0 +1,22 @@
# P3: KNOWN_DIFFERENCES rows 5-10
* 5/6 `ghostty-cli/src/list_themes/preview.rs` `TextInput` is now a port of
`vaxis.widgets.TextInput`: byte cursor, grapheme-wise Backspace/Delete/Left/Right
(`unicode-segmentation`), ASCII-space words for Alt+B/F (and Alt+Left/Right),
Ctrl+W / Alt+Backspace, Alt+D, Ctrl+A/E/K/U/B/F/D. Modifiers must match exactly
(vaxis `Key.matches`), e.g. Ctrl+Left is not Left.
* 7 `rank` lowers only ASCII and compares bytes (zf `std.ascii.toLower`).
* 8 `initial_scheme`/`COLORFGBG` removed: light until a color-scheme report arrives.
* 9 `Preview::new`, `update_filtered`, `update`, `handle_terminal_event` return
`Result`; `load_file` errors propagate and end the preview (exit code 1, as Zig).
* 10 `ghostty-termio/src/exec.rs` `getpgid`: no 5 s cap, waits like Zig. Deviation
(DEVIANT_FIXES.md #13): Zig spins forever if the child died before `setsid`
and is an unreaped zombie (pgid stays ours); Rust stops when `waitid(WNOWAIT)`
reports the child exited.
Tests: unit tests in `preview.rs` and `exec.rs`; PTY tests in
`tests/list_themes_pty.rs` (Rust binary vs `/usr/bin/ghostty`, oracle part skipped
when absent; uses `setsid --ctty`, needs util-linux).
Observed, not fixed (outside rows 5-10): a ZWJ emoji cluster (woman+ZWJ+rocket) in
the search box is drawn by Rust without the second emoji; Zig draws it.
+89
View File
@@ -0,0 +1,89 @@
# Windows code under Wine (agent WINE)
`just test-windows-wine` (= `scripts/windows-wine-test.sh [cargo test args]`)
runs the `x86_64-pc-windows-gnu` test binaries under Wine 11.19 (staging) with
the mingw-w64 toolchain. Dedicated `WINEPREFIX=target/wine-prefix`,
`WINEDEBUG=-all`, no display (console tests only). `RUSTC_WRAPPER=` (kache
breaks cc-rs), `RUSTFLAGS=--cfg wine` (lets tests mark Wine limitations as
ignored; `cfg(wine)` is declared in the workspace lints and ghostty-termio),
`WINEPATH` for the mingw runtime DLLs (libstdc++ from the bundled HarfBuzz).
## Native dependencies
Linking needs real libraries. The script builds them once into
`target/wine-deps/prefix` (like Ghostty, from source): expat 2.6.4 (tarball is
downloaded, or `EXPAT_TARBALL=`), FreeType (the sources vendored in
`freetype-sys`) and fontconfig 2.14.2 (zig package cache copy, `FONTCONFIG_SRC=`,
or a download) with cmake/meson cross builds. HarfBuzz/FreeType/oniguruma for
the `-sys` crates come from their bundled features as before.
## What ran (final run, exit 0)
| crate | passed | ignored |
|---|---|---|
| ghostty-vt | 486 | 1 (pre-existing) |
| ghostty-unicode | 20 | 0 |
| ghostty-datastruct | 88 | 0 |
| ghostty-input | 237 | 0 |
| ghostty-os | 97 | 0 |
| ghostty-config | 283 + 7 + 4 (integration) | 0 |
| ghostty-terminal | 1275 | 0 |
| ghostty-crash | 33 | 0 |
| ghostty-termio | 107 lib + 2 golden + 10 `exec_windows` | 1 (Wine) |
| ghostty-cli | 108 | 4 (Wine) |
Before the fixes: cli 7, config 9, os 2, terminal 1, termio 15 failures and a
link failure (below).
`ghostty-termio/tests/exec_windows.rs` is the Windows counterpart of
`exec_integration`: real `cmd.exe` children on a real ConPTY through
`Termio` + `Thread` (`WaitForMultipleObjects` loop) + the pipe read thread
against a real `Terminal`: `echo` output and exit status, `exit 7`, `Shell`
command via `%WINDIR%\System32\cmd.exe /C`, interactive typing and exit code,
120 KiB of input through the overlapped writer, resize messages
(terminal + `ResizePseudoConsole`), `stop` terminating a running child,
messages after exit, start failure shown in the terminal.
## Bugs found and fixed
* Link: `harfbuzz-sys` `bundled` is built without `HAVE_FREETYPE`, so
`hb_ft_font_create_referenced` was missing on Windows. `ghostty-font/build.rs`
now compiles `hb-ft.cc` against the bundled FreeType headers (Zig builds
HarfBuzz with FreeType). `cc` added as a build-dependency.
* `std::fs::canonicalize` returns `\\?\C:\...` on Windows, Zig's `realpath`
returns `C:\...`; the verbatim form rejects `/` and is not usable as a
`cmd.exe` working directory. New `ghostty_os::path::realpath` strips it; used
by config path expansion, `+new-window`, `+validate-config`, kitty graphics
file loading, screen-file writes (faithfulness fix, not a Zig bug).
* DEVIANT_FIXES 38: absolute Windows theme paths were rejected (`C:` looks like
a `light:`/`dark:` pair). DEVIANT_FIXES 39: `CreateProcessW` is called with a
literal `lpApplicationName`, so a bare `cmd.exe` argv[0] failed with
FileNotFound; PATH is searched first.
* Tests with Unix assumptions fixed (platform-aware expectations, no product
change): shell-integration helpers (verbatim path, `;` PATH delimiter),
`xev::prefer(Iocp)` (Iocp is a candidate on Windows), i18n `init` (no-op on
Windows), kitty `/proc/` guard (POSIX only), config/theme/file-load
directory-open errors (Windows gives `AccessDenied` for a directory, matching
Zig 0.15 `OpenFile`; so `NotAFile` is `FileOpenFailed` there), path
separators, `light:`/`dark:` pairs need quoted backslashes (as in Zig),
`list_themes` file URIs (`file://C:%5C...`, as Zig's `toUri`).
## Wine limitations (ignored with `wine:` reasons)
* `ResizePseudoConsole` is a stub in Wine 11.19 (`fixme:console:
ResizePseudoConsole (100,40) stub, faking success`): the child never sees the
new size. `exec_windows::resize_is_applied_to_the_pseudo_console` is ignored;
the terminal-side resize and the API call are still tested.
* `NtSetInformationFile(FileRenameInformationEx, REPLACE_IF_EXISTS|
POSIX_SEMANTICS)` is unsupported (`fixme:file:NtSetInformationFile
unsupported flags: 0x3`), so `std::fs::rename` over an existing file fails
with `ERROR_ACCESS_DENIED`. The four `ssh_cache` disk-cache tests (atomic
temp-file + rename) are ignored; real Windows supports it.
* Wine's `cmd.exe` has no `mode con`, and its `findstr` ignores `/n`; tests use
a re-run of the test binary as console-size child and `%OS%` expansion.
* Wine maps `/` to `Z:\`; tests on POSIX-looking paths account for it.
## Not covered
fontconfig/font discovery and the renderer/core/GTK crates are not run (no
Windows apprt); ghostty-font links but its tests are not part of the run.
+5
View File
@@ -282,6 +282,11 @@ check-macos target="aarch64-apple-darwin":
check-windows: check-windows:
scripts/windows-crosscheck.sh scripts/windows-crosscheck.sh
# Run the Windows test suites under Wine (builds expat/FreeType/fontconfig for
# mingw on the first run; see docs/rewrite/notes/windows-wine.md).
test-windows-wine *args:
scripts/windows-wine-test.sh {{ args }}
# Build, lint and test the wasm32 targets. # Build, lint and test the wasm32 targets.
check-wasm: check-wasm:
scripts/wasm-check.sh scripts/wasm-check.sh
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Run the Windows test suites under Wine (x86_64-pc-windows-gnu + mingw-w64).
#
# Usage: scripts/windows-wine-test.sh [cargo test args, default: the core crates]
#
# First run builds expat + FreeType + fontconfig for mingw into
# target/wine-deps/prefix (like Ghostty, from source; needs network for the
# expat tarball unless EXPAT_TARBALL points at one, and the fontconfig source
# from the zig package cache or FONTCONFIG_SRC). A dedicated WINEPREFIX lives in
# target/wine-prefix. Headless: no display is needed for console tests.
# See docs/rewrite/notes/windows-wine.md.
set -euo pipefail
cd "$(dirname "$0")/.."
ROOT="$PWD"
D="$ROOT/target/wine-deps"
P="$D/prefix"
export RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= # kache breaks cc-rs
export WINEPREFIX="$ROOT/target/wine-prefix" WINEDEBUG=-all WINEARCH=win64
unset DISPLAY WAYLAND_DISPLAY
build_deps() {
[ -f "$P/lib/libfontconfig.a" ] && return 0
mkdir -p "$D"
local ft expat fc
ft=$(echo "${CARGO_HOME:-$HOME/.cargo}"/registry/src/*/freetype-sys-0.23.0/freetype2)
[ -d "$ft" ] || { cargo fetch --target x86_64-pc-windows-gnu >/dev/null; ft=$(echo "${CARGO_HOME:-$HOME/.cargo}"/registry/src/*/freetype-sys-0.23.0/freetype2); }
fc="${FONTCONFIG_SRC:-}"
if [ -z "$fc" ]; then
for d in "$HOME"/.cache/zig/p/*/; do
grep -q "project('fontconfig'" "$d/meson.build" 2>/dev/null && fc="$d" && break
done
fi
if [ -z "$fc" ]; then
curl -sSLf -o "$D/fontconfig.tar.xz" https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.14.2.tar.xz
tar -C "$D" -xf "$D/fontconfig.tar.xz"; fc="$D/fontconfig-2.14.2"
fi
if [ ! -d "$D/expat-2.6.4" ]; then
if [ -n "${EXPAT_TARBALL:-}" ]; then cp "$EXPAT_TARBALL" "$D/expat.tar.bz2"
else curl -sSLf -o "$D/expat.tar.bz2" https://github.com/libexpat/libexpat/releases/download/R_2_6_4/expat-2.6.4.tar.bz2; fi
tar -C "$D" -xjf "$D/expat.tar.bz2"
fi
x86_64-w64-mingw32-cmake -S "$D/expat-2.6.4" -B "$D/b-expat" -G Ninja -DCMAKE_INSTALL_PREFIX="$P" \
-DBUILD_SHARED_LIBS=OFF -DEXPAT_BUILD_TOOLS=OFF -DEXPAT_BUILD_EXAMPLES=OFF -DEXPAT_BUILD_TESTS=OFF \
-DEXPAT_BUILD_DOCS=OFF -DCMAKE_BUILD_TYPE=Release >/dev/null
cmake --build "$D/b-expat" --target install >/dev/null
x86_64-w64-mingw32-cmake -S "$ft" -B "$D/b-ft" -G Ninja -DCMAKE_INSTALL_PREFIX="$P" -DBUILD_SHARED_LIBS=OFF \
-DFT_DISABLE_BROTLI=ON -DFT_DISABLE_BZIP2=ON -DFT_DISABLE_PNG=ON -DFT_DISABLE_HARFBUZZ=ON \
-DCMAKE_BUILD_TYPE=Release >/dev/null
cmake --build "$D/b-ft" --target install >/dev/null
cat > "$D/cross.ini" <<EOS
[binaries]
c = 'x86_64-w64-mingw32-gcc'
cpp = 'x86_64-w64-mingw32-g++'
ar = 'x86_64-w64-mingw32-ar'
strip = 'x86_64-w64-mingw32-strip'
pkg-config = 'pkg-config'
[host_machine]
system = 'windows'
cpu_family = 'x86_64'
cpu = 'x86_64'
endian = 'little'
EOS
PKG_CONFIG_PATH="$P/lib/pkgconfig:/usr/x86_64-w64-mingw32/lib/pkgconfig" \
PKG_CONFIG_LIBDIR="$P/lib/pkgconfig:/usr/x86_64-w64-mingw32/lib/pkgconfig" \
meson setup "$D/b-fc" "$fc" --cross-file "$D/cross.ini" --prefix "$P" --default-library static \
-Dtools=disabled -Dtests=disabled -Ddoc=disabled -Dnls=disabled -Dcache-build=disabled \
--wrap-mode nodownload >/dev/null
ninja -C "$D/b-fc" install >/dev/null
}
build_deps
export PKG_CONFIG_ALLOW_CROSS=1 PKG_CONFIG_ALL_STATIC=1
export PKG_CONFIG_PATH="$P/lib/pkgconfig" PKG_CONFIG_LIBDIR="$P/lib/pkgconfig:/usr/x86_64-w64-mingw32/lib/pkgconfig"
export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++ \
AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine
# `cfg(wine)` lets tests mark documented Wine limitations as ignored.
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS="--cfg wine"
# libstdc++ (bundled HarfBuzz) / libgcc / winpthreads DLLs for the test exes.
export WINEPATH='Z:\usr\x86_64-w64-mingw32\bin'
CRATES=(-p ghostty-vt -p ghostty-unicode -p ghostty-datastruct -p ghostty-input -p ghostty-os
-p ghostty-config -p ghostty-terminal -p ghostty-crash -p ghostty-termio -p ghostty-cli)
if [ $# -gt 0 ]; then CRATES=("$@"); fi
wine wineboot -i >/dev/null 2>&1 || true
exec timeout "${WINE_TEST_TIMEOUT:-2400}" cargo test --target x86_64-pc-windows-gnu --no-fail-fast "${CRATES[@]}"
+382
View File
@@ -0,0 +1,382 @@
//! PTY-driven checks of the interactive `+list-themes` preview against the
//! upstream Ghostty binary (`/usr/bin/ghostty`, skipped when absent): the
//! same keystrokes must leave the same text on the screen.
#![allow(clippy::unwrap_used)]
use std::{
fmt::Write as _,
io::{Read, Write},
os::fd::OwnedFd,
path::Path,
process::{Command, Stdio},
sync::mpsc,
time::{Duration, Instant},
};
use ghostty_terminal::terminal::{Options, Terminal};
use rustix::{
fs::{Mode, OFlags, open},
pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt},
termios::{Winsize, tcsetwinsize},
};
const ORACLE: &str = "/usr/bin/ghostty";
const COLS: u16 = 100;
const ROWS: u16 = 30;
/// A spawned `+list-themes` session in a PTY.
struct Session {
child: std::process::Child,
master: std::fs::File,
rx: mpsc::Receiver<Vec<u8>>,
output: Vec<u8>,
/// Answer the primary device attributes query (ends vaxis' startup query).
reply_da: bool,
answered: usize,
}
impl Session {
fn spawn(exe: &str, envs: &[(&str, &str)], reply_da: bool) -> Session {
let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY).unwrap();
grantpt(&master).unwrap();
unlockpt(&master).unwrap();
let name = ptsname(&master, Vec::new()).unwrap();
let slave: OwnedFd = open(
name.as_c_str(),
OFlags::RDWR | OFlags::NOCTTY,
Mode::empty(),
)
.unwrap();
tcsetwinsize(
&master,
Winsize {
ws_row: ROWS,
ws_col: COLS,
ws_xpixel: 0,
ws_ypixel: 0,
},
)
.unwrap();
let mut cmd = Command::new("setsid");
cmd.args(["--ctty", exe, "+list-themes"])
.env("TERM", "xterm-256color")
.env("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty")
.env_remove("COLORFGBG")
.stdin(Stdio::from(slave.try_clone().unwrap()))
.stdout(Stdio::from(slave.try_clone().unwrap()))
.stderr(Stdio::from(slave));
for (k, v) in envs {
cmd.env(k, v);
}
let child = cmd.spawn().unwrap();
let master = std::fs::File::from(master);
let (tx, rx) = mpsc::channel();
let mut reader = master.try_clone().unwrap();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
while let Ok(n) = reader.read(&mut buf) {
if n == 0 || tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
});
Session {
child,
master,
rx,
output: vec![],
reply_da,
answered: 0,
}
}
/// Collect output until the program has been quiet for `quiet`.
fn settle(&mut self, quiet: Duration) {
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
match self.rx.recv_timeout(quiet) {
Ok(chunk) => {
self.output.extend_from_slice(&chunk);
if self.reply_da {
let needle = b"\x1b[c";
let count = self
.output
.windows(needle.len())
.filter(|w| *w == needle)
.count();
while self.answered < count {
self.answered += 1;
self.master.write_all(b"\x1b[?62;4c").unwrap();
}
}
}
Err(_) => return,
}
}
}
fn send(&mut self, bytes: &[u8]) {
self.master.write_all(bytes).unwrap();
self.settle(Duration::from_millis(500));
}
/// The screen text after replaying everything received so far.
fn screen(&self) -> Terminal {
let mut t = Terminal::new(Options::new(COLS, ROWS)).unwrap();
t.vt_stream().next_slice(&self.output).unwrap();
t
}
fn finish(mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn rows_of(t: &Terminal) -> Vec<String> {
t.plain_string()
.unwrap()
.lines()
.map(|l| l.trim_end().to_owned())
.collect()
}
fn have_oracle() -> bool {
Path::new(ORACLE).exists()
}
fn rust_exe() -> &'static str {
env!("CARGO_BIN_EXE_ghostty")
}
/// Type `keys` in search mode and return the rows of the screen.
fn search_screen(exe: &str, envs: &[(&str, &str)], keys: &[&[u8]]) -> Vec<String> {
let mut s = Session::spawn(exe, envs, true);
s.settle(Duration::from_millis(1500));
s.send(b"/");
for k in keys {
s.send(k);
}
let rows = rows_of(&s.screen());
s.finish();
rows
}
/// The text inside the search box (the row below the top border).
fn search_box_text(rows: &[String]) -> String {
let i = rows.iter().rposition(|r| r.contains('\u{256d}')).unwrap();
// The box starts at column 20; its content follows the left border.
let inner: String = rows[i + 1].chars().skip(21).collect();
inner
.trim_end()
.trim_end_matches('\u{2502}')
.trim_end()
.to_owned()
}
/// Run a script of key presses in search mode and return the search box
/// text after each one.
fn search_trace(exe: &str, envs: &[(&str, &str)], steps: &[&[u8]]) -> Vec<String> {
let mut s = Session::spawn(exe, envs, true);
s.settle(Duration::from_millis(1500));
s.send(b"/");
let mut out = vec![];
for k in steps {
s.send(k);
out.push(search_box_text(&rows_of(&s.screen())));
}
s.finish();
out
}
/// Compare Rust against the oracle (when present) and against `expected`.
fn check_trace(steps: &[&[u8]], expected: &[&str]) {
let rust = search_trace(rust_exe(), &[], steps);
if have_oracle() {
let zig = search_trace(ORACLE, &[], steps);
assert_eq!(expected, zig.as_slice(), "oracle");
}
assert_eq!(expected, rust.as_slice(), "rust");
}
#[test]
fn word_navigation_and_edit_bindings_match_upstream() {
check_trace(
&[
b"blue red green", // type
b"\x1bb", // Alt+B
b"X",
b"\x1b[1;3D", // Alt+Left
b"Y",
b"\x1bf", // Alt+F
b"Z",
b"\x1b[1;3C", // Alt+Right
b"W",
b"\x17", // Ctrl+W
b"\x1b\x7f", // Alt+Backspace
b"\x01", // Ctrl+A
b"\x1bd", // Alt+D
b"\x1bd",
b"\x05\x0b", // Ctrl+E, Ctrl+K (no-op at end)
b"\x01\x0b", // Ctrl+A, Ctrl+K
b"ab cd",
b"\x02\x02\x04", // Ctrl+B x2, Ctrl+D
b"\x15", // Ctrl+U
],
&[
"blue red green",
"blue red green",
"blue red Xgreen",
"blue red Xgreen",
"blue red YXgreen",
"blue red YXgreen",
"blue red YXgreenZ",
"blue red YXgreenZ",
"blue red YXgreenZW",
"blue red",
"blue",
"blue",
"",
"",
"",
"",
"ab cd",
"ab d",
"d",
],
);
}
#[test]
fn backspace_delete_and_cursor_move_by_grapheme() {
let cluster = "n\u{303}\u{323}";
let typed = format!("a{cluster}b");
check_trace(
&[
"xe\u{301}".as_bytes(),
b"\x7f", // Backspace removes e + U+0301
b"\x15",
typed.as_bytes(),
b"\x1b[D\x1b[D", // Left x2: before the ZWJ cluster
b"\x1b[3~", // Delete removes the whole cluster
],
&["xe\u{301}", "x", "", &typed, &typed, "ab"],
);
}
#[test]
fn search_folds_only_ascii_case() {
let cfg = tempfile::tempdir().unwrap();
let themes = cfg.path().join("ghostty/themes");
std::fs::create_dir_all(&themes).unwrap();
std::fs::write(themes.join("\u{c4}rger"), "background = #101010\n").unwrap();
std::fs::write(themes.join("zzplain"), "background = #101010\n").unwrap();
let envs = [("XDG_CONFIG_HOME", cfg.path().to_str().unwrap())];
// `Ä` matches the theme `Ärger` (exact bytes), `ä` does not (no
// Unicode folding of the theme name or the query).
for exe in [rust_exe(), ORACLE] {
if exe == ORACLE && !have_oracle() {
continue;
}
let upper = search_screen(exe, &envs, &["\u{c4}".as_bytes()]);
assert!(
upper.iter().any(|r| r.contains("\u{c4}rger")),
"{exe}: {upper:?}"
);
let lower = search_screen(exe, &envs, &["\u{e4}".as_bytes()]);
assert!(
!lower.iter().any(|r| r.contains("\u{c4}rger")),
"{exe}: {lower:?}"
);
}
}
/// The (fg, bg) style of every cell of the active screen, as text.
fn style_grid(t: &Terminal) -> Vec<String> {
use ghostty_terminal::{
page_list::Pin,
point::{Coordinate, Point},
};
let screen = t.screens.active();
let pages = &screen.pages;
let mut out = vec![];
for y in 0..pages.rows {
let pin = pages
.pin(Point::Active(Coordinate {
x: 0,
y: u32::from(y),
}))
.unwrap();
let mut row = String::new();
for x in 0..pages.cols {
let st = Pin { x, ..pin }.style(pages).unwrap();
let _ = write!(row, "{:?}/{:?};", st.fg_color, st.bg_color);
}
out.push(row);
}
out
}
fn initial_styles(exe: &str, envs: &[(&str, &str)]) -> Vec<String> {
// No terminal replies of any kind: the scheme stays at its initial value.
let mut s = Session::spawn(exe, envs, false);
s.settle(Duration::from_millis(3000));
let grid = style_grid(&s.screen());
s.finish();
grid
}
#[test]
fn initial_colors_are_light_and_ignore_colorfgbg() {
let plain = initial_styles(rust_exe(), &[]);
let dark_hint = initial_styles(rust_exe(), &[("COLORFGBG", "15;0")]);
assert_eq!(plain, dark_hint);
if have_oracle() {
let zig = initial_styles(ORACLE, &[("COLORFGBG", "15;0")]);
assert_eq!(zig, plain);
}
// Light: black on white (rgb) somewhere on screen.
let all = plain.join("");
assert!(all.contains("Rgb"), "{all}");
}
/// Run the preview with an unreadable user theme; returns the exit status
/// (if the program exited by itself) and the final screen text.
fn unreadable_theme_run(exe: &str) -> (Option<i32>, String) {
use std::os::unix::fs::PermissionsExt;
let cfg = tempfile::tempdir().unwrap();
let themes = cfg.path().join("ghostty/themes");
std::fs::create_dir_all(&themes).unwrap();
let bad = themes.join("unreadable");
std::fs::write(&bad, "background = #101010\n").unwrap();
std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o000)).unwrap();
let envs = [("XDG_CONFIG_HOME", cfg.path().to_str().unwrap())];
let mut s = Session::spawn(exe, &envs, true);
s.settle(Duration::from_millis(2000));
let _ = s.master.write_all(b"/a");
s.settle(Duration::from_millis(1000));
// Give a loaded machine time to reach (and exit from) the failure.
let deadline = Instant::now() + Duration::from_secs(10);
let mut status = s.child.try_wait().unwrap();
while status.is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(100));
status = s.child.try_wait().unwrap();
}
s.settle(Duration::from_millis(300));
let code = status.and_then(|st| st.code());
let text = rows_of(&s.screen()).join("\n");
s.finish();
(code, text)
}
#[test]
fn load_file_error_exits_the_preview() {
let (code, text) = unreadable_theme_run(rust_exe());
let _ = text;
if have_oracle() {
let (zcode, ztext) = unreadable_theme_run(ORACLE);
let _ = ztext;
assert_eq!(zcode.is_some(), code.is_some());
}
assert!(code.is_some(), "the preview must have exited");
}