forked from NotAShelf/beer
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:
parent
53924d381a
commit
1b8138fc4f
2 changed files with 70 additions and 7 deletions
70
src/grid.rs
70
src/grid.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue