use std::cell::RefCell;
use std::io;
use std::rc::Rc;
use ratatui::backend::{Backend as RatatuiBackend, ClearType, CrosstermBackend};
use ratatui::layout::{Position as RtPosition, Rect as RtRect};
use ratatui::{Terminal, TerminalOptions, Viewport};
use crate::backend::Backend;
use crate::runner::{AppLogic, Reaction};
use crate::shell::{ShellApp, ShellConfig};
use crate::testing::{Anchor, ConformanceDriver, FrameInventory, LogicalViewport, TextRun};
use crate::tui::backend::TuiBackend;
use crate::tui::run::{dispatch_event, paint_frame, EventOutcome};
use crate::{ButtonMask, Key, Modifiers, MouseButton, NamedKey, Point, Rect, ScrollDelta, UiEvent};
struct VtSink(Rc<RefCell<vt100::Parser>>);
impl io::Write for VtSink {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.borrow_mut().process(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
pub fn driver_with_shell<A: ShellApp + 'static>(
app: A,
config: ShellConfig,
width: u16,
height: u16,
) -> TuiVtDriver<impl AppLogic> {
let adapter = crate::shell_adapter::build_shell_adapter(app, config);
TuiVtDriver::new(adapter, width, height)
}
pub struct TuiVtDriver<A: AppLogic> {
app: A,
backend: TuiBackend,
terminal: Terminal<CrosstermBackend<VtSink>>,
parser: Rc<RefCell<vt100::Parser>>,
cols: u16,
rows: u16,
exited: bool,
}
impl<A: AppLogic> TuiVtDriver<A> {
pub fn new(app: A, width: u16, height: u16) -> Self {
let parser = Rc::new(RefCell::new(vt100::Parser::new(height, width, 0)));
let sink = VtSink(Rc::clone(&parser));
let crossterm_backend = CrosstermBackend::new(sink);
let terminal = Terminal::with_options(
crossterm_backend,
TerminalOptions {
viewport: Viewport::Fixed(RtRect::new(0, 0, width, height)),
},
)
.expect("CrosstermBackend + Viewport::Fixed never queries a real terminal");
let mut backend = TuiBackend::new();
backend.begin_frame(crate::Viewport::new(width as f32, height as f32, 1.0));
let mut app = app;
app.setup(&mut backend);
let mut driver = Self {
app,
backend,
terminal,
parser,
cols: width,
rows: height,
exited: false,
};
driver.render();
driver
}
pub fn render(&mut self) {
let full_repaint = self.backend.take_full_repaint_requested();
if full_repaint {
self.force_full_repaint();
}
paint_frame(&mut self.terminal, &mut self.backend, &self.app)
.expect("CrosstermBackend render into an in-memory vt100 sink is infallible");
}
fn force_full_repaint(&mut self) {
let sink = VtSink(Rc::clone(&self.parser));
let crossterm_backend = CrosstermBackend::new(sink);
let mut terminal = Terminal::with_options(
crossterm_backend,
TerminalOptions {
viewport: Viewport::Fixed(RtRect::new(0, 0, self.cols, self.rows)),
},
)
.expect("CrosstermBackend + Viewport::Fixed never queries a real terminal");
RatatuiBackend::set_cursor_position(terminal.backend_mut(), RtPosition::new(0, 0))
.expect("MoveTo write into an in-memory vt100 sink is infallible");
RatatuiBackend::clear_region(terminal.backend_mut(), ClearType::All)
.expect("Clear(All) write into an in-memory vt100 sink is infallible");
self.terminal = terminal;
}
pub fn inject_raw(&self, bytes: &[u8]) {
self.parser.borrow_mut().process(bytes);
}
pub fn dispatch(&mut self, event: UiEvent) -> Reaction {
if self.exited {
return Reaction::Exit;
}
let mut result = Reaction::Continue;
for ev in self.backend.translate_injected(vec![event]) {
let outcome = dispatch_event(ev, &mut self.backend, &mut self.app);
match &outcome {
EventOutcome::Continue => {}
EventOutcome::Redraw => self.render(),
EventOutcome::RedrawAfter(d) => {
self.backend.request_frame_in(*d);
}
EventOutcome::Exit => {
self.exited = true;
return Reaction::Exit;
}
}
result = result.merge(outcome.into());
}
result
}
pub fn press(&mut self, key: Key) -> Reaction {
self.dispatch(UiEvent::KeyPressed {
key,
modifiers: Modifiers::default(),
repeat: false,
})
}
pub fn type_char(&mut self, c: char) -> Reaction {
self.press(Key::Char(c))
}
pub fn press_named(&mut self, key: NamedKey) -> Reaction {
self.press(Key::Named(key))
}
pub fn ctrl_char(&mut self, c: char) -> Reaction {
self.dispatch(UiEvent::KeyPressed {
key: Key::Char(c),
modifiers: Modifiers {
ctrl: true,
..Modifiers::default()
},
repeat: false,
})
}
pub fn click(&mut self, x: f32, y: f32) -> Reaction {
self.mouse_down(x, y)
}
pub fn mouse_down(&mut self, x: f32, y: f32) -> Reaction {
self.dispatch(UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(x, y),
modifiers: Modifiers::default(),
})
}
pub fn mouse_move(&mut self, x: f32, y: f32) -> Reaction {
self.dispatch(UiEvent::MouseMoved {
position: Point::new(x, y),
buttons: ButtonMask {
left: true,
..ButtonMask::default()
},
})
}
pub fn mouse_up(&mut self, x: f32, y: f32) -> Reaction {
self.dispatch(UiEvent::MouseUp {
widget: None,
button: MouseButton::Left,
position: Point::new(x, y),
})
}
pub fn drag(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) -> Reaction {
self.mouse_down(x0, y0);
self.mouse_move(x1, y1);
self.mouse_up(x1, y1)
}
pub fn exited(&self) -> bool {
self.exited
}
pub fn screen(&self) -> String {
let parser = self.parser.borrow();
let screen = parser.screen();
let (rows, cols) = screen.size();
let mut out = String::with_capacity((rows as usize) * (cols as usize + 1));
for y in 0..rows {
for x in 0..cols {
let Some(cell) = screen.cell(y, x) else {
continue;
};
if cell.is_wide_continuation() {
continue;
}
let s = cell.contents();
out.push_str(if s.is_empty() { " " } else { s });
}
out.push('\n');
}
out
}
pub fn screen_contains(&self, needle: &str) -> bool {
self.screen().contains(needle)
}
fn row_cells(&self, y: u16) -> Vec<(char, u16, u16)> {
let parser = self.parser.borrow();
let screen = parser.screen();
let (_, cols) = screen.size();
let mut cells = Vec::with_capacity(cols as usize);
let mut x = 0u16;
while x < cols {
let Some(cell) = screen.cell(y, x) else {
break;
};
if cell.is_wide_continuation() {
x += 1;
continue;
}
let w = if cell.is_wide() { 2 } else { 1 };
let ch = cell.contents().chars().next().unwrap_or(' ');
cells.push((ch, x, w));
x += w;
}
cells
}
pub fn find_bounds(&self, needle: &str) -> Option<Rect> {
let needle: Vec<char> = needle.chars().collect();
if needle.is_empty() {
return None;
}
let rows = self.parser.borrow().screen().size().0;
for y in 0..rows {
let cells = self.row_cells(y);
if cells.len() < needle.len() {
continue;
}
if let Some(start) = cells
.windows(needle.len())
.position(|w| w.iter().map(|(c, _, _)| *c).eq(needle.iter().copied()))
{
let (_, start_x, _) = cells[start];
let (_, last_x, last_w) = cells[start + needle.len() - 1];
let width = (last_x + last_w).saturating_sub(start_x);
return Some(Rect::new(start_x as f32, y as f32, width as f32, 1.0));
}
}
None
}
pub fn find(&self, needle: &str) -> Option<(f32, f32)> {
self.find_bounds(needle)
.map(|b| (b.x + 0.5, b.y + b.height / 2.0))
}
}
impl<A: AppLogic> ConformanceDriver for TuiVtDriver<A> {
type App = A;
fn new_fixture(app: Self::App, viewport: LogicalViewport) -> Self {
TuiVtDriver::new(app, viewport.cols as u16, viewport.rows as u16)
}
fn backend_caps(&self) -> crate::BackendCaps {
Backend::backend_caps(&self.backend)
}
fn press_named(&mut self, key: NamedKey) {
TuiVtDriver::press_named(self, key);
}
fn type_char(&mut self, c: char) {
TuiVtDriver::type_char(self, c);
}
fn ctrl_char(&mut self, c: char) {
TuiVtDriver::ctrl_char(self, c);
}
fn click_text_at(&mut self, needle: &str, at: Anchor) {
let bounds = self
.find_bounds(needle)
.unwrap_or_else(|| panic!("TuiVtDriver: {needle:?} not painted:\n{}", self.screen()));
let y = bounds.y + bounds.height / 2.0;
let x = match at {
Anchor::Center => bounds.x + bounds.width / 2.0,
Anchor::LeftEdge => bounds.x + 0.5,
Anchor::RightEdge => bounds.x + bounds.width - 0.5,
};
self.click(x, y);
}
fn drag_text(&mut self, from: &str, to: &str) {
let (x0, y0) = self
.find(from)
.unwrap_or_else(|| panic!("TuiVtDriver: {from:?} not painted:\n{}", self.screen()));
let (x1, y1) = self
.find(to)
.unwrap_or_else(|| panic!("TuiVtDriver: {to:?} not painted:\n{}", self.screen()));
self.drag(x0, y0, x1, y1);
}
fn scroll_at(&mut self, needle: &str, lines: i32) {
let (x, y) = self
.find(needle)
.unwrap_or_else(|| panic!("TuiVtDriver: {needle:?} not painted:\n{}", self.screen()));
let line_height = self.backend.line_height();
self.dispatch(UiEvent::Scroll {
widget: None,
delta: ScrollDelta::new(0.0, lines as f32 * line_height),
position: Point::new(x, y),
});
}
fn inventory(&self) -> FrameInventory {
let rows = self.parser.borrow().screen().size().0;
let mut text_runs = Vec::new();
for y in 0..rows {
let cells = self.row_cells(y);
let mut run: Option<(String, u16, u16)> = None; for (ch, x, w) in cells {
if ch == ' ' {
if let Some((text, start_x, end_x)) = run.take() {
text_runs.push(TextRun {
text,
bounds: Rect::new(
start_x as f32,
y as f32,
(end_x - start_x) as f32,
1.0,
),
});
}
continue;
}
match &mut run {
Some((text, _start_x, end_x)) => {
text.push(ch);
*end_x = x + w;
}
None => run = Some((ch.to_string(), x, x + w)),
}
}
if let Some((text, start_x, end_x)) = run {
text_runs.push(TextRun {
text,
bounds: Rect::new(start_x as f32, y as f32, (end_x - start_x) as f32, 1.0),
});
}
}
FrameInventory {
text_runs,
zones: self.backend.zones().to_vec(),
}
}
fn screen_has(&self, needle: &str) -> bool {
self.screen_contains(needle)
}
fn exited(&self) -> bool {
TuiVtDriver::exited(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::status_bar::{StatusBar, StatusBarSegment};
use crate::types::{Color, WidgetId};
use crate::Rect as QRect;
struct OneLineApp {
text: &'static str,
}
impl AppLogic for OneLineApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_status_bar_interactive(
QRect::new(0.0, 0.0, 30.0, 1.0),
&StatusBar {
id: WidgetId::new("status"),
left_segments: vec![StatusBarSegment {
text: self.text.to_string(),
fg: Color::rgb(255, 255, 255),
bg: Color::rgb(0, 0, 0),
bold: false,
action_id: None,
}],
right_segments: vec![],
},
&crate::InteractionState::new(),
);
}
fn handle(&mut self, _event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
Reaction::Continue
}
}
#[test]
fn conformance_driver_new_fixture_scroll_at_and_inventory_round_trip() {
let driver: TuiVtDriver<OneLineApp> = ConformanceDriver::new_fixture(
OneLineApp {
text: "你好 world"
},
LogicalViewport::new(30, 3),
);
let inv = ConformanceDriver::inventory(&driver);
let texts: Vec<&str> = inv.text_runs().iter().map(|t| t.text.as_str()).collect();
assert!(
texts.contains(&"你好") && texts.contains(&"world"),
"vt100-observed inventory should synthesize a TextRun per whitespace-separated \
run, wide-char aware: {texts:?} (screen:\n{})",
driver.screen()
);
let mut driver = driver;
driver.scroll_at("world", 1);
}
#[test]
fn find_bounds_is_wide_char_aware_for_adjacent_cjk_glyphs() {
let driver = TuiVtDriver::new(
OneLineApp {
text: "你好 world"
},
30,
3,
);
let cjk = driver.find_bounds("你好").expect(
"adjacent double-width glyphs should match as one span over the vt100 screen too",
);
assert_eq!((cjk.x, cjk.width), (0.0, 4.0));
let world = driver
.find_bounds("world")
.expect("world should be found after the CJK run");
assert_eq!(world.x, cjk.x + cjk.width + 1.0);
}
#[test]
fn exited_latches_after_reaction_exit() {
struct QuitOnQ;
impl AppLogic for QuitOnQ {
type AreaId = ();
fn render(&self, _backend: &mut dyn Backend, _area: ()) {}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
match event {
UiEvent::KeyPressed {
key: Key::Char('q'),
..
} => Reaction::Exit,
_ => Reaction::Continue,
}
}
}
let mut driver = TuiVtDriver::new(QuitOnQ, 10, 3);
assert!(!driver.exited());
driver.type_char('q');
assert!(driver.exited());
}
#[test]
fn ctrl_l_forces_a_full_repaint_through_the_public_shell_driver_seam() {
use crate::compose::app_shell::AppShellLayout;
use crate::{Key, Modifiers, ShellApp, ShellConfig, ShellContext};
struct CtrlLRepaints;
impl ShellApp for CtrlLRepaints {
fn render_content(&self, _backend: &mut dyn Backend, _layout: &AppShellLayout) {}
fn handle(
&mut self,
event: UiEvent,
backend: &mut dyn Backend,
_ctx: &ShellContext,
) -> Reaction {
if let UiEvent::KeyPressed {
key: Key::Char('l'),
modifiers: Modifiers { ctrl: true, .. },
..
} = event
{
backend.request_full_repaint();
return Reaction::Redraw;
}
Reaction::Continue
}
}
let mut driver = driver_with_shell(CtrlLRepaints, ShellConfig::new("t", vec![]), 10, 3);
driver.inject_raw(b"\x1b[2;1HX");
assert!(driver.screen_contains("X"), "sanity: stale glyph visible");
driver.render();
assert!(
driver.screen_contains("X"),
"a redraw with no full-repaint request pending must not touch \
cells the diff cache believes are unchanged"
);
driver.ctrl_char('l');
assert!(
!driver.screen_contains("X"),
"Ctrl+L must trigger request_full_repaint and force the stale \
glyph to clear, through the public driver_with_shell seam"
);
}
#[test]
fn render_actually_clears_stale_content_outside_the_diff_cache() {
let mut driver = TuiVtDriver::new(OneLineApp { text: "hi" }, 10, 3);
driver.inject_raw(b"\x1b[2;1HX");
assert!(
driver.screen_contains("X"),
"sanity: the injected stale glyph must be visible before either render"
);
driver.render();
assert!(
driver.screen_contains("X"),
"a redraw with no full-repaint request pending must not touch \
cells the diff cache believes are unchanged"
);
driver.backend.request_full_repaint();
driver.render();
assert!(
!driver.screen_contains("X"),
"render() must actually call Terminal::clear() when a full \
repaint was requested, not just consume the flag — the stale \
glyph should be gone once the real terminal receives a clear"
);
}
}