This commit is contained in:
2026-09-07 21:45:50 +02:00
commit 902a2e4c41
14 changed files with 6148 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
use crate::{config::Config, input, pane, render};
use base64::Engine;
use egui::{Event, Key, Modifiers};
use libghostty_vt::{
fmt,
kitty::graphics::{self, ImageFormat, PlacementIterator},
};
use std::{
cell::RefCell,
rc::Rc,
time::{Duration, Instant},
};
fn plain(term: &libghostty_vt::Terminal<'_, '_>) -> String {
let mut formatter =
fmt::Formatter::new(term, fmt::FormatterOptions::new().with_trim(true)).unwrap();
let bytes = formatter.format_alloc(None).unwrap();
String::from_utf8_lossy(&bytes).into_owned()
}
fn key(key: Key, modifiers: Modifiers) -> Event {
Event::Key {
key,
physical_key: Some(key),
pressed: true,
repeat: false,
modifiers,
}
}
#[test]
fn terminal_preserves_unicode_colors_and_application_input_modes() {
let mut term = pane::terminal(&Config::default()).unwrap();
term.vt_write("hello \x1b[31mred\x1b[0m \u{754c}e\u{301}\r\n".as_bytes());
assert!(plain(&term).contains("hello red \u{754c}e\u{301}"));
let events = [
key(Key::A, Modifiers::NONE),
Event::Text("a".into()),
key(Key::B, Modifiers::NONE),
Event::Text("b".into()),
];
assert_eq!(input::encode(&term, &events).unwrap(), b"ab");
assert_eq!(
input::encode(&term, &[key(Key::C, Modifiers::CTRL)]).unwrap(),
b"\x03"
);
assert_eq!(
input::encode(&term, &[key(Key::ArrowUp, Modifiers::NONE)]).unwrap(),
b"\x1b[A"
);
term.vt_write(b"\x1b[?1h");
assert_eq!(
input::encode(&term, &[key(Key::ArrowUp, Modifiers::NONE)]).unwrap(),
b"\x1bOA"
);
term.resize(12, 6, 9, 20).unwrap();
assert!(plain(&term).contains("hello red"));
}
#[test]
fn queries_return_to_the_pty_and_alternate_screen_restores() {
let mut term = pane::terminal(&Config::default()).unwrap();
let response = Rc::new(RefCell::new(vec![]));
let writer = response.clone();
term.on_pty_write(move |_, bytes| writer.borrow_mut().extend_from_slice(bytes))
.unwrap();
term.vt_write(b"hello\x1b[6n");
assert_eq!(*response.borrow(), b"\x1b[1;6R");
term.vt_write(b"\x1b[?1049hother screen\x1b[?1049l");
assert!(plain(&term).contains("hello"));
assert!(!plain(&term).contains("other screen"));
}
#[test]
fn kitty_images_decode_render_reuse_and_delete() {
graphics::set_png_decoder(Some(Box::new(render::PngDecoder))).unwrap();
let mut term = pane::terminal(&Config::default()).unwrap();
let replies = Rc::new(RefCell::new(vec![]));
let output = replies.clone();
term.on_pty_write(move |_, data| output.borrow_mut().extend_from_slice(data))
.unwrap();
// Split delivery across VT writes, including the control string terminator.
term.vt_write(b"\x1b_Ga=T,f=24,s=2,v=1,i=7,c=4,r=2;//8A");
term.vt_write(b"AP8A\x1b\\");
assert!(String::from_utf8_lossy(&replies.borrow()).contains("OK"));
{
let graphics = term.kitty_graphics().unwrap();
let image = graphics.image(7).expect("RGB image stored");
assert_eq!(image.format().unwrap(), ImageFormat::Rgb);
assert_eq!(image.data().unwrap(), [255, 255, 0, 0, 255, 0]);
let mut it = PlacementIterator::new().unwrap();
let mut ps = it.update(&graphics).unwrap();
let info = ps
.next()
.unwrap()
.placement_render_info(&image, &term)
.unwrap();
assert!(info.viewport_visible);
assert_eq!((info.pixel_width, info.pixel_height), (32, 32));
}
let mut png = vec![];
{
let mut encoder = png::Encoder::new(&mut png, 2, 1);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[255, 0, 0, 0, 255, 0])
.unwrap();
}
let payload = base64::engine::general_purpose::STANDARD.encode(png);
let split = payload.len() / 8 * 4;
term.vt_write(
format!(
"\x1b_Ga=T,f=100,i=8,c=2,r=1,m=1;{}\x1b\\",
&payload[..split]
)
.as_bytes(),
);
term.vt_write(format!("\x1b_Gm=0;{}\x1b\\", &payload[split..]).as_bytes());
{
let graphics = term.kitty_graphics().unwrap();
let image = graphics.image(8).expect("PNG image stored");
assert_eq!(image.format().unwrap(), ImageFormat::Rgba);
assert_eq!(image.data().unwrap(), [255, 0, 0, 255, 0, 255, 0, 255]);
}
let ctx = egui::Context::default();
let mut renderer = render::Renderer::new().unwrap();
let rect = egui::Rect::from_min_size(egui::pos2(50.0, 60.0), egui::vec2(400.0, 240.0));
let mut paint = || {
ctx.run(egui::RawInput::default(), |ctx| {
let painter = ctx
.layer_painter(egui::LayerId::background())
.with_clip_rect(rect);
renderer
.paint(
&term,
&painter,
rect,
egui::vec2(8.0, 16.0),
&egui::FontId::monospace(12.0),
true,
)
.unwrap();
})
};
let first = paint();
assert_eq!(
first
.textures_delta
.set
.iter()
.filter(|(id, _)| matches!(id,egui::TextureId::Managed(n) if *n>0))
.count(),
2
);
assert!(first.shapes.iter().all(|s| rect.contains_rect(s.clip_rect)));
let second = paint();
assert!(
second.textures_delta.set.is_empty(),
"unchanged images must reuse GPU textures"
);
term.vt_write(b"\x1b_Ga=d,d=I,i=7\x1b\\");
assert!(term.kitty_graphics().unwrap().image(7).is_none());
assert!(term.kitty_graphics().unwrap().image(8).is_some());
}
#[test]
fn pty_executes_input_and_bracketed_paste_is_sanitized() {
let config = Config {
shell: vec!["/bin/sh".into()],
..Default::default()
};
let ctx = egui::Context::default();
let mut pane = pane::Pane::new(&config, &ctx).unwrap();
pane.send(b"printf '\\033[31mpty-%s\\033[0m\\n' 'success'\r".to_vec())
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
pane.drain(&ctx);
if plain(&pane.terminal).contains("pty-success") {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(plain(&pane.terminal).contains("pty-success"));
pane.pty.resize(91, 31, 9, 20).unwrap();
pane.send(b"stty size\r".to_vec()).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
pane.drain(&ctx);
if plain(&pane.terminal).contains("31 91") {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(plain(&pane.terminal).contains("31 91"));
pane.send(b"exit\r".to_vec()).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while !pane.dead && Instant::now() < deadline {
pane.drain(&ctx);
std::thread::sleep(Duration::from_millis(10));
}
assert!(pane.dead);
assert!(pane.send(b"after exit".to_vec()).is_err());
}