From 1b091edefbd1d450d9b82ccc3736ba784be80ade Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:55:27 +0000 Subject: [PATCH] docking: drag tab groups by their drag bar and top border Every group with a tab strip has a drag bar between its tabs and the host's header, at least drag_bar_width wide: tabs shrink and overflow before it narrows. The bar and the 4 pt top border move the whole group with its order and active tab. While dragging, a ghost outline follows the pointer and one destination preview paints on the Tooltip layer. On release DockArea resolves the target and emits one MoveGroup: joined as tabs, split beside a frame, or floated at the group's own size. group_drop is filled only for releases without a target. Ctrl or Cmd held at release always floats, for tab drags too. Escape, focus loss, leaving the window and cancel_drag end the drag without a change. A release over the group's own docked position is a no-op; a floating group released over itself moves there. Double-clicking a tab or the bar of a floating or dock-column group minimizes it to its strip; clicking another tab expands it. The bar is focusable; its menu (right-click, Enter or Space) and the tab menu end with Close Tab Group, Minimize or Expand, Float Tab Group and Group with. Floating windows paint floating_background and floating_stroke under their groups, so they stay opaque over a transparent host. A lone bare panel, like a lone protected one, draws no strip and no bar. The group handles and their tests build on storytold/craft-libs#110 by @Zenodeon. Co-authored-by: Zenodeon --- crates/ui/src/docking/group_tests.rs | 521 +++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 4 +- crates/ui/src/docking/render/floating.rs | 14 +- crates/ui/src/docking/render/group.rs | 306 ++++++++++--- crates/ui/src/docking/render/mod.rs | 18 +- crates/ui/src/docking/render/preview.rs | 125 +++++- crates/ui/src/docking/surface.rs | 44 +- 7 files changed, 944 insertions(+), 88 deletions(-) create mode 100644 crates/ui/src/docking/group_tests.rs diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs new file mode 100644 index 00000000..584fff2e --- /dev/null +++ b/crates/ui/src/docking/group_tests.rs @@ -0,0 +1,521 @@ +//! Whole-group gestures: drag bar, top border, drop resolution, minimize and the floating frame. + +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Color32, Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, UiBuilder, Vec2, pos2, vec2}; + +const AREA: &str = "group-area"; + +fn tabs(panels: &[u32], active: usize) -> Node { + Node::Tabs { panels: panels.to_vec(), active } +} + +/// `[1] | [2, 3]` with panel 3 selected. +fn frames() -> Layout { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(300.0), + first: Box::new(tabs(&[1], 0)), + second: Box::new(tabs(&[2, 3], 1)), + }), + floating: Vec::new(), + } +} + +/// A start dock with one column holding `[2, 3]` (3 selected) above `[4]`, beside canvas 0. +fn docked() -> Layout { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(240.0), + first: Box::new(Node::Dock { + side: Side::Start, + columns: vec![Column::new(vec![Group::new(vec![2, 3], 1), Group::new(vec![4], 0)], 240.0)], + }), + second: Box::new(tabs(&[0], 0)), + }), + floating: Vec::new(), + } +} + +#[derive(Default)] +struct TestContent { + protected: Vec, + header: f32, + header_clicks: usize, + floating_size: Option, + host_owned: bool, + whole_group: bool, +} + +impl DockContent for TestContent { + fn body(&mut self, ui: &mut egui::Ui, _: &u32) { + ui.label("Body"); + } + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } + fn permissions(&self, panel: &u32) -> Permissions { + if self.protected.contains(panel) { Permissions::PROTECTED } else { Permissions::default() } + } + fn floating_size(&self, _: &u32) -> Option { + self.floating_size + } + fn resolve_drag(&mut self, _: &Context, _: &DockStyle, request: &DropRequest<'_, u32>) -> Option { + self.whole_group |= request.whole_group; + self.host_owned.then(DropFeedback::default) + } + fn group_header_width(&self, _: &[u32], _: &u32, _: bool) -> f32 { + self.header + } + fn group_header(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) { + if ui.button("≡").clicked() { + self.header_clicks += 1; + } + } +} + +/// Renders a layout through `show_content` in `area` of an 800x600 window and applies what +/// it emits, the way an application's command handler would. +struct Driver { + ctx: Context, + layout: Layout, + content: TestContent, + area: Rect, + modifiers: Modifiers, + focused: bool, + transparent: bool, + shapes: Vec, + time: f64, +} + +impl Driver { + fn new(layout: Layout) -> Self { + Self::with(layout, TestContent::default(), Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))) + } + + fn with(layout: Layout, content: TestContent, area: Rect) -> Self { + let mut driver = Self { + ctx: Context::default(), + layout, + content, + area, + modifiers: Modifiers::NONE, + focused: true, + transparent: false, + shapes: Vec::new(), + time: 0.0, + }; + driver.step(vec![]); + driver.step(vec![]); + driver + } + + fn frame(&mut self, mut events: Vec) -> DockOutput { + let mut output = DockOutput::default(); + events.insert(0, Event::ModifiersChanged(self.modifiers)); + self.time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), + events, + focused: self.focused, + time: Some(self.time), + ..Default::default() + }; + let (layout, content, area, transparent) = (&self.layout, &mut self.content, self.area, self.transparent); + let mut full = self.ctx.run_ui(input, |ui| { + let mut style = DockStyle::from_ui(ui); + if transparent { + style.background = Color32::TRANSPARENT; + style.tab_background = Color32::TRANSPARENT; + } + ui.scope_builder(UiBuilder::new().max_rect(area), |ui| { + output = DockArea::new(Id::new(AREA)).show_content(ui, layout, &style, content); + }); + }); + full.textures_delta.clear(); + self.shapes = full.shapes; + output + } + + fn step(&mut self, events: Vec) -> DockOutput { + let output = self.frame(events); + assert!(output.error.is_none(), "{output:?}"); + let permissions = |p: &u32| self.content.permissions(p); + for action in &output.actions { + self.layout.apply_with_permissions(action.clone(), permissions).unwrap(); + } + output + } + + fn rect(&self, name: &str, panel: u32) -> Rect { + self.ctx.read_response(Id::new(AREA).with((name, Some(&panel)))).unwrap().rect + } + + fn tab(&self, panel: u32) -> Rect { + self.ctx.read_response(Id::new(AREA).with(("tab", &panel))).unwrap().rect + } + + /// Press at `from`, pass the drag threshold, move to `to`; returns the last move's output. + fn hold(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput { + self.step(vec![Event::PointerMoved(from), button(from, true)]); + self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]); + self.step(vec![Event::PointerMoved(to)]) + } + + /// A full drag; returns the release frame's output. + fn drag(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput { + self.hold(from, to); + self.step(vec![button(to, false)]) + } + + /// Lets egui's double- and triple-click windows pass. + fn wait(&mut self) { + for _ in 0..45 { + self.step(vec![]); + } + } + + fn click(&mut self, at: egui::Pos2) -> DockOutput { + self.step(vec![Event::PointerMoved(at), button(at, true)]); + self.step(vec![button(at, false)]) + } +} + +fn button(pos: egui::Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn key(key: egui::Key) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } +} + +fn moved_group(actions: &[Action]) -> Option<&Target> { + actions.iter().find_map(|a| match a { + Action::MoveGroup { to, .. } => Some(to), + _ => None, + }) +} + +#[test] +fn docked_two_tab_group_drags_by_its_bar_and_keeps_order_and_active_tab() { + // Onto another group's strip: one MoveGroup, both panels in order, 3 still selected. + let mut d = Driver::new(frames()); + let bar = d.rect("drag-bar", 2); + assert!(bar.width() >= 40.0); + let out = d.drag(bar.center(), d.tab(1).center()); + assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, placement: Placement::Tab { .. } })), "{:?}", out.actions); + assert_eq!(out.actions.len(), 1); + assert!(out.group_drop.is_none(), "a resolved target leaves group_drop empty"); + assert_eq!(d.layout.root, Some(tabs(&[1, 2, 3], 2))); + + // Onto empty space: a floating window at the group's docked size. + let mut d = Driver::new(frames()); + let border = d.rect("drag-border", 2); + let bar = d.rect("drag-bar", 2); + let out = d.drag(bar.center(), pos2(700.0, 200.0)); + let Some(Target::Float { rect: Some([_, _, width, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) }; + assert_eq!((*width, *height), (border.width(), 600.0)); + assert_eq!(out.group_drop, Some((2, pos2(700.0, 200.0)))); + assert_eq!(d.layout.root, Some(tabs(&[1], 0))); + assert_eq!(d.layout.floating[0].groups, vec![Group::new(vec![2, 3], 1)]); + + // The same group in a dock column joins the group below it. + let mut d = Driver::new(docked()); + let bar = d.rect("drag-bar", 2); + let out = d.drag(bar.center(), d.tab(4).center()); + assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 4, .. })), "{:?}", out.actions); + let Some(Node::Split { first, .. }) = &d.layout.root else { panic!() }; + let Node::Dock { columns, .. } = first.as_ref() else { panic!() }; + assert_eq!(columns[0].groups.len(), 1); + assert_eq!(columns[0].groups[0].panels, vec![4, 2, 3]); + assert_eq!(columns[0].groups[0].active_panel(), Some(&3)); +} + +#[test] +fn drag_bar_survives_a_strip_full_of_tabs() { + let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6, 7, 8], 0)), floating: Vec::new() }; + let content = TestContent { header: 24.0, ..Default::default() }; + let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, 400.0))); + let bar = d.rect("drag-bar", 1); + assert!(bar.width() >= 40.0, "{bar:?}"); + assert_eq!(bar.right(), 300.0 - 24.0, "the bar ends at the header"); + assert!(d.ctx.read_response(Id::new(AREA).with(("overflow", &1u32))).is_some(), "tabs 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); +} + +#[test] +fn top_border_drags_the_group_and_the_menu_button_only_clicks() { + let content = TestContent { header: 30.0, ..Default::default() }; + let mut d = Driver::with(frames(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); + let border = d.rect("drag-border", 2); + assert!(border.height() <= 4.0); + // The band lies over the tabs and wins there. + let over_tab = pos2(d.tab(2).center().x, border.center().y); + let menu = pos2(576.0, d.tab(2).center().y); + + // Pressing ≡ and dragging moves nothing; clicking it reaches the host. + let held = d.hold(menu, menu + vec2(0.0, 200.0)); + assert!(held.group_drag.is_none()); + let out = d.step(vec![button(menu + vec2(0.0, 200.0), false)]); + assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveGroup { .. } | Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions); + d.click(menu); + assert_eq!(d.content.header_clicks, 1); + + let out = d.drag(over_tab, d.tab(1).center()); + assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, .. })), "{:?}", out.actions); + assert_eq!(d.layout.root, Some(tabs(&[1, 2, 3], 2))); +} + +#[test] +fn group_drop_on_a_frame_edge_splits_and_on_a_protected_canvas_centre_floats() { + let canvas = || TestContent { protected: vec![1], ..Default::default() }; + let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)); + let mut d = Driver::with(frames(), canvas(), area); + let out = d.drag(d.rect("drag-bar", 2).center(), pos2(20.0, 300.0)); + assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, placement: Placement::Split(Zone::Left) })), "{:?}", out.actions); + let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() }; + assert_eq!(**first, tabs(&[2, 3], 1)); + assert_eq!(**second, tabs(&[1], 0)); + + // The canvas centre takes nothing: no join, no preview; the group floats there. + let mut d = Driver::with(frames(), canvas(), area); + let out = d.drag(d.rect("drag-bar", 2).center(), pos2(150.0, 300.0)); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions); + assert_eq!(d.layout.root, Some(tabs(&[1], 0))); +} + +#[test] +fn ctrl_or_cmd_held_at_release_always_floats() { + for modifiers in [Modifiers::CTRL, Modifiers::MAC_CMD, Modifiers::COMMAND] { + let mut d = Driver::new(frames()); + let target = d.tab(1).center(); + d.hold(d.rect("drag-bar", 2).center(), target); + d.modifiers = modifiers; + let out = d.step(vec![button(target, false)]); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{modifiers:?}: {:?}", out.actions); + assert_eq!(d.layout.root, Some(tabs(&[1], 0))); + + // A tab drag floats the same way. + let mut d = Driver::new(frames()); + let target = d.tab(1).center(); + d.hold(d.tab(3).center(), target); + d.modifiers = modifiers; + let out = d.step(vec![button(target, false)]); + assert!(out.actions.iter().any(|a| matches!(a, Action::Float { panel: 3, .. })), "{modifiers:?}: {:?}", out.actions); + } +} + +#[test] +fn group_drag_escape_focus_loss_pointer_exit_and_cancel_drag_leave_the_layout_unchanged() { + for case in ["escape", "focus", "exit", "cancel"] { + let mut d = Driver::new(frames()); + let target = d.tab(1).center(); + let held = d.hold(d.rect("drag-bar", 2).center(), target); + assert_eq!(held.group_drag, Some(2)); + let out = match case { + "escape" => d.step(vec![key(egui::Key::Escape)]), + "focus" => { + d.focused = false; + let out = d.step(vec![]); + d.focused = true; + out + } + "exit" => d.step(vec![Event::PointerGone]), + _ => { + cancel_drag::(&d.ctx, Id::new(AREA)); + d.step(vec![Event::PointerMoved(target + vec2(1.0, 0.0))]) + } + }; + assert!(out.actions.is_empty() && out.group_drop.is_none(), "{case}: {out:?}"); + let out = d.step(vec![Event::PointerMoved(target), button(target, false)]); + assert!(out.actions.is_empty() && out.group_drop.is_none() && out.group_drag.is_none(), "{case}: {out:?}"); + assert_eq!(d.layout, frames(), "{case}"); + assert!(!is_dragging::(&d.ctx, Id::new(AREA)), "{case}"); + } +} + +#[test] +fn floated_panel_uses_floating_size_not_the_column_height() { + for size in [Some(vec2(200.0, 150.0)), None] { + let mut layout = docked(); + let Some(Node::Split { first, .. }) = &mut layout.root else { panic!() }; + **first = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![2, 3], 1)], 240.0)] }; + let content = TestContent { protected: vec![0], floating_size: size, ..Default::default() }; + let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); + let out = d.drag(d.tab(3).center(), pos2(700.0, 100.0)); + let Some([_, _, width, height]) = out.actions.iter().find_map(|a| match a { + Action::Float { panel: 3, rect } => Some(*rect), + _ => None, + }) else { + panic!("{:?}", out.actions) + }; + match size { + Some(size) => assert_eq!((width, height), (size.x, size.y)), + None => assert!(height <= 320.0, "{height}"), + } + } +} + +#[test] +fn floating_window_is_opaque_with_a_transparent_host_background() { + let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; + let mut d = Driver::new(layout); + d.transparent = true; + d.step(vec![]); + let window = Rect::from_min_size(pos2(80.0, 70.0), vec2(300.0, 220.0)); + let fill = d.ctx.global_style().visuals.window_fill; + assert_ne!(fill, Color32::TRANSPARENT); + fn filled(shape: &egui::Shape, rect: Rect, fill: Color32) -> bool { + match shape { + egui::Shape::Rect(shape) => shape.rect == rect && shape.fill == fill, + egui::Shape::Vec(shapes) => shapes.iter().any(|shape| filled(shape, rect, fill)), + _ => false, + } + } + assert!(d.shapes.iter().any(|clipped| filled(&clipped.shape, window, fill)), "no opaque fill under the floating window"); +} + +#[test] +fn double_click_on_a_floating_tab_or_bar_minimizes_and_another_tab_expands() { + let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; + let mut d = Driver::new(layout); + let minimized = |d: &Driver| d.layout.floating[0].groups[0].minimized; + let tab = d.tab(6).center(); + d.click(tab); + let out = d.click(tab); + assert!(out.actions.contains(&Action::SetMinimized { panel: 6, minimized: true }), "{:?}", out.actions); + assert!(minimized(&d)); + d.wait(); + + // Another tab of the minimized group expands it and selects that tab. + let other = d.tab(5).center(); + d.click(other); + assert!(!minimized(&d)); + assert_eq!(d.layout.floating[0].groups[0].active_panel(), Some(&5)); + + d.wait(); + let bar = d.rect("drag-bar", 5).center(); + d.click(bar); + d.click(bar); + assert!(minimized(&d)); + d.wait(); + d.click(bar); + d.click(bar); + assert!(!minimized(&d)); +} + +#[test] +fn release_over_own_position_is_a_noop() { + let mut d = Driver::new(frames()); + let bar = d.rect("drag-bar", 2); + let out = d.drag(bar.center(), bar.center() + vec2(0.0, 200.0)); + assert!(out.actions.is_empty() && out.group_drop.is_none(), "{out:?}"); + assert_eq!(d.layout, frames()); +} + +#[test] +fn floating_border_and_drag_bar_move_and_cancel_as_a_group() { + for name in ["drag-border", "drag-bar"] { + let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; + let mut d = Driver::new(layout.clone()); + let from = d.rect(name, 5).center(); + d.hold(from, from + vec2(50.0, 40.0)); + assert_eq!(d.layout, layout, "{name}: a ghost moves, not the window"); + d.step(vec![key(egui::Key::Escape)]); + d.step(vec![button(from + vec2(50.0, 40.0), false)]); + assert_eq!(d.layout, layout, "{name}"); + + let out = d.drag(from, from + vec2(50.0, 40.0)); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{name}: {:?}", out.actions); + let [x, y, w, h] = d.layout.floating[0].rect; + assert_eq!([x, y, w, h], [130.0, 110.0, 300.0, 220.0], "{name}"); + assert_eq!(d.layout.floating[0].groups[0], Group::new(vec![5, 6], 1)); + } +} + +#[test] +fn floating_border_movement_does_not_accumulate_fractional_step_rounding() { + let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; + let mut d = Driver::new(layout); + let from = d.rect("drag-border", 5).center(); + d.step(vec![Event::PointerMoved(from), button(from, true)]); + for index in 1..=8 { + d.step(vec![Event::PointerMoved(from + vec2(80.3, 60.7) * (index as f32 / 8.0))]); + } + d.step(vec![button(from + vec2(80.3, 60.7), false)]); + let [x, y, ..] = d.layout.floating[0].rect; + assert!((x - 160.3).abs() <= 1.0 && (y - 130.7).abs() <= 1.0, "{x} {y}"); +} + +#[test] +fn docked_header_drag_reaches_the_host_hook_and_preserves_the_group() { + for name in ["drag-border", "drag-bar"] { + let content = TestContent { host_owned: true, ..Default::default() }; + let mut d = Driver::with(frames(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); + let out = d.drag(d.rect(name, 2).center(), pos2(70.0, 300.0)); + assert!(d.content.whole_group, "{name}"); + assert!(out.actions.is_empty() && out.group_drop.is_none(), "{name}: the host owns the release"); + assert_eq!(d.layout, frames()); + } +} + +#[test] +fn lone_bare_or_protected_panels_draw_no_strip_and_no_bar() { + struct Bare; + impl DockContent for Bare { + fn body(&mut self, _: &mut egui::Ui, _: &u32) {} + fn bare(&self, panel: &u32) -> bool { + *panel == 1 + } + } + let ctx = Context::default(); + for _ in 0..2 { + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }, |ui| { + DockArea::new(Id::new(AREA)).show_content(ui, &frames(), &DockStyle::from_ui(ui), &mut Bare); + }) + .textures_delta + .clear(); + } + assert!(ctx.read_response(Id::new(AREA).with(("drag-bar", Some(&1u32)))).is_none()); + assert!(ctx.read_response(Id::new(AREA).with(("tab", &1u32))).is_none()); + assert!(ctx.read_response(Id::new(AREA).with(("drag-bar", Some(&2u32)))).is_some()); +} + +#[test] +fn drag_bar_menu_has_the_group_items_and_opens_from_the_keyboard() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, (content, actions): &mut (TestContent, Vec>)| { + let out = DockArea::new(Id::new(AREA)).show_content(ui, &docked(), &DockStyle::from_ui(ui), content); + actions.extend(out.actions); + }, + (TestContent { protected: vec![0], ..Default::default() }, Vec::new()), + ); + h.run_steps(2); + let bar_id = Id::new(AREA).with(("drag-bar", Some(&2u32))); + let at = h.ctx.read_response(bar_id).unwrap().rect.center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + for label in ["Close Tab Group", "Minimize", "Float Tab Group", "Group with"] { + assert_eq!(h.query_all_by_label(label).count(), 1, "{label}"); + } + h.get_by_label("Close Tab Group").click(); + h.run_steps(2); + assert!(h.state().1.contains(&Action::CloseGroup { panel: 3 }), "{:?}", h.state().1); + + h.ctx.memory_mut(|memory| memory.request_focus(bar_id)); + h.run_steps(1); + assert!(DockArea::::new(Id::new(AREA)).chrome_has_focus(&h.ctx, &docked())); + h.input_mut().events.push(key(egui::Key::Enter)); + h.run_steps(2); + assert!(egui::Popup::is_any_open(&h.ctx)); +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 1be249d6..849a5e59 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -19,8 +19,10 @@ pub use surface::{DockArea, DockContent, DockOutput, DockStyle, DropFeedback, Dr pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect}; pub use workspace::Workspace; -use surface::{Drag, GroupDrag}; +use surface::{Drag, GroupDrag, GroupGesture}; +#[cfg(test)] +mod group_tests; #[cfg(test)] mod model_tests; #[cfg(test)] diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index cd55820b..43d18e25 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -2,7 +2,7 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Context, Id, Key, Rect, Sense, Stroke, Ui, Vec2, pos2, vec2}; -use super::{Renderer, clamp_float}; +use super::{GroupKind, Renderer, clamp_float}; use crate::docking::{Action, Floating, Node, PanelLimits, resize::AnchoredResize}; /// Floating window resize handles: id suffix, hit rect and the edges they move. @@ -54,7 +54,10 @@ impl Renderer<'_, P> { let Some(first) = window.panels().next() else { continue }; let [x, y, width, height] = window.rect; let limits = self.node_limits(&Node::Tabs { panels: window.panels().cloned().collect(), active: 0 }); - let size = vec2(width, height).max(limits.min).min(limits.max); + let mut size = vec2(width, height).max(limits.min).min(limits.max); + if window.groups.iter().all(|group| group.minimized) { + size.y = size.y.min(window.groups.len() as f32 * self.style.tab_height); + } let rect = clamp_float(Rect::from_min_size(pos2(x, y), size), screen); let id = self.id.with(("floating", first)); if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { @@ -62,11 +65,14 @@ impl Renderer<'_, P> { ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); self.output.focused = self.activation.clone(); } - egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| { + egui::Area::new(id).fade_in(false).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| { ui.set_min_size(rect.size()); let rect = Rect::from_min_size(ui.min_rect().min, rect.size()); ui.allocate_rect(rect, Sense::hover()); - self.stacked_groups(ui, &window.groups, rect, true); + // Opaque even when the host's panel colours are transparent. + ui.painter().rect_filled(rect, 0.0, self.style.floating_background); + self.stacked_groups(ui, &window.groups, rect, GroupKind::Floating { window: rect }); + ui.painter().rect_stroke(rect, 0.0, self.style.floating_stroke, egui::StrokeKind::Outside); self.resize_handles(ui, window, id, rect, limits, screen); }); } diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 0c80d634..3b80dfb3 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -2,70 +2,77 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Key, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; -use super::{Renderer, clamp_float}; -use crate::docking::{Action, DropTarget, GroupDrag, Permissions, Placement, begin_drag, surface::TAB_PADDING}; +use super::{Renderer, clamp_float, stripless}; +use crate::docking::{Action, DropTarget, Group, 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. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::docking) enum GroupKind { + /// A `Tabs` node of the split tree: side drops split it. + Frame, + /// A group in a dock column. + Dock, + /// A group in the floating window drawn at `window`. + Floating { window: Rect }, +} + +/// The group a tab belongs to, for its menu and minimize gestures. +pub(super) struct TabGroup<'a, P> { + pub(super) panels: &'a [P], + pub(super) selected: &'a P, + pub(super) minimized: bool, + pub(super) kind: GroupKind, + pub(super) rect: Rect, +} + +/// Width of a floating window's move grip at the left of each strip. +const GRIP_WIDTH: f32 = 12.0; +/// The top band of a strip that drags the whole group. +const BORDER_BAND: f32 = 4.0; + impl Renderer<'_, P> { - /// Groups stacked top to bottom in equal shares of `rect`. - pub(super) fn stacked_groups(&mut self, ui: &mut Ui, groups: &[crate::docking::Group

], rect: Rect, floating: bool) { - let share = rect.height() / groups.len().max(1) as f32; - for (index, group) in groups.iter().enumerate() { - let top = rect.top() + share * index as f32; - let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), top + share)); - self.group(ui, &group.panels, group.active, group_rect, floating); + /// Groups stacked top to bottom. Minimized groups take their strip; the others share the + /// rest equally. + pub(super) fn stacked_groups(&mut self, ui: &mut Ui, groups: &[Group

], rect: Rect, kind: GroupKind) { + let strip = self.style.tab_height; + let open = groups.iter().filter(|g| !g.minimized).count(); + let fixed = groups.len().saturating_sub(open) as f32 * strip; + let share = if open > 0 { (rect.height() - fixed).max(0.0) / open as f32 } else { 0.0 }; + let mut top = rect.top(); + for group in groups { + let bottom = (top + if group.minimized { strip } else { share }).min(rect.bottom()); + let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), bottom)); + self.group(ui, &group.panels, group.active, group.minimized, group_rect, kind); + top = bottom; } } - pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, rect: Rect, floating: bool) { + 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 }; let policy = self.permissions(selected); - let bare = panels.len() == 1 && policy == Permissions::PROTECTED; - if bare { + let splits = kind == GroupKind::Frame; + if stripless(panels, &*self.content) { self.contents(ui, selected, rect); self.targets.push(DropTarget { anchor: selected.clone(), rect, tab_strip: None, - tabs: Vec::new(), - allow_splits: !floating, - accepts_tabs: false, + tabs: vec![(selected.clone(), Rect::NOTHING)], + allow_splits: splits, + accepts_tabs: policy.accepts_tabs, }); return; } + let movable = panels.iter().all(|p| self.permissions(p).movable); let mut strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); ui.painter().rect_filled(strip, 0.0, self.style.tab_background); - if floating { - let grip = Rect::from_min_max(strip.min, pos2((strip.left() + 12.0).min(strip.right()), strip.bottom())); - let response = ui.interact(grip, self.id.with(("move-group", panels.first())), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); - self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); - if panels.iter().all(|p| self.permissions(p).movable) - && response.drag_started() - && let Some(first) = panels.first() - { - ui.ctx().data_mut(|d| { - d.insert_temp( - self.id.with("group-drag"), - GroupDrag { panel: first.clone(), original: [rect.left(), rect.top(), rect.width(), rect.height()] }, - ) - }); - } - if panels.iter().all(|p| self.permissions(p).movable) - && response.dragged() - && ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))).is_some() - && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) - { - let moved = clamp_float(rect.translate(ui.input(|i| i.pointer.delta())), ui.ctx().content_rect()); - if let Some(first) = panels.first() { - self.output - .actions - .push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); - } - } - ui.painter().line_segment([grip.center() - vec2(0.0, 5.0), grip.center() + vec2(0.0, 5.0)], self.style.border); + if let GroupKind::Floating { window } = kind { + let grip = Rect::from_min_max(strip.min, pos2((strip.left() + GRIP_WIDTH).min(strip.right()), strip.bottom())); + self.window_grip(ui, panels, grip, window, movable); strip.min.x = grip.right(); } + let floating = matches!(kind, GroupKind::Floating { .. }); let requested = self.content.group_header_width(panels, selected, floating); let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; if header_width > 0.0 { @@ -74,6 +81,9 @@ impl Renderer<'_, P> { child.set_clip_rect(header.intersect(ui.clip_rect())); self.content.group_header(&mut child, 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)); + let tabs_width = (strip.width() - header_width - bar_width).max(0.0); let widths: Vec = panels .iter() .map(|p| { @@ -85,19 +95,21 @@ impl Renderer<'_, P> { + TAB_PADDING }) .collect(); - let fit = tabs::fit(&widths, active, (strip.width() - header_width).max(0.0), 40.0, 22.0); + let fit = tabs::fit(&widths, active, tabs_width, 40.0, 22.0); + let owner = TabGroup { panels, selected, minimized, kind, rect }; let mut x = strip.left(); - let mut targets = Vec::new(); + let mut shown = Vec::new(); for (index, width) in &fit.shown { let Some(panel) = panels.get(*index) else { continue }; let tab = Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height())); - self.tab(ui, panel, tab, *index == active, rect); - targets.push((panel.clone(), tab)); + self.tab(ui, panel, tab, *index == active, rect, Some(&owner)); + shown.push((panel.clone(), tab)); x += width; } if !fit.overflow.is_empty() && fit.overflow_width > 0.0 { let overflow = Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height())); let response = ui.interact(overflow, self.id.with(("overflow", selected)), Sense::click()); + self.register_interaction(ui.ctx(), response.id); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.panels_label)); ui.painter().with_clip_rect(overflow).text(overflow.center(), egui::Align2::CENTER_CENTER, "…", self.style.font.clone(), self.style.text); egui::Popup::menu(&response).show(|ui| { @@ -113,25 +125,190 @@ impl Renderer<'_, P> { } }); }); + x += fit.overflow_width; } - // Include hidden tab identities for source-group detection without creating hit areas. - for panel in panels { - if !targets.iter().any(|(p, _)| p == panel) { - targets.push((panel.clone(), Rect::NOTHING)); - } - } + let bar = 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); + // Registered last, so the band wins over the tabs below it. + let border = Rect::from_min_max(strip.min, pos2(strip.right() - header_width, (strip.top() + BORDER_BAND).min(strip.bottom()))); + self.group_handle(ui, "drag-border", border, &owner, movable); + // Logical order, hidden tabs included without a hit area, for no-op detection. + let tabs = + panels.iter().map(|panel| (panel.clone(), shown.iter().find(|(p, _)| p == panel).map_or(Rect::NOTHING, |(_, rect)| *rect))).collect(); self.targets.push(DropTarget { anchor: selected.clone(), rect, tab_strip: Some(strip), - tabs: targets, - allow_splits: !floating, + tabs, + allow_splits: splits, accepts_tabs: policy.accepts_tabs, }); - self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max)); + if !minimized { + self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max)); + } ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); } + /// The floating window's own move: the window follows the pointer, every group included. + fn window_grip(&mut self, ui: &mut Ui, panels: &[P], grip: Rect, window: Rect, movable: bool) { + let Some(first) = panels.first() else { return }; + let response = ui.interact(grip, self.id.with(("move-group", Some(first))), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); + if movable && response.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center()); + let drag = GroupDrag { + panel: first.clone(), + original: [window.left(), window.top(), window.width(), window.height()], + gesture: GroupGesture::Window, + members: panels.to_vec(), + origin, + source: window, + }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); + } + if movable + && response.dragged() + && let Some(drag) = ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))) + && drag.gesture == GroupGesture::Window + && let Some(pointer) = ui.ctx().pointer_interact_pos() + && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) + { + // From the press origin, so fractional pointer steps do not accumulate rounding. + let moved = clamp_float(drag.source.translate(pointer - drag.origin), ui.ctx().content_rect()); + self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); + } + ui.painter().line_segment([grip.center() - vec2(0.0, 5.0), grip.center() + vec2(0.0, 5.0)], self.style.border); + } + + /// The drag bar (`drag-bar`: click, double-click, menu and drag) or the top border band + /// (`drag-border`: drag only). Both move the whole group. + fn group_handle(&mut self, ui: &mut Ui, name: &'static str, hit: Rect, owner: &TabGroup<'_, P>, movable: bool) { + if !crate::docking::targets::usable(hit) { + return; + } + let bar = name == "drag-bar"; + let sense = if bar { Sense::click_and_drag() } else { Sense::drag() }; + let response = ui.interact(hit, self.id.with((name, owner.panels.first())), sense); + let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response }; + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label)); + if movable && response.drag_started() { + self.start_group_drag(ui, owner.panels, owner.rect); + } + if !bar { + return; + } + if owner.kind != GroupKind::Frame && response.double_clicked() { + self.output.actions.push(Action::SetMinimized { panel: owner.selected.clone(), minimized: !owner.minimized }); + } + if response.has_focus() || response.hovered() { + ui.painter().with_clip_rect(hit).rect_stroke( + hit.shrink(1.0), + 0.0, + Stroke::new(1.0, self.style.accent.gamma_multiply(0.5)), + StrokeKind::Inside, + ); + } + let keyboard = response.has_focus() && ui.input(|i| i.key_pressed(Key::Enter) || i.key_pressed(Key::Space)); + let command = if response.secondary_clicked() || keyboard { + Some(egui::SetOpenCommand::Bool(true)) + } else if response.clicked() { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + egui::Popup::menu(&response).open_memory(command).show(|ui| self.group_menu(ui, owner, owner.selected)); + } + + fn start_group_drag(&mut self, ui: &Ui, panels: &[P], rect: Rect) { + let Some(first) = panels.first() else { return }; + let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(rect.center()); + let drag = GroupDrag { + panel: first.clone(), + original: [rect.left(), rect.top(), rect.width(), rect.height()], + gesture: GroupGesture::Group, + members: panels.to_vec(), + origin, + source: rect, + }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); + } + + /// The group items of the menu tail: Close Tab Group, Minimize or Expand, Float Tab Group + /// and Group with. `panel` is the panel whose menu this is. + fn group_menu(&mut self, ui: &mut Ui, owner: &TabGroup<'_, P>, panel: &P) { + let all = |check: &dyn Fn(crate::docking::Permissions) -> bool| owner.panels.iter().all(|p| check(self.permissions(p))); + let closable = all(&|p| p.closable); + let movable = all(&|p| p.movable); + let floatable = movable && all(&|p| p.floatable); + if closable && self.menu_action(ui, &self.style.close_group_label.clone()) { + self.output.actions.push(Action::CloseGroup { panel: panel.clone() }); + } + if owner.kind != GroupKind::Frame { + let label = if owner.minimized { self.style.expand_label.clone() } else { self.style.minimize_label.clone() }; + if self.menu_action(ui, &label) { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !owner.minimized }); + } + } + if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.style.float_group_label.clone()) { + let rect = clamp_float(owner.rect.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); + self.output.actions.push(Action::MoveGroup { + panel: panel.clone(), + to: Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }, + }); + } + let targets = if movable { self.group_targets(owner.panels) } else { Vec::new() }; + if !targets.is_empty() { + let logical = self.style.group_with_label.clone(); + let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); + let response = ui.menu_button(text, |ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for target in &targets { + if self.menu_label(ui, target).clicked() { + self.output.actions.push(Action::MoveGroup { + panel: panel.clone(), + to: Target::At { anchor: target.clone(), placement: Placement::Tab { before: None } }, + }); + ui.close(); + } + } + }); + }); + response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); + } + } + + /// A stock menu button with shaped text and its logical accessibility label; true when + /// clicked, after closing the menu. + fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool { + let text = self.content.action_label(ui, logical, &self.style.font, self.style.text); + let response = ui.button(text); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), logical)); + if response.clicked() { + ui.close(); + } + response.clicked() + } + + /// One anchor per other group that accepts a whole group as tabs, in layout order. + fn group_targets(&self, members: &[P]) -> Vec

{ + let Some(first) = members.first() else { return Vec::new() }; + let mut places = Vec::new(); + let mut anchors = Vec::new(); + for anchor in self.layout.move_targets(first, |p| self.permissions(p)) { + if members.contains(anchor) { + continue; + } + let Some(place) = self.layout.place(anchor) else { continue }; + if !places.contains(&place) { + places.push(place); + anchors.push(anchor.clone()); + } + } + anchors + } + pub(super) fn menu_label(&mut self, ui: &mut Ui, panel: &P) -> egui::Response { let logical = self.label_of(panel); let text = self.content.label(ui, panel, &logical, &self.style.font, self.style.text); @@ -140,7 +317,8 @@ impl Renderer<'_, P> { response } - pub(super) fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect) { + pub(super) fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect, owner: Option<&TabGroup<'_, P>>) { + let minimized = owner.filter(|owner| owner.kind != GroupKind::Frame).map(|owner| owner.minimized); let policy = self.permissions(panel); let label = self.label_of(panel); let color = if active { self.style.text } else { self.style.inactive_text }; @@ -166,6 +344,14 @@ impl Renderer<'_, P> { if response.clicked() { self.output.actions.push(Action::Activate { panel: panel.clone() }); self.output.focused = Some(panel.clone()); + if minimized == Some(true) && !active { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: false }); + } + } + if let Some(minimized) = minimized + && response.double_clicked() + { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized }); } if policy.movable && response.drag_started() { begin_drag(ui.ctx(), self.id, panel.clone(), group); @@ -212,6 +398,10 @@ impl Renderer<'_, P> { ui.close(); } } + if let Some(owner) = owner { + ui.separator(); + self.group_menu(ui, owner, panel); + } }); } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 9ed2e9c8..7539a1c0 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -8,6 +8,8 @@ use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node mod floating; +pub(in crate::docking) use group::GroupKind; + pub(in crate::docking) use floating::RESIZE_HANDLES; mod group; mod preview; @@ -90,7 +92,7 @@ impl Renderer<'_, P> { self.output.actions.push(Action::ResizeSplit { path: path.clone(), size }); } } - Node::Tabs { panels, active } => self.group(ui, panels, *active, rect, false), + Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame), Node::Dock { columns, .. } => { // Interim rendering: columns side by side, groups stacked evenly. let total: f32 = columns.iter().map(|c| c.width).sum(); @@ -99,7 +101,7 @@ impl Renderer<'_, P> { let width = if total > 0.0 { rect.width() * column.width / total } else { 0.0 }; let column_rect = Rect::from_min_max(pos2(left, rect.top()), pos2(left + width, rect.bottom())); left += width; - self.stacked_groups(ui, &column.groups, column_rect, false); + self.stacked_groups(ui, &column.groups, column_rect, GroupKind::Dock); } } Node::Stack { entries } => { @@ -117,7 +119,7 @@ impl Renderer<'_, P> { }; let panel_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), (top + header + share).min(rect.bottom()))); let title_rect = Rect::from_min_max(panel_rect.min, pos2(panel_rect.right(), (top + header).min(panel_rect.bottom()))); - self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect); + self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect, None); let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max); let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click()); response.widget_info(|| { @@ -207,6 +209,14 @@ impl Renderer<'_, P> { } } +/// A lone protected or bare panel draws no tab strip and no drag bar. +pub(in crate::docking) fn stripless

(panels: &[P], content: &dyn DockContent

) -> bool { + match panels { + [panel] => content.permissions(panel) == Permissions::PROTECTED || content.bare(panel), + _ => false, + } +} + /// Size policy of a subtree, shared by the renderer and its measurements. pub(in crate::docking) fn node_limits

(node: &Node

, style: &DockStyle, content: &dyn DockContent

) -> PanelLimits { match node { @@ -219,7 +229,7 @@ pub(in crate::docking) fn node_limits

(node: &Node

, style: &DockStyle, cont limits.max = limits.max.min(policy.max); } limits.max = limits.max.max(limits.min); - if panels.len() != 1 || panels.first().is_some_and(|p| content.permissions(p) != Permissions::PROTECTED) { + if !stripless(panels, content) { limits.min.y = (limits.min.y + style.tab_height).min(1_000_000.0); limits.max.y = (limits.max.y + style.tab_height).min(1_000_000.0); } diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 9b6214f9..de2aa445 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -1,31 +1,41 @@ use std::{fmt::Debug, hash::Hash}; -use egui::{Context, Key, Pos2, Rect, Stroke, StrokeKind, Vec2, vec2}; +use egui::{Color32, Context, Key, Pos2, Rect, Stroke, StrokeKind, Vec2, vec2}; use super::{Renderer, clamp_float}; -use crate::docking::{Action, Drag, DropRequest, GroupDrag, cancel_drag, dock_model::Place, resolve_drop}; +use crate::docking::{ + Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Placement, Target, Zone, cancel_drag, dock_model::Place, resolve_drop, +}; + +/// Ctrl (Windows, Linux) or Cmd (macOS) held: the drop floats and no target is offered. +fn float_only(ctx: &Context) -> bool { + ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd) +} /// Height of a panel floated out of a dock column when the host gives no floating size. const DOCKED_FLOAT_HEIGHT: f32 = 320.0; impl Renderer<'_, P> { pub(in crate::docking) fn finish_group_drag(&mut self, ctx: &Context) { - let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("group-drag"))) else { return }; - if !self.layout.floating.iter().any(|g| g.contains(&drag.panel) && g.panels().all(|p| self.permissions(p).movable)) { - ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); + let key = self.id.with("group-drag"); + let Some(drag) = ctx.data(|d| d.get_temp::>(key)) else { return }; + if !drag.members.iter().all(|p| self.layout.contains(p) && self.permissions(p).movable) { + ctx.data_mut(|d| d.remove::>(key)); return; } let (escape, focused, down, released, pointer) = ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos())); + let pointer = pointer.filter(|p| p.is_finite()); + let ghost = pointer.map(|pointer| clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect())); if !escape && focused && (down || released) - && let Some(pointer) = pointer.filter(|p| p.is_finite()) + && let Some(pointer) = pointer && !self.blockers.iter().any(|r| r.contains(pointer)) - && self.host_drop(ctx, &drag.panel, pointer, true, released, None) + && self.host_drop(ctx, &drag.panel, pointer, true, released, ghost.filter(|_| drag.gesture == GroupGesture::Group)) { if released { - ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); + ctx.data_mut(|d| d.remove::>(key)); } return; } @@ -33,19 +43,95 @@ impl Renderer<'_, P> { let has_pointer = ctx.input(|i| i.pointer.has_pointer()); // Touch-up also removes the pointer, but still completes a drop. let touch_ended = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::End, .. }))); + let float_only = float_only(ctx); if escape || !focused || !down || !has_pointer { cancel_drag::

(ctx, self.id); if escape || !focused { - self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original }); - } else if released - && (has_pointer || touch_ended) - && let Some(pointer) = pointer.filter(|p| p.is_finite()) - && !self.blockers.iter().any(|r| r.contains(pointer)) - { - self.output.group_drop = Some((drag.panel, pointer)); + if drag.gesture == GroupGesture::Window { + self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original }); + } + return; + } + // Leaving the window ends a group drag where it is, with no drop. + if !released || !(has_pointer || touch_ended) { + return; + } + let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; + match drag.gesture { + GroupGesture::Window => self.output.group_drop = Some((drag.panel, pointer)), + GroupGesture::Group => self.drop_group(ctx, &drag, pointer, float_only), + } + } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { + self.output.group_drag = Some(drag.panel.clone()); + if drag.gesture == GroupGesture::Group { + self.paint_group_drag(ctx, &drag, pointer, float_only); + } + } + } + + /// The valid target under the pointer for a whole group. A target holding the group + /// itself is a no-op proposal; a target the model would reject is no target. + fn group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option> { + let target = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?; + if target.tabs.iter().any(|(p, _)| drag.members.contains(p)) { + return Some(DropProposal { + anchor: drag.panel.clone(), + placement: Placement::Tab { before: None }, + preview: target.rect, + insertion: None, + noop: true, + }); + } + let proposal = resolve_drop(&drag.panel, pointer, std::slice::from_ref(target))?; + let action = Action::MoveGroup { + panel: drag.panel.clone(), + to: Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }, + }; + self.layout.clone().apply_with_permissions(action, |p| self.permissions(p)).is_ok().then_some(proposal) + } + + /// Release of a group drag: join or split at the target, or float at the ghost with the + /// group's size. Over its own docked position nothing changes; a floating group released + /// over itself moves there. + fn drop_group(&mut self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + if pointer.distance(drag.origin) < 2.0 { + return; + } + let floating = matches!(self.layout.place(&drag.panel), Some(Place::Floating { .. })); + let proposal = if float_only { None } else { self.group_proposal(drag, pointer) }; + if let Some(proposal) = proposal.as_ref().filter(|p| !p.noop) { + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + self.output.actions.push(Action::MoveGroup { panel: drag.panel.clone(), to }); + return; + } + if proposal.is_some() && !floating { + return; + } + // No target: hosts that still apply their own policy for a whole-group release get it. + self.output.group_drop = Some((drag.panel.clone(), pointer)); + let rect = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect()); + let action = + Action::MoveGroup { panel: drag.panel.clone(), to: Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) } }; + if self.layout.clone().apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() { + self.output.actions.push(action); + } + } + + /// Ghost outline at the pointer and one destination preview. + fn paint_group_drag(&self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + let painter = self.preview_painter(ctx); + let ghost = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect()); + painter.rect_stroke(ghost, 0.0, Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)), StrokeKind::Inside); + if float_only { + return; + } + if let Some(proposal) = self.group_proposal(drag, pointer).filter(|p| !p.noop) { + let split = matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center); + let fill = if split { self.style.accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT }; + painter.rect(proposal.preview.shrink(1.0), 0.0, fill, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + if let Some(marker) = proposal.insertion { + painter.rect_filled(marker, 0.0, self.style.accent); } - } else if pointer.is_some_and(|p| !self.blockers.iter().any(|r| r.contains(p))) { - self.output.group_drag = Some(drag.panel); } } @@ -74,8 +160,9 @@ impl Renderer<'_, P> { } return; } + let float_only = float_only(ctx); let floatable = self.permissions(&drag.panel).floatable - && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); + && (float_only || gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); let floating_rect = floatable.then(|| self.tab_float_rect(ctx, &drag, pointer)); if self.host_drop(ctx, &drag.panel, pointer, false, released, floating_rect) { if released { @@ -83,7 +170,7 @@ impl Renderer<'_, P> { } return; } - let proposal = resolve_drop(&drag.panel, pointer, &self.targets); + let proposal = if float_only { None } else { resolve_drop(&drag.panel, pointer, &self.targets) }; if released { cancel_drag::

(ctx, self.id); if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !gone) { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 5321fb25..901fc273 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -32,6 +32,19 @@ pub struct DockStyle { pub panels_label: String, pub resize_label: String, pub resize_window_label: String, + /// Accessibility label of a group's drag bar and top border. + pub move_group_label: String, + pub close_group_label: String, + pub minimize_label: String, + pub expand_label: String, + pub float_group_label: String, + pub group_with_label: String, + /// Minimum width of the drag bar left of the group header. Tabs shrink, then overflow, + /// before the bar narrows. + pub drag_bar_width: f32, + /// Painted under every floating window, so it stays opaque when the panel colours are not. + pub floating_background: Color32, + pub floating_stroke: Stroke, } impl DockStyle { @@ -55,11 +68,22 @@ impl DockStyle { panels_label: "Panels".into(), resize_label: "Resize panels".into(), resize_window_label: "Resize panel window".into(), + move_group_label: "Move tab group".into(), + close_group_label: "Close Tab Group".into(), + minimize_label: "Minimize".into(), + expand_label: "Expand".into(), + float_group_label: "Float Tab Group".into(), + group_with_label: "Group with".into(), + drag_bar_width: 40.0, + floating_background: visuals.window_fill, + floating_stroke: visuals.window_stroke, } } pub(super) fn valid(&self) -> bool { - [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size].iter().all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) + [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size, self.drag_bar_width, self.floating_stroke.width] + .iter() + .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) && self.tab_height > 0.0 && self.font.size > 0.0 } @@ -117,10 +141,26 @@ pub(super) struct Drag

{ pub(super) grab: Vec2, } +/// What a group gesture moves. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum GroupGesture { + /// A floating window's grip: the window follows the pointer. + Window, + /// A drag bar or top border: a ghost follows the pointer, the release picks the target. + Group, +} + #[derive(Clone)] pub(super) struct GroupDrag

{ + /// The first member; identifies the gesture. pub(super) panel: P, + /// The floating window rect at the press, restored when a window move is cancelled. pub(super) original: [f32; 4], + pub(super) gesture: GroupGesture, + pub(super) members: Vec

, + pub(super) origin: Pos2, + /// The group (or window) rect at the press. A floated group keeps this size. + pub(super) source: Rect, } /// Start a docking drag from an application's existing tab/header. Use the same area ID when @@ -322,7 +362,7 @@ impl DockArea

{ let matches = |id: Id| id == focused; let group = |panels: &[P]| { panels.iter().any(|panel| matches(self.id.with(("tab", panel))) || matches(self.id.with(("overflow", panel)))) - || matches(self.id.with(("move-group", panels.first()))) + || ["move-group", "drag-bar", "drag-border"].iter().any(|name| matches(self.id.with((*name, panels.first())))) }; for floating in &layout.floating { if floating.groups.iter().any(|g| group(&g.panels))