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 <aswinmani2001@gmail.com>
This commit is contained in:
aionggandZenodeon committed 2026-10-19 04:55:27 +00:00
1 parent 9777c291c3
commit 1b091edefb
7 files changed
+944 -88

No files matched your search

+521
View File
@@ -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<u32> {
Node::Tabs { panels: panels.to_vec(), active }
}
/// `[1] | [2, 3]` with panel 3 selected.
fn frames() -> Layout<u32> {
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<u32> {
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<u32>,
header: f32,
header_clicks: usize,
floating_size: Option<Vec2>,
host_owned: bool,
whole_group: bool,
}
impl DockContent<u32> 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<Vec2> {
self.floating_size
}
fn resolve_drag(&mut self, _: &Context, _: &DockStyle, request: &DropRequest<'_, u32>) -> Option<DropFeedback> {
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<u32>,
content: TestContent,
area: Rect,
modifiers: Modifiers,
focused: bool,
transparent: bool,
shapes: Vec<egui::epaint::ClippedShape>,
time: f64,
}
impl Driver {
fn new(layout: Layout<u32>) -> Self {
Self::with(layout, TestContent::default(), Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)))
}
fn with(layout: Layout<u32>, 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<Event>) -> DockOutput<u32> {
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<Event>) -> DockOutput<u32> {
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<u32> {
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<u32> {
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<u32> {
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<u32>]) -> Option<&Target<u32>> {
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::<u32>(&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::<u32>(&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<u32> 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<Action<u32>>)| {
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::<u32>::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));
}
+3 -1
View File
@@ -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)]
+10 -4
View File
@@ -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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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);
});
}
+248 -58
View File
@@ -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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P>], 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<P>], 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::<GroupDrag<P>>(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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<f32> = panels
.iter()
.map(|p| {
@@ -85,19 +95,21 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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::<GroupDrag<P>>(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<P> {
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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
ui.close();
}
}
if let Some(owner) = owner {
ui.separator();
self.group_menu(ui, owner, panel);
}
});
}
}
+14 -4
View File
@@ -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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
}
/// A lone protected or bare panel draws no tab strip and no drag bar.
pub(in crate::docking) fn stripless<P>(panels: &[P], content: &dyn DockContent<P>) -> 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<P>(node: &Node<P>, style: &DockStyle, content: &dyn DockContent<P>) -> PanelLimits {
match node {
@@ -219,7 +229,7 @@ pub(in crate::docking) fn node_limits<P>(node: &Node<P>, 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);
}
+106 -19
View File
@@ -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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(in crate::docking) fn finish_group_drag(&mut self, ctx: &Context) {
let Some(drag) = ctx.data(|d| d.get_temp::<GroupDrag<P>>(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::<GroupDrag<P>>(self.id.with("group-drag")));
let key = self.id.with("group-drag");
let Some(drag) = ctx.data(|d| d.get_temp::<GroupDrag<P>>(key)) else { return };
if !drag.members.iter().all(|p| self.layout.contains(p) && self.permissions(p).movable) {
ctx.data_mut(|d| d.remove::<GroupDrag<P>>(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::<GroupDrag<P>>(self.id.with("group-drag")));
ctx.data_mut(|d| d.remove::<GroupDrag<P>>(key));
}
return;
}
@@ -33,19 +43,95 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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::<P>(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<P>, pointer: Pos2) -> Option<DropProposal<P>> {
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<P>, 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<P>, 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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::<P>(ctx, self.id);
if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !gone) {
+42 -2
View File
@@ -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<P> {
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<P> {
/// 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<P>,
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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
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))