rust: tui (canvas renderer, views, modals, crossterm event loop)

Port of internal/tui. Bubble Tea's message loop is replaced by a
crossterm input thread, timers and background commands feeding one
loop; the frame cache, canvas, colour quantisation, LOD treemap,
selection persistence and modal architecture are kept. Frames are
diffed against the previous one per line and per style run.
Exposes tui::bench::BenchModel and big_tree for benchmarks.
This commit is contained in:
Andy Dixon
2026-10-08 17:44:53 +01:00
parent c0283a19a9
commit 61a444795b
22 changed files with 5918 additions and 0 deletions
+360
View File
@@ -0,0 +1,360 @@
//! An in-memory cell grid that every view paints into; serialised once per
//! frame with minimal SGR changes.
use super::color256::{quant16, quant256};
use crate::textutil;
use crate::treemap::Rect;
use std::fmt::Write;
/// 0 for the terminal default, otherwise 0x01RRGGBB.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug)]
pub(crate) struct Color(pub u32);
/// Builds a colour.
pub(crate) const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color(1 << 24 | (r as u32) << 16 | (g as u32) << 8 | b as u32)
}
impl Color {
/// Parses "#rrggbb".
pub fn hex(s: &str) -> Option<Color> {
let s = s.strip_prefix('#').unwrap_or(s);
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
u32::from_str_radix(s, 16).ok().map(|v| Color(1 << 24 | v))
}
pub fn is_default(self) -> bool {
self.0 == 0
}
pub fn r(self) -> u8 {
(self.0 >> 16) as u8
}
pub fn g(self) -> u8 {
(self.0 >> 8) as u8
}
pub fn b(self) -> u8 {
self.0 as u8
}
pub fn comps(self) -> (f64, f64, f64) {
(self.r() as f64 / 255.0, self.g() as f64 / 255.0, self.b() as f64 / 255.0)
}
/// Blends c towards o by t (0..1). Default colours are returned as-is.
pub fn mix(self, o: Color, t: f64) -> Color {
if self.0 == 0 || o.0 == 0 {
return self;
}
let (r1, g1, b1) = self.comps();
let (r2, g2, b2) = o.comps();
let f = |a: f64, b: f64| ((a + (b - a) * t) * 255.0) as u8;
rgb(f(r1, r2), f(g1, g2), f(b1, b2))
}
/// Perceived brightness 0..1.
pub fn luma(self) -> f64 {
let (r, g, b) = self.comps();
0.299 * r + 0.587 * g + 0.114 * b
}
}
/// Text attribute bit set.
pub(crate) type Attr = u8;
pub(crate) const BOLD: Attr = 1;
pub(crate) const FAINT: Attr = 2;
pub(crate) const ITALIC: Attr = 4;
pub(crate) const UNDERLINE: Attr = 8;
pub(crate) const REVERSE: Attr = 16;
/// A cell style.
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub(crate) struct Style {
pub fg: Color,
pub bg: Color,
pub attr: Attr,
}
impl Style {
pub const fn new(fg: Color, bg: Color) -> Style {
Style { fg, bg, attr: 0 }
}
pub const fn with(self, a: Attr) -> Style {
Style { attr: self.attr | a, ..self }
}
}
const INLINE: usize = 15;
const HEAP: u8 = 255;
/// One grid cell. The cluster is stored inline (n bytes) or, if longer,
/// in the canvas side table (n == HEAP, index in the first four bytes).
/// n == 0 marks the continuation of a wide cluster.
#[derive(Clone, Copy)]
struct Cell {
g: [u8; INLINE],
n: u8,
w: u8,
st: Style,
}
impl Cell {
fn space(st: Style) -> Cell {
let mut g = [0; INLINE];
g[0] = b' ';
Cell { g, n: 1, w: 1, st }
}
fn is_cont(&self) -> bool {
self.n == 0
}
fn set_space(&mut self) {
self.g[0] = b' ';
self.n = 1;
self.w = 1;
}
}
/// Box glyph sets.
#[derive(Clone, Copy)]
pub(crate) struct BoxChars {
pub h: &'static str,
pub v: &'static str,
pub tl: &'static str,
pub tr: &'static str,
pub bl: &'static str,
pub br: &'static str,
}
pub(crate) const BOX_LIGHT: BoxChars = BoxChars { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘" };
pub(crate) const BOX_ROUND: BoxChars = BoxChars { h: "─", v: "│", tl: "╭", tr: "╮", bl: "╰", br: "╯" };
pub(crate) const BOX_DOUBLE: BoxChars = BoxChars { h: "═", v: "║", tl: "╔", tr: "╗", bl: "╚", br: "╝" };
pub(crate) const BOX_ASCII: BoxChars = BoxChars { h: "-", v: "|", tl: "+", tr: "+", bl: "+", br: "+" };
pub(crate) const BOX_ASCII_SEL: BoxChars = BoxChars { h: "=", v: "#", tl: "#", tr: "#", bl: "#", br: "#" };
/// Selects how colours are serialised.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Depth {
True,
C256,
C16,
None, // attributes only
}
/// An in-memory cell grid.
pub(crate) struct Canvas {
pub w: i32,
pub h: i32,
c: Vec<Cell>,
big: Vec<String>,
}
impl Canvas {
/// Creates a canvas filled with spaces in style st.
pub fn new(w: i32, h: i32, st: Style) -> Canvas {
let (w, h) = (w.max(0), h.max(0));
Canvas { w, h, c: vec![Cell::space(st); (w * h) as usize], big: Vec::new() }
}
fn inside(&self, x: i32, y: i32) -> bool {
x >= 0 && y >= 0 && x < self.w && y < self.h
}
fn cell_str<'a>(&'a self, c: &'a Cell) -> &'a str {
if c.n == HEAP {
let i = u32::from_le_bytes([c.g[0], c.g[1], c.g[2], c.g[3]]) as usize;
return &self.big[i];
}
// Only whole UTF-8 clusters are ever stored.
std::str::from_utf8(&c.g[..c.n as usize]).unwrap_or("?")
}
fn make(&mut self, s: &str, w: i32, st: Style) -> Cell {
let mut g = [0; INLINE];
let n = if s.len() <= INLINE {
g[..s.len()].copy_from_slice(s.as_bytes());
s.len() as u8
} else {
g[..4].copy_from_slice(&(self.big.len() as u32).to_le_bytes());
self.big.push(s.to_string());
HEAP
};
Cell { g, n, w: w as u8, st }
}
/// Writes one cluster of width w at (x, y), repairing any wide cluster
/// it overlaps so the grid never holds half a character.
fn put(&mut self, x: i32, y: i32, s: &str, w: i32, st: Style) {
if !self.inside(x, y) || x + w > self.w {
return;
}
let row = (y * self.w) as usize;
let (xu, wu, cw) = (x as usize, w as usize, self.w as usize);
for i in xu..xu + wu {
if self.c[row + i].is_cont() && i > 0 && i == xu {
// overwriting the tail of a wide cluster
self.c[row + i - 1].set_space();
}
let c = self.c[row + i];
if !c.is_cont() && c.w > 1 && i + 1 < cw && self.c[row + i + 1].is_cont() {
// overwriting its head
self.c[row + i + 1].set_space();
}
}
self.c[row + xu] = self.make(s, w, st);
for i in 1..wu {
self.c[row + xu + i] = Cell { g: [0; INLINE], n: 0, w: 0, st };
}
}
/// Draws s at (x, y), clipped to max_w cells and the canvas. Control
/// characters are replaced as a second line of defence (callers sanitise
/// untrusted strings first). It returns the number of cells used.
pub fn text(&mut self, x: i32, y: i32, s: &str, max_w: i32, st: Style) -> i32 {
if y < 0 || y >= self.h {
return 0;
}
let limit = (x + max_w).min(self.w);
let mut cx = x;
textutil::clusters(s, |c, w| {
let (mut c, mut w) = (c, w as i32);
let b = c.as_bytes();
if b[0] < 0x20 || b[0] == 0x7f || (b.len() > 1 && b[0] == 0xc2 && b[1] < 0xa0) {
(c, w) = ("?", 1);
}
if cx + w > limit {
// A wide cluster that does not fit leaves a blank cell.
while cx < limit {
if cx >= 0 {
self.put(cx, y, " ", 1, st);
}
cx += 1;
}
return false;
}
if cx >= 0 {
self.put(cx, y, c, w, st);
}
cx += w;
true
});
cx - x
}
/// Draws s centred within [x, x+w).
pub fn text_centered(&mut self, x: i32, y: i32, w: i32, s: &str, st: Style) {
let s = textutil::truncate(s, w.max(0) as usize);
self.text(x + (w - textutil::width(&s) as i32) / 2, y, &s, w, st);
}
/// Draws s right-aligned ending at x+w.
pub fn text_right(&mut self, x: i32, y: i32, w: i32, s: &str, st: Style) {
let s = textutil::truncate(s, w.max(0) as usize);
self.text(x + w - textutil::width(&s) as i32, y, &s, w, st);
}
/// Fills r with the single-cell cluster ch.
pub fn fill(&mut self, r: Rect, ch: &str, st: Style) {
for y in r.y.max(0)..(r.y + r.h).min(self.h) {
for x in r.x.max(0)..(r.x + r.w).min(self.w) {
self.put(x, y, ch, 1, st);
}
}
}
/// Applies f to the style of every cell in r.
pub fn restyle(&mut self, r: Rect, f: impl Fn(Style) -> Style) {
for y in r.y.max(0)..(r.y + r.h).min(self.h) {
for x in r.x.max(0)..(r.x + r.w).min(self.w) {
let c = &mut self.c[(y * self.w + x) as usize];
c.st = f(c.st);
}
}
}
/// Draws a border around r (which must be at least 2×2).
pub fn draw_box(&mut self, r: Rect, b: BoxChars, st: Style) {
if r.w < 2 || r.h < 2 {
return;
}
let (x1, y1) = (r.x + r.w - 1, r.y + r.h - 1);
for x in r.x + 1..x1 {
self.put(x, r.y, b.h, 1, st);
self.put(x, y1, b.h, 1, st);
}
for y in r.y + 1..y1 {
self.put(r.x, y, b.v, 1, st);
self.put(x1, y, b.v, 1, st);
}
self.put(r.x, r.y, b.tl, 1, st);
self.put(x1, r.y, b.tr, 1, st);
self.put(r.x, y1, b.bl, 1, st);
self.put(x1, y1, b.br, 1, st);
}
/// Draws a horizontal rule.
pub fn hline(&mut self, x: i32, y: i32, w: i32, ch: &str, st: Style) {
for i in x..x + w {
self.put(i, y, ch, 1, st);
}
}
/// Serialises the canvas as lines of text with SGR styling. For 256 and
/// 16 colours it quantizes with its own perceptual mapping (see
/// color256.rs); Depth::None keeps attributes only.
pub fn render(&self, depth: Depth) -> String {
let mut b = String::with_capacity((self.w * self.h * 4) as usize);
for y in 0..self.h {
if y > 0 {
b.push('\n');
}
let mut cur = Style::default();
let mut first = true;
for x in 0..self.w {
let c = &self.c[(y * self.w + x) as usize];
if c.is_cont() {
continue;
}
if first || c.st != cur {
sgr(&mut b, c.st, depth);
cur = c.st;
first = false;
}
b.push_str(self.cell_str(c));
}
b.push_str("\x1b[0m");
}
b
}
}
fn sgr(b: &mut String, st: Style, depth: Depth) {
b.push_str("\x1b[0");
for (bit, code) in [(BOLD, ";1"), (FAINT, ";2"), (ITALIC, ";3"), (UNDERLINE, ";4"), (REVERSE, ";7")] {
if st.attr & bit != 0 {
b.push_str(code);
}
}
let col = |b: &mut String, c: Color, bg: bool| {
if c.is_default() {
return;
}
let _ = match depth {
Depth::None => Ok(()),
Depth::C256 => write!(b, ";{};5;{}", if bg { 48 } else { 38 }, quant256(c)),
Depth::C16 => {
let n = quant16(c) as u32;
let mut code = if n >= 8 { 90 + n - 8 } else { 30 + n };
if bg {
code += 10;
}
write!(b, ";{code}")
}
Depth::True => write!(b, ";{};2;{};{};{}", if bg { 48 } else { 38 }, c.r(), c.g(), c.b()),
};
};
col(b, st.fg, false);
col(b, st.bg, true);
b.push('m');
}