From c98ee15a7fa4ab60899206e73a920eacda1f0a04 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:13:12 -0400 Subject: [PATCH] docking: tabs shrink before they overflow (#295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docking: tabs shrink before they overflow Tabs in a narrow strip shrink, widest first, down to DockStyle::min_tab_width (54 pt at the default font), cutting their labels with "…" and naming them in a tooltip. Only tabs that do not fit at that width go to the overflow menu. * docking: the drag bar narrows once the strip is narrower than the header plus the bar --- crates/ui/README.md | 3 +- crates/ui/src/docking/group_tests.rs | 56 ++++++++++++++++++++++++++- crates/ui/src/docking/render/group.rs | 9 +++-- crates/ui/src/docking/surface.rs | 14 +++++-- crates/ui/src/docking/tests.rs | 51 ++++++++++++------------ crates/ui/src/docking/trial_tests.rs | 5 +-- docs/ui-docking.md | 21 ++++++---- 7 files changed, 113 insertions(+), 46 deletions(-) diff --git a/crates/ui/README.md b/crates/ui/README.md index 593a628d..9b11f3e2 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -107,12 +107,13 @@ indices and widths, overflow indices, and the actual reserved overflow width. It tabs before hiding them, keeps input order and retains the selected tab. Tiny strips may reduce its width to zero. Negative/non-finite dimensions become zero and large dimensions are bounded. The work is linear in tab count with at most 32 fitting passes; the caller caps its own tab count. +Docking tab strips use this policy, with `DockStyle::min_tab_width` as the minimum. `tabs::fit_whole(natural_widths, selected, available, overflow_width)` keeps every shown tab at its natural width and sends the rest to overflow. Tabs are shown in order up to the first that does not fit; the selected tab is always shown in its logical place, taking the place of the last tabs that fit when it lies past them. Only a selected tab wider than the strip minus the overflow button is -narrowed. Docking tab strips use this policy. +narrowed. `tabs::Tab` registers a tab at a supplied rectangle and invokes the application's painter: diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index f6027aed..2e0c5efa 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -263,12 +263,66 @@ fn drag_bar_survives_a_strip_full_of_tabs() { assert!(bar.width() >= 40.0, "{bar:?}"); assert_eq!(bar.right(), 300.0 - 24.0, "the bar ends at the header"); assert_eq!(d.content.strip_width.get(), 300.0, "the header is sized for the whole strip"); - assert!(d.ctx.read_response(Id::new(AREA).with(("overflow", &1u32))).is_some(), "tabs overflow before the bar narrows"); + assert!(d.ctx.read_response(Id::new(AREA).with(("overflow", &1u32))).is_some(), "tabs shrink, then overflow, before the bar narrows"); let out = d.drag(bar.center(), pos2(500.0, 300.0)); assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions); assert_eq!(d.layout.floating[0].groups()[0].panels.len(), 8); } +/// Panels with ordinary names. +struct Named; + +impl DockContent for Named { + fn body(&mut self, ui: &mut egui::Ui, _: &u32) { + ui.label("Body"); + } + fn logical_label(&self, panel: &u32) -> String { + if *panel == 1 { "Properties" } else { "Layers" }.into() + } +} + +#[test] +fn narrow_frames_shrink_their_tabs_before_the_rest_overflow() { + let texts = |d: &testing::DockDriver| { + let mut out = Vec::new(); + fn walk(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)), + egui::Shape::Text(text) => out.push(text.clone()), + _ => {} + } + } + d.shapes().iter().for_each(|clipped| walk(&clipped.shape, &mut out)); + out + }; + let layout = Layout { root: Some(tabs(&[1, 2], 0)), floating: Vec::new() }; + let area = |width: f32| Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 300.0)); + + // Both tabs show, cut to fit, with their labels inside them. + let d = testing::DockDriver::new(layout.clone(), Named).with_area(area(168.0)); + assert!(d.hit(&Hit::Overflow(1)).is_none(), "no overflow while the tabs fit at their minimum width"); + let min = DockStyle::default().min_tab_width; + for (panel, name) in [(1, "Properties"), (2, "Layers")] { + let tab = d.hit(&Hit::Tab(panel)).unwrap_or_else(|| panic!("{name} is hidden")); + assert!(tab.width() >= min - 0.5, "{name}: {tab:?}"); + let label = texts(&d).into_iter().find(|t| t.galley.text() == name && tab.contains(t.pos)).unwrap_or_else(|| panic!("no {name} label")); + assert!(tab.contains_rect(label.visual_bounding_rect()), "{name}: {:?} outside {tab:?}", label.visual_bounding_rect()); + } + let properties = texts(&d).into_iter().find(|t| t.galley.text() == "Properties").unwrap(); + assert!(properties.galley.elided, "the wider tab shrinks to make room"); + + // Too narrow for both at their minimum width: the active tab stays, the other is listed. + let mut d = testing::DockDriver::new(layout, Named).with_area(area(120.0)); + assert!(d.hit(&Hit::Tab(1)).is_some() && d.hit(&Hit::Tab(2)).is_none()); + let overflow = d.hit(&Hit::Overflow(1)).expect("an overflow button"); + d.click(overflow.center()); + d.settle(); + let listed = texts(&d).into_iter().find(|t| t.galley.text() == "Layers").expect("the menu lists Layers"); + assert!(listed.pos.y >= overflow.bottom(), "{listed:?}"); + let out = d.click(listed.visual_bounding_rect().center()); + assert!(out.actions.contains(&Action::Activate { panel: 2 }), "{:?}", out.actions); +} + #[test] fn top_border_drags_the_group_and_the_menu_button_only_clicks() { let content = TestContent { header: 30.0, ..Default::default() }; diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 9b214ee0..be19494a 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -80,7 +80,7 @@ impl Renderer<'_, P> { self.output.focused = Some(selected.clone()); } } - // The drag bar keeps its reserved width: tabs overflow before it narrows. + // The drag bar keeps its reserved width: tabs shrink, then overflow, before it narrows. let bar_width = self.style.drag_bar_width.clamp(0.0, (strip.width() - header_width).max(0.0)); let tabs_width = (strip.width() - header_width - bar_width).max(0.0); let widths: Vec = panels @@ -94,9 +94,10 @@ impl Renderer<'_, P> { + TAB_PADDING }) .collect(); - // Tabs keep their full names; the rest go to the overflow menu. Only an active tab wider - // than the strip is cut, with its name in a tooltip. - let fit = tabs::fit_whole(&widths, active, tabs_width, OVERFLOW_WIDTH); + // The widest tabs shrink first, to a common width no narrower than `min_tab_width`; only + // when every tab at that width cannot fit do the rest go to the overflow menu. A tab + // narrower than its name cuts it with "…" and names it in a tooltip. + let fit = tabs::fit(&widths, active, tabs_width, self.style.min_tab_width, OVERFLOW_WIDTH); let owner = TabGroup { panels, selected, minimized, kind, rect }; let mut x = strip.left(); let mut shown = Vec::new(); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index dc24a187..6acd40dd 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -38,9 +38,14 @@ pub struct DockStyle { pub float_group_label: String, /// Group menu item that moves the whole group into another group (submenu of targets). pub move_group_to_label: String, - /// Minimum width of the drag bar left of the group header. Tabs overflow before the bar - /// narrows. + /// Minimum width of the drag bar left of the group header. Tabs shrink, then overflow, + /// before the bar narrows; a strip narrower than the header plus this width narrows it. pub drag_bar_width: f32, + /// Narrowest a tab gets before the tabs that do not fit go to the overflow menu: about + /// three characters and the "…" at [`Self::font`]'s size, plus the tab's 24 pt padding + /// (54 pt at the default 12 pt font). Tabs whose names are shorter keep their natural + /// width. Set it again after changing the font. + pub min_tab_width: f32, /// Painted under every floating window, so it stays opaque when the panel colors are not. pub floating_background: Color32, pub floating_stroke: Stroke, @@ -135,6 +140,7 @@ impl DockStyle { } pub fn from_visuals(visuals: &egui::Visuals) -> Self { + let font = FontId::proportional(12.0); Self { tab_height: 26.0, gap: 4.0, @@ -146,7 +152,8 @@ impl DockStyle { inactive_text: visuals.weak_text_color(), border: visuals.widgets.noninteractive.bg_stroke, accent: visuals.selection.stroke.color, - font: FontId::proportional(12.0), + min_tab_width: font.size * 2.5 + TAB_PADDING, + font, float_label: "Float Panel".into(), close_label: "Close Panel".into(), move_panel_to_label: "Move Panel to Group".into(), @@ -208,6 +215,7 @@ impl DockStyle { self.border.width, self.font.size, self.drag_bar_width, + self.min_tab_width, self.floating_stroke.width, self.title_height, self.edge_band, diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index d9abfaeb..c4494b79 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -1401,16 +1401,9 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { } #[test] -fn a_narrow_strip_shows_whole_tabs_and_lists_the_others_in_the_overflow_menu() { +fn a_strip_too_narrow_for_its_tabs_at_their_minimum_width_lists_the_others_in_the_overflow_menu() { use egui::accesskit::Role; use egui_kittest::{Harness, kittest::Queryable}; - fn elided(shape: &egui::Shape) -> bool { - match shape { - egui::Shape::Text(text) => text.galley.elided, - egui::Shape::Vec(shapes) => shapes.iter().any(elided), - _ => false, - } - } let area = Id::new("whole-tabs"); let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6], 5)), floating: vec![] }; let mut h = Harness::builder().with_size(vec2(260.0, 240.0)).build_ui(move |ui| { @@ -1430,7 +1423,10 @@ fn a_narrow_strip_shows_whole_tabs_and_lists_the_others_in_the_overflow_menu() { let mut expected: Vec = (1..shown.len() as u32).collect(); expected.push(6); assert_eq!(shown, expected); - assert!(h.output().shapes.iter().all(|shape| !elided(&shape.shape)), "every shown tab keeps its full name"); + // As many tabs show as fit at the minimum width beside the drag bar and the "…" button. + let style = DockStyle::default(); + let room = 260.0 - style.drag_bar_width - 22.0; + assert!(shown.len() as f32 * style.min_tab_width <= room && (shown.len() + 1) as f32 * style.min_tab_width > room, "{shown:?}"); h.get_by_role_and_label(Role::Button, "Panels").click(); h.run_steps(2); let listed: Vec = (1..=6).filter(|p| h.query_by_role_and_label(Role::Button, &format!("Panel {p}")).is_some()).collect(); @@ -1439,13 +1435,13 @@ fn a_narrow_strip_shows_whole_tabs_and_lists_the_others_in_the_overflow_menu() { } #[test] -fn only_an_active_tab_wider_than_the_strip_is_truncated_and_named_in_a_tooltip() { +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(150.0, 200.0))), events, ..Default::default() }; + 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, @@ -1463,23 +1459,24 @@ fn only_an_active_tab_wider_than_the_strip_is_truncated_and_named_in_a_tooltip() }; run(vec![]); let shapes = run(vec![]); - let tab = |p: u32| ctx.read_response(Id::new("truncate").with(("tab", &p))).map(|response| response.rect); - assert!(tab(2).is_none() && tab(3).is_none(), "the other tabs overflow instead of shrinking"); - let active = tab(1).unwrap(); - // The clip rect would hide an overflowing label; what is laid out must itself fit. - let painted: Vec<&egui::epaint::TextShape> = shapes - .iter() - .filter_map(|shape| match &shape.shape { - egui::Shape::Text(text) if active.contains(text.visual_bounding_rect().center()) => Some(text), - _ => None, - }) - .collect(); - assert_eq!(painted.len(), 1, "{painted:?}"); - assert!(painted[0].galley.elided); - assert!(active.contains_rect(painted[0].visual_bounding_rect()), "label {:?} overflows {active:?}", painted[0].visual_bounding_rect()); - run(vec![Event::PointerMoved(active.center())]); + let tab = |p: u32| ctx.read_response(Id::new("truncate").with(("tab", &p))).expect("every tab shrinks to fit").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<&egui::epaint::TextShape> = shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) if tab.contains(text.visual_bounding_rect().center()) => Some(text), + _ => None, + }) + .collect(); + assert_eq!(painted.len(), 1, "tab {p}: {painted:?}"); + assert!(painted[0].galley.elided, "tab {p}"); + assert!(tab.contains_rect(painted[0].visual_bounding_rect()), "tab {p}: label {:?} overflows {tab:?}", painted[0].visual_bounding_rect()); + } + run(vec![Event::PointerMoved(tab(2).center())]); let shapes = run(vec![]); - let full = label(&1); + 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)), diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index aa3ec6ce..0b825da9 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -79,7 +79,7 @@ impl DockContent for GalleyLabels { } #[test] -fn galley_labels_stay_whole_and_only_an_over_wide_active_tab_ends_with_an_ellipsis_inside_it() { +fn galley_labels_in_shrunk_tabs_end_with_an_ellipsis_inside_the_tab_active_or_not() { let panels = [1, 2, 3, 4, 5, 6]; let layout = Layout { root: Some(tabs(&panels, 3)), floating: Vec::new() }; for width in [320.0, 90.0] { @@ -111,8 +111,7 @@ fn galley_labels_stay_whole_and_only_an_over_wide_active_tab_ends_with_an_ellips let Some((clip, text)) = texts.iter().find(|(_, t)| t.galley.text().ends_with(&format!(" {panel}")) && tab.contains(t.pos)) else { panic!("no label in tab {panel}") }; - let cut = width < 100.0; - assert_eq!(last_char(&text.galley), if cut { Some('…') } else { char::from_digit(panel, 10) }, "{width}: tab {panel}"); + assert_eq!(last_char(&text.galley), Some('…'), "{width}: tab {panel}"); let drawn = Rect::from_min_size(text.pos, text.galley.size()); assert!(drawn.right() <= tab.right() + 0.5 && drawn.left() >= tab.left(), "tab {panel}: {drawn:?} in {tab:?}"); assert!(tab.contains_rect(clip.intersect(drawn)), "tab {panel}"); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index fde3e312..3034a125 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -174,13 +174,20 @@ docking and `craft_ui::panes` both draw) for apps that draw their own windows. ## Tab strips -Every shown tab carries its panel's full name. Tabs that do not fit go to the "…" button at the -end of the strip, whose menu lists the hidden panels; choosing one makes it active. The active tab -is always shown, in its logical place: when it lies past the tabs that fit, it takes the place of -the last of them. Only an active tab wider than the room beside the "…" button, or a lone tab -wider than the strip, is cut with "…", and its tooltip names it in full. The drag bar always keeps -its reserved width: tabs go to the overflow menu before it narrows, and only a strip narrower than -the bar itself shrinks it. A tab dropped on the "…" button goes to the end of the group. +Tabs start at their natural width, wide enough for the panel's name. When the strip is too +narrow, the widest tabs shrink first, to a common width; tabs whose names fit that width keep +their natural width. A tab narrower than its name cuts the name with "…" and shows it in full as a +tooltip. Shrunk tabs keep their hit areas, drag and keyboard behaviour, and accessibility labels +with the full names. + +No tab shrinks below `DockStyle::min_tab_width`: by default about three characters and the "…" +plus the tab's padding, 54 pt at the 12 pt font. Only when the tabs at that width do not all fit +do the rest go to the "…" button at the end of the strip, whose menu lists the hidden panels; +choosing one makes it active. The active tab is always shown, in its logical place, and the +others take the remaining room in their order. An active tab with less room than its minimum +width, or a lone tab wider than the strip, is cut the same way. The drag bar always keeps its +reserved width: tabs shrink, then overflow, before it narrows, and only a strip narrower than the +group header plus the bar shrinks it. A tab dropped on the "…" button goes to the end of the group. ## Floating size