//! The terminal runtime: raw mode and alternate screen, an input thread, //! timers and background commands feeding one message loop, and a //! line-diffing frame writer. //! //! The terminal is restored on every exit path: normal quit, errors and //! panics (a panic hook restores it before the default hook prints; the //! scanner aborts the process on worker panics after the hook has run). use super::model::{debug_panic, Button, Cmd, Key, Model, Msg, Options, Profile}; use crate::brand; use crate::cancel::Cancel; use crate::textutil; use crossbeam_channel::{after, select, Receiver, Sender}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; use crossterm::{cursor, execute, terminal}; use std::fmt::Write as _; use std::io::{self, Write}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Once}; use std::time::{Duration, Instant}; /// Whether the terminal is currently in TUI mode (raw, alternate screen). static ACTIVE: AtomicBool = AtomicBool::new(false); /// Set by the SIGTERM handler; the input thread turns it into a quit. static TERMINATED: AtomicBool = AtomicBool::new(false); /// Mouse off (1000/1002/1003/1015/1006), bracketed paste off, cursor on, /// main screen. const RESTORE: &str = "\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h\x1b[?1049l"; fn write_raw(s: &str) { let mut b = s.as_bytes(); while !b.is_empty() { let n = unsafe { libc::write(1, b.as_ptr().cast(), b.len()) }; if n <= 0 { return; } b = &b[n as usize..]; } } /// Leaves TUI mode. Safe to call repeatedly and from the panic hook (it /// writes directly to fd 1, so it cannot block on a stdout lock). fn restore_terminal() { if ACTIVE.swap(false, Ordering::SeqCst) { write_raw(RESTORE); let _ = terminal::disable_raw_mode(); } } fn install_panic_hook() { static ONCE: Once = Once::new(); ONCE.call_once(|| { let prev = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { restore_terminal(); prev(info); })); extern "C" fn on_term(_: libc::c_int) { TERMINATED.store(true, Ordering::SeqCst); } unsafe { libc::signal(libc::SIGTERM, on_term as *const () as libc::sighandler_t) }; }); } fn forced_profile(c: &str) -> Option { match c { "truecolor" | "24bit" => Some(Profile::TrueColor), "256" => Some(Profile::Ansi256), "16" => Some(Profile::Ansi), "none" => Some(Profile::Ascii), _ => None, } } /// Detects the colour profile from the environment (NO_COLOR, COLORTERM, /// TERM). Over SSH COLORTERM is often not forwarded; --color overrides. fn detect_profile() -> Profile { let env = |k: &str| std::env::var(k).unwrap_or_default().to_lowercase(); let (term, ct) = (env("TERM"), env("COLORTERM")); if !env("NO_COLOR").is_empty() || term == "dumb" { return Profile::Ascii; } if ct == "truecolor" || ct == "24bit" || term.ends_with("-direct") { return Profile::TrueColor; } if ["kitty", "alacritty", "wezterm", "ghostty", "foot", "contour", "rio"].iter().any(|t| term.contains(t)) { return Profile::TrueColor; } if term.contains("256color") || !ct.is_empty() { return Profile::Ansi256; } Profile::Ansi } /// Starts the interactive UI. pub fn run(opts: Options) -> Result<(), crate::Error> { let cancel = Cancel::new(); let (s, t) = (opts.start)(cancel.clone())?; let forced = forced_profile(&opts.color); if forced.is_none() && !opts.color.is_empty() && opts.color != "auto" { cancel.cancel(); return Err(format!("invalid --color {:?} (auto, truecolor, 256, 16, none)", opts.color).into()); } let mouse = opts.mouse; let mut m = Model::new(opts, s, t, cancel.clone()); m.profile = forced.unwrap_or_else(detect_profile); install_panic_hook(); if debug_panic() == "scanner" { std::thread::spawn(|| { std::thread::sleep(Duration::from_millis(500)); // Mirrors the scanner's guard: a worker panic aborts after the hook. if std::panic::catch_unwind(|| panic!("MAPSIZE_DEBUG_PANIC=scanner: deliberate panic to test terminal restoration")).is_err() { std::process::abort(); } }); } let res = enter_terminal(mouse).and_then(|_| event_loop(&mut m)); restore_terminal(); cancel.cancel(); m.cancel.cancel(); // a rescan may have replaced the token Ok(res?) } fn enter_terminal(mouse: bool) -> io::Result<()> { terminal::enable_raw_mode()?; ACTIVE.store(true, Ordering::SeqCst); let mut out = io::stdout(); execute!(out, terminal::EnterAlternateScreen, cursor::Hide, event::EnableBracketedPaste, terminal::SetTitle(brand::NAME))?; if mouse { execute!(out, event::EnableMouseCapture)?; } Ok(()) } /// Bubble Tea keystroke name for a key event. fn convert_key(ev: KeyEvent) -> Option { if ev.kind == KeyEventKind::Release { return None; } let m = ev.modifiers; let (ctrl, alt, shift) = (m.contains(KeyModifiers::CONTROL), m.contains(KeyModifiers::ALT), m.contains(KeyModifiers::SHIFT)); let mut prefix = String::new(); if ctrl { prefix += "ctrl+"; } if alt { prefix += "alt+"; } let named = |n: &str| Some(Key { name: format!("{prefix}{}{n}", if shift { "shift+" } else { "" }), text: String::new(), ctrl_alt: ctrl || alt }); match ev.code { KeyCode::Char(c) => { let base = if c == ' ' { "space".to_string() } else if ctrl || alt { c.to_lowercase().collect() } else { c.to_string() }; let text = if ctrl || alt { String::new() } else { c.to_string() }; Some(Key { name: prefix + &base, text, ctrl_alt: ctrl || alt }) } KeyCode::BackTab => Some(Key { name: format!("{prefix}shift+tab"), ..Default::default() }), KeyCode::Enter => named("enter"), KeyCode::Esc => named("esc"), KeyCode::Tab => named("tab"), KeyCode::Backspace => named("backspace"), KeyCode::Delete => named("delete"), KeyCode::Insert => named("insert"), KeyCode::Home => named("home"), KeyCode::End => named("end"), KeyCode::PageUp => named("pgup"), KeyCode::PageDown => named("pgdown"), KeyCode::Up => named("up"), KeyCode::Down => named("down"), KeyCode::Left => named("left"), KeyCode::Right => named("right"), KeyCode::F(n) => named(&format!("f{n}")), _ => None, } } fn convert(ev: Event) -> Option { match ev { Event::Key(k) => convert_key(k).map(Msg::Key), Event::Resize(w, h) => Some(Msg::Size(w as i32, h as i32)), Event::Mouse(me) => { let (x, y) = (me.column as i32, me.row as i32); match me.kind { MouseEventKind::Down(b) => { let button = match b { MouseButton::Left => Button::Left, MouseButton::Right => Button::Right, MouseButton::Middle => Button::Middle, }; Some(Msg::Click { x, y, button }) } MouseEventKind::ScrollUp => Some(Msg::Wheel { x, y, up: true }), MouseEventKind::ScrollDown => Some(Msg::Wheel { x, y, up: false }), MouseEventKind::Moved | MouseEventKind::Drag(_) => Some(Msg::Motion { x, y }), _ => None, } } // Pastes are ignored, as in the Go version. _ => None, } } /// Reads terminal events until stopped. fn input_thread(tx: Sender, stop: Arc) { while !stop.load(Ordering::Relaxed) { if TERMINATED.load(Ordering::SeqCst) { let _ = tx.send(Msg::Quit); return; } match event::poll(Duration::from_millis(100)) { Ok(true) => match event::read() { Ok(ev) => { if let Some(msg) = convert(ev) { if tx.send(msg).is_err() { return; } } } Err(_) => return, }, Ok(false) => {} Err(_) => return, } } } /// Executes commands: background closures on threads, timers in the loop, /// clipboard writes into the output. struct Runtime { tx: Sender, timers: Vec<(Instant, Msg)>, out: String, // pending escape sequences (clipboard) } impl Runtime { fn exec(&mut self, cmd: Cmd) { match cmd { Cmd::None => {} Cmd::Batch(v) => v.into_iter().for_each(|c| self.exec(c)), Cmd::Run(f) => { let tx = self.tx.clone(); std::thread::spawn(move || { let _ = tx.send(f()); }); } Cmd::Tick(d, msg) => self.timers.push((Instant::now() + d, msg)), Cmd::Clipboard(s) => { let _ = write!(self.out, "\x1b]52;c;{}\x07", base64(s.as_bytes())); } } } } fn base64(b: &[u8]) -> String { const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut s = String::with_capacity(b.len().div_ceil(3) * 4); for c in b.chunks(3) { let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; for i in 0..4 { if i <= c.len() { s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char); } else { s.push('='); } } } s } /// Writes frames, rewriting only what changed since the last one: within a /// changed line, the span between the first and last differing style run. #[derive(Default)] struct Screen { prev: Vec, size: (i32, i32), buf: String, } /// Splits a serialised line into its SGR-led runs as (start byte, start /// column). The canvas emits an escape only to start a run (text is /// sanitised), and the trailing reset is a zero-width run. fn runs(l: &str) -> Vec<(usize, usize)> { let starts: Vec = l.match_indices("\x1b[").map(|(i, _)| i).collect(); let mut col = 0; let mut out = Vec::with_capacity(starts.len()); for (k, &s) in starts.iter().enumerate() { let seg = &l[s..starts.get(k + 1).copied().unwrap_or(l.len())]; out.push((s, col)); col += textutil::width(&seg[seg.find('m').map_or(seg.len(), |p| p + 1)..]); } out } /// The changed parts of a line as (column, byte range of new): runs of new /// that do not appear unchanged at the same column in old, grouped into /// contiguous segments. Empty if the lines are equal. fn line_diff(old: &str, new: &str) -> Vec<(usize, std::ops::Range)> { if old == new { return Vec::new(); } let (ro, rn) = (runs(old), runs(new)); let end = |l: &str, r: &[(usize, usize)], k: usize| r.get(k + 1).map_or(l.len(), |x| x.0); let mut changed = vec![true; rn.len()]; let (mut i, mut j) = (0, 0); while i < ro.len() && j < rn.len() { let (co, cn) = (ro[i].1, rn[j].1); if co == cn { changed[j] = old[ro[i].0..end(old, &ro, i)] != new[rn[j].0..end(new, &rn, j)]; (i, j) = (i + 1, j + 1); } else if co < cn { i += 1; } else { j += 1; } } let mut out: Vec<(usize, std::ops::Range)> = Vec::new(); for (k, &c) in changed.iter().enumerate() { if !c { continue; } let r = rn[k].0..end(new, &rn, k); match out.last_mut() { Some(last) if last.1.end == r.start => last.1.end = r.end, _ => out.push((rn[k].1, r)), } } if out.is_empty() { out.push((0, 0..new.len())); // a line without runs (cannot happen) } out } impl Screen { fn draw(&mut self, out: &mut impl Write, frame: &str, w: i32, h: i32, extra: &str) -> io::Result<()> { self.buf.clear(); self.buf.push_str("\x1b[?2026h"); // synchronized output, where supported if (w, h) != self.size { self.buf.push_str("\x1b[0m\x1b[2J"); self.prev.clear(); self.size = (w, h); } let mut n = 0; for (y, l) in frame.split('\n').enumerate() { match self.prev.get_mut(y) { Some(p) => { let d = line_diff(p, l); for (col, r) in &d { let _ = write!(self.buf, "\x1b[{};{}H{}", y + 1, col + 1, &l[r.clone()]); } if !d.is_empty() { p.clear(); p.push_str(l); } } None => { let _ = write!(self.buf, "\x1b[{};1H{l}", y + 1); self.prev.push(l.to_string()); } } n = y + 1; } self.prev.truncate(n); self.buf.push_str(extra); self.buf.push_str("\x1b[?2026l"); out.write_all(self.buf.as_bytes())?; out.flush() } } fn next_msg(rx: &Receiver, timers: &[(Instant, Msg)]) -> Option { match timers.iter().map(|t| t.0).min() { Some(d) => select! { recv(rx) -> msg => msg.ok(), recv(after(d.saturating_duration_since(Instant::now()))) -> _ => None, }, None => rx.recv().ok(), } } fn event_loop(m: &mut Model) -> io::Result<()> { let (tx, rx) = crossbeam_channel::unbounded::(); let stop = Arc::new(AtomicBool::new(false)); { let (tx, stop) = (tx.clone(), stop.clone()); std::thread::spawn(move || input_thread(tx, stop)); } let (w, h) = terminal::size()?; let mut rt = Runtime { tx, timers: Vec::new(), out: String::new() }; rt.exec(m.update(Msg::Size(w as i32, h as i32))); let init = m.init(); rt.exec(init); let mut screen = Screen::default(); let mut stdout = io::stdout(); let mut last = String::new(); let res = loop { if m.quit { break Ok(()); } let (w, h) = (m.w, m.h); let frame = m.view(); if frame != last || !rt.out.is_empty() { if let Err(e) = screen.draw(&mut stdout, frame, w, h, &rt.out) { break Err(e); } last.clear(); last.push_str(frame); rt.out.clear(); } if let Some(msg) = next_msg(&rx, &rt.timers) { let c = m.update(msg); rt.exec(c); } // Fire due timers, then drain whatever else is queued before the next // frame, so bursts (resize storms) cost one render. let now = Instant::now(); let (due, later): (Vec<_>, Vec<_>) = std::mem::take(&mut rt.timers).into_iter().partition(|t| t.0 <= now); rt.timers = later; for (_, msg) in due { let c = m.update(msg); rt.exec(c); } while let Ok(msg) = rx.try_recv() { if m.quit { break; } let c = m.update(msg); rt.exec(c); } }; stop.store(true, Ordering::Relaxed); res } #[cfg(test)] mod tests { use super::*; #[test] fn base64_matches_rfc4648() { for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("foobar", "Zm9vYmFy")] { assert_eq!(base64(i.as_bytes()), o); } } #[test] fn screen_rewrites_only_changed_lines() { let mut s = Screen::default(); let mut out = Vec::new(); s.draw(&mut out, "a\nb\nc", 1, 3, "").unwrap(); out.clear(); s.draw(&mut out, "a\nX\nc", 1, 3, "").unwrap(); assert_eq!(String::from_utf8(out).unwrap(), "\x1b[?2026h\x1b[2;1HX\x1b[?2026l"); // Within a line only the differing style runs are rewritten. let a = "\x1b[0;1mab\x1b[0;7m日x\x1b[0mcd\x1b[0m"; let b = "\x1b[0;1mab\x1b[0;7m日y\x1b[0mcd\x1b[0m"; assert_eq!(line_diff(a, b), vec![(2, 8..18)]); assert_eq!(&b[8..18], "\x1b[0;7m日y"); assert!(line_diff(a, a).is_empty()); let c = "\x1b[0;2mab\x1b[0;7m日x\x1b[0mcz\x1b[0m"; assert_eq!(line_diff(a, c), vec![(0, 0..8), (5, 18..24)]); } #[test] fn keys_use_bubbletea_names() { let k = |code, m| convert_key(KeyEvent::new(code, m)).unwrap().name; assert_eq!(k(KeyCode::Char('c'), KeyModifiers::CONTROL), "ctrl+c"); assert_eq!(k(KeyCode::Char('G'), KeyModifiers::SHIFT), "G"); assert_eq!(k(KeyCode::Char(' '), KeyModifiers::NONE), "space"); assert_eq!(k(KeyCode::BackTab, KeyModifiers::SHIFT), "shift+tab"); assert_eq!(k(KeyCode::PageDown, KeyModifiers::NONE), "pgdown"); assert_eq!(k(KeyCode::Backspace, KeyModifiers::ALT), "alt+backspace"); } }