//! The terminal itself: raw mode, the alternate screen, and the diff that //! turns a grid of cells into the fewest escape sequences that will do. //! //! Everything above this file paints into a [`Screen`] and never writes a byte, //! which is what makes the widget layer testable. This is the one place that //! knows a TTY exists, and it is behind the `terminal` feature so a build for a //! machine with no terminal crate at all still has the tree and the layout. use std::io::{self, Stdout, Write}; use crossterm::event::{ DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind, }; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use crossterm::{cursor, execute, queue, style}; use crate::graphics::{self, Graphics, Placement}; use crate::keys; use crate::screen::{self, attr, Color, Screen, Style}; /// How far one notch of the wheel moves a list, in rows. const WHEEL_ROWS: i32 = 3; /// One thing that happened, in the vocabulary [`crate::ui::Ui`] takes. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Input { Key(String), Click(u16, u16), Wheel(u16, u16, i32), Resize(u16, u16), } pub struct Term { out: Stdout, /// What is on the screen now, so a frame only sends what changed. last: Screen, mouse: bool, /// The pictures the terminal has been given, and where they are. graphics: Graphics, } impl Term { /// Take the terminal: raw mode, the alternate screen, mouse reporting, and /// no cursor until a focused entry asks for one. pub fn open(mouse: bool) -> io::Result { let (w, h) = crossterm::terminal::size().unwrap_or((80, 24)); let mut out = io::stdout(); enable_raw_mode()?; execute!(out, EnterAlternateScreen, cursor::Hide)?; if mouse { execute!(out, EnableMouseCapture)?; } graphics::set_cell(measure_cell()); Ok(Self { out, last: Screen::new(w, h), mouse, graphics: Graphics::default(), }) } pub fn size(&self) -> (u16, u16) { crossterm::terminal::size().unwrap_or((80, 24)) } /// Give the terminal back. Called from `tui_close`, and again from a panic /// hook — leaving a shell in raw mode with no cursor is the one failure a /// TUI must not have. pub fn close(&mut self) { // The pictures first: a placement is the terminal's, not the screen // buffer's, and one left behind outlives the alternate screen it was // made on. let _ = self.graphics.clear(&mut self.out); if self.mouse { let _ = execute!(self.out, DisableMouseCapture); } let _ = execute!(self.out, cursor::Show, LeaveAlternateScreen); let _ = disable_raw_mode(); } /// Wait up to `timeout_ms` for input and answer everything that had /// arrived. A zero timeout is a poll. pub fn poll(&mut self, timeout_ms: u64) -> Vec { let mut out = Vec::new(); let deadline = std::time::Duration::from_millis(timeout_ms); if !crossterm::event::poll(deadline).unwrap_or(false) { return out; } // Drain what is queued rather than one event a call: a held arrow key // or a paste arrives as a burst, and handling one per frame would make // the UI lag behind the keyboard. loop { match crossterm::event::read() { Ok(Event::Key(key)) => { // Windows reports both press and release; a release would // type every character twice. if key.kind != KeyEventKind::Release { if let Some(name) = keys::name(key) { out.push(Input::Key(name)); } } } Ok(Event::Resize(w, h)) => out.push(Input::Resize(w, h)), Ok(Event::Mouse(mouse)) => match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { out.push(Input::Click(mouse.column, mouse.row)) } // Three rows a notch. A terminal reports one event per // detent and a row is the whole of a line here, so a row a // notch is a backlog that takes forty of them to cross a // screen — which reads as a list that will not move rather // than one moving slowly. MouseEventKind::ScrollDown => { out.push(Input::Wheel(mouse.column, mouse.row, WHEEL_ROWS)) } MouseEventKind::ScrollUp => { out.push(Input::Wheel(mouse.column, mouse.row, -WHEEL_ROWS)) } _ => {} }, Ok(_) => {} Err(_) => break, } if !crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { break; } } out } /// Send whatever differs between `screen` and what is already up there. pub fn flush( &mut self, screen: &Screen, cursor: Option<(u16, u16)>, images: &[Placement], ) -> io::Result<()> { if (self.last.width(), self.last.height()) != (screen.width(), screen.height()) { self.last = Screen::new(screen.width(), screen.height()); // A resize is a new cell size as often as not — a font change, a // window dragged to another screen — and every picture's size is // measured in cells. graphics::set_cell(measure_cell()); queue!( self.out, crossterm::terminal::Clear(crossterm::terminal::ClearType::All) )?; } let mut style = None; let mut at: Option<(u16, u16)> = None; let width = screen.width(); for (i, cell) in screen.cells().iter().enumerate() { let (x, y) = ((i as u16) % width, (i as u16) / width); if self.last.cell(x, y) == Some(cell) { continue; } // The right half of a double-width glyph is not written: the // character to its left was drawn across both cells and left the // cursor past them. Writing here would put a second copy of // whatever follows one column over, and every column after it on // that row would be a column out — which is what a mouse click is // then aimed at. if cell.trail { continue; } // Only move when the run breaks: a full-width change is one seek // and a line of text, not a seek a cell. if at != Some((x, y)) { queue!(self.out, cursor::MoveTo(x, y))?; } if style != Some(cell.style) { write_style(&mut self.out, cell.style)?; style = Some(cell.style); } // The whole glyph, first character and rest: a terminal handed an // arrow without the selector that follows it draws the small mono // arrow rather than the emoji. let mut glyph = cell.ch.to_string(); if let Some(tail) = &cell.tail { glyph.push_str(tail); } queue!(self.out, style::Print(&glyph))?; at = Some((x + screen::glyph_cols(&glyph), y)); } queue!(self.out, style::ResetColor)?; // The pictures after the text, and before the cursor is put back: a // placement is drawn where the cursor is, so it moves the cursor, and // whatever the frame decided about the caret has to be the last word. let cell = graphics::cell(); self.graphics.sync(&mut self.out, images, cell)?; match cursor { Some((x, y)) => queue!(self.out, cursor::MoveTo(x, y), cursor::Show)?, None => queue!(self.out, cursor::Hide)?, } self.out.flush()?; self.last = screen.clone(); Ok(()) } } impl Drop for Term { fn drop(&mut self) { self.close(); } } fn convert(color: Color) -> style::Color { match color { Color::Default => style::Color::Reset, // The terminal downgrades the 256-colour palette itself, which is the // only place that knows how many colours it really has. Color::Indexed(i) => style::Color::AnsiValue(i), Color::Rgb(r, g, b) => style::Color::Rgb { r, g, b }, } } fn write_style(out: &mut Stdout, style: Style) -> io::Result<()> { use style::Attribute; queue!(out, style::SetAttribute(Attribute::Reset))?; for (bit, on) in [ (attr::BOLD, Attribute::Bold), (attr::DIM, Attribute::Dim), (attr::UNDERLINE, Attribute::Underlined), (attr::REVERSE, Attribute::Reverse), (attr::BLINK, Attribute::SlowBlink), (attr::ITALIC, Attribute::Italic), ] { if style.has(bit) { queue!(out, style::SetAttribute(on))?; } } queue!( out, style::SetForegroundColor(convert(style.fg)), style::SetBackgroundColor(convert(style.bg)) ) } /// What one cell measures, asked of the terminal. fn measure_cell() -> (u16, u16) { match crossterm::terminal::window_size() { Ok(size) => graphics::cell_pixels(size.columns, size.rows, size.width, size.height), Err(_) => graphics::cell_pixels(0, 0, 0, 0), } }