use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use std::time::{Duration, Instant};
use crate::backend::{activity_bar_hits, tab_bar_hits_from_layout, ColorDepth};
use crate::dispatch::TextRegion;
use crate::testing::ZoneRec;
use crate::{
Accelerator, AcceleratorId, ActivityBar, Backend, Color, CommandLine, DragState, Form,
ListView, MenuBar, ModalStack, Palette, PlatformServices, Point, Rect as QRect, Split,
StatusBar, TabBar, TabBarLayout, TabChrome, TabFrame, Terminal as TerminalPrim,
TerminalCellSize, TextDisplay, TreeView, UiEvent, Viewport, WidgetId,
};
#[cfg(test)]
use crate::{AcceleratorScope, DragTarget, KeyBinding};
use ratatui::layout::Rect;
use ratatui::Frame;
use super::services::TuiPlatformServices;
use super::text::display_width;
const MIN_GAP_CELLS: f32 = 2.0;
pub struct TuiBackend {
viewport: Viewport,
modal_stack: Rc<RefCell<ModalStack>>,
drag_state: Rc<RefCell<DragState>>,
core: crate::backend_core::BackendCore,
services: TuiPlatformServices,
current_frame_ptr: Cell<*mut ()>,
current_theme: crate::Theme,
nerd_fonts_enabled: bool,
double_click: super::events::DoubleClickDetector,
double_click_folding: bool,
zones: Vec<ZoneRec>,
cached_selection_text: String,
focused_activity_bar: Option<WidgetId>,
last_cursor_position: Option<(u16, u16)>,
tab_bar_layouts: HashMap<WidgetId, (QRect, TabBarLayout)>,
color_depth: ColorDepth,
kitty_keyboard: bool,
mouse_enabled: bool,
sgr_pixel_mouse: bool,
cell_pixel_size: TerminalCellSize,
underline_color_supported: bool,
focus: crate::focus::FocusManager,
user_events: std::sync::Arc<crate::runtime::UserEventQueue>,
frame_scheduler: crate::runtime::FrameScheduler,
full_repaint_requested: bool,
input_gone: bool,
minimap_sizing: Option<crate::primitives::minimap::MinimapSizing>,
editor_right_reserve_w: Option<f32>,
}
impl TuiBackend {
pub fn new() -> Self {
Self {
viewport: Viewport::default(),
modal_stack: Rc::new(RefCell::new(ModalStack::new())),
drag_state: Rc::new(RefCell::new(DragState::new())),
core: crate::backend_core::BackendCore::default(),
services: TuiPlatformServices::new(),
current_frame_ptr: Cell::new(std::ptr::null_mut()),
current_theme: crate::Theme::default(),
nerd_fonts_enabled: false,
double_click: super::events::DoubleClickDetector::new(),
double_click_folding: true,
zones: Vec::new(),
cached_selection_text: String::new(),
focused_activity_bar: None,
last_cursor_position: None,
tab_bar_layouts: HashMap::new(),
color_depth: super::caps::detect_color_depth(),
kitty_keyboard: super::caps::detect_kitty_keyboard(),
mouse_enabled: true,
sgr_pixel_mouse: super::caps::detect_sgr_pixel_mouse(),
cell_pixel_size: TerminalCellSize::new(1.0, 1.0),
underline_color_supported: super::caps::detect_underline_color_support(),
focus: crate::focus::FocusManager::new(),
user_events: crate::runtime::UserEventQueue::new(),
frame_scheduler: crate::runtime::FrameScheduler::new(),
full_repaint_requested: false,
input_gone: false,
minimap_sizing: None,
editor_right_reserve_w: None,
}
}
pub fn color_depth(&self) -> ColorDepth {
self.color_depth
}
pub(crate) fn frame_poll_timeout(&self, ceiling: Duration) -> Duration {
self.frame_scheduler.poll_timeout(ceiling)
}
pub(crate) fn clear_frame_deadline_if_due(&mut self) {
self.frame_scheduler.clear_if_due();
}
pub(crate) fn take_full_repaint_requested(&mut self) -> bool {
std::mem::take(&mut self.full_repaint_requested)
}
pub fn input_gone(&self) -> bool {
self.input_gone
}
pub fn frame_requests(&self) -> u64 {
self.frame_scheduler.requests()
}
pub fn pending_frame_delay(&self) -> Option<Duration> {
self.frame_scheduler.pending_delay()
}
pub fn set_color_depth(&mut self, depth: ColorDepth) {
self.color_depth = depth;
}
pub fn kitty_keyboard(&self) -> bool {
self.kitty_keyboard
}
pub fn set_kitty_keyboard(&mut self, supported: bool) {
self.kitty_keyboard = supported;
}
pub(crate) fn drain_user_events(&mut self) -> Vec<UiEvent> {
let mut out = Vec::new();
self.user_events.drain_into(&mut out);
out
}
pub fn mouse_enabled(&self) -> bool {
self.mouse_enabled
}
pub(crate) fn tui_services(&self) -> &TuiPlatformServices {
&self.services
}
pub fn set_mouse_enabled(&mut self, enabled: bool) {
self.mouse_enabled = enabled;
}
pub fn sgr_pixel_mouse(&self) -> bool {
self.sgr_pixel_mouse
}
pub fn set_sgr_pixel_mouse(&mut self, enabled: bool) {
self.sgr_pixel_mouse = enabled;
}
pub fn cell_pixel_size(&self) -> TerminalCellSize {
self.cell_pixel_size
}
pub fn set_cell_pixel_size(&mut self, size: TerminalCellSize) {
self.cell_pixel_size = size;
}
pub fn underline_color_supported(&self) -> bool {
self.underline_color_supported
}
pub fn set_underline_color_supported(&mut self, supported: bool) {
self.underline_color_supported = supported;
}
pub(crate) fn strip_unsupported_underline_colors(&self, buf: &mut ratatui::buffer::Buffer) {
if self.underline_color_supported {
return;
}
for cell in &mut buf.content {
cell.underline_color = ratatui::style::Color::Reset;
}
}
pub fn enter_frame_scope<R>(
&mut self,
frame: &mut Frame<'_>,
f: impl FnOnce(&mut Self) -> R,
) -> R {
let ptr = frame as *mut Frame<'_> as *mut ();
let prev = self.current_frame_ptr.replace(ptr);
let result = f(self);
self.current_frame_ptr.set(prev);
result
}
fn current_frame_mut(&mut self) -> Option<&mut Frame<'static>> {
let ptr = self.current_frame_ptr.get();
if ptr.is_null() {
None
} else {
Some(unsafe { &mut *(ptr as *mut Frame<'static>) })
}
}
pub fn set_current_theme(&mut self, theme: crate::Theme) {
self.current_theme = theme;
}
pub(crate) fn zones(&self) -> &[ZoneRec] {
&self.zones
}
pub(crate) fn cached_tab_bar_layout(&self, id: &WidgetId) -> Option<(QRect, &TabBarLayout)> {
self.tab_bar_layouts
.get(id)
.map(|(rect, layout)| (*rect, layout))
}
#[cfg(test)]
pub(crate) fn text_regions(&self) -> &[TextRegion] {
&self.core.text_selection.text_regions
}
pub(crate) fn active_text_selection(
&self,
) -> Option<&crate::text_selection::ActiveTextSelection> {
self.core.active_text_selection()
}
pub(crate) fn set_active_text_selection(
&mut self,
region: WidgetId,
anchor: Point,
focus: Point,
) {
self.core.set_active_text_selection(region, anchor, focus);
}
pub(crate) fn clear_text_selection(&mut self) {
let mut drag_state = self.drag_state.borrow_mut();
self.core.clear_text_selection(&mut drag_state);
}
pub(crate) fn clear_selection_display(&mut self) {
self.core.clear_selection_display();
}
fn cancel_text_selection_drag_impl(&mut self) {
let mut drag_state = self.drag_state.borrow_mut();
self.core.cancel_text_selection_drag(&mut drag_state);
}
pub(crate) fn apply_selection_highlight(&mut self, buf: &mut ratatui::buffer::Buffer) {
let sel = match self.core.text_selection.active_text_selection() {
Some(s) => s,
None => return,
};
let region = match self.core.text_selection.find_region(&sel.region) {
Some(r) => r,
None => return,
};
let bounds = crate::event::Rect::new(
region.bounds.x,
region.bounds.y,
region.bounds.width,
region.bounds.height,
);
let ranges = crate::dispatch::text_selection_line_range(sel.anchor, sel.focus, bounds);
let area = buf.area;
let mut lines: Vec<String> = Vec::with_capacity(ranges.len());
for &(row, col_start, col_end) in &ranges {
if row >= area.y + area.height {
continue;
}
let mut line = String::new();
for col in (col_start as u16)..(col_end as u16) {
if col < area.x + area.width {
line.push_str(buf[(col, row)].symbol());
}
}
let trimmed = line.trim_end_matches(|c: char| c.is_whitespace() || c == '\0');
lines.push(trimmed.to_string());
}
self.cached_selection_text = lines.join("\n");
for (row, col_start, col_end) in ranges {
for col in (col_start as u16)..(col_end as u16) {
if col < area.x + area.width && row < area.y + area.height {
let cell = &mut buf[(col, row)];
let fg = cell.fg;
let bg = cell.bg;
cell.fg = bg;
cell.bg = fg;
}
}
}
}
pub(crate) fn cached_selection_text(&self) -> String {
self.cached_selection_text.clone()
}
pub(crate) fn take_last_cursor_position(&mut self) -> Option<(u16, u16)> {
self.last_cursor_position.take()
}
pub(crate) fn select_all_text_region(&mut self) -> bool {
self.core.select_all_text_region()
}
pub(crate) fn focused_activity_bar_id(&self) -> Option<&WidgetId> {
self.focused_activity_bar.as_ref()
}
#[cfg(test)]
pub(crate) fn extract_selection_text(&self, buf: &ratatui::buffer::Buffer) -> String {
let sel = match self.core.text_selection.active_text_selection() {
Some(s) => s,
None => return String::new(),
};
let region = match self.core.text_selection.find_region(&sel.region) {
Some(r) => r,
None => return String::new(),
};
let bounds = crate::event::Rect::new(
region.bounds.x,
region.bounds.y,
region.bounds.width,
region.bounds.height,
);
let ranges = crate::dispatch::text_selection_line_range(sel.anchor, sel.focus, bounds);
let area = buf.area;
let mut lines: Vec<String> = Vec::with_capacity(ranges.len());
for (row, col_start, col_end) in ranges {
if row >= area.y + area.height {
continue;
}
let mut line = String::new();
for col in (col_start as u16)..(col_end as u16) {
if col < area.x + area.width {
line.push_str(buf[(col, row)].symbol());
}
}
let trimmed = line.trim_end_matches(|c: char| c.is_whitespace() || c == '\0');
lines.push(trimmed.to_string());
}
lines.join("\n")
}
fn apply_dispatch(&mut self, raw: Vec<UiEvent>) -> Vec<UiEvent> {
let mut out = Vec::with_capacity(raw.len());
for event in raw {
match event {
UiEvent::MouseDown { .. }
| UiEvent::MouseMoved { .. }
| UiEvent::MouseUp { .. } => {
let modal_stack = self.modal_stack.borrow();
let mut drag_state = self.drag_state.borrow_mut();
out.extend(crate::dispatch::route_pointer(
&modal_stack,
&mut drag_state,
&mut self.core.text_selection,
event,
));
}
UiEvent::KeyPressed {
key,
modifiers,
repeat,
} => {
if let Some(id) = self.focused_activity_bar {
let key_str =
crate::primitives::activity_bar::key_to_activity_bar_string(&key);
out.push(UiEvent::ActivityBar(
id,
crate::ActivityBarEvent::KeyPressed {
key: key_str,
modifiers,
},
));
} else {
out.push(UiEvent::KeyPressed {
key,
modifiers,
repeat,
});
}
}
other => out.push(other),
}
}
out
}
pub(crate) fn translate_injected(&mut self, raw: Vec<UiEvent>) -> Vec<UiEvent> {
let mut out = self.apply_dispatch(raw);
self.apply_accelerators(&mut out);
if self.double_click_folding {
self.double_click.process(&mut out);
}
out
}
pub(crate) fn set_double_click_folding(&mut self, enabled: bool) {
self.double_click_folding = enabled;
}
fn apply_accelerators(&self, events: &mut [UiEvent]) {
self.core.apply_accelerators(events);
}
fn match_keypress(
&self,
key: &crate::Key,
modifiers: crate::Modifiers,
) -> Option<AcceleratorId> {
self.core.match_keypress(key, modifiers)
}
fn drain_native_batch(&mut self, poll_timeout: Duration) -> Vec<UiEvent> {
match ratatui::crossterm::event::poll(poll_timeout) {
Ok(true) => {}
Ok(false) => {
let mut out = Vec::new();
self.user_events.drain_into(&mut out);
return out;
}
Err(_) => {
let mut out = Vec::new();
self.user_events.drain_into(&mut out);
return out;
}
}
let mut raw = Vec::new();
match ratatui::crossterm::event::read() {
Ok(ev) => raw.extend(super::events::crossterm_to_uievents_scaled(
ev,
self.cell_pixel_size,
)),
Err(_) => {
let mut out = Vec::new();
self.user_events.drain_into(&mut out);
return out;
}
}
while ratatui::crossterm::event::poll(Duration::ZERO).unwrap_or(false) {
match ratatui::crossterm::event::read() {
Ok(ev) => raw.extend(super::events::crossterm_to_uievents_scaled(
ev,
self.cell_pixel_size,
)),
Err(_) => break,
}
}
let raw = recover_leaked_sgr_mouse_fragments(raw, self.cell_pixel_size);
let coalesced = coalesce_mouse_moved(raw);
let mut out = self.apply_dispatch(coalesced);
self.apply_accelerators(&mut out);
self.double_click.process(&mut out);
self.user_events.drain_into(&mut out);
out
}
}
impl Default for TuiBackend {
fn default() -> Self {
Self::new()
}
}
impl crate::runtime::PreprocessBackend for TuiBackend {
fn active_text_selection(&self) -> Option<&crate::text_selection::ActiveTextSelection> {
self.active_text_selection()
}
fn set_active_text_selection(&mut self, region: WidgetId, anchor: Point, focus: Point) {
self.set_active_text_selection(region, anchor, focus)
}
fn clear_text_selection(&mut self) {
self.clear_text_selection()
}
fn clear_selection_display(&mut self) {
self.clear_selection_display()
}
fn select_all_text_region(&mut self) -> bool {
self.select_all_text_region()
}
fn selection_text_for_copy(&self) -> String {
self.cached_selection_text()
}
fn focused_activity_bar_id(&self) -> Option<&WidgetId> {
self.focused_activity_bar_id()
}
fn focus_manager_mut(&mut self) -> &mut crate::focus::FocusManager {
&mut self.focus
}
fn match_keypress(
&self,
key: &crate::Key,
modifiers: crate::Modifiers,
) -> Option<AcceleratorId> {
self.match_keypress(key, modifiers)
}
fn fold_double_click(&mut self, ev: UiEvent) -> UiEvent {
ev
}
fn is_copy_keypress(&self, key: &crate::Key, modifiers: &crate::Modifiers) -> bool {
matches!(key, crate::Key::Char('c') | crate::Key::Char('C'))
&& modifiers.ctrl
&& !modifiers.alt
&& !modifiers.cmd
}
}
fn q_rect_to_ratatui(r: QRect) -> Rect {
let x = r.x.max(0.0).round() as u16;
let y = r.y.max(0.0).round() as u16;
let w = r.width.max(0.0).round() as u16;
let h = r.height.max(0.0).round() as u16;
Rect::new(x, y, w, h)
}
fn tab_bar_chrome_layout(bar: &TabBar, area: Rect, chrome: &TabChrome) -> TabBarLayout {
let close_cols = if bar.show_tab_close {
crate::tui::TAB_CLOSE_COLS as usize
} else {
0
};
let brackets = matches!(chrome.active_frame, TabFrame::Brackets);
let tab_widths: Vec<usize> = bar
.tabs
.iter()
.map(|t| {
let has_close = bar.show_tab_close && t.is_closable;
let is_bracket = brackets && t.is_active;
let base = display_width(&t.label);
if is_bracket && has_close {
base + 1 + 1 + 1
} else if is_bracket {
base + 2
} else if has_close {
base + close_cols
} else {
base + crate::tui::TAB_SEPARATOR_COLS as usize
}
})
.collect();
bar.layout(
area.width as f32,
area.height as f32,
0.0,
|i| {
let has_close = bar.show_tab_close && bar.tabs[i].is_closable;
let is_bracket = brackets && bar.tabs[i].is_active;
if is_bracket && has_close {
crate::TabMeasure::new(tab_widths[i] as f32, 1.0).with_trailing(1.0)
} else if has_close {
crate::TabMeasure::new(tab_widths[i] as f32, close_cols as f32)
} else {
crate::TabMeasure::new(tab_widths[i] as f32, 0.0)
}
},
|i| crate::SegmentMeasure::new(bar.right_segments[i].width_cells as f32),
)
}
fn try_reassemble_sgr_mouse(
events: &[UiEvent],
cell_size: TerminalCellSize,
) -> Option<(usize, UiEvent)> {
let UiEvent::KeyPressed {
key: crate::Key::Char('<'),
repeat: false,
..
} = events.get(1)?
else {
return None;
};
let mut body = String::new();
let mut consumed = 2; let mut terminator = None;
for ev in events.iter().skip(2).take(24) {
let UiEvent::KeyPressed {
key: crate::Key::Char(c),
repeat: false,
..
} = ev
else {
return None;
};
consumed += 1;
if *c == 'M' || *c == 'm' {
terminator = Some(*c);
break;
}
if c.is_ascii_digit() || *c == ';' {
body.push(*c);
} else {
return None;
}
}
let terminator = terminator?;
let mut parts = body.split(';');
let cb: u16 = parts.next()?.parse().ok()?;
let x: u16 = parts.next()?.parse().ok()?;
let y: u16 = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
let event = decode_sgr_mouse_report(cb, x, y, terminator == 'm', cell_size)?;
Some((consumed, event))
}
fn decode_sgr_mouse_report(
cb: u16,
x: u16,
y: u16,
is_release: bool,
cell_size: TerminalCellSize,
) -> Option<UiEvent> {
use ratatui::crossterm::event::{
KeyModifiers, MouseButton as CtMouseButton, MouseEvent as CtMouseEvent, MouseEventKind,
};
let button_number = ((cb & 0b11) | ((cb & 0b1100_0000) >> 4)) as u8;
let dragging = cb & 0b0010_0000 != 0;
let mut modifiers = KeyModifiers::empty();
if cb & 0b0000_0100 != 0 {
modifiers |= KeyModifiers::SHIFT;
}
if cb & 0b0000_1000 != 0 {
modifiers |= KeyModifiers::ALT;
}
if cb & 0b0001_0000 != 0 {
modifiers |= KeyModifiers::CONTROL;
}
let kind = match (button_number, dragging) {
(0, false) => MouseEventKind::Down(CtMouseButton::Left),
(1, false) => MouseEventKind::Down(CtMouseButton::Middle),
(2, false) => MouseEventKind::Down(CtMouseButton::Right),
(0, true) => MouseEventKind::Drag(CtMouseButton::Left),
(1, true) => MouseEventKind::Drag(CtMouseButton::Middle),
(2, true) => MouseEventKind::Drag(CtMouseButton::Right),
(3, false) => MouseEventKind::Up(CtMouseButton::Left),
(3, true) | (4, true) | (5, true) => MouseEventKind::Moved,
(4, false) => MouseEventKind::ScrollUp,
(5, false) => MouseEventKind::ScrollDown,
(6, false) => MouseEventKind::ScrollLeft,
(7, false) => MouseEventKind::ScrollRight,
_ => return None,
};
let kind = if is_release {
match kind {
MouseEventKind::Down(b) => MouseEventKind::Up(b),
other => other,
}
} else {
kind
};
super::events::crossterm_mouse_to_uievent_scaled(
CtMouseEvent {
kind,
column: x.saturating_sub(1),
row: y.saturating_sub(1),
modifiers,
},
cell_size,
)
}
fn recover_leaked_sgr_mouse_fragments(
events: Vec<UiEvent>,
cell_size: TerminalCellSize,
) -> Vec<UiEvent> {
let mut out = Vec::with_capacity(events.len());
let mut i = 0;
while i < events.len() {
let opens_escape = matches!(
events[i],
UiEvent::KeyPressed {
key: crate::Key::Char('['),
repeat: false,
..
}
);
if opens_escape {
if let Some((consumed, mouse_event)) = try_reassemble_sgr_mouse(&events[i..], cell_size)
{
out.push(mouse_event);
i += consumed;
continue;
}
}
out.push(events[i].clone());
i += 1;
}
out
}
fn coalesce_mouse_moved(raw: Vec<UiEvent>) -> Vec<UiEvent> {
let mut out = Vec::with_capacity(raw.len());
let mut pending_move: Option<UiEvent> = None;
for ev in raw {
match ev {
UiEvent::MouseMoved { .. } => {
pending_move = Some(ev);
}
other => {
if let Some(m) = pending_move.take() {
out.push(m);
}
out.push(other);
}
}
}
if let Some(m) = pending_move.take() {
out.push(m);
}
out
}
#[cfg(unix)]
pub(crate) enum PollOutcome {
Ready(libc::c_short),
TimedOut,
Interrupted,
Error,
}
#[cfg(unix)]
impl PollOutcome {
pub(crate) fn revents(self) -> Option<libc::c_short> {
match self {
PollOutcome::Ready(revents) => Some(revents),
PollOutcome::TimedOut | PollOutcome::Interrupted | PollOutcome::Error => None,
}
}
}
#[cfg(unix)]
pub(crate) fn poll_fd_revents(fd: std::os::unix::io::RawFd, timeout_ms: i32) -> PollOutcome {
let mut pollfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
let ready = unsafe { libc::poll(&mut pollfd, 1, timeout_ms) };
if ready == 0 {
return PollOutcome::TimedOut;
}
if ready < 0 {
return if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
PollOutcome::Interrupted
} else {
PollOutcome::Error
};
}
PollOutcome::Ready(pollfd.revents)
}
#[cfg(unix)]
pub(crate) fn stdin_hung_up() -> bool {
use std::os::unix::io::AsRawFd;
if !stdin_guard_active() {
return false;
}
let stdin = std::io::stdin();
let fd = stdin.as_raw_fd();
match poll_fd_revents(fd, 0).revents() {
Some(revents) => revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0,
None => false,
}
}
#[cfg(not(unix))]
pub(crate) fn stdin_hung_up() -> bool {
false
}
#[cfg(unix)]
fn stdin_guard_active() -> bool {
let is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) } == 1;
is_tty || std::io::Error::last_os_error().raw_os_error() == Some(libc::EIO)
}
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) enum StdinReadiness {
Ready,
HungUp,
TimedOut,
Unguarded,
}
#[cfg(unix)]
pub(crate) fn wait_for_stdin_ready(deadline: Option<Instant>) -> StdinReadiness {
use std::os::unix::io::AsRawFd;
if !stdin_guard_active() {
return StdinReadiness::Unguarded;
}
let fd = std::io::stdin().as_raw_fd();
loop {
let (timeout_ms, at_or_past_deadline) = match deadline {
Some(deadline) => {
let remaining = deadline.saturating_duration_since(Instant::now());
let ms =
remaining.as_millis() + u128::from(remaining.subsec_nanos() % 1_000_000 != 0);
let clamped_ms = if ms > i32::MAX as u128 {
i32::MAX
} else {
ms as i32
};
(clamped_ms, remaining.is_zero())
}
None => (-1, false),
};
match poll_fd_revents(fd, timeout_ms) {
PollOutcome::Ready(revents) => {
return if revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 {
StdinReadiness::HungUp
} else {
StdinReadiness::Ready
};
}
PollOutcome::TimedOut | PollOutcome::Interrupted => {
if at_or_past_deadline {
return StdinReadiness::TimedOut;
}
continue;
}
PollOutcome::Error => return StdinReadiness::TimedOut,
}
}
}
#[cfg(not(unix))]
pub(crate) fn wait_for_stdin_ready(_deadline: Option<Instant>) -> StdinReadiness {
StdinReadiness::Unguarded
}
impl crate::backend::sealed::Sealed for TuiBackend {}
impl Backend for TuiBackend {
fn viewport(&self) -> Viewport {
self.viewport
}
fn begin_frame(&mut self, viewport: Viewport) {
self.viewport = viewport;
self.core.begin_frame();
self.zones.clear();
self.focused_activity_bar = None;
self.last_cursor_position = None;
self.tab_bar_layouts.clear();
self.modal_stack.borrow_mut().reset_frame_paint();
}
fn register_text_region(&mut self, region: TextRegion) {
self.core.register_text_region(region);
}
fn register_zone(&mut self, id: WidgetId, bounds: QRect) {
self.zones.push(ZoneRec { id, bounds });
}
fn cancel_text_selection_drag(&mut self) {
self.cancel_text_selection_drag_impl();
}
fn end_frame(&mut self) {
#[cfg(debug_assertions)]
for id in self.modal_stack.borrow().unpainted_ids() {
crate::diagnostics::emit(crate::modal_stack::ModalStack::unpainted_modal_message(&id));
}
}
fn set_theme(&mut self, theme: crate::Theme) {
self.set_current_theme(theme);
}
fn theme(&self) -> crate::Theme {
self.current_theme
}
fn set_nerd_fonts(&mut self, enabled: bool) {
self.nerd_fonts_enabled = enabled;
}
fn nerd_fonts_enabled(&self) -> bool {
self.nerd_fonts_enabled
}
fn poll_events(&mut self) -> Vec<UiEvent> {
if stdin_hung_up() {
self.input_gone = true;
let mut out = Vec::new();
self.user_events.drain_into(&mut out);
return out;
}
let mut raw = Vec::new();
while ratatui::crossterm::event::poll(Duration::ZERO).unwrap_or(false) {
match ratatui::crossterm::event::read() {
Ok(ev) => raw.extend(super::events::crossterm_to_uievents_scaled(
ev,
self.cell_pixel_size,
)),
Err(_) => break,
}
}
let raw = recover_leaked_sgr_mouse_fragments(raw, self.cell_pixel_size);
let coalesced = coalesce_mouse_moved(raw);
let mut out = self.apply_dispatch(coalesced);
self.apply_accelerators(&mut out);
self.double_click.process(&mut out);
self.user_events.drain_into(&mut out);
out
}
fn wait_events(&mut self, timeout: Duration) -> Vec<UiEvent> {
if self.input_gone {
if !timeout.is_zero() {
std::thread::sleep(timeout);
}
let mut out = Vec::new();
self.user_events.drain_into(&mut out);
return out;
}
if stdin_hung_up() {
self.input_gone = true;
let mut out = Vec::new();
self.user_events.drain_into(&mut out);
return out;
}
let immediate = self.drain_native_batch(Duration::ZERO);
if !immediate.is_empty() {
return immediate;
}
let deadline = Instant::now().checked_add(timeout);
loop {
match wait_for_stdin_ready(deadline) {
StdinReadiness::HungUp => {
self.input_gone = true;
let mut out = Vec::new();
self.user_events.drain_into(&mut out);
return out;
}
StdinReadiness::TimedOut => {
let mut out = Vec::new();
self.user_events.drain_into(&mut out);
return out;
}
StdinReadiness::Ready => {
let out = self.drain_native_batch(Duration::ZERO);
if out.is_empty() {
continue;
}
return out;
}
StdinReadiness::Unguarded => return self.drain_native_batch(timeout),
}
}
}
fn waker(&self) -> std::sync::Arc<dyn Fn(crate::UserPayload) + Send + Sync> {
let queue = std::sync::Arc::clone(&self.user_events);
std::sync::Arc::new(move |payload: crate::UserPayload| {
queue.push(payload.into_arc());
})
}
fn request_frame_in(&self, delay: Duration) {
self.frame_scheduler.request(delay);
}
fn request_full_repaint(&mut self) {
self.full_repaint_requested = true;
}
fn invalidate_rect(&mut self, rect: crate::event::Rect) {
self.core.record_invalidation(rect);
}
fn invalidation_log(&self) -> &crate::backend::InvalidationLog {
self.core.invalidation_log()
}
fn register_accelerator(&mut self, acc: &Accelerator) {
self.core.register_accelerator(acc);
}
fn unregister_accelerator(&mut self, id: &AcceleratorId) {
self.core.unregister_accelerator(id);
}
fn menu_style(&self) -> crate::backend::MenuStyle {
self.core.menu_style()
}
fn set_menu_style(&mut self, style: crate::backend::MenuStyle) {
self.core.set_menu_style(style);
}
fn modal_stack_handle(&self) -> Rc<RefCell<ModalStack>> {
self.modal_stack.clone()
}
fn drag_state_handle(&self) -> Rc<RefCell<DragState>> {
self.drag_state.clone()
}
fn focus_manager(&self) -> &crate::focus::FocusManager {
&self.focus
}
fn draw_focus_ring(&mut self, rect: QRect) {
let theme = self.current_theme;
let area = q_rect_to_ratatui(rect);
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_focus_ring called outside enter_frame_scope"
);
return;
};
crate::tui::draw_focus_ring(frame.buffer_mut(), area, &theme);
self.register_zone(WidgetId::new("chrome:focus-ring"), rect);
}
fn services(&self) -> &dyn PlatformServices {
&self.services
}
fn backend_caps(&self) -> crate::backend::BackendCaps {
crate::backend::BackendCaps {
mouse: true,
scroll: true,
drag: true,
text_selection: true,
window_control: true,
color_depth: self.color_depth,
kitty_keyboard: self.kitty_keyboard,
sgr_pixel_mouse: self.sgr_pixel_mouse,
..crate::backend::BackendCaps::empty()
}
}
fn line_height(&self) -> f32 {
1.0
}
fn char_width(&self) -> f32 {
1.0
}
fn list_char_width(&self) -> f32 {
1.0
}
fn measure_text(&self, text: &str, _role: crate::FontRole) -> (f32, f32) {
(display_width(text) as f32, 1.0)
}
fn default_fonts(&self) -> crate::backend::PlatformFontDefaults {
crate::backend::PlatformFontDefaults {
editor_family: String::new(),
editor_size_pt: 0.0,
ui_family: String::new(),
ui_size_pt: 0.0,
}
}
fn terminal_scrollbar_default_width(&self) -> f32 {
1.0
}
fn snap_height(&self, h: f32) -> f32 {
h.max(0.0).round()
}
fn context_menu_border_inset(&self) -> f32 {
super::context_menu::BORDER_INSET
}
fn draw_tree(&mut self, rect: QRect, tree: &TreeView) {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let nerd_fonts = self.nerd_fonts_enabled;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_tree called outside enter_frame_scope"
);
return;
};
crate::tui::draw_tree(frame.buffer_mut(), area, tree, &theme, nerd_fonts);
}
fn draw_list(&mut self, rect: QRect, list: &ListView) {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let nerd_fonts = self.nerd_fonts_enabled;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_list called outside enter_frame_scope"
);
return;
};
crate::tui::draw_list(frame.buffer_mut(), area, list, &theme, nerd_fonts);
}
fn draw_data_table(
&mut self,
rect: QRect,
table: &crate::DataTable,
hovered_idx: Option<usize>,
) -> crate::DataTableLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_data_table called outside enter_frame_scope"
);
return self.data_table_layout(rect, table);
};
crate::tui::draw_data_table(frame.buffer_mut(), area, table, &theme, hovered_idx)
}
fn draw_data_table_with_glyphs(
&mut self,
rect: QRect,
table: &crate::DataTable,
hovered_idx: Option<usize>,
glyphs: Option<&crate::primitives::data_table::DataTableGlyphs>,
) -> crate::DataTableLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let nerd_fonts = self.nerd_fonts_enabled;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_data_table_with_glyphs called outside enter_frame_scope"
);
return self.data_table_layout(rect, table);
};
crate::tui::draw_data_table_with_glyphs(
frame.buffer_mut(),
area,
table,
&theme,
hovered_idx,
glyphs,
nerd_fonts,
)
}
fn data_table_layout(&self, rect: QRect, table: &crate::DataTable) -> crate::DataTableLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::data_table_layout(area, table)
}
fn list_hscrollbar(&self, rect: QRect, list: &ListView) -> Option<crate::Scrollbar> {
let area = q_rect_to_ratatui(rect);
list.hscrollbar(
crate::event::Rect::new(
area.x as f32,
area.y as f32,
area.width as f32,
area.height as f32,
),
1.0,
)
}
fn list_vscrollbar(&self, rect: QRect, list: &ListView) -> Option<crate::Scrollbar> {
let area = q_rect_to_ratatui(rect);
list.vscrollbar(
crate::event::Rect::new(
area.x as f32,
area.y as f32,
area.width as f32,
area.height as f32,
),
1.0,
)
}
fn list_layout(&self, rect: QRect, list: &ListView) -> crate::ListViewLayout {
crate::tui::tui_list_layout(q_rect_to_ratatui(rect), list)
}
fn draw_form(&mut self, rect: QRect, form: &Form) {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_form called outside enter_frame_scope"
);
return;
};
crate::tui::draw_form(frame.buffer_mut(), area, form, &theme);
}
fn draw_palette(&mut self, rect: QRect, palette: &Palette) {
self.modal_stack.borrow_mut().mark_painted(&palette.id);
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let nerd_fonts = self.nerd_fonts_enabled;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_palette called outside enter_frame_scope"
);
return;
};
crate::tui::draw_palette(frame.buffer_mut(), area, palette, &theme, nerd_fonts);
}
fn palette_layout(&self, rect: QRect, palette: &Palette) -> crate::PaletteLayout {
crate::tui::tui_palette_layout(q_rect_to_ratatui(rect), palette)
}
fn draw_settings_chrome(
&mut self,
rect: QRect,
header_text: &str,
query: &str,
placeholder: &str,
active: bool,
) {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_settings_chrome called outside enter_frame_scope"
);
return;
};
crate::tui::draw_settings_chrome(
frame.buffer_mut(),
area,
header_text,
query,
placeholder,
active,
&theme,
);
}
fn draw_status_bar_interactive(
&mut self,
rect: QRect,
bar: &StatusBar,
interaction: &crate::interaction::InteractionState,
) -> crate::StatusBarLayout {
let (hovered_id, pressed_id) = (interaction.hovered(), interaction.pressed());
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let layout = bar.layout(area.width as f32, 1.0, MIN_GAP_CELLS, |seg| {
crate::StatusSegmentMeasure::new(seg.text.chars().count() as f32)
});
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_status_bar called outside enter_frame_scope"
);
return layout;
};
crate::tui::draw_status_bar(
frame.buffer_mut(),
area,
bar,
&layout,
&theme,
hovered_id,
pressed_id,
)
}
#[allow(deprecated)] fn draw_tab_bar(
&mut self,
rect: QRect,
bar: &TabBar,
hovered_close_tab: Option<usize>,
) -> crate::TabBarHits {
self.draw_tab_bar_icons(rect, bar, &[], hovered_close_tab)
}
fn draw_tab_bar_layout(
&mut self,
rect: QRect,
bar: &TabBar,
hovered_close_tab: Option<usize>,
) -> TabBarLayout {
self.draw_tab_bar_icons_layout(rect, bar, &[], hovered_close_tab)
}
#[allow(deprecated)] fn draw_tab_bar_icons(
&mut self,
rect: QRect,
bar: &TabBar,
icons: &[Option<crate::TabIcon>],
_hovered_close_tab: Option<usize>,
) -> crate::TabBarHits {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let close_cols = if bar.show_tab_close {
crate::tui::TAB_CLOSE_COLS as usize
} else {
0
};
let tab_widths: Vec<usize> = bar
.tabs
.iter()
.enumerate()
.map(|(i, t)| {
let tab_close = if bar.show_tab_close && t.is_closable {
close_cols
} else {
0
};
let sep = if tab_close == 0 {
crate::tui::TAB_SEPARATOR_COLS as usize
} else {
0
};
display_width(&t.label) + tab_close + sep + crate::tab_icon_cols(icons, i) as usize
})
.collect();
let layout = bar.layout(
area.width as f32,
area.height as f32,
0.0, |i| {
let tab_close_cols = if bar.show_tab_close && bar.tabs[i].is_closable {
close_cols
} else {
0
};
crate::TabMeasure::new(tab_widths[i] as f32, tab_close_cols as f32)
},
|i| crate::SegmentMeasure::new(bar.right_segments[i].width_cells as f32),
);
self.tab_bar_layouts.insert(bar.id, (rect, layout.clone()));
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_tab_bar called outside enter_frame_scope"
);
return self.tab_bar_layout_icons(rect, bar, icons);
};
crate::tui::draw_tab_bar_icons(frame.buffer_mut(), area, bar, icons, &layout, &theme)
}
fn draw_tab_bar_icons_layout(
&mut self,
rect: QRect,
bar: &TabBar,
icons: &[Option<crate::TabIcon>],
_hovered_close_tab: Option<usize>,
) -> TabBarLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let close_cols = if bar.show_tab_close {
crate::tui::TAB_CLOSE_COLS as usize
} else {
0
};
let tab_widths: Vec<usize> = bar
.tabs
.iter()
.enumerate()
.map(|(i, t)| {
let tab_close = if bar.show_tab_close && t.is_closable {
close_cols
} else {
0
};
let sep = if tab_close == 0 {
crate::tui::TAB_SEPARATOR_COLS as usize
} else {
0
};
display_width(&t.label) + tab_close + sep + crate::tab_icon_cols(icons, i) as usize
})
.collect();
let layout = bar.layout(
area.width as f32,
area.height as f32,
0.0,
|i| {
let tab_close_cols = if bar.show_tab_close && bar.tabs[i].is_closable {
close_cols
} else {
0
};
crate::TabMeasure::new(tab_widths[i] as f32, tab_close_cols as f32)
},
|i| crate::SegmentMeasure::new(bar.right_segments[i].width_cells as f32),
);
self.tab_bar_layouts.insert(bar.id, (rect, layout.clone()));
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_tab_bar_icons_layout called outside enter_frame_scope"
);
return layout;
};
#[allow(deprecated)] let _ =
crate::tui::draw_tab_bar_icons(frame.buffer_mut(), area, bar, icons, &layout, &theme);
layout
}
#[allow(deprecated)] fn draw_tab_bar_with_chrome(
&mut self,
rect: QRect,
bar: &TabBar,
_hovered_close_tab: Option<usize>,
chrome: &TabChrome,
) -> crate::TabBarHits {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let layout = tab_bar_chrome_layout(bar, area, chrome);
self.tab_bar_layouts.insert(bar.id, (rect, layout.clone()));
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_tab_bar_with_chrome called outside enter_frame_scope"
);
return self.tab_bar_layout_with_chrome(rect, bar, chrome);
};
crate::tui::draw_tab_bar_with_chrome(frame.buffer_mut(), area, bar, chrome, &layout, &theme)
}
#[allow(deprecated)] fn draw_tab_bar_with_chrome_and_glyphs(
&mut self,
rect: QRect,
bar: &TabBar,
_hovered_close_tab: Option<usize>,
chrome: &TabChrome,
glyphs: Option<&crate::primitives::tab_bar::TabBarGlyphs>,
) -> crate::TabBarHits {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let nerd_fonts = self.nerd_fonts_enabled;
let layout = tab_bar_chrome_layout(bar, area, chrome);
self.tab_bar_layouts.insert(bar.id, (rect, layout.clone()));
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_tab_bar_with_chrome_and_glyphs called outside enter_frame_scope"
);
return self.tab_bar_layout_with_chrome(rect, bar, chrome);
};
crate::tui::draw_tab_bar_icons_with_chrome_and_glyphs(
frame.buffer_mut(),
area,
bar,
&[],
chrome,
&layout,
&theme,
glyphs,
nerd_fonts,
)
}
fn draw_tab_bar_with_chrome_layout(
&mut self,
rect: QRect,
bar: &TabBar,
_hovered_close_tab: Option<usize>,
chrome: &TabChrome,
) -> TabBarLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let close_cols = if bar.show_tab_close {
crate::tui::TAB_CLOSE_COLS as usize
} else {
0
};
let brackets = matches!(chrome.active_frame, TabFrame::Brackets);
let tab_widths: Vec<usize> = bar
.tabs
.iter()
.map(|t| {
let has_close = bar.show_tab_close && t.is_closable;
let is_bracket = brackets && t.is_active;
let base = display_width(&t.label);
if is_bracket && has_close {
base + 1 + 1 + 1
} else if is_bracket {
base + 2
} else if has_close {
base + close_cols
} else {
base + crate::tui::TAB_SEPARATOR_COLS as usize
}
})
.collect();
let layout = bar.layout(
area.width as f32,
area.height as f32,
0.0,
|i| {
let has_close = bar.show_tab_close && bar.tabs[i].is_closable;
let is_bracket = brackets && bar.tabs[i].is_active;
if is_bracket && has_close {
crate::TabMeasure::new(tab_widths[i] as f32, 1.0).with_trailing(1.0)
} else if has_close {
crate::TabMeasure::new(tab_widths[i] as f32, close_cols as f32)
} else {
crate::TabMeasure::new(tab_widths[i] as f32, 0.0)
}
},
|i| crate::SegmentMeasure::new(bar.right_segments[i].width_cells as f32),
);
self.tab_bar_layouts.insert(bar.id, (rect, layout.clone()));
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_tab_bar_with_chrome_layout called outside enter_frame_scope"
);
return layout;
};
#[allow(deprecated)] let _ = crate::tui::draw_tab_bar_with_chrome(
frame.buffer_mut(),
area,
bar,
chrome,
&layout,
&theme,
);
layout
}
fn draw_activity_bar(
&mut self,
rect: QRect,
bar: &ActivityBar,
hovered_idx: Option<usize>,
) -> Vec<crate::ActivityBarRowHit> {
if bar.is_keyboard_focused {
self.focused_activity_bar = Some(bar.id);
}
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let nerd_fonts = self.nerd_fonts_enabled;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_activity_bar called outside enter_frame_scope"
);
return self.activity_bar_layout(rect, bar);
};
crate::tui::draw_activity_bar(
frame.buffer_mut(),
area,
bar,
&theme,
hovered_idx,
nerd_fonts,
)
}
fn draw_activity_bar_with_style(
&mut self,
rect: QRect,
bar: &ActivityBar,
hovered_idx: Option<usize>,
style: &crate::ActivityBarStyle,
) -> Vec<crate::ActivityBarRowHit> {
if bar.is_keyboard_focused {
self.focused_activity_bar = Some(bar.id);
}
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let nerd_fonts = self.nerd_fonts_enabled;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_activity_bar_with_style called outside enter_frame_scope"
);
return self.activity_bar_layout(rect, bar);
};
crate::tui::draw_activity_bar_with_style(
frame.buffer_mut(),
area,
bar,
style,
&theme,
hovered_idx,
nerd_fonts,
)
}
fn status_bar_layout(&self, rect: QRect, bar: &StatusBar) -> crate::StatusBarLayout {
bar.layout(rect.width, 1.0, MIN_GAP_CELLS, |seg| {
crate::StatusSegmentMeasure::new(seg.text.chars().count() as f32)
})
}
#[allow(deprecated)] fn tab_bar_layout(&self, rect: QRect, bar: &TabBar) -> crate::TabBarHits {
self.tab_bar_layout_icons(rect, bar, &[])
}
fn resolve_tab_bar_layout(&self, rect: QRect, bar: &TabBar) -> TabBarLayout {
self.resolve_tab_bar_layout_icons(rect, bar, &[])
}
#[allow(deprecated)] fn tab_bar_layout_icons(
&self,
rect: QRect,
bar: &TabBar,
icons: &[Option<crate::TabIcon>],
) -> crate::TabBarHits {
let close_cols = if bar.show_tab_close {
crate::tui::TAB_CLOSE_COLS as usize
} else {
0
};
let tab_widths: Vec<usize> = bar
.tabs
.iter()
.enumerate()
.map(|(i, t)| {
let tab_close = if bar.show_tab_close && t.is_closable {
close_cols
} else {
0
};
let sep = if tab_close == 0 {
crate::tui::TAB_SEPARATOR_COLS as usize
} else {
0
};
display_width(&t.label) + tab_close + sep + crate::tab_icon_cols(icons, i) as usize
})
.collect();
let layout = bar.layout(
rect.width,
rect.height,
0.0,
|i| {
let tab_close_cols = if bar.show_tab_close && bar.tabs[i].is_closable {
close_cols
} else {
0
};
crate::TabMeasure::new(tab_widths[i] as f32, tab_close_cols as f32)
},
|i| crate::SegmentMeasure::new(bar.right_segments[i].width_cells as f32),
);
let mut hits = tab_bar_hits_from_layout(&layout, bar);
crate::backend::shift_tab_bar_hits(&mut hits, rect.x as f64);
let active_idx = bar.tabs.iter().position(|t| t.is_active);
let reserved: usize = bar
.right_segments
.iter()
.map(|s| s.width_cells as usize)
.sum();
let effective_tab_area = (rect.width as usize).saturating_sub(reserved);
hits.correct_scroll_offset = if let Some(active) = active_idx {
TabBar::fit_active_scroll_offset(active, bar.tabs.len(), effective_tab_area, |i| {
tab_widths[i]
})
} else {
bar.scroll_offset
};
hits
}
fn resolve_tab_bar_layout_icons(
&self,
rect: QRect,
bar: &TabBar,
icons: &[Option<crate::TabIcon>],
) -> TabBarLayout {
let close_cols = if bar.show_tab_close {
crate::tui::TAB_CLOSE_COLS as usize
} else {
0
};
let tab_widths: Vec<usize> = bar
.tabs
.iter()
.enumerate()
.map(|(i, t)| {
let tab_close = if bar.show_tab_close && t.is_closable {
close_cols
} else {
0
};
let sep = if tab_close == 0 {
crate::tui::TAB_SEPARATOR_COLS as usize
} else {
0
};
display_width(&t.label) + tab_close + sep + crate::tab_icon_cols(icons, i) as usize
})
.collect();
bar.layout(
rect.width,
rect.height,
0.0,
|i| {
let tab_close_cols = if bar.show_tab_close && bar.tabs[i].is_closable {
close_cols
} else {
0
};
crate::TabMeasure::new(tab_widths[i] as f32, tab_close_cols as f32)
},
|i| crate::SegmentMeasure::new(bar.right_segments[i].width_cells as f32),
)
}
#[allow(deprecated)] fn tab_bar_layout_with_chrome(
&self,
rect: QRect,
bar: &TabBar,
chrome: &TabChrome,
) -> crate::TabBarHits {
let close_cols = if bar.show_tab_close {
crate::tui::TAB_CLOSE_COLS as usize
} else {
0
};
let brackets = matches!(chrome.active_frame, TabFrame::Brackets);
let tab_widths: Vec<usize> = bar
.tabs
.iter()
.map(|t| {
let has_close = bar.show_tab_close && t.is_closable;
let is_bracket = brackets && t.is_active;
let base = display_width(&t.label);
if is_bracket && has_close {
base + 1 + 1 + 1
} else if is_bracket {
base + 2
} else if has_close {
base + close_cols
} else {
base + crate::tui::TAB_SEPARATOR_COLS as usize
}
})
.collect();
let layout = bar.layout(
rect.width,
rect.height,
0.0,
|i| {
let has_close = bar.show_tab_close && bar.tabs[i].is_closable;
let is_bracket = brackets && bar.tabs[i].is_active;
if is_bracket && has_close {
crate::TabMeasure::new(tab_widths[i] as f32, 1.0).with_trailing(1.0)
} else if has_close {
crate::TabMeasure::new(tab_widths[i] as f32, close_cols as f32)
} else {
crate::TabMeasure::new(tab_widths[i] as f32, 0.0)
}
},
|i| crate::SegmentMeasure::new(bar.right_segments[i].width_cells as f32),
);
let mut hits = tab_bar_hits_from_layout(&layout, bar);
crate::backend::shift_tab_bar_hits(&mut hits, rect.x as f64);
let active_idx = bar.tabs.iter().position(|t| t.is_active);
let reserved: usize = bar
.right_segments
.iter()
.map(|s| s.width_cells as usize)
.sum();
let effective_tab_area = (rect.width as usize).saturating_sub(reserved);
hits.correct_scroll_offset = if let Some(active) = active_idx {
TabBar::fit_active_scroll_offset(active, bar.tabs.len(), effective_tab_area, |i| {
tab_widths[i]
})
} else {
bar.scroll_offset
};
hits
}
fn resolve_tab_bar_layout_with_chrome(
&self,
rect: QRect,
bar: &TabBar,
chrome: &TabChrome,
) -> TabBarLayout {
let close_cols = if bar.show_tab_close {
crate::tui::TAB_CLOSE_COLS as usize
} else {
0
};
let brackets = matches!(chrome.active_frame, TabFrame::Brackets);
let tab_widths: Vec<usize> = bar
.tabs
.iter()
.map(|t| {
let has_close = bar.show_tab_close && t.is_closable;
let is_bracket = brackets && t.is_active;
let base = display_width(&t.label);
if is_bracket && has_close {
base + 1 + 1 + 1
} else if is_bracket {
base + 2
} else if has_close {
base + close_cols
} else {
base + crate::tui::TAB_SEPARATOR_COLS as usize
}
})
.collect();
bar.layout(
rect.width,
rect.height,
0.0,
|i| {
let has_close = bar.show_tab_close && bar.tabs[i].is_closable;
let is_bracket = brackets && bar.tabs[i].is_active;
if is_bracket && has_close {
crate::TabMeasure::new(tab_widths[i] as f32, 1.0).with_trailing(1.0)
} else if has_close {
crate::TabMeasure::new(tab_widths[i] as f32, close_cols as f32)
} else {
crate::TabMeasure::new(tab_widths[i] as f32, 0.0)
}
},
|i| crate::SegmentMeasure::new(bar.right_segments[i].width_cells as f32),
)
}
fn activity_bar_layout(&self, rect: QRect, bar: &ActivityBar) -> Vec<crate::ActivityBarRowHit> {
let lh = 1.0_f32;
activity_bar_hits(rect, bar, lh)
}
fn draw_terminal(&mut self, rect: QRect, term: &TerminalPrim) {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_terminal called outside enter_frame_scope"
);
return;
};
crate::tui::draw_terminal(frame.buffer_mut(), area, term, &theme);
}
fn draw_terminal_divider(&mut self, rect: QRect) {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_terminal_divider called outside enter_frame_scope"
);
return;
};
crate::tui::draw_terminal_divider(frame.buffer_mut(), area.x, area.y, area.height, &theme);
self.register_zone(WidgetId::new("chrome:terminal-divider"), rect);
}
fn draw_solid_fill(&mut self, rect: QRect, color: Color) {
let area = q_rect_to_ratatui(rect);
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_solid_fill called outside enter_frame_scope"
);
return;
};
crate::tui::draw_solid_fill(frame.buffer_mut(), area, color);
self.register_zone(WidgetId::new("chrome:solid-fill"), rect);
}
fn draw_text_display(&mut self, rect: QRect, td: &TextDisplay) {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_text_display called outside enter_frame_scope"
);
return;
};
crate::tui::draw_text_display(frame.buffer_mut(), area, td, &theme);
}
fn draw_command_line(&mut self, rect: QRect, cmd: &CommandLine) {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_command_line called outside enter_frame_scope"
);
return;
};
crate::tui::command_line::draw_command_line(frame.buffer_mut(), area, cmd, &theme);
}
fn draw_command_line_selection(
&mut self,
rect: QRect,
cmd: &CommandLine,
selection: Option<(usize, usize)>,
) {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_command_line_selection called outside enter_frame_scope"
);
return;
};
crate::tui::command_line::draw_command_line_selection(
frame.buffer_mut(),
area,
cmd,
&theme,
selection,
);
}
fn command_line_layout(
&self,
rect: QRect,
cmd: &CommandLine,
) -> crate::primitives::command_line::CommandLineLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::command_line::tui_command_line_layout(cmd, area)
}
fn text_display_layout(
&self,
rect: QRect,
td: &TextDisplay,
) -> crate::primitives::text_display::TextDisplayLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_text_display_layout(td, area)
}
fn draw_text_input(
&mut self,
rect: QRect,
ti: &crate::primitives::text_input::TextInput,
) -> crate::primitives::text_input::TextInputLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_text_input called outside enter_frame_scope"
);
return self.text_input_layout(rect, ti);
};
crate::tui::draw_text_input(frame.buffer_mut(), area, ti, &theme)
}
fn text_input_layout(
&self,
rect: QRect,
ti: &crate::primitives::text_input::TextInput,
) -> crate::primitives::text_input::TextInputLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_text_input_layout(ti, area)
}
fn draw_tooltip(&mut self, tooltip: &crate::Tooltip, layout: &crate::TooltipLayout) {
self.draw_tooltip_with_chrome(tooltip, layout, &crate::TooltipChrome::default());
}
fn draw_tooltip_with_chrome(
&mut self,
tooltip: &crate::Tooltip,
layout: &crate::TooltipLayout,
chrome: &crate::TooltipChrome,
) {
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_tooltip called outside enter_frame_scope"
);
return;
};
crate::tui::draw_tooltip_with_chrome(frame.buffer_mut(), tooltip, layout, chrome, &theme);
self.register_zone(tooltip.id, crate::tui::tooltip_painted_bounds(layout));
}
fn draw_context_menu(
&mut self,
menu: &crate::ContextMenu,
layout: &crate::ContextMenuLayout,
) -> Vec<(QRect, crate::WidgetId)> {
self.modal_stack.borrow_mut().mark_painted(&menu.id);
let theme = self.current_theme;
if let Some(frame) = self.current_frame_mut() {
crate::tui::draw_context_menu(frame.buffer_mut(), menu, layout, &theme);
} else {
debug_assert!(
false,
"TuiBackend::draw_context_menu called outside enter_frame_scope"
);
}
let _ = menu;
layout
.hit_regions
.iter()
.filter_map(|(rect, hit)| match hit {
crate::primitives::context_menu::ContextMenuHit::Item(id) => Some((*rect, *id)),
_ => None,
})
.collect()
}
fn draw_context_menu_with_glyphs(
&mut self,
menu: &crate::ContextMenu,
layout: &crate::ContextMenuLayout,
glyphs: Option<&crate::primitives::context_menu::ContextMenuGlyphs>,
) -> Vec<(QRect, crate::WidgetId)> {
self.modal_stack.borrow_mut().mark_painted(&menu.id);
let theme = self.current_theme;
let nerd_fonts = self.nerd_fonts_enabled;
if let Some(frame) = self.current_frame_mut() {
crate::tui::draw_context_menu_with_glyphs(
frame.buffer_mut(),
menu,
layout,
&theme,
glyphs,
nerd_fonts,
);
} else {
debug_assert!(
false,
"TuiBackend::draw_context_menu_with_glyphs called outside enter_frame_scope"
);
}
layout
.hit_regions
.iter()
.filter_map(|(rect, hit)| match hit {
crate::primitives::context_menu::ContextMenuHit::Item(id) => Some((*rect, *id)),
_ => None,
})
.collect()
}
fn draw_dialog(
&mut self,
dialog: &crate::primitives::dialog::Dialog,
layout: &crate::primitives::dialog::DialogLayout,
) -> Vec<QRect> {
self.modal_stack.borrow_mut().mark_painted(&dialog.id);
let theme = self.current_theme;
if let Some(frame) = self.current_frame_mut() {
crate::tui::draw_dialog(frame.buffer_mut(), dialog, layout, &theme);
} else {
debug_assert!(
false,
"TuiBackend::draw_dialog called outside enter_frame_scope"
);
}
layout
.visible_buttons
.iter()
.map(|vis| vis.bounds)
.collect()
}
fn draw_float(
&mut self,
float: &crate::primitives::float::Float,
layout: &crate::primitives::float::FloatLayout,
) {
self.modal_stack.borrow_mut().mark_painted(&float.id);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_float called outside enter_frame_scope"
);
return;
};
crate::tui::draw_float(frame.buffer_mut(), float, layout, &theme);
self.register_zone(float.id, layout.bounds);
}
fn draw_multi_section_view(
&mut self,
rect: QRect,
view: &crate::primitives::multi_section_view::MultiSectionView,
) {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let nerd_fonts = self.nerd_fonts_enabled;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_multi_section_view called outside enter_frame_scope"
);
return;
};
crate::tui::draw_multi_section_view(frame.buffer_mut(), area, view, &theme, nerd_fonts);
}
fn msv_layout(
&self,
rect: QRect,
view: &crate::primitives::multi_section_view::MultiSectionView,
) -> crate::primitives::multi_section_view::MultiSectionViewLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_msv_layout(view, area)
}
fn msv_metrics(&self) -> crate::primitives::multi_section_view::MsvLayoutMetrics {
crate::primitives::multi_section_view::MsvLayoutMetrics {
header_size: 1.0,
divider_size: 0.0,
scrollbar_size: 1.0,
cell_quantum: 1.0,
}
}
fn tree_layout(&self, rect: QRect, tree: &TreeView) -> crate::primitives::tree::TreeViewLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_tree_layout(tree, area)
}
fn tree_vscrollbar(&self, rect: QRect, tree: &TreeView) -> Option<crate::Scrollbar> {
let area = q_rect_to_ratatui(rect);
tree.vscrollbar_with_track_width(
crate::event::Rect::new(
area.x as f32,
area.y as f32,
area.width as f32,
area.height as f32,
),
1.0,
1.0,
)
}
fn form_layout(&self, rect: QRect, form: &Form) -> crate::primitives::form::FormLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_form_layout(form, area)
}
fn draw_editor(
&mut self,
rect: QRect,
editor: &crate::primitives::editor::Editor,
) -> crate::backend::EditorPaintResult {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let right_reserve = self.editor_right_reserve_w.unwrap_or(0.0).max(0.0).round() as u16;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_editor called outside enter_frame_scope"
);
return crate::backend::EditorPaintResult::default();
};
let tui_result = crate::tui::draw_editor_with_reserve(
frame.buffer_mut(),
area,
editor,
&theme,
right_reserve,
);
if tui_result.cursor_position.is_some() {
self.last_cursor_position = tui_result.cursor_position;
}
crate::backend::EditorPaintResult {
cursor_position_native: tui_result
.cursor_position
.map(|(x, y)| crate::event::Point::new(x as f32, y as f32)),
}
}
fn set_caret_shape(&mut self, shape: crate::EditorCursorShape) {
use ratatui::crossterm::cursor::SetCursorStyle;
use ratatui::crossterm::execute;
let style = match shape {
crate::EditorCursorShape::Block => SetCursorStyle::SteadyBlock,
crate::EditorCursorShape::Bar => SetCursorStyle::SteadyBar,
crate::EditorCursorShape::Underline => SetCursorStyle::SteadyUnderScore,
};
let _ = execute!(std::io::stdout(), style);
}
fn draw_message_list(
&mut self,
rect: QRect,
list: &crate::primitives::message_list::MessageList,
) {
let area = q_rect_to_ratatui(rect);
let panel_bg = self.current_theme.background;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_message_list called outside enter_frame_scope"
);
return;
};
crate::tui::draw_message_list(frame.buffer_mut(), area, list, panel_bg);
}
fn draw_rich_text_popup(
&mut self,
popup: &crate::primitives::rich_text_popup::RichTextPopup,
layout: &crate::primitives::rich_text_popup::RichTextPopupLayout,
) {
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_rich_text_popup called outside enter_frame_scope"
);
return;
};
crate::tui::draw_rich_text_popup(frame.buffer_mut(), popup, layout, &theme);
}
fn draw_find_replace(
&mut self,
rect: QRect,
panel: &crate::primitives::find_replace::FindReplacePanel,
) {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let editor_left = area.x;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_find_replace called outside enter_frame_scope"
);
return;
};
crate::tui::draw_find_replace(frame.buffer_mut(), area, panel, &theme, editor_left);
}
fn draw_completions(
&mut self,
completions: &crate::primitives::completions::Completions,
layout: &crate::primitives::completions::CompletionsLayout,
) {
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_completions called outside enter_frame_scope"
);
return;
};
crate::tui::draw_completions(frame.buffer_mut(), completions, layout, &theme);
}
fn draw_number_input(
&mut self,
number_input: &crate::primitives::number_input::NumberInput,
layout: &crate::primitives::number_input::NumberInputLayout,
) {
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_number_input called outside enter_frame_scope"
);
return;
};
crate::tui::draw_number_input(frame.buffer_mut(), number_input, layout, &theme);
self.register_zone(number_input.id, layout.bounds);
}
fn draw_combo_box(
&mut self,
combo_box: &crate::primitives::combo_box::ComboBox,
layout: &crate::primitives::combo_box::ComboBoxLayout,
) {
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_combo_box called outside enter_frame_scope"
);
return;
};
crate::tui::draw_combo_box(frame.buffer_mut(), combo_box, layout, &theme);
self.register_zone(combo_box.id, layout.field_bounds);
}
fn draw_date_picker(
&mut self,
date_picker: &crate::primitives::date_picker::DatePicker,
layout: &crate::primitives::date_picker::DatePickerLayout,
) {
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_date_picker called outside enter_frame_scope"
);
return;
};
crate::tui::draw_date_picker(frame.buffer_mut(), date_picker, layout, &theme);
self.register_zone(date_picker.id, layout.bounds());
}
fn draw_scrollbar(
&mut self,
_rect: QRect,
scrollbar: &crate::primitives::scrollbar::Scrollbar,
) {
let theme = self.current_theme;
let cell_bg = theme.background;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_scrollbar called outside enter_frame_scope"
);
return;
};
crate::tui::draw_scrollbar(frame.buffer_mut(), scrollbar, &theme, cell_bg);
self.register_zone(scrollbar.id, scrollbar.track);
}
fn draw_drop_overlay(&mut self, overlay: &crate::primitives::drop_zone::DropOverlay) {
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_drop_overlay called outside enter_frame_scope"
);
return;
};
crate::tui::draw_drop_overlay(frame.buffer_mut(), overlay, &theme);
if let Some(bounds) = overlay.highlight.or(overlay.insertion_bar) {
self.register_zone(WidgetId::new("chrome:drop-overlay"), bounds);
}
}
fn draw_menu_bar(
&mut self,
rect: QRect,
bar: &MenuBar,
) -> crate::primitives::menu_bar::MenuBarLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_menu_bar called outside enter_frame_scope"
);
return self.menu_bar_layout(rect, bar);
};
crate::tui::draw_menu_bar(frame.buffer_mut(), area, bar, &theme)
}
fn menu_bar_layout(
&self,
rect: QRect,
bar: &MenuBar,
) -> crate::primitives::menu_bar::MenuBarLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_menu_bar_layout(bar, area)
}
fn draw_split(&mut self, rect: QRect, split: &Split) -> crate::primitives::split::SplitLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_split called outside enter_frame_scope"
);
return self.split_layout(rect, split);
};
let layout = crate::tui::draw_split(frame.buffer_mut(), area, split, &theme);
self.register_zone(split.id, rect);
layout
}
fn split_layout(&self, rect: QRect, split: &Split) -> crate::primitives::split::SplitLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_split_layout(split, area)
}
fn draw_split_tree(
&mut self,
rect: QRect,
tree: &crate::primitives::split_tree::SplitTree,
) -> crate::primitives::split_tree::SplitTreeLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_split_tree called outside enter_frame_scope"
);
return self.split_tree_layout(rect, tree);
};
let layout = crate::tui::draw_split_tree(frame.buffer_mut(), area, tree, &theme);
self.register_zone(WidgetId::new("chrome:split-tree"), rect);
layout
}
fn split_tree_layout(
&self,
rect: QRect,
tree: &crate::primitives::split_tree::SplitTree,
) -> crate::primitives::split_tree::SplitTreeLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_split_tree_layout(tree, area)
}
fn draw_panel(
&mut self,
rect: QRect,
panel: &crate::primitives::panel::Panel,
) -> crate::primitives::panel::PanelLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_panel called outside enter_frame_scope"
);
return self.panel_layout(rect, panel);
};
crate::tui::draw_panel(frame.buffer_mut(), area, panel, &theme)
}
fn panel_layout(
&self,
rect: QRect,
panel: &crate::primitives::panel::Panel,
) -> crate::primitives::panel::PanelLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_panel_layout(panel, area)
}
fn draw_canvas(
&mut self,
rect: QRect,
canvas: &crate::primitives::canvas::Canvas,
) -> crate::primitives::canvas::CanvasLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_canvas called outside enter_frame_scope"
);
return self.canvas_layout(rect, canvas);
};
crate::tui::draw_canvas(frame.buffer_mut(), area, canvas, &theme)
}
fn draw_toast_overlay(
&mut self,
rect: QRect,
stack: &crate::primitives::toast::ToastOverlay,
) -> crate::primitives::toast::ToastStackLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_toast_stack called outside enter_frame_scope"
);
return self.toast_stack_layout(rect, stack);
};
crate::tui::draw_toast_stack(frame.buffer_mut(), area, stack, &theme)
}
fn toast_stack_layout(
&self,
rect: QRect,
stack: &crate::primitives::toast::ToastOverlay,
) -> crate::primitives::toast::ToastStackLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_toast_stack_layout(stack, area)
}
fn draw_pipeline_view(
&mut self,
rect: QRect,
view: &crate::primitives::pipeline_view::PipelineView,
) -> crate::primitives::pipeline_view::PipelineViewLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_pipeline_view called outside enter_frame_scope"
);
return self.pipeline_view_layout(rect, view);
};
crate::tui::draw_pipeline_view(frame.buffer_mut(), area, view, &theme)
}
fn pipeline_view_layout(
&self,
rect: QRect,
view: &crate::primitives::pipeline_view::PipelineView,
) -> crate::primitives::pipeline_view::PipelineViewLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_pipeline_view_layout(view, area)
}
fn draw_progress(
&mut self,
rect: QRect,
bar: &crate::primitives::progress::ProgressBar,
) -> crate::primitives::progress::ProgressBarLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_progress called outside enter_frame_scope"
);
return self.progress_layout(rect, bar);
};
crate::tui::draw_progress(frame.buffer_mut(), area, bar, &theme)
}
fn progress_layout(
&self,
rect: QRect,
bar: &crate::primitives::progress::ProgressBar,
) -> crate::primitives::progress::ProgressBarLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_progress_layout(bar, area)
}
fn draw_spinner(
&mut self,
rect: QRect,
spinner: &crate::primitives::spinner::Spinner,
) -> crate::primitives::spinner::SpinnerLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_spinner called outside enter_frame_scope"
);
return self.spinner_layout(rect, spinner);
};
crate::tui::draw_spinner(frame.buffer_mut(), area, spinner, &theme)
}
fn spinner_layout(
&self,
rect: QRect,
spinner: &crate::primitives::spinner::Spinner,
) -> crate::primitives::spinner::SpinnerLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_spinner_layout(spinner, area)
}
fn draw_button(
&mut self,
rect: QRect,
button: &crate::primitives::button::Button,
) -> crate::primitives::button::ButtonLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_button called outside enter_frame_scope"
);
return self.button_layout(rect, button);
};
crate::tui::draw_button(frame.buffer_mut(), area, button, &theme)
}
fn button_layout(
&self,
rect: QRect,
button: &crate::primitives::button::Button,
) -> crate::primitives::button::ButtonLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_button_layout(button, area)
}
fn draw_radio_group(
&mut self,
rect: QRect,
group: &crate::primitives::radio_group::RadioGroup,
) -> crate::primitives::radio_group::RadioGroupLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_radio_group called outside enter_frame_scope"
);
return self.radio_group_layout(rect, group);
};
crate::tui::draw_radio_group(frame.buffer_mut(), area, group, &theme)
}
fn radio_group_layout(
&self,
rect: QRect,
group: &crate::primitives::radio_group::RadioGroup,
) -> crate::primitives::radio_group::RadioGroupLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_radio_group_layout(group, area)
}
fn draw_switch(
&mut self,
rect: QRect,
switch: &crate::primitives::switch::Switch,
) -> crate::primitives::switch::SwitchLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_switch called outside enter_frame_scope"
);
return self.switch_layout(rect, switch);
};
crate::tui::draw_switch(frame.buffer_mut(), area, switch, &theme)
}
fn switch_layout(
&self,
rect: QRect,
switch: &crate::primitives::switch::Switch,
) -> crate::primitives::switch::SwitchLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_switch_layout(switch, area)
}
fn draw_command_center(
&mut self,
rect: QRect,
cc: &crate::primitives::command_center::CommandCenter,
) -> crate::primitives::command_center::CommandCenterLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_command_center called outside enter_frame_scope"
);
return self.command_center_layout(rect, cc);
};
crate::tui::draw_command_center(frame.buffer_mut(), area, cc, &theme)
}
fn command_center_layout(
&self,
rect: QRect,
cc: &crate::primitives::command_center::CommandCenter,
) -> crate::primitives::command_center::CommandCenterLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_command_center_layout(cc, area)
}
fn draw_chart(
&mut self,
rect: QRect,
chart: &crate::primitives::chart::Chart,
hovered_point: Option<(usize, usize)>,
crosshair_x: Option<f64>,
) -> crate::primitives::chart::ChartLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_chart called outside enter_frame_scope"
);
return self.chart_layout(rect, chart);
};
crate::tui::draw_chart(
frame.buffer_mut(),
area,
chart,
&theme,
hovered_point,
crosshair_x,
)
}
fn chart_layout(
&self,
rect: QRect,
chart: &crate::primitives::chart::Chart,
) -> crate::primitives::chart::ChartLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_chart_layout(chart, area)
}
fn draw_toolbar_with_options(
&mut self,
rect: QRect,
bar: &crate::primitives::toolbar::Toolbar,
interaction: &crate::interaction::InteractionState,
options: crate::primitives::toolbar::ToolbarPaintOptions,
) -> crate::primitives::toolbar::ToolbarLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_toolbar_with_options called outside enter_frame_scope"
);
return self.toolbar_layout(rect, bar);
};
crate::tui::draw_toolbar_with_options(
frame.buffer_mut(),
area,
bar,
&theme,
interaction.hovered(),
interaction.pressed(),
options,
)
}
fn toolbar_layout(
&self,
rect: QRect,
bar: &crate::primitives::toolbar::Toolbar,
) -> crate::primitives::toolbar::ToolbarLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_toolbar_layout(bar, area)
}
fn draw_sidebar_panel_interactive(
&mut self,
rect: QRect,
panel: &crate::primitives::sidebar_panel::SidebarPanel,
interaction: &crate::interaction::InteractionState,
) -> crate::primitives::sidebar_panel::SidebarPanelLayout {
let (hovered_toolbar_id, pressed_toolbar_id) =
(interaction.hovered(), interaction.pressed());
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_sidebar_panel called outside enter_frame_scope"
);
return self.sidebar_panel_layout(rect, panel);
};
crate::tui::draw_sidebar_panel(
frame.buffer_mut(),
area,
panel,
&theme,
hovered_toolbar_id,
pressed_toolbar_id,
)
}
fn sidebar_panel_layout(
&self,
rect: QRect,
panel: &crate::primitives::sidebar_panel::SidebarPanel,
) -> crate::primitives::sidebar_panel::SidebarPanelLayout {
let area = q_rect_to_ratatui(rect);
crate::tui::tui_sidebar_panel_layout(panel, area)
}
fn draw_diff_view(
&mut self,
rect: QRect,
view: &crate::primitives::diff_view::DiffView,
) -> crate::primitives::diff_view::DiffViewLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_diff_view called outside enter_frame_scope"
);
return self.diff_view_layout(rect, view);
};
crate::tui::draw_diff_view(frame.buffer_mut(), area, view, &theme)
}
fn draw_board(
&mut self,
rect: QRect,
model: &crate::primitives::board::BoardModel,
) -> crate::primitives::board::BoardLayout {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_board called outside enter_frame_scope"
);
return self.board_layout(rect, model);
};
crate::tui::draw_board(frame.buffer_mut(), area, model, &theme)
}
fn board_layout(
&self,
rect: QRect,
model: &crate::primitives::board::BoardModel,
) -> crate::primitives::board::BoardLayout {
crate::tui::tui_board_layout(model, q_rect_to_ratatui(rect))
}
fn draw_minimap(
&mut self,
rect: QRect,
minimap: &crate::primitives::minimap::Minimap,
) -> crate::backend::MinimapPaintResult {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let sizing = self.minimap_sizing;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_minimap called outside enter_frame_scope"
);
return crate::backend::MinimapPaintResult {
layout: self.minimap_layout(rect, minimap),
painted: false,
};
};
let layout = match sizing {
Some(sizing) => {
let cols_per_cell =
crate::tui::minimap::resolve_cols_per_cell(minimap, area.width as usize);
crate::tui::draw_minimap_with_sizing(
frame.buffer_mut(),
area,
minimap,
&theme,
cols_per_cell,
sizing,
)
}
None => crate::tui::draw_minimap(frame.buffer_mut(), area, minimap, &theme),
};
self.register_zone(minimap.id, rect);
crate::backend::MinimapPaintResult {
layout,
painted: true,
}
}
fn minimap_layout(
&self,
rect: QRect,
minimap: &crate::primitives::minimap::Minimap,
) -> crate::primitives::minimap::MinimapLayout {
let area = q_rect_to_ratatui(rect);
match self.minimap_sizing {
Some(sizing) => crate::tui::tui_minimap_layout_with_sizing(minimap, area, sizing),
None => crate::tui::tui_minimap_layout(minimap, area),
}
}
fn minimap_sizing(&self) -> Option<crate::primitives::minimap::MinimapSizing> {
self.minimap_sizing
}
fn set_minimap_sizing(&mut self, sizing: Option<crate::primitives::minimap::MinimapSizing>) {
self.minimap_sizing = sizing;
}
fn editor_right_reserve_w(&self) -> Option<f32> {
self.editor_right_reserve_w
}
fn set_editor_right_reserve_w(&mut self, px: Option<f32>) {
self.editor_right_reserve_w = px.map(|v| v.max(0.0).round());
}
fn draw_image(
&mut self,
rect: QRect,
image: &crate::primitives::image::Image,
) -> crate::backend::ImagePaintResult {
let area = q_rect_to_ratatui(rect);
let theme = self.current_theme;
let Some(frame) = self.current_frame_mut() else {
debug_assert!(
false,
"TuiBackend::draw_image called outside enter_frame_scope"
);
return crate::backend::ImagePaintResult::Unsupported;
};
let result = crate::tui::draw_image(frame.buffer_mut(), area, image, &theme);
self.register_zone(image.id, rect);
result
}
fn window(&mut self) -> Option<&mut dyn crate::backend::WindowControl> {
Some(self)
}
}
impl crate::backend::WindowControl for TuiBackend {
fn set_title(&mut self, title: &str) -> crate::backend::ServiceResult<()> {
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::SetTitle;
execute!(std::io::stdout(), SetTitle(title)).map_err(|e| {
crate::backend::BackendError::PlatformFailure {
context: format!("crossterm SetTitle: {e}"),
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::{
Clipboard, FileDialogOptions, MessageDialogChoice, MessageDialogOptions, Notification,
};
use crate::{
ListItem, ListView, Palette, PaletteItem, StyledSpan, StyledText, TerminalCursorShape,
WidgetId,
};
#[derive(Debug, Clone, PartialEq)]
enum DrawCall {
List { rect: QRect, item_count: usize },
Palette { rect: QRect, item_count: usize },
}
struct NoopClipboard;
impl Clipboard for NoopClipboard {
fn read_text(&self) -> Option<String> {
None
}
fn write_text(&self, _t: &str) {}
}
struct MockServices {
clipboard: NoopClipboard,
}
impl MockServices {
fn new() -> Self {
Self {
clipboard: NoopClipboard,
}
}
}
impl PlatformServices for MockServices {
fn clipboard(&self) -> &dyn Clipboard {
&self.clipboard
}
fn show_file_open_dialog(&self, _opts: FileDialogOptions) -> Option<std::path::PathBuf> {
None
}
fn show_file_save_dialog(&self, _opts: FileDialogOptions) -> Option<std::path::PathBuf> {
None
}
fn show_folder_open_dialog(&self, _opts: FileDialogOptions) -> Option<std::path::PathBuf> {
None
}
fn show_message_dialog(&self, _opts: MessageDialogOptions) -> Option<MessageDialogChoice> {
None
}
fn send_notification(&self, _n: Notification) {}
fn open_url(&self, _url: &str) {}
fn platform_name(&self) -> &'static str {
"mock"
}
}
fn mock_empty_tab_bar_layout() -> TabBarLayout {
TabBarLayout {
bar_width: 0.0,
bar_height: 0.0,
visible_tabs: Vec::new(),
visible_segments: Vec::new(),
scroll_left: None,
scroll_right: None,
hit_regions: Vec::new(),
resolved_scroll_offset: 0,
}
}
struct MockBackend {
calls: Vec<DrawCall>,
modal_stack: Rc<RefCell<ModalStack>>,
drag_state: Rc<RefCell<DragState>>,
services: MockServices,
viewport: Viewport,
theme: crate::Theme,
focus: crate::focus::FocusManager,
invalidation_log: crate::backend::InvalidationLog,
}
impl MockBackend {
fn new() -> Self {
Self {
calls: Vec::new(),
modal_stack: Rc::new(RefCell::new(ModalStack::new())),
drag_state: Rc::new(RefCell::new(DragState::new())),
services: MockServices::new(),
viewport: Viewport::new(80.0, 24.0, 1.0),
theme: crate::Theme::default(),
focus: crate::focus::FocusManager::new(),
invalidation_log: crate::backend::InvalidationLog::new(),
}
}
}
impl crate::backend::sealed::Sealed for MockBackend {}
impl Backend for MockBackend {
fn viewport(&self) -> Viewport {
self.viewport
}
fn begin_frame(&mut self, viewport: Viewport) {
self.viewport = viewport;
}
fn end_frame(&mut self) {}
fn set_theme(&mut self, theme: crate::Theme) {
self.theme = theme;
}
fn poll_events(&mut self) -> Vec<UiEvent> {
Vec::new()
}
fn wait_events(&mut self, _t: Duration) -> Vec<UiEvent> {
Vec::new()
}
fn waker(&self) -> std::sync::Arc<dyn Fn(crate::UserPayload) + Send + Sync> {
std::sync::Arc::new(|_payload| {})
}
fn request_frame_in(&self, _delay: Duration) {
}
fn register_accelerator(&mut self, _a: &Accelerator) {}
fn unregister_accelerator(&mut self, _id: &AcceleratorId) {}
fn invalidate_rect(&mut self, rect: crate::event::Rect) {
self.invalidation_log.push(rect);
}
fn invalidation_log(&self) -> &crate::backend::InvalidationLog {
&self.invalidation_log
}
fn modal_stack_handle(&self) -> Rc<RefCell<ModalStack>> {
self.modal_stack.clone()
}
fn drag_state_handle(&self) -> Rc<RefCell<DragState>> {
self.drag_state.clone()
}
fn focus_manager(&self) -> &crate::focus::FocusManager {
&self.focus
}
fn draw_focus_ring(&mut self, _r: QRect) {}
fn services(&self) -> &dyn PlatformServices {
&self.services
}
fn backend_caps(&self) -> crate::backend::BackendCaps {
crate::backend::BackendCaps::empty()
}
fn draw_list(&mut self, rect: QRect, list: &ListView) {
self.calls.push(DrawCall::List {
rect,
item_count: list.items.len(),
});
}
fn draw_data_table(
&mut self,
_rect: QRect,
_table: &crate::DataTable,
_hovered_idx: Option<usize>,
) -> crate::DataTableLayout {
crate::DataTableLayout {
header_height: 0.0,
row_height: 0.0,
columns: Vec::new(),
visible_rows: 0,
viewport_width: 0.0,
viewport_height: 0.0,
scrollbar_width: 0.0,
content_width: 0.0,
h_scrollbar_height: 0.0,
footer_height: 0.0,
h_scroll: 0.0,
}
}
fn data_table_layout(
&self,
_rect: QRect,
_table: &crate::DataTable,
) -> crate::DataTableLayout {
crate::DataTableLayout {
header_height: 0.0,
row_height: 0.0,
columns: Vec::new(),
visible_rows: 0,
viewport_width: 0.0,
viewport_height: 0.0,
scrollbar_width: 0.0,
content_width: 0.0,
h_scrollbar_height: 0.0,
footer_height: 0.0,
h_scroll: 0.0,
}
}
fn list_hscrollbar(&self, _rect: QRect, _list: &ListView) -> Option<crate::Scrollbar> {
None
}
fn list_vscrollbar(&self, _rect: QRect, _list: &ListView) -> Option<crate::Scrollbar> {
None
}
fn list_layout(&self, rect: QRect, list: &ListView) -> crate::ListViewLayout {
crate::tui::tui_list_layout(q_rect_to_ratatui(rect), list)
}
fn draw_palette(&mut self, rect: QRect, palette: &Palette) {
self.calls.push(DrawCall::Palette {
rect,
item_count: palette.items.len(),
});
}
fn palette_layout(&self, rect: QRect, palette: &Palette) -> crate::PaletteLayout {
crate::tui::tui_palette_layout(q_rect_to_ratatui(rect), palette)
}
fn draw_tree(&mut self, _r: QRect, _t: &TreeView) {}
fn draw_form(&mut self, _r: QRect, _f: &Form) {}
fn draw_settings_chrome(
&mut self,
_r: QRect,
_header_text: &str,
_query: &str,
_placeholder: &str,
_active: bool,
) {
}
fn draw_status_bar_interactive(
&mut self,
_r: QRect,
_b: &StatusBar,
_interaction: &crate::interaction::InteractionState,
) -> crate::StatusBarLayout {
crate::StatusBarLayout {
bar_width: 0.0,
bar_height: 0.0,
visible_segments: Vec::new(),
hit_regions: Vec::new(),
resolved_right_start: 0,
}
}
#[allow(deprecated)] fn draw_tab_bar(
&mut self,
_r: QRect,
_b: &TabBar,
_hovered_close_tab: Option<usize>,
) -> crate::TabBarHits {
crate::TabBarHits::default()
}
#[allow(deprecated)] fn draw_tab_bar_icons(
&mut self,
_r: QRect,
_b: &TabBar,
_icons: &[Option<crate::TabIcon>],
_hovered_close_tab: Option<usize>,
) -> crate::TabBarHits {
crate::TabBarHits::default()
}
fn draw_tab_bar_layout(
&mut self,
_r: QRect,
_b: &TabBar,
_hovered_close_tab: Option<usize>,
) -> TabBarLayout {
mock_empty_tab_bar_layout()
}
fn draw_tab_bar_icons_layout(
&mut self,
_r: QRect,
_b: &TabBar,
_icons: &[Option<crate::TabIcon>],
_hovered_close_tab: Option<usize>,
) -> TabBarLayout {
mock_empty_tab_bar_layout()
}
fn draw_activity_bar(
&mut self,
_r: QRect,
_b: &ActivityBar,
_h: Option<usize>,
) -> Vec<crate::ActivityBarRowHit> {
Vec::new()
}
fn draw_terminal(&mut self, _r: QRect, _t: &TerminalPrim) {}
fn draw_terminal_divider(&mut self, _r: QRect) {}
fn draw_solid_fill(&mut self, _r: QRect, _c: Color) {}
fn draw_text_display(&mut self, _r: QRect, _t: &TextDisplay) {}
fn draw_command_line(&mut self, _r: QRect, _c: &CommandLine) {}
fn draw_command_line_selection(
&mut self,
_r: QRect,
_c: &CommandLine,
_s: Option<(usize, usize)>,
) {
}
fn command_line_layout(
&self,
_r: QRect,
_c: &CommandLine,
) -> crate::primitives::command_line::CommandLineLayout {
Default::default()
}
fn status_bar_layout(&self, _r: QRect, _b: &StatusBar) -> crate::StatusBarLayout {
crate::StatusBarLayout {
bar_width: 0.0,
bar_height: 0.0,
visible_segments: Vec::new(),
hit_regions: Vec::new(),
resolved_right_start: 0,
}
}
#[allow(deprecated)] fn tab_bar_layout(&self, _r: QRect, _b: &TabBar) -> crate::TabBarHits {
crate::TabBarHits::default()
}
#[allow(deprecated)] fn tab_bar_layout_icons(
&self,
_r: QRect,
_b: &TabBar,
_icons: &[Option<crate::TabIcon>],
) -> crate::TabBarHits {
crate::TabBarHits::default()
}
fn resolve_tab_bar_layout(&self, _r: QRect, _b: &TabBar) -> TabBarLayout {
mock_empty_tab_bar_layout()
}
fn resolve_tab_bar_layout_icons(
&self,
_r: QRect,
_b: &TabBar,
_icons: &[Option<crate::TabIcon>],
) -> TabBarLayout {
mock_empty_tab_bar_layout()
}
fn activity_bar_layout(
&self,
_r: QRect,
_b: &ActivityBar,
) -> Vec<crate::ActivityBarRowHit> {
Vec::new()
}
fn text_display_layout(
&self,
r: QRect,
td: &TextDisplay,
) -> crate::primitives::text_display::TextDisplayLayout {
td.layout(r.width, r.height, |_| {
crate::primitives::text_display::TextDisplayLineMeasure::new(1.0)
})
}
fn draw_text_input(
&mut self,
r: QRect,
ti: &crate::primitives::text_input::TextInput,
) -> crate::primitives::text_input::TextInputLayout {
ti.layout(
r,
crate::primitives::text_input::TextInputMeasure::new(1.0, 1.0),
)
}
fn text_input_layout(
&self,
r: QRect,
ti: &crate::primitives::text_input::TextInput,
) -> crate::primitives::text_input::TextInputLayout {
ti.layout(
r,
crate::primitives::text_input::TextInputMeasure::new(1.0, 1.0),
)
}
fn draw_number_input(&mut self, _n: &crate::NumberInput, _l: &crate::NumberInputLayout) {}
fn draw_combo_box(&mut self, _c: &crate::ComboBox, _l: &crate::ComboBoxLayout) {}
fn draw_date_picker(&mut self, _d: &crate::DatePicker, _l: &crate::DatePickerLayout) {}
fn draw_tooltip(&mut self, _t: &crate::Tooltip, _l: &crate::TooltipLayout) {}
fn draw_context_menu(
&mut self,
_m: &crate::ContextMenu,
_l: &crate::ContextMenuLayout,
) -> Vec<(QRect, crate::WidgetId)> {
Vec::new()
}
fn draw_dialog(
&mut self,
_d: &crate::primitives::dialog::Dialog,
_l: &crate::primitives::dialog::DialogLayout,
) -> Vec<QRect> {
Vec::new()
}
fn draw_float(
&mut self,
_f: &crate::primitives::float::Float,
_l: &crate::primitives::float::FloatLayout,
) {
}
fn char_width(&self) -> f32 {
1.0
}
fn line_height(&self) -> f32 {
1.0
}
fn list_char_width(&self) -> f32 {
1.0
}
fn measure_text(&self, text: &str, _role: crate::FontRole) -> (f32, f32) {
(text.chars().count() as f32, 1.0)
}
fn default_fonts(&self) -> crate::backend::PlatformFontDefaults {
crate::backend::PlatformFontDefaults {
editor_family: String::new(),
editor_size_pt: 0.0,
ui_family: String::new(),
ui_size_pt: 0.0,
}
}
fn draw_multi_section_view(
&mut self,
_r: QRect,
_v: &crate::primitives::multi_section_view::MultiSectionView,
) {
}
fn msv_layout(
&self,
r: QRect,
v: &crate::primitives::multi_section_view::MultiSectionView,
) -> crate::primitives::multi_section_view::MultiSectionViewLayout {
v.layout(
r,
crate::primitives::multi_section_view::MsvLayoutMetrics::default(),
|_| crate::primitives::multi_section_view::SectionMeasure::default(),
)
}
fn msv_metrics(&self) -> crate::primitives::multi_section_view::MsvLayoutMetrics {
crate::primitives::multi_section_view::MsvLayoutMetrics::default()
}
fn tree_layout(&self, r: QRect, t: &TreeView) -> crate::primitives::tree::TreeViewLayout {
t.layout(r.width, r.height, |_| {
crate::primitives::tree::TreeRowMeasure::new(1.0)
})
}
fn tree_vscrollbar(&self, r: QRect, t: &TreeView) -> Option<crate::Scrollbar> {
t.vscrollbar_with_track_width(r, 1.0, 1.0)
}
fn form_layout(&self, r: QRect, form: &Form) -> crate::primitives::form::FormLayout {
let area = q_rect_to_ratatui(r);
crate::tui::tui_form_layout(form, area)
}
fn draw_editor(
&mut self,
_r: QRect,
_e: &crate::primitives::editor::Editor,
) -> crate::backend::EditorPaintResult {
crate::backend::EditorPaintResult::default()
}
fn draw_message_list(
&mut self,
_r: QRect,
_l: &crate::primitives::message_list::MessageList,
) {
}
fn draw_rich_text_popup(
&mut self,
_p: &crate::primitives::rich_text_popup::RichTextPopup,
_l: &crate::primitives::rich_text_popup::RichTextPopupLayout,
) {
}
fn draw_find_replace(
&mut self,
_r: QRect,
_p: &crate::primitives::find_replace::FindReplacePanel,
) {
}
fn draw_completions(
&mut self,
_c: &crate::primitives::completions::Completions,
_l: &crate::primitives::completions::CompletionsLayout,
) {
}
fn draw_scrollbar(&mut self, _r: QRect, _s: &crate::primitives::scrollbar::Scrollbar) {}
fn draw_drop_overlay(&mut self, _o: &crate::primitives::drop_zone::DropOverlay) {}
fn draw_menu_bar(
&mut self,
_r: QRect,
bar: &MenuBar,
) -> crate::primitives::menu_bar::MenuBarLayout {
let bounds = crate::event::Rect::new(_r.x, _r.y, _r.width, _r.height);
bar.layout(bounds, |_| {
crate::primitives::menu_bar::MenuBarItemMeasure::new(0.0)
})
}
fn menu_bar_layout(
&self,
_r: QRect,
bar: &MenuBar,
) -> crate::primitives::menu_bar::MenuBarLayout {
let bounds = crate::event::Rect::new(_r.x, _r.y, _r.width, _r.height);
bar.layout(bounds, |_| {
crate::primitives::menu_bar::MenuBarItemMeasure::new(0.0)
})
}
fn draw_split(
&mut self,
_r: QRect,
split: &Split,
) -> crate::primitives::split::SplitLayout {
let bounds = crate::event::Rect::new(_r.x, _r.y, _r.width, _r.height);
split.layout(bounds, crate::primitives::split::SplitMeasure::new(1.0))
}
fn split_layout(&self, _r: QRect, split: &Split) -> crate::primitives::split::SplitLayout {
let bounds = crate::event::Rect::new(_r.x, _r.y, _r.width, _r.height);
split.layout(bounds, crate::primitives::split::SplitMeasure::new(1.0))
}
fn draw_split_tree(
&mut self,
_r: QRect,
tree: &crate::primitives::split_tree::SplitTree,
) -> crate::primitives::split_tree::SplitTreeLayout {
let bounds = crate::event::Rect::new(_r.x, _r.y, _r.width, _r.height);
tree.layout(
bounds,
crate::primitives::split_tree::SplitTreeMeasure::new(1.0),
)
}
fn split_tree_layout(
&self,
_r: QRect,
tree: &crate::primitives::split_tree::SplitTree,
) -> crate::primitives::split_tree::SplitTreeLayout {
let bounds = crate::event::Rect::new(_r.x, _r.y, _r.width, _r.height);
tree.layout(
bounds,
crate::primitives::split_tree::SplitTreeMeasure::new(1.0),
)
}
fn draw_panel(
&mut self,
_r: QRect,
panel: &crate::primitives::panel::Panel,
) -> crate::primitives::panel::PanelLayout {
let bounds = crate::event::Rect::new(_r.x, _r.y, _r.width, _r.height);
panel.layout(bounds, crate::primitives::panel::PanelMeasure::new(1.0))
}
fn panel_layout(
&self,
_r: QRect,
panel: &crate::primitives::panel::Panel,
) -> crate::primitives::panel::PanelLayout {
let bounds = crate::event::Rect::new(_r.x, _r.y, _r.width, _r.height);
panel.layout(bounds, crate::primitives::panel::PanelMeasure::new(1.0))
}
fn draw_canvas(
&mut self,
r: QRect,
canvas: &crate::primitives::canvas::Canvas,
) -> crate::primitives::canvas::CanvasLayout {
let bounds = crate::event::Rect::new(r.x, r.y, r.width, r.height);
canvas.layout(bounds)
}
fn draw_toast_overlay(
&mut self,
_r: QRect,
stack: &crate::primitives::toast::ToastOverlay,
) -> crate::primitives::toast::ToastStackLayout {
stack.layout(_r.x, _r.y, _r.width, _r.height, 1.0, 1.0, |_| {
crate::primitives::toast::ToastMeasure::new(40.0, 1.0)
})
}
fn toast_stack_layout(
&self,
_r: QRect,
stack: &crate::primitives::toast::ToastOverlay,
) -> crate::primitives::toast::ToastStackLayout {
stack.layout(_r.x, _r.y, _r.width, _r.height, 1.0, 1.0, |_| {
crate::primitives::toast::ToastMeasure::new(40.0, 1.0)
})
}
fn draw_pipeline_view(
&mut self,
_r: QRect,
view: &crate::primitives::pipeline_view::PipelineView,
) -> crate::primitives::pipeline_view::PipelineViewLayout {
view.layout(
_r.x,
_r.y,
crate::primitives::pipeline_view::PipelineViewMeasure::new(
_r.width, _r.height, 4.0, 10.0,
),
)
}
fn pipeline_view_layout(
&self,
_r: QRect,
view: &crate::primitives::pipeline_view::PipelineView,
) -> crate::primitives::pipeline_view::PipelineViewLayout {
view.layout(
_r.x,
_r.y,
crate::primitives::pipeline_view::PipelineViewMeasure::new(
_r.width, _r.height, 4.0, 10.0,
),
)
}
fn draw_progress(
&mut self,
_r: QRect,
bar: &crate::primitives::progress::ProgressBar,
) -> crate::primitives::progress::ProgressBarLayout {
bar.layout(
_r.x,
_r.y,
crate::primitives::progress::ProgressBarMeasure::new(_r.width, _r.height),
)
}
fn progress_layout(
&self,
_r: QRect,
bar: &crate::primitives::progress::ProgressBar,
) -> crate::primitives::progress::ProgressBarLayout {
bar.layout(
_r.x,
_r.y,
crate::primitives::progress::ProgressBarMeasure::new(_r.width, _r.height),
)
}
fn draw_spinner(
&mut self,
_r: QRect,
spinner: &crate::primitives::spinner::Spinner,
) -> crate::primitives::spinner::SpinnerLayout {
spinner.layout(
_r.x,
_r.y,
crate::primitives::spinner::SpinnerMeasure::new(_r.width, 1.0),
)
}
fn spinner_layout(
&self,
_r: QRect,
spinner: &crate::primitives::spinner::Spinner,
) -> crate::primitives::spinner::SpinnerLayout {
spinner.layout(
_r.x,
_r.y,
crate::primitives::spinner::SpinnerMeasure::new(_r.width, 1.0),
)
}
fn draw_button(
&mut self,
r: QRect,
button: &crate::primitives::button::Button,
) -> crate::primitives::button::ButtonLayout {
button.layout(crate::event::Rect::new(r.x, r.y, r.width, r.height))
}
fn button_layout(
&self,
r: QRect,
button: &crate::primitives::button::Button,
) -> crate::primitives::button::ButtonLayout {
button.layout(crate::event::Rect::new(r.x, r.y, r.width, r.height))
}
fn draw_radio_group(
&mut self,
r: QRect,
group: &crate::primitives::radio_group::RadioGroup,
) -> crate::primitives::radio_group::RadioGroupLayout {
let bounds = crate::event::Rect::new(r.x, r.y, r.width, r.height);
group.layout(
bounds,
|_option: &crate::primitives::radio_group::RadioOption| {
crate::primitives::radio_group::RadioOptionMeasure {
width: bounds.width,
height: 1.0,
}
},
)
}
fn radio_group_layout(
&self,
r: QRect,
group: &crate::primitives::radio_group::RadioGroup,
) -> crate::primitives::radio_group::RadioGroupLayout {
let bounds = crate::event::Rect::new(r.x, r.y, r.width, r.height);
group.layout(
bounds,
|_option: &crate::primitives::radio_group::RadioOption| {
crate::primitives::radio_group::RadioOptionMeasure {
width: bounds.width,
height: 1.0,
}
},
)
}
fn draw_switch(
&mut self,
r: QRect,
switch: &crate::primitives::switch::Switch,
) -> crate::primitives::switch::SwitchLayout {
switch.layout(crate::event::Rect::new(r.x, r.y, r.width, r.height))
}
fn switch_layout(
&self,
r: QRect,
switch: &crate::primitives::switch::Switch,
) -> crate::primitives::switch::SwitchLayout {
switch.layout(crate::event::Rect::new(r.x, r.y, r.width, r.height))
}
fn draw_command_center(
&mut self,
_r: QRect,
cc: &crate::primitives::command_center::CommandCenter,
) -> crate::primitives::command_center::CommandCenterLayout {
cc.layout(
crate::event::Rect::new(_r.x, _r.y, _r.width, _r.height),
crate::primitives::command_center::CommandCenterMeasure {
arrow_width: 2.0,
gap: 1.0,
search_box_width: 0.0,
height: 1.0,
},
)
}
fn command_center_layout(
&self,
_r: QRect,
cc: &crate::primitives::command_center::CommandCenter,
) -> crate::primitives::command_center::CommandCenterLayout {
cc.layout(
crate::event::Rect::new(_r.x, _r.y, _r.width, _r.height),
crate::primitives::command_center::CommandCenterMeasure {
arrow_width: 2.0,
gap: 1.0,
search_box_width: 0.0,
height: 1.0,
},
)
}
fn draw_chart(
&mut self,
_r: QRect,
chart: &crate::primitives::chart::Chart,
_hovered_point: Option<(usize, usize)>,
_crosshair_x: Option<f64>,
) -> crate::primitives::chart::ChartLayout {
chart.layout(
_r.x,
_r.y,
crate::primitives::chart::ChartMeasure {
width: _r.width,
height: _r.height,
char_width: 1.0,
line_height: 1.0,
},
)
}
fn chart_layout(
&self,
_r: QRect,
chart: &crate::primitives::chart::Chart,
) -> crate::primitives::chart::ChartLayout {
chart.layout(
_r.x,
_r.y,
crate::primitives::chart::ChartMeasure {
width: _r.width,
height: _r.height,
char_width: 1.0,
line_height: 1.0,
},
)
}
fn draw_toolbar_with_options(
&mut self,
r: QRect,
bar: &crate::primitives::toolbar::Toolbar,
_interaction: &crate::interaction::InteractionState,
_options: crate::primitives::toolbar::ToolbarPaintOptions,
) -> crate::primitives::toolbar::ToolbarLayout {
bar.layout(r.x, r.y, r.width, r.height, |_| {
crate::primitives::toolbar::ToolbarItemMeasure::new(0.0)
})
}
fn toolbar_layout(
&self,
r: QRect,
bar: &crate::primitives::toolbar::Toolbar,
) -> crate::primitives::toolbar::ToolbarLayout {
bar.layout(r.x, r.y, r.width, r.height, |_| {
crate::primitives::toolbar::ToolbarItemMeasure::new(0.0)
})
}
fn draw_sidebar_panel_interactive(
&mut self,
r: QRect,
panel: &crate::primitives::sidebar_panel::SidebarPanel,
_interaction: &crate::interaction::InteractionState,
) -> crate::primitives::sidebar_panel::SidebarPanelLayout {
panel.layout(
r,
crate::primitives::sidebar_panel::SidebarPanelMeasure::new(1.0, 0.0),
|_| crate::primitives::toolbar::ToolbarItemMeasure::new(0.0),
)
}
fn sidebar_panel_layout(
&self,
r: QRect,
panel: &crate::primitives::sidebar_panel::SidebarPanel,
) -> crate::primitives::sidebar_panel::SidebarPanelLayout {
panel.layout(
r,
crate::primitives::sidebar_panel::SidebarPanelMeasure::new(1.0, 0.0),
|_| crate::primitives::toolbar::ToolbarItemMeasure::new(0.0),
)
}
fn draw_diff_view(
&mut self,
_r: QRect,
view: &crate::primitives::diff_view::DiffView,
) -> crate::primitives::diff_view::DiffViewLayout {
crate::primitives::diff_view::DiffViewLayout {
visible_rows: 0,
total_rows: view.total_rows(),
}
}
fn draw_board(
&mut self,
r: QRect,
model: &crate::primitives::board::BoardModel,
) -> crate::primitives::board::BoardLayout {
crate::primitives::board::board_layout(
model,
r.x,
r.y,
r.width,
r.height,
crate::primitives::board::BoardMeasure::new(
crate::tui::board::TUI_BOARD_COL_MIN_CELLS,
1.0,
1.0,
crate::tui::board::TUI_BOARD_CARD_H,
0.0,
),
)
}
fn board_layout(
&self,
r: QRect,
model: &crate::primitives::board::BoardModel,
) -> crate::primitives::board::BoardLayout {
crate::primitives::board::board_layout(
model,
r.x,
r.y,
r.width,
r.height,
crate::primitives::board::BoardMeasure::new(
crate::tui::board::TUI_BOARD_COL_MIN_CELLS,
1.0,
1.0,
crate::tui::board::TUI_BOARD_CARD_H,
0.0,
),
)
}
fn draw_minimap(
&mut self,
_r: QRect,
_m: &crate::primitives::minimap::Minimap,
) -> crate::backend::MinimapPaintResult {
crate::backend::MinimapPaintResult::default()
}
fn minimap_layout(
&self,
_r: QRect,
_m: &crate::primitives::minimap::Minimap,
) -> crate::primitives::minimap::MinimapLayout {
crate::primitives::minimap::MinimapLayout::default()
}
fn draw_image(
&mut self,
_r: QRect,
_i: &crate::primitives::image::Image,
) -> crate::backend::ImagePaintResult {
crate::backend::ImagePaintResult::Unsupported
}
}
fn paint_overlays<B: Backend>(backend: &mut B, palette: &Palette, list: &ListView) {
backend.draw_palette(QRect::new(10.0, 5.0, 60.0, 14.0), palette);
backend.draw_list(QRect::new(0.0, 20.0, 80.0, 4.0), list);
}
fn sample_palette() -> Palette {
Palette {
id: WidgetId::new("test:palette"),
title: "Pick one".to_string(),
query: String::new(),
query_cursor: 0,
items: vec![
PaletteItem {
text: StyledText {
spans: vec![StyledSpan::plain("alpha")],
},
detail: None,
icon: None,
match_positions: Vec::new(),
depth: 0,
expandable: false,
expanded: false,
},
PaletteItem {
text: StyledText {
spans: vec![StyledSpan::plain("beta")],
},
detail: None,
icon: None,
match_positions: Vec::new(),
depth: 0,
expandable: false,
expanded: false,
},
],
selected_idx: 0,
scroll_offset: 0,
total_count: 2,
has_focus: true,
show_query: true,
create_label: None,
preview: None,
mode: crate::primitives::palette::PaletteMode::List,
}
}
fn sample_list() -> ListView {
ListView {
id: WidgetId::new("test:list"),
title: None,
items: vec![ListItem {
text: StyledText {
spans: vec![StyledSpan::plain("only")],
},
icon: None,
detail: None,
decoration: crate::Decoration::Normal,
}],
selected_idx: 0,
scroll_offset: 0,
has_focus: true,
bordered: false,
h_scroll: 0,
max_content_width: None,
show_v_scrollbar: false,
}
}
#[test]
fn paint_overlays_records_through_mock_backend() {
let mut mock = MockBackend::new();
let palette = sample_palette();
let list = sample_list();
paint_overlays(&mut mock, &palette, &list);
assert_eq!(mock.calls.len(), 2);
assert!(matches!(
mock.calls[0],
DrawCall::Palette { item_count: 2, .. }
));
assert!(matches!(
mock.calls[1],
DrawCall::List { item_count: 1, .. }
));
}
#[test]
fn paint_overlays_compiles_against_tui_backend() {
let _: fn(&mut TuiBackend, &Palette, &ListView) = paint_overlays::<TuiBackend>;
}
#[test]
fn mock_backend_modal_stack_routes_through_trait() {
let mock = MockBackend::new();
mock.modal_stack_handle()
.borrow_mut()
.push(WidgetId::new("test:popup"), QRect::new(0.0, 0.0, 10.0, 5.0));
assert_eq!(mock.modal_stack_handle().borrow().len(), 1);
}
#[test]
fn tui_backend_modal_stack_handle_shares_state() {
let backend = TuiBackend::new();
let h1 = backend.modal_stack_handle();
let h2 = backend.modal_stack_handle();
h1.borrow_mut()
.push(WidgetId::new("test:popup"), QRect::new(0.0, 0.0, 10.0, 5.0));
assert_eq!(h2.borrow().len(), 1);
}
#[test]
#[should_panic(expected = "already borrowed")]
fn modal_stack_handle_reentrant_borrow_mut_panics_loudly() {
let backend = TuiBackend::new();
let handle = backend.modal_stack_handle();
let _first = handle.borrow_mut();
let _second = handle.borrow_mut(); }
#[test]
fn tui_backend_measure_text_uses_display_width_and_ignores_role() {
let backend = TuiBackend::new();
let (w, h) = Backend::measure_text(&backend, "ab日本", crate::FontRole::Chrome);
assert_eq!(w, 6.0, "2 ASCII + 2 double-width CJK cells = 6");
assert_eq!(h, 1.0);
let (chrome_w, _) = Backend::measure_text(&backend, "ab日本", crate::FontRole::Chrome);
let (editor_w, _) = Backend::measure_text(&backend, "ab日本", crate::FontRole::Editor);
assert_eq!(chrome_w, editor_w, "role has nothing to select on TUI");
}
#[test]
fn tui_backend_context_menu_viewport_reserves_border_on_all_four_sides() {
let mut backend = TuiBackend::new();
backend.begin_frame(Viewport::new(80.0, 24.0, 1.0));
assert_eq!(
Backend::context_menu_border_inset(&backend),
1.0,
"TUI paints a one-cell box border outside the item region"
);
assert_eq!(
Backend::context_menu_viewport(&backend),
QRect::new(1.0, 1.0, 78.0, 22.0),
"all four edges must be reserved, not just the top-left"
);
}
#[test]
fn tui_backend_context_menu_viewport_clamps_tiny_viewport_to_empty() {
let mut backend = TuiBackend::new();
backend.begin_frame(Viewport::new(1.0, 1.0, 1.0));
let vp = Backend::context_menu_viewport(&backend);
assert_eq!(vp.width, 0.0, "width cannot go negative: {vp:?}");
assert_eq!(vp.height, 0.0, "height cannot go negative: {vp:?}");
}
#[test]
fn default_border_inset_leaves_the_whole_viewport_for_the_menu() {
let backend = MockBackend::new();
assert_eq!(Backend::context_menu_border_inset(&backend), 0.0);
assert_eq!(
Backend::context_menu_viewport(&backend),
QRect::new(0.0, 0.0, 80.0, 24.0)
);
}
#[test]
fn tui_backend_default_fonts_is_all_sentinel() {
let backend = TuiBackend::new();
let defaults = Backend::default_fonts(&backend);
assert_eq!(defaults.editor_family, "");
assert_eq!(defaults.editor_size_pt, 0.0);
assert_eq!(defaults.ui_family, "");
assert_eq!(defaults.ui_size_pt, 0.0);
}
#[test]
fn modal_stack_handle_outlives_the_backend_borrow_through_the_trait() {
let mut backend = TuiBackend::new();
let stack_rc = {
let dyn_backend: &mut dyn Backend = &mut backend;
dyn_backend.modal_stack_handle() };
backend.begin_frame(Viewport::new(80.0, 24.0, 1.0));
stack_rc
.borrow_mut()
.push(WidgetId::new("test:popup"), QRect::new(0.0, 0.0, 10.0, 5.0));
assert_eq!(backend.modal_stack_handle().borrow().len(), 1);
}
#[test]
fn tui_backend_drag_state_handle_shares_state() {
let backend = TuiBackend::new();
let h1 = backend.drag_state_handle();
let h2 = backend.drag_state_handle();
h1.borrow_mut().begin(DragTarget::TextSelection {
region: WidgetId::new("r"),
anchor: Point::new(0.0, 0.0),
});
assert!(h2.borrow().is_active());
}
#[test]
fn draw_dialog_marks_its_modal_stack_entry_painted() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dialog_id = WidgetId::new("confirm");
let dialog = crate::Dialog {
id: dialog_id.clone(),
title: crate::StyledText::plain("Confirm"),
body: vec![crate::StyledText::plain("Really?")],
table: None,
buttons: vec![crate::DialogButton {
id: WidgetId::new("ok"),
label: "OK".into(),
is_default: true,
is_cancel: false,
tint: None,
}],
severity: None,
vertical_buttons: false,
input: None,
};
let measure = crate::DialogMeasure {
width: 40.0,
title_height: 1.0,
body_height: 1.0,
table_height: 0.0,
input_height: 0.0,
button_row_height: 1.0,
button_width: 10.0,
button_gap: 2.0,
padding: 1.0,
};
let viewport_rect = QRect::new(0.0, 0.0, 80.0, 24.0);
let layout = dialog.layout(viewport_rect, measure, |_| {
crate::ToolbarItemMeasure::new(0.0)
});
let mut backend = TuiBackend::new();
backend.begin_frame(Viewport::new(80.0, 24.0, 1.0));
backend
.modal_stack_handle()
.borrow_mut()
.push(dialog_id.clone(), layout.bounds);
assert_eq!(
backend.modal_stack_handle().borrow().unpainted_ids(),
vec![dialog_id.clone()]
);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal");
terminal
.draw(|frame| {
backend.enter_frame_scope(frame, |b| {
b.draw_dialog(&dialog, &layout);
});
})
.expect("draw");
assert!(backend
.modal_stack_handle()
.borrow()
.unpainted_ids()
.is_empty());
backend.begin_frame(Viewport::new(80.0, 24.0, 1.0));
assert_eq!(
backend.modal_stack_handle().borrow().unpainted_ids(),
vec![dialog_id]
);
}
use crate::{Key, Modifiers, NamedKey};
fn ctrl_p_keypress() -> UiEvent {
UiEvent::KeyPressed {
key: Key::Char('p'),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
repeat: false,
}
}
fn make_acc(id: &str, binding: &str) -> Accelerator {
Accelerator {
id: AcceleratorId::new(id),
binding: KeyBinding::Literal(binding.to_string()),
scope: AcceleratorScope::Global,
label: None,
}
}
fn wide_hscroll_table() -> crate::DataTable {
crate::DataTable {
id: WidgetId::new("wide-hscroll"),
columns: ["a", "b", "c", "d"]
.iter()
.map(|t| crate::Column {
title: (*t).into(),
width: crate::ColumnWidth::Fixed(30.0),
align: crate::ColumnAlign::Left,
})
.collect(),
rows: vec![crate::DataRow {
cells: vec![
StyledText::plain("a-one"),
StyledText::plain("b-two"),
StyledText::plain("c-three"),
StyledText::plain("d-four"),
],
decoration: crate::Decoration::Normal,
}],
selected_idx: None,
scroll_offset: 0,
sort: None,
has_focus: false,
show_scrollbar: false,
min_total_width: Some(120.0),
h_scroll: 0.0,
column_overrides: Vec::new(),
footer: None,
}
}
#[test]
fn data_table_layout_rounds_h_scroll_like_the_renderer() {
let backend = TuiBackend::new();
let rect = QRect::new(0.0, 0.0, 60.0, 3.0);
let mut table = wide_hscroll_table();
table.h_scroll = 23.7;
let layout = backend.data_table_layout(rect, &table);
assert_eq!(
layout.h_scroll, 24.0,
"layout-on-demand must carry the same rounded offset the renderer paints at"
);
table.h_scroll = 15.6;
let layout = backend.data_table_layout(rect, &table);
assert_eq!(layout.h_scroll, 16.0);
assert_eq!(
layout.column_hit(14.0),
Some(1),
"integer click x=14 sits on c1 as painted at h_scroll=15.6"
);
assert_ne!(
layout.column_hit(14.0),
Some(0),
"un-rounded 15.6 would put this at content x=29.6, inside c0"
);
}
#[test]
fn data_table_layout_matches_draw_data_table_at_every_fractional_offset() {
let backend = TuiBackend::new();
let rect = QRect::new(0.0, 0.0, 60.0, 3.0);
let area = q_rect_to_ratatui(rect);
let mut table = wide_hscroll_table();
for tenths in 0..=600u32 {
table.h_scroll = tenths as f32 / 10.0;
let on_demand = backend.data_table_layout(rect, &table);
let mut buf = ratatui::buffer::Buffer::empty(area);
let painted =
crate::tui::draw_data_table(&mut buf, area, &table, &backend.current_theme, None);
assert_eq!(
on_demand, painted,
"layouts diverged at h_scroll = {}",
table.h_scroll
);
}
}
#[test]
fn accelerator_match_replaces_keypressed_with_accelerator() {
let mut backend = TuiBackend::new();
backend.register_accelerator(&make_acc("tui.fuzzy_finder", "<C-p>"));
let mut events = vec![ctrl_p_keypress()];
backend.apply_accelerators(&mut events);
assert_eq!(events.len(), 1);
match &events[0] {
UiEvent::Accelerator(id, mods) => {
assert_eq!(id.as_str(), "tui.fuzzy_finder");
assert!(mods.ctrl);
}
other => panic!("expected Accelerator, got {:?}", other),
}
}
#[test]
fn accelerator_match_named_keys() {
let mut backend = TuiBackend::new();
backend.register_accelerator(&make_acc("debug.continue", "<F5>"));
let mut events = vec![UiEvent::KeyPressed {
key: Key::Named(NamedKey::F(5)),
modifiers: Modifiers::default(),
repeat: false,
}];
backend.apply_accelerators(&mut events);
match &events[0] {
UiEvent::Accelerator(id, _) => assert_eq!(id.as_str(), "debug.continue"),
other => panic!("expected Accelerator, got {:?}", other),
}
}
#[test]
fn accelerator_match_uppercase_letter_normalised() {
let mut backend = TuiBackend::new();
backend.register_accelerator(&make_acc("test.upper", "<C-S-T>"));
let mut events = vec![UiEvent::KeyPressed {
key: Key::Char('T'),
modifiers: Modifiers {
ctrl: true,
shift: true,
..Default::default()
},
repeat: false,
}];
backend.apply_accelerators(&mut events);
match &events[0] {
UiEvent::Accelerator(id, _) => assert_eq!(id.as_str(), "test.upper"),
other => panic!("expected Accelerator, got {:?}", other),
}
}
#[test]
fn accelerator_no_match_stays_keypressed() {
let mut backend = TuiBackend::new();
backend.register_accelerator(&make_acc("tui.fuzzy_finder", "<C-p>"));
let mut events = vec![UiEvent::KeyPressed {
key: Key::Char('q'),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
repeat: false,
}];
backend.apply_accelerators(&mut events);
assert!(matches!(events[0], UiEvent::KeyPressed { .. }));
}
#[test]
fn accelerator_modifier_mismatch_no_match() {
let mut backend = TuiBackend::new();
backend.register_accelerator(&make_acc("tui.fuzzy_finder", "<C-p>"));
let mut events = vec![UiEvent::KeyPressed {
key: Key::Char('p'),
modifiers: Modifiers::default(),
repeat: false,
}];
backend.apply_accelerators(&mut events);
assert!(matches!(events[0], UiEvent::KeyPressed { .. }));
}
#[test]
fn accelerator_unregister_removes_match() {
let mut backend = TuiBackend::new();
backend.register_accelerator(&make_acc("tui.fuzzy_finder", "<C-p>"));
backend.unregister_accelerator(&AcceleratorId::new("tui.fuzzy_finder"));
let mut events = vec![ctrl_p_keypress()];
backend.apply_accelerators(&mut events);
assert!(matches!(events[0], UiEvent::KeyPressed { .. }));
}
#[test]
fn accelerator_re_register_replaces_binding() {
let mut backend = TuiBackend::new();
backend.register_accelerator(&make_acc("test.toggle", "<C-p>"));
backend.register_accelerator(&make_acc("test.toggle", "<C-q>"));
let mut events = vec![ctrl_p_keypress()];
backend.apply_accelerators(&mut events);
assert!(
matches!(events[0], UiEvent::KeyPressed { .. }),
"old binding must not match after re-register"
);
let mut events = vec![UiEvent::KeyPressed {
key: Key::Char('q'),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
repeat: false,
}];
backend.apply_accelerators(&mut events);
assert!(matches!(&events[0], UiEvent::Accelerator(id, _) if id.as_str() == "test.toggle"));
}
#[test]
fn accelerator_widget_scope_skipped() {
let mut backend = TuiBackend::new();
backend.register_accelerator(&Accelerator {
id: AcceleratorId::new("widget.local"),
binding: KeyBinding::Literal("<C-p>".into()),
scope: AcceleratorScope::Widget(WidgetId::new("test:input")),
label: None,
});
let mut events = vec![ctrl_p_keypress()];
backend.apply_accelerators(&mut events);
assert!(matches!(events[0], UiEvent::KeyPressed { .. }));
}
#[test]
fn selection_highlight_and_extract_round_trip() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect as RRect;
use ratatui::style::Color as RC;
let area = RRect::new(0, 0, 10, 3);
let mut buf = Buffer::empty(area);
for (col, ch) in "hello ".chars().enumerate() {
buf[(col as u16, 0)]
.set_char(ch)
.set_fg(RC::White)
.set_bg(RC::Black);
}
for (col, ch) in "world ".chars().enumerate() {
buf[(col as u16, 1)]
.set_char(ch)
.set_fg(RC::White)
.set_bg(RC::Black);
}
for (col, ch) in "! ".chars().enumerate() {
buf[(col as u16, 2)]
.set_char(ch)
.set_fg(RC::White)
.set_bg(RC::Black);
}
let mut backend = TuiBackend::new();
backend.register_text_region(crate::dispatch::TextRegion {
id: WidgetId::new("log:body"),
bounds: crate::event::Rect::new(0.0, 0.0, 10.0, 3.0),
lines: vec![],
});
backend.set_active_text_selection(
WidgetId::new("log:body"),
Point::new(0.0, 0.0),
Point::new(0.0, 2.0),
);
let text = backend.extract_selection_text(&buf);
assert_eq!(text, "hello\nworld\n!");
backend.apply_selection_highlight(&mut buf);
assert_eq!(buf[(0u16, 0u16)].fg, RC::Black);
assert_eq!(buf[(0u16, 0u16)].bg, RC::White);
assert_eq!(buf[(1u16, 2u16)].fg, RC::White);
assert_eq!(buf[(1u16, 2u16)].bg, RC::Black);
}
#[test]
fn selection_clears_on_clear_text_selection() {
let mut backend = TuiBackend::new();
backend.set_active_text_selection(
WidgetId::new("r"),
Point::new(0.0, 0.0),
Point::new(5.0, 0.0),
);
assert!(backend.active_text_selection().is_some());
backend.clear_text_selection();
assert!(backend.active_text_selection().is_none());
backend
.drag_state
.borrow_mut()
.begin(DragTarget::TextSelection {
region: WidgetId::new("r"),
anchor: Point::new(0.0, 0.0),
});
backend.clear_text_selection();
assert!(!backend.drag_state.borrow().is_active());
}
#[test]
fn text_regions_cleared_on_begin_frame() {
let mut backend = TuiBackend::new();
backend.register_text_region(crate::dispatch::TextRegion {
id: WidgetId::new("r"),
bounds: crate::event::Rect::new(0.0, 0.0, 40.0, 20.0),
lines: vec![],
});
assert_eq!(backend.text_regions().len(), 1);
backend.begin_frame(Viewport::default());
assert_eq!(
backend.text_regions().len(),
0,
"text_regions must be cleared on begin_frame"
);
}
#[test]
fn select_all_text_region_sets_full_bounds() {
let mut backend = TuiBackend::new();
backend.register_text_region(crate::dispatch::TextRegion {
id: WidgetId::new("body"),
bounds: crate::event::Rect::new(0.0, 0.0, 10.0, 5.0),
lines: vec![],
});
assert!(
backend.select_all_text_region(),
"should return true when exactly one region is registered"
);
let sel = backend
.active_text_selection()
.expect("selection should be active after select_all_text_region");
assert_eq!(
sel.anchor,
Point::new(0.0, 0.0),
"anchor should be at top-left of region bounds"
);
assert_eq!(
sel.focus,
Point::new(10.0, 5.0),
"focus should be at bottom-right of region bounds"
);
}
#[test]
fn select_all_text_region_returns_false_when_no_regions() {
let mut backend = TuiBackend::new();
assert!(
!backend.select_all_text_region(),
"should return false with no registered regions"
);
assert!(
backend.active_text_selection().is_none(),
"no selection should be set when select_all returns false"
);
}
#[test]
fn focused_activity_bar_cleared_on_begin_frame() {
let mut backend = TuiBackend::new();
backend.focused_activity_bar = Some(WidgetId::new("demo:bar"));
backend.begin_frame(Viewport::default());
assert!(
backend.focused_activity_bar.is_none(),
"focused_activity_bar must be cleared by begin_frame"
);
}
#[test]
fn activity_bar_focused_redirects_key_presses() {
use crate::ActivityBarEvent;
let mut backend = TuiBackend::new();
backend.focused_activity_bar = Some(WidgetId::new("demo:bar"));
let raw = vec![UiEvent::KeyPressed {
key: Key::Char('j'),
modifiers: Modifiers::default(),
repeat: false,
}];
let out = backend.apply_dispatch(raw);
assert_eq!(
out.len(),
1,
"one input event must produce one output event"
);
match &out[0] {
UiEvent::ActivityBar(id, ActivityBarEvent::KeyPressed { key, modifiers }) => {
assert_eq!(id.as_str(), "demo:bar");
assert_eq!(key, "j");
assert_eq!(*modifiers, Modifiers::default());
}
other => panic!("expected UiEvent::ActivityBar KeyPressed, got {other:?}"),
}
}
#[test]
fn activity_bar_unfocused_passes_key_presses_through() {
let mut backend = TuiBackend::new();
assert!(backend.focused_activity_bar.is_none());
let raw = vec![UiEvent::KeyPressed {
key: Key::Char('j'),
modifiers: Modifiers::default(),
repeat: false,
}];
let out = backend.apply_dispatch(raw);
assert_eq!(out.len(), 1);
assert!(
matches!(
&out[0],
UiEvent::KeyPressed {
key: Key::Char('j'),
..
}
),
"key must pass through unchanged when no bar is focused"
);
}
#[test]
fn key_to_activity_bar_string_maps_correctly() {
use crate::primitives::activity_bar::key_to_activity_bar_string;
assert_eq!(key_to_activity_bar_string(&Key::Char('j')), "j");
assert_eq!(key_to_activity_bar_string(&Key::Char('K')), "K");
assert_eq!(
key_to_activity_bar_string(&Key::Named(NamedKey::Escape)),
"Escape"
);
assert_eq!(
key_to_activity_bar_string(&Key::Named(NamedKey::Enter)),
"Enter"
);
assert_eq!(key_to_activity_bar_string(&Key::Named(NamedKey::Up)), "Up");
assert_eq!(
key_to_activity_bar_string(&Key::Named(NamedKey::Down)),
"Down"
);
}
#[test]
fn coalesce_collapses_consecutive_mouse_moved() {
use crate::ButtonMask;
let raw = vec![
UiEvent::MouseMoved {
position: Point::new(1.0, 1.0),
buttons: ButtonMask::default(),
},
UiEvent::MouseMoved {
position: Point::new(2.0, 1.0),
buttons: ButtonMask::default(),
},
UiEvent::MouseMoved {
position: Point::new(3.0, 1.0),
buttons: ButtonMask::default(),
},
];
let out = coalesce_mouse_moved(raw);
assert_eq!(out.len(), 1, "three consecutive moves must collapse to one");
assert!(
matches!(&out[0], UiEvent::MouseMoved { position, .. } if position.x == 3.0),
"surviving event must carry the final position (x=3), got {:?}",
out
);
}
#[test]
fn coalesce_preserves_ordering_around_down_and_up() {
use crate::{ButtonMask, Modifiers, MouseButton};
let raw = vec![
UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(0.0, 0.0),
modifiers: Modifiers::default(),
},
UiEvent::MouseMoved {
position: Point::new(1.0, 0.0),
buttons: ButtonMask {
left: true,
..ButtonMask::default()
},
},
UiEvent::MouseMoved {
position: Point::new(2.0, 0.0),
buttons: ButtonMask {
left: true,
..ButtonMask::default()
},
},
UiEvent::MouseMoved {
position: Point::new(3.0, 0.0),
buttons: ButtonMask {
left: true,
..ButtonMask::default()
},
},
UiEvent::MouseUp {
widget: None,
button: MouseButton::Left,
position: Point::new(3.0, 0.0),
},
];
let out = coalesce_mouse_moved(raw);
assert_eq!(out.len(), 3, "expected [Down, Moved, Up], got {out:?}");
assert!(
matches!(&out[0], UiEvent::MouseDown { .. }),
"first event must be MouseDown"
);
assert!(
matches!(&out[1], UiEvent::MouseMoved { position, .. } if position.x == 3.0),
"middle event must be the final MouseMoved (x=3), got {:?}",
out[1]
);
assert!(
matches!(&out[2], UiEvent::MouseUp { .. }),
"last event must be MouseUp"
);
}
#[test]
fn coalesce_does_not_merge_moves_separated_by_other_events() {
use crate::{ButtonMask, Modifiers};
let raw = vec![
UiEvent::MouseMoved {
position: Point::new(1.0, 0.0),
buttons: ButtonMask::default(),
},
UiEvent::MouseMoved {
position: Point::new(2.0, 0.0),
buttons: ButtonMask::default(),
},
UiEvent::KeyPressed {
key: Key::Char('x'),
modifiers: Modifiers::default(),
repeat: false,
},
UiEvent::MouseMoved {
position: Point::new(5.0, 0.0),
buttons: ButtonMask::default(),
},
UiEvent::MouseMoved {
position: Point::new(6.0, 0.0),
buttons: ButtonMask::default(),
},
];
let out = coalesce_mouse_moved(raw);
assert_eq!(out.len(), 3, "expected 3 events, got {out:?}");
assert!(
matches!(&out[0], UiEvent::MouseMoved { position, .. } if position.x == 2.0),
"first run must collapse to x=2, got {:?}",
out[0]
);
assert!(
matches!(&out[1], UiEvent::KeyPressed { .. }),
"middle event must be KeyPressed"
);
assert!(
matches!(&out[2], UiEvent::MouseMoved { position, .. } if position.x == 6.0),
"second run must collapse to x=6, got {:?}",
out[2]
);
}
const UNSCALED_TEST_CELL: TerminalCellSize = TerminalCellSize {
width: 1.0,
height: 1.0,
};
fn char_run(s: &str) -> Vec<UiEvent> {
s.chars()
.map(|c| UiEvent::KeyPressed {
key: Key::Char(c),
modifiers: Modifiers::default(),
repeat: false,
})
.collect()
}
#[test]
fn recovers_leaked_pure_motion_report() {
use crate::ButtonMask;
let raw = char_run("[<35;10;5M");
let out = recover_leaked_sgr_mouse_fragments(raw, UNSCALED_TEST_CELL);
assert_eq!(
out.len(),
1,
"expected exactly one recovered event: {out:?}"
);
assert!(
matches!(
&out[0],
UiEvent::MouseMoved { position, buttons }
if position.x == 9.0 && position.y == 4.0 && *buttons == ButtonMask::default()
),
"expected MouseMoved(9,4) with no buttons held, got {:?}",
out[0]
);
}
#[test]
fn recovers_leaked_pure_motion_report_scaled_to_pixel_cell_size() {
let raw = char_run("[<35;10;5M");
let cell = TerminalCellSize::new(10.0, 20.0);
let out = recover_leaked_sgr_mouse_fragments(raw, cell);
assert!(
matches!(
&out[..],
[UiEvent::MouseMoved { position, .. }]
if (position.x - 0.9).abs() < f32::EPSILON
&& (position.y - 0.2).abs() < f32::EPSILON
),
"expected MouseMoved(0.9, 0.2) after dividing pixel report (9,4) by cell (10,20), \
got {out:?}"
);
}
#[test]
fn preserves_a_genuine_leading_escape_before_the_recovered_report() {
let mut raw = vec![UiEvent::KeyPressed {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
}];
raw.extend(char_run("[<35;10;5M"));
let out = recover_leaked_sgr_mouse_fragments(raw, UNSCALED_TEST_CELL);
assert_eq!(out.len(), 2, "expected [Escape, MouseMoved]: {out:?}");
assert!(matches!(
&out[0],
UiEvent::KeyPressed {
key: Key::Named(NamedKey::Escape),
..
}
));
assert!(matches!(&out[1], UiEvent::MouseMoved { .. }));
}
#[test]
fn recovers_leaked_click_down_and_up() {
use crate::MouseButton;
let down = recover_leaked_sgr_mouse_fragments(char_run("[<0;3;4M"), UNSCALED_TEST_CELL);
assert!(
matches!(
&down[..],
[UiEvent::MouseDown { button: MouseButton::Left, position, .. }]
if position.x == 2.0 && position.y == 3.0
),
"expected a single MouseDown(2,3): {down:?}"
);
let up = recover_leaked_sgr_mouse_fragments(char_run("[<0;3;4m"), UNSCALED_TEST_CELL);
assert!(
matches!(
&up[..],
[UiEvent::MouseUp { button: MouseButton::Left, position, .. }]
if position.x == 2.0 && position.y == 3.0
),
"expected a single MouseUp(2,3): {up:?}"
);
}
#[test]
fn recovers_leaked_drag_report() {
let out = recover_leaked_sgr_mouse_fragments(char_run("[<32;7;8M"), UNSCALED_TEST_CELL);
assert!(
matches!(
&out[..],
[UiEvent::MouseMoved { position, buttons }]
if position.x == 6.0 && position.y == 7.0 && buttons.left
),
"expected a single held-left MouseMoved(6,7): {out:?}"
);
}
#[test]
fn leaves_non_matching_bracket_runs_untouched() {
let raw = char_run("[<12;34");
let out = recover_leaked_sgr_mouse_fragments(raw.clone(), UNSCALED_TEST_CELL);
assert_eq!(
out, raw,
"an incomplete/unterminated run must pass through verbatim"
);
let raw = char_run("[hello");
let out = recover_leaked_sgr_mouse_fragments(raw.clone(), UNSCALED_TEST_CELL);
assert_eq!(
out, raw,
"'[' not followed by '<' must pass through verbatim"
);
}
#[test]
fn passes_through_unrelated_events_and_recovers_multiple_leaks_in_one_batch() {
let mut raw = vec![UiEvent::KeyPressed {
key: Key::Char('h'),
modifiers: Modifiers::default(),
repeat: false,
}];
raw.extend(char_run("[<35;10;5M"));
raw.push(UiEvent::KeyPressed {
key: Key::Char('i'),
modifiers: Modifiers::default(),
repeat: false,
});
raw.extend(char_run("[<35;11;6M"));
let out = recover_leaked_sgr_mouse_fragments(raw, UNSCALED_TEST_CELL);
assert_eq!(
out.len(),
4,
"expected [Char(h), Moved, Char(i), Moved]: {out:?}"
);
assert!(matches!(
&out[0],
UiEvent::KeyPressed {
key: Key::Char('h'),
..
}
));
assert!(matches!(&out[1], UiEvent::MouseMoved { position, .. } if position.x == 9.0));
assert!(matches!(
&out[2],
UiEvent::KeyPressed {
key: Key::Char('i'),
..
}
));
assert!(matches!(&out[3], UiEvent::MouseMoved { position, .. } if position.x == 10.0));
}
fn audit_bar() -> TabBar {
TabBar {
id: WidgetId::new("tabs"),
tabs: vec![
crate::primitives::tab_bar::TabItem {
label: "main.rs".into(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: true,
},
crate::primitives::tab_bar::TabItem {
label: "lib.rs".into(),
is_active: false,
is_dirty: false,
is_preview: false,
is_closable: true,
},
],
right_segments: vec![],
active_accent: None,
scroll_offset: 0,
show_tab_close: true,
compact: false,
}
}
#[test]
#[allow(deprecated)] fn backend_trait_glyph_overrides_are_reachable_and_honoured() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut backend = TuiBackend::new();
backend.begin_frame(Viewport::new(80.0, 24.0, 1.0));
let bar = audit_bar();
let tab_glyphs = crate::primitives::tab_bar::TabBarGlyphs::new().with_close_glyph("x");
let menu = crate::ContextMenu {
id: WidgetId::new("menu"),
items: vec![crate::primitives::context_menu::ContextMenuItem {
id: Some(WidgetId::new("wrap")),
label: StyledText {
spans: vec![StyledSpan::plain("Word Wrap")],
},
checked: Some(true),
..Default::default()
}],
selected_idx: 0,
bg: None,
placement: crate::primitives::context_menu::ContextMenuPlacement::default(),
};
let menu_layout = menu.layout(
2.0,
1.0,
crate::event::Rect::new(0.0, 0.0, 30.0, 10.0),
20.0,
|_| crate::primitives::context_menu::ContextMenuItemMeasure::new(1.0),
);
let menu_glyphs =
crate::primitives::context_menu::ContextMenuGlyphs::new().with_checked_glyph("+");
let table = crate::DataTable {
id: WidgetId::new("table"),
columns: vec![crate::primitives::data_table::Column {
title: "Name".into(),
width: crate::primitives::data_table::ColumnWidth::Flex(1.0),
align: crate::primitives::data_table::ColumnAlign::Left,
}],
rows: vec![],
selected_idx: None,
scroll_offset: 0,
sort: Some((0, crate::primitives::data_table::SortDirection::Ascending)),
has_focus: false,
show_scrollbar: false,
min_total_width: None,
h_scroll: 0.0,
column_overrides: Vec::new(),
footer: None,
};
let table_glyphs =
crate::primitives::data_table::DataTableGlyphs::new().with_sort_ascending_glyph("^");
let mut tab_terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal");
tab_terminal
.draw(|frame| {
backend.enter_frame_scope(frame, |b| {
b.draw_tab_bar_with_chrome_and_glyphs(
QRect::new(0.0, 0.0, 40.0, 1.0),
&bar,
None,
&TabChrome::default(),
Some(&tab_glyphs),
);
});
})
.expect("draw");
let tab_screen = screen_text(tab_terminal.backend().buffer());
assert!(
tab_screen.contains('x'),
"overridden tab close glyph should reach the screen, got: {tab_screen}"
);
assert!(
!tab_screen.contains(crate::tui::tab_bar::TAB_CLOSE_CHAR),
"default tab close glyph should not leak through the override, got: {tab_screen}"
);
let mut menu_terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal");
menu_terminal
.draw(|frame| {
backend.enter_frame_scope(frame, |b| {
b.draw_context_menu_with_glyphs(&menu, &menu_layout, Some(&menu_glyphs));
});
})
.expect("draw");
let menu_screen = screen_text(menu_terminal.backend().buffer());
assert!(
menu_screen.contains('+'),
"overridden context-menu checked glyph should reach the screen, \
got: {menu_screen}"
);
assert!(
!menu_screen.contains('✓'),
"default context-menu checked glyph should not leak through the \
override, got: {menu_screen}"
);
let mut table_terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal");
table_terminal
.draw(|frame| {
backend.enter_frame_scope(frame, |b| {
b.draw_data_table_with_glyphs(
QRect::new(0.0, 0.0, 40.0, 5.0),
&table,
None,
Some(&table_glyphs),
);
});
})
.expect("draw");
let table_screen = screen_text(table_terminal.backend().buffer());
assert!(
table_screen.contains('^'),
"overridden data-table sort glyph should reach the screen, got: \
{table_screen}"
);
assert!(
!table_screen.contains('▲'),
"default data-table sort-ascending glyph should not leak through \
the override, got: {table_screen}"
);
}
fn screen_text(buf: &ratatui::buffer::Buffer) -> String {
buf.content.iter().fold(String::new(), |mut s, cell| {
s.push_str(cell.symbol());
s
})
}
#[test]
#[allow(deprecated)] fn tab_bar_layout_returns_absolute_x_not_bar_relative() {
let backend = TuiBackend::new();
let bar = audit_bar();
let at_origin = backend.tab_bar_layout(QRect::new(0.0, 0.0, 40.0, 1.0), &bar);
let shifted = backend.tab_bar_layout(QRect::new(12.0, 0.0, 40.0, 1.0), &bar);
assert!(
!at_origin.slot_positions.is_empty(),
"sanity: some tabs should be laid out"
);
for (i, (base, moved)) in at_origin
.slot_positions
.iter()
.zip(shifted.slot_positions.iter())
.enumerate()
{
if *base == (0.0, 0.0) {
assert_eq!(
*moved,
(0.0, 0.0),
"tab {i}: the scrolled-out sentinel must not be shifted"
);
continue;
}
assert_eq!(
(moved.0, moved.1),
(base.0 + 12.0, base.1 + 12.0),
"tab {i}: `tab_bar_layout` is documented to return \
target-surface coordinates, so moving the bar right by 12 \
must move its hit spans right by 12 (issue #552 audit)"
);
}
}
#[test]
#[allow(deprecated)] fn tab_bar_layout_agrees_with_draw_tab_bar_on_coordinate_space() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut backend = TuiBackend::new();
backend.begin_frame(Viewport::new(80.0, 24.0, 1.0));
let bar = audit_bar();
let rect = QRect::new(12.0, 0.0, 40.0, 1.0);
let from_layout = backend.tab_bar_layout(rect, &bar);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal");
let mut painted = None;
terminal
.draw(|frame| {
painted =
Some(backend.enter_frame_scope(frame, |b| b.draw_tab_bar(rect, &bar, None)));
})
.expect("draw");
let from_paint = painted.expect("draw closure ran");
assert!(
from_paint.slot_positions.iter().any(|s| *s != (0.0, 0.0)),
"sanity: the rasteriser should have placed at least one tab"
);
assert_eq!(
from_layout.slot_positions, from_paint.slot_positions,
"`tab_bar_layout` and `draw_tab_bar` must report tab slots in \
the same coordinate space (issue #552 audit)"
);
assert_eq!(
from_layout.close_bounds, from_paint.close_bounds,
"close-button spans must match between the paint and no-paint \
paths too"
);
}
#[test]
#[allow(deprecated)] fn resolve_tab_bar_layout_matches_tab_bar_layout_hits_geometry() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut backend = TuiBackend::new();
backend.begin_frame(Viewport::new(80.0, 24.0, 1.0));
let bar = audit_bar();
let rect = QRect::new(12.0, 0.0, 40.0, 1.0);
let hits = backend.tab_bar_layout(rect, &bar);
let resolved = backend.resolve_tab_bar_layout(rect, &bar);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal");
let mut painted_hits = None;
let mut painted_layout = None;
terminal
.draw(|frame| {
backend.enter_frame_scope(frame, |b| {
painted_hits = Some(b.draw_tab_bar(rect, &bar, None));
painted_layout = Some(b.draw_tab_bar_layout(rect, &bar, None));
});
})
.expect("draw");
let painted_hits = painted_hits.expect("draw closure ran");
let painted_layout = painted_layout.expect("draw closure ran");
assert!(
!resolved.visible_tabs.is_empty(),
"sanity: some tabs should be laid out"
);
for vt in &resolved.visible_tabs {
let expected = (
(rect.x + vt.bounds.x) as f64,
(rect.x + vt.bounds.x + vt.bounds.width) as f64,
);
assert_eq!(
hits.slot_positions[vt.tab_idx], expected,
"tab {}: `resolve_tab_bar_layout` (shifted by rect.x) must \
match `tab_bar_layout`'s absolute slot",
vt.tab_idx
);
let expected_close = vt
.close_bounds
.map(|cb| ((rect.x + cb.x) as f64, (rect.x + cb.x + cb.width) as f64));
assert_eq!(
hits.close_bounds[vt.tab_idx], expected_close,
"tab {}: close-button span must match too",
vt.tab_idx
);
}
for vt in &painted_layout.visible_tabs {
let expected = (
(rect.x + vt.bounds.x) as f64,
(rect.x + vt.bounds.x + vt.bounds.width) as f64,
);
assert_eq!(
painted_hits.slot_positions[vt.tab_idx], expected,
"tab {}: `draw_tab_bar_layout` (shifted by rect.x) must \
match `draw_tab_bar`'s absolute slot",
vt.tab_idx
);
}
assert_eq!(
resolved.visible_tabs, painted_layout.visible_tabs,
"the no-paint and paint `TabBarLayout` accessors must agree \
on geometry for the same bar and rect"
);
}
#[test]
fn snap_height_matches_q_rect_to_ratatui_height_rounding() {
let backend = TuiBackend::new();
for h in [
0.0_f32, 0.2, 0.49, 0.5, 0.51, 1.0, 1.4, 1.6, 2.5, 13.999, -1.0, -0.4,
] {
let snapped = backend.snap_height(h);
let via_rect = q_rect_to_ratatui(QRect::new(0.0, 0.0, 0.0, h)).height;
assert_eq!(
snapped as u16, via_rect,
"snap_height({h}) = {snapped}, but q_rect_to_ratatui reports \
height {via_rect} for the same input"
);
}
}
#[test]
fn tui_backend_scrollbar_reserve_uses_the_zero_default() {
let backend = TuiBackend::new();
assert_eq!(Backend::scrollbar_reserve(&backend), 0.0);
}
#[test]
fn tui_backend_draw_status_bar_interactive_scaled_uses_the_unscaled_default() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let bar = crate::primitives::status_bar::StatusBar {
id: WidgetId::new("status"),
left_segments: vec![crate::primitives::status_bar::StatusBarSegment {
text: "READY".into(),
fg: crate::Color::rgb(255, 255, 255),
bg: crate::Color::rgb(0, 0, 0),
bold: false,
action_id: None,
}],
right_segments: vec![],
};
let rect = QRect::new(0.0, 0.0, 40.0, 1.0);
let interaction = crate::InteractionState::new();
let mut unscaled_backend = TuiBackend::new();
let mut scaled_backend = TuiBackend::new();
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal");
let unscaled_result = std::cell::RefCell::new(None);
let scaled_result = std::cell::RefCell::new(None);
terminal
.draw(|frame| {
let unscaled = unscaled_backend.enter_frame_scope(frame, |b| {
b.draw_status_bar_interactive(rect, &bar, &interaction)
});
*unscaled_result.borrow_mut() = Some(unscaled);
let scaled = scaled_backend.enter_frame_scope(frame, |b| {
b.draw_status_bar_interactive_scaled(rect, &bar, &interaction, 0.5)
});
*scaled_result.borrow_mut() = Some(scaled);
})
.expect("draw");
assert_eq!(
unscaled_result.into_inner().unwrap(),
scaled_result.into_inner().unwrap(),
"TUI has no font to scale; the default impl must ignore font_scale entirely"
);
}
#[test]
fn snap_height_default_impl_is_identity() {
let backend = MockBackend::new();
for h in [0.0_f32, 0.4, 1.6, 13.999, -1.0] {
assert_eq!(
Backend::snap_height(&backend, h),
h,
"default snap_height impl must be identity for pixel backends"
);
}
}
#[test]
fn tui_backend_declares_no_native_dialogs() {
let backend = TuiBackend::new();
assert!(
!backend.backend_caps().native_dialogs,
"TUI has no native alert facility; native_dialogs must stay false"
);
let opts = MessageDialogOptions {
title: "Unsaved Changes".to_string(),
body: "Do you want to save?".to_string(),
buttons: Vec::new(),
severity: None,
};
assert!(backend.services().show_message_dialog(opts).is_none());
}
#[test]
fn list_layout_ignores_rect_origin() {
let backend = TuiBackend::new();
let list = sample_list();
let at_origin = backend.list_layout(QRect::new(0.0, 0.0, 20.0, 5.0), &list);
let shifted = backend.list_layout(QRect::new(7.0, 3.0, 20.0, 5.0), &list);
assert_eq!(
at_origin, shifted,
"list_layout must ignore rect.x/rect.y (LOCAL frame, issue #505)"
);
assert!(
!at_origin.visible_items.is_empty(),
"sanity: the sample list has an item to lay out"
);
}
#[test]
fn list_layout_matches_draw_list_resolver() {
let backend = TuiBackend::new();
let list = sample_list();
let rect = QRect::new(3.0, 1.0, 20.0, 5.0);
let area = q_rect_to_ratatui(rect);
let no_paint = backend.list_layout(rect, &list);
let mut buf = ratatui::buffer::Buffer::empty(area);
crate::tui::draw_list(&mut buf, area, &list, &backend.current_theme, false);
let via_paint_helper = crate::tui::tui_list_layout(area, &list);
assert_eq!(
no_paint, via_paint_helper,
"list_layout must equal the exact helper draw_list uses internally"
);
assert!(
!no_paint.visible_items.is_empty(),
"sanity: the sample list has an item to lay out"
);
}
#[test]
fn board_layout_is_absolute_and_matches_draw_board() {
let backend = TuiBackend::new();
let model = crate::primitives::board::BoardModel {
id: WidgetId::new("test:board"),
columns: vec![crate::primitives::board::BoardColumn {
id: WidgetId::new("col:a"),
title: "Backlog".into(),
cards: vec![crate::primitives::board::BoardCard {
id: WidgetId::new("card:1"),
title: "Do the thing".into(),
labels: vec![],
badges: vec![],
hint: None,
}],
scroll_offset: 0,
}],
selected_card_id: None,
col_scroll_offset: 0,
};
let rect = QRect::new(5.0, 2.0, 40.0, 12.0);
let area = q_rect_to_ratatui(rect);
let no_paint = backend.board_layout(rect, &model);
assert_eq!(
no_paint.bounds,
crate::event::Rect::new(rect.x, rect.y, rect.width, rect.height),
"board_layout must fold rect's origin into `bounds` (ABSOLUTE, issue #505)"
);
let mut buf = ratatui::buffer::Buffer::empty(area);
let painted = crate::tui::draw_board(&mut buf, area, &model, &backend.current_theme);
assert_eq!(
no_paint, painted,
"board_layout must equal the exact layout draw_board painted with"
);
}
#[test]
fn terminal_layout_reserves_scrollbar_gutter_matching_draw_terminal() {
let backend = TuiBackend::new();
let cell = crate::TerminalCell {
text: "x".to_string(),
fg: crate::Color::rgb(200, 200, 200),
bg: crate::Color::rgb(20, 20, 20),
bold: false,
italic: false,
underline: false,
dim: false,
selected: false,
is_cursor: false,
is_find_match: false,
is_find_active: false,
cursor_shape: TerminalCursorShape::Block,
cursor_blinking: false,
};
let term = TerminalPrim {
id: WidgetId::new("t"),
cells: vec![vec![cell; 20]; 5],
scrollbar: Some(crate::TerminalScrollbar {
total_lines: 100,
visible_lines: 5,
scroll_offset: 0,
inverted: false,
width: None,
}),
};
let rect = QRect::new(0.0, 0.0, 10.0, 5.0);
let area = q_rect_to_ratatui(rect);
let layout = backend.terminal_layout(rect, &term);
assert_eq!(
layout.grid_cols, 9,
"terminal_layout must reserve the 1-column scrollbar gutter (TUI's \
terminal_scrollbar_default_width), not the full unreduced rect.width"
);
assert_eq!(
layout.hit_test(9.0, 0.0),
crate::primitives::terminal::TerminalHit::Empty,
"a click at x=9 (the scrollbar gutter column) must not resolve to a grid cell"
);
let mut buf = ratatui::buffer::Buffer::empty(area);
crate::tui::draw_terminal(&mut buf, area, &term, &backend.current_theme);
assert_ne!(
buf[(area.x + 9, area.y)].symbol(),
"x",
"column 9 is draw_terminal's scrollbar gutter — it must not show painted cell \
content, matching terminal_layout's grid_cols=9 exclusion of that column"
);
}
fn sample_diff_view(mode: crate::DiffMode, left_label: Option<String>) -> crate::DiffView {
crate::DiffView {
id: WidgetId::new("diff"),
left: String::new(),
right: String::new(),
left_label,
right_label: None,
hunks: vec![crate::DiffHunk {
left_start: 1,
right_start: 1,
rows: vec![
crate::DiffRow {
left: Some("alpha".into()),
right: Some("ALPHA".into()),
kind: crate::DiffRowKind::Changed,
},
crate::DiffRow {
left: Some("beta".into()),
right: Some("beta".into()),
kind: crate::DiffRowKind::Same,
},
],
}],
mode,
editability: crate::DiffEditability::ReadOnly,
scroll_offset: 0,
focused_pane: crate::DiffPane::Left,
has_focus: false,
}
}
#[test]
fn diff_view_layout_matches_draw_diff_view_side_by_side_with_header() {
let backend = TuiBackend::new();
let view = sample_diff_view(crate::DiffMode::SideBySide, Some("left.txt".into()));
let rect = QRect::new(0.0, 0.0, 40.0, 5.0);
let area = q_rect_to_ratatui(rect);
let no_paint = backend.diff_view_layout(rect, &view);
let mut buf = ratatui::buffer::Buffer::empty(area);
let painted = crate::tui::draw_diff_view(&mut buf, area, &view, &backend.current_theme);
assert_eq!(
no_paint, painted,
"diff_view_layout must equal the exact layout draw_diff_view painted with"
);
assert_eq!(
no_paint.visible_rows, 4,
"a 5-row rect minus a 1-row header (left_label is set) leaves 4 content rows"
);
}
#[test]
fn diff_view_layout_matches_draw_diff_view_unified() {
let backend = TuiBackend::new();
let view = sample_diff_view(crate::DiffMode::Unified, None);
let rect = QRect::new(0.0, 0.0, 40.0, 5.0);
let area = q_rect_to_ratatui(rect);
let no_paint = backend.diff_view_layout(rect, &view);
let mut buf = ratatui::buffer::Buffer::empty(area);
let painted = crate::tui::draw_diff_view(&mut buf, area, &view, &backend.current_theme);
assert_eq!(
no_paint, painted,
"diff_view_layout must equal the exact layout draw_diff_view painted with (unified \
mode reserves no header band)"
);
}
fn blank_editor_line(idx: usize) -> crate::EditorLine {
crate::EditorLine {
raw_text: String::new(),
gutter_text: String::new(),
spans: Vec::new(),
line_idx: idx,
is_current_line: false,
is_fold_header: false,
folded_line_count: 0,
git_diff: None,
diff_status: None,
diagnostics: Vec::new(),
spell_errors: Vec::new(),
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::new(),
colorcolumns: Vec::new(),
}
}
#[test]
fn editor_layout_matches_draw_editor_scrollbar_track() {
let backend = TuiBackend::new();
let rect = QRect::new(2.0, 1.0, 20.0, 5.0);
let editor = crate::Editor {
id: WidgetId::new("ed"),
rect,
lines: (0..5).map(blank_editor_line).collect(),
cursor: None,
extra_cursors: Vec::new(),
selection: None,
extra_selections: Vec::new(),
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 100,
max_col: 4,
gutter_char_width: 0,
is_active: true,
show_active_bg: false,
has_git_diff: false,
has_breakpoints: false,
diagnostic_gutter: HashMap::new(),
code_action_lines: std::collections::HashSet::new(),
bracket_match_positions: Vec::new(),
active_indent_col: None,
tabstop: 4,
cursorline: false,
lightbulb_glyph: '!',
};
let layout = backend.editor_layout(rect, &editor);
let vsb = layout
.v_scrollbar_bounds
.expect("100 lines in a 5-row viewport must trigger a v-scrollbar");
assert_eq!(
vsb,
crate::event::Rect::new(rect.x + rect.width - 1.0, rect.y, 1.0, rect.height),
"editor_layout's v_scrollbar_bounds must match draw_editor's own track formula \
`(area.x + area.width - 1, area.y, 1, track_h)` (src/tui/editor.rs)"
);
let area = q_rect_to_ratatui(rect);
let mut buf = ratatui::buffer::Buffer::empty(area);
crate::tui::draw_editor(&mut buf, area, &editor, &backend.current_theme);
assert_eq!(
layout.hit_test(vsb.x, vsb.y),
crate::EditorHit::VScrollbar,
"a click at the painted scrollbar track's origin must resolve via editor_layout's \
own hit_test as VScrollbar"
);
}
#[test]
fn editor_right_reserve_w_narrows_layout_without_moving_scrollbar() {
let mut backend = TuiBackend::new();
assert_eq!(
backend.editor_right_reserve_w(),
None,
"a host that never calls the setter must see no reserve"
);
let rect = QRect::new(2.0, 1.0, 20.0, 5.0);
let editor = crate::Editor {
id: WidgetId::new("ed"),
rect,
lines: (0..5).map(blank_editor_line).collect(),
cursor: None,
extra_cursors: Vec::new(),
selection: None,
extra_selections: Vec::new(),
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 100,
max_col: 40,
gutter_char_width: 0,
is_active: true,
show_active_bg: false,
has_git_diff: false,
has_breakpoints: false,
diagnostic_gutter: HashMap::new(),
code_action_lines: std::collections::HashSet::new(),
bracket_match_positions: Vec::new(),
active_indent_col: None,
tabstop: 4,
cursorline: false,
lightbulb_glyph: '!',
};
let baseline = backend.editor_layout(rect, &editor);
backend.set_editor_right_reserve_w(Some(3.0));
assert_eq!(backend.editor_right_reserve_w(), Some(3.0));
let reserved = backend.editor_layout(rect, &editor);
let vsb = baseline
.v_scrollbar_bounds
.expect("100 lines in a 5-row viewport must trigger a v-scrollbar");
assert_eq!(
reserved.v_scrollbar_bounds,
Some(vsb),
"v_scrollbar_bounds must stay put — right_reserve_w only narrows the text area"
);
assert_eq!(
reserved.text_bounds.width,
baseline.text_bounds.width - 3.0,
"text_bounds must narrow by exactly the stored reserve"
);
let baseline_hsb = baseline
.h_scrollbar_bounds
.expect("max_col 40 over a narrow viewport must trigger an h-scrollbar");
let reserved_hsb = reserved
.h_scrollbar_bounds
.expect("the reserve narrows visible_cols further, still triggering an h-scrollbar");
assert_eq!(
reserved_hsb.width,
baseline_hsb.width - 3.0,
"h_scrollbar_bounds must narrow by exactly the stored reserve, same as text_bounds"
);
let area = q_rect_to_ratatui(rect);
let mut buf = ratatui::buffer::Buffer::empty(area);
crate::tui::draw_editor_with_reserve(&mut buf, area, &editor, &backend.current_theme, 3);
assert_eq!(
vsb.x,
rect.x + rect.width - 1.0,
"v_scrollbar_bounds's x must match draw_editor's track formula \
(area.x + area.width - 1) even with a non-zero reserve"
);
}
#[test]
fn draw_editor_none_does_not_clobber_earlier_some_cursor_in_same_frame() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let rect = QRect::new(0.0, 0.0, 20.0, 5.0);
let active_editor = crate::Editor {
id: WidgetId::new("active"),
rect,
lines: (0..5).map(blank_editor_line).collect(),
cursor: Some(crate::EditorCursor {
pos: crate::EditorCursorPos {
view_line: 0,
col: 0,
},
shape: crate::EditorCursorShape::Bar,
}),
extra_cursors: Vec::new(),
selection: None,
extra_selections: Vec::new(),
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 5,
max_col: 4,
gutter_char_width: 0,
is_active: true,
show_active_bg: false,
has_git_diff: false,
has_breakpoints: false,
diagnostic_gutter: HashMap::new(),
code_action_lines: std::collections::HashSet::new(),
bracket_match_positions: Vec::new(),
active_indent_col: None,
tabstop: 4,
cursorline: false,
lightbulb_glyph: '!',
};
let mut inactive_editor = active_editor.clone();
inactive_editor.id = WidgetId::new("inactive");
inactive_editor.cursor = None;
inactive_editor.is_active = false;
let mut backend = TuiBackend::new();
backend.begin_frame(Viewport::new(20.0, 5.0, 1.0));
let mut terminal = Terminal::new(TestBackend::new(20, 5)).expect("test terminal");
terminal
.draw(|frame| {
backend.enter_frame_scope(frame, |b| {
let active_result = b.draw_editor(rect, &active_editor);
assert_eq!(
active_result.cursor_position_native,
Some(crate::event::Point::new(0.0, 0.0))
);
let inactive_result = b.draw_editor(rect, &inactive_editor);
assert_eq!(inactive_result.cursor_position_native, None);
});
})
.expect("draw");
assert_eq!(
backend.take_last_cursor_position(),
Some((0, 0)),
"an inactive window's None cursor_position must not clobber the active \
window's Some position recorded earlier in the same frame (quadraui#1002)"
);
}
#[test]
fn mouse_enabled_defaults_to_true() {
let backend = TuiBackend::new();
assert!(backend.mouse_enabled());
assert!(backend.backend_caps().mouse);
}
#[test]
fn set_mouse_enabled_false_is_a_session_level_toggle_only() {
let mut backend = TuiBackend::new();
backend.set_mouse_enabled(false);
assert!(!backend.mouse_enabled());
assert!(
backend.backend_caps().mouse,
"backend_caps().mouse is a static per-backend-type fact, unaffected by \
set_mouse_enabled"
);
}
#[test]
fn set_mouse_enabled_true_restores_state() {
let mut backend = TuiBackend::new();
backend.set_mouse_enabled(false);
backend.set_mouse_enabled(true);
assert!(backend.mouse_enabled());
}
#[test]
fn cell_pixel_size_defaults_to_identity() {
let backend = TuiBackend::new();
assert_eq!(backend.cell_pixel_size(), TerminalCellSize::new(1.0, 1.0));
}
#[test]
fn set_sgr_pixel_mouse_and_cell_size_round_trip() {
let mut backend = TuiBackend::new();
assert!(!backend.backend_caps().sgr_pixel_mouse);
backend.set_sgr_pixel_mouse(true);
backend.set_cell_pixel_size(TerminalCellSize::new(9.0, 18.0));
assert!(backend.sgr_pixel_mouse());
assert!(backend.backend_caps().sgr_pixel_mouse);
assert_eq!(backend.cell_pixel_size(), TerminalCellSize::new(9.0, 18.0));
backend.set_sgr_pixel_mouse(false);
assert!(!backend.backend_caps().sgr_pixel_mouse);
}
#[test]
fn cell_pixel_size_scales_a_live_mouse_report() {
use ratatui::crossterm::event::{MouseButton as CtMouseButton, MouseEvent, MouseEventKind};
let mut backend = TuiBackend::new();
backend.set_sgr_pixel_mouse(true);
backend.set_cell_pixel_size(TerminalCellSize::new(8.0, 16.0));
let raw = MouseEvent {
kind: MouseEventKind::Down(CtMouseButton::Left),
column: 40,
row: 24,
modifiers: ratatui::crossterm::event::KeyModifiers::empty(),
};
let ev =
super::super::events::crossterm_mouse_to_uievent_scaled(raw, backend.cell_pixel_size())
.unwrap();
match ev {
UiEvent::MouseDown { position, .. } => {
assert_eq!(position.x, 5.0); assert_eq!(position.y, 1.5); }
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn no_request_frame_in_call_uses_the_full_ceiling() {
let backend = TuiBackend::new();
assert_eq!(
backend.frame_poll_timeout(Duration::from_millis(250)),
Duration::from_millis(250)
);
}
#[test]
fn request_frame_in_shortens_the_poll_timeout() {
let backend = TuiBackend::new();
Backend::request_frame_in(&backend, Duration::from_millis(10));
let timeout = backend.frame_poll_timeout(Duration::from_millis(250));
assert!(
timeout <= Duration::from_millis(10),
"expected a short timeout, got {timeout:?}"
);
}
#[test]
fn clear_frame_deadline_if_due_is_a_noop_before_the_deadline() {
let mut backend = TuiBackend::new();
Backend::request_frame_in(&backend, Duration::from_secs(60));
backend.clear_frame_deadline_if_due();
let timeout = backend.frame_poll_timeout(Duration::from_millis(1));
assert!(
timeout <= Duration::from_millis(1),
"the still-pending far-future deadline must keep governing the \
ceiling-clamped timeout, got {timeout:?}"
);
}
#[test]
fn clear_frame_deadline_if_due_clears_an_elapsed_deadline() {
let mut backend = TuiBackend::new();
Backend::request_frame_in(&backend, Duration::ZERO);
std::thread::sleep(Duration::from_millis(1));
backend.clear_frame_deadline_if_due();
assert_eq!(
backend.frame_poll_timeout(Duration::from_millis(250)),
Duration::from_millis(250)
);
}
#[test]
fn take_full_repaint_requested_is_consumed_exactly_once() {
let mut backend = TuiBackend::new();
assert!(
!backend.take_full_repaint_requested(),
"nothing requested yet"
);
Backend::request_full_repaint(&mut backend);
assert!(
backend.take_full_repaint_requested(),
"a pending request must be observed"
);
assert!(
!backend.take_full_repaint_requested(),
"the request must be cleared after the first consumption"
);
}
fn empty_tree() -> TreeView {
TreeView {
id: WidgetId::new("tree:empty"),
rows: Vec::new(),
selection_mode: crate::types::SelectionMode::default(),
selected_path: None,
scroll_offset: 0,
style: crate::TreeStyle::default(),
has_focus: false,
}
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "TuiBackend::draw_tree called outside enter_frame_scope")]
fn draw_tree_outside_frame_scope_panics_loudly_in_dev() {
let mut backend = TuiBackend::new();
backend.draw_tree(QRect::new(0.0, 0.0, 10.0, 5.0), &empty_tree());
}
#[test]
#[cfg(not(debug_assertions))]
fn draw_tree_outside_frame_scope_does_not_panic_in_release() {
let mut backend = TuiBackend::new();
backend.draw_tree(QRect::new(0.0, 0.0, 10.0, 5.0), &empty_tree());
}
#[test]
#[cfg(not(debug_assertions))]
fn draw_pipeline_view_outside_frame_scope_degrades_to_pure_layout() {
let mut backend = TuiBackend::new();
let view = crate::primitives::pipeline_view::PipelineView {
id: WidgetId::new("pipeline"),
stages: Vec::new(),
focused_stage: None,
};
let rect = QRect::new(0.0, 0.0, 40.0, 10.0);
let degraded = backend.draw_pipeline_view(rect, &view);
let pure = backend.pipeline_view_layout(rect, &view);
assert_eq!(
degraded, pure,
"a missing frame must fall back to exactly what the pure \
`pipeline_view_layout` twin would have computed"
);
}
#[test]
#[cfg(not(debug_assertions))]
fn draw_minimap_outside_frame_scope_reports_not_painted() {
let mut backend = TuiBackend::new();
let minimap = crate::primitives::minimap::Minimap {
id: WidgetId::new("minimap"),
lines: Vec::new(),
syntax_spans: Vec::new(),
visible_row_start: 0,
visible_row_count: 0,
total_buffer_lines: 0,
};
let rect = QRect::new(0.0, 0.0, 10.0, 20.0);
let result = backend.draw_minimap(rect, &minimap);
assert!(
!result.painted,
"no frame means nothing was actually rasterised"
);
assert_eq!(
result.layout,
backend.minimap_layout(rect, &minimap),
"the reported layout must still be the real geometry, not a stub"
);
}
#[test]
#[cfg(not(debug_assertions))]
fn draw_image_outside_frame_scope_reports_unsupported() {
let mut backend = TuiBackend::new();
let image = crate::primitives::image::Image {
id: WidgetId::new("image"),
source: crate::primitives::image::ImageSource::Bytes(Vec::new()),
intrinsic_size: None,
fit: crate::primitives::image::ImageFit::Contain,
fallback_text: "[image]".to_string(),
};
let rect = QRect::new(0.0, 0.0, 10.0, 10.0);
assert_eq!(
backend.draw_image(rect, &image),
crate::backend::ImagePaintResult::Unsupported,
"mirrors the win/macOS \"no rasteriser available\" report (issue #924)"
);
}
fn underline_fixture_tab_bar() -> TabBar {
TabBar {
id: WidgetId::new("tabs"),
tabs: vec![crate::primitives::tab_bar::TabItem {
label: "app.rs".into(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: true,
}],
right_segments: vec![],
active_accent: Some(crate::Color::rgb(80, 160, 255)),
scroll_offset: 0,
show_tab_close: true,
compact: false,
}
}
fn underline_fixture_editor() -> crate::Editor {
let line = crate::EditorLine {
raw_text: "x".repeat(20),
gutter_text: String::new(),
spans: Vec::new(),
line_idx: 0,
is_current_line: false,
is_fold_header: false,
folded_line_count: 0,
git_diff: None,
diff_status: None,
diagnostics: vec![crate::DiagnosticMark {
start_col: 2,
end_col: 5,
severity: crate::DiagnosticSeverity::Error,
message: String::new(),
}],
spell_errors: vec![crate::SpellMark {
start_col: 8,
end_col: 10,
}],
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::new(),
colorcolumns: Vec::new(),
};
crate::Editor {
id: WidgetId::new("ed"),
rect: QRect::new(0.0, 1.0, 20.0, 4.0),
lines: vec![line],
cursor: None,
extra_cursors: Vec::new(),
selection: None,
extra_selections: Vec::new(),
yank_highlight: None,
scroll_top: 0,
scroll_left: 0,
total_lines: 1,
max_col: 20,
gutter_char_width: 0,
is_active: true,
show_active_bg: false,
has_git_diff: false,
has_breakpoints: false,
diagnostic_gutter: HashMap::new(),
code_action_lines: std::collections::HashSet::new(),
bracket_match_positions: Vec::new(),
active_indent_col: None,
tabstop: 4,
cursorline: false,
lightbulb_glyph: '!',
}
}
struct UnderlineFixtureApp;
impl crate::runner::AppLogic for UnderlineFixtureApp {
type AreaId = ();
fn render(&self, backend: &mut dyn crate::backend::Backend, _area: ()) {
let bar = underline_fixture_tab_bar();
backend.draw_tab_bar(QRect::new(0.0, 0.0, 20.0, 1.0), &bar, None);
let editor = underline_fixture_editor();
backend.draw_editor(editor.rect, &editor);
}
fn handle(
&mut self,
_event: crate::UiEvent,
_backend: &mut dyn crate::backend::Backend,
) -> crate::runner::Reaction {
crate::runner::Reaction::Continue
}
}
#[test]
fn non_windows_caps_leave_underline_colors_intact() {
use ratatui::backend::TestBackend;
let mut backend = TuiBackend::new();
assert_eq!(
backend.underline_color_supported(),
!cfg!(windows),
"default underline-colour support must be off exactly on Windows"
);
backend.set_underline_color_supported(true);
let mut terminal =
ratatui::Terminal::new(TestBackend::new(20, 5)).expect("TestBackend terminal");
super::super::run::paint_frame(&mut terminal, &mut backend, &UnderlineFixtureApp)
.expect("TestBackend render is infallible");
let buf = terminal.backend().buffer();
assert!(
buf.content
.iter()
.any(|c| c.underline_color != ratatui::style::Color::Reset),
"fixture sanity: expected at least one cell with underline_color set \
when underline colour support is on"
);
}
#[test]
fn windows_conpty_caps_strip_every_underline_color() {
use ratatui::backend::TestBackend;
let mut backend = TuiBackend::new();
backend.set_underline_color_supported(false);
let mut terminal =
ratatui::Terminal::new(TestBackend::new(20, 5)).expect("TestBackend terminal");
super::super::run::paint_frame(&mut terminal, &mut backend, &UnderlineFixtureApp)
.expect("TestBackend render is infallible");
let buf = terminal.backend().buffer();
for (i, cell) in buf.content.iter().enumerate() {
assert_eq!(
cell.underline_color,
ratatui::style::Color::Reset,
"cell {i} still carries underline_color {:?} on simulated Windows/ConPTY caps",
cell.underline_color
);
}
}
#[test]
fn windows_conpty_caps_emit_no_sgr_58_bytes() {
use ratatui::backend::CrosstermBackend;
use std::cell::RefCell;
use std::io;
use std::rc::Rc;
#[derive(Clone, Default)]
struct RawSink(Rc<RefCell<Vec<u8>>>);
impl io::Write for RawSink {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.borrow_mut().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
for (supported, should_contain_sgr_58) in [(true, true), (false, false)] {
let sink = RawSink::default();
let crossterm_backend = CrosstermBackend::new(sink.clone());
let mut terminal = ratatui::Terminal::with_options(
crossterm_backend,
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)),
},
)
.expect("CrosstermBackend + Viewport::Fixed never queries a real terminal");
let mut backend = TuiBackend::new();
backend.set_underline_color_supported(supported);
super::super::run::paint_frame(&mut terminal, &mut backend, &UnderlineFixtureApp)
.expect("CrosstermBackend render into an in-memory sink is infallible");
let bytes = sink.0.borrow();
let contains_sgr_58 = bytes.windows(4).any(|w| w == b"\x1b[58");
assert_eq!(
contains_sgr_58, should_contain_sgr_58,
"underline_color_supported={supported}: expected ESC[58 presence to be \
{should_contain_sgr_58}, got {contains_sgr_58}"
);
}
}
}
#[cfg(test)]
mod proptests {
use super::*;
use crate::primitives::terminal::TerminalCellSize;
use proptest::prelude::*;
proptest! {
#[test]
fn decode_sgr_mouse_report_never_panics(
cb in any::<u16>(),
x in any::<u16>(),
y in any::<u16>(),
is_release in any::<bool>(),
) {
let cell_size = TerminalCellSize::new(1.0, 1.0);
let _ = decode_sgr_mouse_report(cb, x, y, is_release, cell_size);
}
#[test]
fn decode_sgr_mouse_report_never_panics_with_zero_cell_size(
cb in any::<u16>(),
x in any::<u16>(),
y in any::<u16>(),
) {
let cell_size = TerminalCellSize::new(0.0, 0.0);
let _ = decode_sgr_mouse_report(cb, x, y, false, cell_size);
}
}
}