diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index b0e5badd2..7a9a68cfb 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -9,6 +9,9 @@ use crate::{ use super::{Action, DockError, DropTarget, Layout, Node, Permissions, Placement, resolve_drop}; +/// Horizontal room around a tab's label, both when measuring its natural width and when cutting it. +const TAB_PADDING: f32 = 24.0; + /// Application-supplied chrome. Body renderers retain their own UI style and contents. #[derive(Clone, Debug)] pub struct DockStyle { @@ -609,7 +612,7 @@ impl Renderer<'_, P> { .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, self.style.font.clone()) .size() .x - + 24.0 + + TAB_PADDING }) .collect(); let fit = tabs::fit(&widths, active, (strip.width() - header_width).max(0.0), 40.0, 22.0); @@ -671,12 +674,15 @@ impl Renderer<'_, P> { let policy = (self.permissions)(panel); let label = (self.label)(panel); let color = if active { self.style.text } else { self.style.inactive_text }; + // A tab squeezed below its natural width cuts its label with '…' inside the padding its + // natural width was measured with, instead of letting it run under its neighbours. let galley = self.content.label(ui, panel, &label, &self.style.font, color).into_galley( ui, - Some(egui::TextWrapMode::Extend), - f32::INFINITY, + Some(egui::TextWrapMode::Truncate), + (rect.width() - TAB_PADDING).max(0.0), self.style.font.clone(), ); + let elided = galley.elided; let response = tabs::Tab::new(self.id.with(("tab", panel)), &label, active) .sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() }) .focus_stroke(Stroke::new(1.0, self.style.accent)) @@ -685,6 +691,7 @@ impl Renderer<'_, P> { painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background }); painter.galley(rect.center() - galley.size() * 0.5, galley.clone(), color); }); + let response = if elided { response.on_hover_text(&label) } else { response }; self.register_interaction(ui.ctx(), response.id); if response.clicked() { self.output.actions.push(Action::Activate { panel: panel.clone() }); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 54d37df6c..db9c8bb4d 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -1090,3 +1090,52 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { ); } } + +#[test] +fn tab_labels_that_do_not_fit_are_truncated_inside_their_tab_and_named_in_a_tooltip() { + let ctx = Context::default(); + ctx.all_styles_mut(|style| style.interaction.tooltip_delay = 0.0); + let layout = Layout { root: Some(tabs(&[1, 2, 3], 0)), floating: vec![] }; + let label = |p: &u32| format!("Eigenschaften und Anpassungen {p}"); + let run = |events: Vec| { + let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 200.0))), events, ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + DockArea::new(Id::new("truncate")).show( + ui, + &layout, + &DockStyle::from_ui(ui), + label, + |_| Permissions::default(), + |ui, _| { + ui.label("Body"); + }, + ); + }); + output.textures_delta.clear(); + output.shapes + }; + run(vec![]); + let shapes = run(vec![]); + let tab = |p: u32| ctx.read_response(Id::new("truncate").with(("tab", &p))).unwrap().rect; + for p in 1..=3 { + let tab = tab(p); + // The clip rect would hide an overflowing label; what is laid out must itself fit. + let painted: Vec = shapes + .iter() + .filter(|shape| matches!(&shape.shape, egui::Shape::Text(_))) + .map(|shape| shape.shape.visual_bounding_rect()) + .filter(|r| tab.contains(r.center())) + .collect(); + assert_eq!(painted.len(), 1, "tab {p}: {painted:?}"); + assert!(tab.contains_rect(painted[0]), "tab {p}: label {:?} overflows {tab:?}", painted[0]); + } + let at = tab(2).center(); + run(vec![Event::PointerMoved(at)]); + let shapes = run(vec![]); + let full = label(&2); + // An elided galley still reports its whole text, so the cut tab label alone would match. + assert!( + shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == full && !text.galley.elided)), + "a truncated tab names its panel in full on hover" + ); +}