| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 1 | //! The terminal itself: raw mode, the alternate screen, and the diff that |
| 2 | //! turns a grid of cells into the fewest escape sequences that will do. |
| 3 | //! |
| 4 | //! Everything above this file paints into a [`Screen`] and never writes a byte, |
| 5 | //! which is what makes the widget layer testable. This is the one place that |
| 6 | //! knows a TTY exists, and it is behind the `terminal` feature so a build for a |
| 7 | //! machine with no terminal crate at all still has the tree and the layout. |
| 8 | |
| 9 | use std::io::{self, Stdout, Write}; |
| 10 | |
| 11 | use crossterm::event::{ |
| 12 | DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind, |
| 13 | }; |
| 14 | use crossterm::terminal::{ |
| 15 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, |
| 16 | }; |
| 17 | use crossterm::{cursor, execute, queue, style}; |
| 18 | |
| 19 | use crate::keys; |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 20 | use crate::screen::{self, attr, Color, Screen, Style}; |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 21 | |
| Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago | 22 | /// How far one notch of the wheel moves a list, in rows. |
| 23 | const WHEEL_ROWS: i32 = 3; |
| 24 | |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 25 | /// One thing that happened, in the vocabulary [`crate::ui::Ui`] takes. |
| 26 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 27 | pub enum Input { |
| 28 | Key(String), |
| 29 | Click(u16, u16), |
| 30 | Wheel(u16, u16, i32), |
| 31 | Resize(u16, u16), |
| 32 | } |
| 33 | |
| 34 | pub struct Term { |
| 35 | out: Stdout, |
| 36 | /// What is on the screen now, so a frame only sends what changed. |
| 37 | last: Screen, |
| 38 | mouse: bool, |
| 39 | } |
| 40 | |
| 41 | impl Term { |
| 42 | /// Take the terminal: raw mode, the alternate screen, mouse reporting, and |
| 43 | /// no cursor until a focused entry asks for one. |
| 44 | pub fn open(mouse: bool) -> io::Result<Self> { |
| 45 | let (w, h) = crossterm::terminal::size().unwrap_or((80, 24)); |
| 46 | let mut out = io::stdout(); |
| 47 | enable_raw_mode()?; |
| 48 | execute!(out, EnterAlternateScreen, cursor::Hide)?; |
| 49 | if mouse { |
| 50 | execute!(out, EnableMouseCapture)?; |
| 51 | } |
| 52 | Ok(Self { |
| 53 | out, |
| 54 | last: Screen::new(w, h), |
| 55 | mouse, |
| 56 | }) |
| 57 | } |
| 58 | |
| 59 | pub fn size(&self) -> (u16, u16) { |
| 60 | crossterm::terminal::size().unwrap_or((80, 24)) |
| 61 | } |
| 62 | |
| 63 | /// Give the terminal back. Called from `tui_close`, and again from a panic |
| 64 | /// hook — leaving a shell in raw mode with no cursor is the one failure a |
| 65 | /// TUI must not have. |
| 66 | pub fn close(&mut self) { |
| 67 | if self.mouse { |
| 68 | let _ = execute!(self.out, DisableMouseCapture); |
| 69 | } |
| 70 | let _ = execute!(self.out, cursor::Show, LeaveAlternateScreen); |
| 71 | let _ = disable_raw_mode(); |
| 72 | } |
| 73 | |
| 74 | /// Wait up to `timeout_ms` for input and answer everything that had |
| 75 | /// arrived. A zero timeout is a poll. |
| 76 | pub fn poll(&mut self, timeout_ms: u64) -> Vec<Input> { |
| 77 | let mut out = Vec::new(); |
| 78 | let deadline = std::time::Duration::from_millis(timeout_ms); |
| 79 | if !crossterm::event::poll(deadline).unwrap_or(false) { |
| 80 | return out; |
| 81 | } |
| 82 | // Drain what is queued rather than one event a call: a held arrow key |
| 83 | // or a paste arrives as a burst, and handling one per frame would make |
| 84 | // the UI lag behind the keyboard. |
| 85 | loop { |
| 86 | match crossterm::event::read() { |
| 87 | Ok(Event::Key(key)) => { |
| 88 | // Windows reports both press and release; a release would |
| 89 | // type every character twice. |
| 90 | if key.kind != KeyEventKind::Release { |
| 91 | if let Some(name) = keys::name(key) { |
| 92 | out.push(Input::Key(name)); |
| 93 | } |
| 94 | } |
| 95 | } |
| 96 | Ok(Event::Resize(w, h)) => out.push(Input::Resize(w, h)), |
| 97 | Ok(Event::Mouse(mouse)) => match mouse.kind { |
| 98 | MouseEventKind::Down(MouseButton::Left) => { |
| 99 | out.push(Input::Click(mouse.column, mouse.row)) |
| 100 | } |
| Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago | 101 | // Three rows a notch. A terminal reports one event per |
| 102 | // detent and a row is the whole of a line here, so a row a |
| 103 | // notch is a backlog that takes forty of them to cross a |
| 104 | // screen — which reads as a list that will not move rather |
| 105 | // than one moving slowly. |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 106 | MouseEventKind::ScrollDown => { |
| Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago | 107 | out.push(Input::Wheel(mouse.column, mouse.row, WHEEL_ROWS)) |
| 108 | } |
| 109 | MouseEventKind::ScrollUp => { |
| 110 | out.push(Input::Wheel(mouse.column, mouse.row, -WHEEL_ROWS)) |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 111 | } |
| 112 | _ => {} |
| 113 | }, |
| 114 | Ok(_) => {} |
| 115 | Err(_) => break, |
| 116 | } |
| 117 | if !crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { |
| 118 | break; |
| 119 | } |
| 120 | } |
| 121 | out |
| 122 | } |
| 123 | |
| 124 | /// Send whatever differs between `screen` and what is already up there. |
| 125 | pub fn flush(&mut self, screen: &Screen, cursor: Option<(u16, u16)>) -> io::Result<()> { |
| 126 | if (self.last.width(), self.last.height()) != (screen.width(), screen.height()) { |
| 127 | self.last = Screen::new(screen.width(), screen.height()); |
| 128 | queue!( |
| 129 | self.out, |
| 130 | crossterm::terminal::Clear(crossterm::terminal::ClearType::All) |
| 131 | )?; |
| 132 | } |
| 133 | let mut style = None; |
| 134 | let mut at: Option<(u16, u16)> = None; |
| 135 | let width = screen.width(); |
| 136 | for (i, cell) in screen.cells().iter().enumerate() { |
| 137 | let (x, y) = ((i as u16) % width, (i as u16) / width); |
| 138 | if self.last.cell(x, y) == Some(cell) { |
| 139 | continue; |
| 140 | } |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 141 | // The right half of a double-width glyph is not written: the |
| 142 | // character to its left was drawn across both cells and left the |
| 143 | // cursor past them. Writing here would put a second copy of |
| 144 | // whatever follows one column over, and every column after it on |
| 145 | // that row would be a column out — which is what a mouse click is |
| 146 | // then aimed at. |
| 147 | if cell.trail { |
| 148 | continue; |
| 149 | } |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 150 | // Only move when the run breaks: a full-width change is one seek |
| 151 | // and a line of text, not a seek a cell. |
| 152 | if at != Some((x, y)) { |
| 153 | queue!(self.out, cursor::MoveTo(x, y))?; |
| 154 | } |
| 155 | if style != Some(cell.style) { |
| 156 | write_style(&mut self.out, cell.style)?; |
| 157 | style = Some(cell.style); |
| 158 | } |
| 159 | queue!(self.out, style::Print(cell.ch))?; |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 160 | at = Some((x + screen::char_cols(cell.ch), y)); |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 161 | } |
| 162 | queue!(self.out, style::ResetColor)?; |
| 163 | match cursor { |
| 164 | Some((x, y)) => queue!(self.out, cursor::MoveTo(x, y), cursor::Show)?, |
| 165 | None => queue!(self.out, cursor::Hide)?, |
| 166 | } |
| 167 | self.out.flush()?; |
| 168 | self.last = screen.clone(); |
| 169 | Ok(()) |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | impl Drop for Term { |
| 174 | fn drop(&mut self) { |
| 175 | self.close(); |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | fn convert(color: Color) -> style::Color { |
| 180 | match color { |
| 181 | Color::Default => style::Color::Reset, |
| 182 | // The terminal downgrades the 256-colour palette itself, which is the |
| 183 | // only place that knows how many colours it really has. |
| 184 | Color::Indexed(i) => style::Color::AnsiValue(i), |
| 185 | Color::Rgb(r, g, b) => style::Color::Rgb { r, g, b }, |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | fn write_style(out: &mut Stdout, style: Style) -> io::Result<()> { |
| 190 | use style::Attribute; |
| 191 | queue!(out, style::SetAttribute(Attribute::Reset))?; |
| 192 | for (bit, on) in [ |
| 193 | (attr::BOLD, Attribute::Bold), |
| 194 | (attr::DIM, Attribute::Dim), |
| 195 | (attr::UNDERLINE, Attribute::Underlined), |
| 196 | (attr::REVERSE, Attribute::Reverse), |
| 197 | (attr::BLINK, Attribute::SlowBlink), |
| 198 | (attr::ITALIC, Attribute::Italic), |
| 199 | ] { |
| 200 | if style.has(bit) { |
| 201 | queue!(out, style::SetAttribute(on))?; |
| 202 | } |
| 203 | } |
| 204 | queue!( |
| 205 | out, |
| 206 | style::SetForegroundColor(convert(style.fg)), |
| 207 | style::SetBackgroundColor(convert(style.bg)) |
| 208 | ) |
| 209 | } |