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 b95aae16ae
commit a8d0d624da
30 files changed
+1668 -240

No files matched your search

Generated
+4
View File
@@ -1443,6 +1443,7 @@ dependencies = [
"tempfile",
"termina",
"thiserror 2.0.21",
"unicode-segmentation",
"unicode-width",
]
@@ -1530,6 +1531,7 @@ name = "ghostty-font"
version = "0.1.0"
dependencies = [
"bitflags 2.13.2",
"cc",
"freetype-sys",
"ghostty-config",
"ghostty-datastruct",
@@ -1686,6 +1688,8 @@ dependencies = [
"ghostty-core",
"ghostty-crash",
"ghostty-os",
"ghostty-terminal",
"ghostty-vt",
"log",
"rustix",
"serde_json",
+5 -1
View File
@@ -29,7 +29,9 @@ ghostty-os.workspace = true
log = "0.4"
[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"
tempfile = "3"
@@ -78,6 +80,8 @@ ghostty-lib = { path = "crates/ghostty-lib" }
[workspace.lints.rust]
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]
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`
C APIs (same headers/ABI), WASM, crash reporting, and the macOS
(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
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/`).
- `just visual-parity` — pixel comparison against the installed Ghostty in a
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.
## Documentation
+1
View File
@@ -24,6 +24,7 @@ ratatui = "0.30.2"
termina = "0.3.3"
thiserror = "2.0.21"
unicode-width = "0.2.2"
unicode-segmentation = "1.13.3"
[build-dependencies]
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!(
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)
);
}
+363 -177
View File
@@ -32,6 +32,7 @@ use ratatui::{
layout::{Position, Rect},
style::{Color, Modifier, Style},
};
use unicode_segmentation::UnicodeSegmentation as _;
use super::{
ColorScheme,
@@ -62,102 +63,137 @@ pub enum Scheme {
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)]
pub struct TextInput {
pub text: String,
/// Cursor position in chars.
/// Cursor position in bytes.
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 {
fn byte_idx(&self, chars: usize) -> usize {
self.text
.char_indices()
.nth(chars)
.map_or(self.text.len(), |(i, _)| i)
}
fn len(&self) -> usize {
self.text.chars().count()
}
pub fn clear(&mut self) {
self.text.clear();
self.cursor = 0;
}
/// Handle an editing key. Returns true if it was consumed.
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 {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Char(c) if !ctrl && !alt => {
let at = self.byte_idx(self.cursor);
self.text.insert(at, c);
self.cursor += 1;
}
KeyCode::Backspace => {
if self.cursor > 0 {
let at = self.byte_idx(self.cursor - 1);
self.text.remove(at);
self.cursor -= 1;
}
}
KeyCode::Delete | KeyCode::Char('d') if key.code == KeyCode::Delete || ctrl => {
if self.cursor < self.len() {
let at = self.byte_idx(self.cursor);
self.text.remove(at);
}
}
KeyCode::Left | KeyCode::Char('b') if key.code == KeyCode::Left || ctrl => {
self.cursor = self.cursor.saturating_sub(1);
}
KeyCode::Right | KeyCode::Char('f') if key.code == KeyCode::Right || ctrl => {
self.cursor = (self.cursor + 1).min(self.len());
}
KeyCode::Home => self.cursor = 0,
KeyCode::Char('a') if ctrl => self.cursor = 0,
KeyCode::End => self.cursor = self.len(),
KeyCode::Char('e') if ctrl => self.cursor = self.len(),
KeyCode::Char('u') if ctrl => {
let at = self.byte_idx(self.cursor);
self.text.drain(..at);
self.cursor = 0;
}
KeyCode::Char('k') if ctrl => {
let at = self.byte_idx(self.cursor);
self.text.truncate(at);
}
KeyCode::Char('w') if ctrl => {
let upto = self.byte_idx(self.cursor);
let head = self.text[..upto].trim_end_matches(' ');
let new_end = head.rfind(' ').map_or(0, |i| i + 1);
self.text.drain(new_end..upto);
self.cursor = self.text[..new_end].chars().count();
}
_ => return false,
// vaxis `Key.matches` compares the modifiers exactly (lock keys
// aside), except that text input tolerates Shift.
let m = key.modifiers;
let none = m.is_empty();
let only = |x: KeyModifiers| m == x;
let ctrl = only(KeyModifiers::CONTROL);
let alt = only(KeyModifiers::ALT);
let code = key.code;
let c = |ch: char| code == KeyCode::Char(ch);
if none && code == KeyCode::Backspace {
self.delete_left(last_grapheme_len(&self.text[..self.cursor]));
} else if (none && code == KeyCode::Delete) || (ctrl && c('d')) {
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]));
} else if (none && code == KeyCode::Right) || (ctrl && c('f')) {
self.move_right(first_grapheme_len(&self.text[self.cursor..]));
} else if (ctrl && c('a')) || (none && code == KeyCode::Home) {
self.cursor = 0;
} else if (ctrl && c('e')) || (none && code == KeyCode::End) {
self.cursor = self.text.len();
} else if ctrl && c('k') {
self.text.truncate(self.cursor);
} else if ctrl && c('u') {
self.delete_left(self.cursor);
} else if alt && (c('b') || code == KeyCode::Left) {
self.move_left(self.backward_word_len());
} else if alt && (c('f') || code == KeyCode::Right) {
self.move_right(self.forward_word_len());
} else if (ctrl && c('w')) || (alt && code == KeyCode::Backspace) {
// Move one word backward, then delete what was moved over.
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
}
}
/// `zf.rank` with `.plain = true, .to_lower = true`: every token must match
/// the (lowercased) name as a subsequence. Only whether a rank exists is used
/// by the preview, so the score is a simple span length (lower is better).
/// the (ASCII-lowercased) name as a subsequence of bytes. Only whether a
/// rank exists is used by the preview, so the score is a simple span length
/// (lower is better).
#[must_use]
pub fn rank(name: &str, tokens: &[&str]) -> Option<f64> {
let name: Vec<char> = name.to_lowercase().chars().collect();
let name: Vec<u8> = name.to_ascii_lowercase().into_bytes();
let mut total = 0.0;
for token in tokens {
let token: Vec<char> = token.chars().collect();
let token = token.as_bytes();
if token.is_empty() {
continue;
}
// Prefer a contiguous match.
let contiguous = name
.windows(token.len())
.position(|w| w == token.as_slice());
let contiguous = name.windows(token.len()).position(|w| w == token);
if let Some(start) = contiguous {
total += start as f64;
continue;
@@ -380,23 +416,8 @@ pub struct Preview {
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 {
#[must_use]
pub fn new(themes: Vec<ThemeListElement>, theme_filter: ColorScheme) -> Preview {
pub fn new(themes: Vec<ThemeListElement>, theme_filter: ColorScheme) -> Result<Preview, Error> {
let mut p = Preview {
should_quit: false,
themes,
@@ -405,7 +426,7 @@ impl Preview {
window: 0,
hex: false,
mode: Mode::Normal,
color_scheme: initial_scheme(),
color_scheme: Scheme::Light,
text_input: TextInput::default(),
theme_filter,
mouse: None,
@@ -415,12 +436,12 @@ impl Preview {
effects: vec![],
overlays: vec![],
};
p.update_filtered();
p
p.update_filtered()?;
Ok(p)
}
/// 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 selected: Option<String> = self
.filtered
@@ -436,8 +457,7 @@ impl Preview {
let searching = !self.text_input.text.is_empty();
for (i, theme) in self.themes.iter_mut().enumerate() {
// Errors are ignored here (Zig would abort the whole preview).
let _ = theme_config.load_file(&absolute_path(&theme.path));
theme_config.load_file(&absolute_path(&theme.path))?;
if !should_include_theme(self.theme_filter, &theme_config) {
continue;
}
@@ -453,13 +473,13 @@ impl Preview {
}
if old == self.filtered {
return;
return Ok(());
}
if self.filtered.is_empty() {
self.current = 0;
self.window = 0;
return;
return Ok(());
}
let (current, window) = (|| {
@@ -485,6 +505,7 @@ impl Preview {
})();
self.current = current;
self.window = window;
Ok(())
}
pub fn up(&mut self, count: usize) {
@@ -516,15 +537,16 @@ impl Preview {
}
/// Handle a terminal event (Zig: `update`).
pub fn update(&mut self, event: &Event) {
pub fn update(&mut self, event: &Event) -> Result<(), Error> {
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),
_ => {}
}
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 shift = key.modifiers.contains(KeyModifiers::SHIFT);
let plain = key.modifiers.difference(KeyModifiers::SHIFT).is_empty();
@@ -553,7 +575,7 @@ impl Preview {
}
if ctrl && matches!(code, KeyCode::Char('x' | '/' | '_')) {
self.text_input.clear();
self.update_filtered();
self.update_filtered()?;
}
if plain && code == KeyCode::Home {
self.current = 0;
@@ -599,7 +621,7 @@ impl Preview {
ColorScheme::Dark => ColorScheme::Light,
ColorScheme::Light => ColorScheme::All,
};
self.update_filtered();
self.update_filtered()?;
}
}
Mode::Help => {
@@ -619,15 +641,15 @@ impl Preview {
Mode::Search => {
if plain && matches!(code, KeyCode::Esc | KeyCode::Enter) {
self.mode = Mode::Normal;
return;
return Ok(());
}
if ctrl && matches!(code, KeyCode::Char('x' | '/' | '_')) {
self.text_input.clear();
self.update_filtered();
return;
self.update_filtered()?;
return Ok(());
}
self.text_input.update(key);
self.update_filtered();
self.update_filtered()?;
}
Mode::Save => {
if is_char('q') {
@@ -641,6 +663,7 @@ impl Preview {
}
}
}
Ok(())
}
fn save_selected_theme(&self) {
@@ -849,12 +872,7 @@ impl Preview {
self.ui_standard(),
);
child.print(&self.text_input.text, self.ui_standard(), 0, 0);
let col = gw(&self
.text_input
.text
.chars()
.take(self.text_input.cursor)
.collect::<String>());
let col = gw(&self.text_input.text[..self.text_input.cursor]);
if col < child.area.width {
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
/// `CSI ? 996 n` and on every change while `CSI ? 2031 h` is active), which
/// 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};
match ev {
termina::Event::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))) => {
app.color_scheme = match t {
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.
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)
}
@@ -1727,10 +1746,10 @@ fn run_loop(app: &mut Preview) -> Result<(), Error> {
// Block for an event, then drain everything that is pending.
let first = tty.read(|_| true)?;
handle_terminal_event(app, first);
handle_terminal_event(app, first)?;
while tty.poll(|_| true, Some(std::time::Duration::ZERO))? {
let ev = tty.read(|_| true)?;
handle_terminal_event(app, ev);
handle_terminal_event(app, ev)?;
}
for effect in app.take_effects() {
match effect {
@@ -1811,7 +1830,7 @@ mod tests {
("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
}
@@ -1833,28 +1852,28 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp);
assert_eq!(4, p.filtered.len());
p.update(&ch('j'));
p.update(&key(KeyCode::Down));
p.update(&ch('j')).unwrap();
p.update(&key(KeyCode::Down)).unwrap();
assert_eq!(2, p.current);
p.update(&key(KeyCode::PageDown));
p.update(&key(KeyCode::PageDown)).unwrap();
assert_eq!(3, p.current);
p.update(&ch('k'));
p.update(&ch('k')).unwrap();
assert_eq!(2, p.current);
p.update(&key(KeyCode::PageUp));
p.update(&key(KeyCode::PageUp)).unwrap();
assert_eq!(0, p.current);
p.update(&key(KeyCode::End));
p.update(&key(KeyCode::End)).unwrap();
assert_eq!(3, p.current);
p.update(&key(KeyCode::Home));
p.update(&key(KeyCode::Home)).unwrap();
assert_eq!(0, p.current);
assert!(!p.should_quit);
p.update(&ch('q'));
p.update(&ch('q')).unwrap();
assert!(p.should_quit);
let mut p = app(&tmp);
p.update(&ctrl('c'));
p.update(&ctrl('c')).unwrap();
assert!(p.should_quit);
let mut p = app(&tmp);
p.update(&key(KeyCode::Esc));
p.update(&key(KeyCode::Esc)).unwrap();
assert!(p.should_quit);
}
@@ -1862,7 +1881,7 @@ mod tests {
fn filter_cycles_and_filters() {
let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp);
p.update(&ch('f'));
p.update(&ch('f')).unwrap();
assert_eq!(ColorScheme::Dark, p.theme_filter);
let names: Vec<_> = p
.filtered
@@ -1870,10 +1889,10 @@ mod tests {
.map(|&i| p.themes[i].theme.as_str())
.collect();
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!(2, p.filtered.len());
p.update(&ch('f'));
p.update(&ch('f')).unwrap();
assert_eq!(ColorScheme::All, p.theme_filter);
assert_eq!(4, p.filtered.len());
}
@@ -1882,25 +1901,25 @@ mod tests {
fn search_mode() {
let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp);
p.update(&ch('/'));
p.update(&ch('/')).unwrap();
assert_eq!(Mode::Search, p.mode);
p.update(&ch('G'));
p.update(&ch('a'));
p.update(&ch('m'));
p.update(&ch('G')).unwrap();
p.update(&ch('a')).unwrap();
p.update(&ch('m')).unwrap();
assert_eq!("Gam", p.text_input.text);
assert_eq!(1, p.filtered.len());
assert_eq!("gamma-dark", p.themes[p.filtered[0]].theme);
p.update(&key(KeyCode::Backspace));
p.update(&key(KeyCode::Backspace));
p.update(&key(KeyCode::Backspace));
p.update(&key(KeyCode::Backspace)).unwrap();
p.update(&key(KeyCode::Backspace)).unwrap();
p.update(&key(KeyCode::Backspace)).unwrap();
assert_eq!(4, p.filtered.len());
p.update(&ch('l'));
p.update(&ch('t'));
p.update(&ch('l')).unwrap();
p.update(&ch('t')).unwrap();
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!(4, p.filtered.len());
p.update(&key(KeyCode::Enter));
p.update(&key(KeyCode::Enter)).unwrap();
assert_eq!(Mode::Normal, p.mode);
}
@@ -1908,19 +1927,19 @@ mod tests {
fn hex_toggle_and_help_mode() {
let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp);
p.update(&ch('x'));
p.update(&ch('x')).unwrap();
assert!(p.hex);
p.update(&ch('d'));
p.update(&ch('d')).unwrap();
assert!(!p.hex);
p.update(&key(KeyCode::F(1)));
p.update(&key(KeyCode::F(1))).unwrap();
assert_eq!(Mode::Help, p.mode);
// Keys are modal: 'j' does nothing in help.
p.update(&ch('j'));
p.update(&ch('j')).unwrap();
assert_eq!(0, p.current);
p.update(&key(KeyCode::Esc));
p.update(&key(KeyCode::Esc)).unwrap();
assert_eq!(Mode::Normal, p.mode);
assert!(!p.should_quit);
p.update(&ch('?'));
p.update(&ch('?')).unwrap();
assert_eq!(Mode::Help, p.mode);
}
@@ -1928,11 +1947,12 @@ mod tests {
fn copy_and_save() {
let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp);
p.update(&ch('c'));
p.update(&ch('c')).unwrap();
p.update(&Event::Key(KeyEvent::new(
KeyCode::Char('C'),
KeyModifiers::SHIFT,
)));
)))
.unwrap();
let effects = p.take_effects();
assert_eq!(
Effect::Copy(ghostty_os::osbytes::EncodedOs::from_encoded("alpha")),
@@ -1947,11 +1967,11 @@ mod tests {
let auto = tmp.path().join("cfg/auto/theme.ghostty");
p.auto_theme_path = Some(auto.clone());
p.update(&key(KeyCode::Enter));
p.update(&key(KeyCode::Enter)).unwrap();
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());
p.update(&key(KeyCode::Esc));
p.update(&key(KeyCode::Esc)).unwrap();
assert_eq!(Mode::Normal, p.mode);
}
@@ -1974,7 +1994,7 @@ mod tests {
fn draw_layout() {
let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp);
p.update(&ch('j'));
p.update(&ch('j')).unwrap();
let buf = render(&mut p, 120, 60);
// List on the left: selected item has the marker and the arrow at col 30.
let r1 = row_text(&buf, 1);
@@ -2014,8 +2034,15 @@ mod tests {
.iter()
.find(|o| o.text == "alpha" && o.x == 2)
.unwrap();
assert!(alpha.link.as_deref().unwrap().starts_with("file:///"));
assert!(alpha.link.as_deref().unwrap().ends_with("/alpha"));
// (On Windows the path is `C:\...`; Zig's `toUri` does not rewrite it,
// 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.
let uls: Vec<(String, Ul)> = ov
.iter()
@@ -2031,7 +2058,7 @@ mod tests {
}
// And the raw escapes carry OSC 8 and the 4:N styles.
let raw = render_overlays(&ov);
assert!(raw.contains("\x1b]8;;file:///"));
assert!(raw.contains(&format!("\x1b]8;;{prefix}")));
assert!(raw.contains("\x1b]8;;\x1b\\"));
for sgr in ["4:2", "4:3", "4:4", "4:5"] {
assert!(raw.contains(sgr), "{sgr}");
@@ -2042,7 +2069,7 @@ mod tests {
fn popup_drops_covered_overlays() {
let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp);
p.update(&ch('?'));
p.update(&ch('?')).unwrap();
let _ = render(&mut p, 120, 40);
// The help window covers the middle of the screen: no overlay may
// intersect it.
@@ -2060,10 +2087,10 @@ mod tests {
let mut p = app(&tmp);
let mut parser = termina::Parser::default();
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);
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);
}
@@ -2085,7 +2112,7 @@ mod tests {
fn draw_help_and_save_and_search_overlays() {
let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp);
p.update(&ch('?'));
p.update(&ch('?')).unwrap();
let buf = render(&mut p, 120, 40);
let all: String = (0..40)
.map(|y| row_text(&buf, y))
@@ -2094,8 +2121,8 @@ mod tests {
assert!(all.contains("Cycle through theme filters."));
assert!(all.contains('\u{256d}') && all.contains('\u{256f}'));
p.update(&key(KeyCode::Esc));
p.update(&key(KeyCode::Enter));
p.update(&key(KeyCode::Esc)).unwrap();
p.update(&key(KeyCode::Enter)).unwrap();
let buf = render(&mut p, 120, 40);
let all: String = (0..40)
.map(|y| row_text(&buf, y))
@@ -2104,9 +2131,9 @@ mod tests {
assert!(all.contains("theme = alpha"));
assert!(all.contains("config-file = ?auto/theme.ghostty"));
p.update(&key(KeyCode::Esc));
p.update(&ch('/'));
p.update(&ch('b'));
p.update(&key(KeyCode::Esc)).unwrap();
p.update(&ch('/')).unwrap();
p.update(&ch('b')).unwrap();
let mut buf = Buffer::empty(Rect::new(0, 0, 120, 40));
let cursor = p.draw(&mut buf);
assert!(cursor.is_some());
@@ -2116,18 +2143,18 @@ mod tests {
fn empty_filter_shows_no_theme_found() {
let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp);
p.update(&ch('/'));
p.update(&ch('z'));
p.update(&ch('z'));
p.update(&ch('/')).unwrap();
p.update(&ch('z')).unwrap();
p.update(&ch('z')).unwrap();
assert_eq!(0, p.filtered.len());
let buf = render(&mut p, 80, 20);
assert!(row_text(&buf, 9).contains("No theme found!"));
// Navigation does not panic with an empty list.
p.update(&key(KeyCode::Enter));
p.update(&key(KeyCode::Esc));
p.update(&key(KeyCode::End));
p.update(&ch('j'));
p.update(&ch('c'));
p.update(&key(KeyCode::Enter)).unwrap();
p.update(&key(KeyCode::Esc)).unwrap();
p.update(&key(KeyCode::End)).unwrap();
p.update(&ch('j')).unwrap();
p.update(&ch('c')).unwrap();
assert_eq!(0, p.current);
}
@@ -2157,17 +2184,19 @@ mod tests {
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);
assert_eq!(2, p.current);
p.update(&mouse(MouseEventKind::ScrollUp, 0));
p.update(&mouse(MouseEventKind::ScrollUp, 0)).unwrap();
render(&mut p, 120, 30);
assert_eq!(1, p.current);
p.update(&mouse(MouseEventKind::ScrollDown, 0));
p.update(&mouse(MouseEventKind::ScrollDown, 0)).unwrap();
render(&mut p, 120, 30);
assert_eq!(2, p.current);
// Click below the last theme does nothing.
p.update(&mouse(MouseEventKind::Up(MouseButton::Left), 20));
p.update(&mouse(MouseEventKind::Up(MouseButton::Left), 20))
.unwrap();
render(&mut p, 120, 30);
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 refs: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), "#000000")).collect();
let themes = make_themes(tmp.path(), &refs);
let mut p = Preview::new(themes, ColorScheme::All);
let mut p = Preview::new(themes, ColorScheme::All).unwrap();
render(&mut p, 100, 10);
p.update(&key(KeyCode::PageDown));
p.update(&key(KeyCode::PageDown)).unwrap();
let buf = render(&mut p, 100, 10);
assert_eq!(20, p.current);
assert_eq!(11, p.window);
@@ -2215,6 +2244,163 @@ mod tests {
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]
fn base64_encoding() {
assert_eq!("", base64(b""));
+8 -3
View File
@@ -62,7 +62,7 @@ impl Options {
.map_err(|e| ArgError::Other(e.to_string()))?;
let realpath = expanded
.to_path()
.and_then(std::fs::canonicalize)
.and_then(ghostty_os::path::realpath)
.map_err(|e| ArgError::Other(e.to_string()))?;
self.working_directory_seen = true;
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 {
// 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(
0,
[
@@ -309,8 +309,13 @@ mod tests {
fn working_directory_is_resolved() {
let o = parse_str("--working-directory=/");
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!(
vec!["--working-directory=/"],
vec![root.as_str()],
o.arguments
.iter()
.map(|v| std::str::from_utf8(v).unwrap())
+4
View File
@@ -216,6 +216,10 @@ mod tests {
}
#[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() {
let tmp = tempfile::tempdir().unwrap();
let cache = DiskCache {
@@ -416,6 +416,10 @@ mod tests {
}
#[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() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("cache");
@@ -438,6 +442,10 @@ mod tests {
}
#[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() {
let tmp = tempfile::tempdir().unwrap();
let cache = DiskCache {
@@ -467,6 +475,10 @@ mod tests {
}
#[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() {
let tmp = tempfile::tempdir().unwrap();
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 {
// Byte-preserving: the option string carries non-UTF-8 bytes via the
// 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_recursive_files()?;
} else {
+22 -4
View File
@@ -4216,7 +4216,7 @@ impl Config {
// Any paths referenced from the CLI are relative to the current working
// directory.
let cwd = std::fs::canonicalize(".")?;
let cwd = crate::path::realpath(".")?;
self.expand_paths(&EncodedOs::from_path(&cwd))?;
Ok(())
}
@@ -5115,12 +5115,22 @@ mod tests {
fn write_theme(td: &TempDir, name: &str, data: &str) -> String {
let p = td.path().join(name);
std::fs::write(&p, data).unwrap();
std::fs::canonicalize(&p)
crate::path::realpath(&p)
.unwrap()
.to_string_lossy()
.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]
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 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.finalize().unwrap();
@@ -5407,7 +5421,11 @@ mod tests {
let td = TempDir::init().unwrap();
let light = write_theme(&td, "theme_light", include_str!("testdata/theme_light"));
let dark = write_theme(&td, "theme_dark", include_str!("testdata/theme_dark"));
let arg = format!("--theme=light:{light},dark:{dark}");
let arg = format!(
"--theme=light:{},dark:{}",
pair_value(&light),
pair_value(&dark)
);
// Light
{
+9 -1
View File
@@ -211,7 +211,15 @@ mod tests {
open(&e("/nonexistent/ghostty/file")).unwrap_err(),
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()));
std::fs::write(&p, "").unwrap();
assert_eq!(
+23 -4
View File
@@ -158,7 +158,7 @@ impl Path {
std::fs::read_dir(&base_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),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
// 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)]
#[allow(clippy::unwrap_used)]
mod tests {
@@ -391,7 +403,14 @@ mod tests {
let mut diags = DiagnosticList::default();
let mut p = Path::Required(e("../nope/x.conf"));
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"));
p.expand(&e("/tmp"), &mut diags).unwrap();
@@ -403,7 +422,7 @@ mod tests {
#[test]
fn expand_interior_nul_resolves_the_c_string_prefix_like_zig() {
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();
// 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() {
use std::os::unix::ffi::OsStrExt;
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"));
std::fs::write(&target, b"x").unwrap();
std::os::unix::fs::symlink(&target, base.join("link")).unwrap();
+23 -5
View File
@@ -381,9 +381,14 @@ mod tests {
);
let mut d = DiagnosticList::default();
assert!(open(&e("/tmp"), None, &mut d).is_none());
// Windows cannot open a directory as a file (AccessDenied).
assert_eq!(
d.items()[0].message,
"not reading theme from \"/tmp\": it is a directory"
if cfg!(windows) {
"failed to load theme from the path \"/tmp\": error.AccessDenied"
} else {
"not reading theme from \"/tmp\": it is a directory"
}
);
}
@@ -469,7 +474,11 @@ mod tests {
let mut d = DiagnosticList::default();
assert!(open(&e(name), Some(&EncodedOs::from_path(&dir)), &mut d).is_none());
assert!(
d.items()[0].message.ends_with("it is a directory"),
d.items()[0].message.ends_with(if cfg!(windows) {
"error.AccessDenied"
} else {
"it is a directory"
}),
"{}",
d.items()[0].message
);
@@ -484,7 +493,10 @@ mod tests {
std::fs::write(dir.join("themes/mytheme-zzz"), "background = #000000\n").unwrap();
let mut d = DiagnosticList::default();
let t = open(&e("mytheme-zzz"), Some(&EncodedOs::from_path(&dir)), &mut d).unwrap();
assert!(t.path.ends_with_plain("themes/mytheme-zzz"));
assert!(
t.path
.ends_with_plain(&format!("themes{}mytheme-zzz", std::path::MAIN_SEPARATOR))
);
assert!(d.is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -493,7 +505,13 @@ mod tests {
fn theme_not_found_lists_tried_paths() {
let mut d = DiagnosticList::default();
assert!(open(&e("nope-zzz"), Some(&e("/nonexistent-res")), &mut d).is_none());
assert!(d.items().iter().any(|x| x.message
== "theme \"nope-zzz\" not found, tried path \"/nonexistent-res/themes/nope-zzz\""));
let tried = std::path::Path::new("/nonexistent-res")
.join("themes")
.join("nope-zzz");
let want = format!(
"theme \"nope-zzz\" not found, tried path \"{}\"",
tried.display()
);
assert!(d.items().iter().any(|x| x.message == want));
}
}
+1 -1
View File
@@ -445,7 +445,7 @@ pub fn write_screen_temp_file(
let mut file = opts.open(&file_path)?;
file.write_all(contents.as_bytes())?;
file.flush()?;
Ok(std::fs::canonicalize(&file_path)?)
Ok(ghostty_os::path::realpath(&file_path)?)
})();
match result {
+4
View File
@@ -47,6 +47,10 @@ objc2-core-graphics = "0.3.2"
objc2-core-text = "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]
default = []
# CoreText for discovery, FreeType for rendering, HarfBuzz for shaping
+48
View File
@@ -24,6 +24,7 @@
fn main() {
println!("cargo:rerun-if-changed=build.rs");
build_harfbuzz_freetype_glue();
for c in [
"font_freetype",
"font_fontconfig",
@@ -77,3 +78,50 @@ fn main() {
on("font_coretext_shaper", 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]
#[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() {
assert!(matches!(
init(std::path::Path::new("ghostty")),
+51
View File
@@ -25,6 +25,39 @@ pub enum 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
/// bytes are carried via [`crate::osbytes`]; see [`expand_os`].
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 {
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.
fn expand(cmd: &str) -> Result<Option<String>, Error> {
super::expand(&crate::osbytes::EncodedOs::from_plain_text(cmd))
+7 -1
View File
@@ -180,7 +180,13 @@ mod tests {
let _g = LOCK.lock().unwrap();
detect().unwrap();
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);
}
@@ -45,6 +45,17 @@ fn os_path_from_bytes(bytes: &[u8]) -> Option<std::ffi::OsString> {
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::{
Command,
Display,
@@ -157,7 +168,7 @@ impl LoadingImage {
log::warn!("failed to get absolute path: BadPathName");
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,
Err(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
// 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())
{
return true;
@@ -779,11 +790,7 @@ mod tests {
fn write(&self, name: &str, data: &[u8]) -> String {
let p = self.0.join(name);
std::fs::write(&p, data).unwrap();
std::fs::canonicalize(p)
.unwrap()
.to_str()
.unwrap()
.to_owned()
realpath(p).unwrap().to_str().unwrap().to_owned()
}
}
impl Drop for TmpDir {
@@ -1032,17 +1039,23 @@ mod tests {
#[test]
fn image_load_rejects_proc_and_nul_paths() {
let c = cmd(
Transmission {
format: TransmissionFormat::Rgb,
medium: TransmissionMedium::File,
width: 1,
height: 1,
..Transmission::default()
},
b"/proc/self/status",
);
assert_eq!(LoadingImage::init(&c).unwrap_err(), Error::InvalidData);
// 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(
Transmission {
format: TransmissionFormat::Rgb,
medium: TransmissionMedium::File,
width: 1,
height: 1,
..Transmission::default()
},
b"/proc/self/status",
);
assert_eq!(LoadingImage::init(&c).unwrap_err(), Error::InvalidData);
}
let c = cmd(
Transmission {
medium: TransmissionMedium::File,
+1
View File
@@ -44,6 +44,7 @@ windows-sys = { version = "0.61", features = [
# and only allowed on minimal, SAFETY-commented items.
[lints.rust]
unsafe_code = "deny"
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(wine)"] }
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
+17 -7
View File
@@ -452,7 +452,9 @@ mod tests {
fn new(shell: Shell) -> Self {
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()
.to_string_lossy()
.into_owned();
@@ -814,7 +816,7 @@ mod tests {
#[test]
fn bash_missing_resources() {
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();
assert!(
setup_bash(&sh("bash"), &EncodedOs::from_path(&dir), &mut env)
@@ -838,7 +840,11 @@ mod tests {
.unwrap()
);
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()
);
}
@@ -858,7 +864,11 @@ mod tests {
.unwrap()
);
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()
);
}
@@ -866,7 +876,7 @@ mod tests {
#[test]
fn xdg_missing_resources() {
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();
assert!(!setup_xdg_data_dirs(&EncodedOs::from_path(&dir), &mut env));
assert_eq!(0, env.len());
@@ -914,7 +924,7 @@ mod tests {
#[test]
fn nushell_missing_resources() {
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();
assert!(setup_nushell(&sh("nu"), &EncodedOs::from_path(&dir), &mut env).is_none());
assert_eq!(0, env.len());
@@ -955,7 +965,7 @@ mod tests {
#[test]
fn zsh_missing_resources() {
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();
assert!(setup_zsh(&sh("zsh"), &EncodedOs::from_path(&dir), &mut env).is_none());
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)
These are known minor differences between the Rust port and Ghostty 1.3.1
found in the final audit (RV18, `notes/review-rv18-codex.md`). They were
recorded, not fixed, because the stopping criterion in `PLAN.md` does not
require chasing P3 items. All are severity P3. Each was verified from source
and, where noted, by PTY reproduction.
| # | 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 |
All previously listed differences (rows 5-10: theme-search word navigation,
grapheme backspace, ASCII-only case folding, initial preview colors, load_file
error propagation, subprocess cleanup wait) have been fixed; see
`notes/final-P3.md` (row 10 is registered as a deviant fix in
`DEVIANT_FIXES.md`). No known differences are currently recorded here.
## 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`).
- 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).
- Live portal / D-Bus integration.
- 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:
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.
check-wasm:
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");
}