From 482511390647ddc8e7319700cd01f9345d6019f5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 09:27:45 -0800 Subject: [PATCH] Part of #6622: Help Bar hints and tool names show in full on hover when clipped The status message already ends in an ellipsis and shows its full text on hover when it is clipped. The Help Bar's hint and the tool name in the status bar ran past the window edge instead; the Dimension tool's hint is wider than an 800-point window. Both now end in an ellipsis and show their full text in a tooltip on hover. Screen readers receive the full text, and text that fits shows no tooltip. In a narrow status bar the tool name now ends in an ellipsis before it takes the status message's room. Before, the tool name could leave the message almost no width. The tooltips of the clipped hint, tool name and message wrap to fit inside the window. egui's tooltips are 500 points wide, which is wider than an 800-point window at UI Scaling 1.6 or more. The hint and tool name tests fail without the change. The status message test covers its existing ellipsis and tooltip. --- crates/ui-egui/src/chrome/hints.rs | 4 +- crates/ui-egui/src/chrome/status_bar.rs | 40 ++++++- crates/ui-egui/src/chrome/tests.rs | 138 +++++++++++++++++++++--- docs/target-app-parity.md | 1 + 4 files changed, 162 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/chrome/hints.rs b/crates/ui-egui/src/chrome/hints.rs index 062059e6d..02f34438a 100644 --- a/crates/ui-egui/src/chrome/hints.rs +++ b/crates/ui-egui/src/chrome/hints.rs @@ -1,4 +1,4 @@ -use super::status_bar::ANIMATED_ZOOM_HINT; +use super::status_bar::{ANIMATED_ZOOM_HINT, clipped_label}; use super::{Cow, Sense, Stroke, Tokens, Ui, VectorcraftApp, menus, theme, vec2, widgets}; fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { @@ -249,7 +249,7 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() }); } - ui.label(job); + clipped_label(ui, job); }); }); widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 899508021..1ed8df873 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -80,13 +80,48 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown } }) .unwrap_or(""); - ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim)); + // The tool name leaves the status message its least width and ends in an ellipsis first. + let room = (ui.available_width() - MESSAGE_MIN - ui.spacing().item_spacing.x).max(0.0); + ui.scope(|ui| { + ui.set_max_width(room); + clipped_label(ui, egui::RichText::new(tool).size(11.5).color(t.text_dim)); + }); status_readouts(app, ui, &t); }); }); widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); } +/// The least width the status message keeps when the window is narrow. +const MESSAGE_MIN: f32 = 180.0; + +/// A label that ends in an ellipsis when it is wider than the room left in `ui`. Only a cut label +/// shows its full text in a tooltip; screen readers always receive the full text. +pub(super) fn clipped_label(ui: &mut Ui, text: impl Into) -> egui::Response { + let galley = text.into().into_galley(ui, Some(egui::TextWrapMode::Truncate), ui.available_width(), egui::FontSelection::Default); + let full = galley.elided.then(|| egui::text::LayoutJob { + sections: galley.job.sections.clone(), + text: galley.job.text.clone(), + language: galley.job.language.clone(), + ..Default::default() + }); + let response = ui.add(egui::Label::new(galley).show_tooltip_when_elided(false)); + match full { + Some(job) => full_text_tooltip(response, job), + None => response, + } +} + +/// Show `text` on hover in a tooltip that wraps to fit inside the window. +fn full_text_tooltip(response: egui::Response, text: impl Into) -> egui::Response { + let text = text.into(); + response.on_hover_ui(|ui| { + let margins = ui.spacing().menu_margin.sum().x + 2.0 * ui.spacing().item_spacing.x; + ui.set_max_width(ui.spacing().tooltip_width.min(ui.ctx().content_rect().width() - margins).max(0.0)); + ui.add(egui::Label::new(text).wrap()); + }) +} + /// The most recently measured status widths, scoped to this UI and display settings. pub(super) struct StatusReadoutWidths { pub(super) key: (crate::i18n::Lang, vectorcraft_doc::Unit, f32), @@ -125,7 +160,6 @@ fn status_readout_widths(ui: &Ui, lang: crate::i18n::Lang, unit: vectorcraft_doc /// Allocate the remaining row once, then paint each field inside its own bounded rectangle. /// Child UIs requested with a desired width can shrink to their contents; these rectangles cannot. pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { - const MESSAGE_MIN: f32 = 180.0; let mono = theme::mono(10.5); let proportional = egui::FontId::proportional(10.5); let unit = app.session.general_unit(); @@ -229,7 +263,7 @@ fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egu let response = ui.interact(rect, ui.scope_id().with(("status-field", id)), Sense::hover()); response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text)); if elided { - response.on_hover_text(text); + full_text_tooltip(response, text); } } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 452fec1cc..5966a3e98 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -163,22 +163,7 @@ fn status_readout_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, wi /// Collect status-bar text, positions, clips and elision state with explicit pointer events. fn status_readout_frame_events(app: &mut crate::VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec) -> Vec { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Text(t) => Some(StatusText { - text: t.galley.job.text.clone(), - at: t.pos, - clip: s.clip_rect, - bounds: egui::Rect::from_min_size(t.pos, t.galley.size()), - elided: t.galley.elided, - }), - _ => None, - }) - .collect() + bar_frame(app, ctx, width, None, events, super::status_bar).0 } /// Find a required text record by prefix, failing the test when it is absent. @@ -373,6 +358,127 @@ fn narrow_status_bars_preserve_the_message_and_hide_optional_readouts() { assert!(message.clip.left() >= status_text(&texts, "Selection").bounds.right()); } +/// A function that draws one of the bars at the bottom of the window. +type DrawBar = fn(&mut crate::VectorcraftApp, &mut egui::Ui); + +/// The texts (positions, clips and elision state) and the screen-reader tree of one frame of +/// `draw` in a window `width` points wide, at `time` with `events`. +fn bar_frame( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + width: f32, + time: Option, + events: Vec, + draw: DrawBar, +) -> (Vec, Vec) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); + let input = egui::RawInput { screen_rect: Some(screen), time, events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| draw(app, ui)); + out.textures_delta.clear(); + let texts = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(StatusText { + text: t.galley.job.text.clone(), + at: t.pos, + clip: s.clip_rect, + bounds: egui::Rect::from_min_size(t.pos, t.galley.size()), + elided: t.galley.elided, + }), + _ => None, + }) + .collect(); + let nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + (texts, nodes) +} + +/// The texts of `draw` with the pointer away, the screen-reader tree, and the texts after the +/// pointer has rested on the first text that starts with `prefix` for longer than the tooltip delay. +fn bar_hover( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + width: f32, + prefix: &str, + draw: DrawBar, +) -> (Vec, Vec, Vec) { + let t = ctx.input(|i| i.time) + 1.0; + bar_frame(app, ctx, width, Some(t), vec![egui::Event::PointerGone], draw); + let (texts, nodes) = bar_frame(app, ctx, width, Some(t + 0.1), vec![], draw); + let at = status_text(&texts, prefix); + let pos = at.clip.intersect(at.bounds).center(); + bar_frame(app, ctx, width, Some(t + 1.0), vec![egui::Event::PointerMoved(pos)], draw); + bar_frame(app, ctx, width, Some(t + 3.0), vec![], draw); + let (hovered, _) = bar_frame(app, ctx, width, Some(t + 3.1), vec![], draw); + (texts, nodes, hovered) +} + +/// Checks the field whose full text is `full`: in a window `narrow` wide it ends in an ellipsis +/// inside the window, screen readers get the full text, and resting the pointer on it shows the +/// full text in a tooltip no wider than the window; in a window `wide` wide it shows in full with no +/// tooltip. +fn assert_clipped_text_shows_on_hover(app: &mut crate::VectorcraftApp, ctx: &egui::Context, full: &str, narrow: f32, wide: f32, draw: DrawBar) { + let prefix: String = full.chars().take(4).collect(); + let (texts, nodes, hovered) = bar_hover(app, ctx, narrow, &prefix, draw); + let field = status_text(&texts, &prefix); + assert_eq!(field.text, full); + assert!(field.elided, "{narrow}: the text is clipped: {field:?}"); + assert!(field.bounds.right() <= narrow, "{narrow}: the ellipsis is inside the window: {field:?}"); + assert!(nodes.iter().any(|n| n.value() == Some(full)), "{narrow}: screen readers receive the full text"); + let tip: Vec<_> = hovered.iter().filter(|t| t.text == full && !t.elided).collect(); + assert_eq!(tip.len(), 1, "{narrow}: the tooltip shows the full text: {hovered:?}"); + assert!(tip[0].bounds.left() >= 0.0 && tip[0].bounds.right() <= narrow, "{narrow}: the tooltip wraps within the window: {tip:?}"); + + let (texts, _, hovered) = bar_hover(app, ctx, wide, &prefix, draw); + assert!(!status_text(&texts, &prefix).elided, "{wide}: the text fits"); + assert_eq!(hovered.iter().filter(|t| t.text == full).count(), 1, "{wide}: no tooltip: {hovered:?}"); +} + +/// A status message too long for the bar ends in an ellipsis and shows in full on hover, in a +/// tooltip that wraps within the window (#6622). +#[test] +fn a_clipped_status_message_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + let long = format!("Saved recovery data for {}illustration.vectorcraft", "a-long-folder-name/".repeat(12)); + app.ui.status = long.clone(); + assert_clipped_text_shows_on_hover(&mut app, &ctx, &long, 700.0, 20000.0, super::status_bar); + app.ui.status = "Saved recovery data for Untitled-1".to_owned(); + let (texts, _, hovered) = bar_hover(&mut app, &ctx, 700.0, "Saved", super::status_bar); + assert!(!status_text(&texts, "Saved").elided); + assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Saved")).count(), 1, "a message that fits has no tooltip: {hovered:?}"); +} + +/// The Help Bar's hint ends in an ellipsis when it is wider than the window and shows in full on +/// hover, in a tooltip that wraps within the window (#6622). The Dimension tool's hint is wider +/// than the smallest main window (800 points); 400 points is that window at UI Scaling 2.0. +#[test] +fn a_clipped_help_bar_hint_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + app.select_tool("dimension"); + let hint: String = super::hint_segments(app.session.tool_id()).into_iter().map(|(s, _)| s).collect(); + for narrow in [800.0, 400.0] { + assert_clipped_text_shows_on_hover(&mut app, &ctx, &hint, narrow, 3000.0, super::hint_bar); + } +} + +/// In a narrow status bar the tool name ends in an ellipsis, shows in full on hover, and leaves +/// the status message its room (#6622). 640 and 500 points are the smallest main window (800 +/// points) at UI Scaling 1.25 and 1.6. +#[test] +fn a_clipped_tool_name_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + for narrow in [640.0, 500.0] { + assert_clipped_text_shows_on_hover(&mut app, &ctx, "Selection", narrow, 1400.0, super::status_bar); + let (texts, _) = bar_frame(&mut app, &ctx, narrow, None, vec![], super::status_bar); + let tool = status_text(&texts, "Selection"); + let message = status_text(&texts, "Saved recovery data"); + assert!(message.clip.left() >= tool.bounds.right() && message.clip.width() >= 24.0, "{narrow}: the message keeps room: {texts:?}"); + } +} + /// Busy-job transitions preserve message placement, labels and accessibility. #[test] fn background_activity_keeps_the_status_message_origin() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e5feae68d..7aa08bf61 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -574,6 +574,7 @@ since 2026-10-01. **Review follow-up:** The fit-query follow-up covers fitting after draft dimensions change, updated artwork after a document revision, cache scope and new-artboard fitting/history. Selection uses the existing application bounds cache; global artwork queries include all drawn document art and are cached by active document and revision; draft geometry and target artboards do not limit membership. This is not a native timing claim. - **Stable status-bar readouts:** The status message keeps its left anchor and clipping allocation as live values, canvas hover and background-job state change. Coordinate and spinner slots stay reserved; narrow windows hide optional diagnostics, then coordinates, to preserve message space. Widths are measured from the active font, translated labels and fixed numeric samples, never current readings. Coordinates through +/-9999.99 selected units, timings through 9999.9 ms and FPS through 9999 remain readable. Extreme values are elided with full tooltip/accessibility text; only elided fields show a tooltip. Coordinate inspection retains the last point per document and clears on PointerGone. Regressions cover message origin/clip/bounds/elision invariance, all registered languages and ten units, responsive layouts, coordinate inspection and document switching, and accessible busy transitions. The contributor rebuilt and confirmed the original status-bar issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. Sample widths use a bounded single-entry UI cache keyed by language, unit and pixels per point; two regressions verify reuse during live updates and invalidation after each display-setting change. +- **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged.