refactor(renderer): add family-aware chrome text metrics
This commit is contained in:
parent
599ab11f6c
commit
73732d4edb
207
crates/op-editor-ui/src/widgets/test_family_gap_backend.rs
Normal file
207
crates/op-editor-ui/src/widgets/test_family_gap_backend.rs
Normal 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)
|
||||
}
|
||||
189
crates/op-editor-ui/src/widgets/text_metrics.rs
Normal file
189
crates/op-editor-ui/src/widgets/text_metrics.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue