use std::cell::RefCell;
use std::io;
use std::rc::Rc;
use std::time::{Duration, Instant};
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::Terminal;
use crate::backend::Backend;
use crate::runner::{AppLogic, Reaction};
use crate::runtime::{ResizeDebouncer, RESIZE_SETTLE};
pub(crate) use crate::runtime::EventOutcome;
use crate::tui::backend::TuiBackend;
use crate::tui::color::DepthLimitedBackend;
use crate::UiEvent;
type LiveBackend = DepthLimitedBackend<CrosstermBackend<io::Stdout>>;
const POLL_TIMEOUT_CEILING: Duration = crate::runtime::IDLE_POLL_CEILING;
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct RunConfig {
pub mouse: bool,
}
impl Default for RunConfig {
fn default() -> Self {
Self { mouse: true }
}
}
impl RunConfig {
pub fn no_mouse() -> Self {
Self {
mouse: false,
..Default::default()
}
}
}
pub fn run<A: AppLogic>(app: A) -> io::Result<()> {
run_with(app, RunConfig::default())
}
pub fn run_with<A: AppLogic>(app: A, config: RunConfig) -> io::Result<()> {
let mut runner = TuiRunner::new_with(app, config)?;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| loop {
match runner.pump(POLL_TIMEOUT_CEILING)? {
StepOutcome::Continue => {}
StepOutcome::Exited => return Ok(()),
}
}));
runner.finish();
match result {
Ok(io_result) => io_result,
Err(payload) => std::panic::resume_unwind(payload),
}
}
struct TerminalSetup {
terminal: Rc<RefCell<Terminal<LiveBackend>>>,
backend: TuiBackend,
kbd_enhanced: bool,
sgr_pixel_mouse: bool,
}
fn setup_terminal(config: RunConfig) -> io::Result<TerminalSetup> {
use ratatui::crossterm::event::{EnableBracketedPaste, EnableMouseCapture};
enable_raw_mode()?;
let mut stdout = io::stdout();
if config.mouse {
execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
} else {
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
}
let kbd_enhanced = push_keyboard_enhancement(&mut stdout);
let probed_cell_pixel_size = if config.mouse
&& !super::caps::sgr_pixel_mouse_blocked_by_multiplexer()
&& super::caps::probe_sgr_pixel_mouse()
{
query_cell_pixel_size()
} else {
None
};
let sgr_pixel_mouse = probed_cell_pixel_size.is_some();
let cell_pixel_size = probed_cell_pixel_size.unwrap_or(crate::TerminalCellSize::new(1.0, 1.0));
if sgr_pixel_mouse {
let _ = enable_sgr_pixel_mouse(&mut stdout);
}
let mut backend = TuiBackend::new();
backend.set_kitty_keyboard(kbd_enhanced);
backend.set_mouse_enabled(config.mouse);
backend.set_sgr_pixel_mouse(sgr_pixel_mouse);
backend.set_cell_pixel_size(cell_pixel_size);
let crossterm_backend = CrosstermBackend::new(stdout);
let depth_limited = DepthLimitedBackend::new(crossterm_backend, backend.color_depth());
let mut terminal = Terminal::new(depth_limited)?;
terminal.clear()?;
let terminal = Rc::new(RefCell::new(terminal));
let dialog_surface: Rc<RefCell<dyn crate::tui::services::DialogSurface>> = terminal.clone();
backend.tui_services().set_dialog_surface(dialog_surface);
Ok(TerminalSetup {
terminal,
backend,
kbd_enhanced,
sgr_pixel_mouse,
})
}
fn teardown_terminal(
terminal: &Rc<RefCell<Terminal<LiveBackend>>>,
kbd_enhanced: bool,
sgr_pixel_mouse: bool,
mouse: bool,
) {
use ratatui::crossterm::event::{DisableBracketedPaste, DisableMouseCapture};
let mut terminal = terminal.borrow_mut();
if kbd_enhanced {
let _ = pop_keyboard_enhancement(terminal.backend_mut());
}
if sgr_pixel_mouse {
let _ = disable_sgr_pixel_mouse(terminal.backend_mut());
}
let _ = disable_raw_mode();
if mouse {
let _ = execute!(
terminal.backend_mut(),
DisableMouseCapture,
DisableBracketedPaste,
LeaveAlternateScreen
);
} else {
let _ = execute!(
terminal.backend_mut(),
DisableBracketedPaste,
LeaveAlternateScreen
);
}
let _ = terminal.show_cursor();
}
pub use crate::runner::StepOutcome;
pub struct TuiRunner<A: AppLogic> {
terminal: Rc<RefCell<Terminal<LiveBackend>>>,
backend: TuiBackend,
app: A,
needs_redraw: bool,
resize_debouncer: ResizeDebouncer,
resize_deadline: Option<Instant>,
kbd_enhanced: bool,
sgr_pixel_mouse: bool,
mouse: bool,
finished: bool,
}
impl<A: AppLogic> TuiRunner<A> {
pub fn new(app: A) -> io::Result<Self> {
Self::new_with(app, RunConfig::default())
}
pub fn new_with(mut app: A, config: RunConfig) -> io::Result<Self> {
let mouse = config.mouse;
let TerminalSetup {
terminal,
mut backend,
kbd_enhanced,
sgr_pixel_mouse,
} = setup_terminal(config)?;
let size = terminal.borrow().size()?;
backend.begin_frame(crate::Viewport::new(
size.width as f32,
size.height as f32,
1.0,
));
app.setup(&mut backend);
Ok(Self {
terminal,
backend,
app,
needs_redraw: true,
resize_debouncer: ResizeDebouncer::new(),
resize_deadline: None,
kbd_enhanced,
sgr_pixel_mouse,
mouse,
finished: false,
})
}
pub fn app(&self) -> &A {
&self.app
}
pub fn app_mut(&mut self) -> &mut A {
&mut self.app
}
pub fn backend(&self) -> &TuiBackend {
&self.backend
}
pub fn backend_mut(&mut self) -> &mut TuiBackend {
&mut self.backend
}
pub fn step(&mut self) -> io::Result<StepOutcome> {
self.run_one(None)
}
pub fn pump(&mut self, timeout: Duration) -> io::Result<StepOutcome> {
self.run_one(Some(timeout))
}
fn run_one(&mut self, block_for: Option<Duration>) -> io::Result<StepOutcome> {
if self.finished {
return Ok(StepOutcome::Exited);
}
let full_repaint = self.backend.take_full_repaint_requested();
self.needs_redraw |= full_repaint;
if self.needs_redraw {
let mut guard = self.terminal.borrow_mut();
if full_repaint {
guard.clear()?;
}
render_frame(&mut guard, &mut self.backend, &self.app)?;
drop(guard);
self.needs_redraw = false;
}
let events = match block_for {
Some(ceiling) => {
let mut timeout = self.backend.frame_poll_timeout(ceiling);
if let Some(d) = self.resize_deadline {
timeout = timeout.min(d.saturating_duration_since(Instant::now()));
}
self.backend.wait_events(timeout)
}
None => self.backend.poll_events(),
};
self.backend.clear_frame_deadline_if_due();
for event in events {
if let Some(outcome) = self.handle_native_event(event)? {
return Ok(outcome);
}
}
if self.backend.input_gone() {
return Ok(self.finish());
}
if self.resize_deadline.is_some_and(|d| Instant::now() >= d) {
self.resize_deadline = None;
if let Some(viewport) = self.resize_debouncer.take() {
if let Some(outcome) = self.dispatch_and_map(UiEvent::WindowResized { viewport })? {
return Ok(outcome);
}
}
}
match self.app.tick(&mut self.backend) {
Reaction::Continue => {}
Reaction::Redraw => self.needs_redraw = true,
Reaction::RedrawAfter(d) => self.backend.request_frame_in(d),
Reaction::Exit => return Ok(self.finish()),
}
Ok(StepOutcome::Continue)
}
fn handle_native_event(&mut self, event: UiEvent) -> io::Result<Option<StepOutcome>> {
if let UiEvent::WindowResized { viewport } = event {
if self.backend.sgr_pixel_mouse() {
match query_cell_pixel_size() {
Some(size) => self.backend.set_cell_pixel_size(size),
None => {
let _ = disable_sgr_pixel_mouse(self.terminal.borrow_mut().backend_mut());
self.backend.set_sgr_pixel_mouse(false);
self.backend
.set_cell_pixel_size(crate::TerminalCellSize::new(1.0, 1.0));
}
}
}
self.resize_debouncer.note(viewport);
self.resize_deadline = Some(Instant::now() + RESIZE_SETTLE);
self.needs_redraw = true;
return Ok(None);
}
self.dispatch_and_map(event)
}
fn dispatch_and_map(&mut self, event: UiEvent) -> io::Result<Option<StepOutcome>> {
match dispatch_event(event, &mut self.backend, &mut self.app) {
EventOutcome::Continue => Ok(None),
EventOutcome::Redraw => {
self.needs_redraw = true;
Ok(None)
}
EventOutcome::RedrawAfter(d) => {
self.backend.request_frame_in(d);
Ok(None)
}
EventOutcome::Exit => Ok(Some(self.finish())),
}
}
fn finish(&mut self) -> StepOutcome {
if !self.finished {
teardown_terminal(
&self.terminal,
self.kbd_enhanced,
self.sgr_pixel_mouse,
self.mouse,
);
self.finished = true;
}
StepOutcome::Exited
}
}
impl<A: AppLogic> Drop for TuiRunner<A> {
fn drop(&mut self) {
self.finish();
}
}
pub(crate) fn paint_frame<A, B>(
terminal: &mut Terminal<B>,
backend: &mut TuiBackend,
app: &A,
) -> io::Result<()>
where
A: AppLogic,
B: ratatui::backend::Backend,
{
let focus_ring_rect = crate::runtime::focused_stop_rect(backend, app, A::AreaId::default());
terminal
.draw(|frame| {
let frame_area = frame.area();
backend.begin_frame(crate::Viewport::new(
frame_area.width as f32,
frame_area.height as f32,
1.0,
));
backend.enter_frame_scope(frame, |b| {
app.render(b, A::AreaId::default());
if let Some(rect) = focus_ring_rect {
b.draw_focus_ring(rect);
}
});
backend.strip_unsupported_underline_colors(frame.buffer_mut());
backend.apply_selection_highlight(frame.buffer_mut());
if let Some(pos) = backend.take_last_cursor_position() {
frame.set_cursor_position(pos);
}
})
.map_err(|e| io::Error::other(e.to_string()))?;
backend.end_frame();
Ok(())
}
pub(crate) fn render_frame<A, B>(
terminal: &mut Terminal<B>,
backend: &mut TuiBackend,
app: &A,
) -> io::Result<()>
where
A: AppLogic,
B: ratatui::backend::Backend,
{
paint_frame(terminal, backend, app)
}
pub(crate) fn dispatch_event<A: AppLogic>(
event: UiEvent,
backend: &mut TuiBackend,
app: &mut A,
) -> EventOutcome {
crate::runtime::preprocess_event(event, backend, app)
}
fn push_keyboard_enhancement(stdout: &mut io::Stdout) -> bool {
use ratatui::crossterm::event::{KeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
if !super::caps::probe_kitty_keyboard() {
return false;
}
execute!(
stdout,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
)
)
.is_ok()
}
fn pop_keyboard_enhancement(backend: &mut LiveBackend) -> io::Result<()> {
use ratatui::crossterm::event::PopKeyboardEnhancementFlags;
execute!(backend, PopKeyboardEnhancementFlags)
}
fn query_cell_pixel_size() -> Option<crate::TerminalCellSize> {
let ws = ratatui::crossterm::terminal::window_size().ok()?;
if ws.rows == 0 || ws.columns == 0 || ws.width == 0 || ws.height == 0 {
return None;
}
Some(crate::TerminalCellSize::new(
ws.width as f32 / ws.columns as f32,
ws.height as f32 / ws.rows as f32,
))
}
fn enable_sgr_pixel_mouse(w: &mut impl io::Write) -> io::Result<()> {
w.write_all(b"\x1b[?1016h")?;
w.flush()
}
fn disable_sgr_pixel_mouse(w: &mut impl io::Write) -> io::Result<()> {
w.write_all(b"\x1b[?1016l")?;
w.flush()
}
#[cfg(test)]
mod tests {
use crate::runner::{AppLogic, Reaction};
use crate::tui::testing::TuiDriver;
use crate::{Backend, Key, Point, Rect, TextRegion, UiEvent, WidgetId};
struct SelectionRecorder {
last_copied: Option<String>,
selection_changes: Vec<(Point, Point)>,
events: Vec<UiEvent>,
}
impl SelectionRecorder {
fn new() -> Self {
Self {
last_copied: None,
selection_changes: Vec::new(),
events: Vec::new(),
}
}
fn config_rect() -> Rect {
Rect::new(0.0, 0.0, 20.0, 5.0)
}
}
impl AppLogic for SelectionRecorder {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.register_text_region(TextRegion {
id: WidgetId::new("test-region"),
bounds: Self::config_rect(),
lines: vec![
"line one".into(),
"line two".into(),
"line three".into(),
"line four".into(),
"line five".into(),
],
});
}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
self.events.push(event.clone());
match event {
UiEvent::TextCopied(text) => {
self.last_copied = Some(text);
Reaction::Redraw
}
UiEvent::TextSelectionChanged { anchor, focus, .. } => {
self.selection_changes.push((anchor, focus));
Reaction::Redraw
}
UiEvent::KeyPressed {
key: Key::Char('q'),
..
} => Reaction::Exit,
_ => Reaction::Continue,
}
}
}
#[test]
fn drag_over_text_region_emits_selection_changed() {
let mut driver = TuiDriver::new(SelectionRecorder::new(), 40, 10);
driver.mouse_down(2.0, 0.0);
driver.mouse_move(2.0, 2.0);
assert!(
!driver.app().selection_changes.is_empty(),
"drag over a text region must emit TextSelectionChanged"
);
let (anchor, _focus) = driver.app().selection_changes[0];
assert!(
anchor.y < 1.0,
"anchor row should be 0 (drag started at y=0), got y={}",
anchor.y
);
}
#[test]
fn ctrl_c_with_selection_emits_text_copied() {
let mut driver = TuiDriver::new(SelectionRecorder::new(), 40, 10);
driver.mouse_down(0.0, 0.0);
driver.mouse_move(0.0, 1.0);
driver.mouse_up(0.0, 1.0);
driver.ctrl_char('c');
assert!(
driver.app().last_copied.is_some(),
"Ctrl-C with active selection must emit TextCopied to the app"
);
}
#[test]
fn ctrl_c_without_selection_does_not_emit_text_copied() {
let mut driver = TuiDriver::new(SelectionRecorder::new(), 40, 10);
driver.ctrl_char('c');
assert!(
driver.app().last_copied.is_none(),
"Ctrl-C without an active selection must NOT emit TextCopied"
);
}
#[test]
fn dpi_changed_forces_redraw_even_when_app_ignores_it() {
let mut driver = TuiDriver::new(SelectionRecorder::new(), 40, 10);
let reaction = driver.dispatch(UiEvent::DpiChanged(2.0));
assert_eq!(
reaction,
Reaction::Redraw,
"DpiChanged must force a redraw regardless of the app's own Reaction"
);
assert!(
driver.app().events.iter().any(
|e| matches!(e, UiEvent::DpiChanged(scale) if (*scale - 2.0).abs() < f32::EPSILON)
),
"the app must still observe the DpiChanged event itself, not just the forced redraw"
);
}
#[test]
fn ctrl_a_then_ctrl_c_copies_full_region() {
let mut driver = TuiDriver::new(SelectionRecorder::new(), 40, 10);
driver.ctrl_char('a'); driver.ctrl_char('c');
assert!(
driver.app().last_copied.is_some(),
"Ctrl-A + Ctrl-C must copy the region content"
);
let text = driver.app().last_copied.as_deref().unwrap_or("");
assert!(
!text.is_empty(),
"copied text must not be empty after Ctrl-A"
);
}
#[test]
fn mouse_down_clears_selection_display() {
let mut driver = TuiDriver::new(SelectionRecorder::new(), 40, 10);
driver.mouse_down(0.0, 0.0);
driver.mouse_move(0.0, 2.0);
driver.mouse_up(0.0, 2.0);
assert!(
driver.backend().active_text_selection().is_some(),
"drag should have established a selection before the test"
);
driver.mouse_down(0.0, 4.0);
assert!(
driver.backend().active_text_selection().is_none(),
"MouseDown must clear the previously displayed selection"
);
}
#[test]
fn ctrl_v_is_never_forwarded_as_a_raw_keypress() {
let mut driver = TuiDriver::new(SelectionRecorder::new(), 40, 10);
driver.ctrl_char('v');
assert!(
!driver.app().events.iter().any(|e| matches!(
e,
UiEvent::KeyPressed {
key: Key::Char('v'),
..
}
)),
"Ctrl-V must never fall through to app.handle as a raw 'v' \
keypress, got {:?}",
driver.app().events
);
}
#[test]
fn cancel_text_selection_drag_does_not_clear_display() {
use crate::DragTarget;
let mut backend = crate::tui::backend::TuiBackend::new();
backend
.drag_state_handle()
.borrow_mut()
.begin(DragTarget::TextSelection {
region: WidgetId::new("r"),
anchor: Point::new(0.0, 0.0),
});
backend.set_active_text_selection(
WidgetId::new("r"),
Point::new(0.0, 0.0),
Point::new(5.0, 0.0),
);
backend.cancel_text_selection_drag();
assert!(
!backend.drag_state_handle().borrow().is_active(),
"cancel_text_selection_drag must end the active drag"
);
assert!(
backend.active_text_selection().is_some(),
"cancel_text_selection_drag must NOT clear the active selection display"
);
}
struct EditorCursorApp {
cursor_col: usize,
show_editor: bool,
last_result: std::cell::RefCell<Option<crate::backend::EditorPaintResult>>,
}
impl EditorCursorApp {
fn new(cursor_col: usize) -> Self {
Self {
cursor_col,
show_editor: true,
last_result: std::cell::RefCell::new(None),
}
}
fn build_editor(&self) -> crate::Editor {
crate::Editor {
id: WidgetId::new("editor"),
rect: Rect::new(0.0, 0.0, 20.0, 5.0),
lines: vec![crate::EditorLine {
raw_text: "hello world".into(),
gutter_text: String::new(),
spans: vec![],
line_idx: 0,
is_current_line: true,
is_fold_header: false,
folded_line_count: 0,
git_diff: None,
diff_status: None,
diagnostics: vec![],
spell_errors: vec![],
is_breakpoint: false,
is_conditional_bp: false,
is_dap_current: false,
is_wrap_continuation: false,
segment_col_offset: 0,
annotation: None,
ghost_suffix: None,
is_ghost_continuation: false,
indent_guides: vec![],
colorcolumns: vec![],
}],
cursor: Some(crate::EditorCursor {
pos: crate::EditorCursorPos {
view_line: 0,
col: self.cursor_col,
},
shape: crate::EditorCursorShape::Bar,
}),
extra_cursors: vec![],
selection: None,
extra_selections: vec![],
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 1,
max_col: 11,
gutter_char_width: 0,
is_active: true,
show_active_bg: false,
has_git_diff: false,
has_breakpoints: false,
diagnostic_gutter: Default::default(),
code_action_lines: Default::default(),
bracket_match_positions: vec![],
active_indent_col: None,
tabstop: 4,
cursorline: false,
lightbulb_glyph: '\0',
}
}
}
impl AppLogic for EditorCursorApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
if self.show_editor {
let editor = self.build_editor();
let result = backend.draw_editor(editor.rect, &editor);
*self.last_result.borrow_mut() = Some(result);
}
}
fn handle(&mut self, _event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
Reaction::Continue
}
}
#[test]
fn editor_bar_cursor_position_reaches_the_terminal_frame() {
let mut driver = TuiDriver::new(EditorCursorApp::new(3), 40, 10);
assert_eq!(
driver.terminal_cursor_position(),
Some((3, 0)),
"draw_editor's cursor_position must reach Frame::set_cursor_position"
);
}
#[test]
fn editor_bar_cursor_position_updates_across_frames() {
let mut driver = TuiDriver::new(EditorCursorApp::new(3), 40, 10);
assert_eq!(driver.terminal_cursor_position(), Some((3, 0)));
driver.app_mut().cursor_col = 7;
driver.render();
assert_eq!(
driver.terminal_cursor_position(),
Some((7, 0)),
"a later frame's draw_editor call must overwrite the previous cursor position"
);
}
#[test]
fn draw_editor_result_maps_cell_cursor_to_native_point() {
let mut driver = TuiDriver::new(EditorCursorApp::new(3), 40, 10);
driver.render();
let result = driver
.app()
.last_result
.borrow()
.clone()
.expect("render() must call draw_editor and capture its result");
assert_eq!(
result.cursor_position_native,
Some(Point::new(3.0, 0.0)),
"cursor_position_native must carry the cell-rounded (x, y) pair widened to f32"
);
}
}