brewery
notashelf /
1b8138fc4f8abd1fd8809f2016d8485c28d624a2

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 1b8138fc4f8a

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified2 files changed+70-7
grid: store and render combining marks on the base cell

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ic1aedda14fa6102c4dc80f9fd6863c7f6a6a6964
diff --git a/src/grid.rs b/src/grid.rsindex b59eb61..74ce114 100644--- a/src/grid.rs+++ b/src/grid.rs@@ -113,6 +113,10 @@ pub struct Cell {     pub underline: Underline,     /// Underline colour; `Default` means "follow the foreground".     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 {@@ -124,6 +128,7 @@ impl Default for Cell {             flags: Flags::empty(),             underline: Underline::None,             underline_color: Color::Default,+            combining: None,         }     } }@@ -230,15 +235,19 @@ pub struct Grid {     word_delimiters: String,     /// History retention cap for the main screen.     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> {     (0..cols).map(|i| i % 8 == 0 && i != 0).collect() } -/// Characters that terminate a word for double-click selection. `_`, `-`, `.`,-/// `/`, `:`, `~` are deliberately *not* delimiters so paths, URLs, and option-/// flags select as one unit.+/// Default characters that terminate a word for double-click selection (the+/// `word-delimiters` config key overrides this). `_`, `-`, `.`, `/`, `:`, `~`+/// are deliberately *not* delimiters so paths, URLs, and option flags select as+/// one unit. const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?";  /// Whether `c` is part of a word (not whitespace, not in `delims`).@@ -282,6 +291,7 @@ impl Grid {             search: None,             word_delimiters: WORD_DELIMITERS.to_string(),             scrollback_cap: SCROLLBACK_CAP,+            last_base: None,         }     } @@ -526,7 +536,8 @@ impl Grid {     pub fn print(&mut self, c: char) {         let width = c.width().unwrap_or(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;         }         if self.wrap_pending {@@ -552,11 +563,14 @@ impl Grid {         let (x, y) = (self.cursor.x, self.cursor.y);         let mut cell = self.pen.clone();         cell.c = c;+        cell.combining = None;         cell.flags.remove(Flags::WIDE_CONT);         self.lines[y].cells[x] = cell;+        self.last_base = Some((x, y));         if width == 2 && x + 1 < self.cols {             let mut cont = self.pen.clone();             cont.c = ' ';+            cont.combining = None;             cont.flags.insert(Flags::WIDE_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) {         let (x, y) = (self.cursor.x, self.cursor.y);         let end = self.cols;@@ -1139,13 +1170,20 @@ impl Grid {     /// The characters of an absolute row in `[from, to)`, skipping wide     /// continuation cells.     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()))             .unwrap_or(&[])             .iter()             .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 ---@@ -1353,6 +1391,24 @@ mod tests {     }      #[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]     fn carriage_return_and_line_feed() {         let mut g = Grid::new(8, 4);         for c in "hi".chars() {diff --git a/src/render.rs b/src/render.rsindex cc3b0e3..1c01c67 100644--- a/src/render.rs+++ b/src/render.rs@@ -204,6 +204,13 @@ impl Renderer {             if cell.c != ' ' {                 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);         }