use crate::{
cell::Cell,
component::Component,
event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, ResizeEvent,
},
layout::{split, Constraint, Direction},
overlay::{OverlayBox, OverlayContent, OverlayHandle, OverlayOptions},
renderer::Renderer,
surface::Surface,
terminal::{CrosstermTerminal, Size, Terminal},
};
use anyhow::Result;
use std::io::{self, stdout, Write};
enum RenderStrategy {
Full,
Incremental,
}
pub struct TUI {
terminal: Box<dyn Terminal>,
children: Vec<Box<dyn Component>>,
focus_index: usize,
overlay_stack: Vec<OverlayHandleWrapper>,
dirty: bool,
prev_surface: Option<Surface>,
renderer: Renderer,
last_width: u16,
last_height: u16,
running: bool,
cursor_marker_pending: bool,
event_handler: Option<Box<dyn FnMut(crate::Event) + Send>>,
layout: Option<(Direction, Vec<Constraint>)>,
}
struct OverlayHandleWrapper {
overlay: Box<dyn OverlayHandle>,
}
impl TUI {
pub fn new(mut terminal: impl Terminal + 'static) -> Self {
let size = terminal.size().unwrap_or(Size {
width: 80,
height: 24,
});
Self {
terminal: Box::new(terminal),
children: Vec::new(),
focus_index: 0,
overlay_stack: Vec::new(),
dirty: true,
prev_surface: None,
renderer: Renderer::new(),
last_width: size.width,
last_height: size.height,
running: false,
cursor_marker_pending: false,
event_handler: None,
layout: None,
}
}
pub fn with_crossterm() -> Result<Self> {
let terminal = CrosstermTerminal::new()?;
Ok(Self::new(terminal))
}
pub fn add_child(&mut self, component: impl Component + 'static) -> usize {
let index = self.children.len();
self.children.push(Box::new(component));
self.request_render();
index
}
pub fn remove_child(&mut self, index: usize) {
if index < self.children.len() {
self.children.remove(index);
if self.focus_index >= self.children.len() && !self.children.is_empty() {
self.focus_index = self.children.len() - 1;
}
self.request_render();
}
}
pub fn set_focus(&mut self, index: usize) {
if index < self.children.len() {
if self.focus_index < self.children.len() {
if let Some(child) = self.children.get_mut(self.focus_index) {
child.unfocus();
}
}
self.focus_index = index;
if let Some(child) = self.children.get_mut(index) {
child.focus();
}
self.request_render();
}
}
pub fn focus_index(&self) -> usize {
self.focus_index
}
pub fn children_count(&self) -> usize {
self.children.len()
}
pub fn add_overlay<T: OverlayContent + 'static>(
&mut self,
content: T,
options: OverlayOptions,
) -> usize {
let id = self.overlay_stack.len();
let mut boxed = OverlayBox::new(content, options);
boxed.set_id(id);
self.overlay_stack.push(OverlayHandleWrapper {
overlay: Box::new(boxed),
});
self.request_render();
id
}
pub fn remove_overlay(&mut self, id: usize) {
if id < self.overlay_stack.len() {
self.overlay_stack.remove(id);
self.request_render();
}
}
pub fn clear_overlays(&mut self) {
self.overlay_stack.clear();
self.request_render();
}
pub fn request_render(&mut self) {
self.dirty = true;
}
pub fn on_event(&mut self, handler: impl FnMut(crate::Event) + Send + 'static) {
self.event_handler = Some(Box::new(handler));
}
pub fn set_layout(&mut self, direction: Direction, constraints: Vec<Constraint>) {
self.layout = Some((direction, constraints));
self.request_render();
}
pub fn clear_layout(&mut self) {
self.layout = None;
self.request_render();
}
pub fn start(&mut self) -> Result<()> {
if self.running {
return Ok(());
}
self.running = true;
crossterm::execute!(stdout(), crossterm::terminal::EnterAlternateScreen)?;
crossterm::execute!(stdout(), crossterm::cursor::Hide)?;
crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture)?;
self.render()?;
while self.running {
if let Some(event) = self.poll_event(std::time::Duration::from_millis(16)) {
self.handle_event(event);
}
if self.dirty {
self.render()?;
}
}
self.cleanup()?;
Ok(())
}
pub fn stop(&mut self) {
self.running = false;
}
pub fn is_running(&self) -> bool {
self.running
}
pub fn request_cursor_position_query(&mut self) -> Result<()> {
self.cursor_marker_pending = true;
self.terminal.query_cursor_position()?;
Ok(())
}
pub fn set_ime_cursor(&mut self, row: u16, col: u16) {
self.renderer.set_cursor_position(Some((row, col)));
}
pub fn clear_ime_cursor(&mut self) {
self.renderer.set_cursor_position(None);
}
fn poll_event(&mut self, timeout: std::time::Duration) -> Option<crate::Event> {
if self.cursor_marker_pending {
if !crossterm::event::poll(std::time::Duration::from_millis(5)).ok()? {
self.cursor_marker_pending = false;
if let Ok(pos) = self.terminal.cursor_pos() {
return Some(crate::Event::CursorPosition(pos.row, pos.col));
}
return None;
}
self.cursor_marker_pending = false;
}
if crossterm::event::poll(timeout).ok()? {
crossterm::event::read().ok().map(Self::convert_event)
} else {
None
}
}
fn convert_event(event: crossterm::event::Event) -> crate::Event {
match event {
crossterm::event::Event::Key(key) => {
let code = match key.code {
crossterm::event::KeyCode::Enter => KeyCode::Enter,
crossterm::event::KeyCode::Esc => KeyCode::Escape,
crossterm::event::KeyCode::Tab => KeyCode::Tab,
crossterm::event::KeyCode::Backspace => KeyCode::Backspace,
crossterm::event::KeyCode::Delete => KeyCode::Delete,
crossterm::event::KeyCode::Up => KeyCode::Up,
crossterm::event::KeyCode::Down => KeyCode::Down,
crossterm::event::KeyCode::Left => KeyCode::Left,
crossterm::event::KeyCode::Right => KeyCode::Right,
crossterm::event::KeyCode::Home => KeyCode::Home,
crossterm::event::KeyCode::End => KeyCode::End,
crossterm::event::KeyCode::PageUp => KeyCode::PageUp,
crossterm::event::KeyCode::PageDown => KeyCode::PageDown,
crossterm::event::KeyCode::Insert => KeyCode::Insert,
crossterm::event::KeyCode::F(n) => KeyCode::F(n),
crossterm::event::KeyCode::Char(c) => KeyCode::Char(c),
_ => KeyCode::Enter, };
let modifiers = KeyModifiers {
shift: key
.modifiers
.contains(crossterm::event::KeyModifiers::SHIFT),
ctrl: key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL),
alt: key.modifiers.contains(crossterm::event::KeyModifiers::ALT),
meta: key.modifiers.contains(crossterm::event::KeyModifiers::META),
};
crate::Event::Key(KeyEvent::with_modifiers(code, modifiers))
}
crossterm::event::Event::Mouse(mouse) => {
let kind = match mouse.kind {
crossterm::event::MouseEventKind::Down(_btn) => MouseEventKind::Press,
crossterm::event::MouseEventKind::Up(_btn) => MouseEventKind::Release,
crossterm::event::MouseEventKind::Drag(_btn) => MouseEventKind::Drag,
crossterm::event::MouseEventKind::Moved => MouseEventKind::Moved,
crossterm::event::MouseEventKind::ScrollDown => MouseEventKind::ScrollDown,
crossterm::event::MouseEventKind::ScrollUp => MouseEventKind::ScrollUp,
crossterm::event::MouseEventKind::ScrollLeft => MouseEventKind::ScrollLeft,
crossterm::event::MouseEventKind::ScrollRight => MouseEventKind::ScrollRight,
};
let button = match mouse.kind {
crossterm::event::MouseEventKind::Down(btn)
| crossterm::event::MouseEventKind::Up(btn)
| crossterm::event::MouseEventKind::Drag(btn) => match btn {
crossterm::event::MouseButton::Left => MouseButton::Left,
crossterm::event::MouseButton::Right => MouseButton::Right,
crossterm::event::MouseButton::Middle => MouseButton::Middle,
},
_ => MouseButton::None,
};
crate::Event::Mouse(MouseEvent {
kind,
button,
row: mouse.row,
col: mouse.column,
})
}
crossterm::event::Event::Resize(cols, rows) => crate::Event::Resize(ResizeEvent {
width: cols,
height: rows,
}),
crossterm::event::Event::FocusGained => crate::Event::FocusGained,
crossterm::event::Event::FocusLost => crate::Event::FocusLost,
_ => crate::Event::None,
}
}
fn handle_event(&mut self, event: crate::Event) {
if let Some(top) = self.overlay_stack.last_mut() {
if top.overlay.is_hidden() {
return;
}
if top.overlay.handle_event(&event) {
self.request_render();
return;
}
}
if let crate::Event::Key(ref key) = event {
if key.code == KeyCode::Escape && !self.overlay_stack.is_empty() {
self.overlay_stack.pop();
self.request_render();
return;
}
}
if self.focus_index < self.children.len()
&& self.children[self.focus_index].handle_event(&event) {
self.request_render();
return;
}
if let crate::Event::Key(key) = &event {
match key.code {
KeyCode::Tab => {
if self.children.len() > 1 {
let next = if key.modifiers.shift {
self.focus_index.saturating_sub(1)
} else {
(self.focus_index + 1) % self.children.len()
};
self.set_focus(next);
}
}
KeyCode::Char('c') if key.modifiers.ctrl => {
self.stop();
}
_ => {}
}
}
if let Some(ref mut handler) = self.event_handler {
handler(event);
}
}
fn render(&mut self) -> Result<()> {
let size = self.terminal.size()?;
let strategy = self.determine_render_strategy(size);
let mut surface = Surface::new(size.width, size.height);
let empty_cell = Cell::new(' ');
surface.fill(empty_cell);
let area = surface.area();
if let Some((ref direction, ref constraints)) = self.layout {
let areas = split(area, *direction, constraints);
for (i, child) in self.children.iter_mut().enumerate() {
if let Some(&child_area) = areas.get(i) {
child.render(&mut surface, child_area);
}
}
} else {
for child in &mut self.children {
child.render(&mut surface, area);
}
}
for overlay in &mut self.overlay_stack {
if !overlay.overlay.is_hidden() {
overlay.overlay.render(&mut surface, area);
}
}
match strategy {
RenderStrategy::Full => {
self.renderer.begin_sync();
self.renderer.clear_screen();
for row in 0..size.height {
for col in 0..size.width {
if let Some(cell) = surface.get(row, col) {
self.renderer.render_cell(row, col, cell);
}
}
}
self.renderer.end_sync()?;
}
RenderStrategy::Incremental => {
self.renderer.begin_sync();
if let (Some(first), Some(last)) = (surface.first_dirty(), surface.last_dirty()) {
self.renderer.render_changed_lines(
&surface,
first,
last.min(size.height - 1),
)?;
}
self.renderer.end_sync()?;
}
}
self.dirty = false;
surface.clear_dirty();
self.prev_surface = Some(surface);
print!("\x1b[?25l");
io::stdout().flush()?;
Ok(())
}
fn determine_render_strategy(&mut self, size: Size) -> RenderStrategy {
if self.prev_surface.is_none() {
self.last_width = size.width;
self.last_height = size.height;
return RenderStrategy::Full;
}
if size.width != self.last_width {
self.last_width = size.width;
self.last_height = size.height;
return RenderStrategy::Full;
}
if let Some(ref prev) = self.prev_surface {
if let (Some(_first), Some(last)) = (prev.first_dirty(), prev.last_dirty()) {
if last > size.height / 4 * 3 {
return RenderStrategy::Full;
}
}
}
RenderStrategy::Incremental
}
fn cleanup(&mut self) -> Result<()> {
crossterm::execute!(stdout(), crossterm::cursor::Show)?;
crossterm::execute!(stdout(), crossterm::event::DisableMouseCapture)?;
crossterm::execute!(stdout(), crossterm::terminal::LeaveAlternateScreen)?;
io::stdout().flush()?;
Ok(())
}
pub fn force_redraw(&mut self) {
if let Some(ref mut surf) = self.prev_surface {
surf.mark_all_dirty();
}
self.dirty = true;
}
pub fn size(&mut self) -> Result<Size> {
self.terminal.size()
}
}
impl Drop for TUI {
fn drop(&mut self) {
if self.running {
let _ = self.cleanup();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cell::Cell;
use crate::terminal::{CursorVisibility, Position, Size, Terminal};
struct MockTerminal {
size: Size,
}
impl MockTerminal {
fn new(w: u16, h: u16) -> Self {
Self {
size: Size::new(w, h),
}
}
}
impl Terminal for MockTerminal {
fn size(&mut self) -> anyhow::Result<Size> {
Ok(self.size)
}
fn cursor_pos(&self) -> anyhow::Result<Position> {
Ok(Position { row: 0, col: 0 })
}
fn set_cursor_pos(&mut self, _pos: Position) -> anyhow::Result<()> {
Ok(())
}
fn set_cursor_visibility(&mut self, _v: CursorVisibility) -> anyhow::Result<()> {
Ok(())
}
fn clear_screen(&mut self) -> anyhow::Result<()> {
Ok(())
}
fn clear_line(&mut self) -> anyhow::Result<()> {
Ok(())
}
fn flush(&mut self) -> anyhow::Result<()> {
Ok(())
}
fn query_cursor_position(&mut self) -> anyhow::Result<()> {
Ok(())
}
fn set_ime_cursor(&mut self, _row: u16, _col: u16) -> anyhow::Result<()> {
Ok(())
}
}
fn make_tui() -> TUI {
TUI::new(MockTerminal::new(80, 24))
}
#[test]
fn tui_creation() {
let tui = make_tui();
assert_eq!(tui.children_count(), 0);
assert_eq!(tui.focus_index(), 0);
assert!(!tui.is_running());
}
#[test]
fn tui_default_size() {
let mut tui = make_tui();
let size = tui.size().unwrap();
assert_eq!(size.width, 80);
assert_eq!(size.height, 24);
}
#[test]
fn tui_drop_does_not_panic() {
let tui = make_tui();
drop(tui);
}
#[test]
fn overlay_add_and_clear() {
let mut tui = make_tui();
struct TestOverlay;
impl crate::component::Component for TestOverlay {
fn request_render(&mut self) {}
fn is_dirty(&self) -> bool { false }
fn clear_dirty(&mut self) {}
fn handle_event(&mut self, _event: &crate::Event) -> bool { false }
fn render(&mut self, _surface: &mut Surface, _area: crate::Rect) {}
fn min_size(&self) -> crate::terminal::Size { crate::terminal::Size::new(1, 1) }
}
impl crate::overlay::OverlayContent for TestOverlay {}
let opts = crate::overlay::OverlayOptions::default();
let id0 = tui.add_overlay(TestOverlay, opts.clone());
assert_eq!(id0, 0);
let id1 = tui.add_overlay(TestOverlay, opts);
assert_eq!(id1, 1);
tui.remove_overlay(0);
tui.clear_overlays();
}
#[test]
fn request_render_sets_dirty() {
let mut tui = make_tui();
tui.dirty = false;
tui.request_render();
assert!(tui.dirty);
}
#[test]
fn render_strategy_first_render_is_full() {
let mut tui = make_tui();
let size = Size::new(80, 24);
let strategy = tui.determine_render_strategy(size);
assert!(matches!(strategy, RenderStrategy::Full));
}
#[test]
fn render_strategy_width_change_is_full() {
let mut tui = make_tui();
let size = Size::new(80, 24);
let _ = tui.determine_render_strategy(size);
tui.prev_surface = Some(Surface::new(80, 24));
tui.last_width = 80;
tui.last_height = 24;
let new_size = Size::new(120, 24);
let strategy = tui.determine_render_strategy(new_size);
assert!(matches!(strategy, RenderStrategy::Full));
}
#[test]
fn render_strategy_same_size_is_incremental() {
let mut tui = make_tui();
let prev = Surface::new(80, 24);
tui.prev_surface = Some(prev);
tui.last_width = 80;
tui.last_height = 24;
let size = Size::new(80, 24);
let strategy = tui.determine_render_strategy(size);
assert!(matches!(strategy, RenderStrategy::Incremental));
}
#[test]
fn render_strategy_dirty_in_lower_quarter_is_incremental() {
let mut tui = make_tui();
let mut prev = Surface::new(80, 24);
prev.set(5, 0, Cell::new('X'));
tui.prev_surface = Some(prev);
tui.last_width = 80;
tui.last_height = 24;
let size = Size::new(80, 24);
let strategy = tui.determine_render_strategy(size);
assert!(matches!(strategy, RenderStrategy::Incremental));
}
#[test]
fn render_strategy_dirty_in_upper_quarter_is_full() {
let mut tui = make_tui();
let mut prev = Surface::new(80, 24);
prev.set(22, 0, Cell::new('X'));
tui.prev_surface = Some(prev);
tui.last_width = 80;
tui.last_height = 24;
let size = Size::new(80, 24);
let strategy = tui.determine_render_strategy(size);
assert!(matches!(strategy, RenderStrategy::Full));
}
#[test]
fn force_redraw_marks_prev_surface_dirty() {
let mut tui = make_tui();
tui.prev_surface = Some(Surface::new(80, 24));
tui.dirty = false;
tui.force_redraw();
assert!(tui.dirty);
if let Some(ref s) = tui.prev_surface {
assert!(s.is_any_dirty());
}
}
}