From b6688c881883d938d82ed6137f6649e7cccf0a86 Mon Sep 17 00:00:00 2001 From: Fini Date: Fri, 7 Aug 2026 01:55:31 +0800 Subject: [PATCH] fix(renderer): measure chrome text in its painted font --- .github/workflows/rust-check.yml | 10 + crates/op-editor-core/src/render_backend.rs | 16 ++ .../src/widgets/ai_chat_model_picker.rs | 5 +- .../src/widgets/ai_chat_panel_footer.rs | 41 ++- .../src/widgets/ai_chat_panel_header.rs | 7 +- .../src/widgets/ai_chat_panel_minimized.rs | 10 +- .../src/widgets/ai_chat_panel_paint.rs | 54 +++- .../src/widgets/canvas_agent_cursor.rs | 3 +- .../src/widgets/canvas_selection_overlay.rs | 21 +- .../src/widgets/collab_avatar_paint.rs | 4 +- .../op-editor-ui/src/widgets/collab_panel.rs | 5 +- .../src/widgets/collab_panel_owner_confirm.rs | 5 +- .../src/widgets/collab_panel_paint.rs | 9 +- .../op-editor-ui/src/widgets/export_dialog.rs | 3 +- .../op-editor-ui/src/widgets/figma_import.rs | 5 +- .../src/widgets/figma_import_progress.rs | 5 +- .../src/widgets/file_drop_overlay.rs | 3 +- .../src/widgets/git_panel/paint.rs | 3 +- .../src/widgets/git_panel_commit_card.rs | 5 +- .../src/widgets/git_panel_empty.rs | 5 +- .../git_panel_menus/remote_settings.rs | 3 +- .../src/widgets/git_panel_ready.rs | 7 +- .../src/widgets/git_panel_tracked_picker.rs | 7 +- .../widgets/html_import_diagnostics_panel.rs | 5 +- .../src/widgets/ime_preedit_overlay.rs | 7 +- .../op-editor-ui/src/widgets/import_menu.rs | 3 +- .../op-editor-ui/src/widgets/login_modal.rs | 3 +- .../src/widgets/preview_device_switcher.rs | 3 +- .../src/widgets/property_panel.rs | 6 +- .../src/widgets/property_panel_code.rs | 5 +- .../src/widgets/property_panel_code_error.rs | 5 +- .../widgets/property_panel_code_generating.rs | 3 +- .../src/widgets/property_panel_compositing.rs | 14 +- .../src/widgets/property_panel_effects.rs | 3 +- .../src/widgets/property_panel_fill.rs | 21 +- .../src/widgets/property_panel_fill_body.rs | 33 ++- .../src/widgets/property_panel_image_fill.rs | 3 +- .../src/widgets/property_panel_image_node.rs | 5 +- .../widgets/property_panel_image_popovers.rs | 5 +- .../src/widgets/property_panel_inputs.rs | 18 +- .../src/widgets/property_panel_instance.rs | 29 +- .../src/widgets/property_panel_layer.rs | 33 ++- .../src/widgets/property_panel_page.rs | 3 +- .../src/widgets/property_panel_sections.rs | 10 +- .../src/widgets/property_panel_stroke.rs | 9 +- .../src/widgets/property_panel_text.rs | 3 +- .../property_panel_typography_paint.rs | 3 +- .../src/widgets/property_panel_widget.rs | 3 +- .../op-editor-ui/src/widgets/settings_form.rs | 9 +- .../src/widgets/slides_panel_tabs.rs | 10 +- .../src/widgets/slideshow_toolbar.rs | 3 +- .../src/widgets/text_metrics_paint_tests.rs | 247 ++++++++++++++++++ .../src/widgets/text_selection.rs | 3 +- crates/op-editor-ui/src/widgets/top_bar.rs | 3 +- .../op-editor-ui/src/widgets/top_bar_paint.rs | 16 +- .../src/widgets/variables_preset_menu.rs | 4 +- .../src/widget_host/text_edit_press.rs | 9 +- tools/check-text-measure.sh | 75 ++++++ 58 files changed, 687 insertions(+), 163 deletions(-) create mode 100644 crates/op-editor-ui/src/widgets/text_metrics_paint_tests.rs create mode 100755 tools/check-text-measure.sh diff --git a/.github/workflows/rust-check.yml b/.github/workflows/rust-check.yml index 9fc83d74b..f894969d3 100644 --- a/.github/workflows/rust-check.yml +++ b/.github/workflows/rust-check.yml @@ -16,6 +16,7 @@ on: - 'tools/check-jian-boundaries.sh' - 'tools/check-web-server-headless.sh' - 'tools/check-widget-boundary.sh' + - 'tools/check-text-measure.sh' - '.github/workflows/rust-check.yml' push: branches: ['**'] @@ -30,6 +31,7 @@ on: - 'tools/check-jian-boundaries.sh' - 'tools/check-web-server-headless.sh' - 'tools/check-widget-boundary.sh' + - 'tools/check-text-measure.sh' - '.github/workflows/rust-check.yml' jobs: @@ -115,6 +117,14 @@ jobs: if: runner.os == 'Linux' run: bash tools/check-widget-boundary.sh + # Family-blind text measurement is invisible to every other check: + # it compiles, it passes the test suite (test backends measure blind + # and family-aware identically), and it only shows as sheared or + # off-centre glyphs on a real machine. + - name: Verify chrome text is measured in its paint family + if: runner.os == 'Linux' + run: bash tools/check-text-measure.sh + - name: Verify op-host-web-server headless boundary (no winit/glutin/GL) if: runner.os == 'Linux' run: bash tools/check-web-server-headless.sh diff --git a/crates/op-editor-core/src/render_backend.rs b/crates/op-editor-core/src/render_backend.rs index 6b9da66cf..6ef25d38f 100644 --- a/crates/op-editor-core/src/render_backend.rs +++ b/crates/op-editor-core/src/render_backend.rs @@ -4,6 +4,22 @@ //! cross-platform component-library extraction. Existing OP code keeps //! importing this module during migration; new shared widget code should //! import from `jian_widgets` directly. +//! +//! # Calling convention: never measure chrome text with `measure_text` +//! +//! `RenderBackend::measure_text` is family-BLIND — it resolves the backend's +//! default typeface (bundled Roboto on native), while chrome strings are +//! drawn as named `system-ui` runs that native resolves through the system +//! `FontMgr`. The blind call therefore under-reports the painted width, and +//! nothing errors: a fitter emits no ellipsis and the clip shears a glyph in +//! half, a centred label sits off-centre, a bubble is born too narrow for its +//! own text. It does not reproduce in CI either, because the test backends +//! measure both ways identically. +//! +//! Chrome measurement goes through `op_editor_ui::widgets::text_metrics` +//! (`measure_chrome` / `fit_chrome` / `centered_text_x` / `measure_in_family`), +//! which names the family the run is painted in. `tools/check-text-measure.sh` +//! enforces this for `op-editor-ui/src/widgets/`. pub use jian_widgets::geometry::{Color, Point2D, Rect}; pub use jian_widgets::painter::{ diff --git a/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs b/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs index 8372cf1ce..eea094d6d 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs @@ -9,6 +9,7 @@ use crate::widgets::brand_icons::{paint_brand_logo, paint_opencode_logo, BrandLo use crate::widgets::button::paint_button_feedback_wash; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::property_panel_text_input::paint_text_input_view_value; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use jian_core::text_input::TextInputState; @@ -301,7 +302,7 @@ pub fn paint_model_picker( (theme.muted_foreground).to_jian(), Point2D::new(0.0, 0.0), ); - let w = cx.backend.measure_text(empty, 12.0); + let w = text_metrics::measure_chrome(cx.backend, empty, 12.0); cx.backend.draw_text( &layout, Point2D::new( @@ -531,7 +532,7 @@ fn paint_search_row( } fn paint_badge(cx: &mut PaintCx<'_>, theme: &Theme, text: &str, right_x: f32, y: f32) { - let w = cx.backend.measure_text(text, 9.0) + 8.0; + let w = text_metrics::measure_chrome(cx.backend, text, 9.0) + 8.0; let rect = Rect { origin: Point2D::new(right_x - w, y), size: Point2D::new(w, 16.0), diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_footer.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_footer.rs index 0a5b36844..ea536db10 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_footer.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_footer.rs @@ -20,8 +20,7 @@ //! future use but are no longer wired to a footer chip as of #32. use super::ai_chat_panel::{ - chat_neutral_feedback_color, footer_label_width, AIChatPlaceholder, FooterLayout, - INPUT_TOOLBAR_HEIGHT, PAD, + chat_neutral_feedback_color, AIChatPlaceholder, FooterLayout, INPUT_TOOLBAR_HEIGHT, PAD, }; use crate::widgets::ai_chat_panel_controls::draw_label; use crate::widgets::icons::{draw_icon, Icon}; @@ -132,24 +131,24 @@ impl<'a> AIChatPlaceholder<'a> { } } -pub(crate) fn fit_footer_label(label: &str, size: f32, max_w: f32) -> String { - if footer_label_width(label, size) <= max_w { - return label.to_string(); - } - let ellipsis_w = footer_label_width("…", size); - let budget = (max_w - ellipsis_w).max(0.0); - let mut out = String::new(); - let mut w = 0.0; - for ch in label.chars() { - let next = footer_label_width(&ch.to_string(), size); - if w + next > budget { - break; - } - out.push(ch); - w += next; - } - out.push('…'); - out +/// Ellipsize a footer chip label to `max_w`. +/// +/// Measured through the backend in the family the run paints with, NOT +/// through [`footer_label_width`]. That estimator hard-codes the bundled +/// Roboto's 0.55-em ASCII advance, which is narrower than the `system-ui` +/// face this label is drawn in — fitting against it returns a string the +/// widget believes fits, and the chip's clip then shears the last glyph in +/// half with no ellipsis to show for it. `footer_label_width` keeps its +/// backend-free callers (chip geometry, computed before a painter exists). +/// +/// [`footer_label_width`]: super::ai_chat_panel::footer_label_width +pub(crate) fn fit_footer_label( + backend: &mut dyn crate::RenderBackend, + label: &str, + size: f32, + max_w: f32, +) -> String { + crate::widgets::text_metrics::fit_chrome(backend, label, max_w, size) } pub(crate) fn footer_label_baseline(center_y: f32, size: f32) -> f32 { @@ -364,7 +363,7 @@ pub(crate) fn paint_bottom_toolbar( .unwrap_or(widget.label_no_models.as_str()); // Reserve space for the chevron-down on the right of the pill. let label_w = (footer.model.origin.x + footer.model.size.x - 18.0 - model_label_x).max(0.0); - let model_name_fit = fit_footer_label(model_name, 11.0, label_w); + let model_name_fit = fit_footer_label(cx.backend, model_name, 11.0, label_w); let model_label = TextLayout::single_run( &model_name_fit, "system-ui", diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_header.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_header.rs index c593b0762..3c191937d 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_header.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_header.rs @@ -26,6 +26,7 @@ use super::ai_chat_panel::{ChatTabInfo, HEADER_HEIGHT, PAD}; use crate::theme::Theme; use crate::widgets::ai_chat_panel_controls::chat_neutral_feedback_color; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; @@ -227,7 +228,7 @@ pub(crate) fn paint_header_tabs( }; let title_max_w = (tr.body.size.x - TAB_PAD_X - right_inset).max(0.0); let title_text = crate::util::ellipsize_to_width(&tab.title, title_max_w, |s| { - cx.backend.measure_text(s, TAB_FONT_SIZE) + text_metrics::measure_chrome(cx.backend, s, TAB_FONT_SIZE) }); let text_color = if is_active { theme.foreground @@ -348,8 +349,8 @@ pub(crate) fn paint_new_chat_tooltip(cx: &mut PaintCx<'_>, theme: &Theme, rect: let cmd_sym = "⌘"; let t_sym = "T"; - let cmd_text_w = cx.backend.measure_text(cmd_sym, chip_font); - let t_text_w = cx.backend.measure_text(t_sym, chip_font); + let cmd_text_w = text_metrics::measure_chrome(cx.backend, cmd_sym, chip_font); + let t_text_w = text_metrics::measure_chrome(cx.backend, t_sym, chip_font); let cmd_w = cmd_text_w + pad_x * 2.0; let t_w = t_text_w + pad_x * 2.0; let chips_total = cmd_w + gap + t_w; diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_minimized.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_minimized.rs index c5f8879d0..0a59dde05 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_minimized.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_minimized.rs @@ -12,6 +12,7 @@ use super::ai_chat_panel::{chat_neutral_feedback_color, footer_label_width, AICh use crate::theme::Theme; use crate::widgets::ai_chat_panel_footer::fit_footer_label; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; @@ -170,7 +171,7 @@ pub(crate) fn paint_minimized_bar( }; if layout.text.size.x > 0.0 { let fitted = crate::util::ellipsize_to_width(label, layout.text.size.x, |s| { - cx.backend.measure_text(s, MINIMIZED_TEXT_FONT) + text_metrics::measure_chrome(cx.backend, s, MINIMIZED_TEXT_FONT) }); let text = TextLayout::single_run( &fitted, @@ -189,7 +190,12 @@ pub(crate) fn paint_minimized_bar( } if layout.model.size.x > 0.0 { - let fitted = fit_footer_label(model_name, MINIMIZED_MODEL_FONT, layout.model.size.x); + let fitted = fit_footer_label( + cx.backend, + model_name, + MINIMIZED_MODEL_FONT, + layout.model.size.x, + ); let text = TextLayout::single_run( &fitted, "system-ui", diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs index 59f6ccdcd..23c1f957b 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_paint.rs @@ -7,6 +7,7 @@ use super::ai_chat_panel::{ExampleCard, HEADER_HEIGHT, PAD}; use crate::theme::Theme; use crate::widgets::button::paint_button_feedback_wash; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; @@ -44,11 +45,12 @@ pub(crate) fn example_card_rects(rect: Rect) -> [Rect; 4] { /// Ellipsize `text` so it fits within `max_w` at the given font size. fn ellipsize(cx: &mut PaintCx<'_>, text: &str, max_w: f32, size: f32) -> String { - if cx.backend.measure_text(text, size) <= max_w { + if text_metrics::measure_chrome(cx.backend, text, size) <= max_w { return text.to_string(); } let mut s = text.to_string(); - while !s.is_empty() && cx.backend.measure_text(&format!("{s}…"), size) > max_w { + while !s.is_empty() && text_metrics::measure_chrome(cx.backend, &format!("{s}…"), size) > max_w + { s.pop(); } format!("{s}…") @@ -124,16 +126,25 @@ pub(crate) fn paint_examples( let opacity = if disabled { 0.6 } else { 1.0 }; // ── Centered hint header ───────────────────────────────────────────────── + // Fitted before centring: the card is user-resizable and some locales' + // hint runs half again as long as the English one, so an unfitted line + // is centred straight off both edges of a narrow panel. let hint_font = 12.0; - let hint = TextLayout::single_run( + let hint_label = text_metrics::fit_chrome( + cx.backend, hint_label, + (rect.size.x - PAD * 2.0).max(0.0), + hint_font, + ); + let hint = TextLayout::single_run( + &hint_label, "system-ui", hint_font, (theme.muted_foreground).with_alpha(opacity).to_jian(), Point2D::new(0.0, 0.0), ); let hint_y = rect.origin.y + HEADER_HEIGHT + HINT_OFFSET + hint_font * 0.35; - let hint_w = cx.backend.measure_text(hint_label, hint_font); + let hint_w = text_metrics::measure_chrome(cx.backend, &hint_label, hint_font); cx.backend.draw_text( &hint, Point2D::new(rect.origin.x + (rect.size.x - hint_w) / 2.0, hint_y), @@ -214,34 +225,51 @@ pub(crate) fn paint_examples( let tip2_top = tip1_top + TIP_LINE_H; let tip2_y = tip2_top + tip_font * 0.35; + // Both tips are fitted to the card before they are centred. They are + // long fixed English sentences and the chat card is user-resizable, so + // an unfitted line runs off both edges of a narrow panel — and the + // paperclip that trails tip 2 goes with it. + let tip_max_w = (rect.size.x - PAD * 2.0).max(0.0); + // Tip line 1: "Tip: Export design to code via Claude Code in terminal." - let tip1 = "Tip: Export design to code via Claude Code in terminal."; + let tip1 = text_metrics::fit_chrome( + cx.backend, + "Tip: Export design to code via Claude Code in terminal.", + tip_max_w, + tip_font, + ); let tip1_layout = TextLayout::single_run( - tip1, + &tip1, "system-ui", tip_font, tip_color, Point2D::new(0.0, 0.0), ); - let tip1_w = cx.backend.measure_text(tip1, tip_font); + let tip1_w = text_metrics::measure_chrome(cx.backend, &tip1, tip_font); cx.backend.draw_text( &tip1_layout, Point2D::new(rect.origin.x + (rect.size.x - tip1_w) / 2.0, tip1_y), ); // Tip line 2: "Drop image / text file to chat or via 📎" - let tip2_text = "Drop image / text file to chat or via"; + // The trailing paperclip is part of the line, so its width comes out of + // the budget the text is fitted to. + let clip_size = tip_font * 1.2; + let clip_gap = 4.0; + let tip2_text = text_metrics::fit_chrome( + cx.backend, + "Drop image / text file to chat or via", + (tip_max_w - clip_gap - clip_size).max(0.0), + tip_font, + ); let tip2_layout = TextLayout::single_run( - tip2_text, + &tip2_text, "system-ui", tip_font, tip_color, Point2D::new(0.0, 0.0), ); - let tip2_w = cx.backend.measure_text(tip2_text, tip_font); - // Inline paperclip icon width estimate: tip_font * 1.2. - let clip_size = tip_font * 1.2; - let clip_gap = 4.0; + let tip2_w = text_metrics::measure_chrome(cx.backend, &tip2_text, tip_font); let total_w = tip2_w + clip_gap + clip_size; let tip2_x = rect.origin.x + (rect.size.x - total_w) / 2.0; cx.backend diff --git a/crates/op-editor-ui/src/widgets/canvas_agent_cursor.rs b/crates/op-editor-ui/src/widgets/canvas_agent_cursor.rs index e2ee04be4..5738c30e4 100644 --- a/crates/op-editor-ui/src/widgets/canvas_agent_cursor.rs +++ b/crates/op-editor-ui/src/widgets/canvas_agent_cursor.rs @@ -17,6 +17,7 @@ use std::collections::HashMap; use super::canvas_agent_cursor_motion::{cursor_kinematics, parked_cursor_position, Waypoint}; use crate::layout_scene::SceneNode; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::agent_indicators::{AgentIndicators, AgentTag}; @@ -727,7 +728,7 @@ fn paint_name_pill(cx: &mut PaintCx<'_>, sprite: &CursorSprite, name: &str) { // Clear the pencil body's down-right diagonal (~22px) with a little air. const OFFSET_X: f32 = 18.0; const OFFSET_Y: f32 = 24.0; - let name_w = cx.backend.measure_text(name, FONT); + let name_w = text_metrics::measure_chrome(cx.backend, name, FONT); let pill = Rect::xywh( sprite.pos.x + OFFSET_X, sprite.pos.y + OFFSET_Y, diff --git a/crates/op-editor-ui/src/widgets/canvas_selection_overlay.rs b/crates/op-editor-ui/src/widgets/canvas_selection_overlay.rs index d3cd2aa5e..2bb029275 100644 --- a/crates/op-editor-ui/src/widgets/canvas_selection_overlay.rs +++ b/crates/op-editor-ui/src/widgets/canvas_selection_overlay.rs @@ -1,6 +1,7 @@ use crate::layout_scene::{NodeKind, SceneNode}; use crate::theme::Theme; use crate::widgets::canvas_overlay_transform::OverlayTransform; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::agent_indicators::AgentIndicators; @@ -8,6 +9,9 @@ use op_editor_core::Viewport; use std::collections::HashSet; const LABEL_FONT_SIZE: f32 = 12.0; +/// Weight the pill's label run paints at — measurement names it too, so the +/// pill is sized against the medium face it actually shows. +const LABEL_FONT_WEIGHT: u16 = 500; const LABEL_HEIGHT: f32 = 22.0; const LABEL_PAD_X: f32 = 8.0; const LABEL_GAP: f32 = 6.0; @@ -204,7 +208,7 @@ fn paint_selection_label( let max_label_width = (input.canvas_rect.size.x - LABEL_MARGIN * 2.0).max(32.0); let max_text_width = (max_label_width - LABEL_PAD_X * 2.0).max(0.0); let text = truncate_label_to_width(cx, label, max_text_width); - let text_width = cx.backend.measure_text(&text, LABEL_FONT_SIZE); + let text_width = measure_label(cx, &text); let label_width = (text_width + LABEL_PAD_X * 2.0).min(max_label_width); let canvas_left = input.canvas_rect.origin.x + LABEL_MARGIN; let canvas_top = input.canvas_rect.origin.y + LABEL_MARGIN; @@ -238,19 +242,26 @@ fn paint_selection_label( input.theme.primary.to_jian(), Point2D::ZERO, ) - .with_font_weight(500); + .with_font_weight(LABEL_FONT_WEIGHT); cx.backend.draw_text( &layout, Point2D::new(pill.origin.x + LABEL_PAD_X, pill.origin.y + 15.0), ); } +/// Width of a selection-label run, measured in the family AND weight the +/// pill paints it with — the pill is sized from this, so a family-blind +/// number would build a pill too narrow to hold its own label. +fn measure_label(cx: &mut PaintCx<'_>, text: &str) -> f32 { + text_metrics::measure_chrome_weighted(cx.backend, text, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT) +} + fn truncate_label_to_width(cx: &mut PaintCx<'_>, label: &str, max_width: f32) -> String { - if cx.backend.measure_text(label, LABEL_FONT_SIZE) <= max_width { + if measure_label(cx, label) <= max_width { return label.to_string(); } let ellipsis = "…"; - let ellipsis_width = cx.backend.measure_text(ellipsis, LABEL_FONT_SIZE); + let ellipsis_width = measure_label(cx, ellipsis); if ellipsis_width >= max_width { return ellipsis.to_string(); } @@ -259,7 +270,7 @@ fn truncate_label_to_width(cx: &mut PaintCx<'_>, label: &str, max_width: f32) -> let mut probe = out.clone(); probe.push(ch); probe.push_str(ellipsis); - if cx.backend.measure_text(&probe, LABEL_FONT_SIZE) > max_width { + if measure_label(cx, &probe) > max_width { break; } out.push(ch); diff --git a/crates/op-editor-ui/src/widgets/collab_avatar_paint.rs b/crates/op-editor-ui/src/widgets/collab_avatar_paint.rs index 54e7ec8b5..c1c747308 100644 --- a/crates/op-editor-ui/src/widgets/collab_avatar_paint.rs +++ b/crates/op-editor-ui/src/widgets/collab_avatar_paint.rs @@ -5,6 +5,7 @@ use crate::collab_avatar_runtime::{ }; use crate::widgets::canvas_viewport_image::note_pending_decode; use crate::widgets::collab_ui::CollabAvatarModel; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, ImageDrawMode, Point2D, Rect, TextLayout}; @@ -42,7 +43,8 @@ pub(super) fn paint_collab_avatar( Point2D::ZERO, ) .with_font_weight(600); - let initials_w = cx.backend.measure_text(&participant.initials, text_size); + let initials_w = + text_metrics::measure_chrome_weighted(cx.backend, &participant.initials, text_size, 600); cx.backend.draw_text( &initials, Point2D::new( diff --git a/crates/op-editor-ui/src/widgets/collab_panel.rs b/crates/op-editor-ui/src/widgets/collab_panel.rs index a3a47bf13..aee1eda35 100644 --- a/crates/op-editor-ui/src/widgets/collab_panel.rs +++ b/crates/op-editor-ui/src/widgets/collab_panel.rs @@ -12,6 +12,7 @@ use crate::widgets::collab_ui::{ }; use crate::widgets::editor_state_ext::theme_for; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Point2D, Rect}; use op_editor_core::{CollabPanelHover, CollabUiAction, EditorUiState}; @@ -478,7 +479,7 @@ impl Widget for CollabPanel<'_> { let shown = crate::util::ellipsize_to_width( invite.as_str(), rect.size.x - PAD * 2.0 - 48.0, - |text| cx.backend.measure_text(text, 11.0), + |text| text_metrics::measure_chrome_weighted(cx.backend, text, 11.0, 500), ); paint_text( cx, @@ -530,7 +531,7 @@ impl Widget for CollabPanel<'_> { let shown = crate::util::ellipsize_to_width( endpoint.as_str(), rect.size.x - PAD * 2.0 - 30.0, - |text| cx.backend.measure_text(text, 11.0), + |text| text_metrics::measure_chrome(cx.backend, text, 11.0), ); paint_text( cx, diff --git a/crates/op-editor-ui/src/widgets/collab_panel_owner_confirm.rs b/crates/op-editor-ui/src/widgets/collab_panel_owner_confirm.rs index fc319b8da..a3f778232 100644 --- a/crates/op-editor-ui/src/widgets/collab_panel_owner_confirm.rs +++ b/crates/op-editor-ui/src/widgets/collab_panel_owner_confirm.rs @@ -8,6 +8,7 @@ use super::*; use crate::widgets::collab_ui::CollabOwnerConfirmModel; +use crate::widgets::text_metrics; pub(super) const CONFIRM_OWNER_HEAD_HEIGHT: f32 = 72.0; pub(super) const CONFIRM_OWNER_ROW_HEIGHT: f32 = 32.0; @@ -58,7 +59,7 @@ impl CollabPanel<'_> { 500, ); let shown = crate::util::ellipsize_to_width(&row.value, width, |text| { - cx.backend.measure_text(text, 11.0) + text_metrics::measure_chrome(cx.backend, text, 11.0) }); paint_text( cx, @@ -87,7 +88,7 @@ impl CollabPanel<'_> { ); let quoted = format!("“{}”", claimed.value); let shown = crate::util::ellipsize_to_width("ed, width, |text| { - cx.backend.measure_text(text, 11.0) + text_metrics::measure_chrome(cx.backend, text, 11.0) }); paint_text( cx, diff --git a/crates/op-editor-ui/src/widgets/collab_panel_paint.rs b/crates/op-editor-ui/src/widgets/collab_panel_paint.rs index 732aefdc3..4c6e673ed 100644 --- a/crates/op-editor-ui/src/widgets/collab_panel_paint.rs +++ b/crates/op-editor-ui/src/widgets/collab_panel_paint.rs @@ -2,6 +2,7 @@ use super::*; use crate::widgets::collab_ui::{role_label, CollabAvatarModel}; +use crate::widgets::text_metrics; use crate::{Color, TextLayout}; impl CollabPanel<'_> { @@ -78,7 +79,7 @@ impl CollabPanel<'_> { let split = { let mut end = notice.len(); for (index, _) in notice.char_indices().skip(1) { - if cx.backend.measure_text(¬ice[..index], FONT) > max_width { + if text_metrics::measure_chrome(cx.backend, ¬ice[..index], FONT) > max_width { end = notice .char_indices() .take_while(|(byte, _)| *byte < index) @@ -104,7 +105,7 @@ impl CollabPanel<'_> { return; } let second = crate::util::ellipsize_to_width(rest, max_width, |text| { - cx.backend.measure_text(text, FONT) + text_metrics::measure_chrome(cx.backend, text, FONT) }); paint_text( cx, @@ -168,7 +169,7 @@ pub(super) fn paint_participant( if participant.is_self { 600 } else { 400 }, ); let role = role_label(ui, participant.role); - let role_w = cx.backend.measure_text(role, 10.0); + let role_w = text_metrics::measure_chrome(cx.backend, role, 10.0); paint_text( cx, role, @@ -208,7 +209,7 @@ pub(super) fn paint_button( } else { theme.secondary_foreground }; - let width = cx.backend.measure_text(label, 11.0); + let width = text_metrics::measure_chrome_weighted(cx.backend, label, 11.0, 500); // Centre against the button's own height. A hardcoded baseline was tuned // for the 32 px action row and left every 28 px button (admission // decisions, service-region options) painting its label low. diff --git a/crates/op-editor-ui/src/widgets/export_dialog.rs b/crates/op-editor-ui/src/widgets/export_dialog.rs index 70dd2302b..32916c05c 100644 --- a/crates/op-editor-ui/src/widgets/export_dialog.rs +++ b/crates/op-editor-ui/src/widgets/export_dialog.rs @@ -11,6 +11,7 @@ use crate::theme::Theme; use crate::widgets::editor_state_ext::doc_export_format; +use crate::widgets::text_metrics; use crate::{Color, Point2D, Rect, RenderBackend, TextLayout}; use op_editor_core::editor_ui_state::EditorUiState; @@ -324,7 +325,7 @@ fn paint_centered_label( color: Color, rect: Rect, ) { - let w = backend.measure_text(text, size); + let w = text_metrics::measure_chrome(backend, text, size); let layout = TextLayout::single_run( text, FONT_FAMILY, diff --git a/crates/op-editor-ui/src/widgets/figma_import.rs b/crates/op-editor-ui/src/widgets/figma_import.rs index 01d254c16..d4721cfe4 100644 --- a/crates/op-editor-ui/src/widgets/figma_import.rs +++ b/crates/op-editor-ui/src/widgets/figma_import.rs @@ -6,6 +6,7 @@ use crate::theme::Theme; use crate::widgets::editor_state_ext::theme_for; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Point2D, Rect, TextLayout}; use jian_widgets::components::button::{Button, ButtonVariant}; @@ -340,7 +341,7 @@ impl Widget for FigmaImportModal { } let headline = t(self.locale, self.source, "drop"); - let head_w = cx.backend.measure_text(headline, 13.0); + let head_w = text_metrics::measure_chrome(cx.backend, headline, 13.0); let head_layout = TextLayout::single_run( headline, "system-ui", @@ -357,7 +358,7 @@ impl Widget for FigmaImportModal { ); let sub = t(self.locale, self.source, "browse"); - let sub_w = cx.backend.measure_text(sub, 11.0); + let sub_w = text_metrics::measure_chrome(cx.backend, sub, 11.0); let sub_layout = TextLayout::single_run( sub, "system-ui", diff --git a/crates/op-editor-ui/src/widgets/figma_import_progress.rs b/crates/op-editor-ui/src/widgets/figma_import_progress.rs index 6dee919d2..ecd7eebba 100644 --- a/crates/op-editor-ui/src/widgets/figma_import_progress.rs +++ b/crates/op-editor-ui/src/widgets/figma_import_progress.rs @@ -9,6 +9,7 @@ use crate::theme::Theme; use crate::widgets::editor_state_ext::theme_for; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Point2D, Rect, TextLayout}; use op_editor_core::editor_ui_state::Locale; @@ -107,7 +108,7 @@ impl Widget for ImportProgressOverlay { // Headline directly below the glyph. let headline = parsing_title(self.locale, self.source); - let head_w = cx.backend.measure_text(headline, 14.0); + let head_w = text_metrics::measure_chrome(cx.backend, headline, 14.0); let head_layout = TextLayout::single_run( headline, "system-ui", @@ -151,7 +152,7 @@ impl Widget for ImportProgressOverlay { // Subtitle below the dots. let sub = parsing_subtitle(self.locale, self.source); - let sub_w = cx.backend.measure_text(sub, 11.0); + let sub_w = text_metrics::measure_chrome(cx.backend, sub, 11.0); let sub_layout = TextLayout::single_run( sub, "system-ui", diff --git a/crates/op-editor-ui/src/widgets/file_drop_overlay.rs b/crates/op-editor-ui/src/widgets/file_drop_overlay.rs index 935053a8c..2cf954867 100644 --- a/crates/op-editor-ui/src/widgets/file_drop_overlay.rs +++ b/crates/op-editor-ui/src/widgets/file_drop_overlay.rs @@ -8,6 +8,7 @@ use crate::theme::Theme; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::{Color, Point2D, Rect, RenderBackend, TextLayout}; /// Paint the drop overlay across `canvas_rect` (the editor's canvas @@ -67,7 +68,7 @@ pub fn paint_file_drop_overlay( // 4. Otherwise a centred card: download icon + label. let label_text = op_i18n::translate(locale, "dialog.dropToOpen"); - let label_w = backend.measure_text(label_text, 14.0); + let label_w = text_metrics::measure_chrome(backend, label_text, 14.0); let card_w = (label_w + 56.0).max(220.0); let card_h = 104.0; let cx = canvas_rect.origin.x + canvas_rect.size.x / 2.0; diff --git a/crates/op-editor-ui/src/widgets/git_panel/paint.rs b/crates/op-editor-ui/src/widgets/git_panel/paint.rs index 81e1fb764..e0a53dd96 100644 --- a/crates/op-editor-ui/src/widgets/git_panel/paint.rs +++ b/crates/op-editor-ui/src/widgets/git_panel/paint.rs @@ -5,6 +5,7 @@ //! under the 800-line cap; geometry comes from `git_panel/geometry.rs`. use super::*; +use crate::widgets::text_metrics; impl GitPanel<'_> { /// Paint the panel into `rect`. @@ -402,7 +403,7 @@ impl GitPanel<'_> { // instead of overflowing the right edge. A long localized label // can still exceed the fixed button — clip the draw to the rect // so it never bleeds into a neighbour. - let label_w = cx.backend.measure_text(label, 12.0); + let label_w = text_metrics::measure_chrome(cx.backend, label, 12.0); let text_x = rect.origin.x + (rect.size.x - label_w).max(6.0) / 2.0; let baseline = rect.origin.y + rect.size.y / 2.0 + 4.0; cx.backend.save(); diff --git a/crates/op-editor-ui/src/widgets/git_panel_commit_card.rs b/crates/op-editor-ui/src/widgets/git_panel_commit_card.rs index 4a9402eae..93a6ba8b3 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_commit_card.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_commit_card.rs @@ -15,6 +15,7 @@ use crate::widgets::git_panel::{GitPanel, GitPanelHit}; use crate::widgets::git_panel_ready::{MAX_COMMITS, MSG_X, READY_PAD, ROW_H}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect}; use jian_widgets::components::card::Card; @@ -196,7 +197,7 @@ impl GitPanel<'_> { for (k, p) in summary.patches.iter().take(MAX_PATCH_ROWS).enumerate() { let py = card_top + 54.0 + k as f32 * PATCH_ROW_H; self.text(cx, &p.op, body_x, py, 10.0, t.foreground); - let opw = cx.backend.measure_text(&p.op, 10.0); + let opw = text_metrics::measure_chrome(cx.backend, &p.op, 10.0); self.text( cx, &p.node_id, @@ -294,7 +295,7 @@ impl GitPanel<'_> { let mut cur = x; for (label, color) in &segments { self.text(cx, label, cur, y, 10.0, *color); - cur += cx.backend.measure_text(label, 10.0) + 10.0; + cur += text_metrics::measure_chrome(cx.backend, label, 10.0) + 10.0; } } diff --git a/crates/op-editor-ui/src/widgets/git_panel_empty.rs b/crates/op-editor-ui/src/widgets/git_panel_empty.rs index 27bd7e996..47dd83d9b 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_empty.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_empty.rs @@ -9,6 +9,7 @@ use crate::widgets::git_panel::{GitPanel, EMPTY_STATE_WIDTH}; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect}; @@ -104,7 +105,7 @@ impl GitPanel<'_> { size: f32, color: Color, ) { - let w = cx.backend.measure_text(s, size); + let w = text_metrics::measure_chrome(cx.backend, s, size); self.text(cx, s, center_x - w / 2.0, baseline_y, size, color); } @@ -305,7 +306,7 @@ impl GitPanel<'_> { let label = self.t("git.empty.requireSavedFile"); // TS `px-3` (12) each side around a `text-xs` (12 px) label. - let pill_w = cx.backend.measure_text(label, 12.0) + 24.0; + let pill_w = text_metrics::measure_chrome(cx.backend, label, 12.0) + 24.0; // Anchor near the Init card, then clamp inside the panel so the // (often long) localized string never bleeds past the edge. diff --git a/crates/op-editor-ui/src/widgets/git_panel_menus/remote_settings.rs b/crates/op-editor-ui/src/widgets/git_panel_menus/remote_settings.rs index 20281eec4..3aec5ac6d 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_menus/remote_settings.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_menus/remote_settings.rs @@ -4,6 +4,7 @@ //! every file under the 800-line cap. use super::*; +use crate::widgets::text_metrics; impl GitPanel<'_> { // ── Remote-settings subview ────────────────────────────────────── @@ -196,7 +197,7 @@ impl GitPanel<'_> { _ => self.t("git.remote.storedAuth.none"), } }; - let sw = cx.backend.measure_text(status, 11.0); + let sw = text_metrics::measure_chrome(cx.backend, status, 11.0); self.text( cx, status, diff --git a/crates/op-editor-ui/src/widgets/git_panel_ready.rs b/crates/op-editor-ui/src/widgets/git_panel_ready.rs index 74e0fc120..e388446cf 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_ready.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_ready.rs @@ -13,6 +13,7 @@ use crate::widgets::git_panel::{truncate, GitPanel, GitPanelHit, PAD}; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect}; use jian_widgets::components::text_area::TextArea; @@ -476,7 +477,7 @@ impl GitPanel<'_> { // text-muted-foreground`). +14 gives the `p-6`-style top // breathing room so it doesn't crowd the commit-box divider. let label = self.t("git.history.empty"); - let tw = cx.backend.measure_text(label, 12.0); + let tw = text_metrics::measure_chrome(cx.backend, label, 12.0); self.text( cx, label, @@ -535,7 +536,7 @@ impl GitPanel<'_> { author_first_token(&commit.author), commit.time_label, ); - let author_w = cx.backend.measure_text(&meta, 10.0); + let author_w = text_metrics::measure_chrome(cx.backend, &meta, 10.0); let author_x = rect.origin.x + width - READY_PAD - author_w; // Truncate the message to the space left of the meta. let msg_w = (author_x - msg_x - 8.0).max(0.0); @@ -637,7 +638,7 @@ impl GitPanel<'_> { let label = self.t("git.commit.submitButton"); let icon_s = 11.0; let gap = 4.0; - let label_w = cx.backend.measure_text(label, 11.0); + let label_w = text_metrics::measure_chrome(cx.backend, label, 11.0); let content_w = icon_s + gap + label_w; let start_x = rect.origin.x + (rect.size.x - content_w).max(6.0) / 2.0; let color = alpha(t.primary_foreground, factor); diff --git a/crates/op-editor-ui/src/widgets/git_panel_tracked_picker.rs b/crates/op-editor-ui/src/widgets/git_panel_tracked_picker.rs index 92870ae43..30f2bd19a 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_tracked_picker.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_tracked_picker.rs @@ -12,6 +12,7 @@ use crate::widgets::button::paint_button_feedback_wash; use crate::widgets::git_panel::{truncate, GitPanel, GitPanelHit, PAD}; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect}; use jian_widgets::components::select::{Select, SelectHit, SelectState}; @@ -232,7 +233,7 @@ impl GitPanel<'_> { self.t("git.picker.milestoneCount") .replace("{{count}}", &c.milestone_count.to_string()) }; - let meta_w = cx.backend.measure_text(&meta, 10.0); + let meta_w = text_metrics::measure_chrome(cx.backend, &meta, 10.0); let meta_x = row.origin.x + row.size.x - 10.0 - meta_w; self.text( cx, @@ -307,7 +308,7 @@ impl GitPanel<'_> { let p = self.tracked_picker_panel(panel_rect); let mid = p.origin.x + p.size.x / 2.0; let heading = self.t("git.picker.empty.heading"); - let hw = cx.backend.measure_text(heading, 13.0); + let hw = text_metrics::measure_chrome(cx.backend, heading, 13.0); self.text( cx, heading, @@ -319,7 +320,7 @@ impl GitPanel<'_> { let body = self.t("git.picker.empty.body"); let body_chars = ((p.size.x - TP_PAD * 2.0) / 6.0) as usize; let body = truncate(body, body_chars.max(8)); - let bw = cx.backend.measure_text(&body, 11.0); + let bw = text_metrics::measure_chrome(cx.backend, &body, 11.0); self.text( cx, &body, diff --git a/crates/op-editor-ui/src/widgets/html_import_diagnostics_panel.rs b/crates/op-editor-ui/src/widgets/html_import_diagnostics_panel.rs index 3b45f95db..e7bf3566c 100644 --- a/crates/op-editor-ui/src/widgets/html_import_diagnostics_panel.rs +++ b/crates/op-editor-ui/src/widgets/html_import_diagnostics_panel.rs @@ -13,6 +13,7 @@ use crate::theme::Theme; use crate::widgets::editor_state_ext::{theme_for, translate}; +use crate::widgets::text_metrics; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Point2D, Rect, TextLayout}; use jian_widgets::centered_text_baseline_y; @@ -248,7 +249,7 @@ fn wrap_row_lines(cx: &mut PaintCx<'_>, text: &str, font_size: f32, max_width: f for ch in text.chars() { let mut candidate = current.clone(); candidate.push(ch); - if cx.backend.measure_text(&candidate, font_size) <= max_width { + if text_metrics::measure_chrome(cx.backend, &candidate, font_size) <= max_width { if ch.is_whitespace() { last_break = Some(candidate.len()); } @@ -290,7 +291,7 @@ fn finish_with_ellipsis( ) -> Vec { while !current.is_empty() { let candidate = format!("{}…", current.trim_end()); - if cx.backend.measure_text(&candidate, font_size) <= max_width { + if text_metrics::measure_chrome(cx.backend, &candidate, font_size) <= max_width { lines.push(candidate); return lines; } diff --git a/crates/op-editor-ui/src/widgets/ime_preedit_overlay.rs b/crates/op-editor-ui/src/widgets/ime_preedit_overlay.rs index e74a44ae4..301ccc51e 100644 --- a/crates/op-editor-ui/src/widgets/ime_preedit_overlay.rs +++ b/crates/op-editor-ui/src/widgets/ime_preedit_overlay.rs @@ -13,6 +13,7 @@ //! bubble rather than inline at the caret; inline rendering would //! need per-input paint surgery across ten input surfaces. +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout, Theme}; @@ -35,7 +36,7 @@ pub fn paint_ime_preedit( if text.is_empty() { return; } - let text_w = cx.backend.measure_text(text, FONT_SIZE); + let text_w = text_metrics::measure_chrome(cx.backend, text, FONT_SIZE); let w = text_w + PAD_X * 2.0; let h = FONT_SIZE + PAD_Y * 2.0; let (x, y) = match anchor { @@ -81,11 +82,11 @@ pub fn paint_ime_preedit( _ => (0, text.len()), }; let pre_w = if from > 0 { - cx.backend.measure_text(&text[..from], FONT_SIZE) + text_metrics::measure_chrome(cx.backend, &text[..from], FONT_SIZE) } else { 0.0 }; - let seg_w = cx.backend.measure_text(&text[from..to], FONT_SIZE); + let seg_w = text_metrics::measure_chrome(cx.backend, &text[from..to], FONT_SIZE); if seg_w > 0.0 { let uy = y + h - 3.0; cx.backend.stroke_line( diff --git a/crates/op-editor-ui/src/widgets/import_menu.rs b/crates/op-editor-ui/src/widgets/import_menu.rs index 4e10cb1e0..368a14f59 100644 --- a/crates/op-editor-ui/src/widgets/import_menu.rs +++ b/crates/op-editor-ui/src/widgets/import_menu.rs @@ -7,6 +7,7 @@ use crate::theme::Theme; use crate::widgets::editor_state_ext::theme_for; +use crate::widgets::text_metrics; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Point2D, Rect, TextLayout}; pub use jian_widgets::components::select::SelectHit; @@ -151,7 +152,7 @@ impl ImportMenu { cx.backend.clip_rect(popup); for (index, choice) in ImportMenuChoice::ALL.iter().enumerate() { let shortcut = choice.shortcut_label(); - let width = cx.backend.measure_text(shortcut, SHORTCUT_FONT_SIZE); + let width = text_metrics::measure_chrome(cx.backend, shortcut, SHORTCUT_FONT_SIZE); let layout = TextLayout::single_run( shortcut, "system-ui", diff --git a/crates/op-editor-ui/src/widgets/login_modal.rs b/crates/op-editor-ui/src/widgets/login_modal.rs index 7fd6f93cd..a515952ca 100644 --- a/crates/op-editor-ui/src/widgets/login_modal.rs +++ b/crates/op-editor-ui/src/widgets/login_modal.rs @@ -17,6 +17,7 @@ use crate::widgets::canvas_viewport_image::{ }; use crate::widgets::editor_state_ext::theme_for; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Color, Point2D, Rect, RenderBackend, TextLayout}; use op_editor_core::editor_ui_state::Locale; @@ -380,7 +381,7 @@ fn paint_status_note( let font_size = 10.5; let icon_size = 13.0; let gap = 7.0; - let text_width = backend.measure_text(text, font_size); + let text_width = text_metrics::measure_chrome(backend, text, font_size); let group_width = icon_size + gap + text_width; let group_x = status.origin.x + (status.size.x - group_width) / 2.0; draw_icon( diff --git a/crates/op-editor-ui/src/widgets/preview_device_switcher.rs b/crates/op-editor-ui/src/widgets/preview_device_switcher.rs index 191b67725..1b6c5c07b 100644 --- a/crates/op-editor-ui/src/widgets/preview_device_switcher.rs +++ b/crates/op-editor-ui/src/widgets/preview_device_switcher.rs @@ -2,6 +2,7 @@ use op_editor_core::PreviewDeviceKind; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout, Theme}; @@ -78,7 +79,7 @@ impl PreviewDeviceSwitcher<'_> { } let label_text = self.labels[index]; - let text_width = cx.backend.measure_text(label_text, 12.0); + let text_width = text_metrics::measure_chrome(cx.backend, label_text, 12.0); let label = TextLayout::single_run( label_text, "system-ui", diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index 866680131..aeb67bbe0 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -27,6 +27,7 @@ use crate::theme::Theme; use crate::widgets::property_panel_sections as sections; +use crate::widgets::text_metrics; use crate::widgets::WidgetId; use crate::{Point2D, Rect}; use jian_widgets::components::select::SelectState; @@ -403,7 +404,8 @@ pub(super) fn action_wash_rect( }; if let Some(key) = key { let label = op_i18n::translate(locale, key); - let content_right = r.origin.x + RADIO_GUTTER + backend.measure_text(label, 10.0); + let content_right = + r.origin.x + RADIO_GUTTER + text_metrics::measure_chrome(backend, label, 10.0); let left = r.origin.x - ACTION_WASH_PAD_X; let right = (content_right + ACTION_WASH_PAD_X).min(r.origin.x + r.size.x); return Rect { @@ -428,7 +430,7 @@ pub(super) fn action_wash_rect( // is clamped to the cell so a long localized label can't wash over the // adjacent column. let cell_right = r.origin.x + r.size.x; - let content_right = r.origin.x + 22.0 + backend.measure_text(label, 12.0); + let content_right = r.origin.x + 22.0 + text_metrics::measure_chrome(backend, label, 12.0); let left = r.origin.x - ACTION_WASH_PAD_X; let right = (content_right + ACTION_WASH_PAD_X).min(cell_right); return Rect { diff --git a/crates/op-editor-ui/src/widgets/property_panel_code.rs b/crates/op-editor-ui/src/widgets/property_panel_code.rs index 9d5b4449f..dc3a81226 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_code.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_code.rs @@ -11,6 +11,7 @@ use crate::widgets::property_panel_action::{CodegenAction, PropertyPanelAction}; use crate::widgets::property_panel_inputs::{ paint_section_label, INPUT_HEIGHT, PAD_X, SECTION_HEADER_HEIGHT, TAB_HEIGHT, }; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use code_i18n::CodePanelStrings; @@ -271,7 +272,7 @@ fn idle_generate_y(state: &CodegenState, body_y: f32) -> f32 { /// Draw `label` centered horizontally in `[x, x+w]` at baseline `py`. fn draw_centered_line(cx: &mut PaintCx<'_>, text: &str, color: Color, x: f32, w: f32, py: f32) { - let tw = cx.backend.measure_text(text, 13.0); + let tw = text_metrics::measure_chrome(cx.backend, text, 13.0); draw_line(cx, text, color, x + (w - tw) / 2.0, py); } @@ -360,7 +361,7 @@ fn paint_idle_body( if let Some(err) = state.error.as_ref() { let detail = error::display_error_detail(strings, err); let detail = crate::util::ellipsize_to_width(&detail, w - PAD_X * 2.0, |text| { - cx.backend.measure_text(text, 13.0) + text_metrics::measure_chrome(cx.backend, text, 13.0) }); draw_centered_line( cx, diff --git a/crates/op-editor-ui/src/widgets/property_panel_code_error.rs b/crates/op-editor-ui/src/widgets/property_panel_code_error.rs index 6b51094e5..60c1b3c07 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_code_error.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_code_error.rs @@ -10,6 +10,7 @@ use crate::theme::Theme; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::property_panel_action::CodegenAction; use crate::widgets::property_panel_inputs::{INPUT_HEIGHT, PAD_X}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout}; use op_editor_core::codegen::{CodegenHover, CodegenState}; @@ -132,7 +133,7 @@ pub(super) fn paint_error_body( .unwrap_or(strings.generation_failed()), ); let lines = detail_lines(&detail, text_w, |text| { - cx.backend.measure_text(text, DETAIL_FONT_SIZE) + text_metrics::measure_chrome(cx.backend, text, DETAIL_FONT_SIZE) }); for (index, line) in lines.iter().enumerate() { draw_text( @@ -151,7 +152,7 @@ pub(super) fn paint_error_body( .map(|detail| format!("Details: {detail}")) { let diagnostic = crate::util::ellipsize_to_width(&diagnostic, text_w, |text| { - cx.backend.measure_text(text, DETAIL_FONT_SIZE) + text_metrics::measure_chrome(cx.backend, text, DETAIL_FONT_SIZE) }); draw_text( cx, diff --git a/crates/op-editor-ui/src/widgets/property_panel_code_generating.rs b/crates/op-editor-ui/src/widgets/property_panel_code_generating.rs index f1daa242d..78ff9b45e 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_code_generating.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_code_generating.rs @@ -3,6 +3,7 @@ use super::{action_hovered, code_neutral_hover_color, paint_full_button, FullBut use crate::theme::Theme; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::property_panel_inputs::{INPUT_HEIGHT, PAD_X}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::codegen::{ChunkStatus, CodegenHover, CodegenState}; @@ -227,7 +228,7 @@ fn paint_step(cx: &mut PaintCx<'_>, theme: &Theme, step: StepPaint<'_>) { step.rect.origin.x + 30.0, center_y + 4.0, ); - let status_w = cx.backend.measure_text(status_label, 10.0); + let status_w = text_metrics::measure_chrome(cx.backend, status_label, 10.0); draw_text( cx, status_label, diff --git a/crates/op-editor-ui/src/widgets/property_panel_compositing.rs b/crates/op-editor-ui/src/widgets/property_panel_compositing.rs index c7758533f..f2a20547d 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_compositing.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_compositing.rs @@ -153,15 +153,25 @@ pub fn paint_fill_trigger( paint_trigger(cx, theme, fill_trigger_rect(x, y, width), &label); } +const TRIGGER_FONT_SIZE: f32 = 11.0; + fn paint_trigger(cx: &mut PaintCx<'_>, theme: &Theme, rect: Rect, label: &str) { + // jian's SelectTrigger clips its value instead of ellipsizing it — see + // `text_metrics::fit_select_trigger_label`. + let label = crate::widgets::text_metrics::fit_select_trigger_label( + cx.backend, + label, + rect, + TRIGGER_FONT_SIZE, + ); jian_widgets::components::select_trigger::SelectTrigger { icon_paths: None, - label, + label: &label, placeholder: "", hovered: false, pressed: false, enabled: true, - font_size: 11.0, + font_size: TRIGGER_FONT_SIZE, bordered: true, } .paint( diff --git a/crates/op-editor-ui/src/widgets/property_panel_effects.rs b/crates/op-editor-ui/src/widgets/property_panel_effects.rs index c3d1c4230..23f4d3c8f 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_effects.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_effects.rs @@ -7,6 +7,7 @@ use crate::widgets::property_panel_inputs::{ paint_section_divider, paint_section_label_with_add, INPUT_RADIUS, PAD_X, SECTION_GAP, }; use crate::widgets::property_panel_sections::{EditContext, PropertyLabels}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout}; use op_editor_core::PropertyFocus; @@ -197,7 +198,7 @@ fn paint_effect_row( ); let label = effect_label(labels, effect.kind); let max_label_w = (rects.slider.origin.x - (rects.row.origin.x + 27.0) - 4.0).max(1.0); - let measured = cx.backend.measure_text(label, 10.0).max(1.0); + let measured = text_metrics::measure_chrome(cx.backend, label, 10.0).max(1.0); let label_size = (10.0 * max_label_w / measured).clamp(8.0, 10.0); let label_layout = TextLayout::single_run( label, diff --git a/crates/op-editor-ui/src/widgets/property_panel_fill.rs b/crates/op-editor-ui/src/widgets/property_panel_fill.rs index 0f54002f4..bb0cab75f 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_fill.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_fill.rs @@ -25,6 +25,7 @@ use crate::widgets::property_panel_inputs::{ }; use crate::widgets::property_panel_layout::fill_body_height_with_stops; use crate::widgets::property_panel_sections::{EditContext, PropertyLabels}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use jian_ops_schema::node::path::PathFillRule; @@ -325,7 +326,7 @@ fn paint_fill_rule_control( .to_jian(), Point2D::new(0.0, 0.0), ); - let text_w = cx.backend.measure_text(label, 11.0); + let text_w = text_metrics::measure_chrome(cx.backend, label, 11.0); cx.backend.draw_text( &text, Point2D::new( @@ -455,9 +456,17 @@ fn paint_one_fill( } } let dropdown_rect = head.dropdown; + // jian's SelectTrigger clips its value instead of ellipsizing it — see + // `text_metrics::fit_select_trigger_label`. + let fill_label = crate::widgets::text_metrics::fit_select_trigger_label( + cx.backend, + fill_type_label(locale, fill_type), + dropdown_rect, + 12.0, + ); jian_widgets::components::select_trigger::SelectTrigger { icon_paths: None, - label: fill_type_label(locale, fill_type), + label: &fill_label, placeholder: "", hovered: false, pressed: false, @@ -517,9 +526,11 @@ fn paint_one_fill( cx.backend .draw_text(&pct, Point2D::new(pct_x, pct_rect.origin.y + 19.0)); if let Some(pos) = edit.caret_at(opacity_focus) { - let w = cx - .backend - .measure_text(&pct_text[..pos.min(pct_text.len())], 12.0); + let w = text_metrics::measure_chrome( + cx.backend, + &pct_text[..pos.min(pct_text.len())], + 12.0, + ); cx.backend.fill_rect( Rect { origin: Point2D::new(pct_x + w, pct_rect.origin.y + 6.0), diff --git a/crates/op-editor-ui/src/widgets/property_panel_fill_body.rs b/crates/op-editor-ui/src/widgets/property_panel_fill_body.rs index bf4f92642..8b7bb7896 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_fill_body.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_fill_body.rs @@ -14,6 +14,7 @@ use crate::widgets::property_panel_inputs::{ SECTION_HEADER_HEIGHT, }; use crate::widgets::property_panel_sections::EditContext; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::PropertyFocus; @@ -107,9 +108,11 @@ pub(crate) fn paint_fill_solid_body( .draw_text(&hex_layout, Point2D::new(hex_x, hex_rect.origin.y + 19.0)); if variable_ref.is_none() { if let Some(pos) = edit.caret_at(hex_focus) { - let w = cx - .backend - .measure_text(&hex_text[..pos.min(hex_text.len())], 12.0); + let w = text_metrics::measure_chrome( + cx.backend, + &hex_text[..pos.min(hex_text.len())], + 12.0, + ); cx.backend.fill_rect( Rect { origin: Point2D::new(hex_x + w, hex_rect.origin.y + 6.0), @@ -209,9 +212,11 @@ pub(crate) fn paint_fill_gradient_body( cx.backend .draw_text(&value, Point2D::new(value_x, angle_rect.origin.y + 19.0)); if let Some(pos) = edit.caret_at(angle_focus) { - let w = cx - .backend - .measure_text(&value_text[..pos.min(value_text.len())], 12.0); + let w = text_metrics::measure_chrome( + cx.backend, + &value_text[..pos.min(value_text.len())], + 12.0, + ); cx.backend.fill_rect( Rect { origin: Point2D::new(value_x + w, angle_rect.origin.y + 6.0), @@ -332,9 +337,11 @@ pub(crate) fn paint_fill_gradient_body( Point2D::new(hex_text_x, hex_rect.origin.y + 19.0), ); if let Some(pos) = edit.caret_at(hex_focus) { - let w = cx - .backend - .measure_text(&hex_text[..pos.min(hex_text.len())], 12.0); + let w = text_metrics::measure_chrome( + cx.backend, + &hex_text[..pos.min(hex_text.len())], + 12.0, + ); cx.backend.fill_rect( Rect { origin: Point2D::new(hex_text_x + w, hex_rect.origin.y + 6.0), @@ -394,9 +401,11 @@ pub(crate) fn paint_fill_gradient_body( cx.backend .draw_text(&pct_layout, Point2D::new(pct_x, pct_rect.origin.y + 19.0)); if let Some(pos) = edit.caret_at(offset_focus) { - let w = cx - .backend - .measure_text(&pct_text[..pos.min(pct_text.len())], 12.0); + let w = text_metrics::measure_chrome( + cx.backend, + &pct_text[..pos.min(pct_text.len())], + 12.0, + ); cx.backend.fill_rect( Rect { origin: Point2D::new(pct_x + w, pct_rect.origin.y + 6.0), diff --git a/crates/op-editor-ui/src/widgets/property_panel_image_fill.rs b/crates/op-editor-ui/src/widgets/property_panel_image_fill.rs index 7b4c29367..a29cbc4e8 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_image_fill.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_image_fill.rs @@ -13,6 +13,7 @@ use crate::widgets::property_panel_layout::{ action_button_rects_with_fill_picker, VisibleSections, }; use crate::widgets::property_panel_sections::EditContext; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; @@ -617,7 +618,7 @@ fn paint_centered_label( rect: Rect, color: Color, ) { - let w = cx.backend.measure_text(label, 11.0); + let w = text_metrics::measure_chrome(cx.backend, label, 11.0); paint_label( cx, theme, diff --git a/crates/op-editor-ui/src/widgets/property_panel_image_node.rs b/crates/op-editor-ui/src/widgets/property_panel_image_node.rs index 3531df0b7..d30cbb413 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_image_node.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_image_node.rs @@ -16,6 +16,7 @@ use crate::widgets::property_panel_image_preview::paint_image_preview; use crate::widgets::property_panel_inputs::{ paint_section_divider, paint_section_label, INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, }; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout}; @@ -275,7 +276,7 @@ fn paint_warning_row( (text).to_jian(), Point2D::new(0.0, 0.0), ); - let w = cx.backend.measure_text("Relink", 9.0); + let w = text_metrics::measure_chrome(cx.backend, "Relink", 9.0); cx.backend.draw_text( &relink, Point2D::new( @@ -289,7 +290,7 @@ fn paint_warning_row( fn paint_outline_button(cx: &mut PaintCx<'_>, theme: &Theme, rect: Rect, icon: Icon, label: &str) { cx.backend.fill_round_rect(rect, 6.0, theme.card); cx.backend.stroke_round_rect(rect, 6.0, theme.border, 1.0); - let label_w = cx.backend.measure_text(label, 11.0); + let label_w = text_metrics::measure_chrome(cx.backend, label, 11.0); let start_x = rect.origin.x + (rect.size.x - label_w - 17.0) / 2.0; draw_icon( cx.backend, diff --git a/crates/op-editor-ui/src/widgets/property_panel_image_popovers.rs b/crates/op-editor-ui/src/widgets/property_panel_image_popovers.rs index 34fd72934..0bc4c2a4b 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_image_popovers.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_image_popovers.rs @@ -11,6 +11,7 @@ use crate::widgets::property_panel_image_assets::{ }; use crate::widgets::property_panel_image_preview::paint_image_source; use crate::widgets::property_panel_layout::VisibleSections; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, ImageAdjustments, ImageDrawMode, Point2D, Rect, TextLayout}; use op_editor_core::image_panel_state::{ImageGeneratePhase, ImagePanelState, ImageSearchSource}; @@ -65,7 +66,7 @@ fn paint_centered_label( (color).to_jian(), Point2D::new(0.0, 0.0), ); - let w = cx.backend.measure_text(text, size); + let w = text_metrics::measure_chrome(cx.backend, text, size); cx.backend .draw_text(&layout, Point2D::new(centre_x - w / 2.0, baseline)); } @@ -360,7 +361,7 @@ pub fn paint_generate_popover( theme.muted_foreground }; let generate_label = tr(locale, "common.generate"); - let label_w = cx.backend.measure_text(generate_label, 11.0); + let label_w = text_metrics::measure_chrome(cx.backend, generate_label, 11.0); let start_x = btn.origin.x + (btn.size.x - label_w - 18.0) / 2.0; draw_icon( cx.backend, diff --git a/crates/op-editor-ui/src/widgets/property_panel_inputs.rs b/crates/op-editor-ui/src/widgets/property_panel_inputs.rs index 7643f8f66..c10f51c5c 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_inputs.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_inputs.rs @@ -8,6 +8,7 @@ use crate::theme::Theme; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use jian_core::text_input::TextInputState; @@ -191,7 +192,7 @@ pub fn paint_input_with_prefix_focused_state( rect.origin.y + rect.size.y / 2.0 + 4.0, ), ); - let prefix_w = cx.backend.measure_text(prefix, 12.0); + let prefix_w = text_metrics::measure_chrome(cx.backend, prefix, 12.0); let value_x = rect.origin.x + 10.0 + prefix_w + 8.0; let baseline_y = rect.origin.y + rect.size.y / 2.0 + 4.0; if let (true, Some(input)) = (focused, input) { @@ -234,9 +235,8 @@ pub fn paint_input_with_prefix_focused_state( cx.backend .draw_text(&value_layout, Point2D::new(value_x, baseline_y)); if let Some(pos) = caret { - let value_w = cx - .backend - .measure_text(&value[..pos.min(value.len())], 12.0); + let value_w = + text_metrics::measure_chrome(cx.backend, &value[..pos.min(value.len())], 12.0); cx.backend.fill_rect( Rect { origin: Point2D::new(value_x + value_w, rect.origin.y + 6.0), @@ -333,9 +333,8 @@ pub fn paint_input_with_suffix_focused_state( cx.backend .draw_text(&value_layout, Point2D::new(value_x, baseline_y)); if let Some(pos) = caret { - let value_w = cx - .backend - .measure_text(&value[..pos.min(value.len())], 12.0); + let value_w = + text_metrics::measure_chrome(cx.backend, &value[..pos.min(value.len())], 12.0); cx.backend.fill_rect( Rect { origin: Point2D::new(value_x + value_w, rect.origin.y + 6.0), @@ -462,9 +461,8 @@ pub fn paint_input_with_icon_focused_state( cx.backend .draw_text(&value_layout, Point2D::new(value_x, baseline_y)); if let Some(pos) = caret { - let caret_w = cx - .backend - .measure_text(&value[..pos.min(value.len())], 12.0); + let caret_w = + text_metrics::measure_chrome(cx.backend, &value[..pos.min(value.len())], 12.0); cx.backend.fill_rect( Rect { origin: Point2D::new(value_x + caret_w, rect.origin.y + 6.0), diff --git a/crates/op-editor-ui/src/widgets/property_panel_instance.rs b/crates/op-editor-ui/src/widgets/property_panel_instance.rs index 53368b7f1..5226c6bb8 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_instance.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_instance.rs @@ -13,6 +13,7 @@ use crate::widgets::property_panel_inputs::{ }; use crate::widgets::property_panel_sections::PropertyLabels; use crate::widgets::property_panel_visibility::ComponentButtonState; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; @@ -313,23 +314,39 @@ fn paint_button( 1.3, ); } - let label = TextLayout::single_run( + // The clip below is the button's text column; fit the label to it so a + // long localized label ellipsizes inside the button instead of being + // sheared by that clip. + let text_clip = Rect { + origin: Point2D::new(rect.origin.x + 34.0, rect.origin.y), + size: Point2D::new((rect.size.x - 68.0).max(0.0), rect.size.y), + }; + // The centred placement below carries a +12 leading-icon offset, so the + // label's budget is the clip less that offset at BOTH ends — otherwise a + // label fitted to the full clip is shifted right out of it. + const CENTRED_LABEL_OFFSET: f32 = 12.0; + let label_text = text_metrics::fit_chrome( + cx.backend, label_text, + (text_clip.size.x - CENTRED_LABEL_OFFSET * 2.0).max(0.0), + 13.0, + ); + let label = TextLayout::single_run( + &label_text, "system-ui", 13.0, accent.to_jian(), Point2D::new(0.0, 0.0), ); let text_x = if centered { - rect.origin.x + (rect.size.x - cx.backend.measure_text(label_text, 13.0)) / 2.0 + 12.0 + rect.origin.x + + (rect.size.x - text_metrics::measure_chrome(cx.backend, &label_text, 13.0)) / 2.0 + + 12.0 } else { rect.origin.x + 36.0 }; cx.backend.save(); - cx.backend.clip_rect(Rect { - origin: Point2D::new(rect.origin.x + 34.0, rect.origin.y), - size: Point2D::new((rect.size.x - 68.0).max(0.0), rect.size.y), - }); + cx.backend.clip_rect(text_clip); cx.backend.draw_text( &label, Point2D::new(text_x, rect.origin.y + rect.size.y / 2.0 + 4.5), diff --git a/crates/op-editor-ui/src/widgets/property_panel_layer.rs b/crates/op-editor-ui/src/widgets/property_panel_layer.rs index f4f7ef8c7..a4e5f45ca 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_layer.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_layer.rs @@ -7,6 +7,7 @@ use crate::widgets::property_panel_inputs::{ }; use crate::widgets::property_panel_sections::{EditContext, PropertyLabels}; use crate::widgets::property_panel_snapshot::{EllipseArcSummary, NodeSnapshot}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout}; use op_editor_core::PropertyFocus; @@ -155,8 +156,19 @@ fn paint_labeled_input( let baseline_y = rect.origin.y + 19.0; let prefix_x = rect.origin.x + 10.0; + // The prefix label yields to the value: it is fitted to whatever is left + // of the box after the value, its unit and the gaps between them. The + // value is what the user is reading and editing, so a long localized + // label ("Deckkraft") must ellipsize rather than push the digits under + // the clip set above — which shears them with no ellipsis at all. + let value_w = text_metrics::measure_chrome(cx.backend, value, 12.0); + let suffix_w = suffix.map_or(0.0, |unit| { + 6.0 + text_metrics::measure_chrome(cx.backend, unit, 12.0) + }); + let prefix_budget = (rect.size.x - 10.0 - 8.0 - value_w - suffix_w - 8.0).max(0.0); + let prefix = text_metrics::fit_chrome(cx.backend, prefix, prefix_budget, 12.0); let prefix_layout = TextLayout::single_run( - prefix, + &prefix, "system-ui", 12.0, (theme.muted_foreground).to_jian(), @@ -164,7 +176,7 @@ fn paint_labeled_input( ); cx.backend .draw_text(&prefix_layout, Point2D::new(prefix_x, baseline_y)); - let prefix_w = cx.backend.measure_text(prefix, 12.0); + let prefix_w = text_metrics::measure_chrome(cx.backend, &prefix, 12.0); let value_x = prefix_x + prefix_w + 8.0; if !edit.paint_input_view_at( cx, @@ -201,9 +213,7 @@ fn paint_labeled_input( cx.backend .draw_text(&value_layout, Point2D::new(value_x, baseline_y)); if let Some(pos) = edit.caret_at(focus) { - let w = cx - .backend - .measure_text(&value[..pos.min(value.len())], 12.0); + let w = text_metrics::measure_chrome(cx.backend, &value[..pos.min(value.len())], 12.0); cx.backend.fill_rect( Rect { origin: Point2D::new(value_x + w, rect.origin.y + 6.0), @@ -214,7 +224,12 @@ fn paint_labeled_input( } } if let Some(unit) = suffix { - let value_w = cx.backend.measure_text(value, 12.0); + // The unit trails the value, but it is clamped inside the input's + // right padding: the input is clipped to `rect`, so a wide value + // would otherwise push the unit under the clip and shear it. Short + // values — every ordinary case — are unaffected. + let unit_w = text_metrics::measure_chrome(cx.backend, unit, 12.0); + let unit_x = (value_x + value_w + 6.0).min(rect.origin.x + rect.size.x - 8.0 - unit_w); let unit_layout = TextLayout::single_run( unit, "system-ui", @@ -222,10 +237,8 @@ fn paint_labeled_input( (theme.muted_foreground).to_jian(), Point2D::new(0.0, 0.0), ); - cx.backend.draw_text( - &unit_layout, - Point2D::new(value_x + value_w + 6.0, baseline_y), - ); + cx.backend + .draw_text(&unit_layout, Point2D::new(unit_x, baseline_y)); } cx.backend.restore(); } diff --git a/crates/op-editor-ui/src/widgets/property_panel_page.rs b/crates/op-editor-ui/src/widgets/property_panel_page.rs index fec272ab9..74137ed5a 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_page.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_page.rs @@ -7,6 +7,7 @@ use crate::widgets::property_panel_inputs::{ SECTION_GAP, TAB_HEIGHT, }; use crate::widgets::property_panel_sections::EditContext; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::PropertyFocus; @@ -190,7 +191,7 @@ fn paint_background_input( .draw_text(&text, Point2D::new(value_x, rect.origin.y + 19.0)); if let Some(pos) = edit.caret_at(focus) { let prefix = &value[..pos.min(value.len())]; - let caret_x = value_x + cx.backend.measure_text(prefix, 12.0); + let caret_x = value_x + text_metrics::measure_chrome(cx.backend, prefix, 12.0); cx.backend.fill_rect( Rect::xywh(caret_x, rect.origin.y + 6.0, 1.5, rect.size.y - 12.0), theme.foreground, diff --git a/crates/op-editor-ui/src/widgets/property_panel_sections.rs b/crates/op-editor-ui/src/widgets/property_panel_sections.rs index 22bcfb033..dc437c67e 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_sections.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_sections.rs @@ -679,12 +679,16 @@ pub fn paint_size_section( y + SECTION_GAP } +/// Checkbox + label. `w` is the half-column the pair occupies; the label is +/// fitted to what is left of it after the 16px box and its gutter, because a +/// long localized label ("Remplir la hauteur", "高さに合わせる") otherwise +/// runs straight over the neighbouring column and off the rail's right edge. fn paint_check_row( cx: &mut PaintCx<'_>, theme: &Theme, x: f32, y: f32, - _w: f32, + w: f32, label: &str, checked: bool, ) { @@ -701,8 +705,10 @@ fn paint_check_row( box_rect, &crate::widgets::button::tokens_from_theme(theme), ); + let label = + crate::widgets::text_metrics::fit_chrome(cx.backend, label, (w - 22.0).max(0.0), 12.0); let lbl = TextLayout::single_run( - label, + &label, "system-ui", 12.0, (theme.foreground).to_jian(), diff --git a/crates/op-editor-ui/src/widgets/property_panel_stroke.rs b/crates/op-editor-ui/src/widgets/property_panel_stroke.rs index 6a00f2578..c820b4cfd 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_stroke.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_stroke.rs @@ -13,6 +13,7 @@ use crate::widgets::property_panel_inputs::{ }; use crate::widgets::property_panel_mode_popover as mode_popover; use crate::widgets::property_panel_sections::{EditContext, PropertyLabels}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout}; use op_editor_core::{PaddingEditMode, PropertyFocus}; @@ -332,9 +333,11 @@ fn paint_stroke_hex_text( .draw_text(&hex_layout, Point2D::new(hex_x, hex_rect.origin.y + 19.0)); if stroke_variable_ref.is_none() { if let Some(pos) = edit.caret_at(PropertyFocus::StrokeHex) { - let w = cx - .backend - .measure_text(&hex_text[..pos.min(hex_text.len())], 12.0); + let w = text_metrics::measure_chrome( + cx.backend, + &hex_text[..pos.min(hex_text.len())], + 12.0, + ); cx.backend.fill_rect( Rect { origin: Point2D::new(hex_x + w, hex_rect.origin.y + 6.0), diff --git a/crates/op-editor-ui/src/widgets/property_panel_text.rs b/crates/op-editor-ui/src/widgets/property_panel_text.rs index 2d30e2bd7..70f0f96b2 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_text.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_text.rs @@ -15,6 +15,7 @@ use crate::widgets::property_panel_inputs::{ }; use crate::widgets::property_panel_sections::EditContext; use crate::widgets::property_panel_typography::display_font_family; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout}; use op_editor_core::PropertyFocus; @@ -361,7 +362,7 @@ pub fn paint_text_section( caption_color, Point2D::new(0.0, 0.0), ); - let ls_caption_w = cx.backend.measure_text(ls_label, 9.0); + let ls_caption_w = text_metrics::measure_chrome(cx.backend, ls_label, 9.0); cx.backend.draw_text( &ls_caption, Point2D::new(x + width - PAD_X - 2.0 - ls_caption_w, y + 10.0), diff --git a/crates/op-editor-ui/src/widgets/property_panel_typography_paint.rs b/crates/op-editor-ui/src/widgets/property_panel_typography_paint.rs index 2d4c9be29..2c7d22e68 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_typography_paint.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_typography_paint.rs @@ -11,6 +11,7 @@ use crate::theme::Theme; use crate::widgets::button::paint_button_feedback_wash; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::property_panel_layout::VisibleSections; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout}; use jian_widgets::components::select::SelectState; @@ -202,7 +203,7 @@ fn paint_font_picker_layout( (theme.muted_foreground).to_jian(), Point2D::new(0.0, 0.0), ); - let w = cx.backend.measure_text(label_str, 11.0); + let w = text_metrics::measure_chrome(cx.backend, label_str, 11.0); cx.backend.draw_text( &label, Point2D::new( diff --git a/crates/op-editor-ui/src/widgets/property_panel_widget.rs b/crates/op-editor-ui/src/widgets/property_panel_widget.rs index 3630da026..296b41cbe 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_widget.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_widget.rs @@ -21,6 +21,7 @@ use crate::widgets::property_panel_inputs::{ INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT, }; use crate::widgets::property_panel_sections::EditContext; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout}; use op_editor_core::PropertyFocus; @@ -341,7 +342,7 @@ fn paint_list_count_row( (theme.foreground).to_jian(), Point2D::new(0.0, 0.0), ); - let count_w = cx.backend.measure_text(&count_text, 12.0); + let count_w = text_metrics::measure_chrome(cx.backend, &count_text, 12.0); cx.backend.draw_text( &count_layout, Point2D::new(rect.origin.x + rect.size.x - 12.0 - count_w, baseline_y), diff --git a/crates/op-editor-ui/src/widgets/settings_form.rs b/crates/op-editor-ui/src/widgets/settings_form.rs index 9c9e925a6..cb3efe3c9 100644 --- a/crates/op-editor-ui/src/widgets/settings_form.rs +++ b/crates/op-editor-ui/src/widgets/settings_form.rs @@ -9,6 +9,7 @@ use crate::theme::Theme; use crate::widgets::agent_settings_caret::paint_settings_input_view; use crate::widgets::button::paint_ghost_button_feedback; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::editor_ui_state::EditorUiState; @@ -32,11 +33,13 @@ pub(crate) fn draw_text(cx: &mut PaintCx<'_>, text: &str, size: f32, color: Colo /// Trim `value` with a `...` suffix until it measures within `max_w`. pub(crate) fn ellipsize(cx: &mut PaintCx<'_>, value: &str, max_w: f32, size: f32) -> String { - if cx.backend.measure_text(value, size) <= max_w { + if text_metrics::measure_chrome(cx.backend, value, size) <= max_w { return value.to_string(); } let mut out = value.to_string(); - while !out.is_empty() && cx.backend.measure_text(&format!("{out}..."), size) > max_w { + while !out.is_empty() + && text_metrics::measure_chrome(cx.backend, &format!("{out}..."), size) > max_w + { out.pop(); } format!("{out}...") @@ -66,7 +69,7 @@ pub(crate) fn paint_empty( w: f32, ) -> f32 { let shown = ellipsize(cx, text, w, 13.0); - let text_w = cx.backend.measure_text(&shown, 13.0); + let text_w = text_metrics::measure_chrome(cx.backend, &shown, 13.0); draw_text( cx, &shown, diff --git a/crates/op-editor-ui/src/widgets/slides_panel_tabs.rs b/crates/op-editor-ui/src/widgets/slides_panel_tabs.rs index 1ec5e829b..c1b2769b8 100644 --- a/crates/op-editor-ui/src/widgets/slides_panel_tabs.rs +++ b/crates/op-editor-ui/src/widgets/slides_panel_tabs.rs @@ -15,6 +15,7 @@ use super::{ TAB_RADIUS, TAB_ROW_HEIGHT, }; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::top_bar_geometry::estimated_text_width; use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout, Theme}; @@ -206,7 +207,12 @@ impl SlidesPanelTabs { let icon_y = rect.origin.y + (rect.size.y - TAB_ICON_SIZE) / 2.0; if !self.compact { - let width = cx.backend.measure_text(label, TAB_FONT); + let width = text_metrics::measure_chrome_weighted( + cx.backend, + label, + TAB_FONT, + if selected { 600 } else { 400 }, + ); cx.backend.draw_text( &TextLayout::single_run( label, @@ -241,7 +247,7 @@ impl SlidesPanelTabs { TAB_FONT, (rect.size.x - TAB_PAD_X * 2.0 - TAB_ICON_SIZE - TAB_ICON_GAP).max(0.0), ); - let label_w = cx.backend.measure_text(&label, TAB_FONT); + let label_w = text_metrics::measure_chrome_weighted(cx.backend, &label, TAB_FONT, 600); let content_w = TAB_ICON_SIZE + TAB_ICON_GAP + label_w; let icon_x = rect.origin.x + (rect.size.x - content_w) / 2.0; draw_icon( diff --git a/crates/op-editor-ui/src/widgets/slideshow_toolbar.rs b/crates/op-editor-ui/src/widgets/slideshow_toolbar.rs index 3f69c1eef..dccecf02d 100644 --- a/crates/op-editor-ui/src/widgets/slideshow_toolbar.rs +++ b/crates/op-editor-ui/src/widgets/slideshow_toolbar.rs @@ -18,6 +18,7 @@ use op_editor_core::preview_slideshow::SlideshowToolbarButton; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout, Theme}; @@ -158,7 +159,7 @@ impl SlideshowToolbar<'_> { ); } - let text_width = cx.backend.measure_text(self.label, FONT_SIZE); + let text_width = text_metrics::measure_chrome(cx.backend, self.label, FONT_SIZE); let counter_x = pill.origin.x + BUTTON_W + (counter_width(self.label) - text_width) / 2.0; let label = TextLayout::single_run( self.label, diff --git a/crates/op-editor-ui/src/widgets/text_metrics_paint_tests.rs b/crates/op-editor-ui/src/widgets/text_metrics_paint_tests.rs new file mode 100644 index 000000000..c7e0cfa23 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/text_metrics_paint_tests.rs @@ -0,0 +1,247 @@ +//! Cross-panel guard against family-blind text measurement. +//! +//! Every covered panel is painted **twice**: once into a backend where a +//! named family is 40% wider than the default face (the real macOS gap +//! between the bundled Roboto that `RenderBackend::measure_text` resolves and +//! the `.AppleSystemUIFont` a `system-ui` run actually paints), and once into +//! the control where the two agree — which is what every other test backend +//! models, and precisely why this bug class ships green. +//! +//! The assertion is the **difference**: widening the painted family must not +//! push a single new run outside its panel. A widget that fits, centres, or +//! sizes a container against the family-blind number fails immediately — +//! its fitter trims to the wrong budget, so the wider paint spills. A widget +//! that measures through [`crate::widgets::text_metrics`] trims to the real +//! budget and stays inside in both worlds. +//! +//! Diffing rather than asserting absolute containment is deliberate. Some +//! localized strings (long Russian / Hindi empty-state copy) are painted +//! without any fitter at all and overflow in *both* worlds; that is a real +//! but separate layout bug, and folding it in here would bury the signal this +//! guard exists to carry. +//! +//! This test is the asset, not the per-call-site assertions: any future +//! `measure_text` that creeps back into a covered panel fails here. + +use op_editor_core::{EditorState, NodeId}; + +use crate::widgets::test_family_gap_backend::FamilyGapBackend; +use crate::widgets::{PaintCx, Widget}; +use crate::{Point2D, Rect}; + +/// Paint `f` into `backend` and hand it back with everything it drew. +fn paint_into(mut backend: FamilyGapBackend, f: impl FnOnce(&mut PaintCx<'_>)) -> FamilyGapBackend { + { + let mut cx = PaintCx { + backend: &mut backend, + }; + f(&mut cx); + } + backend +} + +/// Paint `f` under both faces and assert the wider one spills no further. +#[track_caller] +fn assert_no_new_overflow(what: &str, container: Rect, f: impl Fn(&mut PaintCx<'_>)) { + let gap = paint_into(FamilyGapBackend::default(), &f); + let control = paint_into(FamilyGapBackend::uniform(), &f); + + assert!( + !gap.runs.is_empty(), + "{what} painted no text — the guard would pass vacuously" + ); + + let gap_over = gap.overflowing(container); + let control_over = control.overflowing(container); + if gap_over.len() <= control_over.len() { + return; + } + let detail: Vec = gap_over + .iter() + .map(|run| { + format!( + "{:?} @x={} spans {}px in {:?} (clip {:?})", + run.text, + run.origin.x, + run.width_in_paint_family(), + run.family, + run.clip.map(|c| (c.origin.x, c.origin.x + c.size.x)), + ) + }) + .collect(); + panic!( + "{what}: painting in the real (wider) family pushed {} run(s) outside \ + [{}, {}] versus {} in the control — something measured with \ + RenderBackend::measure_text instead of crate::widgets::text_metrics.\n {}", + gap_over.len(), + container.origin.x, + container.origin.x + container.size.x, + control_over.len(), + detail.join("\n "), + ); +} + +fn sample_state() -> EditorState { + let mut state = EditorState::sample(); + state.set_single_selection(NodeId::new("n10")); + state +} + +#[test] +fn settings_modal_holds_its_content_column_in_the_painted_family() { + use crate::widgets::agent_settings_panel::{content_viewport, AgentSettingsPanel}; + + for tab in [ + op_editor_core::AgentSettingsTab::Agents, + op_editor_core::AgentSettingsTab::Mcp, + op_editor_core::AgentSettingsTab::Images, + op_editor_core::AgentSettingsTab::System, + ] { + for locale in op_i18n::Locale::ALL { + let mut state = EditorState::default(); + state.editor_ui.locale = locale; + state.editor_ui.agent_settings.tab = tab; + let panel = AgentSettingsPanel::for_editor(&state); + let rect = panel.rect(1200.0, 800.0); + + // The modal rect, not the content column: the sidebar nav paints + // left of the column and is part of the same modal. + assert_no_new_overflow(&format!("settings modal {tab:?}/{locale:?}"), rect, |cx| { + panel.paint(cx, rect) + }); + assert_no_new_overflow( + &format!("settings modal content column {tab:?}/{locale:?}"), + content_viewport(rect), + |cx| panel.paint(cx, rect), + ); + } + } +} + +#[test] +fn property_panel_holds_the_rail_in_the_painted_family() { + use crate::widgets::PropertyPanel; + + for locale in op_i18n::Locale::ALL { + let mut state = sample_state(); + state.editor_ui.locale = locale; + let Some(panel) = PropertyPanel::for_selection(&state) else { + continue; + }; + let rect = Rect { + origin: Point2D::new(920.0, 44.0), + size: Point2D::new(280.0, 1600.0), + }; + + assert_no_new_overflow(&format!("property panel {locale:?}"), rect, |cx| { + panel.paint(cx, rect) + }); + } +} + +#[test] +fn layer_panel_holds_the_rail_in_the_painted_family() { + use crate::widgets::LayerPanel; + + for locale in op_i18n::Locale::ALL { + let mut state = sample_state(); + state.editor_ui.locale = locale; + let panel = LayerPanel::from_editor(&state); + let rect = Rect { + origin: Point2D::new(0.0, 44.0), + size: Point2D::new(240.0, 900.0), + }; + + assert_no_new_overflow(&format!("layer panel {locale:?}"), rect, |cx| { + panel.paint(cx, rect) + }); + } +} + +#[test] +fn chat_panel_holds_its_card_in_the_painted_family() { + use crate::widgets::AIChatPlaceholder; + + for locale in op_i18n::Locale::ALL { + let mut state = EditorState::sample(); + state.editor_ui.locale = locale; + let panel = AIChatPlaceholder::from_editor(&state); + let rect = Rect { + origin: Point2D::new(600.0, 300.0), + size: Point2D::new(360.0, 520.0), + }; + + assert_no_new_overflow(&format!("chat panel {locale:?}"), rect, |cx| { + panel.paint(cx, rect) + }); + } +} + +#[test] +fn variables_panel_holds_its_panel_in_the_painted_family() { + use crate::widgets::variables_panel::VariablesPanel; + + for locale in op_i18n::Locale::ALL { + let mut state = EditorState::sample(); + state.editor_ui.locale = locale; + let panel = VariablesPanel::for_editor(&state); + let rect = Rect { + origin: Point2D::new(320.0, 120.0), + size: Point2D::new(560.0, 420.0), + }; + + assert_no_new_overflow(&format!("variables panel {locale:?}"), rect, |cx| { + panel.paint(cx, rect) + }); + } +} + +#[test] +fn top_bar_holds_the_bar_in_the_painted_family() { + use crate::widgets::{TopBar, TOP_BAR_HEIGHT}; + + for locale in op_i18n::Locale::ALL { + let mut ui = op_editor_core::editor_ui_state::EditorUiState { + account_ui_available: true, + ..Default::default() + }; + ui.locale = locale; + ui.collab.availability = op_editor_core::CollabAvailability::Ready; + let bar = TopBar::for_editor_ui(&ui); + let rect = Rect { + origin: Point2D::ZERO, + size: Point2D::new(1400.0, TOP_BAR_HEIGHT), + }; + + assert_no_new_overflow(&format!("top bar {locale:?}"), rect, |cx| { + bar.paint(cx, rect) + }); + } +} + +/// The guard has to be able to fail. Painting a deliberately family-blind +/// fitter into the pair must be caught — otherwise a green run above proves +/// nothing. +#[test] +#[should_panic(expected = "instead of crate::widgets::text_metrics")] +fn the_guard_catches_a_family_blind_fitter() { + use crate::{Color, TextLayout}; + + let container = Rect::xywh(0.0, 0.0, 100.0, 30.0); + assert_no_new_overflow("deliberately blind widget", container, |cx| { + // Exactly the shipped bug: fit against `measure_text`, paint as a + // named family. The control's two faces agree so it fits there; the + // real face is wider, so it spills. + let fitted = crate::util::ellipsize_to_width("a long chrome label here", 100.0, |s| { + cx.backend.measure_text(s, 12.0) + }); + let layout = TextLayout::single_run( + &fitted, + "system-ui", + 12.0, + Color::WHITE.to_jian(), + Point2D::ZERO, + ); + cx.backend.draw_text(&layout, Point2D::ZERO); + }); +} diff --git a/crates/op-editor-ui/src/widgets/text_selection.rs b/crates/op-editor-ui/src/widgets/text_selection.rs index 9834c68bd..c1b67ebb7 100644 --- a/crates/op-editor-ui/src/widgets/text_selection.rs +++ b/crates/op-editor-ui/src/widgets/text_selection.rs @@ -1,6 +1,7 @@ //! Small paint helpers for whole-input text selection feedback. use crate::theme::Theme; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect}; @@ -16,7 +17,7 @@ pub(super) fn paint_single_line_selection( if text.is_empty() { return; } - let width = cx.backend.measure_text(text, font_size).min(max_x - text_x); + let width = text_metrics::measure_chrome(cx.backend, text, font_size).min(max_x - text_x); if width <= 0.0 { return; } diff --git a/crates/op-editor-ui/src/widgets/top_bar.rs b/crates/op-editor-ui/src/widgets/top_bar.rs index 9d218caeb..99d9a685e 100644 --- a/crates/op-editor-ui/src/widgets/top_bar.rs +++ b/crates/op-editor-ui/src/widgets/top_bar.rs @@ -497,7 +497,8 @@ impl TopBar { // the visible chip so the first press always lands. let status_text = self.chip_status_text(); // Prefer the host-measured text width so the hit area matches the - // painted chip exactly (paint uses skia `measure_text` @ 11 px). + // painted chip exactly. Paint and the host both measure through + // `text_metrics` at 11 px, in the family the chip is drawn in. // Without a measure backend (wasm) fall back to a ~7 px/char // estimate — close to the 11 px-font advance, never the old // 12 px/char + 16 px slop that ballooned the target into the gap. diff --git a/crates/op-editor-ui/src/widgets/top_bar_paint.rs b/crates/op-editor-ui/src/widgets/top_bar_paint.rs index 0089c709e..8954d4cc3 100644 --- a/crates/op-editor-ui/src/widgets/top_bar_paint.rs +++ b/crates/op-editor-ui/src/widgets/top_bar_paint.rs @@ -7,6 +7,7 @@ use crate::theme::Theme; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::top_bar::*; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; @@ -386,7 +387,7 @@ impl TopBar { let show_dot = status_text.is_some(); let chip_text: &str = status_text.as_deref().unwrap_or(self.label_agents_and_mcp); let icons_span = self.agent_icons_span(); - let text_w = cx.backend.measure_text(chip_text, 11.0); + let text_w = text_metrics::measure_chrome(cx.backend, chip_text, 11.0); let chip_rect = self.agent_chip_rect(rect, text_w); // Hover wash behind the whole chip (TS `hover:bg-accent`). let _ = crate::widgets::button::paint_ghost_button_feedback( @@ -561,7 +562,7 @@ fn paint_collaboration_chip( ); cx.backend .draw_text(&overflow_layout, Point2D::new(x, center_y + 3.0)); - x += cx.backend.measure_text(&overflow, 9.0) + 4.0; + x += text_metrics::measure_chrome(cx.backend, &overflow, 9.0) + 4.0; } x += 6.0; } @@ -571,8 +572,15 @@ fn paint_collaboration_chip( x += 10.0; cx.backend.save(); cx.backend.clip_rect(rect); + // The pill's width comes from `estimated_text_width` (a 0.68-em ASCII + // guess made before a painter exists, so hit-test and paint agree on one + // rect). That guess is narrower than the `system-ui` face this label is + // drawn in, so the clip above would shear the last glyph. Fit the label + // to what the pill actually leaves for it and let it ellipsize instead. + let label_max_w = (rect.origin.x + rect.size.x - 9.0 - x).max(0.0); + let label_text = text_metrics::fit_chrome(cx.backend, &model.label, label_max_w, 11.0); let label = TextLayout::single_run( - &model.label, + &label_text, "system-ui", 11.0, foreground.to_jian(), @@ -623,7 +631,7 @@ pub(super) fn paint_account_button( }; cx.backend.fill_oval(avatar_rect, theme.primary); let letter = account.initial().to_string(); - let letter_w = cx.backend.measure_text(&letter, 11.0); + let letter_w = text_metrics::measure_chrome(cx.backend, &letter, 11.0); let label = TextLayout::single_run( &letter, "system-ui", diff --git a/crates/op-editor-ui/src/widgets/variables_preset_menu.rs b/crates/op-editor-ui/src/widgets/variables_preset_menu.rs index a1c08e1eb..529b311a9 100644 --- a/crates/op-editor-ui/src/widgets/variables_preset_menu.rs +++ b/crates/op-editor-ui/src/widgets/variables_preset_menu.rs @@ -17,6 +17,7 @@ use crate::theme::Theme; use crate::widgets::editor_state_ext::theme_for; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::editor_ui_state::Locale; @@ -346,7 +347,8 @@ impl ThemePresetMenu { // Solid caret — same convention as the variables panel's // inline inputs (no blink). let clipped = boundary_at_or_before(&self.name_draft, self.caret_pos); - let caret_x = text_x + cx.backend.measure_text(&self.name_draft[..clipped], 13.0); + let caret_x = + text_x + text_metrics::measure_chrome(cx.backend, &self.name_draft[..clipped], 13.0); cx.backend.fill_rect( Rect { origin: Point2D::new(caret_x, input.origin.y + (input.size.y - 16.0) / 2.0), diff --git a/crates/op-host-native/src/widget_host/text_edit_press.rs b/crates/op-host-native/src/widget_host/text_edit_press.rs index e517d333d..1e2ef6094 100644 --- a/crates/op-host-native/src/widget_host/text_edit_press.rs +++ b/crates/op-host-native/src/widget_host/text_edit_press.rs @@ -94,12 +94,19 @@ impl WidgetHostNative { /// measured with the shared measure-only backend so `TopBar`'s /// agent-chip hit area matches the painted chip exactly instead of a /// char-count estimate that overran into the file-name gap. + /// + /// Family-aware, and it has to stay that way: `top_bar_paint` sizes the + /// painted chip through `op_editor_ui::widgets::text_metrics`, so a + /// family-blind number here would resolve the bundled Roboto and hand + /// back a hit rect narrower than the chip the user can see. pub(in crate::widget_host) fn topbar_chip_text_w( &self, top_bar: &op_editor_ui::widgets::TopBar, ) -> f32 { let chip_text = top_bar.chip_text(); - self.with_measure_only(|backend| backend.measure_text(&chip_text, 11.0)) + self.with_measure_only(|backend| { + op_editor_ui::widgets::text_metrics::measure_chrome(backend, &chip_text, 11.0) + }) } /// Family-aware TopBar hit-test. The centered title group's horizontal diff --git a/tools/check-text-measure.sh b/tools/check-text-measure.sh new file mode 100755 index 000000000..06f232d86 --- /dev/null +++ b/tools/check-text-measure.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# tools/check-text-measure.sh — chrome text must be measured in the family +# it is painted in. +# +# `RenderBackend::measure_text` (jian's `Painter::measure_text`) is +# family-BLIND: it resolves the backend's default typeface — the bundled +# Roboto on native, the CanvasKit default in the browser — while every chrome +# string is DRAWN as a named run (`system-ui`, which macOS resolves to +# `.AppleSystemUIFont`). SF Pro is wider than Roboto at the same point size, +# so the blind call under-reports the painted width. +# +# Nothing errors when that happens. An ellipsizer believes a string fits and +# emits no `…`, so the content clip shears the last glyph in half; a centred +# label lands left of centre; a tooltip bubble sized as `measured + padding` +# is born too narrow for its own text; a caret drifts off the glyph being +# edited. And none of it reproduces in CI, because every test backend +# measures blind and family-aware identically — the bug is visible only by +# eye, on a real machine. That is what makes this worth a build gate. +# +# The sanctioned path is `crate::widgets::text_metrics` (`measure_chrome` / +# `fit_chrome` / `centered_text_x` / `measure_in_family`), which routes to +# `measure_text_family` with the run's real family. +# +# Exit semantics: +# 0 PASS — no family-blind measurement in widget code. +# 1 FAIL — a widget module called `measure_text` directly. + +set -euo pipefail + +WIDGETS="crates/op-editor-ui/src/widgets" + +# Files that legitimately name the blind call: +# text_metrics.rs — defines the sanctioned wrappers, and its own +# tests compare blind against family-aware. +# text_input_backend.rs — a decorating `RenderBackend` that must forward +# every trait method, blind one included. +# test_family_gap_backend.rs / test_capture_backend.rs +# — test backends implementing the trait. +# text_metrics_paint_tests.rs +# — the cross-panel guard's negative control +# paints a deliberately blind fitter to prove the +# guard can fail. +ALLOWED_RE='^crates/op-editor-ui/src/widgets/(text_metrics|text_metrics_paint_tests|text_input_backend|test_family_gap_backend|test_capture_backend)\.rs:' + +# `.measure_text(` as a method call, excluding the family-aware and +# weight-aware siblings (`measure_text_family`, `measure_text_family_styled`, +# `measure_text_weighted`, `measure_text_styled`) and trait-impl signatures +# (`fn measure_text(`). +hits="$(grep -RInE '\.[[:space:]]*measure_text[[:space:]]*\(' "${WIDGETS}" 2>/dev/null \ + | grep -vE "${ALLOWED_RE}" \ + || true)" + +if [ -n "${hits}" ]; then + printf 'FAIL: family-blind text measurement in widget code\n' >&2 + printf '\n' >&2 + printf '%s\n' "${hits}" >&2 + printf '\n' >&2 + printf 'RenderBackend::measure_text resolves the backend default font, not the\n' >&2 + printf 'family these runs are painted with. Measure through\n' >&2 + printf 'crate::widgets::text_metrics instead:\n' >&2 + printf '\n' >&2 + printf ' cx.backend.measure_text(s, size)\n' >&2 + printf ' -> text_metrics::measure_chrome(cx.backend, s, size)\n' >&2 + printf ' ellipsize_to_width(s, w, |t| cx.backend.measure_text(t, size))\n' >&2 + printf ' -> text_metrics::fit_chrome(cx.backend, s, w, size)\n' >&2 + printf ' rect.origin.x + (rect.size.x - measured) / 2.0\n' >&2 + printf ' -> text_metrics::centered_text_x(cx.backend, s, size, rect)\n' >&2 + printf '\n' >&2 + printf 'For a run drawn in some other family (a jian component painting in\n' >&2 + printf '"Inter", a monospace readout), name that family:\n' >&2 + printf ' text_metrics::measure_in_family / fit_in_family.\n' >&2 + exit 1 +fi + +echo "PASS: text measurement check"