use std::cell::RefCell;
use std::rc::Rc;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
pub use ratatui::style::{Color, Modifier};
use crate::backend::Backend;
use crate::runner::{AppLogic, Reaction};
use crate::shell::{ShellApp, ShellConfig};
use crate::testing::driver_core::DriverCore;
use crate::testing::{
Anchor, ConformanceDriver, DriverInput, FrameInventory, LogicalViewport, TextRun,
};
use crate::tui::backend::TuiBackend;
use crate::tui::run::{dispatch_event, render_frame, EventOutcome};
use crate::tui::text::char_cell_width;
use crate::{
ButtonMask, Key, Modifiers, MouseButton, NamedKey, Point, Rect, ScrollDelta, UiEvent, WidgetId,
};
pub fn driver_with_shell<A: ShellApp + 'static>(
app: A,
config: ShellConfig,
width: u16,
height: u16,
) -> TuiDriver<impl AppLogic> {
let adapter = crate::shell_adapter::build_shell_adapter(app, config);
TuiDriver::new(adapter, width, height)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct CellStyle {
pub fg: Color,
pub bg: Color,
pub modifiers: Modifier,
}
pub struct TuiDriver<A: AppLogic> {
core: DriverCore<TuiBackend, A>,
terminal: Rc<RefCell<Terminal<TestBackend>>>,
}
impl<A: AppLogic> TuiDriver<A> {
pub fn new(app: A, width: u16, height: u16) -> Self {
let terminal =
Terminal::new(TestBackend::new(width, height)).expect("TestBackend terminal");
let terminal = Rc::new(RefCell::new(terminal));
let mut backend = TuiBackend::new();
let dialog_surface: Rc<RefCell<dyn crate::tui::services::DialogSurface>> = terminal.clone();
backend.tui_services().set_dialog_surface(dialog_surface);
backend
.tui_services()
.queue_dialog_events(std::iter::empty());
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 {
core: DriverCore::new(app, backend),
terminal,
};
driver.render();
driver
}
pub fn queue_dialog_events(&self, events: impl IntoIterator<Item = UiEvent>) {
self.core
.backend()
.tui_services()
.queue_dialog_events(events);
}
pub fn render(&mut self) {
let full_repaint = self.core.backend_mut().take_full_repaint_requested();
let (backend, app) = self.core.parts_mut();
let mut terminal = self.terminal.borrow_mut();
if full_repaint {
terminal.clear().expect("TestBackend clear is infallible");
}
render_frame(&mut terminal, backend, app).expect("TestBackend render is infallible");
}
pub fn resize(&mut self, width: u16, height: u16) {
self.terminal
.borrow_mut()
.backend_mut()
.resize(width, height);
}
pub fn dispatch(&mut self, event: UiEvent) -> Reaction {
if self.core.exited() {
return Reaction::Exit;
}
let mut result = Reaction::Continue;
let translated = self.core.backend_mut().translate_injected(vec![event]);
for ev in translated {
let outcome = {
let (backend, app) = self.core.parts_mut();
dispatch_event(ev, backend, app)
};
match &outcome {
EventOutcome::Continue => {}
EventOutcome::Redraw => self.render(),
EventOutcome::RedrawAfter(d) => {
self.core.backend_mut().request_frame_in(*d);
}
EventOutcome::Exit => {
self.core.mark_exited();
return Reaction::Exit;
}
}
result = result.merge(outcome.into());
}
result
}
pub fn pump_user_events(&mut self) -> Reaction {
if self.core.exited() {
return Reaction::Exit;
}
let events = self.core.backend_mut().drain_user_events();
let mut result = Reaction::Continue;
for ev in events {
let outcome = {
let (backend, app) = self.core.parts_mut();
dispatch_event(ev, backend, app)
};
match &outcome {
EventOutcome::Continue => {}
EventOutcome::Redraw => self.render(),
EventOutcome::RedrawAfter(d) => {
self.core.backend_mut().request_frame_in(*d);
}
EventOutcome::Exit => {
self.core.mark_exited();
return Reaction::Exit;
}
}
result = result.merge(outcome.into());
}
result
}
pub fn tick(&mut self) -> Reaction {
if self.core.exited() {
return Reaction::Exit;
}
let reaction = {
let (backend, app) = self.core.parts_mut();
app.tick(backend)
};
match reaction {
Reaction::Continue => Reaction::Continue,
Reaction::Redraw => {
self.render();
Reaction::Redraw
}
Reaction::RedrawAfter(d) => {
self.core.backend_mut().request_frame_in(d);
Reaction::RedrawAfter(d)
}
Reaction::Exit => {
self.core.mark_exited();
Reaction::Exit
}
}
}
pub fn press(&mut self, key: Key) -> Reaction {
DriverInput::press(self, key)
}
pub fn type_char(&mut self, c: char) -> Reaction {
DriverInput::type_char(self, c)
}
pub fn press_named(&mut self, key: NamedKey) -> Reaction {
DriverInput::press_named(self, key)
}
pub fn ctrl_char(&mut self, c: char) -> Reaction {
DriverInput::ctrl_char(self, c)
}
pub fn click(&mut self, x: f32, y: f32) -> Reaction {
DriverInput::click(self, 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 {
DriverInput::drag(self, x0, y0, x1, y1)
}
pub fn double_click(&mut self, x: f32, y: f32) -> Reaction {
self.dispatch(UiEvent::DoubleClick {
widget: None,
position: Point::new(x, y),
})
}
pub fn middle_click(&mut self, x: f32, y: f32) -> Reaction {
self.click_with(MouseButton::Middle, Modifiers::default(), x, y)
}
pub fn right_click(&mut self, x: f32, y: f32) -> Reaction {
self.click_with(MouseButton::Right, Modifiers::default(), x, y)
}
pub fn click_with(
&mut self,
button: MouseButton,
modifiers: Modifiers,
x: f32,
y: f32,
) -> Reaction {
self.dispatch(UiEvent::MouseDown {
widget: None,
button,
position: Point::new(x, y),
modifiers,
})
}
pub fn set_double_click_folding(&mut self, enabled: bool) {
self.core.backend_mut().set_double_click_folding(enabled);
}
pub fn exited(&self) -> bool {
self.core.exited()
}
pub fn screen(&self) -> String {
let terminal = self.terminal.borrow();
let buf = terminal.backend().buffer();
let area = buf.area;
let mut out = String::new();
for y in area.top()..area.bottom() {
let mut x = area.left();
while x < area.right() {
let symbol = buf[(x, y)].symbol();
let ch = symbol.chars().next().unwrap_or(' ');
out.push_str(symbol);
x += char_cell_width(ch).max(1);
}
out.push('\n');
}
out
}
pub fn screen_contains(&self, needle: &str) -> bool {
self.screen().contains(needle)
}
pub fn style_at(&self, x: u16, y: u16) -> Option<CellStyle> {
let terminal = self.terminal.borrow();
let buf = terminal.backend().buffer();
let area = buf.area;
if x < area.left() || x >= area.right() || y < area.top() || y >= area.bottom() {
return None;
}
let cell = &buf[(x, y)];
Some(CellStyle {
fg: cell.fg,
bg: cell.bg,
modifiers: cell.modifier,
})
}
pub fn styled_row(&self, y: u16) -> Vec<(char, CellStyle)> {
let terminal = self.terminal.borrow();
let buf = terminal.backend().buffer();
let area = buf.area;
if y < area.top() || y >= area.bottom() {
return Vec::new();
}
(area.left()..area.right())
.map(|x| {
let cell = &buf[(x, y)];
let ch = cell.symbol().chars().next().unwrap_or(' ');
(
ch,
CellStyle {
fg: cell.fg,
bg: cell.bg,
modifiers: cell.modifier,
},
)
})
.collect()
}
pub fn app(&self) -> &A {
self.core.app()
}
pub fn app_mut(&mut self) -> &mut A {
self.core.app_mut()
}
pub fn backend(&self) -> &TuiBackend {
self.core.backend()
}
pub fn terminal_cursor_position(&mut self) -> Option<(u16, u16)> {
self.terminal
.borrow_mut()
.get_cursor_position()
.ok()
.map(|p| (p.x, p.y))
}
fn row_cells(&self, y: u16) -> Vec<(char, u16, u16)> {
let terminal = self.terminal.borrow();
let buf = terminal.backend().buffer();
let area = buf.area;
let mut cells = Vec::new();
let mut x = area.left();
while x < area.right() {
let symbol = buf[(x, y)].symbol();
let ch = symbol.chars().next().unwrap_or(' ');
let w = char_cell_width(ch).max(1);
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 area = self.terminal.borrow().backend().buffer().area;
for y in area.top()..area.bottom() {
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))
}
pub fn tab_center(&self, bar: &WidgetId, tab_idx: usize) -> Option<(f32, f32)> {
let (rect, layout) = self.core.backend().cached_tab_bar_layout(bar)?;
let (cx, cy) = layout.tab_center(tab_idx)?;
Some((rect.x + cx, rect.y + cy))
}
pub fn tab_close_center(&self, bar: &WidgetId, tab_idx: usize) -> Option<(f32, f32)> {
let (rect, layout) = self.core.backend().cached_tab_bar_layout(bar)?;
let (cx, cy) = layout.tab_close_center(tab_idx)?;
Some((rect.x + cx, rect.y + cy))
}
}
impl<A: AppLogic> DriverInput for TuiDriver<A> {
fn dispatch(&mut self, event: UiEvent) -> Reaction {
self.dispatch(event)
}
fn mouse_down(&mut self, x: f32, y: f32) -> Reaction {
self.mouse_down(x, y)
}
fn mouse_move(&mut self, x: f32, y: f32) -> Reaction {
self.mouse_move(x, y)
}
fn mouse_up(&mut self, x: f32, y: f32) -> Reaction {
self.mouse_up(x, y)
}
}
impl<A: AppLogic> ConformanceDriver for TuiDriver<A> {
type App = A;
fn new_fixture(app: Self::App, viewport: LogicalViewport) -> Self {
TuiDriver::new(app, viewport.cols as u16, viewport.rows as u16)
}
fn backend_caps(&self) -> crate::BackendCaps {
crate::Backend::backend_caps(self.core.backend())
}
fn press_named(&mut self, key: NamedKey) {
TuiDriver::press_named(self, key);
}
fn type_char(&mut self, c: char) {
TuiDriver::type_char(self, c);
}
fn ctrl_char(&mut self, c: char) {
TuiDriver::ctrl_char(self, c);
}
fn click_text_at(&mut self, needle: &str, at: Anchor) {
let bounds = self
.find_bounds(needle)
.unwrap_or_else(|| panic!("TuiDriver: {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!("TuiDriver: {from:?} not painted:\n{}", self.screen()));
let (x1, y1) = self
.find(to)
.unwrap_or_else(|| panic!("TuiDriver: {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!("TuiDriver: {needle:?} not painted:\n{}", self.screen()));
let line_height = self.core.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 area = self.terminal.borrow().backend().buffer().area;
let mut text_runs = Vec::new();
for y in area.top()..area.bottom() {
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.core.backend().zones().to_vec(),
}
}
fn screen_has(&self, needle: &str) -> bool {
self.screen_contains(needle)
}
fn exited(&self) -> bool {
TuiDriver::exited(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Viewport;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
struct SetupViewportRecorder {
seen: Rc<Cell<Option<Viewport>>>,
}
impl AppLogic for SetupViewportRecorder {
type AreaId = ();
fn setup(&mut self, backend: &mut dyn Backend) {
self.seen.set(Some(backend.viewport()));
}
fn render(&self, _backend: &mut dyn Backend, _area: ()) {}
fn handle(&mut self, _event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
Reaction::Continue
}
}
struct FullRepaintRequester;
impl AppLogic for FullRepaintRequester {
type AreaId = ();
fn render(&self, _backend: &mut dyn Backend, _area: ()) {}
fn handle(&mut self, event: UiEvent, backend: &mut dyn Backend) -> Reaction {
if matches!(event, UiEvent::KeyPressed { .. }) {
backend.request_full_repaint();
return Reaction::Redraw;
}
Reaction::Continue
}
}
#[test]
fn dispatch_consumes_a_pending_full_repaint_request_via_render() {
let mut driver = TuiDriver::new(FullRepaintRequester, 20, 5);
let reaction = driver.press(Key::Named(NamedKey::Enter));
assert_eq!(reaction, Reaction::Redraw);
assert!(
!driver.core.backend_mut().take_full_repaint_requested(),
"TuiDriver::render must consume the pending full-repaint \
request during the redraw `dispatch` already triggered"
);
}
#[test]
fn render_actually_clears_stale_content_outside_the_diff_cache() {
use ratatui::backend::Backend as RatatuiBackend;
use ratatui::buffer::Cell;
let mut driver = TuiDriver::new(FullRepaintRequester, 10, 3);
let mut stale = Cell::default();
stale.set_symbol("X");
RatatuiBackend::draw(
driver.terminal.borrow_mut().backend_mut(),
std::iter::once((0u16, 0u16, &stale)),
)
.expect("TestBackend draw is infallible");
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.core.backend_mut().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 every cell repaints unconditionally"
);
}
#[test]
fn setup_sees_real_viewport_not_default() {
let seen = Rc::new(Cell::new(None));
let app = SetupViewportRecorder { seen: seen.clone() };
let _driver = TuiDriver::new(app, 120, 40);
let vp = seen.get().expect("setup() should have run");
assert_eq!(
(vp.width, vp.height),
(120.0, 40.0),
"setup() must see the driver's real size, not Viewport::default() (80×24)"
);
}
use crate::primitives::status_bar::{StatusBar, StatusBarSegment};
use crate::types::{Color, WidgetId};
struct OneLineApp {
text: &'static str,
}
impl AppLogic for OneLineApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_status_bar_interactive(
Rect::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 find_bounds_is_wide_char_aware_for_adjacent_cjk_glyphs() {
let driver = TuiDriver::new(
OneLineApp {
text: "你好 world"
},
30,
3,
);
let cjk = driver.find_bounds("你好").expect(
"adjacent double-width glyphs should match as one span, not be split by a \
spurious blank continuation cell",
);
assert_eq!(
(cjk.x, cjk.width),
(0.0, 4.0),
"two double-width glyphs occupy 4 cells total"
);
let (x, y) = driver.find("你好").expect("find should also locate it");
assert_eq!((x, y), (0.5, 0.5));
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 screen_is_wide_char_aware_for_adjacent_cjk_glyphs() {
let driver = TuiDriver::new(
OneLineApp {
text: "你好 world"
},
30,
3,
);
assert!(
driver.screen_contains("你好"),
"screen() must not insert a phantom space inside a contiguous \
double-width run:\n{}",
driver.screen()
);
assert!(
driver.screen_contains("你好 world"),
"the whole line must read back exactly as painted:\n{}",
driver.screen()
);
}
#[test]
fn conformance_driver_new_fixture_scroll_at_and_inventory_round_trip() {
let driver: TuiDriver<OneLineApp> = ConformanceDriver::new_fixture(
OneLineApp {
text: "你好 world"
},
LogicalViewport::new(30, 3),
);
assert_eq!(
(
driver.backend().viewport().width,
driver.backend().viewport().height
),
(30.0, 3.0),
"new_fixture's LogicalViewport should map straight through to TUI cells"
);
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"),
"inventory() should synthesize a TextRun per whitespace-separated \
run on the cell grid, wide-char aware: {texts:?}"
);
let mut driver = driver;
driver.scroll_at("world", 1);
}
#[derive(Default)]
struct EventRecorder {
events: Rc<RefCell<Vec<UiEvent>>>,
}
impl AppLogic for EventRecorder {
type AreaId = ();
fn render(&self, _backend: &mut dyn Backend, _area: ()) {}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
self.events.borrow_mut().push(event);
Reaction::Continue
}
}
#[test]
fn double_click_is_deterministic_across_many_calls() {
let events = Rc::new(RefCell::new(Vec::new()));
let app = EventRecorder {
events: events.clone(),
};
let mut driver = TuiDriver::new(app, 20, 5);
for i in 0..1000 {
events.borrow_mut().clear();
driver.double_click(3.0, 2.0);
let recorded = events.borrow();
assert_eq!(
recorded.len(),
1,
"call {i}: double_click should deliver exactly one event, got {recorded:?}"
);
assert_eq!(
recorded[0],
UiEvent::DoubleClick {
widget: None,
position: Point::new(3.0, 2.0),
},
"call {i}: expected a single DoubleClick with no prior MouseDown"
);
}
}
#[test]
fn middle_click_delivers_middle_mouse_down() {
let events = Rc::new(RefCell::new(Vec::new()));
let app = EventRecorder {
events: events.clone(),
};
let mut driver = TuiDriver::new(app, 20, 5);
driver.middle_click(4.0, 1.0);
let recorded = events.borrow();
assert_eq!(recorded.len(), 1);
assert_eq!(
recorded[0],
UiEvent::MouseDown {
widget: None,
button: MouseButton::Middle,
position: Point::new(4.0, 1.0),
modifiers: Modifiers::default(),
}
);
}
#[test]
fn right_click_delivers_right_mouse_down() {
let events = Rc::new(RefCell::new(Vec::new()));
let app = EventRecorder {
events: events.clone(),
};
let mut driver = TuiDriver::new(app, 20, 5);
driver.right_click(4.0, 1.0);
let recorded = events.borrow();
assert_eq!(recorded.len(), 1);
assert_eq!(
recorded[0],
UiEvent::MouseDown {
widget: None,
button: MouseButton::Right,
position: Point::new(4.0, 1.0),
modifiers: Modifiers::default(),
}
);
}
#[test]
fn click_with_delivers_button_and_modifiers() {
let events = Rc::new(RefCell::new(Vec::new()));
let app = EventRecorder {
events: events.clone(),
};
let mut driver = TuiDriver::new(app, 20, 5);
let ctrl = Modifiers {
ctrl: true,
..Modifiers::default()
};
driver.click_with(MouseButton::Left, ctrl, 2.0, 3.0);
let recorded = events.borrow();
assert_eq!(recorded.len(), 1);
assert_eq!(
recorded[0],
UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(2.0, 3.0),
modifiers: ctrl,
}
);
}
#[test]
fn folding_disabled_keeps_two_clicks_separate() {
let events = Rc::new(RefCell::new(Vec::new()));
let app = EventRecorder {
events: events.clone(),
};
let mut driver = TuiDriver::new(app, 20, 5);
driver.set_double_click_folding(false);
driver.click(5.0, 5.0);
driver.click(5.0, 5.0);
let recorded = events.borrow();
let mouse_downs = recorded
.iter()
.filter(|e| matches!(e, UiEvent::MouseDown { .. }))
.count();
let double_clicks = recorded
.iter()
.filter(|e| matches!(e, UiEvent::DoubleClick { .. }))
.count();
assert_eq!(
(mouse_downs, double_clicks),
(2, 0),
"folding disabled should yield two MouseDowns and no DoubleClick, got {recorded:?}"
);
}
#[test]
fn folding_enabled_by_default_still_folds_two_clicks() {
let events = Rc::new(RefCell::new(Vec::new()));
let app = EventRecorder {
events: events.clone(),
};
let mut driver = TuiDriver::new(app, 20, 5);
driver.click(5.0, 5.0);
driver.click(5.0, 5.0);
let recorded = events.borrow();
let double_clicks = recorded
.iter()
.filter(|e| matches!(e, UiEvent::DoubleClick { .. }))
.count();
assert_eq!(
double_clicks, 1,
"default folding should still fold two same-position clicks: {recorded:?}"
);
}
use crate::primitives::tab_bar::{TabBar, TabItem};
use crate::theme::Theme;
use crate::types::WidgetId as QWidgetId;
struct TabBarApp {
theme: Theme,
}
impl AppLogic for TabBarApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.set_theme(self.theme);
backend.draw_tab_bar(
Rect::new(0.0, 0.0, 20.0, 1.0),
&TabBar {
id: QWidgetId::new("tabs"),
tabs: vec![
TabItem {
label: "perm".to_string(),
is_active: false,
is_dirty: false,
is_preview: false,
is_closable: false,
},
TabItem {
label: "prev".to_string(),
is_active: false,
is_dirty: false,
is_preview: true,
is_closable: false,
},
],
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: false,
compact: false,
},
None,
);
}
fn handle(&mut self, _event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
Reaction::Continue
}
}
#[test]
fn style_at_distinguishes_preview_tab_from_permanent_tab() {
let theme = Theme::default();
let driver = TuiDriver::new(TabBarApp { theme }, 20, 1);
assert!(driver.screen_contains("perm│prev│"));
let perm_style = driver
.style_at(0, 0)
.expect("(0, 0) is inside the 20x1 buffer");
let preview_style = driver
.style_at(5, 0)
.expect("(5, 0) is inside the 20x1 buffer");
assert!(
!perm_style.modifiers.contains(Modifier::ITALIC),
"permanent tab must not be italic: {perm_style:?}"
);
assert!(
preview_style.modifiers.contains(Modifier::ITALIC),
"preview tab must be italic: {preview_style:?}"
);
assert_eq!(
perm_style.fg,
crate::tui::ratatui_color(theme.tab_inactive_fg),
"permanent tab's fg should be the theme's tab_inactive_fg token"
);
assert_eq!(
preview_style.fg,
crate::tui::ratatui_color(theme.tab_preview_inactive_fg),
"preview tab's fg should be the theme's tab_preview_inactive_fg token"
);
}
#[test]
fn style_at_out_of_bounds_returns_none() {
let driver = TuiDriver::new(
TabBarApp {
theme: Theme::default(),
},
20,
1,
);
assert!(
driver.style_at(20, 0).is_none(),
"x == width is out of range"
);
assert!(
driver.style_at(0, 1).is_none(),
"y == height is out of range"
);
assert!(
driver.style_at(100, 100).is_none(),
"far outside is out of range"
);
assert!(
driver.style_at(19, 0).is_some(),
"x == width - 1 is the last valid column"
);
}
#[test]
fn styled_row_length_matches_buffer_width() {
let driver = TuiDriver::new(
TabBarApp {
theme: Theme::default(),
},
20,
1,
);
let row = driver.styled_row(0);
assert_eq!(row.len(), 20, "row length should equal buffer width");
assert_eq!(
row.iter().map(|(ch, _)| *ch).collect::<String>(),
"perm│prev│ ",
"styled_row's chars should match screen()'s glyphs left to right"
);
assert!(!row[0].1.modifiers.contains(Modifier::ITALIC));
assert!(row[5].1.modifiers.contains(Modifier::ITALIC));
assert!(
driver.styled_row(1).is_empty(),
"out-of-range row should be empty, not panic"
);
}
struct NoCloseTabBarApp {
active: usize,
}
impl NoCloseTabBarApp {
const RECT: Rect = Rect::new(0.0, 0.0, 20.0, 1.0);
fn bar(&self) -> TabBar {
TabBar {
id: QWidgetId::new("tabs"),
tabs: vec![
TabItem {
label: "Problems".into(),
is_active: self.active == 0,
is_dirty: false,
is_preview: false,
is_closable: false,
},
TabItem {
label: "Output".into(),
is_active: self.active == 1,
is_dirty: false,
is_preview: false,
is_closable: false,
},
],
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: false,
compact: true,
}
}
}
impl AppLogic for NoCloseTabBarApp {
type AreaId = ();
#[allow(deprecated)] fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_tab_bar(Self::RECT, &self.bar(), None);
}
#[allow(deprecated)] fn handle(&mut self, event: UiEvent, backend: &mut dyn Backend) -> Reaction {
let UiEvent::MouseDown { position, .. } = event else {
return Reaction::Continue;
};
let hits = backend.tab_bar_layout(Self::RECT, &self.bar());
let x = position.x as f64;
for (i, (sx, ex)) in hits.slot_positions.iter().enumerate() {
if x >= *sx && x < *ex {
self.active = i;
return Reaction::Redraw;
}
}
Reaction::Continue
}
}
#[test]
fn no_close_tab_bar_separator_click_through_backend_measurer_activates_its_tab() {
let app = NoCloseTabBarApp { active: 1 };
let mut driver = TuiDriver::new(app, 20, 1);
assert!(
driver.screen_contains("Problems│Output│"),
"the real backend measurer should reserve a separator column after each no-close tab"
);
let (sep_x, sep_y) = driver
.find("│")
.expect("separator glyph should be visible on the 20-cell bar");
driver.click(sep_x, sep_y);
assert_eq!(
driver.app().active,
0,
"clicking the separator painted right after tab 0's label should activate tab 0, \
not fall through to a dead zone"
);
}
struct InteractiveTabBarApp {
active: usize,
closed_idx: Option<usize>,
}
impl InteractiveTabBarApp {
const RECT: Rect = Rect::new(0.0, 0.0, 20.0, 1.0);
fn bar(&self) -> TabBar {
TabBar {
id: QWidgetId::new("tabs"),
tabs: (0..3)
.map(|i| TabItem {
label: format!("tab{i}"),
is_active: i == self.active,
is_dirty: false,
is_preview: false,
is_closable: true,
})
.collect(),
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
}
}
}
impl AppLogic for InteractiveTabBarApp {
type AreaId = ();
#[allow(deprecated)] fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_tab_bar(Self::RECT, &self.bar(), None);
}
#[allow(deprecated)] fn handle(&mut self, event: UiEvent, backend: &mut dyn Backend) -> Reaction {
let UiEvent::MouseDown { position, .. } = event else {
return Reaction::Continue;
};
let hits = backend.tab_bar_layout(Self::RECT, &self.bar());
let x = position.x as f64;
for (i, close) in hits.close_bounds.iter().enumerate() {
if let Some((sx, ex)) = close {
if x >= *sx && x < *ex {
self.closed_idx = Some(i);
return Reaction::Redraw;
}
}
}
for (i, (sx, ex)) in hits.slot_positions.iter().enumerate() {
if x >= *sx && x < *ex {
self.active = i;
return Reaction::Redraw;
}
}
Reaction::Continue
}
}
#[test]
fn tab_center_click_activates_target_tab() {
let app = InteractiveTabBarApp {
active: 0,
closed_idx: None,
};
let mut driver = TuiDriver::new(app, 20, 1);
let bar_id = QWidgetId::new("tabs");
assert_eq!(driver.app().active, 0);
let (x, y) = driver
.tab_center(&bar_id, 1)
.expect("tab 1 should be visible on a 20-cell bar with 3 short tabs");
driver.click(x, y);
assert_eq!(
driver.app().active,
1,
"clicking tab 1's center should activate it"
);
}
#[test]
fn tab_close_center_click_closes_target_tab_not_just_activates() {
let app = InteractiveTabBarApp {
active: 0,
closed_idx: None,
};
let mut driver = TuiDriver::new(app, 20, 1);
let bar_id = QWidgetId::new("tabs");
let (x, y) = driver
.tab_close_center(&bar_id, 1)
.expect("tab 1 is closable and should have a close button");
driver.click(x, y);
assert_eq!(
driver.app().closed_idx,
Some(1),
"clicking tab 1's close button should close it"
);
assert_ne!(
driver.app().active,
1,
"closing tab 1 must not merely activate it — active should stay at its prior value"
);
}
#[test]
fn tab_close_center_resolves_to_authoritative_tab_close_hit() {
use crate::primitives::tab_bar::TabBarHit;
let app = InteractiveTabBarApp {
active: 0,
closed_idx: None,
};
let mut driver = TuiDriver::new(app, 20, 1);
let bar_id = QWidgetId::new("tabs");
let bar = driver.app().bar();
let (x, y) = driver
.tab_close_center(&bar_id, 1)
.expect("tab 1 is closable and should have a close button");
let resolved = crate::Backend::resolve_tab_bar_layout(
driver.core.backend_mut(),
InteractiveTabBarApp::RECT,
&bar,
);
let local_x = x - InteractiveTabBarApp::RECT.x;
let local_y = y - InteractiveTabBarApp::RECT.y;
assert_eq!(
resolved.hit_test(local_x, local_y),
TabBarHit::TabClose(1),
"the close-glyph rect `draw_tab_bar` painted must be the same \
rect a consumer hit-tests against"
);
}
#[test]
fn tab_close_center_none_when_tab_not_closable() {
struct NonClosableTabBarApp;
impl AppLogic for NonClosableTabBarApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_tab_bar(
Rect::new(0.0, 0.0, 20.0, 1.0),
&TabBar {
id: QWidgetId::new("tabs"),
tabs: vec![TabItem {
label: "a".to_string(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: false,
}],
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
},
None,
);
}
fn handle(&mut self, _event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
Reaction::Continue
}
}
let driver = TuiDriver::new(NonClosableTabBarApp, 20, 1);
let bar_id = QWidgetId::new("tabs");
assert!(
driver.tab_center(&bar_id, 0).is_some(),
"the tab itself is visible"
);
assert!(
driver.tab_close_center(&bar_id, 0).is_none(),
"a non-closable tab drew no close button, so its center must be None"
);
}
#[test]
fn tab_center_none_when_scrolled_out_of_view() {
struct ScrolledTabBarApp;
impl AppLogic for ScrolledTabBarApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_tab_bar(
Rect::new(0.0, 0.0, 5.0, 1.0),
&TabBar {
id: QWidgetId::new("tabs"),
tabs: vec![
TabItem {
label: "a".to_string(),
is_active: false,
is_dirty: false,
is_preview: false,
is_closable: true,
},
TabItem {
label: "b".to_string(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: true,
},
TabItem {
label: "c".to_string(),
is_active: false,
is_dirty: false,
is_preview: false,
is_closable: true,
},
],
scroll_offset: 1,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
},
None,
);
}
fn handle(&mut self, _event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
Reaction::Continue
}
}
let driver = TuiDriver::new(ScrolledTabBarApp, 5, 1);
let bar_id = QWidgetId::new("tabs");
assert!(
driver.tab_center(&bar_id, 0).is_none(),
"tab 0 is scrolled out of view behind scroll_offset=1"
);
assert!(driver.tab_close_center(&bar_id, 0).is_none());
assert!(
driver.tab_center(&bar_id, 1).is_some(),
"tab 1 (the scroll target) should still be visible"
);
}
struct BackgroundResultApp {
last: String,
}
impl BackgroundResultApp {
fn new() -> Self {
Self {
last: "waiting".to_string(),
}
}
}
impl AppLogic for BackgroundResultApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_text_display(
Rect::new(0.0, 0.0, 40.0, 1.0),
&crate::TextDisplay {
id: QWidgetId::new("result"),
lines: vec![crate::TextDisplayLine {
spans: vec![crate::types::StyledSpan::plain(self.last.clone())],
decoration: Default::default(),
timestamp: None,
}],
scroll_offset: 0,
auto_scroll: true,
max_lines: 0,
has_focus: false,
title: None,
show_scrollbar: false,
},
);
}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
match event {
UiEvent::User(payload) => {
if let Some(text) = payload.downcast_ref::<String>() {
self.last = text.clone();
Reaction::Redraw
} else {
Reaction::Continue
}
}
_ => Reaction::Continue,
}
}
}
#[test]
fn waker_delivers_background_thread_result_to_rendered_output() {
let mut driver = TuiDriver::new(BackgroundResultApp::new(), 40, 3);
assert!(
driver.screen_contains("waiting"),
"initial render should show the placeholder"
);
let waker = driver.backend().waker();
let handle = std::thread::spawn(move || {
waker(crate::UserPayload::new("done from background".to_string()));
});
handle.join().expect("background thread must not panic");
let reaction = driver.pump_user_events();
assert_eq!(
reaction,
Reaction::Redraw,
"delivering the queued UiEvent::User must trigger a redraw"
);
assert!(
driver.screen_contains("done from background"),
"the background thread's payload must reach rendered output"
);
assert!(
!driver.screen_contains("waiting"),
"the placeholder must be gone once the real result renders"
);
}
#[test]
fn waker_delivers_multiple_background_payloads_in_order() {
let mut driver = TuiDriver::new(BackgroundResultApp::new(), 40, 3);
let waker1 = driver.backend().waker();
let waker2 = driver.backend().waker();
let t1 = std::thread::spawn(move || {
waker1(crate::UserPayload::new("first".to_string()));
});
t1.join().unwrap();
let t2 = std::thread::spawn(move || {
waker2(crate::UserPayload::new("second".to_string()));
});
t2.join().unwrap();
driver.pump_user_events();
assert!(
driver.screen_contains("second"),
"the later payload must win — both were delivered in push order"
);
}
use crate::compose::app_shell::{AppShellLayout, PanelDefinition};
use crate::shell::ShellContext;
use crate::types::Icon;
struct NerdFontsToggleApp {
nerd_fonts: bool,
}
impl ShellApp for NerdFontsToggleApp {
fn render_content(&self, _backend: &mut dyn Backend, _layout: &AppShellLayout) {}
fn handle(
&mut self,
_event: UiEvent,
_backend: &mut dyn Backend,
_ctx: &ShellContext,
) -> Reaction {
Reaction::Continue
}
fn setup(&mut self, backend: &mut dyn Backend) {
backend.set_nerd_fonts(self.nerd_fonts);
}
}
fn nerd_fonts_toggle_config() -> ShellConfig {
let panel_id = WidgetId::new("panel:explorer");
let bottom_id = WidgetId::new("panel:settings");
ShellConfig::new(
"Demo",
vec![PanelDefinition {
id: panel_id.clone(),
icon: "X".to_string(),
tooltip: "Explorer".to_string(),
title: "EXPLORER".to_string(),
}],
)
.with_bottom_items(vec![PanelDefinition {
id: bottom_id.clone(),
icon: "Y".to_string(),
tooltip: "Settings".to_string(),
title: "SETTINGS".to_string(),
}])
.with_panel_icon(panel_id, Icon::new("\u{f07c}", "\u{25c6}"))
.with_panel_icon(bottom_id, Icon::new("\u{f013}", "\u{25a0}"))
}
#[test]
fn shell_config_panel_icon_paints_fallback_when_nerd_fonts_off() {
let driver = driver_with_shell(
NerdFontsToggleApp { nerd_fonts: false },
nerd_fonts_toggle_config(),
40,
10,
);
assert!(
driver.screen_contains("\u{25c6}"),
"nerd_fonts off must paint the top panel's fallback glyph"
);
assert!(
!driver.screen_contains("\u{f07c}"),
"nerd_fonts off must not paint the top panel's Nerd Font glyph"
);
}
#[test]
fn shell_config_panel_icon_paints_glyph_when_nerd_fonts_on() {
let driver = driver_with_shell(
NerdFontsToggleApp { nerd_fonts: true },
nerd_fonts_toggle_config(),
40,
10,
);
assert!(
driver.screen_contains("\u{f07c}"),
"nerd_fonts on must paint the top panel's Nerd Font glyph"
);
assert!(
!driver.screen_contains("\u{25c6}"),
"nerd_fonts on must not paint the top panel's fallback glyph"
);
}
#[test]
fn shell_config_panel_icon_paints_fallback_for_bottom_item_when_nerd_fonts_off() {
let driver = driver_with_shell(
NerdFontsToggleApp { nerd_fonts: false },
nerd_fonts_toggle_config(),
40,
10,
);
assert!(
driver.screen_contains("\u{25a0}"),
"nerd_fonts off must paint the bottom item's fallback glyph"
);
assert!(
!driver.screen_contains("\u{f013}"),
"nerd_fonts off must not paint the bottom item's Nerd Font glyph"
);
}
#[test]
fn shell_config_panel_icon_paints_glyph_for_bottom_item_when_nerd_fonts_on() {
let driver = driver_with_shell(
NerdFontsToggleApp { nerd_fonts: true },
nerd_fonts_toggle_config(),
40,
10,
);
assert!(
driver.screen_contains("\u{f013}"),
"nerd_fonts on must paint the bottom item's Nerd Font glyph"
);
assert!(
!driver.screen_contains("\u{25a0}"),
"nerd_fonts on must not paint the bottom item's fallback glyph"
);
}
#[test]
fn resize_shrinks_buffer_without_touching_cached_viewport() {
let mut driver = TuiDriver::new(
OneLineApp {
text: "hello resize world",
},
30,
3,
);
assert_eq!(
(
driver.backend().viewport().width,
driver.backend().viewport().height
),
(30.0, 3.0),
"sanity: TuiBackend's viewport should start matching the driver's initial size"
);
driver.resize(5, 1);
let area = driver.terminal.borrow().backend().buffer().area;
assert_eq!(
(area.width, area.height),
(5, 1),
"resize() must shrink the TestBackend's real cell buffer right away"
);
assert_eq!(
(
driver.backend().viewport().width,
driver.backend().viewport().height
),
(30.0, 3.0),
"resize() must not touch TuiBackend's cached viewport — it should stay stale \
at the pre-resize size until the next render()"
);
driver.render();
assert_eq!(
(
driver.backend().viewport().width,
driver.backend().viewport().height
),
(5.0, 1.0),
"render() after resize() must resync TuiBackend's viewport to the new size"
);
}
}