mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-10 12:31:46 +00:00
docking: tabs shrink before they overflow (#295)
* 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
This commit is contained in:
1 parent
36db71c5c3
commit
c98ee15a7f
7 files changed
+113
-46
No files matched your search
+2
-1
@@ -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:
|
||||
|
||||
|
||||
@@ -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<u32> 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<u32, Named>| {
|
||||
let mut out = Vec::new();
|
||||
fn walk(shape: &egui::Shape, out: &mut Vec<egui::epaint::TextShape>) {
|
||||
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() };
|
||||
|
||||
@@ -80,7 +80,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<f32> = panels
|
||||
@@ -94,9 +94,10 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<u32> = (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<u32> = (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<Event>| {
|
||||
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)),
|
||||
|
||||
@@ -79,7 +79,7 @@ impl DockContent<u32> 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}");
|
||||
|
||||
+14
-7
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user