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