diff --git a/crates/op-editor-ui/src/widgets/test_family_gap_backend.rs b/crates/op-editor-ui/src/widgets/test_family_gap_backend.rs new file mode 100644 index 000000000..858022b87 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/test_family_gap_backend.rs @@ -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, + /// 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, + /// 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>, + /// 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 { + 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) +} diff --git a/crates/op-editor-ui/src/widgets/text_metrics.rs b/crates/op-editor-ui/src/widgets/text_metrics.rs new file mode 100644 index 000000000..ba2ffb449 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/text_metrics.rs @@ -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}" + ); + } +}