fix(editor): center top bar title group

This commit is contained in:
Kayshen-X 2026-07-29 22:35:03 +08:00
parent 2b713635f5
commit 91fa60898e
14 changed files with 256 additions and 70 deletions

View file

@ -354,6 +354,45 @@ impl TopBar {
/// - Sun (third from right) → ToggleTheme
/// - Globe (fourth from right) → ToggleLocale
pub fn hit_test(&self, rect: Rect, point: Point2D) -> Option<TopBarHit> {
if !rect.contains(point) {
return None;
}
let git_rect = if GIT_BUTTON_AVAILABLE && self.file_controls_visible() {
self.git_button_rect(rect)
} else {
Rect::xywh(rect.origin.x, rect.origin.y, 0.0, 0.0)
};
self.hit_test_with_git_rect(rect, point, git_rect)
}
/// Hit-test using the same font-family measurement as paint.
///
/// Native hosts use this path for the centered title group's Git button so
/// its hover/press target follows the painted group even when the system
/// font's advances differ from the cross-platform fallback estimator.
pub fn hit_test_with_measure(
&self,
rect: Rect,
point: Point2D,
measure: impl FnMut(&str, f32) -> f32,
) -> Option<TopBarHit> {
if !rect.contains(point) {
return None;
}
let git_rect = if GIT_BUTTON_AVAILABLE && self.file_controls_visible() {
self.git_button_rect_with_measure(rect, measure)
} else {
Rect::xywh(rect.origin.x, rect.origin.y, 0.0, 0.0)
};
self.hit_test_with_git_rect(rect, point, git_rect)
}
fn hit_test_with_git_rect(
&self,
rect: Rect,
point: Point2D,
git_rect: Rect,
) -> Option<TopBarHit> {
if !(rect).contains(point) {
return None;
}
@ -388,11 +427,12 @@ impl TopBar {
// only — see `GIT_BUTTON_AVAILABLE`; also hidden inside a VS Code
// embed alongside the rest of the file-scoped chrome — the file
// name it hangs off doesn't paint either).
if GIT_BUTTON_AVAILABLE && self.file_controls_visible() {
let git_rect = self.git_button_rect(rect);
if git_rect.size.x > 0.0 && git_rect.contains(point) {
return Some(TopBarHit::ToggleGitPanel);
}
if GIT_BUTTON_AVAILABLE
&& self.file_controls_visible()
&& git_rect.size.x > 0.0
&& git_rect.contains(point)
{
return Some(TopBarHit::ToggleGitPanel);
}
// Right cluster: Maximize / Play / Sun / Globe-with-chevron
// (right→left). Maximize + Play + Sun are normal ICON_BUTTON

View file

@ -259,6 +259,25 @@ impl TopBar {
})
}
/// Git-button geometry using caller-provided family-aware text metrics.
///
/// Paint and native input both use the system font. Keeping this measured
/// variant alongside the deterministic fallback prevents the centered
/// title group's variable width from shifting the visible hover target away
/// from the button.
pub fn git_button_rect_with_measure(
&self,
top_bar_rect: Rect,
measure: impl FnMut(&str, f32) -> f32,
) -> Rect {
self.title_layout(top_bar_rect, measure)
.git_rect
.unwrap_or(Rect {
origin: Point2D::new(top_bar_rect.origin.x, top_bar_rect.origin.y),
size: Point2D::new(0.0, 0.0),
})
}
pub(super) fn git_icon_left(git_button: Rect) -> f32 {
git_button.origin.x + GIT_BUTTON_PAD_X
}
@ -275,6 +294,20 @@ impl TopBar {
(r.size.x > 0.0).then_some(r.origin.x + r.size.x / 2.0)
}
/// Measured counterpart of [`Self::git_button_center_x`] for native
/// popover placement.
pub fn git_button_center_x_with_measure(
&self,
top_bar_rect: Rect,
measure: impl FnMut(&str, f32) -> f32,
) -> Option<f32> {
if !GIT_BUTTON_AVAILABLE || !self.file_controls_visible() {
return None;
}
let r = self.git_button_rect_with_measure(top_bar_rect, measure);
(r.size.x > 0.0).then_some(r.origin.x + r.size.x / 2.0)
}
/// Deterministic title geometry shared by paint, hit-test, and popup
/// anchoring. The estimate is intentionally conservative; paint clips to
/// the returned slot as a final guard against platform font differences.

View file

@ -164,11 +164,13 @@ impl TopBar {
);
// ── Bounded centered file title ─────────────────────
// File name, dirty marker, and Git button share one slot between
// the import control and agent chip. Paint uses the same family
// metrics as the text runs, so the title ends exactly at the
// reserved Git gap instead of inheriting a conservative estimate.
// Clipping remains a hard guard against platform-font differences.
// File name, dirty marker, and Git button form one visual group
// centered against the full window. The slot between the import
// control and agent chip only clamps that group when the viewport
// is too narrow. Paint uses the same family metrics as the text
// runs, so the title ends exactly at the reserved Git gap instead
// of inheriting a conservative estimate. Clipping remains a hard
// guard against platform-font differences.
let title = self.title_layout(rect, |text, size| {
cx.backend.measure_text_family(text, size, "system-ui")
});

View file

@ -1,5 +1,5 @@
use super::top_bar::*;
use super::top_bar_title::elide_filename_to_width;
use super::top_bar_title::{elide_filename_to_width, TopBarTitleLayout};
use crate::theme::Theme;
use crate::widgets::icons::Icon;
use crate::widgets::{PaintCx, Widget};
@ -66,8 +66,21 @@ fn long_dirty_title_stays_between_left_and_right_controls() {
);
}
fn title_group_center_x(bar: &TopBar, layout: &TopBarTitleLayout) -> f32 {
let group_right = layout
.git_rect
.map(|git| git.origin.x + git.size.x)
.or_else(|| {
layout
.edited_x
.map(|edited_x| edited_x + title_test_width(bar.label_edited, 11.0))
})
.unwrap_or_else(|| layout.file_x + title_test_width(&layout.file_name, 13.0));
(layout.file_x + group_right) / 2.0
}
#[test]
fn git_position_does_not_follow_file_name_length() {
fn title_group_stays_at_window_center_for_short_and_long_names() {
let make_bar = |file_name: &str| {
let mut bar = TopBar::new(file_name);
bar.edited = true;
@ -79,18 +92,85 @@ fn git_position_does_not_follow_file_name_length() {
};
let short = make_bar("test.op");
let long = make_bar("openpencil-super-long-project-file-name-for-title-overflow-check (1).op");
let rect = Rect {
origin: Point2D::new(37.0, 0.0),
size: Point2D::new(1_400.0, TOP_BAR_HEIGHT),
};
let short_layout = short.title_layout(rect, title_test_width);
let long_layout = long.title_layout(rect, title_test_width);
let window_center = rect.origin.x + rect.size.x / 2.0;
assert!(short_layout.git_rect.is_some());
assert!(long_layout.git_rect.is_some());
assert!(nearly_eq(
title_group_center_x(&short, &short_layout),
window_center
));
assert!(nearly_eq(
title_group_center_x(&long, &long_layout),
window_center
));
assert!(long_layout.file_name.contains('…'), "{long_layout:?}");
assert!(long_layout.file_name.ends_with(".op"));
}
#[test]
fn title_group_center_is_independent_of_asymmetric_chrome() {
let rect = Rect {
origin: Point2D::ZERO,
size: Point2D::new(1_600.0, TOP_BAR_HEIGHT),
};
let mut compact = TopBar::new("Untitled");
compact.git_branch = Some("main".to_string());
let mut crowded = TopBar::new("Untitled");
crowded.git_branch = Some("main".to_string());
crowded.agent_count = 6;
crowded.connected = [true; 6];
crowded.mcp_count = 8;
crowded.account_button_visible = true;
crowded.collab.visible = true;
crowded.collab.label = "Connected".to_string();
for bar in [&compact, &crowded] {
let layout = bar.title_layout(rect, title_test_width);
assert!(layout.git_rect.is_some(), "{layout:?}");
assert!(nearly_eq(
title_group_center_x(bar, &layout),
rect.size.x / 2.0
));
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn measured_git_hit_tracks_the_painted_centered_group() {
let bar = TopBar::new("centered-document.op");
let rect = Rect {
origin: Point2D::ZERO,
size: Point2D::new(1_200.0, TOP_BAR_HEIGHT),
};
let short_git = short.git_button_rect(rect);
let long_git = long.git_button_rect(rect);
let long_layout = long.title_layout(rect, title_test_width);
let compact_metric = |text: &str, size: f32| text.chars().count() as f32 * size * 0.2;
let measured = bar.git_button_rect_with_measure(rect, compact_metric);
let estimated = bar.git_button_rect(rect);
let point = Point2D::new(
measured.origin.x + measured.size.x / 2.0,
TOP_BAR_HEIGHT / 2.0,
);
assert!(short_git.size.x > 0.0);
assert!(nearly_eq(short_git.origin.x, long_git.origin.x));
assert!(long_layout.file_name.contains('…'), "{long_layout:?}");
assert!(long_layout.file_name.ends_with(".op"));
assert!(
(measured.origin.x - estimated.origin.x).abs() > ICON_BUTTON,
"fixture must distinguish exact and fallback geometry"
);
assert_eq!(
bar.hit_test_with_measure(rect, point, compact_metric),
Some(TopBarHit::ToggleGitPanel)
);
assert_ne!(
bar.hit_test(rect, point),
Some(TopBarHit::ToggleGitPanel),
"fallback geometry intentionally sits elsewhere in this fixture"
);
}
#[test]

View file

@ -1,8 +1,10 @@
//! Bounded file-title layout for the `TopBar`.
//!
//! The title lives in the center slot between the import button and the
//! agent chip. Long file names are middle-elided while the edited marker and
//! Git button keep their own space.
//! agent chip. The visible file-name / edited-marker / Git-button group is
//! centered against the full window whenever the slot has room; left/right
//! chrome only clamps it in genuinely narrow layouts. Long file names are
//! middle-elided while the edited marker and Git button keep their own space.
use crate::widgets::top_bar::*;
use crate::{Point2D, Rect};
@ -24,10 +26,11 @@ pub(super) struct TopBarTitleLayout {
impl TopBar {
/// Compute the title layout using one caller-supplied text metric.
///
/// Paint supplies exact backend metrics while hit-test and popup anchoring
/// use a conservative fallback. The Git anchor itself is fixed, and the
/// edited marker is reserved before the file name is elided, so it cannot
/// be pushed underneath the agent chip by a long path basename.
/// Paint supplies exact backend metrics; native hit-test and popup
/// anchoring pass the same family-aware metric, while backend-less callers
/// use a conservative fallback. The edited marker and Git button are
/// reserved before the file name is elided, so neither can be pushed under
/// the agent chip by a long path basename.
pub(super) fn title_layout(
&self,
top_bar_rect: Rect,
@ -78,12 +81,8 @@ impl TopBar {
} else {
0.0
};
let title_box_w = (slot_width - git_span).clamp(0.0, TITLE_TEXT_MAX_WIDTH);
let desired_box_left = top_bar_rect.origin.x + (top_bar_rect.size.x - title_box_w) / 2.0;
let max_box_left = (slot_right - title_box_w - git_span).max(slot_left);
let title_box_left = desired_box_left.clamp(slot_left, max_box_left);
let max_file_w = (title_box_w - edited_span).max(0.0);
let max_title_w = (slot_width - git_span).clamp(0.0, TITLE_TEXT_MAX_WIDTH);
let max_file_w = (max_title_w - edited_span).max(0.0);
let file_name = elide_filename_to_width(&self.file_name, max_file_w, |candidate| {
measure(candidate, 13.0)
});
@ -94,14 +93,15 @@ impl TopBar {
0.0
};
let title_w = file_w + actual_edited_gap + edited_w;
// Git anchors at the fixed box's right edge. Right-align the measured
// title/status content to that edge so the visible gap stays exactly
// `GIT_GAP`, independent of how much the name was elided.
let file_x = title_box_left + (title_box_w - title_w).max(0.0);
let group_w = title_w + git_span;
let desired_group_left = top_bar_rect.origin.x + (top_bar_rect.size.x - group_w) / 2.0;
let max_group_left = (slot_right - group_w).max(slot_left);
let group_left = desired_group_left.clamp(slot_left, max_group_left);
let file_x = group_left;
let edited_x = self.edited.then_some(file_x + file_w + actual_edited_gap);
let git_rect = show_git.then_some(Rect {
origin: Point2D::new(
title_box_left + title_box_w + GIT_GAP,
group_left + title_w + GIT_GAP,
top_bar_rect.origin.y + (top_bar_rect.size.y - ICON_BUTTON) / 2.0,
),
size: Point2D::new(measured_git_w, ICON_BUTTON),

View file

@ -341,8 +341,10 @@ pub struct WidgetHostNative {
/// Lazily-created measure-only Skia backend for text-edit
/// hit-testing OUTSIDE the paint pass (press / drag / arrow-key
/// line mapping). Same `measure_text_weighted` implementation the
/// paint backend uses, so hit geometry matches painted glyphs.
pub(in crate::widget_host) text_measure: Option<crate::NativeBackend>,
/// paint backend uses, so hit geometry matches painted glyphs. Interior
/// mutability also lets read-only TopBar geometry (hover + Git popover
/// placement) use the same metrics without widening those APIs to `&mut`.
pub(in crate::widget_host) text_measure: std::cell::RefCell<Option<crate::NativeBackend>>,
/// Active panel-resize drag — set when the cursor is pressed
/// within the resize gutter of LayerPanel's right edge or
/// PropertyPanel's left edge.

View file

@ -55,7 +55,7 @@ impl WidgetHostNative {
let mut top_bar = TopBar::for_editor_ui(&self.editor_state.editor_ui);
top_bar.chip_text_w = Some(self.topbar_chip_text_w(&top_bar));
let new_hover = (!chat_surface_owns_point)
.then(|| top_bar.hit_test(tb_rect, Point2D::new(x, y)))
.then(|| self.topbar_hit_test(&top_bar, tb_rect, Point2D::new(x, y)))
.flatten()
.map(op_editor_ui::widgets::editor_state_ext::topbar_button_hover);
if new_hover != self.editor_state.editor_ui.topbar_button_hover {

View file

@ -248,8 +248,9 @@ fn git_panel_hangs_centred_under_the_git_button() {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(vw, TOP_BAR_HEIGHT),
};
let btn_cx = TopBar::for_editor_ui(&host.editor_state().editor_ui)
.git_button_center_x(top_bar_rect)
let top_bar = TopBar::for_editor_ui(&host.editor_state().editor_ui);
let btn_cx = host
.topbar_git_button_center_x(&top_bar, top_bar_rect)
.expect("Git button is shown on desktop");
let panel_cx = r.origin.x + r.size.x / 2.0;
assert!(

View file

@ -49,7 +49,7 @@ impl WidgetHostNative {
image_input_geometry: None,
chat_text_selection_drag: None,
text_edit_selection_drag: None,
text_measure: None,
text_measure: std::cell::RefCell::new(None),
panel_resize: None,
variables_resize: None,
node_drag: None,

View file

@ -17,7 +17,7 @@ fn topbar_point(host: &mut WidgetHostNative, target: TopBarHit) -> Point2D {
let mut x = rect.origin.x;
while x < rect.origin.x + rect.size.x {
let point = Point2D::new(x, TOP_BAR_HEIGHT / 2.0);
if topbar.hit_test(rect, point) == Some(target) {
if host.topbar_hit_test(&topbar, rect, point) == Some(target) {
return point;
}
x += 1.0;

View file

@ -42,7 +42,7 @@ impl WidgetHostNative {
let top_bar = TopBar::for_editor_ui(&self.editor_state.editor_ui);
let min_x = canvas_left + GIT_PANEL_INSET;
let max_x = (canvas_left + canvas_w - pw - GIT_PANEL_INSET).max(min_x);
let origin_x = match top_bar.git_button_center_x(top_bar_rect) {
let origin_x = match self.topbar_git_button_center_x(&top_bar, top_bar_rect) {
Some(cx) => (cx - pw / 2.0).clamp(min_x, max_x),
None => min_x,
};

View file

@ -451,7 +451,7 @@ impl WidgetHostNative {
// Up-caret connecting the panel to the Git button — drawn
// after the panel so its base covers the panel's top border
// and reads as one continuous popover surface.
if let Some(btn_cx) = top_bar.git_button_center_x(top_bar_rect) {
if let Some(btn_cx) = self.topbar_git_button_center_x(&top_bar, top_bar_rect) {
let half = GIT_PANEL_CARET_HALF;
let top = panel_rect.origin.y;
let caret_x = btn_cx.clamp(

View file

@ -109,7 +109,7 @@ impl WidgetHostNative {
};
let mut top_bar = TopBar::for_editor_ui(&self.editor_state.editor_ui);
top_bar.chip_text_w = Some(self.topbar_chip_text_w(&top_bar));
if let Some(hit) = top_bar.hit_test(top_bar_rect, Point2D::new(x, y)) {
if let Some(hit) = self.topbar_hit_test(&top_bar, top_bar_rect, Point2D::new(x, y)) {
self.close_image_popovers_for_higher_overlay();
// A TopBar destination replaces the collaboration Join caret.
// Clear even stale focus so reopening Join cannot resurrect it.

View file

@ -47,6 +47,9 @@ impl RenderBackend for MeasureOnly<'_> {
fn measure_text_weighted(&mut self, text: &str, font_size: f32, weight: u16) -> f32 {
self.inner.measure_text_weighted(text, font_size, weight)
}
fn measure_text_family(&mut self, text: &str, font_size: f32, family: &str) -> f32 {
self.inner.measure_text_family(text, font_size, family)
}
fn text_ascent(&mut self, font_size: f32, weight: u16) -> f32 {
self.inner.text_ascent(font_size, weight)
}
@ -74,25 +77,59 @@ fn inverse_rotate_doc(p: Point2D, node: &SceneNode) -> Point2D {
}
impl WidgetHostNative {
fn with_measure_only<R>(&self, f: impl FnOnce(&mut MeasureOnly<'_>) -> R) -> R {
let mut measure = self
.text_measure
.borrow_mut()
.take()
.unwrap_or_else(|| crate::NativeBackend::with_dpi(1.0));
let out = f(&mut MeasureOnly {
inner: &mut measure,
});
*self.text_measure.borrow_mut() = Some(measure);
out
}
/// Width (px) of the agent chip's text at the paint font size (11),
/// 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.
pub(in crate::widget_host) fn topbar_chip_text_w(
&mut self,
&self,
top_bar: &op_editor_ui::widgets::TopBar,
) -> f32 {
let chip_text = top_bar.chip_text();
let mut measure = self
.text_measure
.take()
.unwrap_or_else(|| crate::NativeBackend::with_dpi(1.0));
let w = MeasureOnly {
inner: &mut measure,
}
.measure_text(&chip_text, 11.0);
self.text_measure = Some(measure);
w
self.with_measure_only(|backend| backend.measure_text(&chip_text, 11.0))
}
/// Family-aware TopBar hit-test. The centered title group's horizontal
/// position depends on its actual glyph advances, so the native target must
/// use the same system-font measurement as paint.
pub(in crate::widget_host) fn topbar_hit_test(
&self,
top_bar: &op_editor_ui::widgets::TopBar,
rect: Rect,
point: Point2D,
) -> Option<op_editor_ui::widgets::TopBarHit> {
self.with_measure_only(|backend| {
top_bar.hit_test_with_measure(rect, point, |text, size| {
backend.measure_text_family(text, size, "system-ui")
})
})
}
/// Exact center of the painted TopBar Git button, shared by the Git
/// popover body and caret placement.
pub(in crate::widget_host) fn topbar_git_button_center_x(
&self,
top_bar: &op_editor_ui::widgets::TopBar,
rect: Rect,
) -> Option<f32> {
self.with_measure_only(|backend| {
top_bar.git_button_center_x_with_measure(rect, |text, size| {
backend.measure_text_family(text, size, "system-ui")
})
})
}
/// The edited Text node's resolved scene node, cloned out of the
@ -117,19 +154,10 @@ impl WidgetHostNative {
node: &SceneNode,
f: impl FnOnce(&TextEditLayout, &mut dyn RenderBackend) -> R,
) -> R {
let mut measure = self
.text_measure
.take()
.unwrap_or_else(|| crate::NativeBackend::with_dpi(1.0));
let out = {
let mut backend = MeasureOnly {
inner: &mut measure,
};
let layout = text_edit_layout(&mut backend, node);
f(&layout, &mut backend)
};
self.text_measure = Some(measure);
out
self.with_measure_only(|backend| {
let layout = text_edit_layout(backend, node);
f(&layout, backend)
})
}
/// Convert a screen point to the edited node's un-rotated doc