clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 1b8138fc4f8a
tarballunverified2 files changed+70-7
@@ -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() {@@ -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); }