From 512b54355cd1418f4e8c56a3fbd7483c3c274bf3 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:12:14 -0400 Subject: [PATCH] docking: apps can draw extra tabs after a group's panel tabs (#356) DockContent::group_extra_tabs_width and group_extra_tabs draw host tabs right after a group's panel tabs, before the drag bar and the header. They fit with the panel tabs as one more tab that never overflows: both shrink widest first to min_tab_width, and in a narrower row the panel tabs overflow while the extra tabs keep that width. --- crates/ui/src/docking/group_tests.rs | 36 +++++++++++++++ crates/ui/src/docking/render/group.rs | 64 ++++++++++++++++++++------- crates/ui/src/docking/surface.rs | 19 ++++++++ docs/ui-docking.md | 6 ++- 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 665e8d73..d6b28adc 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -49,6 +49,9 @@ struct TestContent { header_clicks: usize, /// The strip width the last header was sized for. strip_width: std::cell::Cell, + /// Width the extra tabs ask for, and the rect they were last drawn in. + extra: f32, + extra_rect: Option, floating_size: Option, host_owned: bool, whole_group: bool, @@ -95,6 +98,12 @@ impl DockContent for TestContent { self.header_clicks += 1; } } + fn group_extra_tabs_width(&self, _: &[u32], _: &u32, _: bool) -> f32 { + self.extra + } + fn group_extra_tabs(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) { + self.extra_rect = Some(ui.max_rect()); + } } /// Renders a layout through `show_content` in `area` of an 800x600 window and applies what @@ -288,6 +297,33 @@ fn drag_bar_survives_a_strip_full_of_tabs() { assert_eq!(d.layout.floating[0].groups()[0].panels.len(), 8); } +#[test] +/// The extra tabs follow the last panel tab, before the drag bar and the header. In a narrow row +/// they shrink to the minimum tab width while the panel tabs overflow, and the bar keeps its +/// width; a tab dropped on them goes after the last panel tab. +fn extra_tabs_follow_the_panel_tabs_and_overflow_with_them() { + let content = TestContent { header: 24.0, extra: 100.0, ..Default::default() }; + let layout = Layout { root: Some(tabs(&[1, 2], 0)), floating: Vec::new() }; + let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0))); + let (extra, bar) = (d.content.extra_rect.expect("extra tabs drawn"), d.rect("drag-bar", 1)); + assert_eq!((extra.left(), extra.width()), (d.tab(2).right(), 100.0)); + assert_eq!((bar.left(), bar.right()), (extra.right(), 600.0 - 24.0), "the bar and the header follow"); + d.drag(d.tab(1).center(), extra.center()); + assert_eq!(d.layout.root, Some(tabs(&[2, 1], 1)), "a drop on the extra tabs appends"); + + let content = TestContent { header: 24.0, extra: 100.0, ..Default::default() }; + let layout = Layout { root: Some(tabs(&[1, 2, 3], 0)), floating: Vec::new() }; + let d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 400.0))); + let overflow = d.ctx.read_response(Id::unique(AREA).with(("overflow", &1u32))).expect("the panel tabs overflow").rect; + let (extra, bar) = (d.content.extra_rect.expect("extra tabs drawn"), d.rect("drag-bar", 1)); + assert_eq!(overflow.left(), d.tab(1).right()); + assert_eq!(extra.left(), overflow.right(), "the extra tabs follow the overflow button"); + let min = DockStyle::default().min_tab_width; + assert!(extra.width() >= min - 0.5 && extra.width() < 100.0, "{extra:?}"); + assert_eq!((bar.left(), bar.right()), (extra.right(), 200.0 - 24.0)); + assert!(bar.width() >= 40.0, "{bar:?}"); +} + /// Panels with ordinary names. struct Named; diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 1ddf3bca..e4e04cda 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -3,7 +3,7 @@ use std::{fmt::Debug, hash::Hash, sync::Arc}; use egui::{Color32, Galley, Id, Key, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; use super::{Renderer, clamp_float, stripless}; -use crate::docking::{Action, DropTarget, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING}; +use crate::docking::{Action, DockContent, DropTarget, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING}; use crate::tabs; /// Where a group is drawn. It decides the drop targets the group offers and its gestures. @@ -42,9 +42,9 @@ impl Renderer<'_, P> { /// its whole rect is the body and its only tab has `Rect::NOTHING`. Side splits are offered /// only for a `Frame` or `Floating` group whose selected panel accepts splits. Tab labels /// shrink widest-first to the minimum width, then the remaining tabs move to the overflow menu; - /// hidden overflow tabs still appear in the drop target with `Rect::NOTHING`. `minimized` hides - /// the body but keeps the strip. Tabs, strip, header and drag bar are mirrored in right-to-left - /// layout. + /// hidden overflow tabs still appear in the drop target with `Rect::NOTHING`. The host's extra + /// tabs follow the panel tabs and fit with them ([`fit_with_extra`]). `minimized` hides the body + /// but keeps the strip. Tabs, strip, header and drag bar are mirrored in right-to-left layout. pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) { let Some(selected) = panels.get(active) else { return }; self.record_float(panels, rect.size(), rect.size()); @@ -81,13 +81,7 @@ impl Renderer<'_, P> { let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; if header_width > 0.0 { let header = mirror(Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max)); - let mut child = ui.new_child(UiBuilder::new().scope_id(self.id.with(("header", selected))).max_rect(header)); - child.set_clip_rect(header.intersect(ui.clip_rect())); - self.content.group_header(&mut child, panels, selected, floating); - // A press in the header focuses the shown panel, as a press on its tab does. - if child.rect_contains_pointer(header) && child.input(|i| i.pointer.any_pressed()) { - self.output.focused = Some(selected.clone()); - } + self.host_area(ui, "header", header, selected, |content, ui| content.group_header(ui, panels, selected, floating)); } // 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)); @@ -103,10 +97,12 @@ impl Renderer<'_, P> { + TAB_PADDING }) .collect(); + let wanted = self.content.group_extra_tabs_width(panels, selected, floating); + let wanted = if wanted.is_finite() { wanted.clamp(0.0, tabs_width) } else { 0.0 }; // 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 (fit, extra_width) = fit_with_extra(&widths, active, tabs_width, wanted, self.style.min_tab_width); let owner = TabGroup { panels, selected, minimized, kind, rect }; let mut x = strip.left(); let mut shown = Vec::new(); @@ -138,6 +134,12 @@ impl Renderer<'_, P> { }); x += fit.overflow_width; } + let mut extra = Rect::NOTHING; + if extra_width > 0.0 { + extra = mirror(Rect::from_min_size(pos2(x, strip.top()), vec2(extra_width, strip.height()))); + self.host_area(ui, "extra-tabs", extra, selected, |content, ui| content.group_extra_tabs(ui, panels, selected, floating)); + x += extra_width; + } let bar = mirror(Rect::from_min_max(pos2(x.min(strip.right() - header_width), strip.top()), pos2(strip.right() - header_width, strip.bottom()))); self.group_handle(ui, "drag-bar", bar, &owner, movable); @@ -146,7 +148,7 @@ impl Renderer<'_, P> { self.group_handle(ui, "drag-border", border, &owner, movable); if self.style.wheel_selects_tabs { let tabs_area = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, strip.bottom()))); - self.wheel_tabs(ui, tabs_area, panels, active); + self.wheel_tabs(ui, tabs_area, extra, panels, active); } // Logical order, hidden tabs included without a hit area, for no-op detection. let tabs = @@ -166,6 +168,17 @@ impl Renderer<'_, P> { ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); } + /// A part of the strip the host draws (`header` or `extra-tabs`) at `rect`. A press in it + /// focuses the shown panel, as a press on its tab does. + fn host_area(&mut self, ui: &mut Ui, name: &'static str, rect: Rect, selected: &P, draw: impl FnOnce(&mut dyn DockContent

, &mut Ui)) { + let mut child = ui.new_child(UiBuilder::new().scope_id(self.id.with((name, selected))).max_rect(rect)); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + draw(&mut *self.content, &mut child); + if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) { + self.output.focused = Some(selected.clone()); + } + } + /// The drag bar (`drag-bar`: drag, double-click, and the group menu on a right click, Enter /// or Space) or the top border band (`drag-border`: drag only). Both move the whole group; /// a left click on the bar only focuses it. @@ -209,10 +222,11 @@ impl Renderer<'_, P> { } /// The wheel over a group's strip: down selects the next tab, up the previous, one tab per - /// notch, stopping at the ends. Trackpad scrolling adds up to a notch. - fn wheel_tabs(&mut self, ui: &Ui, strip: Rect, panels: &[P], active: usize) { + /// notch, stopping at the ends. Trackpad scrolling adds up to a notch. The host's extra tabs + /// (`extra`) keep the wheel for their own widgets. + fn wheel_tabs(&mut self, ui: &Ui, strip: Rect, extra: Rect, panels: &[P], active: usize) { let key = self.id.with(("tab-wheel", panels.first())); - if !ui.rect_contains_pointer(strip) { + if !ui.rect_contains_pointer(strip) || ui.rect_contains_pointer(extra) { ui.ctx().data_mut(|d| d.remove::(key)); return; } @@ -476,3 +490,21 @@ impl Renderer<'_, P> { }); } } + +/// Fit the panel tabs and the host's extra tabs (`extra` wide), which fit as one more tab that +/// never overflows: in a row too narrow for every tab at its minimum width, the panel tabs +/// overflow into the room the extra tabs leave at theirs. Returns the extra tabs' width. +fn fit_with_extra(widths: &[f32], active: usize, available: f32, extra: f32, minimum: f32) -> (tabs::TabFit, f32) { + if extra <= 0.0 { + return (tabs::fit(widths, active, available, minimum, OVERFLOW_WIDTH), 0.0); + } + let all: Vec = widths.iter().copied().chain([extra]).collect(); + let mut fit = tabs::fit(&all, active, available, minimum, OVERFLOW_WIDTH); + if fit.overflow.is_empty() { + let width = fit.shown.pop().filter(|(index, _)| *index == widths.len()).map_or(0.0, |(_, width)| width); + return (fit, width); + } + let fit = tabs::fit(widths, active, (available - extra.min(minimum)).max(0.0), minimum, OVERFLOW_WIDTH); + let used = fit.shown.iter().map(|(_, width)| width).sum::() + fit.overflow_width; + (fit, extra.min(available - used).max(0.0)) +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index ec3cc049..3c6417dd 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -911,6 +911,19 @@ pub trait DockContent

{ /// [`DockArea::new`], `active` and the widget's rect when its response reports /// `drag_started()`; the panel then moves as its tab would. fn group_header(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _floating: bool) {} + /// Width the group's extra tabs want: the host's own tabs, drawn right after the group's + /// panel tabs (and their "…" button), before the drag bar. They fit with the panel tabs: + /// both shrink widest first to [`DockStyle::min_tab_width`] (the extra tabs as one tab); in a + /// row narrower still, the panel tabs overflow and the extra tabs keep that width. Zero draws + /// none. + fn group_extra_tabs_width(&self, _panels: &[P], _active: &P, _floating: bool) -> f32 { + 0.0 + } + /// Draw the extra tabs into `ui.max_rect()`, which can be narrower than + /// [`Self::group_extra_tabs_width`] asked for; the host fits its tabs into it, with an + /// overflow of its own. Their widgets take their own input as the header's do, and a press + /// among them focuses the shown panel. A drop on them adds the panel after the last panel tab. + fn group_extra_tabs(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _floating: bool) {} } /// Host-owned drop resolution request, see [`DockContent::resolve_drag`]. @@ -995,6 +1008,12 @@ impl + ?Sized> DockContent

for Overrides<'_, P, C> { fn group_header(&mut self, ui: &mut Ui, panels: &[P], active: &P, floating: bool) { self.inner.group_header(ui, panels, active, floating); } + fn group_extra_tabs_width(&self, panels: &[P], active: &P, floating: bool) -> f32 { + self.inner.group_extra_tabs_width(panels, active, floating) + } + fn group_extra_tabs(&mut self, ui: &mut Ui, panels: &[P], active: &P, floating: bool) { + self.inner.group_extra_tabs(ui, panels, active, floating); + } } /// Optional renderer for applications without an existing docking surface. Apps with custom diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 7fb944ab..f1fd5790 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -131,7 +131,11 @@ its own command path, so pointer, keyboard and control commands share one path. `DockContent

` is the app's adapter. It draws each panel body and gives its label, icon, permissions, size limits, dock column heights, preferred floating size, panel menu and group header. The group header sits at the end of the tab strip; `group_header_width` gets the strip width, so a header that -draws its own tabs can size them to the room left. An icon is an image (`icon`), or drawn in code by `paint_icon`, which +draws its own tabs can size them to the room left. Extra tabs (`group_extra_tabs_width` and +`group_extra_tabs`) sit right after the group's panel tabs, before the drag bar, and fit with +them: both shrink widest first to `min_tab_width`, the extra tabs as one tab, and in a row +narrower still the panel tabs overflow while the extra tabs keep that width. The host fits its +own tabs into the rect it gets. An icon is an image (`icon`), or drawn in code by `paint_icon`, which gets a painter, the icon rect and the tint color and returns true when it painted. Panel bodies keep a stable egui id when they move, so widget memory survives.