From f29038249880c59eea0068664a7594db3fd7f338 Mon Sep 17 00:00:00 2001 From: Tobias Breckle <25928171+tbreckle@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:57:20 +0200 Subject: [PATCH] fix(ui): wrap long toolbar panel labels and centre one-button panels (#409) (#410) A panel label too wide for its panel (beside its caret) wraps between words onto a second line; a single word wider than the panel stays whole. Each line is centred, and the label's hover area grows to hold both lines. A panel's buttons are centred in the panel, so a one-button panel such as Finish Sketch lines its icon up with its label. Co-authored-by: Claude Opus 5.5 Co-authored-by: bherbruck --- crates/ui-egui/src/i18n_ui_tests.rs | 44 +++++++++++++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 37 ++++++++++++++++-------- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/i18n_ui_tests.rs b/crates/ui-egui/src/i18n_ui_tests.rs index 7c27d391..9e501bed 100644 --- a/crates/ui-egui/src/i18n_ui_tests.rs +++ b/crates/ui-egui/src/i18n_ui_tests.rs @@ -55,3 +55,47 @@ fn translated_dialog_buttons_do_not_wrap() { assert_eq!(out.shapes.iter().find_map(|shape| cancel_rows(&shape.shape)), Some(1)); out.textures_delta.clear(); } + +#[test] +fn long_panel_label_wraps_centred_and_its_button_is_centred() { + fn label_rows(shape: &egui::Shape) -> Option> { + match shape { + egui::Shape::Text(text) if text.galley.job.text == "SKIZZE BEENDEN" => { + Some(text.galley.rows.iter().map(|row| row.rect().translate(text.pos.to_vec2())).collect()) + } + egui::Shape::Vec(shapes) => shapes.iter().find_map(label_rows), + _ => None, + } + } + fn hover_rect(shape: &egui::Shape, hover: egui::Color32, at: egui::Pos2) -> Option { + match shape { + egui::Shape::Rect(r) if r.fill == hover && r.rect.contains(at) => Some(r.rect), + egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| hover_rect(s, hover, at)), + _ => None, + } + } + let mut h = Harness::new(); + h.app.preferences.language = "de".into(); + h.app.home.open = false; + h.app.run("sketch.create", json!({"plane":"xy"})).unwrap(); + h.frames(2); + let label = scenario::handles("panel:SKETCH:FINISH SKETCH")[0].1; + let button = scenario::handles("button:sketch.finish")[0].1; + assert!((label.x - button.x).abs() < 1.0, "the only button sits centred over its label: {label:?} {button:?}"); + + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); + let mut out = + h.ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events: vec![egui::Event::PointerMoved(label)], ..Default::default() }, |ui| { + h.app.ui(ui) + }); + out.textures_delta.clear(); + let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| h.app.ui(ui)); + let rows = out.shapes.iter().find_map(|shape| label_rows(&shape.shape)).expect("the Finish Sketch panel label is drawn"); + assert_eq!(rows.len(), 2, "the label wider than its one-button panel wraps: {rows:?}"); + assert!(rows[0].width() != rows[1].width(), "the lines differ in width: {rows:?}"); + assert!((rows[0].center().x - rows[1].center().x).abs() < 1.0, "each line is centred: {rows:?}"); + let hover = crate::theme::Tokens::get().hover; + let lr = out.shapes.iter().find_map(|shape| hover_rect(&shape.shape, hover, label)).expect("the hovered label is highlighted"); + assert!(rows.iter().all(|row| lr.contains_rect(*row)), "the highlight holds both lines: {lr:?} {rows:?}"); + out.textures_delta.clear(); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 83344f0c..8a5ea8b7 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -555,7 +555,11 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { // Dropped on the panel's free space (all of a collapsed panel): the button goes to // its end. let drawn = if collapsed { 0 } else { shown.len() }; - let free = Rect::from_min_size(pos2(px + 4.0 + drawn as f32 * 40.0, top + 2.0), vec2((width - 8.0 - drawn as f32 * 40.0).max(8.0), 38.0)); + // Buttons narrower than the panel's minimum width (a one-button panel) are centred. + let inset = if drawn == 0 { 0.0 } else { ((width - 8.0 - (drawn as f32 * 40.0 - 2.0)) / 2.0).max(0.0).floor() }; + let bx = px + 4.0 + inset; + let free_x = bx + drawn as f32 * 40.0; + let free = Rect::from_min_size(pos2(free_x, top + 2.0), vec2((px + width - 4.0 - free_x).max(8.0), 38.0)); let fresp = ui.interact(free, ui.id().with(("panel_free", panel)), if collapsed { Sense::click() } else { Sense::hover() }); if fresp.dnd_hover_payload::().is_some() { painter.line_segment([pos2(free.left(), free.top() + 2.0), pos2(free.left(), free.bottom() - 2.0)], Stroke::new(2.0, t.accent)); @@ -584,7 +588,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { } } for (i, c) in shown.iter().enumerate().take(drawn) { - let br = Rect::from_min_size(pos2(px + 4.0 + i as f32 * 40.0, top + 2.0), vec2(38.0, 38.0)); + let br = Rect::from_min_size(pos2(bx + i as f32 * 40.0, top + 2.0), vec2(38.0, 38.0)); let info = c.info(&app.session); let resp = ui.interact(br, ui.id().with(("cmd", c.id)), Sense::click_and_drag()); crate::scenario::publish_handle(&format!("toolbar:{}", c.id), br.center()); @@ -644,13 +648,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { painter.text(br.center(), Align2::CENTER_CENTER, "—", FontId::proportional(14.0), t.border); } // Panel label with drop-down. - let lr = Rect::from_min_size(pos2(px, top + 42.0), vec2(width, 18.0)); let label = app.tr_label(panel).to_string(); - let lresp = ui.interact(lr, ui.id().with(("panel", panel)), Sense::click()); - crate::scenario::publish_handle(&format!("panel:{tab}:{panel}"), lr.center()); - if lresp.hovered() && enabled_panel { - painter.rect_filled(lr, 3.0, t.hover); - } let lc = if *panel == "FINISH SKETCH" { t.sketch_accent } else if enabled_panel { @@ -658,12 +656,27 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { } else { t.text_dim }; - let g = painter.layout_no_wrap(label, FontId::proportional(10.5), lc); - let gx = lr.center().x - (g.size().x + 9.0) / 2.0; - let gy = lr.center().y - g.size().y / 2.0; + // A label too wide for its panel (beside the caret) wraps between words onto a + // second line; a single word wider than the panel stays whole. Each line is + // centred, and the hover area grows to hold them. + let font = FontId::proportional(10.5); + let widest_word = label.split_whitespace().map(|w| painter.layout_no_wrap(w.to_string(), font.clone(), lc).size().x).fold(0.0, f32::max); + let mut job = egui::text::LayoutJob::simple(label, font, lc, (width - 14.0).max(widest_word + 1.0)); + job.wrap.max_rows = 2; + job.halign = egui::Align::Center; + let g = painter.layout_job(job); let gw = g.size().x; + let lr = Rect::from_center_size(pos2(px + width / 2.0, top + 51.0), vec2(width, (g.size().y + 2.0).max(18.0))); + let lresp = ui.interact(lr, ui.id().with(("panel", panel)), Sense::click()); + crate::scenario::publish_handle(&format!("panel:{tab}:{panel}"), lr.center()); + if lresp.hovered() && enabled_panel { + painter.rect_filled(lr, 3.0, t.hover); + } + // Centred rows sit around x = 0, so the galley is placed at the text's centre. + let gx = lr.center().x - 4.5; + let gy = lr.center().y - g.size().y / 2.0; painter.galley(pos2(gx, gy), g, lc); - caret(&painter, pos2(gx + gw + 6.0, lr.center().y), lc); + caret(&painter, pos2(gx + gw / 2.0 + 6.0, lr.center().y), lc); if lresp.clicked() { ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_panel_open"), popup_id)); }