fix(renderer): measure chrome text in its painted font
This commit is contained in:
parent
15dad86774
commit
b6688c8818
10
.github/workflows/rust-check.yml
vendored
10
.github/workflows/rust-check.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
247
crates/op-editor-ui/src/widgets/text_metrics_paint_tests.rs
Normal file
247
crates/op-editor-ui/src/widgets/text_metrics_paint_tests.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
75
tools/check-text-measure.sh
Executable 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"
|
||||
Loading…
Reference in a new issue