feat(font): add run iterator, shared grid set and config integration
Completes the font engine port: - shaper/run.rs ports run.zig: the RunIterator over terminal rows that splits text runs at style/face boundaries, selection and cursor, handles graphemes and ligatures; Shaper::shape_run shapes a run. - shared_grid_set.rs ports SharedGridSet.zig: grids keyed by a config derived key, handed out as Arc<SharedGrid>. - Font-related types now have one canonical definition in ghostty-config (Variation, Descriptor, CodepointMap, metric modifiers, freetype load flags, synthetic style, shaping break) which the font crate re-exports. MetricModifier::apply_i32 now saturates to -i32::MAX like Zig. - The 23 terminal-driven harfbuzz shaper tests are ported, printing via Screen until Terminal lands. Test Plan: - cargo test -p ghostty-font (157 passed, 3 ignored - also skipped in Zig); Devanagari/Chakma/Bengali tests return early without system fonts - cargo test -p ghostty-config (255 passed)
This commit is contained in:
1 parent
ab9c3fc9db
commit
c5d2cf4a64
19 files changed
+3204
-554
No files matched your search
@@ -1,6 +1,6 @@
|
||||
//! HarfBuzz shaper. Port of `font/shaper/harfbuzz.zig`.
|
||||
//!
|
||||
//! The Zig shaper is driven by `RunIterator` (shaper/run.zig, deferred to F2)
|
||||
//! The Zig shaper is driven by `RunIterator` (shaper/run.rs)
|
||||
//! through the hooks `prepare` / `addCodepoint` / `finalize`; those hooks are
|
||||
//! exposed here as [`Shaper::prepare`], [`Shaper::add_codepoint`] and
|
||||
//! [`Shaper::finalize`]. [`Shaper::shape`] takes the face of the run's font
|
||||
@@ -12,7 +12,8 @@
|
||||
|
||||
use super::hb;
|
||||
use crate::face::freetype::Face;
|
||||
use crate::shape::{self, Cell, DEFAULT_FEATURES, FeatureList};
|
||||
use crate::shape::{self, Cell, DEFAULT_FEATURES, FeatureList, TextRun};
|
||||
use crate::shared_grid::SharedGrid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ShapeError {
|
||||
@@ -22,6 +23,15 @@ pub enum ShapeError {
|
||||
HarfbuzzFailed,
|
||||
}
|
||||
|
||||
/// Errors from [`Shaper::shape_run`].
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ShapeRunError {
|
||||
#[error(transparent)]
|
||||
GetFace(#[from] crate::collection::GetFaceError),
|
||||
#[error(transparent)]
|
||||
Shape(#[from] ShapeError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Codepoint {
|
||||
cluster: u32,
|
||||
@@ -137,6 +147,28 @@ impl Shaper {
|
||||
self.shape(face)
|
||||
}
|
||||
|
||||
/// Shape the given text run (`shape(run)` in Zig). The run must be the
|
||||
/// immediately previous run that was iterated since it shares state with
|
||||
/// the shaper. The face is looked up in `grid` under its exclusive lock
|
||||
/// (FreeType faces are not thread safe).
|
||||
///
|
||||
/// The return value is only valid until the next shape call is called.
|
||||
pub fn shape_run(
|
||||
&mut self,
|
||||
grid: &SharedGrid,
|
||||
run: &TextRun,
|
||||
) -> Result<&[Cell], ShapeRunError> {
|
||||
// We only do shaping if the font is not a special-case. For
|
||||
// special-case fonts, the codepoint == glyph_index so we don't need
|
||||
// to run any shaping.
|
||||
if run.font_index.special().is_none() {
|
||||
let mut guard = grid.write();
|
||||
let face = guard.resolver.collection.get_face(run.font_index)?;
|
||||
return Ok(self.shape(Some(face))?);
|
||||
}
|
||||
Ok(self.shape(None)?)
|
||||
}
|
||||
|
||||
/// Shape the current run (as built through the hooks). `face` is the face
|
||||
/// for the run's font index, or `None` if the font is a special-case
|
||||
/// font (sprite), for which codepoint == glyph_index.
|
||||
@@ -266,6 +298,11 @@ impl Shaper {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::face::{DesiredSize, FontData, Options};
|
||||
use crate::shaper::run::{RunIterator, RunOptions};
|
||||
use crate::shaper::test_util::{
|
||||
Term, TestFont, shape_all, test_shaper, test_shaper_with_discovered_font,
|
||||
test_shaper_with_font,
|
||||
};
|
||||
use crate::{Library, embedded};
|
||||
|
||||
fn text(s: &str) -> Vec<(u32, u32)> {
|
||||
@@ -299,32 +336,6 @@ mod tests {
|
||||
s.shape(Some(&f)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_inconsolata_ligs() {
|
||||
let f = face(embedded::INCONSOLATA);
|
||||
// Some of our tests rely on dlig being enabled by default.
|
||||
let mut s = Shaper::new(&shape::Options {
|
||||
features: vec!["dlig".to_string()],
|
||||
})
|
||||
.unwrap();
|
||||
let cells = s.shape_text(Some(&f), &text(">=")).unwrap();
|
||||
assert_eq!(1, cells.len());
|
||||
let cells = s.shape_text(Some(&f), &text("===")).unwrap();
|
||||
assert_eq!(1, cells.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_monaspace_ligs() {
|
||||
let f = face(embedded::MONASPACE_NEON);
|
||||
// Some of our tests rely on dlig being enabled by default.
|
||||
let mut s = Shaper::new(&shape::Options {
|
||||
features: vec!["dlig".to_string()],
|
||||
})
|
||||
.unwrap();
|
||||
let cells = s.shape_text(Some(&f), &text("===")).unwrap();
|
||||
assert_eq!(1, cells.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_ligatures_disabled_by_feature() {
|
||||
let f = face(embedded::INCONSOLATA);
|
||||
@@ -336,37 +347,6 @@ mod tests {
|
||||
assert_eq!(3, cells.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_arabic_forced_ltr() {
|
||||
let f = face(embedded::ARABIC);
|
||||
let mut s = Shaper::new(&shape::Options::default()).unwrap();
|
||||
let arabic = include_str!("../../testdata/arabic.txt");
|
||||
let line = arabic.lines().next().unwrap();
|
||||
let t = text(line);
|
||||
let n = t.len();
|
||||
let cells = s.shape_text(Some(&f), &t).unwrap();
|
||||
assert_eq!(n, cells.len());
|
||||
let mut x = cells[0].x;
|
||||
for cell in &cells[1..] {
|
||||
assert_eq!(x + 1, cell.x);
|
||||
x = cell.x;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_combining_characters() {
|
||||
let f = face(embedded::INCONSOLATA);
|
||||
let mut s = Shaper::new(&shape::Options::default()).unwrap();
|
||||
// n + two combining diaeresis in cell 0, then 'a' in cell 1.
|
||||
let t = [('n' as u32, 0), (0x0308, 0), (0x0308, 0), ('a' as u32, 1)];
|
||||
let cells = s.shape_text(Some(&f), &t).unwrap();
|
||||
assert_eq!(4, cells.len());
|
||||
assert_eq!(0, cells[0].x);
|
||||
assert_eq!(0, cells[1].x);
|
||||
assert_eq!(0, cells[2].x);
|
||||
assert_eq!(1, cells[3].x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_special_font_passthrough() {
|
||||
// Special fonts (sprite) are not shaped: glyph index == codepoint.
|
||||
@@ -397,4 +377,608 @@ mod tests {
|
||||
assert_eq!(0, cells[0].x);
|
||||
assert_eq!(3, cells[1].x);
|
||||
}
|
||||
|
||||
/// Run all runs over `row` of `t` with the default options and return
|
||||
/// the shaped runs.
|
||||
fn run_row(
|
||||
ts: &mut crate::shaper::test_util::TestShaper,
|
||||
t: &Term,
|
||||
y: u32,
|
||||
) -> Vec<crate::shaper::test_util::Shaped> {
|
||||
let cells = t.row(y);
|
||||
shape_all(&mut ts.shaper, RunOptions::new(&ts.grid, &cells))
|
||||
}
|
||||
|
||||
fn count_runs(
|
||||
ts: &mut crate::shaper::test_util::TestShaper,
|
||||
cells: &[crate::shaper::run::RunCell],
|
||||
selection: Option<[u16; 2]>,
|
||||
cursor_x: Option<usize>,
|
||||
) -> usize {
|
||||
let mut opts = RunOptions::new(&ts.grid, cells);
|
||||
opts.selection = selection;
|
||||
opts.cursor_x = cursor_x;
|
||||
shape_all(&mut ts.shaper, opts).len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_iterator() {
|
||||
let mut ts = test_shaper();
|
||||
|
||||
{
|
||||
let mut t = Term::new(5, 3);
|
||||
t.print("ABCD");
|
||||
let cells = t.row(0);
|
||||
let mut it = RunIterator::new(RunOptions::new(&ts.grid, &cells));
|
||||
let mut count = 0;
|
||||
while it.next(&mut ts.shaper).unwrap().is_some() {
|
||||
count += 1;
|
||||
}
|
||||
assert_eq!(1, count);
|
||||
}
|
||||
|
||||
// Spaces should be part of a run
|
||||
{
|
||||
let mut t = Term::new(10, 3);
|
||||
t.print("ABCD EFG");
|
||||
let cells = t.row(0);
|
||||
let mut it = RunIterator::new(RunOptions::new(&ts.grid, &cells));
|
||||
let mut count = 0;
|
||||
while it.next(&mut ts.shaper).unwrap().is_some() {
|
||||
count += 1;
|
||||
}
|
||||
assert_eq!(1, count);
|
||||
}
|
||||
|
||||
{
|
||||
let mut t = Term::new(5, 3);
|
||||
t.print("A\u{1F603}D");
|
||||
let cells = t.row(0);
|
||||
let mut it = RunIterator::new(RunOptions::new(&ts.grid, &cells));
|
||||
let mut count = 0;
|
||||
while it.next(&mut ts.shaper).unwrap().is_some() {
|
||||
count += 1;
|
||||
|
||||
// All runs should be exactly length 1
|
||||
assert_eq!(1, ts.shaper.hb_buf_len());
|
||||
}
|
||||
assert_eq!(3, count);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_iterator_empty_cells_with_background_set() {
|
||||
use ghostty_terminal::page::{Cell, ContentTag};
|
||||
use ghostty_vt::color::Rgb;
|
||||
|
||||
let mut ts = test_shaper();
|
||||
let mut t = Term::new(5, 3);
|
||||
// Set red background and write A
|
||||
t.bg_rgb(255, 0, 0);
|
||||
t.print("A");
|
||||
|
||||
// Get our first row
|
||||
for x in [1, 2] {
|
||||
let mut cell = Cell::default();
|
||||
cell.set_content_tag(ContentTag::BgColorRgb);
|
||||
cell.set_color_rgb(Rgb {
|
||||
r: 0xFF,
|
||||
g: 0,
|
||||
b: 0,
|
||||
});
|
||||
*t.cell_mut(x, 0) = cell;
|
||||
}
|
||||
|
||||
let cells = t.row(0);
|
||||
let mut it = RunIterator::new(RunOptions::new(&ts.grid, &cells));
|
||||
{
|
||||
let run = it.next(&mut ts.shaper).unwrap().unwrap();
|
||||
assert_eq!(3, ts.shaper.hb_buf_len());
|
||||
let shaped = ts.shaper.shape_run(&ts.grid, &run).unwrap();
|
||||
assert_eq!(3, shaped.len());
|
||||
}
|
||||
assert!(it.next(&mut ts.shaper).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape() {
|
||||
let mut ts = test_shaper();
|
||||
// Thumbs up plain, thumbs up plain, medium skin tone
|
||||
let mut t = Term::new(10, 3);
|
||||
t.print("\u{1F44D}\u{1F44D}\u{1F3FD}");
|
||||
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
assert_eq!(3, runs[0].hb_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_inconsolata_ligs() {
|
||||
let mut ts = test_shaper();
|
||||
|
||||
{
|
||||
let mut t = Term::new(5, 3);
|
||||
t.print(">=");
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
assert_eq!(2, runs[0].run.cells);
|
||||
assert_eq!(1, runs[0].cells.len());
|
||||
}
|
||||
|
||||
{
|
||||
let mut t = Term::new(5, 3);
|
||||
t.print("===");
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
assert_eq!(3, runs[0].run.cells);
|
||||
assert_eq!(1, runs[0].cells.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_monaspace_ligs() {
|
||||
let mut ts = test_shaper_with_font(TestFont::MonaspaceNeon);
|
||||
|
||||
let mut t = Term::new(5, 3);
|
||||
t.print("===");
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
assert_eq!(3, runs[0].run.cells);
|
||||
assert_eq!(1, runs[0].cells.len());
|
||||
}
|
||||
|
||||
// Ghostty doesn't currently support RTL and our renderers assume
|
||||
// that cells are in strict LTR order. This means that we need to
|
||||
// force RTL text to be LTR for rendering. This test ensures that
|
||||
// we are correctly forcing RTL text to be LTR.
|
||||
#[test]
|
||||
fn shape_arabic_forced_ltr() {
|
||||
let mut ts = test_shaper_with_font(TestFont::Arabic);
|
||||
|
||||
let mut t = Term::new(120, 30);
|
||||
t.print(include_str!("../../testdata/arabic.txt"));
|
||||
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
assert_eq!(25, runs[0].run.cells);
|
||||
let cells = &runs[0].cells;
|
||||
assert_eq!(25, cells.len());
|
||||
let mut x = cells[0].x;
|
||||
for cell in &cells[1..] {
|
||||
assert_eq!(x + 1, cell.x);
|
||||
x = cell.x;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_emoji_width() {
|
||||
let mut ts = test_shaper();
|
||||
|
||||
let mut t = Term::new(5, 3);
|
||||
t.print("\u{1F44D}");
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
assert_eq!(2, runs[0].run.cells);
|
||||
assert_eq!(1, runs[0].cells.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_emoji_width_long() {
|
||||
use ghostty_terminal::page::{Cell, ContentTag};
|
||||
|
||||
let mut ts = test_shaper();
|
||||
|
||||
// Make a screen and add a long emoji sequence to it.
|
||||
let mut t = Term::new(30, 3);
|
||||
{
|
||||
let pin = t
|
||||
.screen
|
||||
.pages
|
||||
.pin(ghostty_terminal::point::Point::Active(
|
||||
ghostty_terminal::point::Coordinate { x: 0, y: 1 },
|
||||
))
|
||||
.unwrap();
|
||||
let page = t.screen.page_mut(pin.node);
|
||||
let y = usize::from(pin.y);
|
||||
let mut cell = Cell::default();
|
||||
cell.set_content_tag(ContentTag::Codepoint);
|
||||
cell.set_codepoint(0x1F9D4); // Person with beard
|
||||
*page.get_cells_mut(y).unwrap().first_mut().unwrap() = cell;
|
||||
let graphemes = [
|
||||
0x1F3FB, // Light skin tone (Fitz 1-2)
|
||||
0x200D, // ZWJ
|
||||
0x2642, // Male sign
|
||||
0xFE0F, // Emoji presentation selector
|
||||
];
|
||||
page.set_graphemes(0, y, &graphemes).unwrap();
|
||||
}
|
||||
|
||||
let runs = run_row(&mut ts, &t, 1);
|
||||
assert_eq!(1, runs.len());
|
||||
assert_eq!(4, runs[0].hb_len);
|
||||
assert_eq!(1, runs[0].cells.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_variation_selector_vs15() {
|
||||
let mut ts = test_shaper();
|
||||
|
||||
// Victory sign (default text) + VS15 to force text
|
||||
let mut t = Term::new(10, 3);
|
||||
t.print("\u{270C}\u{FE0E}");
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
assert_eq!(1, runs[0].hb_len);
|
||||
assert_eq!(1, runs[0].cells.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_variation_selector_vs16() {
|
||||
let mut ts = test_shaper();
|
||||
|
||||
// Victory sign (default text) + VS16 to force color
|
||||
let mut t = Term::new(10, 3);
|
||||
t.print("\u{270C}\u{FE0F}");
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
assert_eq!(1, runs[0].hb_len);
|
||||
assert_eq!(1, runs[0].cells.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_with_empty_cells_in_between() {
|
||||
let mut ts = test_shaper();
|
||||
|
||||
let mut t = Term::new(30, 3);
|
||||
t.print("A");
|
||||
t.cursor_right(5);
|
||||
t.print("B");
|
||||
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
assert_eq!(7, runs[0].cells.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_combining_characters() {
|
||||
let mut ts = test_shaper();
|
||||
|
||||
let mut t = Term::new(30, 3);
|
||||
t.print("n\u{0308}\u{0308}a");
|
||||
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
let cells = &runs[0].cells;
|
||||
assert_eq!(4, cells.len());
|
||||
assert_eq!(0, cells[0].x);
|
||||
assert_eq!(0, cells[1].x);
|
||||
assert_eq!(0, cells[2].x);
|
||||
assert_eq!(1, cells[3].x);
|
||||
}
|
||||
|
||||
// This test exists because the string it uses causes HarfBuzz to output a
|
||||
// non-monotonic run with our cluster level set to `characters`, which we need
|
||||
// to handle by tracking the max cluster for the run.
|
||||
#[test]
|
||||
fn shape_devanagari_string() {
|
||||
// We need a font that supports devanagari for this to work, if we can't
|
||||
// find Arial Unicode MS, which is a system font on macOS, we just skip
|
||||
// the test.
|
||||
let Some(mut ts) = test_shaper_with_discovered_font("Arial Unicode MS") else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Disable grapheme clustering
|
||||
let mut t = Term::new(30, 3);
|
||||
t.print("अपार्टमेंट");
|
||||
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
let cells = &runs[0].cells;
|
||||
assert_eq!(8, cells.len());
|
||||
let xs: Vec<u16> = cells.iter().map(|c| c.x).collect();
|
||||
assert_eq!(vec![0, 1, 2, 4, 4, 5, 5, 6], xs);
|
||||
}
|
||||
|
||||
// This test fails on Linux if you have the "Noto Sans Tai Tham" font installed
|
||||
// locally. Disabled upstream (`return error.SkipZigTest`).
|
||||
#[test]
|
||||
#[ignore = "skipped upstream: fails on Linux with Noto Sans Tai Tham installed"]
|
||||
fn shape_tai_tham_vowels_position_differs_from_advance() {
|
||||
// Note that while this test was necessary for CoreText, the old logic was
|
||||
// working for HarfBuzz. Still we keep it to ensure it has the correct
|
||||
// behavior.
|
||||
let Some(mut ts) = test_shaper_with_discovered_font("Noto Sans Tai Tham") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut t = Term::new(30, 3);
|
||||
t.print_clustered("\u{1a2F}\u{1a70}"); // ᨯ ᩰ
|
||||
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
let cells = &runs[0].cells;
|
||||
assert_eq!(2, cells.len());
|
||||
assert_eq!(0, cells[0].x);
|
||||
assert_eq!(0, cells[1].x);
|
||||
|
||||
// The first glyph renders in the next cell. We expect the x_offset
|
||||
// to equal the cell width. However, with FreeType the cell_width is
|
||||
// computed from ASCII glyphs, and Noto Sans Tai Tham only has the
|
||||
// space character in ASCII (with a 3px advance), so the cell_width
|
||||
// metric doesn't match the actual Tai Tham glyph positioning.
|
||||
assert_eq!(7, cells[0].x_offset);
|
||||
assert_eq!(0, cells[1].x_offset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_tibetan_characters() {
|
||||
// We need a font that has multiple glyphs for this codepoint to reproduce
|
||||
// the old broken behavior, and Noto Serif Tibetan is one of them. If we
|
||||
// can't find it we just skip the test.
|
||||
let Some(mut ts) = test_shaper_with_discovered_font("Noto Serif Tibetan") else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Enable grapheme clustering
|
||||
let mut t = Term::new(30, 3);
|
||||
t.print_clustered("\u{0f00}"); // ༀ
|
||||
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
let cells = &runs[0].cells;
|
||||
assert_eq!(2, cells.len());
|
||||
assert_eq!(0, cells[0].x);
|
||||
assert_eq!(0, cells[1].x);
|
||||
|
||||
// The second glyph renders at the correct location
|
||||
assert!(cells[1].x_offset < 2);
|
||||
}
|
||||
|
||||
// Disabled upstream (`return error.SkipZigTest`).
|
||||
#[test]
|
||||
#[ignore = "skipped upstream: fails on Linux with Noto Sans Tai Tham installed"]
|
||||
fn shape_tai_tham_letters_run_offset_y_differs_from_zero() {
|
||||
let Some(mut ts) = test_shaper_with_discovered_font("Noto Sans Tai Tham") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut t = Term::new(30, 3);
|
||||
// First grapheme cluster: HA, SAKOT. Second grapheme cluster,
|
||||
// combining with the first in a ligature: YA, U.
|
||||
t.print_clustered("\u{1a49}\u{1a60}\u{1a3f}\u{1a69}");
|
||||
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
let cells = &runs[0].cells;
|
||||
assert_eq!(3, cells.len());
|
||||
assert_eq!(0, cells[0].x);
|
||||
assert_eq!(0, cells[1].x);
|
||||
assert_eq!(0, cells[2].x); // U from second grapheme
|
||||
|
||||
// The U glyph renders at a y below zero
|
||||
assert_eq!(-3, cells[2].y_offset);
|
||||
}
|
||||
|
||||
// Disabled upstream (`return error.SkipZigTest`).
|
||||
#[test]
|
||||
#[ignore = "skipped upstream (error.SkipZigTest)"]
|
||||
fn shape_javanese_ligatures() {
|
||||
let Some(mut ts) = test_shaper_with_discovered_font("Noto Sans Javanese") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut t = Term::new(30, 3);
|
||||
// NA, PANGKON; then HA, vowel sign SUKU combining in a ligature.
|
||||
t.print_clustered("\u{a9a4}\u{a9c0}\u{a9b2}\u{a9b8}");
|
||||
|
||||
let cell_width = ts.grid.metrics().cell_width;
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
let cells = &runs[0].cells;
|
||||
assert_eq!(3, cells.len());
|
||||
assert_eq!(0, cells[0].x);
|
||||
assert_eq!(0, cells[1].x);
|
||||
assert_eq!(0, cells[2].x);
|
||||
|
||||
// The vowel sign SUKU renders with correct x_offset
|
||||
assert!(i64::from(cells[2].x_offset) > 3 * i64::from(cell_width));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_chakma_vowel_sign_with_ligature_vowel_sign_renders_first() {
|
||||
// We need a font that supports Chakma for this to work, if we can't find
|
||||
// Noto Sans Chakma Regular we just skip the test.
|
||||
let Some(mut ts) = test_shaper_with_discovered_font("Noto Sans Chakma") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut t = Term::new(30, 3);
|
||||
// BAA; TAA + Virama; YYAA + Vowel Sign U (ligature with the second).
|
||||
t.print_clustered("\u{1111d}\u{11116}\u{11133}\u{11120}\u{1112c}");
|
||||
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
let cells = &runs[0].cells;
|
||||
assert_eq!(4, cells.len());
|
||||
assert_eq!(0, cells[0].x);
|
||||
// See the giant "We need to reset the `cell_offset`" comment, but here
|
||||
// we should technically have the rest of these be `x` of 1, but that
|
||||
// would require going back in the stream to adjust past cells, and
|
||||
// we don't take on that complexity.
|
||||
assert_eq!(0, cells[1].x);
|
||||
assert_eq!(0, cells[2].x);
|
||||
assert_eq!(0, cells[3].x);
|
||||
|
||||
// The vowel sign U renders before the TAA:
|
||||
assert!(cells[1].x_offset < cells[2].x_offset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_bengali_ligatures_with_out_of_order_vowels() {
|
||||
// Whereas this test in CoreText had everything shaping into one giant
|
||||
// ligature, HarfBuzz splits it into a few clusters. It still looks okay
|
||||
// (see #10332).
|
||||
let Some(mut ts) = test_shaper_with_discovered_font("Arial Unicode MS") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut t = Term::new(30, 3);
|
||||
// RA + AA; SSA + Virama; TTA + Virama; RA + Vowel sign E.
|
||||
t.print_clustered("\u{09b0}\u{09be}\u{09b7}\u{09cd}\u{099f}\u{09cd}\u{09b0}\u{09c7}");
|
||||
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
let cells = &runs[0].cells;
|
||||
assert_eq!(8, cells.len());
|
||||
assert_eq!(0, cells[0].x);
|
||||
assert_eq!(0, cells[1].x);
|
||||
// Whereas CoreText puts everything all into the first cell (see the
|
||||
// corresponding test), HarfBuzz splits into two clusters.
|
||||
for c in &cells[2..] {
|
||||
assert_eq!(2, c.x);
|
||||
}
|
||||
|
||||
// The vowel sign E renders before the SSA:
|
||||
assert!(cells[2].x_offset < cells[3].x_offset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_box_glyphs() {
|
||||
let mut ts = test_shaper();
|
||||
|
||||
let mut t = Term::new(10, 3);
|
||||
t.print("\u{2500}\u{2501}"); // horiz line
|
||||
|
||||
let runs = run_row(&mut ts, &t, 0);
|
||||
assert_eq!(1, runs.len());
|
||||
assert_eq!(2, runs[0].hb_len);
|
||||
let cells = &runs[0].cells;
|
||||
assert_eq!(2, cells.len());
|
||||
assert_eq!(0x2500, cells[0].glyph_index);
|
||||
assert_eq!(0, cells[0].x);
|
||||
assert_eq!(0x2501, cells[1].glyph_index);
|
||||
assert_eq!(1, cells[1].x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_selection_boundary() {
|
||||
let mut ts = test_shaper();
|
||||
|
||||
let mut t = Term::new(10, 3);
|
||||
t.print("a1b2c3d4e5");
|
||||
let cells = t.row(0);
|
||||
|
||||
// Full line selection
|
||||
assert_eq!(1, count_runs(&mut ts, &cells, Some([0, 9]), None));
|
||||
// Offset x, goes to end of line selection
|
||||
assert_eq!(2, count_runs(&mut ts, &cells, Some([2, 9]), None));
|
||||
// Offset x, starts at beginning of line
|
||||
assert_eq!(2, count_runs(&mut ts, &cells, Some([0, 3]), None));
|
||||
// Selection only subset of line
|
||||
assert_eq!(3, count_runs(&mut ts, &cells, Some([1, 3]), None));
|
||||
// Selection only one character
|
||||
assert_eq!(3, count_runs(&mut ts, &cells, Some([1, 1]), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_cursor_boundary() {
|
||||
let mut ts = test_shaper();
|
||||
|
||||
let mut t = Term::new(10, 3);
|
||||
t.print("a1b2c3d4e5");
|
||||
let cells = t.row(0);
|
||||
|
||||
// No cursor is full line
|
||||
assert_eq!(1, count_runs(&mut ts, &cells, None, None));
|
||||
|
||||
// Cursor at index 0 is two runs
|
||||
assert_eq!(2, count_runs(&mut ts, &cells, None, Some(0)));
|
||||
// And without cursor splitting remains one
|
||||
assert_eq!(1, count_runs(&mut ts, &cells, None, None));
|
||||
|
||||
// Cursor at index 1 is three runs
|
||||
assert_eq!(3, count_runs(&mut ts, &cells, None, Some(1)));
|
||||
assert_eq!(1, count_runs(&mut ts, &cells, None, None));
|
||||
|
||||
// Cursor at last col is two runs
|
||||
assert_eq!(2, count_runs(&mut ts, &cells, None, Some(9)));
|
||||
assert_eq!(1, count_runs(&mut ts, &cells, None, None));
|
||||
|
||||
// The font break config disables cursor splitting.
|
||||
let mut opts = RunOptions::new(&ts.grid, &cells);
|
||||
opts.cursor_x = Some(1);
|
||||
opts.apply_break_config(ghostty_config::types_a::FontShapingBreak { cursor: false });
|
||||
assert_eq!(1, shape_all(&mut ts.shaper, opts).len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_cursor_boundary_and_colored_emoji() {
|
||||
let mut ts = test_shaper();
|
||||
|
||||
let mut t = Term::new(3, 10);
|
||||
t.print("\u{1F44D}\u{1F3FC}");
|
||||
let cells = t.row(0);
|
||||
|
||||
// No cursor is full line
|
||||
assert_eq!(1, count_runs(&mut ts, &cells, None, None));
|
||||
|
||||
// Cursor on emoji does not split it
|
||||
assert_eq!(1, count_runs(&mut ts, &cells, None, Some(0)));
|
||||
assert_eq!(1, count_runs(&mut ts, &cells, None, None));
|
||||
assert_eq!(1, count_runs(&mut ts, &cells, None, Some(1)));
|
||||
assert_eq!(1, count_runs(&mut ts, &cells, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_cell_attribute_change() {
|
||||
use ghostty_vt::sgr::Attribute;
|
||||
|
||||
let mut ts = test_shaper();
|
||||
|
||||
// Plain >= should shape into 1 run
|
||||
{
|
||||
let mut t = Term::new(10, 3);
|
||||
t.print(">=");
|
||||
assert_eq!(1, count_runs(&mut ts, &t.row(0), None, None));
|
||||
}
|
||||
|
||||
// Bold vs regular should split
|
||||
{
|
||||
let mut t = Term::new(3, 10);
|
||||
t.print(">");
|
||||
t.sgr(Attribute::Bold);
|
||||
t.print("=");
|
||||
assert_eq!(2, count_runs(&mut ts, &t.row(0), None, None));
|
||||
}
|
||||
|
||||
// Changing fg color should split
|
||||
{
|
||||
let mut t = Term::new(3, 10);
|
||||
t.fg_rgb(1, 2, 3);
|
||||
t.print(">");
|
||||
t.fg_rgb(3, 2, 1);
|
||||
t.print("=");
|
||||
assert_eq!(2, count_runs(&mut ts, &t.row(0), None, None));
|
||||
}
|
||||
|
||||
// Changing bg color should not split
|
||||
{
|
||||
let mut t = Term::new(3, 10);
|
||||
t.bg_rgb(1, 2, 3);
|
||||
t.print(">");
|
||||
t.bg_rgb(3, 2, 1);
|
||||
t.print("=");
|
||||
assert_eq!(1, count_runs(&mut ts, &t.row(0), None, None));
|
||||
}
|
||||
|
||||
// Same bg color should not split
|
||||
{
|
||||
let mut t = Term::new(3, 10);
|
||||
t.bg_rgb(1, 2, 3);
|
||||
t.print(">");
|
||||
t.print("=");
|
||||
assert_eq!(1, count_runs(&mut ts, &t.row(0), None, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Text shapers. Port of `font/shaper/`.
|
||||
//!
|
||||
//! `run.zig` (RunIterator over terminal rows) is deferred (F2).
|
||||
|
||||
pub mod cache;
|
||||
pub mod feature;
|
||||
pub mod harfbuzz;
|
||||
mod hb;
|
||||
pub mod noop;
|
||||
pub mod run;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_util;
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Text runs and the run iterator. Port of `font/shaper/run.zig`.
|
||||
//!
|
||||
//! A [`RunIterator`] walks one terminal row (given as a slice of
|
||||
//! [`RunCell`]) and yields [`TextRun`]s: maximal sequences of cells that can
|
||||
//! be shaped together (same font, same style apart from background, not
|
||||
//! crossing a selection or cursor boundary, ...). The codepoints of each run
|
||||
//! are fed to the shaper through the [`RunIteratorHook`] trait
|
||||
//! (`prepare` / `add_codepoint` / `finalize`).
|
||||
|
||||
#![allow(clippy::collapsible_if)]
|
||||
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
|
||||
use ghostty_config::types_a::FontShapingBreak;
|
||||
use ghostty_terminal::page::{Cell, ContentTag, Page, Wide};
|
||||
use ghostty_terminal::style::{Color, Style as TermStyle};
|
||||
|
||||
use crate::collection::Index;
|
||||
use crate::shared_grid::{Error as GridError, SharedGrid};
|
||||
use crate::{Presentation, Style};
|
||||
|
||||
/// The Kitty graphics unicode placeholder codepoint
|
||||
/// (`terminal.kitty.graphics.unicode.placeholder`).
|
||||
// TODO(integration): use the constant from ghostty-terminal once the kitty
|
||||
// graphics module lands there.
|
||||
pub const KITTY_PLACEHOLDER: u32 = 0x10EEEE;
|
||||
|
||||
/// A single text run. A text run is only valid for one Shaper instance and
|
||||
/// until the next run is created. A text run never goes across multiple
|
||||
/// rows in a terminal, so it is guaranteed to always be one line.
|
||||
///
|
||||
/// Deviation: the Zig struct carries a `grid: *SharedGrid` field. Here the
|
||||
/// grid is not part of the run; shapers take it explicitly
|
||||
/// (`harfbuzz::Shaper::shape_run(grid, run)`), which keeps `TextRun` `Copy`
|
||||
/// and lifetime-free.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TextRun {
|
||||
/// A unique hash for this run. This can be used to cache the shaping
|
||||
/// results. We don't provide a means to compare actual values if the
|
||||
/// hash is the same, so we should continue to improve this hash to
|
||||
/// lower the chance of hash collisions if they become a problem. If
|
||||
/// there are hash collisions, it would result in rendering issues but
|
||||
/// the core data would be correct.
|
||||
///
|
||||
/// The hash is position-independent within the row by using relative
|
||||
/// cluster positions. This allows identical runs in different positions
|
||||
/// to share the same cache entry, improving cache efficiency.
|
||||
pub hash: u64,
|
||||
|
||||
/// The offset in the row where this run started. This is added to the
|
||||
/// X position of the final shaped cells to get the absolute position
|
||||
/// in the row where they belong.
|
||||
pub offset: u16,
|
||||
|
||||
/// The total number of cells produced by this run.
|
||||
pub cells: u16,
|
||||
|
||||
/// The font index to use for the glyphs of this run.
|
||||
pub font_index: Index,
|
||||
}
|
||||
|
||||
/// One cell of a row to shape: the equivalent of one element of
|
||||
/// `terminal.RenderState.Cell` (`raw`, `grapheme`, `style`).
|
||||
// TODO(integration): replace with the render-state cell type from
|
||||
// ghostty-terminal once `RenderState` is ported there.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RunCell {
|
||||
/// The raw page cell.
|
||||
pub raw: Cell,
|
||||
/// The extra codepoints of the grapheme (empty unless
|
||||
/// `raw.has_grapheme()`).
|
||||
pub grapheme: Vec<u32>,
|
||||
/// The resolved style (default style if the cell has no styling).
|
||||
pub style: TermStyle,
|
||||
}
|
||||
|
||||
impl RunCell {
|
||||
/// Build the run cells for row `y` of a page.
|
||||
pub fn from_page_row(page: &Page, y: usize) -> Vec<RunCell> {
|
||||
let Some(cells) = page.get_cells(y) else {
|
||||
return Vec::new();
|
||||
};
|
||||
cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(x, &raw)| {
|
||||
let grapheme = if raw.has_grapheme() {
|
||||
page.cell_index(x, y)
|
||||
.ok()
|
||||
.and_then(|i| page.lookup_grapheme(i))
|
||||
.map(<[u32]>::to_vec)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let style = if raw.has_styling() {
|
||||
page.style(raw)
|
||||
} else {
|
||||
TermStyle::default()
|
||||
};
|
||||
RunCell {
|
||||
raw,
|
||||
grapheme,
|
||||
style,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for [`RunIterator`] (`shape.RunOptions`).
|
||||
pub struct RunOptions<'a> {
|
||||
/// The font state for the terminal screen. Cached values may be updated
|
||||
/// during shaping (the grid has interior locking).
|
||||
pub grid: &'a SharedGrid,
|
||||
|
||||
/// The cells for the row to shape.
|
||||
pub cells: &'a [RunCell],
|
||||
|
||||
/// The x boundaries of the selection in this row.
|
||||
pub selection: Option<[u16; 2]>,
|
||||
|
||||
/// The cursor position within this row. This is used to break shaping
|
||||
/// on cursor boundaries. This can be disabled by setting this to
|
||||
/// None.
|
||||
pub cursor_x: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'a> RunOptions<'a> {
|
||||
pub fn new(grid: &'a SharedGrid, cells: &'a [RunCell]) -> Self {
|
||||
RunOptions {
|
||||
grid,
|
||||
cells,
|
||||
selection: None,
|
||||
cursor_x: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the font break configuration to the run.
|
||||
pub fn apply_break_config(&mut self, config: FontShapingBreak) {
|
||||
if !config.cursor {
|
||||
self.cursor_x = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hooks a shaper provides to the run iterator
|
||||
/// (`font.Shaper.RunIteratorHook`).
|
||||
pub trait RunIteratorHook {
|
||||
/// Called before a new run is started.
|
||||
fn prepare(&mut self);
|
||||
/// Called for each codepoint of the run, with the (run relative) cluster.
|
||||
fn add_codepoint(&mut self, cp: u32, cluster: u32);
|
||||
/// Called when the run is complete.
|
||||
fn finalize(&mut self);
|
||||
}
|
||||
|
||||
impl RunIteratorHook for crate::shaper::harfbuzz::Shaper {
|
||||
fn prepare(&mut self) {
|
||||
Self::prepare(self);
|
||||
}
|
||||
fn add_codepoint(&mut self, cp: u32, cluster: u32) {
|
||||
Self::add_codepoint(self, cp, cluster);
|
||||
}
|
||||
fn finalize(&mut self) {
|
||||
Self::finalize(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl RunIteratorHook for crate::shaper::noop::Shaper {
|
||||
fn prepare(&mut self) {
|
||||
Self::prepare(self);
|
||||
}
|
||||
fn add_codepoint(&mut self, cp: u32, cluster: u32) {
|
||||
Self::add_codepoint(self, cp, cluster);
|
||||
}
|
||||
fn finalize(&mut self) {
|
||||
Self::finalize(self);
|
||||
}
|
||||
}
|
||||
|
||||
/// RunIterator is an iterator that yields text runs. Unlike Zig, which
|
||||
/// stores the hooks inside the iterator, the shaper is passed to
|
||||
/// [`RunIterator::next`] so it can be used (to shape) between calls.
|
||||
pub struct RunIterator<'a> {
|
||||
pub opts: RunOptions<'a>,
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl<'a> RunIterator<'a> {
|
||||
pub fn new(opts: RunOptions<'a>) -> Self {
|
||||
RunIterator { opts, i: 0 }
|
||||
}
|
||||
|
||||
pub fn next(&mut self, hooks: &mut dyn RunIteratorHook) -> Result<Option<TextRun>, GridError> {
|
||||
let cells = self.opts.cells;
|
||||
|
||||
// Trim the right side of a row that might be empty
|
||||
let max: usize = (0..cells.len())
|
||||
.rev()
|
||||
.find(|&i| !cells[i].raw.is_empty())
|
||||
.map_or(0, |i| i + 1);
|
||||
|
||||
// Invisible cells don't have any glyphs rendered,
|
||||
// so we explicitly skip them in the shaping process.
|
||||
while self.i < max
|
||||
&& (cells[self.i].raw.has_styling() && cells[self.i].style.flags.invisible)
|
||||
{
|
||||
self.i += 1;
|
||||
}
|
||||
|
||||
// We're over at the max
|
||||
if self.i >= max {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Track the font for our current run
|
||||
let mut current_font = Index::default();
|
||||
|
||||
// Allow the hook to prepare
|
||||
hooks.prepare();
|
||||
|
||||
// Initialize our hash for this run.
|
||||
let mut hasher = DefaultHasher::new();
|
||||
|
||||
// Let's get our style that we'll expect for the run.
|
||||
let style: TermStyle = if cells[self.i].raw.has_styling() {
|
||||
cells[self.i].style
|
||||
} else {
|
||||
TermStyle::default()
|
||||
};
|
||||
|
||||
// Go through cell by cell and accumulate while we build our run.
|
||||
let start = self.i;
|
||||
let end = 'run: {
|
||||
for j in start..max {
|
||||
// Use relative cluster positions (offset from run start) to make
|
||||
// the shaping cache position-independent. This ensures that runs
|
||||
// with identical content but different starting positions in the
|
||||
// row produce the same hash, enabling cache reuse.
|
||||
let cluster = (j - start) as u32;
|
||||
let cell = &cells[j];
|
||||
let raw = cell.raw;
|
||||
|
||||
// If we have a selection and we're at a boundary point, then
|
||||
// we break the run here.
|
||||
if let Some(bounds) = self.opts.selection {
|
||||
if j > start {
|
||||
let (b0, b1) = (usize::from(bounds[0]), usize::from(bounds[1]));
|
||||
if b0 > 0 && j == b0 {
|
||||
break 'run j;
|
||||
}
|
||||
if b1 > 0 && j == b1 + 1 {
|
||||
break 'run j;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we're a spacer, then we ignore it
|
||||
match raw.wide() {
|
||||
Wide::Narrow | Wide::Wide => {}
|
||||
Wide::SpacerHead | Wide::SpacerTail => continue,
|
||||
}
|
||||
|
||||
// If our cell attributes are changing, then we split the run.
|
||||
// This prevents a single glyph for ">=" to be rendered with
|
||||
// one color when the two components have different styling.
|
||||
if j > start {
|
||||
let prev_cell = cells[j - 1].raw;
|
||||
|
||||
// If the prev cell and this cell are both plain
|
||||
// codepoints then we check if they are commonly "bad"
|
||||
// ligatures and spit the run if they are.
|
||||
if prev_cell.content_tag() == ContentTag::Codepoint
|
||||
&& raw.content_tag() == ContentTag::Codepoint
|
||||
{
|
||||
let prev_cp = prev_cell.codepoint();
|
||||
let cp = raw.codepoint();
|
||||
match char::from_u32(prev_cp) {
|
||||
// fl, fi
|
||||
Some('f') if cp == u32::from('l') || cp == u32::from('i') => {
|
||||
break 'run j;
|
||||
}
|
||||
// st
|
||||
Some('s') if cp == u32::from('t') => break 'run j,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// If the style is exactly the change then fast path out.
|
||||
// Otherwise the style is different. We allow differing
|
||||
// background styles but any other change results in a
|
||||
// new run.
|
||||
if prev_cell.style_id() != raw.style_id() {
|
||||
let c1 = comparable_style(style);
|
||||
let c2 = comparable_style(if raw.has_styling() {
|
||||
cell.style
|
||||
} else {
|
||||
TermStyle::default()
|
||||
});
|
||||
if c1 != c2 {
|
||||
break 'run j;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text runs break when font styles change so we need to get
|
||||
// the proper style.
|
||||
let font_style = if style.flags.bold {
|
||||
if style.flags.italic {
|
||||
Style::BoldItalic
|
||||
} else {
|
||||
Style::Bold
|
||||
}
|
||||
} else if style.flags.italic {
|
||||
Style::Italic
|
||||
} else {
|
||||
Style::Regular
|
||||
};
|
||||
|
||||
// Determine the presentation format for this glyph.
|
||||
let presentation: Option<Presentation> = if raw.has_grapheme() {
|
||||
// We only check the FIRST codepoint because I believe the
|
||||
// presentation format must be directly adjacent to the codepoint.
|
||||
let cps = &cell.grapheme;
|
||||
assert_ne!(cps.len(), 0);
|
||||
match cps[0] {
|
||||
0xFE0E => Some(Presentation::Text),
|
||||
0xFE0F => Some(Presentation::Emoji),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
// If we're not a grapheme, our individual char could be
|
||||
// an emoji so we want to check if we expect emoji presentation.
|
||||
// The font grid index_for_codepoint we use below will do this
|
||||
// automatically.
|
||||
None
|
||||
};
|
||||
|
||||
// If our cursor is on this line then we break the run around the
|
||||
// cursor. This means that any row with a cursor has at least
|
||||
// three breaks: before, exactly the cursor, and after.
|
||||
//
|
||||
// We do not break a cell that is exactly the grapheme. If there
|
||||
// are cells following that contain joiners, we allow those to
|
||||
// break. This creates an effect where hovering over an emoji
|
||||
// such as a skin-tone emoji is fine, but hovering over the
|
||||
// joiners will show the joiners allowing you to modify the
|
||||
// emoji.
|
||||
if !raw.has_grapheme() {
|
||||
if let Some(cursor_x) = self.opts.cursor_x {
|
||||
// Exactly: self.i is the cursor and we iterated once. This
|
||||
// means that we started exactly at the cursor and did at
|
||||
// exactly one iteration. Why exactly one? Because we may
|
||||
// start at our cursor but do many if our cursor is exactly
|
||||
// on an emoji.
|
||||
if start == cursor_x && j == start + 1 {
|
||||
break 'run j;
|
||||
}
|
||||
|
||||
// Before: up to and not including the cursor. This means
|
||||
// that we started before the cursor (self.i < cursor_x)
|
||||
// and j is now at the cursor meaning we haven't yet processed
|
||||
// the cursor.
|
||||
if start < cursor_x && j == cursor_x {
|
||||
assert!(j > 0);
|
||||
break 'run j;
|
||||
}
|
||||
|
||||
// After: after the cursor. We don't need to do anything
|
||||
// special, we just let the run complete.
|
||||
}
|
||||
}
|
||||
|
||||
// We need to find a font that supports this character. If
|
||||
// there are additional zero-width codepoints (to form a single
|
||||
// grapheme, i.e. combining characters), we need to find a font
|
||||
// that supports all of them.
|
||||
let (font_idx, fallback): (Index, Option<u32>) = 'font_info: {
|
||||
// If we find a font that supports this entire grapheme
|
||||
// then we use that.
|
||||
if let Some(idx) = self.index_for_cell(cell, font_style, presentation)? {
|
||||
break 'font_info (idx, None);
|
||||
}
|
||||
|
||||
// Otherwise we need a fallback character. Prefer the
|
||||
// official replacement character.
|
||||
if let Some(idx) = self.opts.grid.get_index(0xFFFD, font_style, presentation)? {
|
||||
break 'font_info (idx, Some(0xFFFD));
|
||||
}
|
||||
|
||||
// Fallback to space
|
||||
if let Some(idx) =
|
||||
self.opts
|
||||
.grid
|
||||
.get_index(u32::from(' '), font_style, presentation)?
|
||||
{
|
||||
break 'font_info (idx, Some(u32::from(' ')));
|
||||
}
|
||||
|
||||
// We can't render at all. This is a bug, we should always
|
||||
// have a font that can render a space.
|
||||
unreachable!("no font can render a space");
|
||||
};
|
||||
|
||||
if j == start {
|
||||
current_font = font_idx;
|
||||
}
|
||||
|
||||
// If our fonts are not equal, then we're done with our run.
|
||||
if font_idx != current_font {
|
||||
break 'run j;
|
||||
}
|
||||
|
||||
// If we're a fallback character, add that and continue; we
|
||||
// don't want to add the entire grapheme.
|
||||
if let Some(cp) = fallback {
|
||||
add_codepoint(hooks, &mut hasher, cp, cluster);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we're a Kitty unicode placeholder then we add a blank.
|
||||
if raw.codepoint() == KITTY_PLACEHOLDER {
|
||||
add_codepoint(hooks, &mut hasher, u32::from(' '), cluster);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add all the codepoints for our grapheme
|
||||
add_codepoint(
|
||||
hooks,
|
||||
&mut hasher,
|
||||
if raw.codepoint() == 0 {
|
||||
u32::from(' ')
|
||||
} else {
|
||||
raw.codepoint()
|
||||
},
|
||||
cluster,
|
||||
);
|
||||
if raw.has_grapheme() {
|
||||
for &cp in &cell.grapheme {
|
||||
// Do not send presentation modifiers
|
||||
if cp == 0xFE0E || cp == 0xFE0F {
|
||||
continue;
|
||||
}
|
||||
add_codepoint(hooks, &mut hasher, cp, cluster);
|
||||
}
|
||||
}
|
||||
}
|
||||
max
|
||||
};
|
||||
|
||||
// Finalize our buffer
|
||||
hooks.finalize();
|
||||
|
||||
// Add our length to the hash as an additional mechanism to avoid collisions
|
||||
(end - start).hash(&mut hasher);
|
||||
|
||||
// Add our font index
|
||||
current_font.hash(&mut hasher);
|
||||
|
||||
// Move our cursor.
|
||||
self.i = end;
|
||||
|
||||
Ok(Some(TextRun {
|
||||
hash: hasher.finish(),
|
||||
offset: start as u16,
|
||||
cells: (end - start) as u16,
|
||||
font_index: current_font,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Find a font index that supports the grapheme for the given cell,
|
||||
/// or None if no such font exists.
|
||||
///
|
||||
/// This is used to find a font that supports the entire grapheme.
|
||||
/// We look for fonts that support each individual codepoint and then
|
||||
/// find the common font amongst all candidates.
|
||||
fn index_for_cell(
|
||||
&self,
|
||||
cell: &RunCell,
|
||||
style: Style,
|
||||
presentation: Option<Presentation>,
|
||||
) -> Result<Option<Index>, GridError> {
|
||||
let raw = cell.raw;
|
||||
let grid = self.opts.grid;
|
||||
if raw.is_empty() || raw.codepoint() == 0 || raw.codepoint() == KITTY_PLACEHOLDER {
|
||||
return grid.get_index(u32::from(' '), style, presentation);
|
||||
}
|
||||
|
||||
// Get the font index for the primary codepoint.
|
||||
let primary_cp = raw.codepoint();
|
||||
let Some(primary) = grid.get_index(primary_cp, style, presentation)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Easy, and common: we aren't a multi-codepoint grapheme, so
|
||||
// we just return whatever index for the cell codepoint.
|
||||
if !raw.has_grapheme() {
|
||||
return Ok(Some(primary));
|
||||
}
|
||||
|
||||
// If this is a grapheme, we need to find a font that supports
|
||||
// all of the codepoints in the grapheme.
|
||||
let graphemes = &cell.grapheme;
|
||||
let mut candidates: Vec<Index> = Vec::with_capacity(graphemes.len() + 1);
|
||||
candidates.push(primary);
|
||||
|
||||
for &cp in graphemes {
|
||||
// Ignore Emoji ZWJs
|
||||
if cp == 0xFE0E || cp == 0xFE0F || cp == 0x200D {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find a font that supports this codepoint. If none support this
|
||||
// then the whole grapheme can't be rendered so we return None.
|
||||
//
|
||||
// We explicitly do not require the additional grapheme components
|
||||
// to support the base presentation, since it is common for emoji
|
||||
// fonts to support the base emoji with emoji presentation but not
|
||||
// certain ZWJ-combined characters like the male and female signs.
|
||||
let Some(idx) = grid.get_index(cp, style, None)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
candidates.push(idx);
|
||||
}
|
||||
|
||||
// We need to find a candidate that has ALL of our codepoints
|
||||
for &idx in &candidates {
|
||||
if !grid.has_codepoint(idx, primary_cp, presentation) {
|
||||
continue;
|
||||
}
|
||||
let all = graphemes.iter().all(|&cp| {
|
||||
// Ignore Emoji ZWJs
|
||||
cp == 0xFE0E || cp == 0xFE0F || cp == 0x200D || grid.has_codepoint(idx, cp, None)
|
||||
});
|
||||
if all {
|
||||
// We have a candidate that supports all of our codepoints.
|
||||
return Ok(Some(idx));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn add_codepoint(
|
||||
hooks: &mut dyn RunIteratorHook,
|
||||
hasher: &mut DefaultHasher,
|
||||
cp: u32,
|
||||
cluster: u32,
|
||||
) {
|
||||
cp.hash(hasher);
|
||||
cluster.hash(hasher);
|
||||
hooks.add_codepoint(cp, cluster);
|
||||
}
|
||||
|
||||
/// Returns a style that when compared must be identical for a run to
|
||||
/// continue.
|
||||
fn comparable_style(style: TermStyle) -> TermStyle {
|
||||
let mut s = style;
|
||||
|
||||
// We allow background colors to differ because we'll just paint the
|
||||
// cell background whatever the style is, and wherever the glyph
|
||||
// lands on top of it will be the color of the glyph.
|
||||
s.bg_color = Color::None;
|
||||
|
||||
s
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Test helpers shared by the shaper tests (the Zig `testShaper*` helpers
|
||||
//! plus a tiny terminal emulation on top of `ghostty_terminal::screen::Screen`).
|
||||
//!
|
||||
//! TODO(integration): the Zig tests drive a full `Terminal` through a VT
|
||||
//! stream and read a `RenderState`. `Terminal` is not available in
|
||||
//! ghostty-terminal yet, so these tests print through `Screen` instead
|
||||
//! (`Term`), which covers everything the tests need (text, grapheme
|
||||
//! clusters, SGR, cursor movement).
|
||||
|
||||
#![allow(clippy::collapsible_if)]
|
||||
|
||||
use ghostty_terminal::page::Cell;
|
||||
use ghostty_terminal::point::{Coordinate, Point};
|
||||
use ghostty_terminal::screen::{Options, Screen};
|
||||
use ghostty_vt::color::Rgb;
|
||||
use ghostty_vt::sgr::Attribute;
|
||||
|
||||
use crate::codepoint_resolver::CodepointResolver;
|
||||
use crate::collection::{AddOptions, Collection, LoadOptions, SizeAdjustment};
|
||||
use crate::discovery::{Descriptor, Fontconfig};
|
||||
use crate::face::{DesiredSize, FontData, Options as FaceOptions};
|
||||
use crate::shaper::harfbuzz::Shaper;
|
||||
use crate::shaper::run::{RunCell, RunIterator, RunOptions};
|
||||
use crate::{Face, Library, SharedGrid, Style, embedded, shape};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum TestFont {
|
||||
Inconsolata,
|
||||
MonaspaceNeon,
|
||||
Arabic,
|
||||
}
|
||||
|
||||
pub(crate) struct TestShaper {
|
||||
pub shaper: Shaper,
|
||||
pub grid: SharedGrid,
|
||||
}
|
||||
|
||||
fn face_opts() -> FaceOptions {
|
||||
FaceOptions::new(DesiredSize::new(12.0))
|
||||
}
|
||||
|
||||
fn add_regular(c: &mut Collection, face: Face) {
|
||||
c.add(
|
||||
face,
|
||||
AddOptions {
|
||||
style: Style::Regular,
|
||||
fallback: false,
|
||||
size_adjustment: SizeAdjustment::None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Helper to return a fully initialized shaper.
|
||||
pub(crate) fn test_shaper() -> TestShaper {
|
||||
test_shaper_with_font(TestFont::Inconsolata)
|
||||
}
|
||||
|
||||
pub(crate) fn test_shaper_with_font(font_req: TestFont) -> TestShaper {
|
||||
let test_font = match font_req {
|
||||
TestFont::Inconsolata => embedded::INCONSOLATA,
|
||||
TestFont::MonaspaceNeon => embedded::MONASPACE_NEON,
|
||||
TestFont::Arabic => embedded::ARABIC,
|
||||
};
|
||||
|
||||
let lib = Library::init().unwrap();
|
||||
let mut c = Collection::new();
|
||||
c.load_options = Some(LoadOptions::new(lib.clone()));
|
||||
|
||||
add_regular(
|
||||
&mut c,
|
||||
Face::init(&lib, FontData::Static(test_font), face_opts()).unwrap(),
|
||||
);
|
||||
add_regular(
|
||||
&mut c,
|
||||
Face::init(&lib, FontData::Static(embedded::EMOJI), face_opts()).unwrap(),
|
||||
);
|
||||
add_regular(
|
||||
&mut c,
|
||||
Face::init(&lib, FontData::Static(embedded::EMOJI_TEXT), face_opts()).unwrap(),
|
||||
);
|
||||
|
||||
let grid = SharedGrid::new(CodepointResolver::new(c)).unwrap();
|
||||
let shaper = Shaper::new(&shape::Options {
|
||||
// Some of our tests rely on dlig being enabled by default
|
||||
features: vec!["dlig".to_string()],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
TestShaper { shaper, grid }
|
||||
}
|
||||
|
||||
/// Returns None (the test is skipped, like `error.SkipZigTest`) if the font
|
||||
/// can't be discovered.
|
||||
pub(crate) fn test_shaper_with_discovered_font(font_req: &str) -> Option<TestShaper> {
|
||||
let lib = Library::init().unwrap();
|
||||
let mut c = Collection::new();
|
||||
c.load_options = Some(LoadOptions::new(lib.clone()));
|
||||
|
||||
// Discover and add our font to the collection.
|
||||
{
|
||||
let disco = Fontconfig::init();
|
||||
let mut it = disco
|
||||
.discover(&Descriptor {
|
||||
family: Some(font_req.to_string()),
|
||||
size: 12.0,
|
||||
monospace: false,
|
||||
..Descriptor::default()
|
||||
})
|
||||
.ok()?;
|
||||
let mut face = it.next().ok()??;
|
||||
|
||||
// Check which font was discovered - skip if it doesn't match the request
|
||||
let name = face.name().unwrap_or_else(|_| "(unknown)".to_string());
|
||||
if !name.contains(font_req) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let loaded = face.load(&lib, face_opts()).ok()?;
|
||||
add_regular(&mut c, loaded);
|
||||
}
|
||||
|
||||
let grid = SharedGrid::new(CodepointResolver::new(c)).unwrap();
|
||||
let shaper = Shaper::new(&shape::Options::default()).unwrap();
|
||||
Some(TestShaper { shaper, grid })
|
||||
}
|
||||
|
||||
/// A minimal terminal on top of a [`Screen`].
|
||||
pub(crate) struct Term {
|
||||
pub screen: Screen,
|
||||
}
|
||||
|
||||
impl Term {
|
||||
pub fn new(cols: u16, rows: u16) -> Term {
|
||||
Term {
|
||||
screen: Screen::new(Options::new(cols, rows, 0)).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a string with grapheme clustering disabled (the default of
|
||||
/// `Terminal`: `grapheme_cluster` mode off). Zero-width codepoints
|
||||
/// still combine with the previous cell.
|
||||
pub fn print(&mut self, s: &str) {
|
||||
self.print_mode(s, false);
|
||||
}
|
||||
|
||||
/// Print a string with grapheme clustering enabled
|
||||
/// (`t.modes.set(.grapheme_cluster, true)`): codepoints that do not
|
||||
/// break the grapheme cluster are attached to the cell of the cluster's
|
||||
/// first codepoint.
|
||||
pub fn print_clustered(&mut self, s: &str) {
|
||||
self.print_mode(s, true);
|
||||
}
|
||||
|
||||
fn print_mode(&mut self, s: &str, clustering: bool) {
|
||||
let mut state = ghostty_unicode::BreakState::default();
|
||||
let mut prev: Option<u32> = None;
|
||||
let mut base: Option<ghostty_terminal::page_list::Pin> = None;
|
||||
for ch in s.chars() {
|
||||
let cp = ch as u32;
|
||||
if clustering {
|
||||
if let (Some(p), Some(pin)) = (prev, base) {
|
||||
if !ghostty_unicode::grapheme_break(p, cp, &mut state) {
|
||||
let page = self.screen.page_mut(pin.node);
|
||||
page.append_grapheme(usize::from(pin.x), usize::from(pin.y), cp)
|
||||
.unwrap();
|
||||
prev = Some(cp);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A new cluster starts at the cursor (wide chars never wrap in
|
||||
// these tests).
|
||||
base = Some(self.screen.cursor_pin());
|
||||
state = ghostty_unicode::BreakState::default();
|
||||
self.screen
|
||||
.test_write_string(ch.encode_utf8(&mut [0; 4]))
|
||||
.unwrap();
|
||||
prev = Some(cp);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sgr(&mut self, attr: Attribute<'_>) {
|
||||
self.screen.set_attribute(attr).unwrap();
|
||||
}
|
||||
|
||||
pub fn fg_rgb(&mut self, r: u8, g: u8, b: u8) {
|
||||
self.sgr(Attribute::DirectColorFg(Rgb { r, g, b }));
|
||||
}
|
||||
|
||||
pub fn bg_rgb(&mut self, r: u8, g: u8, b: u8) {
|
||||
self.sgr(Attribute::DirectColorBg(Rgb { r, g, b }));
|
||||
}
|
||||
|
||||
pub fn cursor_right(&mut self, n: u16) {
|
||||
self.screen.cursor_right(n);
|
||||
}
|
||||
|
||||
/// Mutable access to the cell at (x, y) of the active area.
|
||||
pub fn cell_mut(&mut self, x: u16, y: u32) -> &mut Cell {
|
||||
self.screen
|
||||
.pages
|
||||
.get_cell_mut(Point::Active(Coordinate { x, y }))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The run cells of row `y` of the active area (`state.row_data.get(y).cells`).
|
||||
pub fn row(&self, y: u32) -> Vec<RunCell> {
|
||||
let pin = self
|
||||
.screen
|
||||
.pages
|
||||
.pin(Point::Active(Coordinate { x: 0, y }))
|
||||
.unwrap();
|
||||
RunCell::from_page_row(self.screen.page(pin.node), usize::from(pin.y))
|
||||
}
|
||||
}
|
||||
|
||||
/// One iterated run together with its shaped cells and the number of
|
||||
/// codepoints that were in the HarfBuzz buffer before shaping.
|
||||
pub(crate) struct Shaped {
|
||||
pub run: crate::shape::TextRun,
|
||||
pub hb_len: usize,
|
||||
pub cells: Vec<shape::Cell>,
|
||||
}
|
||||
|
||||
/// Iterate all runs of `opts`, shaping each one.
|
||||
pub(crate) fn shape_all(shaper: &mut Shaper, opts: RunOptions<'_>) -> Vec<Shaped> {
|
||||
let mut it = RunIterator::new(opts);
|
||||
let mut out = Vec::new();
|
||||
while let Some(run) = it.next(shaper).unwrap() {
|
||||
let hb_len = shaper.hb_buf_len();
|
||||
let cells = shaper.shape_run(it.opts.grid, &run).unwrap().to_vec();
|
||||
out.push(Shaped { run, hb_len, cells });
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in new issue
Block a user