docking: one model; profile differences are style data

Remove `Profile`. What it switched is now a `DockStyle` setting with the
dock-and-canvas behaviour as the default: `side_edge_zones`,
`new_dock_zones`, `maximize_item`, `close_others_item`,
`command_press_floats`, `float_hint`, and plain bools for
`wheel_selects_tabs` and `middle_click_closes_tabs`. The undock and
Close Panel Group labels go; an app sets `float_label`,
`float_group_label` and `close_group_label` instead.

Remove the size machinery that made frames stick: `DockContent::bare`,
`FixedSlot` and the split that took a parent's or sibling's half beside
a fixed-size frame, and the gap snapping to the nearest frame. A tab
group takes the largest maximum of its panels, so a panel without one
lifts the cap of the group it joins. A dragged divider stores a ratio
whatever mode it was saved with.

Sizes follow the reference rules: a divider resizes only the groups
that touch it and stops at their minimums; a four-way handle where a
divider ends on another moves both (`Divider::Intersection`); a split or
outer-edge drop that would leave a group without `min_pane` is not
offered, and a group too small to halve takes the drop as a tab.

Tests: one per rule, the stuck cases from the FilmCraft map, and a
property test over random drops checking minimums and divider travel.
This commit is contained in:
aiongg committed 2026-10-22 20:01:31 +00:00
1 parent 958c5ada58
commit d30c207c8d
20 files changed
+974 -511

No files matched your search

+4 -24
View File
@@ -207,38 +207,18 @@ pub(super) fn node_at_mut<'a, P>(mut node: &'a mut Node<P>, path: &[bool]) -> Op
Some(node)
}
/// Put `new` on the `zone` side of the frame at `path`, in a new half-size split. A frame that
/// holds the fixed size of its parent split along the same axis keeps that size: on its outer
/// side the new split wraps the parent, on its inner side it halves the parent's other child.
/// So a fixed-width tool strip neither halves nor shares its width with the new frame.
/// Put `new` on the `zone` side of the frame at `path`: the frame splits into two halves and
/// every other frame keeps its size.
pub(super) fn split_frame<P>(root: &mut Node<P>, path: &[bool], new: Node<P>, zone: Zone) -> bool {
let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal };
let before = matches!(zone, Zone::Top | Zone::Left);
let (target, new_first) = match fixed_slot(root, path, axis) {
Some((parent, anchor_first)) if before == anchor_first => (parent.to_vec(), before),
Some((parent, anchor_first)) => ([parent, &[anchor_first]].concat(), !before),
None => (path.to_vec(), before),
};
let Some(node) = node_at_mut(root, &target) else { return false };
let new_first = matches!(zone, Zone::Top | Zone::Left);
let Some(node) = node_at_mut(root, path) else { return false };
let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 });
let (first, second) = if new_first { (new, old) } else { (old, new) };
*node = Node::Split { axis, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) };
true
}
/// The parent path of the frame at `path` and whether the frame is its first child, when the
/// parent splits along `axis` and the frame holds its fixed size.
pub(super) fn fixed_slot<'a, P>(root: &Node<P>, path: &'a [bool], axis: SplitAxis) -> Option<(&'a [bool], bool)> {
let (second, parent) = path.split_last()?;
let Node::Split { axis: parent_axis, size, .. } = node_at(root, parent)? else { return None };
let fixed = match size {
SplitSize::FixedFirst(_) => !second,
SplitSize::FixedSecond(_) => *second,
SplitSize::Ratio(_) => false,
};
(*parent_axis == axis && fixed).then_some((parent, !second))
}
/// Insert `new` at `index` and select the member at `index + selected`.
fn splice<P>(panels: &mut Vec<P>, active: &mut usize, index: usize, new: Vec<P>, selected: usize) {
let index = index.min(panels.len());
+1 -1
View File
@@ -1,4 +1,4 @@
//! [D] Dock profile: columns, stacked groups, seams, column bars and their drops.
//! [D] Docks: columns, stacked groups, seams, column bars and their drops.
use super::*;
use crate::layout::{SplitAxis, SplitSize};
+50 -33
View File
@@ -1,4 +1,4 @@
//! Floating windows (title bar, stacks, close), the frame profile (outer edge, Maximize,
//! Floating windows (title bar, stacks, close), the split-tree settings (outer edge, Maximize,
//! Ctrl/Cmd undock, frame menu items), hidden panels, native windows and the public chrome.
use super::*;
@@ -80,11 +80,26 @@ enum Hide {
Keep,
}
/// The style of an editor whose layout is one split tree of tab groups, as FilmCraft sets it.
pub(super) fn frame_style(style: &mut DockStyle) {
style.side_edge_zones = true;
style.new_dock_zones = false;
style.close_others_item = true;
style.maximize_item = true;
style.command_press_floats = true;
style.float_hint = true;
style.wheel_selects_tabs = true;
style.middle_click_closes_tabs = true;
style.float_label = "Undock Panel".into();
style.float_group_label = "Undock Panel Group".into();
style.close_group_label = "Close Panel Group".into();
}
struct Driver {
ctx: Context,
layout: Layout<u32>,
content: Content,
profile: Profile,
split_tree: bool,
maximized: Option<u32>,
hide: Hide,
modifiers: Modifiers,
@@ -92,12 +107,12 @@ struct Driver {
}
impl Driver {
fn new(layout: Layout<u32>, profile: Profile) -> Self {
fn new(layout: Layout<u32>, split_tree: bool) -> Self {
let mut driver = Self {
ctx: Context::default(),
layout,
content: Content::default(),
profile,
split_tree,
maximized: None,
hide: Hide::None,
modifiers: Modifiers::NONE,
@@ -119,12 +134,14 @@ impl Driver {
time: Some(self.time),
..Default::default()
};
let (layout, content, profile, maximized, hide) = (&self.layout, &mut self.content, self.profile, self.maximized, self.hide);
let (layout, content, split_tree, maximized, hide) = (&self.layout, &mut self.content, self.split_tree, self.maximized, self.hide);
let keep: Vec<u32> = content.keep.clone();
let keep = move |p: &u32| keep.contains(p);
let full = self.ctx.run_ui(input, |ui| {
let mut style = DockStyle::from_ui(ui);
style.profile = profile;
if split_tree {
frame_style(&mut style);
}
let hide = match hide {
Hide::None => HidePanels::None,
Hide::All => HidePanels::All,
@@ -200,7 +217,7 @@ fn groups_of(window: &Floating<u32>) -> Vec<Vec<u32>> {
#[test]
fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_groups() {
// The window follows the pointer with every group; Escape puts it back.
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
let title = d.title(5).center();
d.hold(title, title + vec2(40.0, 30.0));
assert_eq!(d.layout.floating[0].rect, [340.0, 110.0, 240.0, 300.0]);
@@ -221,7 +238,7 @@ fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_gro
assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![8], vec![5], vec![6, 7]]);
// Released over a docked group's strip: every panel joins it as tabs.
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
let title = d.title(5).center();
let strip = d.tab(4).unwrap().center();
let out = d.drag(title, strip);
@@ -230,7 +247,7 @@ fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_gro
assert_eq!(d.layout.place(&4), d.layout.place(&6));
// Released over the canvas: the window stays where it was moved and hosts get the release.
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
let title = d.title(5).center();
let out = d.drag(title, pos2(500.0, 300.0));
assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveWindow { .. })), "{:?}", out.actions);
@@ -241,7 +258,7 @@ fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_gro
#[test]
fn title_bar_drag_onto_dock_targets_docks_the_window() {
// Released on the seam between two docked groups: the window's group stacks between them.
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
let title = d.title(8).center();
let seam = pos2(120.0, d.tab(4).unwrap().top());
let out = d.drag(title, seam);
@@ -258,7 +275,7 @@ fn title_bar_drag_onto_dock_targets_docks_the_window() {
assert_eq!(stacked, vec![vec![2, 3], vec![8], vec![4]]);
// Released on the new-dock band along the right edge: the window becomes an end dock.
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
let title = d.title(5).center();
let out = d.drag(title, pos2(796.0, 300.0));
assert!(out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::NewDock { side: Side::End } }), "{:?}", out.actions);
@@ -268,7 +285,7 @@ fn title_bar_drag_onto_dock_targets_docks_the_window() {
#[test]
fn floating_stack_seams_and_bands_insert_groups() {
// A group dragged by its bar to another window's top band stacks above that group.
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
let bar = d.response(Id::new(AREA).with(("drag-bar", Some(&6u32)))).unwrap().center();
let out = d.drag(bar, pos2(660.0, 360.0));
assert!(
@@ -281,7 +298,7 @@ fn floating_stack_seams_and_bands_insert_groups() {
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5]]);
// A tab dropped on the seam between two stacked groups becomes a group between them.
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
let tab = d.tab(8).unwrap().center();
let seam = pos2(420.0, 237.0);
let out = d.drag(tab, seam);
@@ -293,7 +310,7 @@ fn floating_stack_seams_and_bands_insert_groups() {
assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![8], vec![6, 7]]);
// The divider between two open groups resizes both.
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
let divider = d.response(DockArea::hit_id(Id::new(AREA), &Hit::Divider(Divider::Group(5u32)))).unwrap().center();
d.drag(divider, divider + vec2(0.0, 40.0));
let heights: Vec<_> = window_of(&d.layout, 5).unwrap().groups.iter().map(|g| g.height.unwrap()).collect();
@@ -302,7 +319,7 @@ fn floating_stack_seams_and_bands_insert_groups() {
#[test]
fn title_bar_double_click_minimizes_every_group_and_close_box_closes_the_window() {
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
let title = d.title(5).center();
d.click(title);
d.click(title);
@@ -334,7 +351,7 @@ fn closed_floating_window_reopens_each_panel_at_its_last_rect() {
#[test]
fn ctrl_drag_from_a_frame_strip_undocks_to_a_floating_window() {
let mut d = Driver::new(frames(), Profile::Frame);
let mut d = Driver::new(frames(), true);
let tab = d.tab(3).unwrap().center();
let other = d.tab(1).unwrap().center();
d.modifiers = Modifiers::COMMAND;
@@ -345,23 +362,23 @@ fn ctrl_drag_from_a_frame_strip_undocks_to_a_floating_window() {
assert!(window_of(&d.layout, 3).is_some());
// Without the modifier the same drag joins the other frame.
let mut d = Driver::new(frames(), Profile::Frame);
let mut d = Driver::new(frames(), true);
let out = d.drag(tab, other);
assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 3, anchor: 1, .. }]), "{:?}", out.actions);
}
#[test]
fn outer_edge_band_splits_at_the_root() {
// Frame profile: all four edges.
let mut d = Driver::new(frames(), Profile::Frame);
// With side edge zones: all four edges.
let mut d = Driver::new(frames(), true);
let tab = d.tab(3).unwrap().center();
let out = d.drag(tab, pos2(3.0, 300.0));
assert!(out.actions.contains(&Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }), "{:?}", out.actions);
let Some(Node::Split { axis: SplitAxis::Horizontal, first, .. }) = &d.layout.root else { panic!("{:?}", d.layout) };
assert_eq!(**first, tabs(&[3], 0));
// Dock profile: the bottom edge of the part between the docks, for a Timeline.
let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, Profile::Dock);
// The defaults: the bottom edge of the part between the docks, for a Timeline.
let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, false);
let tab = d.tab(2).unwrap().center();
let out = d.drag(tab, pos2(500.0, 597.0));
assert!(out.actions.iter().any(|a| matches!(a, Action::Move { panel: 2, placement: Placement::Edge(Zone::Bottom), .. })), "{:?}", out.actions);
@@ -370,7 +387,7 @@ fn outer_edge_band_splits_at_the_root() {
assert!(matches!(&**second, Node::Split { axis: SplitAxis::Vertical, second, .. } if **second == tabs(&[2], 0)));
// Its left and right edges are the docks' (new columns and docks), not root splits.
let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, Profile::Dock);
let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, false);
let tab = d.tab(4).unwrap().center();
let out = d.drag(tab, pos2(246.0, 300.0));
assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { placement: Placement::Edge(_), .. })), "{:?}", out.actions);
@@ -380,7 +397,7 @@ fn outer_edge_band_splits_at_the_root() {
fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input() {
let mut layout = frames();
layout.floating.push(Floating::single(vec![5], 0, [400.0, 300.0, 200.0, 150.0]));
let mut d = Driver::new(layout.clone(), Profile::Frame);
let mut d = Driver::new(layout.clone(), true);
let window_title = d.title(5).center();
d.maximized = Some(2);
d.step(vec![]);
@@ -392,7 +409,7 @@ fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input()
assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveFloating { .. } | Action::MoveWindow { .. })), "{:?}", out.actions);
// A floating panel does not maximize.
let mut d = Driver::new(layout, Profile::Frame);
let mut d = Driver::new(layout, true);
d.maximized = Some(5);
d.step(vec![]);
d.step(vec![]);
@@ -406,7 +423,7 @@ fn close_other_panels_keeps_only_the_chosen_tab() {
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|ui, (content, actions, events): &mut State| {
let mut style = DockStyle::from_ui(ui);
style.profile = Profile::Frame;
frame_style(&mut style);
let out = DockArea::new(Id::new(AREA)).show_content(ui, &frames(), &style, content);
actions.extend(out.actions);
events.extend(out.events);
@@ -444,7 +461,7 @@ fn close_other_panels_keeps_only_the_chosen_tab() {
#[test]
fn hide_panels_keeps_protected_panels_and_the_except_set() {
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
d.hide = Hide::All;
d.step(vec![]);
d.step(vec![]);
@@ -469,7 +486,7 @@ fn hide_panels_keeps_protected_panels_and_the_except_set() {
#[test]
fn a_canvas_that_accepts_splits_is_still_protected() {
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
d.content.beside = true;
d.hide = Hide::All;
d.step(vec![]);
@@ -484,7 +501,7 @@ fn a_canvas_that_accepts_splits_is_still_protected() {
fn native_window_falls_back_in_window_when_viewports_are_embedded() {
let mut layout = workspace();
layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]);
let mut d = Driver::new(layout.clone(), Profile::Dock);
let mut d = Driver::new(layout.clone(), false);
d.content.native = true;
assert!(d.ctx.embed_viewports());
let out = d.step(vec![]);
@@ -519,7 +536,7 @@ fn native_window_close_returns_the_group_to_its_in_window_rect() {
});
let mut layout = workspace();
layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]);
let mut d = Driver::new(Layout::default(), Profile::Dock);
let mut d = Driver::new(Layout::default(), false);
d.ctx.set_embed_viewports(false);
d.content.native = true;
d.layout = layout;
@@ -601,7 +618,7 @@ fn hidden_panels_leave_their_split_and_resizing_names_the_layout_path() {
}),
floating: Vec::new(),
};
let mut d = Driver::new(layout, Profile::Frame);
let mut d = Driver::new(layout, true);
d.hide = Hide::Keep;
d.content.keep = vec![1, 2];
d.step(vec![]);
@@ -622,7 +639,7 @@ fn a_hidden_floating_window_does_not_cover_dock_targets() {
// Window 5 covers the column's seam at x 150..240 until it is hidden.
let mut layout = workspace();
layout.floating = vec![Floating::single(vec![5], 0, [150.0, 250.0, 200.0, 150.0]), Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0])];
let mut d = Driver::new(layout, Profile::Dock);
let mut d = Driver::new(layout, false);
d.hide = Hide::Keep;
d.content.keep = vec![2, 3, 4, 8];
d.step(vec![]);
@@ -643,14 +660,14 @@ fn top_edges_resize_from_the_band_above_the_title_bar() {
(ResizeEdge::TopLeft, vec2(-30.0, 20.0), [530.0, 320.0, 230.0, 180.0]),
(ResizeEdge::TopRight, vec2(20.0, -10.0), [560.0, 290.0, 220.0, 210.0]),
] {
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
let handle = d.response(DockArea::hit_id(area, &Hit::Resize(8u32, edge))).unwrap();
assert_eq!(handle.bottom(), d.title(8).top(), "{edge:?} lies above the title bar");
d.drag(handle.center(), handle.center() + by);
assert_eq!(window_of(&d.layout, 8).unwrap().rect, expected, "{edge:?}");
}
// The title bar still moves the window, and arrow keys move the focused top edge.
let mut d = Driver::new(workspace(), Profile::Dock);
let mut d = Driver::new(workspace(), false);
let title = d.title(8);
d.drag(pos2(title.center().x, title.top() + 1.0), pos2(title.center().x - 50.0, title.top() + 1.0));
assert_eq!(window_of(&d.layout, 8).unwrap().rect, [510.0, 300.0, 200.0, 200.0]);
-211
View File
@@ -1,211 +0,0 @@
//! Frame profile in a Premiere-style editing layout, as FilmCraft draws it: a bottom row of
//! Project | Tools | Timeline | Audio Meters, where Tools and the meters are narrow fixed-width
//! frames without a tab strip that a grip on top moves.
use std::collections::HashMap;
use super::testing::DockDriver;
use super::*;
use crate::layout::{SplitAxis, SplitSize};
use egui::{Id, Modifiers, Pos2, Rect, Sense, pos2, vec2};
const SOURCE: [u32; 4] = [1, 11, 12, 13];
const PROGRAM: u32 = 2;
const PROPERTIES: [u32; 3] = [3, 31, 32];
const PROJECT: [u32; 7] = [4, 41, 42, 43, 44, 45, 46];
const TOOLS: u32 = 5;
const TIMELINE: u32 = 6;
const METERS: u32 = 7;
const TOOLS_WIDTH: f32 = 64.0;
const METERS_WIDTH: f32 = 116.0;
/// Panel bodies by their rects, with a FilmCraft-style 8 pt grip on the bare frames.
#[derive(Default)]
struct Film {
bodies: HashMap<u32, Rect>,
}
impl DockContent<u32> for Film {
fn body(&mut self, ui: &mut egui::Ui, panel: &u32) {
let frame = ui.max_rect();
self.bodies.insert(*panel, frame);
if self.bare(panel) {
let grip = Rect::from_min_size(frame.min, vec2(frame.width(), 8.0_f32.min(frame.height())));
if ui.interact(grip, Id::new(("grip", *panel)), Sense::drag()).drag_started() {
begin_drag(ui.ctx(), Id::new("dock-driver"), *panel, frame);
}
}
ui.allocate_rect(frame, Sense::hover());
}
fn logical_label(&self, panel: &u32) -> String {
format!("Panel {panel}")
}
fn bare(&self, panel: &u32) -> bool {
matches!(*panel, TOOLS | METERS)
}
}
type Driver = DockDriver<u32, Film>;
fn tabs(panels: &[u32]) -> Node<u32> {
Node::Tabs { panels: panels.to_vec(), active: 0 }
}
fn split(axis: SplitAxis, size: SplitSize, first: Node<u32>, second: Node<u32>) -> Node<u32> {
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
}
fn hsplit(size: SplitSize, first: Node<u32>, second: Node<u32>) -> Node<u32> {
split(SplitAxis::Horizontal, size, first, second)
}
/// Tools | Timeline | Audio Meters.
fn tools_row() -> Node<u32> {
hsplit(SplitSize::FixedFirst(TOOLS_WIDTH), tabs(&[TOOLS]), hsplit(SplitSize::FixedSecond(METERS_WIDTH), tabs(&[TIMELINE]), tabs(&[METERS])))
}
/// FilmCraft's Editing workspace: Source | Program | Properties over Project | Tools | Timeline | Meters.
fn editing() -> Layout<u32> {
let top = hsplit(SplitSize::FixedSecond(450.0), hsplit(SplitSize::Ratio(0.5), tabs(&SOURCE), tabs(&[PROGRAM])), tabs(&PROPERTIES));
let bottom = hsplit(SplitSize::Ratio(0.29), tabs(&PROJECT), tools_row());
Layout { root: Some(split(SplitAxis::Vertical, SplitSize::Ratio(0.57), top, bottom)), floating: Vec::new() }
}
fn driver(layout: Layout<u32>) -> Driver {
let mut d = DockDriver::new(layout, Film::default()).with_style(|style| style.profile = Profile::Frame);
d.screen = Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 860.0));
d.area = d.screen;
d.settle();
d
}
fn body(d: &Driver, panel: u32) -> Rect {
d.content.bodies.get(&panel).copied().unwrap_or_else(|| panic!("panel {panel} not drawn"))
}
fn floated_project() -> Driver {
let mut layout = editing();
layout.apply(Action::MoveGroup { panel: PROJECT[0], to: Target::Float { rect: Some([600.0, 120.0, 400.0, 300.0]) } }).unwrap();
driver(layout)
}
fn split_at(anchor: u32, zone: Zone) -> Target<u32> {
Target::At { anchor, placement: Placement::Split(zone) }
}
#[test]
fn a_narrow_frame_offers_both_side_targets_and_a_seam_splits_beside_its_frames() {
let d = floated_project();
let tools = body(&d, TOOLS);
assert_eq!((tools.left(), tools.width()), (0.0, TOOLS_WIDTH), "Tools starts the bottom row");
// Left: past the 8 pt outer edge band.
for (at, zone) in [(pos2(20.0, tools.center().y), Zone::Left), (pos2(tools.right() - 12.0, tools.center().y), Zone::Right)] {
let mut d = floated_project();
let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap();
let out = d.drag(bar.center(), at);
assert_eq!(out.actions, vec![Action::MoveGroup { panel: PROJECT[0], to: split_at(TOOLS, zone) }], "{zone:?}");
}
// The gap between Tools and Timeline: a tab dropped there splits instead of floating.
let mut d = floated_project();
let timeline = body(&d, TIMELINE);
let seam = pos2((tools.right() + timeline.left()) * 0.5, timeline.center().y);
assert!(!tools.contains(seam) && !timeline.contains(seam), "the seam is outside both frames");
let out = d.drag(d.hit(&Hit::Tab(PROGRAM)).unwrap().center(), seam);
assert!(
matches!(&out.actions[..], [Action::Move { panel: PROGRAM, anchor: TOOLS | TIMELINE, placement: Placement::Split(Zone::Left | Zone::Right) }]),
"{:?}",
out.actions
);
}
fn bottom(layout: &Layout<u32>) -> &Node<u32> {
let Some(Node::Split { second, .. }) = &layout.root else { panic!("{:?}", layout.root) };
second
}
/// Drag a split divider by `dx`, hovering it first as a pointer does.
fn drag_divider(d: &mut Driver, path: &[bool], dx: f32) {
let divider = d.hit(&Hit::Divider(Divider::Split(path.to_vec()))).unwrap_or_else(|| panic!("{path:?} in {:?}", d.hits()));
d.move_to(divider.center());
d.drag(divider.center(), divider.center() + vec2(dx, 0.0));
d.settle();
}
#[test]
fn a_split_beside_a_fixed_width_frame_keeps_its_width_and_gets_its_own_divider() {
// Left of Tools: the new frame takes half the row, beside Tools at its own width.
let mut d = floated_project();
let tools = body(&d, TOOLS);
let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap();
let at = pos2(20.0, tools.center().y);
d.hold(bar.center(), at);
let row = Rect::from_x_y_ranges(0.0..=1440.0, tools.y_range());
assert!(d.filled(Rect::from_min_max(row.min, pos2(row.center().x, row.bottom())).shrink(1.0)), "the preview shows the new frame");
d.release(at);
assert_eq!(bottom(&d.layout), &hsplit(SplitSize::Ratio(0.5), tabs(&PROJECT), tools_row()));
d.settle();
assert_eq!(body(&d, TOOLS).width(), TOOLS_WIDTH);
// Each divider resizes its own two sides; Tools keeps its width.
let project = body(&d, PROJECT[0]).width();
drag_divider(&mut d, &[true], -200.0);
let widths = |d: &Driver| (body(d, PROJECT[0]).width().round(), body(d, TOOLS).width().round());
assert_eq!(widths(&d), ((project - 200.0).round(), TOOLS_WIDTH));
drag_divider(&mut d, &[true, true], 30.0);
assert_eq!(widths(&d), ((project - 200.0).round(), TOOLS_WIDTH + 30.0));
// Right of Tools: the new frame takes half of what lies right of Tools.
let mut d = driver(editing());
let tools = body(&d, TOOLS);
let tab = d.hit(&Hit::Tab(PROGRAM)).unwrap();
d.drag(tab.center(), pos2(tools.right() - 12.0, tools.center().y));
let rest = hsplit(SplitSize::FixedSecond(METERS_WIDTH), tabs(&[TIMELINE]), tabs(&[METERS]));
let right = hsplit(SplitSize::FixedFirst(TOOLS_WIDTH), tabs(&[TOOLS]), hsplit(SplitSize::Ratio(0.5), tabs(&[PROGRAM]), rest));
assert_eq!(bottom(&d.layout), &hsplit(SplitSize::Ratio(0.29), tabs(&PROJECT), right));
d.settle();
assert_eq!(body(&d, TOOLS).width(), TOOLS_WIDTH);
}
fn texts(d: &Driver) -> Vec<String> {
fn walk(shape: &egui::Shape, out: &mut Vec<String>) {
match shape {
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)),
egui::Shape::Text(text) => out.push(text.galley.text().to_owned()),
_ => {}
}
}
let mut out = Vec::new();
d.shapes().iter().for_each(|clipped| walk(&clipped.shape, &mut out));
out
}
#[test]
fn ctrl_or_cmd_floats_from_a_tab_a_drag_bar_or_a_grip_and_the_preview_says_so() {
const HINT: &str = "Hold Ctrl to undock";
let floated = |actions: &[Action<u32>], panel: u32| match actions {
[Action::Float { panel: p, .. }] => *p == panel,
[Action::MoveGroup { panel: p, to: Target::Float { .. } }] => *p == panel,
_ => false,
};
let d = driver(editing());
let over = body(&d, TIMELINE).center();
let tab = d.hit(&Hit::Tab(PROJECT[0])).unwrap().center();
let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center();
let grip = pos2(body(&d, TOOLS).center().x, body(&d, TOOLS).top() + 4.0);
for (from, panel) in [(tab, PROJECT[0]), (bar, PROJECT[0]), (grip, TOOLS)] {
// Without the modifier, a release over a frame docks there; the preview names the modifier.
let mut d = driver(editing());
d.hold(from, over);
assert!(texts(&d).iter().any(|t| t == HINT), "{from:?}: {:?}", texts(&d));
let out = d.release(over);
assert!(!out.actions.is_empty() && !floated(&out.actions, panel), "{from:?}: {:?}", out.actions);
// Held, it floats, and the hint is gone.
let mut d = driver(editing());
d.modifiers = Modifiers::COMMAND;
d.hold(from, over);
assert!(!texts(&d).iter().any(|t| t == HINT), "{from:?}");
let out = d.release(over);
assert!(floated(&out.actions, panel), "{from:?}: {:?}", out.actions);
}
}
+5 -5
View File
@@ -493,12 +493,12 @@ fn docked_header_drag_reaches_the_host_hook_and_preserves_the_group() {
}
#[test]
fn lone_bare_or_protected_panels_draw_no_strip_and_no_bar() {
fn a_lone_protected_panel_draws_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
fn permissions(&self, panel: &u32) -> Permissions {
if *panel == 1 { Permissions::PROTECTED } else { Permissions::default() }
}
}
let ctx = Context::default();
@@ -669,7 +669,7 @@ fn frame_tabs_follow_the_wheel_and_close_on_a_middle_click() {
let wheel = |unit, y| Event::MouseWheel { unit, delta: vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE };
let middle = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Middle, pressed, modifiers: Modifiers::NONE };
// Off by default in the Dock profile.
// Off by default.
let mut d = Driver::new(frames());
let over = d.tab(2).center();
d.step(vec![Event::PointerMoved(over), wheel(egui::MouseWheelUnit::Line, 1.0)]);
@@ -678,7 +678,7 @@ fn frame_tabs_follow_the_wheel_and_close_on_a_middle_click() {
assert_eq!(second(&d), tabs(&[2, 3], 1));
let mut d = Driver::new(frames());
d.tweak = |style| style.profile = Profile::Frame;
d.tweak = super::floating_tests::frame_style;
d.step(vec![]);
let over = d.tab(2).center();
d.step(vec![Event::PointerMoved(over), wheel(egui::MouseWheelUnit::Line, 1.0)]);
+9
View File
@@ -68,6 +68,9 @@ pub enum Divider<P> {
Column(P),
/// Below the dock or floating group holding this panel first.
Group(P),
/// Where the divider of a split inside another split ends on the outer split's divider,
/// by the outer and the inner split's paths. Dragging it moves both dividers.
Intersection(Vec<bool>, Vec<bool>),
}
fn key(area: Id) -> Id {
@@ -101,6 +104,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'_, P> {
Hit::Divider(Divider::Column(p)) => id.with(("column-resize", Some(p))),
Hit::Divider(Divider::Group(p)) => id.with(("group-resize", Some(p))),
Hit::Divider(Divider::Split(path)) => id.with(("split", path)),
Hit::Divider(Divider::Intersection(outer, inner)) => id.with(("intersection", outer, inner)),
Hit::Title(p) => id.with(("move-group", Some(p))),
Hit::Close(p) => id.with(("move-group", Some(p))).with("close"),
Hit::Resize(p, edge) => {
@@ -169,9 +173,14 @@ pub(super) fn named<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(area:
}
}
let mut nodes = layout.root.as_ref().map(|root| vec![(root, Vec::<bool>::new())]).unwrap_or_default();
let mut splits: Vec<Vec<bool>> = Vec::new();
while let Some((node, path)) = nodes.pop() {
if let Node::Split { first, second, .. } = node {
name(Hit::Divider(Divider::Split(path.clone())));
for outer in splits.iter().filter(|outer| path.starts_with(outer)) {
name(Hit::Divider(Divider::Intersection(outer.clone(), path.clone())));
}
splits.push(path.clone());
let mut second_path = path.clone();
second_path.push(true);
let mut first_path = path;
+1 -7
View File
@@ -38,9 +38,6 @@ impl DockStyle {
/// | `dock.expandColumn` | `expand_column_label` | Expand column |
/// | `dock.moveWindow` | `move_window_label` | Move panel window |
/// | `dock.closeWindow` | `close_window_label` | Close panel window |
/// | `dock.undock` | `undock_label` | Undock Panel |
/// | `dock.undockGroup` | `undock_group_label` | Undock Panel Group |
/// | `dock.closePanelGroup` | `close_panel_group_label` | Close Panel Group |
/// | `dock.closeOthers` | `close_others_label` | Close Other Panels in Group |
/// | `dock.maximize` | `maximize_label` | Maximize Frame |
/// | `dock.restoreFrame` | `restore_frame_label` | Restore Frame Size |
@@ -57,7 +54,7 @@ impl DockStyle {
self
}
fn labels_mut(&mut self) -> [(&'static str, &mut String); 28] {
fn labels_mut(&mut self) -> [(&'static str, &mut String); 25] {
[
("dock.float", &mut self.float_label),
("dock.close", &mut self.close_label),
@@ -78,9 +75,6 @@ impl DockStyle {
("dock.expandColumn", &mut self.expand_column_label),
("dock.moveWindow", &mut self.move_window_label),
("dock.closeWindow", &mut self.close_window_label),
("dock.undock", &mut self.undock_label),
("dock.undockGroup", &mut self.undock_group_label),
("dock.closePanelGroup", &mut self.close_panel_group_label),
("dock.closeOthers", &mut self.close_others_label),
("dock.maximize", &mut self.maximize_label),
("dock.restoreFrame", &mut self.restore_frame_label),
+4 -4
View File
@@ -27,8 +27,8 @@ pub use hits::{Divider, Hit, ResizeEdge};
pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone};
pub use render::{close_flyout, flyout, open_flyout};
pub use surface::{
DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, Profile, begin_drag,
cancel_drag, float_space, is_dragging,
DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, begin_drag, cancel_drag,
float_space, is_dragging,
};
pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect};
pub use workspace::Workspace;
@@ -42,12 +42,12 @@ mod dock_tests;
#[cfg(test)]
mod floating_tests;
#[cfg(test)]
mod frame_tests;
#[cfg(test)]
mod group_tests;
#[cfg(test)]
mod iconic_tests;
#[cfg(test)]
mod model_tests;
#[cfg(test)]
mod split_tests;
#[cfg(test)]
mod tests;
+29 -16
View File
@@ -1,4 +1,4 @@
//! [D] The dock profile: side docks of columns, each a column bar above stacked groups.
//! [D] Docks: side docks of columns, each a column bar above stacked groups.
//!
//! Columns run from the window edge inwards. Every column's inner edge resizes it; the last
//! column's inner edge is the dock's inner edge. Behavior follows VectorCraft `floating.rs`
@@ -10,7 +10,7 @@ use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, p
use super::{GroupKind, Renderer};
use crate::docking::{
Action, Column, DockEvent, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Profile, Side, Target,
Action, Column, DockEvent, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target,
dock_model::Unit,
targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line},
};
@@ -41,6 +41,31 @@ fn side_at(edge: Edge, right_to_left: bool) -> Side {
}
}
/// The rects of a dock beside `other` in `rect`, and the widest the dock may get.
pub(in crate::docking) fn dock_rects<P>(
side: Side,
columns: &[Column<P>],
other: &Node<P>,
rect: Rect,
style: &DockStyle,
content: &dyn crate::docking::DockContent<P>,
) -> (Rect, Rect, f32) {
let gap = style.gap;
let room = (rect.width() - gap - super::node_limits(other, style, content).min.x).clamp(0.0, rect.width());
let width = columns.iter().map(|c| column_width(c, style)).sum::<f32>().clamp(0.0, room);
let (dock, rest) = match edge_of(side, style.right_to_left) {
Edge::Left => (
Rect::from_min_max(rect.min, pos2(rect.left() + width, rect.bottom())),
Rect::from_min_max(pos2((rect.left() + width + gap).min(rect.right()), rect.top()), rect.max),
),
Edge::Right => (
Rect::from_min_max(pos2(rect.right() - width, rect.top()), rect.max),
Rect::from_min_max(rect.min, pos2((rect.right() - width - gap).max(rect.left()), rect.bottom())),
),
};
(dock, rest, room)
}
pub(in crate::docking) fn column_width<P>(column: &Column<P>, style: &DockStyle) -> f32 {
if column.iconic { column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width) } else { column.width }
}
@@ -125,19 +150,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
(other, Node::Dock { side, columns }) => (false, *side, columns, other),
_ => return false,
};
let gap = self.style.gap;
let room = (rect.width() - gap - self.node_limits(other).min.x).clamp(0.0, rect.width());
let width = columns.iter().map(|c| column_width(c, self.style)).sum::<f32>().clamp(0.0, room);
let (dock, rest) = match edge_of(side, self.style.right_to_left) {
Edge::Left => (
Rect::from_min_max(rect.min, pos2(rect.left() + width, rect.bottom())),
Rect::from_min_max(pos2((rect.left() + width + gap).min(rect.right()), rect.top()), rect.max),
),
Edge::Right => (
Rect::from_min_max(pos2(rect.right() - width, rect.top()), rect.max),
Rect::from_min_max(rect.min, pos2((rect.right() - width - gap).max(rect.left()), rect.bottom())),
),
};
let (dock, rest, room) = dock_rects(side, columns, other, rect, self.style, &*self.content);
self.dock(ui, side, columns, dock, room);
path.push(dock_first);
self.node(ui, other, rest, path);
@@ -470,7 +483,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// have no dock yet.
fn dock_candidates(&self) -> Vec<DockDrop<P>> {
let mut candidates = self.dock_targets.clone();
if self.style.profile != Profile::Dock || !usable(self.workspace) {
if !self.style.new_dock_zones || !usable(self.workspace) {
return candidates;
}
let rtl = self.style.right_to_left;
+8
View File
@@ -240,6 +240,14 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
return self.stack_proposal(window, members, pointer);
}
let (anchor, zone, preview) = self.edge_proposal(members, pointer)?;
let lead = members.first()?.clone();
let action = match members {
[_] => Action::Move { panel: lead, anchor: anchor.clone(), placement: Placement::Edge(zone) },
_ => Action::MoveGroup { panel: lead, to: Target::At { anchor: anchor.clone(), placement: Placement::Edge(zone) } },
};
if !self.leaves_room(action) {
return None;
}
Some(DropProposal { anchor, placement: Placement::Edge(zone), preview, insertion: None, noop: false })
}
+29 -37
View File
@@ -1,4 +1,5 @@
//! Frame profile: Maximize Frame, the outer edge band, hidden panels and the frame menu items.
//! What the split tree adds around the groups: Maximize Frame, the outer edge band, hidden
//! panels, the float hint and the group menu tail.
use std::{borrow::Cow, collections::HashMap, fmt::Debug, hash::Hash};
@@ -6,7 +7,7 @@ use egui::{Context, Pos2, Rect, pos2, vec2};
use super::{GroupKind, Renderer, floating::StackHit, group::TabGroup};
use crate::docking::{
Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Profile, Zone,
Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Zone,
dock_model::{Place, node_at},
};
@@ -157,14 +158,14 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
self.frame.paths.as_ref().and_then(|paths| paths.get(path).cloned()).unwrap_or_else(|| path.to_vec())
}
/// The outer edge band under the pointer (P8): top and bottom in both profiles, left and
/// right in the frame profile. Returns a docked anchor that is not moving, the zone and
/// the preview of the new frame.
/// The outer edge band under the pointer (P8): top and bottom, and left and right with
/// [`crate::docking::DockStyle::side_edge_zones`]. Returns a docked anchor that is not
/// moving, the zone and the preview of the new frame.
pub(super) fn edge_proposal(&self, members: &[P], pointer: Pos2) -> Option<(P, Zone, Rect)> {
let area = self.frame.edge.filter(|area| area.contains(pointer))?;
let band = self.style.edge_band.min(area.width() / 4.0).min(area.height() / 4.0);
let mut edges = vec![(pointer.y - area.top(), Zone::Top), (area.bottom() - pointer.y, Zone::Bottom)];
if self.style.profile == Profile::Frame {
if self.style.side_edge_zones {
edges.extend([(pointer.x - area.left(), Zone::Left), (area.right() - pointer.x, Zone::Right)]);
}
let (distance, zone) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?;
@@ -183,10 +184,10 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
Some((anchor, zone, preview))
}
/// Under the pointer while a drag offers a docking target (frame profile): which modifier
/// floats the drop instead.
/// Under the pointer while a drag offers a docking target: which modifier floats the drop
/// instead.
pub(super) fn paint_float_hint(&self, ctx: &Context, pointer: Pos2) {
if self.style.profile != Profile::Frame || self.style.float_hint_label.is_empty() {
if !self.style.float_hint || self.style.float_hint_label.is_empty() {
return;
}
let key = if ctx.os() == egui::os::OperatingSystem::Mac { "⌘" } else { "Ctrl" };
@@ -198,22 +199,10 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
painter.galley(rect.center() - galley.size() * 0.5, galley, self.style.text);
}
pub(super) fn float_label(&self) -> String {
if self.style.profile == Profile::Frame { self.style.undock_label.clone() } else { self.style.float_label.clone() }
}
pub(super) fn float_group_label(&self) -> String {
if self.style.profile == Profile::Frame { self.style.undock_group_label.clone() } else { self.style.float_group_label.clone() }
}
pub(super) fn close_group_label(&self) -> String {
if self.style.profile == Profile::Frame { self.style.close_panel_group_label.clone() } else { self.style.close_group_label.clone() }
}
/// Ctrl/Cmd held when a frame-profile tab drag starts: the drop floats (S9).
/// Ctrl/Cmd held when a split-tree tab drag starts: the drop floats (S9).
pub(super) fn latch_undock(&self, ctx: &Context, kind: Option<GroupKind>) {
let command = ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd);
if self.style.profile == Profile::Frame && kind == Some(GroupKind::Frame) && command {
if self.style.command_press_floats && kind == Some(GroupKind::Frame) && command {
ctx.data_mut(|d| {
if let Some(drag) = d.get_temp::<crate::docking::Drag<P>>(self.id.with("drag")) {
d.insert_temp(self.id.with("drag"), crate::docking::Drag { undock: true, ..drag });
@@ -222,21 +211,24 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
}
/// Frame items of the menu tail: Close Other Panels in Group and Maximize Frame (frame
/// profile), and Move Panel to New Window where native windows are available.
/// The menu tail: Close Other Panels in Group and Maximize Frame when the style shows
/// them, and Move Panel to New Window where native windows are available.
pub(super) fn frame_items(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) {
if self.style.profile == Profile::Frame {
let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable);
if owner.panels.len() > 1 && others && self.style.close_menu_items && self.menu_action(ui, &self.style.close_others_label.clone()) {
self.output.actions.push(Action::CloseOthers { panel: panel.clone() });
}
if owner.kind == GroupKind::Frame {
let restore = self.frame.maximized.as_ref().is_some_and(|m| owner.panels.contains(m));
let label = if restore { self.style.restore_frame_label.clone() } else { self.style.maximize_label.clone() };
if self.menu_action(ui, &label) {
let panel = if restore { self.frame.maximized.clone().unwrap_or_else(|| panel.clone()) } else { panel.clone() };
self.output.events.push(DockEvent::ToggleMaximize { panel });
}
let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable);
if self.style.close_others_item
&& owner.panels.len() > 1
&& others
&& self.style.close_menu_items
&& self.menu_action(ui, &self.style.close_others_label.clone())
{
self.output.actions.push(Action::CloseOthers { panel: panel.clone() });
}
if self.style.maximize_item && owner.kind == GroupKind::Frame {
let restore = self.frame.maximized.as_ref().is_some_and(|m| owner.panels.contains(m));
let label = if restore { self.style.restore_frame_label.clone() } else { self.style.maximize_label.clone() };
if self.menu_action(ui, &label) {
let panel = if restore { self.frame.maximized.clone().unwrap_or_else(|| panel.clone()) } else { panel.clone() };
self.output.events.push(DockEvent::ToggleMaximize { panel });
}
}
#[cfg(feature = "native-windows")]
+5 -5
View File
@@ -134,7 +134,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
// Registered last, so the band wins over the tabs below it.
let border = mirror(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);
if self.style.wheel_selects_tabs() {
if self.style.wheel_selects_tabs {
let tabs_area = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, strip.bottom())));
self.wheel_tabs(ui, tabs_area, panels, active);
}
@@ -255,7 +255,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let closable = self.style.close_menu_items && all(&|p| p.closable);
let movable = all(&|p| p.movable);
let floatable = movable && all(&|p| p.floatable);
if closable && self.menu_action(ui, &self.close_group_label()) {
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 {
@@ -267,7 +267,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if owner.kind == GroupKind::Dock && self.menu_action(ui, &self.style.collapse_to_icons_label.clone()) {
self.output.actions.push(Action::SetIconic { panel: panel.clone(), iconic: true });
}
if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label()) {
if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.style.float_group_label.clone()) {
let size = self.group_float_size(owner.panels, owner.rect.size());
let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), self.float_bounds);
self.output.actions.push(Action::MoveGroup {
@@ -395,7 +395,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized });
}
}
if self.style.middle_click_closes_tabs() && policy.closable && response.clicked_by(egui::PointerButton::Middle) {
if self.style.middle_click_closes_tabs && policy.closable && response.clicked_by(egui::PointerButton::Middle) {
self.output.actions.push(Action::Close { panel: panel.clone() });
}
if policy.movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), group) {
@@ -404,7 +404,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
response.context_menu(|ui| {
self.content.panel_menu(ui, panel);
if policy.movable && policy.floatable {
let logical = self.float_label();
let logical = self.style.float_label.clone();
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));
+79 -43
View File
@@ -1,8 +1,8 @@
use std::{collections::HashMap, fmt::Debug, hash::Hash};
use egui::{Context, Id, Rect, Sense, Ui, UiBuilder, Vec2, pos2, vec2};
use egui::{Context, CursorIcon, Id, Rect, Sense, Ui, UiBuilder, Vec2, pos2, vec2};
use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split_rect};
use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis};
use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions, dock_model::Place};
@@ -25,6 +25,9 @@ pub(in crate::docking) use dock::fit_widths;
pub(in crate::docking) use floating::RESIZE_HANDLES;
mod group;
mod preview;
mod sizes;
pub(in crate::docking) use sizes::{DROP_MARGIN, Drawn, Sizer};
pub(super) struct Renderer<'a, P> {
pub(super) id: Id,
@@ -50,18 +53,16 @@ pub(super) struct Renderer<'a, P> {
/// Each panel's place in `layout`, and its entry in `float_space.contents`.
pub(super) places: HashMap<P, Place>,
pub(super) contents: HashMap<P, usize>,
/// Frames drawn as the fixed-size side of their parent split, for split previews.
pub(super) fixed_slots: Vec<FixedSlot<P>>,
/// Split dividers drawn so far, by view path, for the intersections of their ancestors.
pub(super) dividers: Vec<DrawnDivider>,
}
/// A frame that holds the fixed size of its parent split: the parent's and the other child's
/// rects, where a side split of the frame goes (see `dock_model::split_frame`).
pub(super) struct FixedSlot<P> {
pub(super) panels: Vec<P>,
pub(super) axis: SplitAxis,
pub(super) first: bool,
pub(super) parent: Rect,
pub(super) sibling: Rect,
pub(super) struct DrawnDivider {
path: Vec<bool>,
axis: SplitAxis,
/// The whole split's rect.
rect: Rect,
divider: Rect,
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
@@ -111,25 +112,10 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
match node {
Node::Split { axis: SplitAxis::Horizontal, first, second, .. } if self.dock_split(ui, first, second, rect, path) => {}
Node::Split { axis, size, first, second } => {
let Some(wanted) = split_rect(rect, *axis, *size, self.style.gap, 0.0) else { return };
let (lo, hi) = split_bounds(wanted.available(), *axis, self.node_limits(first), self.node_limits(second));
let first_size = wanted.first_size().clamp(lo, hi);
let Some(split) = split_rect(rect, *axis, SplitSize::FixedFirst(first_size), self.style.gap, 0.0) else { return };
let fixed = match size {
SplitSize::FixedFirst(_) => Some((first, true, split.second)),
SplitSize::FixedSecond(_) => Some((second, false, split.first)),
SplitSize::Ratio(_) => None,
};
let panels = |node: &Node<P>| match node {
Node::Tabs { panels, .. } => Some(panels.clone()),
Node::Stack { entries } => Some(entries.iter().map(|e| e.panel.clone()).collect()),
Node::Split { .. } | Node::Dock { .. } => None,
};
if let Some((node, first, sibling)) = fixed
&& let Some(panels) = panels(node)
{
self.fixed_slots.push(FixedSlot { panels, axis: *axis, first, parent: rect, sibling });
}
let sizer = self.sizer();
let Some(split) = sizer.split(*axis, *size, first, second, rect) else { return };
let (lo, hi) = sizer.range(node, rect);
let mark = self.dividers.len();
path.push(false);
self.node(ui, first, split.first, path);
path.pop();
@@ -147,13 +133,16 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
&self.style.resize_label,
ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent },
)
.value(split.first_size(), lo..=hi)
.value(split.first_size(), (split.first_size() + lo)..=(split.first_size() + hi))
.show(ui);
self.register_interaction(ui.ctx(), response.response.id);
if response.delta != 0.0
&& let Some(size) = split.resized(*size, (split.first_size() + response.delta).clamp(lo, hi) - split.first_size(), 0.0)
{
self.output.actions.push(Action::ResizeSplit { path: layout_path, size });
if response.delta != 0.0 {
let actions = self.divider_actions(node, rect, path, response.delta);
self.output.actions.extend(actions);
}
self.intersections(ui, node, rect, path, split.divider, mark);
if self.dividers.len() < crate::docking::model::MAX_NODES {
self.dividers.push(DrawnDivider { path: path.clone(), axis: *axis, rect, divider: split.divider });
}
}
Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame),
@@ -250,8 +239,50 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
}
pub(super) fn node_limits(&self, node: &Node<P>) -> PanelLimits {
node_limits(node, self.style, &*self.content)
pub(super) fn sizer(&self) -> Sizer<'_, P> {
Sizer { style: self.style, content: &*self.content }
}
/// `ResizeSplit` actions that move the divider of the split `node`, drawn at view path
/// `path` in `rect`, by `delta` points.
fn divider_actions(&self, node: &Node<P>, rect: Rect, path: &[bool], delta: f32) -> Vec<Action<P>> {
let sizes = self.sizer().drag(node, rect, delta, &mut path.to_vec());
sizes.into_iter().map(|(path, size)| Action::ResizeSplit { path: self.original_path(&path), size }).collect()
}
/// Four-way handles where a divider drawn inside the split `node` (since `mark`) ends on
/// the split's own divider: dragging one moves both dividers.
fn intersections(&mut self, ui: &mut Ui, node: &Node<P>, rect: Rect, path: &[bool], divider: Rect, mark: usize) {
let Node::Split { axis, .. } = node else { return };
let side = (self.style.gap * 3.0).max(12.0);
let mut found = Vec::new();
for inner in self.dividers.get(mark..).unwrap_or_default() {
if inner.axis == *axis || !inner.divider.expand(1.0).intersects(divider) {
continue;
}
let center = match axis {
SplitAxis::Horizontal => pos2(divider.center().x, inner.divider.center().y),
SplitAxis::Vertical => pos2(inner.divider.center().x, divider.center().y),
};
found.push((inner.path.clone(), inner.rect, Rect::from_center_size(center, Vec2::splat(side)).intersect(rect)));
}
for (inner_path, inner_rect, hit) in found {
let Some(inner) = inner_path.get(path.len()..).and_then(|rest| crate::docking::dock_model::node_at(node, rest)) else { continue };
let id = self.id.with(("intersection", self.original_path(path), self.original_path(&inner_path)));
let response = ui.interact(hit, id, Sense::drag());
self.register_interaction(ui.ctx(), id);
if response.hovered() || response.dragged() {
ui.ctx().set_cursor_icon(CursorIcon::Move);
}
let delta = response.drag_delta();
if delta != Vec2::ZERO && delta.is_finite() {
let along = |axis: SplitAxis| if axis == SplitAxis::Horizontal { delta.x } else { delta.y };
let Node::Split { axis: inner_axis, .. } = inner else { continue };
let mut actions = self.divider_actions(node, rect, path, along(*axis));
actions.extend(self.divider_actions(inner, inner_rect, &inner_path, along(*inner_axis)));
self.output.actions.extend(actions);
}
}
}
pub(super) fn contents(&mut self, ui: &mut Ui, panel: &P, rect: Rect) {
@@ -273,10 +304,10 @@ 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.
/// A lone protected 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).is_protected() || content.bare(panel),
[panel] => content.permissions(panel).is_protected(),
_ => false,
}
}
@@ -288,12 +319,14 @@ pub(in crate::docking) fn node_limits<P>(node: &Node<P>, style: &DockStyle, cont
let width: f32 = columns.iter().map(|c| dock::column_width(c, style)).sum();
PanelLimits { min: vec2(width.clamp(style.min_pane, 1_000_000.0), style.min_pane), ..PanelLimits::default() }
}
// A group is as large as its largest panel allows: a panel without a maximum lifts the
// maximum of the panels it joins.
Node::Tabs { panels, .. } => {
let mut limits = PanelLimits::default();
let mut limits = PanelLimits { min: Vec2::ZERO, max: if panels.is_empty() { PanelLimits::default().max } else { Vec2::ZERO } };
for panel in panels {
let policy = content.limits(panel);
limits.min = limits.min.max(policy.min).max(Vec2::splat(style.min_pane));
limits.max = limits.max.min(policy.max);
limits.max = limits.max.max(policy.max);
}
limits.max = limits.max.max(limits.min);
if !stripless(panels, content) {
@@ -304,11 +337,14 @@ pub(in crate::docking) fn node_limits<P>(node: &Node<P>, style: &DockStyle, cont
}
Node::Stack { entries } => {
let mut limits = PanelLimits::default();
if !entries.is_empty() {
limits.max.x = 0.0;
}
for entry in entries {
let policy = content.limits(&entry.panel);
limits.min.x = limits.min.x.max(policy.min.x).max(style.min_pane);
limits.min.y += if entry.open { policy.min.y.max(style.min_pane) } else { 0.0 } + style.tab_height;
limits.max.x = limits.max.x.min(policy.max.x);
limits.max.x = limits.max.x.max(policy.max.x);
}
limits.min = limits.min.min(Vec2::splat(1_000_000.0));
limits.max = limits.max.max(limits.min);
+55 -45
View File
@@ -1,17 +1,15 @@
use std::{fmt::Debug, hash::Hash};
use std::{collections::HashMap, fmt::Debug, hash::Hash};
use egui::{Context, Key, Pos2, Rect, Ui, Vec2, pos2, vec2};
use super::{Renderer, clamp_float, dock::group_action};
use super::{DROP_MARGIN, Drawn, Renderer, clamp_float, dock::group_action};
use crate::docking::chrome::drop_preview;
use crate::docking::{
Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Node, Placement, Target, Zone, cancel_drag,
Action, Drag, DropProposal, DropRequest, DropTarget, GroupDrag, GroupGesture, Layout, Node, Placement, Target, Zone, cancel_drag,
dock_model::{Place, node_at},
resolve_drop,
surface::TAB_PADDING,
zone_rect,
};
use crate::layout::SplitAxis;
/// Ctrl (Windows, Linux) or Cmd (macOS) held: the drop floats and no target is offered.
fn float_only(ctx: &Context) -> bool {
@@ -90,7 +88,6 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
return valid(proposal);
}
let own = |t: &crate::docking::DropTarget<P>| t.tabs.iter().any(|(p, _)| drag.members.contains(p));
let pointer = self.seam_pointer(pointer);
let target =
self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer) && !(window && own(t)))?;
if own(target) {
@@ -102,49 +99,48 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
noop: true,
});
}
let proposal = resolve_drop(&drag.panel, pointer, std::slice::from_ref(target))?;
let proposal = self.resolve_roomy(&drag.panel, pointer, std::slice::from_ref(target), |to| group_action(drag, to))?;
if window && matches!(proposal.placement, Placement::Split(_)) {
return None;
}
valid(self.split_preview(proposal))
valid(proposal)
}
/// The preview of a side split beside a frame that holds its parent's fixed size: the half
/// of the parent or of the other child that the new frame takes.
fn split_preview(&self, mut proposal: DropProposal<P>) -> DropProposal<P> {
let Placement::Split(zone) = proposal.placement else { return proposal };
let axis = match zone {
Zone::Center => return proposal,
Zone::Left | Zone::Right => SplitAxis::Horizontal,
Zone::Top | Zone::Bottom => SplitAxis::Vertical,
};
let Some(slot) = self.fixed_slots.iter().find(|slot| slot.axis == axis && slot.panels.contains(&proposal.anchor)) else { return proposal };
let opposite = match zone {
Zone::Left => Zone::Right,
Zone::Right => Zone::Left,
Zone::Top => Zone::Bottom,
_ => Zone::Top,
};
let before = matches!(zone, Zone::Left | Zone::Top);
let preview = if before == slot.first { zone_rect(slot.parent, zone) } else { zone_rect(slot.sibling, opposite) };
proposal.preview = preview.unwrap_or(proposal.preview);
proposal
}
/// A pointer in the gap between docked frames, outside every target, moves onto the edge of
/// the nearest frame that takes splits, so a seam splits beside that frame rather than floating.
pub(super) fn seam_pointer(&self, pointer: Pos2) -> Pos2 {
let usable = |rect: Rect| crate::docking::targets::usable(rect);
if self.targets.iter().any(|t| usable(t.rect) && t.rect.contains(pointer)) {
return pointer;
/// [`resolve_drop`], where a split that would leave a group without room becomes a join
/// of the target group.
fn resolve_roomy(&self, panel: &P, pointer: Pos2, targets: &[DropTarget<P>], action: impl Fn(Target<P>) -> Action<P>) -> Option<DropProposal<P>> {
let proposal = resolve_drop(panel, pointer, targets)?;
if !matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center) {
return Some(proposal);
}
self.targets
.iter()
.filter(|t| t.allow_splits && usable(t.rect))
.map(|t| (t.rect.distance_to_pos(pointer), t.rect))
.filter(|(distance, _)| *distance <= self.style.gap)
.min_by(|a, b| a.0.total_cmp(&b.0))
.map_or(pointer, |(_, rect)| rect.clamp(pointer))
if self.leaves_room(action(Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() })) {
return Some(proposal);
}
let target = targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?;
resolve_drop(panel, pointer, std::slice::from_ref(&DropTarget { allow_splits: false, ..target.clone() }))
}
/// Whether `action`, a drop that splits a group or the outer edge, leaves room: every group
/// it makes smaller stays [`DROP_MARGIN`] above its minimum, so each divider can still
/// travel both ways. An action the model rejects is left to the model's check.
pub(super) fn leaves_room(&self, action: Action<P>) -> bool {
let mut candidate = self.layout.clone();
if candidate.apply_with_permissions(action, |p| self.permissions(p)).is_err() {
return true;
}
let sizer = self.sizer();
let drawn = |layout: &Layout<P>| {
let mut out: Vec<Drawn<'_, P>> = Vec::new();
if let Some(root) = &layout.root {
sizer.groups(root, self.workspace, &mut out);
}
out.into_iter().filter_map(|d| Some((lead(d.node)?.clone(), d.rect.size(), d.limits.min))).collect::<Vec<_>>()
};
let before: HashMap<P, Vec2> = drawn(self.layout).into_iter().map(|(panel, size, _)| (panel, size)).collect();
drawn(&candidate).into_iter().all(|(panel, size, min)| {
let room = size - min;
(room.x >= DROP_MARGIN && room.y >= DROP_MARGIN) || before.get(&panel).is_some_and(|old| size.x >= old.x - 0.5 && size.y >= old.y - 0.5)
})
}
/// Release of a group drag: join or split at the target, or float at the ghost with the
@@ -276,8 +272,13 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let proposal = if float_only {
None
} else {
self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false)
.or_else(|| resolve_drop(&drag.panel, self.seam_pointer(pointer), &self.targets).map(|p| self.split_preview(p)))
self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false).or_else(|| {
let panel = &drag.panel;
self.resolve_roomy(panel, pointer, &self.targets, |to| match to {
Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement },
to => Action::MovePanel { panel: panel.clone(), to },
})
})
};
if released {
cancel_drag::<P>(ctx, self.id);
@@ -453,3 +454,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
true
}
}
/// The panel that names a drawn group: its first panel; none for a dock or a split.
fn lead<P>(node: &Node<P>) -> Option<&P> {
match node {
Node::Tabs { panels, .. } => panels.first(),
Node::Stack { entries } => entries.first().map(|e| &e.panel),
Node::Split { .. } | Node::Dock { .. } => None,
}
}
+172
View File
@@ -0,0 +1,172 @@
//! Split-tree geometry: where each split draws its children, how far a divider can travel,
//! and the sizes that move a divider while every group away from it keeps its size.
use egui::Rect;
use super::{dock::dock_rects, node_limits, split_bounds};
use crate::docking::{DockContent, DockStyle, Node, PanelLimits};
use crate::layout::{SplitAxis, SplitLayout, SplitSize, split_rect};
/// Room a group keeps above its minimum after a drop, so each of its dividers can still
/// travel both ways.
pub(in crate::docking) const DROP_MARGIN: f32 = 1.0;
pub(in crate::docking) struct Sizer<'a, P> {
pub(in crate::docking) style: &'a DockStyle,
pub(in crate::docking) content: &'a dyn DockContent<P>,
}
/// One group as drawn: its node, rect and limits.
pub(in crate::docking) struct Drawn<'a, P> {
pub(in crate::docking) node: &'a Node<P>,
pub(in crate::docking) rect: Rect,
pub(in crate::docking) limits: PanelLimits,
}
fn extent(rect: Rect, axis: SplitAxis) -> f32 {
match axis {
SplitAxis::Horizontal => rect.width(),
SplitAxis::Vertical => rect.height(),
}
}
fn along(v: egui::Vec2, axis: SplitAxis) -> f32 {
match axis {
SplitAxis::Horizontal => v.x,
SplitAxis::Vertical => v.y,
}
}
impl<P> Sizer<'_, P> {
fn limits(&self, node: &Node<P>) -> PanelLimits {
node_limits(node, self.style, self.content)
}
/// The split as drawn: its stored size, clamped so both children fit their limits.
pub(in crate::docking) fn split(&self, axis: SplitAxis, size: SplitSize, first: &Node<P>, second: &Node<P>, rect: Rect) -> Option<SplitLayout> {
let wanted = split_rect(rect, axis, size, self.style.gap, 0.0)?;
let (lo, hi) = split_bounds(wanted.available(), axis, self.limits(first), self.limits(second));
split_rect(rect, axis, SplitSize::FixedFirst(wanted.first_size().clamp(lo, hi)), self.style.gap, 0.0)
}
/// The children of a split drawn in `rect`, with their rects; a dock beside its neighbour
/// is drawn by the dock renderer.
pub(in crate::docking) fn children<'n>(&self, node: &'n Node<P>, rect: Rect) -> Option<[(&'n Node<P>, Rect); 2]> {
let Node::Split { axis, size, first, second } = node else { return None };
match (&**first, &**second) {
(Node::Dock { side, columns }, other) if *axis == SplitAxis::Horizontal => {
let (dock, rest, _) = dock_rects(*side, columns, other, rect, self.style, self.content);
Some([(first, dock), (second, rest)])
}
(other, Node::Dock { side, columns }) if *axis == SplitAxis::Horizontal => {
let (dock, rest, _) = dock_rects(*side, columns, other, rect, self.style, self.content);
Some([(first, rest), (second, dock)])
}
_ => {
let split = self.split(*axis, *size, first, second, rect)?;
Some([(first, split.first), (second, split.second)])
}
}
}
/// Every group of `node` drawn in `rect`; a dock counts as one.
pub(in crate::docking) fn groups<'n>(&self, node: &'n Node<P>, rect: Rect, out: &mut Vec<Drawn<'n, P>>) {
match self.children(node, rect) {
Some(children) => children.into_iter().for_each(|(child, rect)| self.groups(child, rect, out)),
None => out.push(Drawn { node, rect, limits: self.limits(node) }),
}
}
/// How far the end edge (`end`) or start edge of `node` drawn in `rect` can move along
/// `axis`: inward while the groups touching it stay at their minimums, and outward up to
/// their maximums.
pub(in crate::docking) fn travel(&self, node: &Node<P>, rect: Rect, axis: SplitAxis, end: bool) -> (f32, f32) {
let docked =
matches!(node, Node::Split { first, second, .. } if matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. }));
match (node, self.children(node, rect)) {
(Node::Split { first, .. }, Some([a, b])) if docked => {
let (other, rect) = if matches!(**first, Node::Dock { .. }) { b } else { a };
self.travel(other, rect, axis, end)
}
(Node::Split { axis: split_axis, .. }, Some([a, b])) if *split_axis == axis => {
let (child, rect) = if end { b } else { a };
self.travel(child, rect, axis, end)
}
(_, Some([a, b])) => {
let (a, b) = (self.travel(a.0, a.1, axis, end), self.travel(b.0, b.1, axis, end));
(a.0.min(b.0), a.1.min(b.1))
}
(_, None) => {
let limits = self.limits(node);
let size = extent(rect, axis);
((size - along(limits.min, axis)).max(0.0), (along(limits.max, axis) - size).max(0.0))
}
}
}
/// The sizes that move the end (`end`) or start edge of `node` drawn in `rect` outward by
/// `grow` points: only the groups touching that edge change. Paths are relative to `path`.
pub(in crate::docking) fn follow(
&self,
(node, rect): (&Node<P>, Rect),
axis: SplitAxis,
end: bool,
grow: f32,
path: &mut Vec<bool>,
out: &mut Vec<(Vec<bool>, SplitSize)>,
) {
let Some([a, b]) = self.children(node, rect) else { return };
let Node::Split { axis: split_axis, first, second, .. } = node else { return };
let docked = matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. });
let visit = |child: (&Node<P>, Rect), second_child: bool, path: &mut Vec<bool>, out: &mut Vec<(Vec<bool>, SplitSize)>| {
path.push(second_child);
self.follow(child, axis, end, grow, path, out);
path.pop();
};
if docked {
let dock_first = matches!(**first, Node::Dock { .. });
visit(if dock_first { b } else { a }, dock_first, path, out);
} else if *split_axis == axis {
let available = extent(a.1, axis) + extent(b.1, axis) + grow;
let first_size = extent(a.1, axis) + if end { 0.0 } else { grow };
if available > 0.0 {
out.push((path.clone(), SplitSize::Ratio((first_size / available).clamp(0.0, 1.0))));
}
visit(if end { b } else { a }, end, path, out);
} else {
visit(a, false, path, out);
visit(b, true, path, out);
}
}
/// The divider of the split `node` drawn in `rect` moved by `delta` points, clamped to its
/// travel: the split's own size and those of the splits that keep the groups away from the
/// divider at their sizes. Paths are relative to `path`, the split's own.
pub(in crate::docking) fn drag(&self, node: &Node<P>, rect: Rect, delta: f32, path: &mut Vec<bool>) -> Vec<(Vec<bool>, SplitSize)> {
let Node::Split { axis, size, first, second } = node else { return Vec::new() };
let Some(split) = self.split(*axis, *size, first, second, rect) else { return Vec::new() };
let (lo, hi) = self.range(node, rect);
let delta = delta.clamp(lo, hi);
if delta == 0.0 || !delta.is_finite() || split.available() <= 0.0 {
return Vec::new();
}
let mut out = vec![(path.clone(), SplitSize::Ratio(((split.first_size() + delta) / split.available()).clamp(0.0, 1.0)))];
path.push(false);
self.follow((first, split.first), *axis, true, delta, path, &mut out);
path.pop();
path.push(true);
self.follow((second, split.second), *axis, false, -delta, path, &mut out);
path.pop();
out
}
/// How far the divider of the split `node` drawn in `rect` can move: `(lo, hi)`, with
/// `lo <= 0 <= hi`.
pub(in crate::docking) fn range(&self, node: &Node<P>, rect: Rect) -> (f32, f32) {
let Node::Split { axis, size, first, second } = node else { return (0.0, 0.0) };
let Some(split) = self.split(*axis, *size, first, second, rect) else { return (0.0, 0.0) };
let (first_in, first_out) = self.travel(first, split.first, *axis, true);
let (second_in, second_out) = self.travel(second, split.second, *axis, false);
(-first_in.min(second_out), first_out.min(second_in))
}
}
+445
View File
@@ -0,0 +1,445 @@
//! A split tree of tab groups in an editing layout like FilmCraft's: Source | Program |
//! Properties over Project | Tools | Timeline | Audio Meters, every group an ordinary
//! resizable one. One test per docking rule, the stuck cases of the FilmCraft map, and a
//! property test over random drops.
use std::collections::HashMap;
use super::floating_tests::frame_style;
use super::render::{Drawn, Sizer};
use super::testing::DockDriver;
use super::*;
use crate::layout::{SplitAxis, SplitSize};
use egui::{Modifiers, Pos2, Rect, Vec2, pos2, vec2};
const SOURCE: [u32; 4] = [1, 11, 12, 13];
const PROGRAM: u32 = 2;
const PROPERTIES: [u32; 3] = [3, 31, 32];
const PROJECT: [u32; 7] = [4, 41, 42, 43, 44, 45, 46];
const TOOLS: u32 = 5;
const TIMELINE: u32 = 6;
const METERS: u32 = 7;
const INFO: u32 = 9;
const SCREEN: Vec2 = Vec2::new(1440.0, 860.0);
/// Panel bodies by their rects; `caps` are maximum widths a host asks for.
#[derive(Default)]
struct Film {
bodies: HashMap<u32, Rect>,
caps: HashMap<u32, f32>,
}
impl DockContent<u32> for Film {
fn body(&mut self, ui: &mut egui::Ui, panel: &u32) {
self.bodies.insert(*panel, ui.max_rect());
}
fn logical_label(&self, panel: &u32) -> String {
format!("Panel {panel}")
}
fn limits(&self, panel: &u32) -> PanelLimits {
match self.caps.get(panel) {
Some(width) => PanelLimits { min: Vec2::ZERO, max: vec2(*width, 1_000_000.0) },
None => PanelLimits::default(),
}
}
}
type Driver = DockDriver<u32, Film>;
fn tabs(panels: &[u32]) -> Node<u32> {
Node::Tabs { panels: panels.to_vec(), active: 0 }
}
fn split(axis: SplitAxis, ratio: f32, first: Node<u32>, second: Node<u32>) -> Node<u32> {
Node::Split { axis, size: SplitSize::Ratio(ratio), first: Box::new(first), second: Box::new(second) }
}
fn hsplit(ratio: f32, first: Node<u32>, second: Node<u32>) -> Node<u32> {
split(SplitAxis::Horizontal, ratio, first, second)
}
/// The Editing workspace, with Info floating over Properties.
fn editing() -> Layout<u32> {
let top = hsplit(0.69, hsplit(0.5, tabs(&SOURCE), tabs(&[PROGRAM])), tabs(&PROPERTIES));
let row = hsplit(0.06, tabs(&[TOOLS]), hsplit(0.88, tabs(&[TIMELINE]), tabs(&[METERS])));
let bottom = hsplit(0.29, tabs(&PROJECT), row);
Layout {
root: Some(split(SplitAxis::Vertical, 0.57, top, bottom)),
floating: vec![Floating::single(vec![INFO], 0, [1120.0, 60.0, 280.0, 200.0])],
}
}
fn driver(layout: Layout<u32>, content: Film) -> Driver {
let mut d = DockDriver::new(layout, content).with_style(frame_style);
d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
d.area = d.screen;
d.settle();
d
}
fn film() -> Driver {
driver(editing(), Film::default())
}
/// A group's rect: its active body and its tab strip.
fn group(d: &Driver, panel: u32) -> Rect {
let body = d.content.bodies.get(&panel).copied().unwrap_or_else(|| panic!("panel {panel} not drawn"));
Rect::from_min_max(pos2(body.left(), body.top() - 26.0), body.max)
}
fn tab(d: &Driver, panel: u32) -> Pos2 {
d.hit(&Hit::Tab(panel)).unwrap_or_else(|| panic!("tab {panel}")).center()
}
/// Drag a divider by `delta`, hovering it first as a pointer does.
fn drag_hit(d: &mut Driver, hit: Hit<u32>, delta: Vec2) {
let at = d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits())).center();
d.move_to(at);
d.drag(at, at + delta);
d.settle();
}
fn divider(path: &[bool]) -> Hit<u32> {
Hit::Divider(Divider::Split(path.to_vec()))
}
fn sizes(d: &Driver, panels: &[u32]) -> Vec<Vec2> {
panels.iter().map(|p| group(d, *p).size()).collect()
}
fn close(a: Rect, b: Rect) -> bool {
(a.min - b.min).length() < 1.0 && (a.max - b.max).length() < 1.0
}
#[test]
fn a_group_is_a_picture_frame_whose_edges_split_it_in_half_and_whose_middle_joins_it() {
let base = film();
let timeline = group(&base, TIMELINE);
let others = [PROJECT[0], TOOLS, METERS, SOURCE[0], PROGRAM, PROPERTIES[0]];
let inset = |zone| {
let (w, h) = (timeline.width() * 0.08, (timeline.height() - 26.0) * 0.08);
match zone {
Zone::Left => pos2(timeline.left() + w, timeline.center().y + 13.0),
Zone::Right => pos2(timeline.right() - w, timeline.center().y + 13.0),
Zone::Top => pos2(timeline.center().x, timeline.top() + 26.0 + h),
Zone::Bottom => pos2(timeline.center().x, timeline.bottom() - h),
Zone::Center => pos2(timeline.center().x, timeline.center().y + 13.0),
}
};
for zone in [Zone::Left, Zone::Right, Zone::Top, Zone::Bottom] {
let mut d = film();
let before = sizes(&d, &others);
let at = inset(zone);
d.hold(tab(&d, INFO), at);
let half = zone_rect(timeline, zone).unwrap();
assert!(d.filled(half.shrink(1.0)), "{zone:?}: the preview is that half of the group");
let out = d.release(at);
assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TIMELINE, placement: Placement::Split(zone) }], "{zone:?}");
d.settle();
let (info, rest) = (group(&d, INFO), group(&d, TIMELINE));
assert!(close(info.union(rest), timeline), "{zone:?}: the two halves fill the group: {info:?} {rest:?} in {timeline:?}");
assert!((info.area() - rest.area()).abs() < info.area() * 0.05, "{zone:?}: about halves");
assert_eq!(sizes(&d, &others), before, "{zone:?}: no other group changes");
}
// The middle and the tab row join the group.
let mut d = film();
let out = d.drag(tab(&d, INFO), inset(Zone::Center));
assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TIMELINE, placement: Placement::Split(Zone::Center) }]);
let mut d = film();
let out = d.drag(tab(&d, INFO), pos2(timeline.center().x, timeline.top() + 10.0));
assert!(matches!(&out.actions[..], [Action::Move { panel: INFO, placement: Placement::Tab { .. }, .. }]), "{:?}", out.actions);
// The gap between two groups belongs to neither: the tab floats.
let mut d = film();
let tools = group(&d, TOOLS);
let gap = pos2(tools.right() + 2.0, tools.center().y);
let out = d.drag(tab(&d, INFO), gap);
assert!(matches!(&out.actions[..], [Action::Float { panel: INFO, .. }]), "{:?}", out.actions);
}
#[test]
fn window_edges_add_a_full_column_or_row_without_taking_the_groups_edges() {
let mut d = film();
let project = group(&d, PROJECT[0]);
let out = d.drag(tab(&d, INFO), pos2(3.0, project.center().y));
assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: SOURCE[0], placement: Placement::Edge(Zone::Left) }]);
d.settle();
let info = group(&d, INFO);
assert_eq!((info.top(), info.bottom(), info.left()), (0.0, SCREEN.y, 0.0), "a full-height column at the left");
assert!((info.width() - SCREEN.x * 0.25).abs() < 4.0, "{info:?}");
for (at, zone) in [(pos2(SCREEN.x - 3.0, 300.0), Zone::Right), (pos2(700.0, 3.0), Zone::Top), (pos2(700.0, SCREEN.y - 3.0), Zone::Bottom)] {
let mut d = film();
let out = d.drag(tab(&d, INFO), at);
assert!(matches!(&out.actions[..], [Action::Move { placement: Placement::Edge(z), .. }] if *z == zone), "{zone:?}: {:?}", out.actions);
}
// Past the window band, the group's own edge band splits that group.
let mut d = film();
let out = d.drag(tab(&d, INFO), pos2(project.left() + 20.0, project.center().y + 13.0));
assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: PROJECT[0], placement: Placement::Split(Zone::Left) }]);
}
#[test]
fn groups_have_one_small_minimum_and_no_maximum() {
let mut d = film();
let tools = group(&d, TOOLS).width();
drag_hit(&mut d, divider(&[true, true]), vec2(400.0, 0.0));
assert!((group(&d, TOOLS).width() - (tools + 400.0)).abs() < 1.0, "Tools grows as wide as it is dragged");
drag_hit(&mut d, divider(&[true, true]), vec2(-2000.0, 0.0));
assert_eq!(group(&d, TOOLS).width(), 40.0, "and shrinks to the minimum, no further");
// A group too narrow for two halves takes a drop on its edge as a tab.
let tools = group(&d, TOOLS);
let out = d.drag(tab(&d, INFO), pos2(tools.right() - 4.0, tools.center().y + 30.0));
assert!(
matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(Zone::Center) | Placement::Tab { .. } }]),
"{:?}",
out.actions
);
}
#[test]
fn a_divider_resizes_only_the_groups_that_share_it() {
// Timeline | Meters: Project and Tools keep their widths.
let mut d = film();
let (project, tools, timeline, meters) = (group(&d, PROJECT[0]), group(&d, TOOLS), group(&d, TIMELINE), group(&d, METERS));
drag_hit(&mut d, divider(&[true, true, true]), vec2(-100.0, 0.0));
assert_eq!((group(&d, PROJECT[0]), group(&d, TOOLS)), (project, tools));
assert!((group(&d, TIMELINE).width() - (timeline.width() - 100.0)).abs() < 1.0);
assert!((group(&d, METERS).width() - (meters.width() + 100.0)).abs() < 1.0);
// Project | the rest: only Tools, on the other side, gives way; Timeline and Meters stay.
let mut d = film();
drag_hit(&mut d, divider(&[true]), vec2(15.0, 0.0));
assert!((group(&d, PROJECT[0]).width() - (project.width() + 15.0)).abs() < 1.0);
assert!((group(&d, TOOLS).width() - (tools.width() - 15.0)).abs() < 1.0);
assert!(close(group(&d, TIMELINE), timeline) && close(group(&d, METERS), meters), "{:?}", d.layout);
// Top row: Source | Program leaves Properties alone.
let mut d = film();
let properties = group(&d, PROPERTIES[0]);
drag_hit(&mut d, divider(&[false, false]), vec2(80.0, 0.0));
assert_eq!(group(&d, PROPERTIES[0]), properties);
}
#[test]
fn an_intersection_moves_both_dividers() {
let mut d = film();
let (project, tools, source) = (group(&d, PROJECT[0]), group(&d, TOOLS), group(&d, SOURCE[0]));
let hit = Hit::Divider(Divider::Intersection(vec![], vec![true]));
let at = d.hit(&hit).unwrap_or_else(|| panic!("{:?}", d.hits()));
assert!(at.contains(pos2(project.right() + 2.0, project.top() - 2.0)), "{at:?} at Project's top right corner");
drag_hit(&mut d, hit, vec2(15.0, -60.0));
assert!((group(&d, PROJECT[0]).width() - (project.width() + 15.0)).abs() < 1.0);
assert!((group(&d, PROJECT[0]).height() - (project.height() + 60.0)).abs() < 1.0);
assert!((group(&d, TOOLS).width() - (tools.width() - 15.0)).abs() < 1.0);
assert!((group(&d, SOURCE[0]).height() - (source.height() - 60.0)).abs() < 1.0);
assert_eq!(group(&d, SOURCE[0]).width(), source.width());
}
#[test]
fn closing_a_group_gives_its_space_to_its_neighbours() {
let mut d = film();
let (tools, meters) = (group(&d, TOOLS), group(&d, METERS));
d.layout.apply(Action::CloseGroup { panel: TIMELINE }).unwrap();
d.settle();
let meters_now = group(&d, METERS);
assert_eq!(group(&d, TOOLS), tools);
assert_eq!((meters_now.right(), meters_now.top(), meters_now.bottom()), (meters.right(), meters.top(), meters.bottom()));
assert!((meters_now.left() - (tools.right() + 4.0)).abs() < 1.0, "the row has no hole: {meters_now:?}");
}
// The stuck cases of the FilmCraft map (map.md section 3).
#[test]
fn info_dropped_onto_a_capped_group_lifts_the_cap() {
let content = Film { caps: HashMap::from([(METERS, 116.0)]), ..Film::default() };
let mut d = driver(editing(), content);
let meters = group(&d, METERS);
d.drag(tab(&d, INFO), pos2(meters.center().x, meters.center().y + 13.0));
d.settle();
assert!(d.layout.location(&INFO).is_ok_and(|l| l.anchor == Some(METERS)), "{:?}", d.layout);
// Info is the group's shown panel.
let width = group(&d, INFO).width();
drag_hit(&mut d, divider(&[true, true, true]), vec2(-200.0, 0.0));
assert!((group(&d, INFO).width() - (width + 200.0)).abs() < 1.0, "the group grows past the cap of one of its panels");
drag_hit(&mut d, divider(&[true, true, true]), vec2(150.0, 0.0));
assert!((group(&d, INFO).width() - (width + 50.0)).abs() < 1.0, "and shrinks again");
}
#[test]
fn a_drop_beside_a_narrow_group_splits_that_group_only() {
let mut d = film();
drag_hit(&mut d, divider(&[true, true]), vec2(80.0, 0.0));
let (tools, project, timeline, meters) = (group(&d, TOOLS), group(&d, PROJECT[0]), group(&d, TIMELINE), group(&d, METERS));
assert!(tools.width() > 120.0, "{tools:?}");
let at = pos2(tools.right() - 8.0, tools.center().y + 13.0);
d.hold(tab(&d, INFO), at);
assert!(d.filled(zone_rect(tools, Zone::Right).unwrap().shrink(1.0)), "the preview is Tools' right half");
let out = d.release(at);
assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(Zone::Right) }]);
d.settle();
assert!(close(group(&d, TOOLS).union(group(&d, INFO)), tools));
assert_eq!((group(&d, PROJECT[0]), group(&d, TIMELINE), group(&d, METERS)), (project, timeline, meters));
}
#[test]
fn every_divider_travels_after_drops() {
let mut d = film();
let tools = group(&d, TOOLS);
d.drag(tab(&d, INFO), pos2(tools.right() - 8.0, tools.center().y + 13.0));
d.settle();
let meters = group(&d, METERS);
d.drag(tab(&d, PROGRAM), pos2(meters.center().x, meters.center().y + 13.0));
d.settle();
let timeline = group(&d, TIMELINE);
d.drag(tab(&d, PROPERTIES[0]), pos2(timeline.center().x, timeline.bottom() - 10.0));
d.settle();
assert_dividers_travel(&mut d);
}
/// Each split divider moves both ways when dragged, from the layout as it is.
fn assert_dividers_travel(d: &mut Driver) {
let start = d.layout.clone();
let paths: Vec<Vec<bool>> = d
.hits()
.into_iter()
.filter_map(|(hit, _)| match hit {
Hit::Divider(Divider::Split(path)) => Some(path),
_ => None,
})
.collect();
assert!(!paths.is_empty());
for path in paths {
for step in [12.0, -24.0] {
let Some(rect) = d.hit(&divider(&path)) else { continue };
let delta = if rect.width() < rect.height() { vec2(step, 0.0) } else { vec2(0.0, step) };
let before = d.layout.clone();
drag_hit(d, divider(&path), delta);
assert_ne!(d.layout, before, "divider {path:?} does not move by {step}");
}
d.layout = start.clone();
d.settle();
}
}
/// Every group at or above its minimum, and every divider with room both ways.
fn assert_sizes(d: &Driver, context: &str) {
let mut style = DockStyle::default();
frame_style(&mut style);
let Some(root) = &d.layout.root else { return };
let sizer = Sizer { style: &style, content: &d.content };
let mut groups: Vec<Drawn<'_, u32>> = Vec::new();
sizer.groups(root, d.area, &mut groups);
for g in &groups {
let room = g.rect.size() - g.limits.min;
assert!(room.x >= -0.01 && room.y >= -0.01, "{context}: {:?} is below its minimum {:?}", g.rect, g.limits.min);
}
let mut nodes = vec![(root, d.area)];
while let Some((node, rect)) = nodes.pop() {
let Some(children) = sizer.children(node, rect) else { continue };
let (lo, hi) = sizer.range(node, rect);
assert!(lo < -0.5 && hi > 0.5, "{context}: a divider is stuck ({lo}, {hi}) at {rect:?} in {:?}", d.layout);
nodes.extend(children);
}
}
/// A small deterministic generator, so a failing sequence repeats.
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
self.0 >> 33
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
fn unit(&mut self) -> f32 {
(self.next() % 1000) as f32 / 1000.0
}
}
#[test]
fn random_drops_leave_every_group_its_minimum_and_every_divider_travel() {
for seed in 1..=6u64 {
let mut rng = Lcg(seed);
let mut d = film();
for step in 0..14 {
// Only the bodies drawn now.
d.content.bodies.clear();
d.settle();
let panels: Vec<u32> = d.layout.panels().into_iter().copied().collect();
let panel = panels[rng.below(panels.len())];
let Some(from) = d.hit(&Hit::Tab(panel)).map(|r| r.center()) else { continue };
let target = panels[rng.below(panels.len())];
let Some(rect) = d.content.bodies.get(&target).copied() else { continue };
let to = match rng.below(8) {
// A window edge.
0 => {
let edges = [
pos2(3.0, rect.center().y),
pos2(SCREEN.x - 3.0, rect.center().y),
pos2(rect.center().x, 3.0),
pos2(rect.center().x, SCREEN.y - 3.0),
];
edges[rng.below(4)]
}
// The tab row.
1 => pos2(rect.center().x, rect.top() - 13.0),
// Anywhere in the body: an edge band or the middle.
_ => rect.lerp_inside(vec2(0.02 + rng.unit() * 0.96, 0.02 + rng.unit() * 0.96)),
};
d.drag(from, to);
d.settle();
if d.layout.floating.iter().any(|w| w.contains(&panel)) && panel != INFO {
// Back into the tree: this test is about the docked layout.
d.layout.apply(Action::Move { panel, anchor: target, placement: Placement::Split(Zone::Center) }).ok();
d.settle();
}
assert_sizes(&d, &format!("seed {seed}, step {step}"));
}
assert_dividers_travel(&mut d);
}
}
#[test]
fn ctrl_or_cmd_floats_from_a_tab_or_a_drag_bar_and_the_preview_says_so() {
const HINT: &str = "Hold Ctrl to undock";
let floated = |actions: &[Action<u32>], panel: u32| match actions {
[Action::Float { panel: p, .. }] => *p == panel,
[Action::MoveGroup { panel: p, to: Target::Float { .. } }] => *p == panel,
_ => false,
};
fn texts(d: &Driver) -> Vec<String> {
fn walk(shape: &egui::Shape, out: &mut Vec<String>) {
match shape {
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)),
egui::Shape::Text(text) => out.push(text.galley.text().to_owned()),
_ => {}
}
}
let mut out = Vec::new();
d.shapes().iter().for_each(|clipped| walk(&clipped.shape, &mut out));
out
}
let d = film();
let over = group(&d, TIMELINE).center() + vec2(0.0, 13.0);
let from_tab = tab(&d, PROJECT[0]);
let from_bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center();
for from in [from_tab, from_bar] {
// Without the modifier, a release over a group docks there; the preview names the modifier.
let mut d = film();
d.hold(from, over);
assert!(texts(&d).iter().any(|t| t == HINT), "{from:?}: {:?}", texts(&d));
let out = d.release(over);
assert!(!out.actions.is_empty() && !floated(&out.actions, PROJECT[0]), "{from:?}: {:?}", out.actions);
// Held, it floats, and the hint is gone.
let mut d = film();
d.modifiers = Modifiers::COMMAND;
d.hold(from, over);
assert!(!texts(&d).iter().any(|t| t == HINT), "{from:?}");
let out = d.release(over);
assert!(floated(&out.actions, PROJECT[0]), "{from:?}: {:?}", out.actions);
}
}
+34 -49
View File
@@ -44,13 +44,16 @@ pub struct DockStyle {
/// Painted under every floating window, so it stays opaque when the panel colors are not.
pub floating_background: Color32,
pub floating_stroke: Stroke,
/// Dock: docks, columns and stacked groups. Frame: a split tree of tab frames, with
/// Maximize, Ctrl/Cmd-drag undock, splits on all four outer edges and frame menu labels.
pub profile: Profile,
/// Height of a floating window's title bar.
pub title_height: f32,
/// Width of the band along the layout's outer edge that splits at the root.
pub edge_band: f32,
/// The outer edge band also runs along the left and right edges, where a drop adds a
/// full-height column. Off: only the top and bottom edges add a full-width row.
pub side_edge_zones: bool,
/// [D] The docked area's left and right edges, where no dock is yet, take a drop as a new
/// dock (see [`Self::new_dock_band`]).
pub new_dock_zones: bool,
/// [D] Height of the bar above each dock column. It drags the column and holds its »/« button.
pub column_bar_height: f32,
/// [D] Height of the band around the seam between two stacked groups, in docks and
@@ -69,7 +72,7 @@ pub struct DockStyle {
/// [D] Column menu item that toggles Auto-Collapse Iconic Panels.
pub auto_collapse_label: String,
/// [D] Lay docks out right to left: `Side::Start` is the right edge. egui has no app-wide
/// layout direction, so the host sets this from its locale. Frame splits keep their sides.
/// layout direction, so the host sets this from its locale. Splits keep their sides.
pub right_to_left: bool,
/// [D] Accessibility label of a column bar.
pub move_column_label: String,
@@ -81,18 +84,23 @@ pub struct DockStyle {
pub expand_column_label: String,
pub move_window_label: String,
pub close_window_label: String,
/// Frame profile menu labels.
pub undock_label: String,
pub undock_group_label: String,
pub close_panel_group_label: String,
/// Group menu items Close Other Panels in Group, and Maximize Frame / Restore Frame Size
/// for groups of the split tree, each shown when its flag is on.
pub close_others_label: String,
pub close_others_item: bool,
pub maximize_label: String,
pub restore_frame_label: String,
pub maximize_item: bool,
/// Shown where native windows are available (feature `native-windows`).
pub new_window_label: String,
/// Frame profile: shown under the pointer while a drag offers a docking target. `{key}`
/// stands for the modifier that floats the drop instead: ⌘ on macOS, Ctrl elsewhere.
/// Shown under the pointer while a drag offers a docking target, when [`Self::float_hint`]
/// is on. `{key}` stands for the modifier that floats the drop instead: ⌘ on macOS, Ctrl
/// elsewhere.
pub float_hint_label: String,
pub float_hint: bool,
/// Ctrl/Cmd held when a tab drag of a split-tree group starts makes its drop float, even
/// when the key is let go during the drag. Off: only the key held at the drop counts.
pub command_press_floats: bool,
/// Show the built-in Close items in tab and group menus (Close Panel, Close Tab Group,
/// Close Other Panels in Group) for closable panels. A host that draws its own Close items
/// turns this off and keeps [`Permissions::closable`], so its close actions still apply.
@@ -100,20 +108,9 @@ pub struct DockStyle {
/// Show × on the title bar of a floating window whose panels are all closable.
pub close_button: bool,
/// The mouse wheel over a tab strip selects the next or previous tab, one per notch.
/// `None`: on in [`Profile::Frame`], off in [`Profile::Dock`].
pub wheel_selects_tabs: Option<bool>,
/// A middle click on a tab closes its panel when the panel is closable. `None`: on in
/// [`Profile::Frame`], off in [`Profile::Dock`].
pub middle_click_closes_tabs: Option<bool>,
}
/// Which arrangement a [`DockStyle`] renders and labels.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum Profile {
#[default]
Dock,
Frame,
pub wheel_selects_tabs: bool,
/// A middle click on a tab closes its panel when the panel is closable.
pub middle_click_closes_tabs: bool,
}
impl Default for DockStyle {
@@ -157,9 +154,10 @@ impl DockStyle {
drag_bar_width: 40.0,
floating_background: visuals.window_fill,
floating_stroke: visuals.window_stroke,
profile: Profile::Dock,
title_height: super::chrome::TITLE_HEIGHT,
edge_band: 8.0,
side_edge_zones: false,
new_dock_zones: true,
column_bar_height: 14.0,
seam_band: 8.0,
new_dock_band: 12.0,
@@ -176,29 +174,22 @@ impl DockStyle {
expand_column_label: "Expand column".into(),
move_window_label: "Move panel window".into(),
close_window_label: "Close panel window".into(),
undock_label: "Undock Panel".into(),
undock_group_label: "Undock Panel Group".into(),
close_panel_group_label: "Close Panel Group".into(),
close_others_label: "Close Other Panels in Group".into(),
close_others_item: false,
maximize_label: "Maximize Frame".into(),
restore_frame_label: "Restore Frame Size".into(),
maximize_item: false,
new_window_label: "Move Panel to New Window".into(),
float_hint_label: "Hold {key} to undock".into(),
float_hint: false,
command_press_floats: false,
close_menu_items: true,
close_button: true,
wheel_selects_tabs: None,
middle_click_closes_tabs: None,
wheel_selects_tabs: false,
middle_click_closes_tabs: false,
}
}
pub(super) fn wheel_selects_tabs(&self) -> bool {
self.wheel_selects_tabs.unwrap_or(self.profile == Profile::Frame)
}
pub(super) fn middle_click_closes_tabs(&self) -> bool {
self.middle_click_closes_tabs.unwrap_or(self.profile == Profile::Frame)
}
pub(super) fn valid(&self) -> bool {
[
self.tab_height,
@@ -415,7 +406,9 @@ impl<P> Clone for HidePanels<'_, P> {
impl<P> Copy for HidePanels<'_, P> {}
/// Preferred body bounds. Tiny viewports relax minima proportionally rather than producing
/// inverted rectangles. Maximums constrain a pane when its sibling can occupy the remainder.
/// inverted rectangles. Maximums constrain a pane when its sibling can occupy the remainder. A
/// tab group takes the largest minimum and the largest maximum of its panels, so a panel without
/// a maximum lifts the maximum of the panels it joins.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PanelLimits {
pub min: Vec2,
@@ -447,7 +440,7 @@ pub(super) struct Drag<P> {
pub(super) origin: Pos2,
pub(super) source: Rect,
pub(super) grab: Vec2,
/// Ctrl/Cmd held at the press of a frame-profile tab drag: the drop always floats.
/// Ctrl/Cmd held at the press, with [`DockStyle::command_press_floats`]: the drop floats.
pub(super) undock: bool,
}
@@ -557,11 +550,6 @@ pub trait DockContent<P> {
fn allow_native(&self, _panel: &P) -> bool {
false
}
/// A lone bare panel draws no tab strip and no drag bar, like a lone protected panel. It
/// still moves by command and menu.
fn bare(&self, _panel: &P) -> bool {
false
}
/// Host-owned drop resolution for targets the renderer cannot know about. Returning
/// feedback consumes the gesture, including no-op targets; the renderer then emits no move.
fn resolve_drag(&mut self, _ctx: &Context, _style: &DockStyle, _request: &DropRequest<'_, P>) -> Option<DropFeedback> {
@@ -644,9 +632,6 @@ impl<P, C: DockContent<P> + ?Sized> DockContent<P> for Overrides<'_, P, C> {
fn allow_native(&self, panel: &P) -> bool {
self.inner.allow_native(panel)
}
fn bare(&self, panel: &P) -> bool {
self.inner.bare(panel)
}
fn resolve_drag(&mut self, ctx: &Context, style: &DockStyle, request: &DropRequest<'_, P>) -> Option<DropFeedback> {
self.inner.resolve_drag(ctx, style, request)
}
@@ -826,7 +811,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
float_space: FloatSpace { contents, ..FloatSpace::new(rect) },
places,
contents: content_index,
fixed_slots: Vec::new(),
dividers: Vec::new(),
};
if let Some(root) = &view.root {
renderer.node(ui, root, rect, &mut Vec::new());
+4 -2
View File
@@ -651,8 +651,10 @@ fn split_inner_edge_remains_draggable_beside_a_scrollbar() {
frame(&mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]);
frame(&mut layout, vec![Event::PointerMoved(start + vec2(40.0, 0.0))]);
frame(&mut layout, vec![pointer_button(start + vec2(40.0, 0.0), false)]);
let Node::Split { size: SplitSize::FixedFirst(width), .. } = layout.root.unwrap() else { panic!() };
assert!((width - 260.0).abs() < 0.001, "sidebar width after inner-edge drag: {width}");
// A dragged divider keeps a ratio of the space, whatever mode it was saved with.
let Node::Split { size: SplitSize::Ratio(ratio), .. } = layout.root.unwrap() else { panic!() };
// 800 points less the 4 point divider.
assert!((ratio * 796.0 - 260.0).abs() < 0.01, "sidebar width after inner-edge drag: {}", ratio * 796.0);
}
#[test]
+1 -1
View File
@@ -195,7 +195,7 @@ fn localized_covers_every_label_and_contains_takes_a_borrowed_panel() {
});
full.textures_delta.clear();
let values: Vec<&str> = debug.split("_label: \"").skip(1).filter_map(|rest| rest.split('"').next()).collect();
assert_eq!(values.len(), 28, "{debug}");
assert_eq!(values.len(), 25, "{debug}");
assert!(values.iter().all(|v| v.starts_with("dock.")), "a label without a key: {values:?}");
let unique: std::collections::HashSet<_> = values.iter().collect();
assert_eq!(unique.len(), values.len());
+39 -28
View File
@@ -7,16 +7,17 @@ gestures. The app owns panel contents, commands, document state and where layout
Office apps that draw their own document with a few task panes beside it use
[`craft_ui::panes`](ui-panes.md) instead.
## Profiles
## Arrangements
`DockStyle::profile` picks one of two arrangements. Both use the same model and actions.
An app builds its style with `DockStyle::from_ui`, `from_visuals` or `default` and then sets
fields; new fields can be added, so the struct has no literal form outside the crate.
One model serves every app. What differs between apps is the default layout and a few
`DockStyle` settings. An app builds its style with `DockStyle::from_ui`, `from_visuals` or
`default` and then sets fields; new fields can be added, so the struct has no literal form
outside the crate.
| Profile | Arrangement | Apps |
|---|---|---|
| `Profile::Dock` (default) | Docks on the window's start and end edges. A dock has columns; a column has stacked groups and can collapse to icons. Frames (tab groups and splits) fill the space between the docks. | Creative apps with a canvas and panel docks |
| `Profile::Frame` | A split tree of tab frames. Frame menus offer Maximize Frame, Undock and Close Other Panels. Ctrl/Cmd + drag undocks, and a hint under the pointer says so. Drops on all four outer edges split at the root. | Apps whose whole window is frames |
| Arrangement | Layout | Style settings | Apps |
|---|---|---|---|
| Docks beside a canvas (the defaults) | `Node::Dock`s on the window's start and end edges. A dock has columns; a column has stacked groups and can collapse to icons. Frames (tab groups and splits) fill the space between the docks. | `new_dock_zones` on | Creative apps with a canvas and panel docks |
| A split tree of tab groups | `Split`s and `Tabs` only. | `side_edge_zones`, `maximize_item`, `close_others_item`, `command_press_floats`, `float_hint`, `wheel_selects_tabs`, `middle_click_closes_tabs` on, `new_dock_zones` off; menu labels such as "Undock Panel" set on `float_label`, `float_group_label` and `close_group_label` | Apps whose whole window is frames |
Sides are logical: `Side::Start` is the left edge, or the right edge when
`DockStyle::right_to_left` is set. egui has no app-wide direction, so the app sets it from its
@@ -32,7 +33,7 @@ their sides.
| Node | Holds |
|---|---|
| `Tabs` | A tab group: panels and the active index. |
| `Split` | Two children, horizontal or vertical, with a ratio or a fixed first or second size. |
| `Split` | Two children, horizontal or vertical, with a ratio. A fixed first or second size from a saved layout is drawn at that size until the divider moves; a moved divider stores a ratio. |
| `Stack` | Accordion entries, each open or closed, with an optional height. |
| `Dock` | A side and its columns. A `Column` has `Group`s, a width, and `iconic`. |
@@ -68,11 +69,9 @@ layout.apply(Action::Move {
`Placement::for_zone(layout, anchor, zone, right_to_left)` maps a left, right, top, bottom or
center zone onto what the anchor's container takes: a split in the frame tree, a new column on
that side or a new group above or below in a dock, a group above or below in a floating window.
- A side split zone puts what moves in a new half-size split of the anchor's frame. A frame
that holds the fixed size of its parent split along the same axis, such as a tool strip, keeps
that size: on its outer side the new split wraps the parent, on its inner side it halves the
parent's other child. The new split has its own divider, and the drop preview shows the half
the new frame takes.
- A side split zone puts what moves in a new half-size split of the anchor's frame, and no
other frame changes size. The new split has its own divider, and the drop preview shows the
half the new frame takes.
- Other actions select, minimize, collapse to icons, resize splits, columns and groups, and move
a floating window to its own OS window (`SetNative`, feature `native-windows`).
@@ -112,19 +111,19 @@ its own command path, so pointer, keyboard and control commands share one path.
`DockContent<P>` is the app's adapter. It draws each panel body and gives its label, icon,
permissions, size limits, preferred floating size, panel menu and group header. The group header
sits at the end of the tab strip; `group_header_width` gets the strip width, so a header that
draws its own tabs can size them to the room left. `bare` hides the tab strip of a lone panel. An icon is an image (`icon`), or drawn in code by `paint_icon`, which
draws its own tabs can size them to the room left. An icon is an image (`icon`), or drawn in code by `paint_icon`, which
gets a painter, the icon rect and the tint color and returns true when it painted. Panel bodies keep a stable egui id when they move, so widget memory
survives.
`DockOutput` also has:
- `focused`: the panel the user focused this frame: a clicked tab, a drop, or a press of any
pointer button in a panel body (bare and protected panels included) or in the group header,
pointer button in a panel body (protected panels included) or in the group header,
which focuses the group's shown panel. Commands, `raise_panel` and
workspace resets never set it; a host that tracks the last focused panel clears it when a reset
or command hides, closes or moves that panel.
- `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (Frame) and
`DockEvent::ToggleAutoCollapse` (Dock), and `DockEvent::FlyoutChanged { panel }` (Dock): the
- `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (`maximize_item`) and
`DockEvent::ToggleAutoCollapse` and `DockEvent::FlyoutChanged { panel }` (docks): the
flyout opened, switched or closed (`None`). It comes once per change, whatever made it: an
icon, Escape, a press outside, a raised panel, `open_flyout` or `close_flyout`.
- `error`: a rejected gesture.
@@ -191,21 +190,33 @@ A flyout fits its content the same way, without the title bar.
4 pt band above the title bar (`chrome::RESIZE_BAND`), so they take no moves and no clicks on
the title bar's buttons. A resize keeps the opposite edges in place and stays within the size
limits and the screen. A focused resize handle moves its edge 8 pt per arrow key.
- Over a frame, the tab strip and the middle join its tabs, and the nearest side splits it. A
side zone is a quarter of the frame deep, at least 32 pt and at most half the frame, so a narrow
tool strip keeps both side targets. A drop in the gap between two frames splits beside the
nearer one.
- Over a frame, the tab strip and the middle join its tabs, and the nearest side splits it. The
side zones are trapezoids along the four edges, like a picture frame: a quarter of the frame
deep, at least 32 pt and at most half the frame. A split that would leave either half without
`DockStyle::min_pane` joins the frame's tabs instead. The gap between two frames is no target.
- The outer edge band (`DockStyle::edge_band`) takes a drop as a new frame across the whole
window side: top and bottom, and left and right with `side_edge_zones`. It takes a quarter of
the window and is offered only when every frame keeps its minimum. Past the band, a frame's own
side zones apply.
- A divider resizes only the frames that touch it; every other frame keeps its size. It stops
when a frame touching it reaches its minimum. Where a divider ends on another divider, a
four-way handle (`Divider::Intersection`) moves both.
- Every frame has the same minimum, `DockStyle::min_pane` (plus its tab strip), unless its panels
ask for more through `PanelLimits::min`. Frames have no maximum unless every panel in them asks
for one through `PanelLimits::max`: a panel without a maximum lifts the maximum of the panels it
joins.
- Drop previews show the destination: an outline for tabs, a line for a new group, column or dock,
a filled half for a split. Escape or losing window focus cancels a drag.
- In the Frame profile, Ctrl (⌘ on macOS) held while dragging a tab, a drag bar or a grip that
calls `begin_drag` floats the drop wherever it lands. While a drag offers a docking target, a
hint under the pointer names the modifier: `float_hint_label`, where `{key}` becomes ⌘ or Ctrl.
- Ctrl (⌘ on macOS) held at the drop floats it wherever it lands. With `command_press_floats`,
the key held when a tab drag of a frame starts is enough. With `float_hint`, a hint under the
pointer names the modifier while a drag offers a docking target: `float_hint_label`, where
`{key}` becomes ⌘ or Ctrl.
- Double-click a tab or drag bar to minimize a group to its strip.
- Right-click a tab for its menu, or a drag bar for the group menu; Enter or Space on a focused
drag bar opens it too. A left click on the drag bar only focuses it.
- In the Frame profile, the mouse wheel over a tab strip selects the next or previous tab, and a
middle click on a closable panel's tab closes it. `DockStyle::wheel_selects_tabs` and
`middle_click_closes_tabs` turn either on or off in any profile.
- With `DockStyle::wheel_selects_tabs`, the mouse wheel over a tab strip selects the next or
previous tab. With `middle_click_closes_tabs`, a middle click on a closable panel's tab closes
it.
- A column's »/« button collapses it to icons, one icon per panel with a separator between groups.
Widen an iconic column to show panel names. Click an icon to open its group as a flyout on the
canvas side. Drag an icon to move its panel, or an icon group's top edge (the separator line above it) to move the group.