Files
mapsize/rust/src/tui/canvas.rs
T
Andy Dixon 61a444795b 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.
2026-10-08 17:44:53 +01:00

361 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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');
}