diff --git a/Cargo.lock b/Cargo.lock index f515728..a38cf3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 24d0d95..755ded4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/README.md b/README.md index e5998f4..c6a5a64 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/ghostty-cli/Cargo.toml b/crates/ghostty-cli/Cargo.toml index a235b26..e3d19dc 100644 --- a/crates/ghostty-cli/Cargo.toml +++ b/crates/ghostty-cli/Cargo.toml @@ -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 diff --git a/crates/ghostty-cli/src/list_themes.rs b/crates/ghostty-cli/src/list_themes.rs index dcb612e..4b2ff16 100644 --- a/crates/ghostty-cli/src/list_themes.rs +++ b/crates/ghostty-cli/src/list_themes.rs @@ -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) ); } diff --git a/crates/ghostty-cli/src/list_themes/preview.rs b/crates/ghostty-cli/src/list_themes/preview.rs index 2754f95..cc29f51 100644 --- a/crates/ghostty-cli/src/list_themes/preview.rs +++ b/crates/ghostty-cli/src/list_themes/preview.rs @@ -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 { - let name: Vec = name.to_lowercase().chars().collect(); + let name: Vec = name.to_ascii_lowercase().into_bytes(); let mut total = 0.0; for token in tokens { - let token: Vec = 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, } -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::().ok()) { - return if bg <= 6 || bg == 8 { - Scheme::Dark - } else { - Scheme::Light - }; - } - } - Scheme::Light -} - impl Preview { - #[must_use] - pub fn new(themes: Vec, theme_filter: ColorScheme) -> Preview { + pub fn new(themes: Vec, theme_filter: ColorScheme) -> Result { 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 = 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::()); + 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, 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 = (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"")); diff --git a/crates/ghostty-cli/src/new_window.rs b/crates/ghostty-cli/src/new_window.rs index ba26b29..d45d2f2 100644 --- a/crates/ghostty-cli/src/new_window.rs +++ b/crates/ghostty-cli/src/new_window.rs @@ -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()) diff --git a/crates/ghostty-cli/src/ssh_cache.rs b/crates/ghostty-cli/src/ssh_cache.rs index 09654a6..4eea8d2 100644 --- a/crates/ghostty-cli/src/ssh_cache.rs +++ b/crates/ghostty-cli/src/ssh_cache.rs @@ -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 { diff --git a/crates/ghostty-cli/src/ssh_cache/disk_cache.rs b/crates/ghostty-cli/src/ssh_cache/disk_cache.rs index 76b3559..222af6d 100644 --- a/crates/ghostty-cli/src/ssh_cache/disk_cache.rs +++ b/crates/ghostty-cli/src/ssh_cache/disk_cache.rs @@ -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 { diff --git a/crates/ghostty-cli/src/validate_config.rs b/crates/ghostty-cli/src/validate_config.rs index 7e13de4..11c2124 100644 --- a/crates/ghostty-cli/src/validate_config.rs +++ b/crates/ghostty-cli/src/validate_config.rs @@ -42,7 +42,7 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result { 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 { diff --git a/crates/ghostty-config/src/config.rs b/crates/ghostty-config/src/config.rs index b717767..3cefeda 100644 --- a/crates/ghostty-config/src/config.rs +++ b/crates/ghostty-config/src/config.rs @@ -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 { diff --git a/crates/ghostty-config/src/file_load.rs b/crates/ghostty-config/src/file_load.rs index 33dd3c4..e0a3fba 100644 --- a/crates/ghostty-config/src/file_load.rs +++ b/crates/ghostty-config/src/file_load.rs @@ -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!( diff --git a/crates/ghostty-config/src/path.rs b/crates/ghostty-config/src/path.rs index d84a978..47bb1d1 100644 --- a/crates/ghostty-config/src/path.rs +++ b/crates/ghostty-config/src/path.rs @@ -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::io::Result { + ghostty_os::path::realpath(path) +} + +#[cfg(not(windows))] +pub(crate) fn realpath(path: impl AsRef) -> std::io::Result { + 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(); diff --git a/crates/ghostty-config/src/theme.rs b/crates/ghostty-config/src/theme.rs index 318878b..2cb9d06 100644 --- a/crates/ghostty-config/src/theme.rs +++ b/crates/ghostty-config/src/theme.rs @@ -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)); } } diff --git a/crates/ghostty-core/src/surface/binding.rs b/crates/ghostty-core/src/surface/binding.rs index 662e8e5..df1175a 100644 --- a/crates/ghostty-core/src/surface/binding.rs +++ b/crates/ghostty-core/src/surface/binding.rs @@ -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 { diff --git a/crates/ghostty-font/Cargo.toml b/crates/ghostty-font/Cargo.toml index 368b51b..8be5062 100644 --- a/crates/ghostty-font/Cargo.toml +++ b/crates/ghostty-font/Cargo.toml @@ -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 diff --git a/crates/ghostty-font/build.rs b/crates/ghostty-font/build.rs index a6bfe12..40bde78 100644 --- a/crates/ghostty-font/build.rs +++ b/crates/ghostty-font/build.rs @@ -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; + }; + // `/harfbuzz-sys-X/harfbuzz/src` -> `/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"); +} diff --git a/crates/ghostty-os/src/i18n.rs b/crates/ghostty-os/src/i18n.rs index d9df52d..9d26c20 100644 --- a/crates/ghostty-os/src/i18n.rs +++ b/crates/ghostty-os/src/i18n.rs @@ -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")), diff --git a/crates/ghostty-os/src/path.rs b/crates/ghostty-os/src/path.rs index 40ac59f..a2dae66 100644 --- a/crates/ghostty-os/src/path.rs +++ b/crates/ghostty-os/src/path.rs @@ -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) -> io::Result { + 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, 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, Error> { super::expand(&crate::osbytes::EncodedOs::from_plain_text(cmd)) diff --git a/crates/ghostty-os/src/xev.rs b/crates/ghostty-os/src/xev.rs index 1b7e3e4..fb590f6 100644 --- a/crates/ghostty-os/src/xev.rs +++ b/crates/ghostty-os/src/xev.rs @@ -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); } diff --git a/crates/ghostty-terminal/src/kitty/graphics_image.rs b/crates/ghostty-terminal/src/kitty/graphics_image.rs index bb7411a..e4efbc3 100644 --- a/crates/ghostty-terminal/src/kitty/graphics_image.rs +++ b/crates/ghostty-terminal/src/kitty/graphics_image.rs @@ -45,6 +45,17 @@ fn os_path_from_bytes(bytes: &[u8]) -> Option { 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) -> std::io::Result { + ghostty_os::path::realpath(path) +} + +#[cfg(not(windows))] +fn realpath(path: impl AsRef) -> std::io::Result { + 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) -> // 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, diff --git a/crates/ghostty-termio/Cargo.toml b/crates/ghostty-termio/Cargo.toml index 0a5c990..87c1e00 100644 --- a/crates/ghostty-termio/Cargo.toml +++ b/crates/ghostty-termio/Cargo.toml @@ -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 } diff --git a/crates/ghostty-termio/src/shell_integration.rs b/crates/ghostty-termio/src/shell_integration.rs index 90b39c8..ef5b8ba 100644 --- a/crates/ghostty-termio/src/shell_integration.rs +++ b/crates/ghostty-termio/src/shell_integration.rs @@ -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()); diff --git a/crates/ghostty-termio/tests/exec_windows.rs b/crates/ghostty-termio/tests/exec_windows.rs new file mode 100644 index 0000000..225b7c3 --- /dev/null +++ b/crates/ghostty-termio/tests/exec_windows.rs @@ -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); + +impl SurfaceMailbox for SurfaceRec { + fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize { + let _ = self.0.send(msg); + 1 + } +} + +#[derive(Default)] +struct RendererRec { + resizes: Mutex>, + 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>, + surface: Receiver, + renderer: Arc, +} + +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(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option) -> 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 `. + 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(); +} diff --git a/docs/rewrite/KNOWN_DIFFERENCES.md b/docs/rewrite/KNOWN_DIFFERENCES.md index 4e4fb01..a441abf 100644 --- a/docs/rewrite/KNOWN_DIFFERENCES.md +++ b/docs/rewrite/KNOWN_DIFFERENCES.md @@ -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). diff --git a/docs/rewrite/notes/final-P3.md b/docs/rewrite/notes/final-P3.md new file mode 100644 index 0000000..eb5ae16 --- /dev/null +++ b/docs/rewrite/notes/final-P3.md @@ -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. diff --git a/docs/rewrite/notes/windows-wine.md b/docs/rewrite/notes/windows-wine.md new file mode 100644 index 0000000..43b65fb --- /dev/null +++ b/docs/rewrite/notes/windows-wine.md @@ -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. diff --git a/justfile b/justfile index e495f07..c332134 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/scripts/windows-wine-test.sh b/scripts/windows-wine-test.sh new file mode 100755 index 0000000..2ed7542 --- /dev/null +++ b/scripts/windows-wine-test.sh @@ -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" </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[@]}" diff --git a/tests/list_themes_pty.rs b/tests/list_themes_pty.rs new file mode 100644 index 0000000..8f43e9e --- /dev/null +++ b/tests/list_themes_pty.rs @@ -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>, + output: Vec, + /// 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 { + 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 { + 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 { + 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 { + 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 { + // 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, 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"); +}