From 5c28add6cfc2d381959773a31e2fc501024f1744 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:48:41 +0200 Subject: [PATCH] Truncate dock tab labels that do not fit their tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tab squeezed below its natural width painted its whole label centred and only clipped it, so long names ran over the tab's edges and under its neighbours. The label is now cut with '…' inside the same padding the natural width is measured with, and a cut tab names its panel in full on hover. --- crates/ui/src/docking/surface.rs | 13 +++++++-- crates/ui/src/docking/tests.rs | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) 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" + ); +}