fix(renderer): measure chrome text in its painted font

This commit is contained in:
Fini 2026-08-07 01:55:31 +08:00
parent 15dad86774
commit b6688c8818
58 changed files with 687 additions and 163 deletions

View file

@ -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

View file

@ -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::{

View file

@ -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),

View file

@ -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",

View file

@ -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;

View file

@ -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",

View file

@ -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

View file

@ -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,

View file

@ -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);

View file

@ -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(

View file

@ -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,

View file

@ -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(&quoted, width, |text| {
cx.backend.measure_text(text, 11.0)
text_metrics::measure_chrome(cx.backend, text, 11.0)
});
paint_text(
cx,

View file

@ -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(&notice[..index], FONT) > max_width {
if text_metrics::measure_chrome(cx.backend, &notice[..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.

View file

@ -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,

View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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();

View file

@ -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;
}
}

View file

@ -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.

View file

@ -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,

View file

@ -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);

View file

@ -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,

View file

@ -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<String> {
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;
}

View file

@ -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(

View file

@ -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",

View file

@ -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(

View file

@ -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",

View file

@ -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 {

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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,

View file

@ -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),

View file

@ -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),

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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),

View file

@ -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),

View file

@ -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();
}

View file

@ -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,

View file

@ -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(),

View file

@ -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),

View file

@ -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),

View file

@ -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(

View file

@ -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),

View file

@ -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,

View file

@ -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(

View file

@ -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,

View file

@ -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<String> = 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);
});
}

View file

@ -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;
}

View file

@ -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.

View file

@ -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",

View file

@ -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),

View file

@ -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

75
tools/check-text-measure.sh Executable file
View file

@ -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"