refactor(renderer): add family-aware chrome text metrics

This commit is contained in:
Fini 2026-08-07 01:53:00 +08:00
parent 599ab11f6c
commit 73732d4edb
2 changed files with 396 additions and 0 deletions

View file

@ -0,0 +1,207 @@
//! Test-only `RenderBackend` whose named-family measurement is 40% wider
//! than its family-blind one — the shape of the real native gap, where
//! `measure_text` resolves the bundled Roboto but a `system-ui` run paints
//! as `.AppleSystemUIFont` (SF Pro).
//!
//! Every other test backend measures blind and family-aware identically, so
//! a widget that fits / centres / sizes against `RenderBackend::measure_text`
//! looks perfect under them and shears on a real machine. Painting a panel
//! into this backend reproduces the machine: a fitter that measured blind
//! emits a string too wide for its column, and
//! [`PaintedRun::width_in_paint_family`] catches it.
//!
//! The cross-panel guard in `text_metrics_paint_tests` is the intended
//! consumer — assert every captured run fits the column it was painted into.
use crate::{Color, Point2D, Rect, RenderBackend, TextLayout};
/// Advance as a fraction of font size for the backend's *default* typeface —
/// what `measure_text` reports. Matches jian's own fallback heuristic
/// (`Painter::measure_text`: `font_size * 0.55` per ASCII char, full size for
/// everything else) so the blind number here is the number a font-less
/// environment really produces.
const BLIND_ASCII_RATIO: f32 = 0.55;
/// Same, for any *named* family — what `draw_text` will actually paint.
/// Deliberately 40% wider, the shape of the Roboto → SF Pro gap.
const FAMILY_ASCII_RATIO: f32 = 0.77;
/// Non-ASCII advances are ~1em, and they are the SAME in both faces on
/// purpose: a CJK / Cyrillic / Devanagari glyph resolves through the same
/// system fallback whichever Latin family the run names, so there is no gap
/// to model there. The Roboto → SF Pro gap is a Latin-advance phenomenon,
/// and inflating non-Latin here would manufacture failures no machine shows.
const WIDE_RATIO: f32 = 1.0;
fn width(text: &str, font_size: f32, ascii_ratio: f32) -> f32 {
text.chars()
.map(|c| {
font_size
* if c.is_ascii() {
ascii_ratio
} else {
WIDE_RATIO
}
})
.sum()
}
/// One `draw_text` call, with everything needed to re-measure it the way it
/// was painted.
#[derive(Debug, Clone)]
pub(crate) struct PaintedRun {
pub(crate) text: String,
pub(crate) family: String,
pub(crate) font_size: f32,
/// Captured so a future guard can measure a weighted run in its own
/// face; the width model here is weight-independent.
#[allow(dead_code)]
pub(crate) font_weight: u16,
/// Origin passed to `draw_text` — x is the run's left edge.
pub(crate) origin: Point2D,
/// Innermost clip in effect when the run was drawn, if any. A run wider
/// than this is SHEARED on screen — glyphs cut mid-stroke with no
/// ellipsis, which is the exact symptom this whole guard exists for.
pub(crate) clip: Option<Rect>,
/// Advance ratio the painting backend charges a named family.
painted_ascii_ratio: f32,
}
impl PaintedRun {
/// Width this run occupies on screen, measured in the family it names.
pub(crate) fn width_in_paint_family(&self) -> f32 {
let ratio = if self.family.is_empty() {
BLIND_ASCII_RATIO
} else {
self.painted_ascii_ratio
};
width(&self.text, self.font_size, ratio)
}
/// Right edge of the painted run.
pub(crate) fn right_edge(&self) -> f32 {
self.origin.x + self.width_in_paint_family()
}
/// Whether the run spills past `container` or past its own clip. Both
/// read the same on screen: text that does not fit the box it was put in.
fn spills(&self, container: Rect) -> bool {
let (mut left, mut right) = (container.origin.x, container.origin.x + container.size.x);
if let Some(clip) = self.clip {
left = left.max(clip.origin.x);
right = right.min(clip.origin.x + clip.size.x);
}
self.origin.x < left - 0.01 || self.right_edge() > right + 0.01
}
}
pub(crate) struct FamilyGapBackend {
/// Every `draw_text`, in paint order.
pub(crate) runs: Vec<PaintedRun>,
/// Live clip stack — `save` / `restore` / `clip_rect` are modelled so a
/// run's recorded clip is the one actually in effect when it was drawn.
clip_stack: Vec<Option<Rect>>,
/// Advance ratio charged for a NAMED family. `FAMILY_ASCII_RATIO` is the
/// real machine; `BLIND_ASCII_RATIO` is the control where the two faces
/// agree, which is what every other test backend models.
family_ascii_ratio: f32,
}
impl Default for FamilyGapBackend {
fn default() -> Self {
Self {
runs: Vec::new(),
clip_stack: vec![None],
family_ascii_ratio: FAMILY_ASCII_RATIO,
}
}
}
impl FamilyGapBackend {
/// The control: named families measure exactly like the default face, so
/// nothing a family-aware fitter does can change the outcome. Paint a
/// panel into this and into [`Self::default`] and diff — see
/// `text_metrics_paint_tests`.
pub(crate) fn uniform() -> Self {
Self {
family_ascii_ratio: BLIND_ASCII_RATIO,
..Self::default()
}
}
/// Runs that do not fit the box they were painted into — either
/// `container` or, where one is in effect, their own clip.
pub(crate) fn overflowing(&self, container: Rect) -> Vec<&PaintedRun> {
self.runs
.iter()
.filter(|run| run.spills(container))
.collect()
}
fn clip(&self) -> Option<Rect> {
self.clip_stack.last().copied().flatten()
}
}
impl RenderBackend for FamilyGapBackend {
fn begin_frame(&mut self) {}
fn end_frame(&mut self) {}
fn fill_rect(&mut self, _: Rect, _: Color) {}
fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {}
fn draw_text(&mut self, layout: &TextLayout, point: Point2D) {
for run in layout.runs() {
self.runs.push(PaintedRun {
text: run.content.clone(),
family: run.font_family.clone(),
font_size: run.font_size,
font_weight: run.font_weight,
origin: Point2D::new(point.x + run.origin.x, point.y + run.origin.y),
clip: self.clip(),
painted_ascii_ratio: self.family_ascii_ratio,
});
}
}
fn clip_rect(&mut self, rect: Rect) {
let merged = match self.clip() {
Some(current) => intersect_x(current, rect),
None => rect,
};
if let Some(top) = self.clip_stack.last_mut() {
*top = Some(merged);
}
}
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {}
fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {}
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {}
fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {}
fn measure_text(&mut self, text: &str, font_size: f32) -> f32 {
width(text, font_size, BLIND_ASCII_RATIO)
}
fn measure_text_family(&mut self, text: &str, font_size: f32, family: &str) -> f32 {
let ratio = if family.is_empty() {
BLIND_ASCII_RATIO
} else {
self.family_ascii_ratio
};
width(text, font_size, ratio)
}
fn save(&mut self) {
self.clip_stack.push(self.clip());
}
fn restore(&mut self) {
if self.clip_stack.len() > 1 {
self.clip_stack.pop();
}
}
fn translate(&mut self, _: Point2D) {}
fn resize(&mut self, _: u32, _: u32) {}
fn dpi_scale(&self) -> f32 {
1.0
}
}
/// Horizontal intersection of two clips. Only x matters here — every guard
/// this backend serves asks "does the text fit its column".
fn intersect_x(a: Rect, b: Rect) -> Rect {
let left = a.origin.x.max(b.origin.x);
let right = (a.origin.x + a.size.x).min(b.origin.x + b.size.x);
Rect::xywh(left, a.origin.y, (right - left).max(0.0), a.size.y)
}

View file

@ -0,0 +1,189 @@
//! Family-aware text measurement for editor chrome.
//!
//! **Chrome code must never call `RenderBackend::measure_text` directly.**
//! That call is family-BLIND: it resolves whatever the backend's default
//! typeface is (the bundled Roboto on native, the CanvasKit default in the
//! browser), while every chrome string is *drawn* as a named run —
//! [`CHROME_FONT_FAMILY`] — which native resolves through the system
//! `FontMgr` (`.AppleSystemUIFont` / SF Pro on macOS) and the browser
//! resolves through CSS font matching. jian says as much on the trait
//! itself (`jian_widgets::painter::Painter::measure_text_family`).
//!
//! SF Pro is wider than Roboto at the same point size, so a family-blind
//! measurement *under-reports* the painted width. The failure is silent by
//! construction:
//!
//! - an ellipsizer believes a string fits, emits no `…`, and the content
//! clip shears the last glyph in half at the column edge;
//! - a `(container - measured) / 2` centring lands the run visibly left of
//! centre;
//! - a container sized as `measured + padding` (a tooltip bubble, a pill,
//! a chip) is born too narrow to hold its own label;
//! - a caret drawn at `measure(&text[..pos])` drifts away from the glyph
//! the user is editing, further with every character.
//!
//! None of those raise an error, and none of them reproduce under the test
//! backends (whose blind and family-aware measurements agree), which is why
//! this class of bug is only ever found by eye on a real machine.
//!
//! Everything here routes through `measure_text_family` with the family the
//! run is actually painted in. Reach for [`measure_chrome`] where you used
//! to reach for `measure_text`, [`fit_chrome`] to ellipsize, and
//! [`centered_text_x`] to centre. `tools/check-text-measure.sh` fails the
//! build when a widget module goes back to the blind call.
use crate::{Rect, RenderBackend};
/// The font family every chrome string is DRAWN with — the `family`
/// argument of the `TextLayout::single_run` calls all over `widgets/`.
/// Measurement has to name the same one.
pub const CHROME_FONT_FAMILY: &str = "system-ui";
/// Painted width of `text` at `font_size` in the chrome font family.
///
/// The drop-in replacement for `backend.measure_text(text, font_size)`.
pub fn measure_chrome(backend: &mut dyn RenderBackend, text: &str, font_size: f32) -> f32 {
backend.measure_text_family(text, font_size, CHROME_FONT_FAMILY)
}
/// Painted width of `text` at `font_size` and `weight` in the chrome font
/// family — for runs that carry a `.with_font_weight(…)`, where the bold
/// face's advances differ from the regular one.
pub fn measure_chrome_weighted(
backend: &mut dyn RenderBackend,
text: &str,
font_size: f32,
weight: u16,
) -> f32 {
backend.measure_text_family_styled(text, font_size, CHROME_FONT_FAMILY, weight, false)
}
/// Painted width of `text` in an explicitly named `family` — for the few
/// chrome runs that are not [`CHROME_FONT_FAMILY`] (monospace readouts,
/// the font-picker's preview rows, which paint each entry in its own face).
pub fn measure_in_family(
backend: &mut dyn RenderBackend,
text: &str,
font_size: f32,
family: &str,
) -> f32 {
backend.measure_text_family(text, font_size, family)
}
/// Ellipsize `text` with a trailing `…` until it fits `max_w`, measured in
/// the family it will be drawn in.
pub fn fit_chrome(
backend: &mut dyn RenderBackend,
text: &str,
max_w: f32,
font_size: f32,
) -> String {
crate::util::ellipsize_to_width(text, max_w, |s| measure_chrome(backend, s, font_size))
}
/// Ellipsize `text` to `max_w` measured in an explicitly named `family` —
/// the [`fit_chrome`] twin for runs a jian component paints in its own face.
pub fn fit_in_family(
backend: &mut dyn RenderBackend,
text: &str,
max_w: f32,
font_size: f32,
family: &str,
) -> String {
crate::util::ellipsize_to_width(text, max_w, |s| {
measure_in_family(backend, s, font_size, family)
})
}
/// Family jian's `SelectTrigger` paints its value in, and the horizontal
/// space its own chrome takes: `PAD_X`(8) either side plus the 14px chevron
/// and its 4px gutter. Mirrored from
/// `jian_widgets::components::select_trigger` — a vendored component we may
/// not edit, and one that **clips** its value rather than ellipsizing it.
const SELECT_TRIGGER_FAMILY: &str = "Inter";
const SELECT_TRIGGER_INSET: f32 = 8.0 + 8.0 + 14.0 + 4.0;
/// Fit a value for a jian `SelectTrigger` occupying `rect`.
///
/// The component clips its value to the box, so an over-long localized value
/// is cut mid-glyph with nothing to signal it. Fitting here turns that into
/// an ellipsis — and it must measure in the component's own family, not the
/// chrome one.
pub fn fit_select_trigger_label(
backend: &mut dyn RenderBackend,
label: &str,
rect: Rect,
font_size: f32,
) -> String {
fit_in_family(
backend,
label,
(rect.size.x - SELECT_TRIGGER_INSET).max(0.0),
font_size,
SELECT_TRIGGER_FAMILY,
)
}
/// Left edge that centres `text` horizontally inside `rect`.
///
/// Centring on a family-blind width biases the run left by half the
/// measurement error, which is what makes a "centred" button label read as
/// slightly off on a real machine but perfectly centred in every test.
pub fn centered_text_x(
backend: &mut dyn RenderBackend,
text: &str,
font_size: f32,
rect: Rect,
) -> f32 {
rect.origin.x + (rect.size.x - measure_chrome(backend, text, font_size)) / 2.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::test_family_gap_backend::FamilyGapBackend;
#[test]
fn measure_chrome_reports_the_named_family_not_the_blind_default() {
let mut backend = FamilyGapBackend::default();
let blind = backend.measure_text("Doubao", 13.0);
let painted = measure_chrome(&mut backend, "Doubao", 13.0);
assert!(
painted > blind,
"chrome measurement must resolve {CHROME_FONT_FAMILY}, not the backend default"
);
}
#[test]
fn fit_chrome_ellipsizes_against_the_painted_width() {
let mut backend = FamilyGapBackend::default();
let text = "A very long provider name that cannot fit";
let max_w = 60.0;
let fitted = fit_chrome(&mut backend, text, max_w, 13.0);
assert!(fitted.ends_with('…'), "expected truncation: {fitted:?}");
assert!(
measure_chrome(&mut backend, &fitted, 13.0) <= max_w,
"fitted text must fit the column when measured in its paint family"
);
}
#[test]
fn centered_text_x_centres_on_the_painted_width() {
let mut backend = FamilyGapBackend::default();
let rect = Rect::xywh(10.0, 0.0, 200.0, 24.0);
let x = centered_text_x(&mut backend, "Connect", 12.0, rect);
let w = measure_chrome(&mut backend, "Connect", 12.0);
let left_gap = x - rect.origin.x;
let right_gap = rect.origin.x + rect.size.x - (x + w);
assert!(
(left_gap - right_gap).abs() < 0.01,
"label should sit centred: {left_gap} vs {right_gap}"
);
}
}