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

+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();
}