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
b64a2886ec
commit
7b408257c4
19 files changed
+3204
-554
No files matched your search
@@ -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