mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 11:03:34 +00:00
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:
1 parent
9777c291c3
commit
1b091edefb
7 files changed
+944
-88
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user