mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-30 07:09:11 +00:00
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:
1 parent
958c5ada58
commit
d30c207c8d
20 files changed
+974
-511
No files matched your search
@@ -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,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};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(÷r(&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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user