Merge pull request #6639 from dotCooCoo/fix/issue-6622

Part of #6622: Help Bar hints and tool names show in full on hover when clipped
This commit is contained in:
BFlat
2026-11-12 02:01:07 +07:00
committed by GitHub
4 changed files with 162 additions and 21 deletions
+2 -2
View File
@@ -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());
+37 -3
View File
@@ -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::WidgetText>) -> 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::WidgetText>) -> 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);
}
}
+122 -16
View File
@@ -175,22 +175,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<egui::Event>) -> Vec<StatusText> {
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.
@@ -385,6 +370,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<f64>,
events: Vec<egui::Event>,
draw: DrawBar,
) -> (Vec<StatusText>, Vec<egui::accesskit::Node>) {
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<StatusText>, Vec<egui::accesskit::Node>, Vec<StatusText>) {
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() {
+1
View File
@@ -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.