| 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 | }; |
| Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago | 14 | use crossterm::event::{ |
| 15 | KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, |
| 16 | }; |
| 17 | use crossterm::terminal::supports_keyboard_enhancement; |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 18 | use crossterm::terminal::{ |
| 19 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, |
| 20 | }; |
| 21 | use crossterm::{cursor, execute, queue, style}; |
| 22 | |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 23 | use crate::graphics::{self, Graphics, Placement}; |
| Run the formatter over the tree 3e8c6f0 nandi 13d ago | 24 | use crate::keys; |
| Take a wide glyph off the grid whole, and stop trusting the cursor after one 48212a7 nandi 9d ago | 25 | use crate::screen::{attr, Color, Screen, Style}; |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 26 | |
| Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago | 27 | /// How far one notch of the wheel moves a list, in rows. |
| 28 | const WHEEL_ROWS: i32 = 3; |
| 29 | |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 30 | /// One thing that happened, in the vocabulary [`crate::ui::Ui`] takes. |
| 31 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 32 | pub enum Input { |
| 33 | Key(String), |
| 34 | Click(u16, u16), |
| 35 | Wheel(u16, u16, i32), |
| 36 | Resize(u16, u16), |
| 37 | } |
| 38 | |
| 39 | pub struct Term { |
| 40 | out: Stdout, |
| 41 | /// What is on the screen now, so a frame only sends what changed. |
| 42 | last: Screen, |
| 43 | mouse: bool, |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 44 | /// The pictures the terminal has been given, and where they are. |
| 45 | graphics: Graphics, |
| Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago | 46 | /// Whether the keyboard protocol was pushed, and so has to be popped. |
| 47 | enhanced: bool, |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 48 | } |
| 49 | |
| 50 | impl Term { |
| 51 | /// Take the terminal: raw mode, the alternate screen, mouse reporting, and |
| 52 | /// no cursor until a focused entry asks for one. |
| 53 | pub fn open(mouse: bool) -> io::Result<Self> { |
| 54 | let (w, h) = crossterm::terminal::size().unwrap_or((80, 24)); |
| 55 | let mut out = io::stdout(); |
| 56 | enable_raw_mode()?; |
| 57 | execute!(out, EnterAlternateScreen, cursor::Hide)?; |
| 58 | if mouse { |
| 59 | execute!(out, EnableMouseCapture)?; |
| 60 | } |
| Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago | 61 | // Shift+Enter is a newline in the compose bar and Enter sends the |
| 62 | // line, which a terminal can only tell apart when it is asked to: the |
| 63 | // legacy encoding gives both of them the same byte. This is the kitty |
| 64 | // keyboard protocol's first flag and nothing more — the terminals that |
| 65 | // have it answer the query, and the ones that do not are left as they |
| 66 | // were, with Alt+Enter and Ctrl+J as the way to break a line there. |
| 67 | let enhanced = matches!(supports_keyboard_enhancement(), Ok(true)); |
| 68 | if enhanced { |
| 69 | let _ = execute!( |
| 70 | out, |
| 71 | PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) |
| 72 | ); |
| 73 | } |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 74 | graphics::set_cell(measure_cell()); |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 75 | Ok(Self { |
| 76 | out, |
| 77 | last: Screen::new(w, h), |
| 78 | mouse, |
| Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago | 79 | enhanced, |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 80 | graphics: Graphics::default(), |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 81 | }) |
| 82 | } |
| 83 | |
| 84 | pub fn size(&self) -> (u16, u16) { |
| 85 | crossterm::terminal::size().unwrap_or((80, 24)) |
| 86 | } |
| 87 | |
| 88 | /// Give the terminal back. Called from `tui_close`, and again from a panic |
| 89 | /// hook — leaving a shell in raw mode with no cursor is the one failure a |
| 90 | /// TUI must not have. |
| 91 | pub fn close(&mut self) { |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 92 | // The pictures first: a placement is the terminal's, not the screen |
| 93 | // buffer's, and one left behind outlives the alternate screen it was |
| 94 | // made on. |
| 95 | let _ = self.graphics.clear(&mut self.out); |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 96 | if self.mouse { |
| 97 | let _ = execute!(self.out, DisableMouseCapture); |
| 98 | } |
| Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago | 99 | if self.enhanced { |
| 100 | let _ = execute!(self.out, PopKeyboardEnhancementFlags); |
| 101 | } |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 102 | let _ = execute!(self.out, cursor::Show, LeaveAlternateScreen); |
| 103 | let _ = disable_raw_mode(); |
| 104 | } |
| 105 | |
| 106 | /// Wait up to `timeout_ms` for input and answer everything that had |
| 107 | /// arrived. A zero timeout is a poll. |
| 108 | pub fn poll(&mut self, timeout_ms: u64) -> Vec<Input> { |
| 109 | let mut out = Vec::new(); |
| 110 | let deadline = std::time::Duration::from_millis(timeout_ms); |
| 111 | if !crossterm::event::poll(deadline).unwrap_or(false) { |
| 112 | return out; |
| 113 | } |
| 114 | // Drain what is queued rather than one event a call: a held arrow key |
| 115 | // or a paste arrives as a burst, and handling one per frame would make |
| 116 | // the UI lag behind the keyboard. |
| 117 | loop { |
| 118 | match crossterm::event::read() { |
| 119 | Ok(Event::Key(key)) => { |
| 120 | // Windows reports both press and release; a release would |
| 121 | // type every character twice. |
| 122 | if key.kind != KeyEventKind::Release { |
| 123 | if let Some(name) = keys::name(key) { |
| 124 | out.push(Input::Key(name)); |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | Ok(Event::Resize(w, h)) => out.push(Input::Resize(w, h)), |
| 129 | Ok(Event::Mouse(mouse)) => match mouse.kind { |
| 130 | MouseEventKind::Down(MouseButton::Left) => { |
| 131 | out.push(Input::Click(mouse.column, mouse.row)) |
| 132 | } |
| Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago | 133 | // Three rows a notch. A terminal reports one event per |
| 134 | // detent and a row is the whole of a line here, so a row a |
| 135 | // notch is a backlog that takes forty of them to cross a |
| 136 | // screen — which reads as a list that will not move rather |
| 137 | // than one moving slowly. |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 138 | MouseEventKind::ScrollDown => { |
| Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago | 139 | out.push(Input::Wheel(mouse.column, mouse.row, WHEEL_ROWS)) |
| 140 | } |
| 141 | MouseEventKind::ScrollUp => { |
| 142 | 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 | 143 | } |
| 144 | _ => {} |
| 145 | }, |
| 146 | Ok(_) => {} |
| 147 | Err(_) => break, |
| 148 | } |
| 149 | if !crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { |
| 150 | break; |
| 151 | } |
| 152 | } |
| 153 | out |
| 154 | } |
| 155 | |
| 156 | /// Send whatever differs between `screen` and what is already up there. |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 157 | pub fn flush( |
| 158 | &mut self, |
| 159 | screen: &Screen, |
| 160 | cursor: Option<(u16, u16)>, |
| 161 | images: &[Placement], |
| 162 | ) -> io::Result<()> { |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 163 | if (self.last.width(), self.last.height()) != (screen.width(), screen.height()) { |
| 164 | self.last = Screen::new(screen.width(), screen.height()); |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 165 | // A resize is a new cell size as often as not — a font change, a |
| 166 | // window dragged to another screen — and every picture's size is |
| 167 | // measured in cells. |
| 168 | graphics::set_cell(measure_cell()); |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 169 | queue!( |
| 170 | self.out, |
| 171 | crossterm::terminal::Clear(crossterm::terminal::ClearType::All) |
| 172 | )?; |
| 173 | } |
| 174 | let mut style = None; |
| 175 | let mut at: Option<(u16, u16)> = None; |
| 176 | let width = screen.width(); |
| Take a wide glyph off the grid whole, and stop trusting the cursor after one 48212a7 nandi 9d ago | 177 | let cells = screen.cells(); |
| 178 | for (i, cell) in cells.iter().enumerate() { |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 179 | let (x, y) = ((i as u16) % width, (i as u16) / width); |
| Take a wide glyph off the grid whole, and stop trusting the cursor after one 48212a7 nandi 9d ago | 180 | let wide = !cell.trail && x + 1 < width && cells.get(i + 1).is_some_and(|c| c.trail); |
| 181 | // A wide glyph is its two cells together: when only the right |
| 182 | // half changed, a terminal has lost the whole glyph, so the left |
| 183 | // half is written again as well. |
| 184 | let changed = self.last.cell(x, y) != Some(cell) |
| 185 | || (wide && self.last.cell(x + 1, y) != cells.get(i + 1)); |
| 186 | if !changed { |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 187 | continue; |
| 188 | } |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 189 | // The right half of a double-width glyph is not written: the |
| 190 | // character to its left was drawn across both cells and left the |
| 191 | // cursor past them. Writing here would put a second copy of |
| 192 | // whatever follows one column over, and every column after it on |
| 193 | // that row would be a column out — which is what a mouse click is |
| 194 | // then aimed at. |
| 195 | if cell.trail { |
| 196 | continue; |
| 197 | } |
| Take a wide glyph off the grid whole, and stop trusting the cursor after one 48212a7 nandi 9d ago | 198 | if wide { |
| 199 | // Blank the right half first. Terminals do not agree on how |
| 200 | // wide `✏️` or `↩️` is — a symbol carrying the emoji selector |
| 201 | // is two columns in some and one in others — and one that |
| 202 | // draws it narrow would otherwise leave whatever stood in that |
| 203 | // column standing beside it. |
| 204 | if style != Some(cell.style) { |
| 205 | write_style(&mut self.out, cell.style)?; |
| 206 | style = Some(cell.style); |
| 207 | } |
| 208 | queue!(self.out, cursor::MoveTo(x + 1, y), style::Print(' '))?; |
| 209 | at = None; |
| 210 | } |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 211 | // Only move when the run breaks: a full-width change is one seek |
| 212 | // and a line of text, not a seek a cell. |
| 213 | if at != Some((x, y)) { |
| 214 | queue!(self.out, cursor::MoveTo(x, y))?; |
| 215 | } |
| 216 | if style != Some(cell.style) { |
| 217 | write_style(&mut self.out, cell.style)?; |
| 218 | style = Some(cell.style); |
| 219 | } |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 220 | // The whole glyph, first character and rest: a terminal handed an |
| 221 | // arrow without the selector that follows it draws the small mono |
| 222 | // arrow rather than the emoji. |
| 223 | let mut glyph = cell.ch.to_string(); |
| 224 | if let Some(tail) = &cell.tail { |
| 225 | glyph.push_str(tail); |
| 226 | } |
| 227 | queue!(self.out, style::Print(&glyph))?; |
| Take a wide glyph off the grid whole, and stop trusting the cursor after one 48212a7 nandi 9d ago | 228 | // After a wide glyph, where the cursor went is the terminal's |
| 229 | // opinion rather than ours, so the next cell seeks instead of |
| 230 | // trusting it. |
| 231 | at = if wide { None } else { Some((x + 1, y)) }; |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 232 | } |
| 233 | queue!(self.out, style::ResetColor)?; |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 234 | // The pictures after the text, and before the cursor is put back: a |
| 235 | // placement is drawn where the cursor is, so it moves the cursor, and |
| 236 | // whatever the frame decided about the caret has to be the last word. |
| 237 | let cell = graphics::cell(); |
| 238 | self.graphics.sync(&mut self.out, images, cell)?; |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 239 | match cursor { |
| 240 | Some((x, y)) => queue!(self.out, cursor::MoveTo(x, y), cursor::Show)?, |
| 241 | None => queue!(self.out, cursor::Hide)?, |
| 242 | } |
| 243 | self.out.flush()?; |
| 244 | self.last = screen.clone(); |
| 245 | Ok(()) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | impl Drop for Term { |
| 250 | fn drop(&mut self) { |
| 251 | self.close(); |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | fn convert(color: Color) -> style::Color { |
| 256 | match color { |
| 257 | Color::Default => style::Color::Reset, |
| 258 | // The terminal downgrades the 256-colour palette itself, which is the |
| 259 | // only place that knows how many colours it really has. |
| 260 | Color::Indexed(i) => style::Color::AnsiValue(i), |
| 261 | Color::Rgb(r, g, b) => style::Color::Rgb { r, g, b }, |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | fn write_style(out: &mut Stdout, style: Style) -> io::Result<()> { |
| 266 | use style::Attribute; |
| 267 | queue!(out, style::SetAttribute(Attribute::Reset))?; |
| 268 | for (bit, on) in [ |
| 269 | (attr::BOLD, Attribute::Bold), |
| 270 | (attr::DIM, Attribute::Dim), |
| 271 | (attr::UNDERLINE, Attribute::Underlined), |
| 272 | (attr::REVERSE, Attribute::Reverse), |
| 273 | (attr::BLINK, Attribute::SlowBlink), |
| 274 | (attr::ITALIC, Attribute::Italic), |
| 275 | ] { |
| 276 | if style.has(bit) { |
| 277 | queue!(out, style::SetAttribute(on))?; |
| 278 | } |
| 279 | } |
| 280 | queue!( |
| 281 | out, |
| 282 | style::SetForegroundColor(convert(style.fg)), |
| 283 | style::SetBackgroundColor(convert(style.bg)) |
| 284 | ) |
| 285 | } |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 286 | |
| 287 | /// What one cell measures, asked of the terminal. |
| 288 | fn measure_cell() -> (u16, u16) { |
| 289 | match crossterm::terminal::window_size() { |
| 290 | Ok(size) => graphics::cell_pixels(size.columns, size.rows, size.width, size.height), |
| 291 | Err(_) => graphics::cell_pixels(0, 0, 0, 0), |
| 292 | } |
| 293 | } |