From da177d4e02e03fac587ba4113b55333bdd43f3db Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Sat, 19 Sep 2026 06:22:05 -0700 Subject: [PATCH] Prototype per-cell border protocol Paint independently colored cell edges and center lines without consuming text positions. Use an experimental APC namespace and a shared physical width scale so thin strokes match on both axes. Support inside and centered placement and row-local text layers. Include a compact protocol and interactive comparison demo. --- misc/scripts/cell-borders.py | 437 ++++++++++++++++++++++++++++ rio-grid/src/cell_borders.rs | 285 ++++++++++++++++++ rio-grid/src/lib.rs | 11 + rio-vt/src/ansi/border_protocol.rs | 179 ++++++++++++ rio-vt/src/ansi/mod.rs | 1 + rio-vt/src/crosswords/grid/mod.rs | 51 ++++ rio-vt/src/crosswords/grid/tests.rs | 3 + rio-vt/src/crosswords/mod.rs | 177 +++++++++++ rio-vt/src/crosswords/square.rs | 3 +- rio-vt/src/performer/handler.rs | 12 +- specs/cell-border-protocol.md | 128 ++++++++ 11 files changed, 1285 insertions(+), 2 deletions(-) create mode 100644 misc/scripts/cell-borders.py create mode 100644 rio-grid/src/cell_borders.rs create mode 100644 rio-vt/src/ansi/border_protocol.rs create mode 100644 specs/cell-border-protocol.md diff --git a/misc/scripts/cell-borders.py b/misc/scripts/cell-borders.py new file mode 100644 index 0000000000..ccc59263f8 --- /dev/null +++ b/misc/scripts/cell-borders.py @@ -0,0 +1,437 @@ +#!/usr/bin/env python3 +"""Rio cell-border prototype: python3 misc/scripts/cell-borders.py [--emit].""" + +import argparse +import shutil +import sys +import termios +import tty + +ESC = "\x1b" +BG, PANEL, TEXT, MUTED = "10151d", "212b36", "d3dae3", "8e9cac" +TEAL, RED, GOLD, BLUE = "80c5b7", "d67b88", "e4be78", "8bade2" +TOP, RIGHT, BOTTOM, LEFT, HORIZONTAL, VERTICAL = 1, 2, 4, 8, 16, 32 +H_LEFT, H_RIGHT, V_TOP, V_BOTTOM = 64, 128, 256, 512 + + +def rgb(color): + return ";".join(str(int(color[i:i + 2], 16)) for i in (0, 2, 4)) + + +def move(row, col): + return f"{ESC}[{row};{col}H" + + +def border(row, col, mask, width=16, color=TEAL, placement=0, layer=1, + rows=1, cols=1): + """Paint selected strokes on each cell in a cursor-relative rectangle.""" + body = f"rio-border;1;set;{mask};{width};{color};{placement};{layer};{rows};{cols}" + return move(row, col) + f"{ESC}_{body}{ESC}\\" + + +def outline(row, col, rows, cols, color=TEAL, width=16, placement=0, layer=1): + """Four strips make an outer rectangle, without bordering its interior cells.""" + return "".join(( + border(row, col, TOP, width, color, placement, layer, cols=cols), + border(row + rows - 1, col, BOTTOM, width, color, placement, layer, cols=cols), + border(row, col, LEFT, width, color, placement, layer, rows=rows), + border(row, col + cols - 1, RIGHT, width, color, placement, layer, rows=rows), + )) + + + +def half_outline(row, col, text_width, color=TEAL, width=16): + """Outline a three-row block shape at the centers of its perimeter cells.""" + right = col + text_width + 1 + return "".join(( + border(row, col, H_RIGHT | V_BOTTOM, width, color), + border(row, right, H_LEFT | V_BOTTOM, width, color), + border(row + 2, col, H_RIGHT | V_TOP, width, color), + border(row + 2, right, H_LEFT | V_TOP, width, color), + border(row, col + 1, HORIZONTAL, width, color, cols=text_width), + border(row + 2, col + 1, HORIZONTAL, width, color, cols=text_width), + border(row + 1, col, VERTICAL, width, color), + border(row + 1, right, VERTICAL, width, color), + )) + + +def scene(enabled=True): + output = [f"{ESC}[0m{ESC}[48;2;{rgb(BG)}m{ESC}[2J{ESC}[H"] + borders = [] + + def text(row, col, value, fg=TEXT, bg=BG): + output.append(move(row, col) + f"{ESC}[38;2;{rgb(fg)};48;2;{rgb(bg)}m" + value) + + def fill(row, col, height, width, color): + for y in range(row, row + height): + text(y, col, " " * width, bg=color) + + text(1, 3, "RIO / CELL BORDERS", TEAL) + text(1, 55, "cell edges / centerline segments", MUTED) + text(3, 3, "Thin lines, full cells. Text keeps its own foreground.", MUTED) + fill(5, 3, 19, 74, PANEL) + borders.append(outline(5, 3, 19, 74, BLUE)) + text(6, 5, "Abandon this change?", TEAL, PANEL) + text(8, 5, "qpvuntsm", "c49be0", PANEL) + text(8, 16, "Keep focus when closing a preview", bg=PANEL) + text(10, 5, "Changes to abandon (3 files)", bg=PANEL) + for row, status, path, delta, color in ( + (12, "D", "README.md", "+0 -3", RED), + (13, "M", "crates/jk-tui/src/chrome.rs", "+1 -1", GOLD), + (14, "A", "docs/tui-design.md", "+5 -0", TEAL), + ): + text(row, 5, status, color, PANEL) + text(row, 8, path, bg=PANEL) + text(row, 64, delta, color, PANEL) + text(16, 5, "Recover with Undo in the action menu.", MUTED, PANEL) + for col, width, label, color, background in ( + (5, 18, "View diff", MUTED, "2b3745"), + (26, 18, "Cancel", TEAL, "263e41"), + (47, 28, "Abandon change", RED, "422e38"), + ): + fill(18, col, 3, width, background) + text(19, col + (width - len(label)) // 2, label, color, background) + borders.append(outline(18, col, 3, width, color)) + text(22, 5, "Tab / arrows choose Enter activate Esc cancel", MUTED, PANEL) + + text(25, 3, "ACTIVE FIELD", TEAL) + text(25, 41, "INACTIVE FIELD", MUTED) + fill(26, 3, 3, 36, PANEL) + fill(26, 41, 3, 36, PANEL) + text(27, 5, "Search changes...", TEXT, PANEL) + text(27, 43, "Filter by author", MUTED, PANEL) + borders.extend((outline(26, 3, 3, 36, TEAL), outline(26, 41, 3, 36, MUTED))) + + text(30, 3, "ACTIVE / UNDERLINE ONLY", TEAL) + text(30, 41, "INACTIVE / UNDERLINE ONLY", MUTED) + fill(31, 3, 3, 36, PANEL) + fill(31, 41, 3, 36, PANEL) + text(32, 5, "Search changes...", TEXT, PANEL) + text(32, 43, "Filter by author", MUTED, PANEL) + borders.extend((border(33, 3, BOTTOM, color=TEAL, cols=36), + border(33, 41, BOTTOM, color=MUTED, cols=36))) + + text(35, 3, "QUADRANTS", MUTED) + text(35, 20, "WIDTH 4 / 16 / 32", MUTED) + text(35, 46, "INSIDE / CENTERED", MUTED) + text(35, 68, "BELOW / ABOVE", MUTED) + # A colored center cross adds a third color to each two-color block cell. + text(37, 4, "▚▞▚▞", BLUE, "34453f") + text(38, 4, "▞▚▞▚", BLUE, "34453f") + borders.append(border(37, 4, HORIZONTAL | VERTICAL, color=GOLD, rows=2, cols=4)) + borders.append(outline(37, 4, 2, 4, RED)) + for col, width in ((21, 4), (27, 16), (33, 32)): + fill(37, col, 2, 4, PANEL) + borders.append(outline(37, col, 2, 4, TEAL, width)) + for col, placement in ((47, 0), (57, 1)): + fill(37, col, 2, 6, "344151") + borders.append(outline(37, col, 2, 6, GOLD, 32, placement)) + for col, layer in ((69, 0), (76, 1)): + text(37, col, "████", BLUE, PANEL) + borders.append(border(37, col, HORIZONTAL, 32, RED, layer=layer, cols=4)) + text(40, 3, "b: borders h: blocks j: joins p: placement c: colors q: quit " + + ("BORDERS ON" if enabled else "BORDERS OFF"), MUTED) + # Painting is deliberately last: subsequent text writes clear cell borders. + if enabled: + output.extend(borders) + output.append(f"{ESC}[0m" + move(41, 1)) + return "".join(output) + + + +def placement_scene(enabled=True, stroke_width=32): + """Compare placement against fixed cell boundaries at several button sizes.""" + surround, button_bg = "303a48", "305a73" + output = [f"{ESC}[0m{ESC}[48;2;{rgb(BG)}m{ESC}[2J{ESC}[H"] + borders = [] + + def text(row, col, value, fg=TEXT, bg=BG): + output.append(move(row, col) + f"{ESC}[38;2;{rgb(fg)};48;2;{rgb(bg)}m" + value) + + def fill(row, col, height, width, color): + for y in range(row, row + height): + text(y, col, " " * width, bg=color) + + text(1, 3, "CELL EDGE / PLACEMENT", TEAL) + text(3, 3, "Same button sizes and stroke widths on both sides.", MUTED) + for panel_col, placement, title, description in ( + (3, 0, "INSIDE", "Stroke stays on the blue fill."), + (41, 1, "CENTERED", "Stroke straddles blue and slate."), + ): + text(5, panel_col, title, GOLD) + text(6, panel_col, description, MUTED) + for row, height, width, label, caption in ( + (10, 1, 2, "OK", "1 row / 2 columns: no padding"), + (15, 3, 16, "Save", "3 rows / 16 columns: padded"), + (22, 5, 28, "Apply changes", "5 rows / 28 columns: spacious"), + ): + text(row - 2, panel_col, caption, MUTED) + fill(row - 1, panel_col, height + 2, 36, surround) + col = panel_col + (36 - width) // 2 + fill(row, col, height, width, button_bg) + text(row + height // 2, col + (width - len(label)) // 2, + label, TEXT, button_bg) + # Adjacent text and the background transition expose outward spill. + text(row - 1, col, "above", MUTED, surround) + text(row + height, col, "below", MUTED, surround) + text(row + height // 2, col - 2, "L>", MUTED, surround) + text(row + height // 2, col + width, ", + cols: usize, + y: u16, + extras: &ExtrasMap, + grid: &mut GridRenderer, + cell_w: u32, + cell_h: u32, + layer: u8, + preedit: Option<&PreeditRow<'_>>, + output: &mut Vec, +) { + if !row.has_extras { + return; + } + for x in 0..cols { + let square = row[Column(x)]; + if preedit.is_some_and(|p| p.suppresses(square, x)) { + continue; + } + let Some(borders) = cell_borders(square, extras) else { + continue; + }; + for (segment, stroke) in borders.iter().enumerate() { + let Some(stroke) = stroke.filter(|stroke| stroke.layer == layer) else { + continue; + }; + let rect = geometry(segment, stroke, cell_w, cell_h); + let Some(slot) = ensure_rectangle(grid, rect.width, rect.height) else { + continue; + }; + output.push(CellText { + glyph_pos: [slot.x as u32, slot.y as u32], + glyph_size: [slot.w as u32, slot.h as u32], + bearings: [rect.x as i16, (cell_h as i32 - rect.y) as i16], + grid_pos: [x as u16, y], + color: [stroke.color[0], stroke.color[1], stroke.color[2], 255], + atlas: CellText::ATLAS_GRAYSCALE, + // Preserve the explicitly requested color, including under the cursor. + bools: CellText::BOOL_NO_MIN_CONTRAST | CellText::BOOL_IS_CURSOR_GLYPH, + page: slot.page, + _pad: 0, + }); + } + } +} + +fn cell_borders(square: Square, extras: &ExtrasMap) -> Option<&CellBorders> { + square + .extras_id_checked() + .and_then(|id| extras.get(&id)) + .and_then(|extra| extra.borders.as_deref()) +} + +#[derive(Debug, PartialEq, Eq)] +struct Rectangle { + x: i32, + y: i32, + width: u32, + height: u32, +} + +fn geometry(segment: usize, stroke: BorderStroke, cell_w: u32, cell_h: u32) -> Rectangle { + let thickness = ((cell_w as f32 * stroke.width as f32 / 256.0).round() as u32).max(1); + let centered = stroke.placement == 1; + let half = thickness as i32 / 2; + let edge_inset = if centered { half } else { thickness as i32 }; + let mut rect = Rectangle { + x: 0, + y: 0, + width: cell_w, + height: thickness, + }; + match segment { + 0 => rect.y = if centered { -half } else { 0 }, + 1 => { + rect.x = cell_w as i32 - edge_inset; + rect.width = thickness; + rect.height = cell_h; + } + 2 => rect.y = cell_h as i32 - edge_inset, + 3 => { + rect.x = if centered { -half } else { 0 }; + rect.width = thickness; + rect.height = cell_h; + } + 4 => rect.y = cell_h as i32 / 2 - half, + 5 => { + rect.x = cell_w as i32 / 2 - half; + rect.width = thickness; + rect.height = cell_h; + } + // Each arm includes the whole center square. This makes adjoining + // perpendicular arms meet without a missing outside-corner pixel. + 6 => { + rect.y = cell_h as i32 / 2 - half; + rect.width = (cell_w as i32 / 2 - half) as u32 + thickness; + } + 7 => { + rect.x = cell_w as i32 / 2 - half; + rect.y = cell_h as i32 / 2 - half; + rect.width = cell_w - rect.x as u32; + } + 8 => { + rect.x = cell_w as i32 / 2 - half; + rect.width = thickness; + rect.height = (cell_h as i32 / 2 - half) as u32 + thickness; + } + 9 => { + rect.x = cell_w as i32 / 2 - half; + rect.y = cell_h as i32 / 2 - half; + rect.width = thickness; + rect.height = cell_h - rect.y as u32; + } + _ => unreachable!("ten border segments"), + } + rect +} + +fn ensure_rectangle( + grid: &mut GridRenderer, + width: u32, + height: u32, +) -> Option { + let width = u16::try_from(width).ok()?; + let height = u16::try_from(height).ok()?; + let key = GlyphKey { + font_id: BORDER_FONT_ID, + glyph_id: u32::from(width) | (u32::from(height) << 16), + size_bucket: 0, + }; + if let Some(slot) = grid.lookup_glyph(key) { + return Some(slot); + } + let bytes = vec![255; usize::from(width) * usize::from(height)]; + grid.insert_glyph( + key, + RasterizedGlyph { + width, + height, + bearing_x: 0, + bearing_y: 0, + bytes: &bytes, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stroke(width: u8, placement: u8) -> BorderStroke { + BorderStroke { + color: [31, 127, 255], + width, + placement, + layer: 0, + } + } + + #[test] + fn background_payload_cannot_alias_border_extras() { + let mut square = Square::default(); + square.set_bg_rgb(255, 127, 63); + let alias = square + .extras_id() + .expect("RGB payload overlaps extras bits"); + let mut extras = ExtrasMap::default(); + extras.insert( + alias, + super::super::Extras { + borders: Some(std::sync::Arc::new([Some(stroke(16, 0)); 10])), + ..Default::default() + }, + ); + assert!(cell_borders(square, &extras).is_none()); + let mut text = Square::from_char(' '); + text.set_extras_id(Some(alias)); + assert!(cell_borders(text, &extras).is_some()); + } + + #[test] + fn both_axes_have_equal_physical_thickness() { + let horizontal = geometry(0, stroke(32, 0), 16, 36); + let vertical = geometry(3, stroke(32, 0), 16, 36); + assert_eq!(horizontal.height, 2); + assert_eq!(vertical.width, horizontal.height); + assert_eq!(geometry(0, stroke(1, 0), 8, 18).height, 1); + } + + #[test] + fn inside_edges_stay_inside_cell() { + for segment in 0..10 { + let rect = geometry(segment, stroke(32, 0), 16, 36); + assert!(rect.x >= 0 && rect.y >= 0); + assert!(rect.x + rect.width as i32 <= 16); + assert!(rect.y + rect.height as i32 <= 36); + } + } + + fn contains(rect: &Rectangle, x: i32, y: i32) -> bool { + x >= rect.x + && x < rect.x + rect.width as i32 + && y >= rect.y + && y < rect.y + rect.height as i32 + } + + #[test] + fn opposite_arms_cover_full_center_lines_at_odd_sizes() { + for (w, h, width) in [(16, 36, 32), (17, 35, 32), (23, 47, 32), (9, 19, 1)] { + for (first, second, full) in [(6, 7, 4), (8, 9, 5)] { + let first = geometry(first, stroke(width, 0), w, h); + let second = geometry(second, stroke(width, 0), w, h); + let full = geometry(full, stroke(width, 0), w, h); + for y in -1..=h as i32 { + for x in -1..=w as i32 { + assert_eq!( + contains(&first, x, y) || contains(&second, x, y), + contains(&full, x, y) + ); + } + } + } + } + } + + #[test] + fn perpendicular_arms_make_square_corners_without_spurs() { + for (w, h) in [(16, 36), (17, 35), (23, 47)] { + let horizontal = geometry(4, stroke(32, 0), w, h); + let vertical = geometry(5, stroke(32, 0), w, h); + for (horizontal_arm, vertical_arm) in [(6, 8), (6, 9), (7, 8), (7, 9)] { + let a = geometry(horizontal_arm, stroke(32, 0), w, h); + let b = geometry(vertical_arm, stroke(32, 0), w, h); + for y in 0..h as i32 { + for x in 0..w as i32 { + let on_horizontal = contains(&horizontal, x, y) + && if horizontal_arm == 6 { + x < vertical.x + vertical.width as i32 + } else { + x >= vertical.x + }; + let on_vertical = contains(&vertical, x, y) + && if vertical_arm == 8 { + y < horizontal.y + horizontal.height as i32 + } else { + y >= horizontal.y + }; + assert_eq!( + contains(&a, x, y) || contains(&b, x, y), + on_horizontal || on_vertical + ); + if contains(&horizontal, x, y) && contains(&vertical, x, y) { + assert!(contains(&a, x, y) && contains(&b, x, y)); + } + } + } + } + } + } + + #[test] + fn centered_edges_overlap_and_cross_bisects_cell() { + assert_eq!(geometry(0, stroke(32, 1), 16, 36).y, -1); + assert_eq!(geometry(1, stroke(32, 1), 16, 36).x, 15); + assert_eq!(geometry(2, stroke(32, 1), 16, 36).y, 35); + assert_eq!(geometry(3, stroke(32, 1), 16, 36).x, -1); + assert_eq!(geometry(4, stroke(32, 0), 16, 36).y, 17); + assert_eq!(geometry(5, stroke(32, 0), 16, 36).x, 7); + } +} diff --git a/rio-grid/src/lib.rs b/rio-grid/src/lib.rs index 713b7f0326..1a707b941b 100644 --- a/rio-grid/src/lib.rs +++ b/rio-grid/src/lib.rs @@ -43,6 +43,7 @@ use smallvec::SmallVec; /// cells. The renderer reads via `extras.get(&id)`. pub type ExtrasMap = FxHashMap; +mod cell_borders; pub mod preedit; use preedit::{PreeditCaret, PreeditCell, PreeditLine}; @@ -1899,6 +1900,11 @@ pub fn build_row_fg( // glyphs at the same codepoint without interfering. let glyph_registry = font_library.glyph_registry_for(route_id); + cell_borders::emit( + row, cols, y, extras_table, grid, cell_w_u32, cell_h_u32, 0, preedit, + fg_scratch, + ); + // Phase 1: underline pass. Emit before glyphs so grayscale quads // draw under the characters. emit_underlines( @@ -2445,6 +2451,11 @@ pub fn build_row_fg( x = end; } + cell_borders::emit( + row, cols, y, extras_table, grid, cell_w_u32, cell_h_u32, 1, preedit, + fg_scratch, + ); + // Phase 2.5: composition pass. Every grapheme cluster shapes as // its own run — never ligating with the surrounding terminal text // — with the foreground forced to the terminal background and diff --git a/rio-vt/src/ansi/border_protocol.rs b/rio-vt/src/ansi/border_protocol.rs new file mode 100644 index 0000000000..adffa794d5 --- /dev/null +++ b/rio-vt/src/ansi/border_protocol.rs @@ -0,0 +1,179 @@ +//! Experimental cell borders carried in a private APC namespace. +//! +//! Commands modify existing cells, independently of text and SGR state. + +pub const PREFIX: &[u8] = b"rio-border;"; +pub const SUPPORT_REPLY: &str = "\x1b_rio-border;1;ok\x1b\\"; + +/// A solid stroke. Width is in 1/256 of the cell width on both axes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BorderStroke { + pub color: [u8; 3], + pub width: u8, + /// 0: inside the cell; 1: centered on the edge. + pub placement: u8, + /// 0: below text; 1: above text (within the owning row). + pub layer: u8, +} + +/// Top, right, bottom, left, horizontal center, vertical center, +/// then left/right horizontal arms and top/bottom vertical arms. +pub type CellBorders = [Option; 10]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BorderCommand { + Query, + Paint { + mask: u16, + stroke: Option, + rows: u16, + cols: u16, + }, +} + +/// Parse a complete body; unknown versions and malformed bodies are ignored atomically. +pub fn parse(data: &[u8]) -> Option { + if data.len() > 256 { + return None; + } + let body = std::str::from_utf8(data).ok()?; + let mut fields = body.split(';'); + if fields.next()? != "rio-border" || fields.next()? != "1" { + return None; + } + let verb = fields.next()?; + if verb == "q" { + return fields.next().is_none().then_some(BorderCommand::Query); + } + if !matches!(verb, "set" | "clear") { + return None; + } + let mask = decimal(fields.next()?, 1023)?; + if mask == 0 { + return None; + } + let stroke = if verb == "set" { + let width = decimal(fields.next()?, 32)? as u8; + if width == 0 { + return None; + } + let rgb = fields.next()?; + if rgb.len() != 6 || !rgb.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let color = [ + u8::from_str_radix(&rgb[0..2], 16).ok()?, + u8::from_str_radix(&rgb[2..4], 16).ok()?, + u8::from_str_radix(&rgb[4..6], 16).ok()?, + ]; + Some(BorderStroke { + color, + width, + placement: decimal(fields.next()?, 1)? as u8, + layer: decimal(fields.next()?, 1)? as u8, + }) + } else { + None + }; + let rows = decimal(fields.next()?, u16::MAX)?; + let cols = decimal(fields.next()?, u16::MAX)?; + if rows == 0 || cols == 0 || fields.next().is_some() { + return None; + } + Some(BorderCommand::Paint { + mask, + stroke, + rows, + cols, + }) +} + +fn decimal(field: &str, max: u16) -> Option { + if field.is_empty() || !field.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + field.parse::().ok().filter(|&value| value <= max) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_query_and_independent_strokes() { + assert_eq!(parse(b"rio-border;1;q"), Some(BorderCommand::Query)); + assert_eq!( + parse(b"rio-border;1;set;33;16;12AbEF;1;0;2;3"), + Some(BorderCommand::Paint { + mask: 33, + stroke: Some(BorderStroke { + color: [0x12, 0xab, 0xef], + width: 16, + placement: 1, + layer: 0, + }), + rows: 2, + cols: 3 + }) + ); + assert_eq!( + parse(b"rio-border;1;clear;63;1;65535"), + Some(BorderCommand::Paint { + mask: 63, + stroke: None, + rows: 1, + cols: 65535 + }) + ); + } + + #[test] + fn accepts_half_centerline_corner_masks() { + assert_eq!( + parse(b"rio-border;1;set;640;16;abcdef;0;1;1;1"), + Some(BorderCommand::Paint { + mask: 640, + stroke: Some(BorderStroke { + color: [0xab, 0xcd, 0xef], + width: 16, + placement: 0, + layer: 1, + }), + rows: 1, + cols: 1, + }) + ); + assert_eq!( + parse(b"rio-border;1;clear;1023;1;1"), + Some(BorderCommand::Paint { + mask: 1023, + stroke: None, + rows: 1, + cols: 1 + }) + ); + } + + #[test] + fn rejects_malformed_commands_without_partial_effects() { + for body in [ + "rio-border;2;q", + "rio-border;1;q;extra", + "rio-border;1;clear;0;1;1", + "rio-border;1;clear;1024;1;1", + "rio-border;1;clear;1;0;1", + "rio-border;1;clear;1;1;65536", + "rio-border;1;clear;+1;1;1", + "rio-border;1;set;1;0;ffffff;0;0;1;1", + "rio-border;1;set;1;33;ffffff;0;0;1;1", + "rio-border;1;set;1;16;zzzzzz;0;0;1;1", + "rio-border;1;set;1;16;ffffff;2;0;1;1", + "rio-border;1;set;1;16;ffffff;0;2;1;1", + "rio-border;1;set;1;16;ffffff;0;0;1;1;extra", + ] { + assert_eq!(parse(body.as_bytes()), None, "{body}"); + } + assert_eq!(parse(&[b'0'; 257]), None); + assert_eq!(parse(b"rio-border;1;\xff"), None); + } +} diff --git a/rio-vt/src/ansi/mod.rs b/rio-vt/src/ansi/mod.rs index edce47f663..f49106fc9a 100644 --- a/rio-vt/src/ansi/mod.rs +++ b/rio-vt/src/ansi/mod.rs @@ -1,3 +1,4 @@ +pub mod border_protocol; use bitflags::bitflags; use serde::{Deserialize, Serialize}; diff --git a/rio-vt/src/crosswords/grid/mod.rs b/rio-vt/src/crosswords/grid/mod.rs index b7d510c763..bda4f48482 100644 --- a/rio-vt/src/crosswords/grid/mod.rs +++ b/rio-vt/src/crosswords/grid/mod.rs @@ -711,6 +711,57 @@ impl Grid { } } + /// Modify decoration metadata without overwriting text or an inline background. + pub fn paint_cell_border( + &mut self, + pos: Pos, + mask: u16, + stroke: Option, + ) { + use std::sync::Arc; + let mut cell = self[pos]; + let mut extras = cell + .extras_id_checked() + .and_then(|id| self.extras_table.get(id).cloned()) + .unwrap_or_default(); + let mut borders = extras.borders.as_deref().copied().unwrap_or([None; 10]); + for (part, border) in borders.iter_mut().enumerate() { + if mask & (1 << part) != 0 { + *border = stroke; + } + } + let borders = borders + .iter() + .any(Option::is_some) + .then(|| Arc::new(borders)); + if extras.borders == borders { + return; + } + extras.borders = borders; + let id = if extras.is_empty() { + None + } else { + let id = self.alloc_extras(extras); + if id == 0 { + return; + } + Some(id) + }; + if cell.is_bg_only() { + let style = self.style_of(&cell); + let wrap = + cell.contains_cell_flag(crate::crosswords::square::CellFlags::WRAPLINE); + cell = Square::default().with_style_id(self.intern_style(style)); + if wrap { + cell.insert_cell_flag(crate::crosswords::square::CellFlags::WRAPLINE); + } + } + cell.set_extras_id(id); + self[pos] = cell; + self[pos.row].has_extras |= id.is_some(); + self[pos.row].has_styles |= cell.carries_style(); + } + /// The interned styles slice; `StyleId`s index into it. #[inline] pub fn styles(&self) -> &[Style] { diff --git a/rio-vt/src/crosswords/grid/tests.rs b/rio-vt/src/crosswords/grid/tests.rs index 52e90e4746..27762b2b1f 100644 --- a/rio-vt/src/crosswords/grid/tests.rs +++ b/rio-vt/src/crosswords/grid/tests.rs @@ -436,6 +436,7 @@ fn extras_sweep_resets_reclaim_cadence() { for i in 0..EXTRAS_RECLAIM_CADENCE { table.alloc(Extras { zerowidth: Vec::new(), + borders: None, hyperlink: Some(Hyperlink::new(Some(i.to_string()), i.to_string())), }); } @@ -489,12 +490,14 @@ fn extras_reclaim_keeps_ids_of_hidden_cached_rows() { let id = grid.alloc_extras(Extras { zerowidth: vec!['\u{301}'], hyperlink: None, + borders: None, }); grid[Line(3)][Column(0)].set_extras_id(Some(id)); grid[Line(3)].has_extras = true; let dead = grid.alloc_extras(Extras { zerowidth: vec!['\u{302}'], hyperlink: None, + borders: None, }); // Shrinking with the cursor at the top drops the bottom rows into diff --git a/rio-vt/src/crosswords/mod.rs b/rio-vt/src/crosswords/mod.rs index 14c732e5ba..bba8f291df 100644 --- a/rio-vt/src/crosswords/mod.rs +++ b/rio-vt/src/crosswords/mod.rs @@ -5292,6 +5292,40 @@ impl Handler for Crosswords { .send_event(RioEvent::PtyWrite(self.route_id, response), self.window_id); } + fn cell_border(&mut self, command: crate::ansi::border_protocol::BorderCommand) { + use crate::ansi::border_protocol::{BorderCommand, SUPPORT_REPLY}; + match command { + BorderCommand::Query => { + self.event_proxy.send_event( + RioEvent::PtyWrite(self.route_id, SUPPORT_REPLY.to_owned()), + self.window_id, + ); + } + BorderCommand::Paint { + mask, + stroke, + rows, + cols, + } => { + let start = self.grid.cursor.pos; + let end_row = + (start.row.0 as usize + rows as usize).min(self.grid.screen_lines()); + let end_col = (start.col.0 + cols as usize).min(self.grid.columns()); + for row in start.row.0 as usize..end_row { + for col in start.col.0..end_col { + self.grid.paint_cell_border( + Pos::new(Line(row as i32), Column(col)), + mask, + stroke, + ); + } + } + // Centered strokes may affect neighboring rows as well. + self.mark_fully_damaged(); + } + } + } + #[inline] fn glyph_protocol_response(&mut self, response: String) { self.event_proxy @@ -5850,6 +5884,149 @@ mod tests { Crosswords::new(size, CursorShape::Block, VoidListener {}, window_id, 0, 10) } + fn borders_at( + cw: &Crosswords, + row: i32, + col: usize, + ) -> crate::ansi::border_protocol::CellBorders { + let cell = cw.grid[Line(row)][Column(col)]; + cell.extras_id_checked() + .and_then(|id| cw.grid.extras_table.get(id)) + .and_then(|extras| extras.borders.as_deref().copied()) + .unwrap_or([None; 10]) + } + + #[test] + fn cell_border_apc_decorates_text_and_blank_without_moving_cursor() { + use crate::performer::handler::Processor; + let mut cw = make_crosswords(); + let mut processor = Processor::default(); + processor.advance(&mut cw, b"X\x1b[H"); + let cursor = cw.grid.cursor.pos; + // Feed the terminator separately to exercise streaming APC dispatch. + processor.advance(&mut cw, b"\x1b_rio-border;1;set;9;16;12abef;0;1;1;2"); + assert_eq!(borders_at(&cw, 0, 0), [None; 10]); + processor.advance(&mut cw, b"\x1b\\"); + assert_eq!(cw.grid.cursor.pos, cursor); + assert_eq!(cw.grid[Line(0)][Column(0)].c(), 'X'); + assert_eq!(cw.grid[Line(0)][Column(1)].c(), '\0'); + let borders = borders_at(&cw, 0, 0); + assert_eq!(borders, borders_at(&cw, 0, 1)); + let stroke = borders[0].unwrap(); + assert_eq!(stroke.color, [0x12, 0xab, 0xef]); + assert_eq!(stroke.width, 16); + assert_eq!(stroke.layer, 1); + assert_eq!(borders[3], Some(stroke)); + assert!(borders[1].is_none()); + assert_eq!(borders_at(&cw, 0, 2), [None; 10]); + } + + #[test] + fn cell_border_edges_compose_and_clear_preserves_text_extras() { + use crate::performer::handler::Processor; + let mut cw = make_crosswords(); + let mut processor = Processor::default(); + processor.advance( + &mut cw, + "\x1b]8;;https://example.com\x07e\u{301}\x1b]8;;\x07\x1b[H".as_bytes(), + ); + processor.advance(&mut cw, b"\x1b_rio-border;1;set;1;8;ff0000;0;0;1;1\x1b\\"); + processor.advance(&mut cw, b"\x1b_rio-border;1;set;34;16;0000ff;1;1;1;1\x1b\\"); + let borders = borders_at(&cw, 0, 0); + assert_eq!(borders[0].unwrap().color, [255, 0, 0]); + assert_eq!(borders[1].unwrap().color, [0, 0, 255]); + assert_eq!(borders[5], borders[1]); + processor.advance(&mut cw, b"\x1b_rio-border;1;clear;1;1;1\x1b\\"); + assert!(borders_at(&cw, 0, 0)[0].is_none()); + assert_eq!(borders_at(&cw, 0, 0)[1], borders[1]); + processor.advance(&mut cw, b"\x1b_rio-border;1;clear;63;1;1\x1b\\"); + assert_eq!(borders_at(&cw, 0, 0), [None; 10]); + assert_eq!(cw.grid[Line(0)][Column(0)].c(), 'e'); + assert_eq!(extras_of(&cw, 0, 0), ['\u{301}']); + assert!(cw.cell_hyperlink(Line(0), Column(0)).is_some()); + } + + #[test] + fn cell_border_quadrant_corner_uses_only_two_half_centerlines() { + use crate::performer::handler::Processor; + let mut cw = make_crosswords(); + let mut processor = Processor::default(); + processor.advance(&mut cw, "▗\x1b[H".as_bytes()); + processor.advance(&mut cw, b"\x1b_rio-border;1;set;640;16;abcdef;0;1;1;1\x1b\\"); + let borders = borders_at(&cw, 0, 0); + assert!(borders[7].is_some()); + assert_eq!(borders[7], borders[9]); + assert_eq!(borders.iter().filter(|stroke| stroke.is_some()).count(), 2); + assert_eq!(cw.grid[Line(0)][Column(0)].c(), '▗'); + processor.advance(&mut cw, b"\x1b_rio-border;1;clear;1023;1;1\x1b\\"); + assert_eq!(borders_at(&cw, 0, 0), [None; 10]); + } + + #[test] + fn cell_border_malformed_apc_has_no_partial_effect() { + use crate::performer::handler::Processor; + let mut cw = make_crosswords(); + let mut processor = Processor::default(); + processor.advance( + &mut cw, + b"\x1b_rio-border;1;set;63;8;123456;0;0;1;1;extra\x1b\\", + ); + assert_eq!(borders_at(&cw, 0, 0), [None; 10]); + assert_eq!(cw.grid.cursor.pos, Pos::new(Line(0), Column(0))); + processor.advance(&mut cw, b"X"); + assert_eq!(cw.grid[Line(0)][Column(0)].c(), 'X'); + } + + #[test] + fn cell_border_preserves_erased_background_and_clips_to_screen() { + use crate::performer::handler::Processor; + let mut cw = make_crosswords(); + let mut processor = Processor::default(); + processor.advance(&mut cw, b"\x1b[48;2;10;20;30m\x1b[2J\x1b[4;4H"); + let cell = cw.grid[Line(3)][Column(3)]; + let background = cw.grid.style_of(&cell).bg; + processor.advance( + &mut cw, + b"\x1b_rio-border;1;set;63;8;abcdef;0;0;65535;65535\x1b\\", + ); + let cell = cw.grid[Line(3)][Column(3)]; + assert_eq!(cw.grid.style_of(&cell).bg, background); + assert!(borders_at(&cw, 3, 3)[..6].iter().all(Option::is_some)); + assert_eq!(borders_at(&cw, 3, 2), [None; 10]); + assert_eq!(borders_at(&cw, 2, 3), [None; 10]); + assert_eq!(cw.grid.cursor.pos, Pos::new(Line(3), Column(3))); + } + + #[test] + fn cell_border_overwrite_and_erase_remove_decoration() { + use crate::performer::handler::Processor; + let mut cw = make_crosswords(); + let mut processor = Processor::default(); + processor.advance(&mut cw, b"\x1b_rio-border;1;set;63;8;abcdef;0;0;1;2\x1b\\"); + processor.advance(&mut cw, b"X\x1b[K"); + assert_eq!(borders_at(&cw, 0, 0), [None; 10]); + assert_eq!(borders_at(&cw, 0, 1), [None; 10]); + } + + #[test] + fn cell_border_scrolls_with_cells_and_stays_on_its_screen() { + use crate::performer::handler::Processor; + let mut cw = make_crosswords(); + let mut processor = Processor::default(); + processor.advance( + &mut cw, + b"\x1b[2;1HX\x1b[2;1H\x1b_rio-border;1;set;1;8;abcdef;0;0;1;1\x1b\\", + ); + let borders = borders_at(&cw, 1, 0); + processor.advance(&mut cw, b"\x1b[4;1H\n"); + assert_eq!(cw.grid[Line(0)][Column(0)].c(), 'X'); + assert_eq!(borders_at(&cw, 0, 0), borders); + processor.advance(&mut cw, b"\x1b[?1049h"); + assert_eq!(borders_at(&cw, 0, 0), [None; 10]); + processor.advance(&mut cw, b"\x1b[?1049l"); + assert_eq!(borders_at(&cw, 0, 0), borders); + } + #[test] fn swap_alt_reinterns_cursor_style_into_alt_table() { use crate::config::colors::{AnsiColor, ColorRgb}; diff --git a/rio-vt/src/crosswords/square.rs b/rio-vt/src/crosswords/square.rs index d33d5bcb22..56a624bd18 100644 --- a/rio-vt/src/crosswords/square.rs +++ b/rio-vt/src/crosswords/square.rs @@ -201,6 +201,7 @@ pub type ExtrasId = u16; /// Allocated only for cells that need it; pooled in a `Vec` on the grid. #[derive(Default, Debug, Clone, PartialEq, Eq, Hash)] pub struct Extras { + pub borders: Option>, pub zerowidth: Vec, pub hyperlink: Option, } @@ -208,7 +209,7 @@ pub struct Extras { impl Extras { #[inline] pub fn is_empty(&self) -> bool { - self.zerowidth.is_empty() && self.hyperlink.is_none() + self.zerowidth.is_empty() && self.hyperlink.is_none() && self.borders.is_none() } } diff --git a/rio-vt/src/performer/handler.rs b/rio-vt/src/performer/handler.rs index bd2c2e89d4..e26e48cc6c 100644 --- a/rio-vt/src/performer/handler.rs +++ b/rio-vt/src/performer/handler.rs @@ -1,4 +1,4 @@ -use crate::ansi::glyph_protocol; +use crate::ansi::{border_protocol, glyph_protocol}; use crate::ansi::iterm2_image_protocol; use crate::ansi::kitty_graphics_protocol; use crate::ansi::CursorShape; @@ -446,6 +446,9 @@ pub trait Handler { /// Send a kitty graphics protocol response fn kitty_graphics_response(&mut self, _response: String) {} + /// Apply a parsed cell-border command. + fn cell_border(&mut self, _command: border_protocol::BorderCommand) {} + /// Send a Glyph Protocol response (query reply, register ack, etc.). fn glyph_protocol_response(&mut self, _response: String) {} @@ -789,6 +792,13 @@ impl<'a, H: Handler + 'a> Performer<'a, H> { String::from_utf8_lossy(&data[..data.len().min(50)]) ); + if data.starts_with(border_protocol::PREFIX) { + if let Some(command) = border_protocol::parse(data) { + self.handler.cell_border(command); + } + return; + } + // Check if this is a Glyph Protocol APC (starts with "25a1"). // Glyph Protocol is checked before Kitty so its fixed-string // prefix short-circuits quickly; the two prefixes are disjoint. diff --git a/specs/cell-border-protocol.md b/specs/cell-border-protocol.md new file mode 100644 index 0000000000..6837b8e643 --- /dev/null +++ b/specs/cell-border-protocol.md @@ -0,0 +1,128 @@ +# Cell borders: experimental Rio protocol + +Paint thin, independently colored lines on the edges or center of any terminal cell, including +empty cells. Text keeps its foreground and background; borders supply additional colors without +consuming character positions. This is a prototype, not a registered or interoperable standard. + +## Wire format + +Like Rio's [Glyph Protocol](glyph-protocol.md), this uses APC (`ESC _ … ESC \`) with an explicit +namespace. It avoids assigning experimental SGR numbers and keeps border painting separate from +text rendition. Spaces shown below separate notation only; actual fields contain no whitespace. + +```text +ESC _ rio-border;1;set;MASK;WIDTH;RRGGBB;PLACEMENT;LAYER;ROWS;COLS ESC \ +ESC _ rio-border;1;clear;MASK;ROWS;COLS ESC \ +ESC _ rio-border;1;q ESC \ +``` + +The query reply is `ESC _ rio-border;1;ok ESC \`. Set and clear have no reply. Clients should read +query replies while their input handler is active and fall back when no reply arrives. The demo +assumes this prototype is running and does not query. + +| Field | Values | +| --- | --- | +| `MASK` | Add stroke bits, listed below; valid range `1..1023`. | +| `WIDTH` | Integer `1..32`, in 1/256 of **cell width**; minimum one physical pixel. | +| `RRGGBB` | Exactly six hexadecimal digits; opaque RGB, independent of text colors. | +| `PLACEMENT` | `0`: inside the cell; `1`: centered on the edge, spilling into neighbors. | +| `LAYER` | `0`: below text; `1`: above text. Both are above the cell background. | +| `ROWS`, `COLS` | Decimal `1..65535`; cursor-anchored rectangle, clipped to visible screen. | + +Mask bits are top `1`, right `2`, bottom `4`, left `8`, center horizontal `16`, and center vertical +`32`. Four half-centerline arms are horizontal left `64`, horizontal right `128`, vertical top +`256`, and vertical bottom `512`. These run from the cell center to the named edge. Center strokes +stay centered regardless of placement. Width uses cell width for both axes. Half arms overlap +across the central stroke square, so perpendicular arms make a clean corner. Full lines and +half arms are independent slots; avoid selecting both over the same segment unless intentional. +Use mask `1023` to clear all strokes (the original `63` clears only the first six). + +Set replaces only the selected strokes on **every cell** in the region; it does not draw only a +rectangle's perimeter. Each stroke retains its own width, color, placement, and layer. Use four +one-cell-thick strips to outline a panel. Clear removes only selected strokes. Neither operation +moves the cursor or changes text. Integer fields are decimal without signs. + +For example, add a teal top edge to 20 cells starting at row 4, column 8: + +```python +print("\x1b[4;8H\x1b_rio-border;1;set;1;16;80c5b7;0;1;1;20\x1b\\", end="") +``` + +Width uses one common physical scale, avoiding the unequal thickness of horizontal and vertical +Unicode eighth-blocks. Rounding and the one-pixel minimum mean several small widths can look +identical at ordinary font sizes. Corners are rectangular joins. Center horizontal and vertical +strokes make a cross for outlining quadrants; each can have a different color. + +## Lifetime and prototype boundaries + +Borders belong to cells and follow cell movement and scrolling. Overwriting or erasing a cell +clears its borders, including when writing a space. Paint text first, then borders. SGR reset +does not clear existing borders. There is no sticky border pen and no extra copied text. + +Malformed or unknown commands are ignored atomically. The maximum command body is 256 bytes. +No arbitrary z-index, alpha, rounded corners, dashed lines, or persistence format is defined. +Layer ordering applies within the owning row. For centered strokes spilling across rows, row +render order can override the requested layer. Viewport edges clip overflow. Shared edges do not +merge styles automatically: applications should choose one owner when colors differ. Existing +text/style serialization does not preserve borders in this prototype. + +## Try it + +Build and launch a prototype window from the repository root: + +```sh +cargo build -p rioterm +./target/debug/rio -e python3 misc/scripts/cell-borders.py +``` + +Alternatively, run inside the prototype Rio build, with at least 88 columns and 42 rows: + +```sh +python3 misc/scripts/cell-borders.py +``` + +Press `b` to compare with and without borders; press `q` or Escape to exit. The demo restores the +original screen and cursor visibility on exit. It includes a dialog inspired by the supplied +screenshot, outlined and underline-only active/inactive fields, quadrant crosses, stroke widths, +placement, and text layers. +Press `p` for the placement detail view: identical buttons of three sizes compare inside and +centered strokes against contrasting cell backgrounds and adjacent text. Press `w` to cycle +stroke widths. Blue is the button's exact cell area; slate is its surroundings. Toggle borders +to inspect that boundary. Use `--placement` to start directly in this view. + +Press `c` for button brightness comparisons: dim, medium, and bright borders; three fill +brightnesses; flat, raised, and pressed edges; and inline labels bordered with no padding. +Use `--colors` to start in this view. The placement view also compares unpadded `OK` labels. + +Press `h` (or use `--blocks`) for buttons made with quadrant corners (`▗ ▖ ▝ ▘`) and half-block +edges (`▄ ▀ ▐ ▌`). Their borders follow the internal centerlines rather than the full-cell +perimeter. The top-left `▗` corner uses mask `640` (right horizontal arm + bottom vertical arm). +The other corners use `576`, `384`, and `320`. `half_outline(...)` in the demo shows the eight +commands needed; block foreground, label foreground, and border colors remain independent. +This view needs the rebuilt prototype with half-centerline support. + +Press `j` (or use `--junctions`) for offset header, content, and status dividers. The shared +horizontal lines sit at the background transitions in `▀` cells. A horizontal line plus the top +arm (`272`) makes an upward T; horizontal plus bottom (`528`) makes a downward T. Dividers above +and below can terminate at different columns without crossing into the neighboring band. + +To capture one frame without entering the alternate screen or waiting for input: + +```sh +python3 misc/scripts/cell-borders.py --emit > /tmp/cell-borders.ansi +cat /tmp/cell-borders.ansi +``` + +The Python script also provides small `border(...)` and `outline(...)` helpers for experiments. + +## Nearby prior art + +[XTerm's rectangular-area controls](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) +include DECCARA for changing character attributes in a region. They provide a useful precedent +for painting existing cells, but do not supply these independent cell-edge strokes. +[Kitty's graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) +provides image placement and z-order relative to text. This experiment uses much smaller, +cell-owned geometry instead of images. Rio's Glyph Protocol supplies the local APC precedent; +custom glyphs still occupy text positions, whereas these borders do not. + +This short comparison is not an exhaustive prior-art survey or a claim of novelty.