grid: store and render combining marks on the base cell

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ic1aedda14fa6102c4dc80f9fd6863c7f6a6a6964
This commit is contained in:
raf 2026-06-25 12:53:06 +03:00
commit 1b8138fc4f
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
2 changed files with 70 additions and 7 deletions

View file

@ -113,6 +113,10 @@ pub struct Cell {
pub underline: Underline, pub underline: Underline,
/// Underline colour; `Default` means "follow the foreground". /// Underline colour; `Default` means "follow the foreground".
pub underline_color: Color, pub underline_color: Color,
/// Zero-width combining marks attached to `c`, in arrival order. `None` for
/// the common case; the renderer stacks each over the base glyph. This is
/// the grapheme cluster a future shaper (HarfBuzz) would consume.
pub combining: Option<Box<str>>,
} }
impl Default for Cell { impl Default for Cell {
@ -124,6 +128,7 @@ impl Default for Cell {
flags: Flags::empty(), flags: Flags::empty(),
underline: Underline::None, underline: Underline::None,
underline_color: Color::Default, underline_color: Color::Default,
combining: None,
} }
} }
} }
@ -230,15 +235,19 @@ pub struct Grid {
word_delimiters: String, word_delimiters: String,
/// History retention cap for the main screen. /// History retention cap for the main screen.
scrollback_cap: usize, scrollback_cap: usize,
/// Position of the last printed base cell, so a following zero-width
/// combining mark can attach to it.
last_base: Option<(usize, usize)>,
} }
fn default_tabs(cols: usize) -> Vec<bool> { fn default_tabs(cols: usize) -> Vec<bool> {
(0..cols).map(|i| i % 8 == 0 && i != 0).collect() (0..cols).map(|i| i % 8 == 0 && i != 0).collect()
} }
/// Characters that terminate a word for double-click selection. `_`, `-`, `.`, /// Default characters that terminate a word for double-click selection (the
/// `/`, `:`, `~` are deliberately *not* delimiters so paths, URLs, and option /// `word-delimiters` config key overrides this). `_`, `-`, `.`, `/`, `:`, `~`
/// flags select as one unit. /// are deliberately *not* delimiters so paths, URLs, and option flags select as
/// one unit.
const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?"; const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?";
/// Whether `c` is part of a word (not whitespace, not in `delims`). /// Whether `c` is part of a word (not whitespace, not in `delims`).
@ -282,6 +291,7 @@ impl Grid {
search: None, search: None,
word_delimiters: WORD_DELIMITERS.to_string(), word_delimiters: WORD_DELIMITERS.to_string(),
scrollback_cap: SCROLLBACK_CAP, scrollback_cap: SCROLLBACK_CAP,
last_base: None,
} }
} }
@ -526,7 +536,8 @@ impl Grid {
pub fn print(&mut self, c: char) { pub fn print(&mut self, c: char) {
let width = c.width().unwrap_or(0); let width = c.width().unwrap_or(0);
if width == 0 { if width == 0 {
// Combining/zero-width: no standalone storage yet. // A zero-width combining mark attaches to the last base cell.
self.add_combining(c);
return; return;
} }
if self.wrap_pending { if self.wrap_pending {
@ -552,11 +563,14 @@ impl Grid {
let (x, y) = (self.cursor.x, self.cursor.y); let (x, y) = (self.cursor.x, self.cursor.y);
let mut cell = self.pen.clone(); let mut cell = self.pen.clone();
cell.c = c; cell.c = c;
cell.combining = None;
cell.flags.remove(Flags::WIDE_CONT); cell.flags.remove(Flags::WIDE_CONT);
self.lines[y].cells[x] = cell; self.lines[y].cells[x] = cell;
self.last_base = Some((x, y));
if width == 2 && x + 1 < self.cols { if width == 2 && x + 1 < self.cols {
let mut cont = self.pen.clone(); let mut cont = self.pen.clone();
cont.c = ' '; cont.c = ' ';
cont.combining = None;
cont.flags.insert(Flags::WIDE_CONT); cont.flags.insert(Flags::WIDE_CONT);
self.lines[y].cells[x + 1] = cont; self.lines[y].cells[x + 1] = cont;
} }
@ -574,6 +588,23 @@ impl Grid {
} }
} }
/// Attach a zero-width combining mark to the most recently printed base
/// cell. Capped so a malicious stream of marks cannot grow a cell unbounded.
fn add_combining(&mut self, mark: char) {
const MAX_MARKS: usize = 8;
let Some((x, y)) = self.last_base else {
return;
};
let Some(cell) = self.lines.get_mut(y).and_then(|l| l.cells.get_mut(x)) else {
return;
};
let mut s: String = cell.combining.take().map(String::from).unwrap_or_default();
if s.chars().count() < MAX_MARKS {
s.push(mark);
}
cell.combining = Some(s.into_boxed_str());
}
fn shift_right(&mut self, n: usize) { fn shift_right(&mut self, n: usize) {
let (x, y) = (self.cursor.x, self.cursor.y); let (x, y) = (self.cursor.x, self.cursor.y);
let end = self.cols; let end = self.cols;
@ -1139,13 +1170,20 @@ impl Grid {
/// The characters of an absolute row in `[from, to)`, skipping wide /// The characters of an absolute row in `[from, to)`, skipping wide
/// continuation cells. /// continuation cells.
fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String { fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String {
self.abs_row(row) let mut out = String::new();
for cell in self
.abs_row(row)
.get(from..to.min(self.abs_row(row).len())) .get(from..to.min(self.abs_row(row).len()))
.unwrap_or(&[]) .unwrap_or(&[])
.iter() .iter()
.filter(|c| !c.flags.contains(Flags::WIDE_CONT)) .filter(|c| !c.flags.contains(Flags::WIDE_CONT))
.map(|c| c.c) {
.collect() out.push(cell.c);
if let Some(marks) = &cell.combining {
out.push_str(marks);
}
}
out
} }
// --- scrollback search --- // --- scrollback search ---
@ -1352,6 +1390,24 @@ mod tests {
assert_eq!(g.cursor(), (1, 1)); assert_eq!(g.cursor(), (1, 1));
} }
#[test]
fn combining_marks_attach_to_base_cell() {
let mut g = Grid::new(8, 1);
// "e" + COMBINING ACUTE ACCENT + "x": the mark joins the 'e' cell, the
// 'x' lands in the next cell (the mark advanced nothing).
for c in "e\u{0301}x".chars() {
g.print(c);
}
assert_eq!(g.cell(0, 0).c, 'e');
assert_eq!(g.cell(0, 0).combining.as_deref(), Some("\u{0301}"));
assert_eq!(g.cell(1, 0).c, 'x');
assert_eq!(g.cursor(), (2, 0));
// Copied text round-trips the full grapheme cluster.
g.start_selection(0, 0);
g.extend_selection(0, 1);
assert_eq!(g.selection_text().as_deref(), Some("e\u{0301}x"));
}
#[test] #[test]
fn carriage_return_and_line_feed() { fn carriage_return_and_line_feed() {
let mut g = Grid::new(8, 4); let mut g = Grid::new(8, 4);

View file

@ -204,6 +204,13 @@ impl Renderer {
if cell.c != ' ' { if cell.c != ' ' {
self.draw_glyph(&mut canvas, cell.c, cell_style(cell), origin_x, row_top, fg); self.draw_glyph(&mut canvas, cell.c, cell_style(cell), origin_x, row_top, fg);
} }
// Stack any combining marks over the base glyph; their own bearings
// position them (no shaper, so placement is the font's default).
if let Some(marks) = &cell.combining {
for mark in marks.chars() {
self.draw_glyph(&mut canvas, mark, cell_style(cell), origin_x, row_top, fg);
}
}
draw_decorations(&mut canvas, cell, theme, origin_x, row_top, m, fg); draw_decorations(&mut canvas, cell, theme, origin_x, row_top, m, fg);
} }