diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index ab80552d..dbcb65bd 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -254,7 +254,9 @@ impl<'a> FloatingFrame<'a> { } /// A drop preview: a 2 pt accent outline inside `rect`, filled with 15% accent when `filled`. -/// Docking and the pane host draw every destination and drag ghost with it. +/// Docking draws its drag ghosts with it, and with +/// [`crate::docking::DockStyle::side_edge_zones`] the region a drop takes; the pane host draws +/// every destination with it. pub fn drop_preview(painter: &egui::Painter, rect: Rect, accent: Color32, filled: bool) { let fill = if filled { accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT }; painter.rect(rect, 0.0, fill, Stroke::new(2.0, accent), StrokeKind::Inside); diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index dcfd2b73..85f47251 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -259,6 +259,108 @@ fn group_dropped_on_a_top_or_bottom_band_stacks_above_or_below() { } } +#[test] +fn a_dock_groups_top_and_bottom_thirds_stack_its_sides_make_columns_and_its_middle_joins() { + // Group 3's body is 0..240 across and 333..600 down. + for (at, expected, line) in [ + (pos2(120.0, 360.0), vec![vec![vec![2], vec![5], vec![3]]], Some(hline(307.0, 0.0..=240.0))), + (pos2(120.0, 580.0), vec![vec![vec![2], vec![3], vec![5]]], Some(hline(598.0, 0.0..=240.0))), + (pos2(30.0, 466.0), vec![vec![vec![5]], vec![vec![2], vec![3]]], Some(vline(0.0, 0.0..=600.0))), + (pos2(200.0, 466.0), vec![vec![vec![2], vec![3]], vec![vec![5]]], Some(vline(240.0, 0.0..=600.0))), + (pos2(120.0, 466.0), vec![vec![vec![2], vec![3, 5]]], None), + ] { + let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + let mut d = Driver::new(layout); + d.hold(bar(&d, 5), at); + if let Some(line) = line { + assert!(d.painted(line), "{at:?}: the line of the edge it docks to"); + } + d.step(vec![button(at, false)]); + assert_eq!(columns(&d.layout, Side::Start), expected, "{at:?}"); + } +} + +#[test] +fn with_side_edge_zones_a_dock_groups_side_zones_are_40_pt_deep() { + // Group 3's body is 0..240 across and 333..600 down. 60 pt in from its left edge and 57 + // below its top are in its outer thirds but past 40 pt: the middle, which joins it. + for (at, expected) in [ + (pos2(30.0, 466.0), vec![vec![vec![5]], vec![vec![2], vec![3]]]), + (pos2(120.0, 360.0), vec![vec![vec![2], vec![5], vec![3]]]), + (pos2(60.0, 466.0), vec![vec![vec![2], vec![3, 5]]]), + (pos2(120.0, 390.0), vec![vec![vec![2], vec![3, 5]]]), + ] { + let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + let mut d = testing::DockDriver::new(layout, TestContent::default()) + .with_area(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 600.0))) + .with_style(|s| s.side_edge_zones = true); + let tab = d.hit(&Hit::Tab(5)).unwrap_or_else(|| panic!("tab 5")).center(); + d.drag(tab, at); + assert_eq!(columns(&d.layout, Side::Start), expected, "{at:?}"); + } +} + +#[test] +fn a_group_edge_scrolled_out_of_view_previews_its_line_at_the_views_edge() { + // Groups 306 pt high from y = 14: group 3 spans 320..626 and its body's bottom third starts + // at 533, above the window's bottom. + let layout = with_floating(docked(Side::Start, &[&[&[2], &[3], &[4]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + let mut d = Driver::with(layout, TestContent { min_height: 280.0 }, false); + let at = pos2(120.0, 580.0); + d.hold(bar(&d, 5), at); + assert!(d.painted(hline(598.0, 0.0..=240.0)), "the line at the view's bottom"); + d.step(vec![button(at, false)]); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3], vec![5], vec![4]]]); +} + +#[test] +fn a_stripless_dock_groups_top_third_stacks_above_it() { + // Canvas 9 in the middle; protected panel 0 alone in the lower group draws no tab strip, so + // its body is 307..600. + let mut layout = with_floating(docked(Side::Start, &[&[&[2], &[0]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + if let Some(Node::Split { second, .. }) = &mut layout.root { + **second = Node::Tabs { panels: vec![9], active: 0 }; + } + let mut d = Driver::new(layout); + d.drag(bar(&d, 5), pos2(120.0, 330.0)); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![5], vec![0]]]); +} + +#[test] +fn dock_bands_are_28_pt_wide() { + let window = || with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + let target = |layout: Layout, at: Pos2| { + let mut d = Driver::new(layout); + let out = d.drag(bar(&d, 5), at); + moved(&out.actions).map(|(_, to)| to.clone()) + }; + let column_after = Target::At { anchor: 2, placement: Placement::Column { after: true } }; + // 14 pt either side of the dock's inner edge at 240; past it, the protected canvas. + assert_eq!(target(window(), pos2(253.0, 300.0)), Some(column_after.clone())); + assert_ne!(target(window(), pos2(255.0, 300.0)), Some(column_after)); + // 28 pt along the window edge without a dock. + assert_eq!(target(window(), pos2(573.0, 300.0)), Some(Target::NewDock { side: Side::End })); + assert_ne!(target(window(), pos2(571.0, 300.0)), Some(Target::NewDock { side: Side::End })); + // 28 pt along the bottom of the part between the docks, which splits at the root. + assert!(matches!(target(window(), pos2(420.0, 573.0)), Some(Target::At { placement: Placement::Edge(Zone::Bottom), .. }))); + assert!(!matches!(target(window(), pos2(420.0, 571.0)), Some(Target::At { placement: Placement::Edge(_), .. }))); + // 14 pt either side of a seam. In a 400 pt column, 16 pt in from the window edge and 13 + // above the seam at 307 is relatively nearer group 2's left than its bottom, but the seam + // takes it; 15 above, the left third makes a column. + let wide = || { + let mut layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [450.0, 300.0, 100.0, 150.0])]); + if let Some(Node::Split { size, first, .. }) = &mut layout.root { + *size = SplitSize::FixedFirst(400.0); + if let Node::Dock { columns, .. } = &mut **first { + columns.iter_mut().for_each(|c| c.width = 400.0); + } + } + layout + }; + assert_eq!(target(wide(), pos2(16.0, 294.0)), Some(Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } })); + assert_eq!(target(wide(), pos2(16.0, 292.0)), Some(Target::At { anchor: 2, placement: Placement::Column { after: false } })); +} + #[test] fn group_dropped_on_a_column_edge_makes_a_new_column() { // The dock's inner edge: a column after the existing one, previewed full height. diff --git a/crates/ui/src/docking/drop_tests.rs b/crates/ui/src/docking/drop_tests.rs new file mode 100644 index 00000000..8a7fe6f3 --- /dev/null +++ b/crates/ui/src/docking/drop_tests.rs @@ -0,0 +1,197 @@ +//! Drop targets of the default profile over frames: a group body's thirds, the tab outline, +//! the fallback where a side has no room, and the root edge band. + +use std::collections::HashMap; + +use super::testing::DockDriver; +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Color32, Pos2, Rect, pos2, vec2}; + +/// Panel bodies by their rects; `rules` are the permissions of panels that differ from the +/// default, `min_width` every panel's least width. +#[derive(Default)] +struct Bodies { + bodies: HashMap, + rules: HashMap, + min_width: f32, +} + +impl DockContent for Bodies { + fn body(&mut self, ui: &mut egui::Ui, panel: &u32) { + self.bodies.insert(*panel, ui.max_rect()); + } + fn permissions(&self, panel: &u32) -> Permissions { + self.rules.get(panel).copied().unwrap_or_default() + } + fn limits(&self, _: &u32) -> PanelLimits { + PanelLimits { min: vec2(self.min_width, 0.0), ..PanelLimits::default() } + } +} + +type Driver = DockDriver; + +/// Frames `[1]` and `[2, 3]` side by side, each half of the area. +fn frames() -> Layout { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.5), + first: Box::new(Node::Tabs { panels: vec![1], active: 0 }), + second: Box::new(Node::Tabs { panels: vec![2, 3], active: 1 }), + }), + floating: Vec::new(), + } +} + +fn driver(rules: &[(u32, Permissions)]) -> Driver { + DockDriver::new(frames(), Bodies { rules: rules.iter().copied().collect(), ..Bodies::default() }) +} + +/// The body of `panel` and its group's rect, the 26 pt strip included. +fn group(d: &Driver, panel: u32) -> (Rect, Rect) { + let body = d.content.bodies.get(&panel).copied().unwrap_or_else(|| panic!("panel {panel} not drawn")); + (body, 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() +} + +/// The fill of the last frame's rect shape at `rect` that has no stroke. +fn fill_at(d: &Driver, rect: Rect) -> Option { + d.shapes().iter().find_map(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) if shape.rect == rect && shape.stroke.width == 0.0 && shape.fill != Color32::TRANSPARENT => Some(shape.fill), + _ => None, + }) +} + +/// The 4 pt line along `rect`'s edge on `zone`'s side, inside it. +fn line(rect: Rect, zone: Zone) -> Rect { + match zone { + Zone::Left => Rect::from_x_y_ranges(rect.left()..=(rect.left() + 4.0), rect.y_range()), + Zone::Right => Rect::from_x_y_ranges((rect.right() - 4.0)..=rect.right(), rect.y_range()), + Zone::Top => Rect::from_x_y_ranges(rect.x_range(), rect.top()..=(rect.top() + 4.0)), + Zone::Bottom | Zone::Center => Rect::from_x_y_ranges(rect.x_range(), (rect.bottom() - 4.0)..=rect.bottom()), + } +} + +fn accent() -> Color32 { + DockStyle::default().accent +} + +#[test] +fn a_drop_on_a_groups_outer_third_docks_beside_it_with_a_line_and_its_middle_joins_with_an_outline() { + let d = driver(&[]); + let (body, rect) = group(&d, 1); + let (w, h) = (body.width(), body.height()); + let line = |zone| line(rect, zone); + for (zone, at) in [ + (Zone::Left, pos2(body.left() + w * 0.3, body.center().y)), + (Zone::Right, pos2(body.right() - w * 0.3, body.center().y)), + (Zone::Top, pos2(body.center().x, body.top() + h * 0.3)), + (Zone::Bottom, pos2(body.center().x, body.bottom() - h * 0.3)), + // In the top left corner, the top is relatively nearer. + (Zone::Top, pos2(body.left() + w * 0.25, body.top() + h * 0.2)), + ] { + let mut d = driver(&[]); + d.hold(tab(&d, 3), at); + assert!(d.filled(line(zone)), "{zone:?}: a line along the group's whole edge"); + assert!(!d.filled(zone_rect(rect, zone).unwrap().shrink(1.0)), "{zone:?}: no filled half"); + assert!(!d.outlined(rect.shrink(1.0)), "{zone:?}"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(zone) }], "{zone:?}"); + } + // The middle and the tab strip join: the group's outline with a light fill, no line. + for at in [body.center(), pos2(rect.center().x, rect.top() + 13.0)] { + let mut d = driver(&[]); + d.hold(tab(&d, 3), at); + assert!(d.outlined(rect.shrink(1.0)), "{at:?}"); + assert_eq!(fill_at(&d, rect.shrink(1.0)), Some(accent().gamma_multiply(0.08)), "{at:?}: a light fill"); + assert!([Zone::Left, Zone::Right, Zone::Top, Zone::Bottom].into_iter().all(|z| !d.filled(line(z))), "{at:?}"); + let out = d.release(at); + assert!( + matches!(&out.actions[..], [Action::Move { panel: 3, anchor: 1, placement: Placement::Tab { .. } | Placement::Split(Zone::Center) }]), + "{:?}", + out.actions + ); + } + // A group's drag bar previews with the same painter. + let mut d = driver(&[]); + let bar = d.hit(&Hit::DragBar(2)).unwrap().center(); + let at = pos2(body.left() + w * 0.3, body.center().y); + d.hold(bar, at); + assert!(d.filled(line(Zone::Left))); + assert!(!d.filled(zone_rect(rect, Zone::Left).unwrap().shrink(1.0))); + let mut d = driver(&[]); + d.hold(bar, body.center()); + assert!(d.outlined(rect.shrink(1.0))); +} + +#[test] +fn a_group_that_takes_no_tabs_maps_its_whole_body_to_its_edges() { + let canvas = Permissions { accepts_tabs: false, ..Permissions::default() }; + let d = driver(&[(1, canvas)]); + let (body, rect) = group(&d, 1); + for (zone, at) in [(Zone::Left, body.center() - vec2(10.0, 0.0)), (Zone::Top, body.center() - vec2(0.0, 10.0))] { + let mut d = driver(&[(1, canvas)]); + d.hold(tab(&d, 3), at); + assert!(d.filled(line(rect, zone)), "{zone:?}"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(zone) }], "{zone:?}"); + } + // Its strip joins nothing. + let mut d = driver(&[(1, canvas)]); + let out = d.drag(tab(&d, 3), pos2(rect.center().x, rect.top() + 13.0)); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { anchor: 1, placement: Placement::Tab { .. }, .. })), "{:?}", out.actions); +} + +#[test] +fn a_side_without_room_joins_as_a_tab_or_offers_nothing() { + // Two 400 pt frames of panels at least 300 wide: neither has room for two halves side by + // side, but both have room for two halves stacked. + let narrow = |rules: &[(u32, Permissions)]| { + DockDriver::new(frames(), Bodies { rules: rules.iter().copied().collect(), min_width: 300.0, ..Bodies::default() }) + }; + let d = narrow(&[]); + let (body, rect) = group(&d, 1); + let left = pos2(body.left() + body.width() * 0.25, body.center().y); + let mut d = narrow(&[]); + d.hold(tab(&d, 3), left); + assert!(d.outlined(rect.shrink(1.0)), "the tab outline"); + let out = d.release(left); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Center) }]); + let top = pos2(body.center().x, body.top() + body.height() * 0.2); + let mut d = narrow(&[]); + let out = d.drag(tab(&d, 3), top); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Top) }]); + // A group that takes no tabs offers nothing there. + let mut d = narrow(&[(1, Permissions { accepts_tabs: false, ..Permissions::default() })]); + d.hold(tab(&d, 3), left); + assert!(!d.outlined(rect.shrink(1.0))); + let out = d.release(left); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { anchor: 1, .. })), "{:?}", out.actions); +} + +#[test] +fn the_root_edge_band_is_28_pt_and_previews_a_line_along_the_layouts_edge() { + let d = driver(&[]); + let (body, _) = group(&d, 1); + let x = body.center().x; + let mut d = driver(&[]); + d.hold(tab(&d, 3), pos2(x, 573.0)); + assert!(d.filled(Rect::from_x_y_ranges(0.0..=800.0, 596.0..=600.0)), "a line along the layout's whole bottom edge"); + let out = d.release(pos2(x, 573.0)); + assert!(matches!(&out.actions[..], [Action::Move { panel: 3, placement: Placement::Edge(Zone::Bottom), .. }]), "{:?}", out.actions); + // Past the band, the group's own bottom third. + let mut d = driver(&[]); + let out = d.drag(tab(&d, 3), pos2(x, 571.0)); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Bottom) }]); + // The band reaches only 4 pt past its line into a tab strip, which joins the group. + let mut d = driver(&[]); + let out = d.drag(tab(&d, 3), pos2(x, 12.0)); + assert!(matches!(&out.actions[..], [Action::Move { panel: 3, anchor: 1, placement: Placement::Tab { .. } }]), "{:?}", out.actions); + let mut d = driver(&[]); + let out = d.drag(tab(&d, 3), pos2(x, 7.0)); + assert!(matches!(&out.actions[..], [Action::Move { panel: 3, placement: Placement::Edge(Zone::Top), .. }]), "{:?}", out.actions); +} diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 8d5c7ec5..78365b3e 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -391,6 +391,18 @@ fn outer_edge_band_splits_at_the_root() { assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { placement: Placement::Edge(_), .. })), "{:?}", out.actions); } +#[test] +fn the_split_tree_outer_edge_band_is_8_pt() { + let mut d = Driver::new(frames(), true); + let tab = d.tab(3).unwrap().center(); + let out = d.drag(tab, pos2(7.0, 300.0)); + assert!(out.actions.contains(&Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }), "{:?}", out.actions); + // Past it, frame [1]'s left side zone. + let mut d = Driver::new(frames(), true); + let out = d.drag(tab, pos2(10.0, 300.0)); + assert!(out.actions.contains(&Action::Move { panel: 3, anchor: 1, placement: Placement::Split(Zone::Left) }), "{:?}", out.actions); +} + #[test] fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input() { let mut layout = frames(); diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 2e0c5efa..fc071e66 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -354,7 +354,7 @@ fn group_drop_on_a_frame_edge_splits_and_on_a_protected_canvas_floats() { let canvas = || TestContent { protected: vec![1], ..Default::default() }; let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)); let mut d = Driver::with(frames(), TestContent::default(), area); - let out = d.drag(d.rect("drag-bar", 2).center(), pos2(20.0, 300.0)); + let out = d.drag(d.rect("drag-bar", 2).center(), pos2(40.0, 300.0)); assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, placement: Placement::Split(Zone::Left) })), "{:?}", out.actions); let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() }; assert_eq!(**first, tabs(&[2, 3], 1)); @@ -362,7 +362,7 @@ fn group_drop_on_a_frame_edge_splits_and_on_a_protected_canvas_floats() { // A protected canvas takes nothing, at its center or its edge: no join, no split, no // preview; the group floats there. - for at in [pos2(150.0, 300.0), pos2(20.0, 300.0)] { + for at in [pos2(150.0, 300.0), pos2(40.0, 300.0)] { let mut d = Driver::with(frames(), canvas(), area); let out = d.drag(d.rect("drag-bar", 2).center(), at); assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions); diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index aff39e73..f960c3e6 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -34,7 +34,10 @@ pub use surface::{ DockArea, DockContent, DockEvent, DockHeights, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging, route_app_drag, }; -pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect, zone_shape}; +pub use targets::{ + DropProposal, DropTarget, SIDE_EDGE_ZONE_DEPTH, ZoneRule, body_zone, drop_zone, resolve_drop, resolve_drop_by, tab_insertion_marker, zone_rect, + zone_shape, +}; pub use workspace::Workspace; use surface::{Drag, GroupDrag, GroupGesture}; @@ -44,6 +47,8 @@ mod command_tests; #[cfg(test)] mod dock_tests; #[cfg(test)] +mod drop_tests; +#[cfg(test)] mod floating_tests; #[cfg(test)] mod group_tests; diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 5fd79bdc..258af673 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -895,9 +895,9 @@ fn window_move(out: &super::DockOutput) -> Option<&Target> { #[test] fn a_native_windows_bar_docks_it_at_frame_edges_and_dock_lines_but_not_a_body_center() { - // The right edge zone of frame [3, 4], 25 pt in, clear of the new-dock band along the window's edge. + // The right edge zone of frame [3, 4], 40 pt in, clear of the new-dock band along the window's edge. let mut d = driver(layout(true)); - let out = drag_bar_to(&mut d, 5, pos2(775.0, 300.0)); + let out = drag_bar_to(&mut d, 5, pos2(760.0, 300.0)); assert!(matches!(window_move(&out), Some(Target::At { anchor: 3 | 4, placement: Placement::Split(super::Zone::Right) })), "{:?}", out.actions); assert!(d.layout.floating.is_empty()); assert!(in_root(&d, 5) && in_root(&d, 6)); @@ -1459,10 +1459,10 @@ fn the_bars_close_button_wins_over_the_resize_bands() { assert_eq!(resize(pos2(237.0, 100.0)), Some(D::East), "the right edge"); assert_eq!(resize(pos2(120.0, 1.0)), Some(D::North), "the top edge above the bar"); assert_eq!(resize(pos2(2.0, 298.0)), Some(D::SouthWest), "the bottom-left corner"); - // Inside a corner's L, the bar moves the window. + // Inside a corner's L, the bar moves the window, here to the middle of frame [1, 2]. let rect = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); - let (moved, resize, _) = press_and_drag(pos2(10.0, 10.0), vec2(20.0, 20.0)); - assert_eq!((moved, resize), (rect(420.0, 150.0, 660.0, 450.0), None)); + let (moved, resize, _) = press_and_drag(pos2(10.0, 10.0), vec2(-110.0, 150.0)); + assert_eq!((moved, resize), (rect(290.0, 280.0, 530.0, 580.0), None)); } #[test] diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index 65cf3337..5376788e 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -184,7 +184,10 @@ impl Renderer<'_, P> { std::mem::swap(&mut self.frame.windows, &mut surface.windows); let bounds = std::mem::replace(&mut self.float_bounds, surface.bounds); let blockers = std::mem::take(&mut self.blockers); + // A native window has no docks. + let groups = std::mem::take(&mut self.dock_groups); let result = f(self); + self.dock_groups = groups; std::mem::swap(&mut self.targets, &mut surface.targets); std::mem::swap(&mut self.dock_targets, &mut surface.dock_targets); std::mem::swap(&mut self.frame.windows, &mut surface.windows); diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index ba754d53..be0aeee4 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -8,17 +8,44 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; -use super::{GroupKind, Renderer, RowItem, row_drag, row_range}; +use super::{GroupKind, Renderer, RowItem, row_drag, row_range, stripless}; use crate::docking::{ - Action, Column, DockEvent, DockHeights, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target, + Action, Column, DockEvent, DockHeights, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target, Zone, dock_model::Unit, - targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line}, + targets::{ + DockDrop, DockTarget, SIDE_EDGE_ZONE_DEPTH, STRIP_REACH, body_zone, column_edge, drop_zone, horizontal_line, resolve_dock, usable, + vertical_line, + }, }; use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; /// Width of the »/« button at the outer end of a column bar. const COLLAPSE_WIDTH: f32 = 18.0; +/// [D] An open group of a dock column: its visible body, and the target and line of its left, +/// right, top and bottom zones (see [`DockStyle::side_zone_fraction`]). The top and bottom +/// stack a group above or below it; the left and right make a column on that side. +#[derive(Clone, Debug)] +pub(in crate::docking) struct DockGroup

{ + body: Rect, + /// The group takes tabs, so its body has a middle that joins it. + center: bool, + sides: [Option<(DockTarget

, Rect)>; 4], +} + +impl

DockGroup

{ + fn side(&self, zone: Zone) -> Option<&(DockTarget

, Rect)> { + let index = match zone { + Zone::Left => 0, + Zone::Right => 1, + Zone::Top => 2, + Zone::Bottom => 3, + Zone::Center => return None, + }; + self.sides.get(index)?.as_ref() + } +} + /// The physical window edge a dock sits on. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(in crate::docking) enum Edge { @@ -230,8 +257,9 @@ impl Renderer<'_, P> { Edge::Left => (rect.left(), 1.0), Edge::Right => (rect.right(), -1.0), }; + let (band_width, line_width) = (self.style.column_edge_band, self.style.drop_line_width); if columns.is_empty() { - let (band, line) = column_edge(outer, inward, rect.y_range()); + let (band, line) = column_edge(outer, rect.y_range(), band_width, line_width); let band = band.union(Rect::from_x_y_ranges( outer.min(outer + inward * self.style.new_dock_band)..=outer.max(outer + inward * self.style.new_dock_band), rect.y_range(), @@ -257,13 +285,24 @@ impl Renderer<'_, P> { let width = fitted.min((rect.width() - (x - outer).abs()).max(0.0)); let inner = x + inward * width; let column_rect = Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range()); + let groups = self.dock_groups.len(); self.column(ui, column, first, column_rect, edge); - if index == 0 { - let (band, line) = column_edge(x, inward, rect.y_range()); - self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: false }, band, line }); + let (outer_band, outer_line) = column_edge(x, rect.y_range(), band_width, line_width); + let (inner_band, inner_line) = column_edge(inner, rect.y_range(), band_width, line_width); + let before = (DockTarget::ColumnEdge { anchor: first.clone(), after: false }, outer_line); + let after = (DockTarget::ColumnEdge { anchor: first.clone(), after: true }, inner_line); + let (left, right) = match edge { + Edge::Left => (before.clone(), after.clone()), + Edge::Right => (after.clone(), before.clone()), + }; + for group in self.dock_groups.iter_mut().skip(groups) { + group.sides[0] = Some(left.clone()); + group.sides[1] = Some(right.clone()); } - let (band, line) = column_edge(inner, -inward, rect.y_range()); - self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: true }, band, line }); + if index == 0 { + self.dock_targets.push(DockDrop { target: before.0, band: outer_band, line: before.1 }); + } + self.dock_targets.push(DockDrop { target: after.0, band: inner_band, line: after.1 }); drawn.push((index, column, first, width, inner)); x = inner; } @@ -408,10 +447,13 @@ impl Renderer<'_, P> { } let xs = view.x_range(); let half = self.style.seam_band * 0.5; + let width = self.style.drop_line_width; + // A line on an edge scrolled out of the view shows at the view's edge. + let line = |y: f32| horizontal_line(y.clamp(view.top() + width * 0.5, view.bottom() - width * 0.5), xs, width); let push = |targets: &mut Vec>, target: DockTarget

, band: Rect, line: Rect| { let band = band.intersect(view); if usable(band) { - targets.push(DockDrop { target, band, line: line.intersect(view.expand(2.0)) }); + targets.push(DockDrop { target, band, line }); } }; let mut previous: Option<&P> = None; @@ -419,20 +461,20 @@ impl Renderer<'_, P> { let Some(anchor) = group.panels.first() else { continue }; if let Some(above) = previous { let band = Rect::from_x_y_ranges(xs, (rect.top() - half)..=(rect.top() + half)); - push(&mut self.dock_targets, DockTarget::Seam { anchor: above.clone() }, band, horizontal_line(rect.top(), xs)); + push(&mut self.dock_targets, DockTarget::Seam { anchor: above.clone() }, band, line(rect.top())); } - if !group.minimized { - let body = Rect::from_min_max(pos2(rect.left(), (rect.top() + self.style.tab_height).min(rect.bottom())), rect.max); - let quarter = body.height() * 0.25; - let upper = Rect::from_x_y_ranges(xs, body.top()..=(body.top() + quarter)); - let lower = Rect::from_x_y_ranges(xs, (body.bottom() - quarter)..=body.bottom()); - push(&mut self.dock_targets, DockTarget::GroupBand { anchor: anchor.clone(), below: false }, upper, horizontal_line(rect.top(), xs)); - push( - &mut self.dock_targets, - DockTarget::GroupBand { anchor: anchor.clone(), below: true }, - lower, - horizontal_line(rect.bottom(), xs), - ); + let header = if stripless(&group.panels, &*self.content) { 0.0 } else { self.style.tab_height }; + let body = Rect::from_min_max(pos2(rect.left(), (rect.top() + header).min(rect.bottom())), rect.max).intersect(view); + if !group.minimized && usable(body) { + let center = group.panels.get(group.active).is_some_and(|p| self.permissions(p).accepts_tabs); + let (above, below) = + (DockTarget::GroupBand { anchor: anchor.clone(), below: false }, DockTarget::GroupBand { anchor: anchor.clone(), below: true }); + // The left and right sides are the column's edges, set once the column is drawn. + self.dock_groups.push(DockGroup { + body, + center, + sides: [None, None, Some((above, line(rect.top()))), Some((below, line(rect.bottom())))], + }); } previous = Some(anchor); } @@ -441,7 +483,7 @@ impl Renderer<'_, P> { && top < view.bottom() { let band = Rect::from_x_y_ranges(xs, top..=view.bottom()); - push(&mut self.dock_targets, DockTarget::GroupBand { anchor: last.clone(), below: true }, band, horizontal_line(top, xs)); + push(&mut self.dock_targets, DockTarget::GroupBand { anchor: last.clone(), below: true }, band, line(top)); } // Height dividers between an open group and the next open one, after the groups so // their hit areas win at the boundary. The heights of the groups a drag changes are @@ -583,12 +625,48 @@ impl Renderer<'_, P> { Edge::Right => (self.workspace.right(), -self.style.new_dock_band), }; let band = Rect::from_x_y_ranges(x.min(x + inward)..=x.max(x + inward), ys); - let line = vertical_line(x + inward.signum() * 2.0, ys); + let width = self.style.drop_line_width; + let line = vertical_line(x + inward.signum() * width * 0.5, ys, width); candidates.push(DockDrop { target: DockTarget::NewDock { side }, band, line }); } candidates } + /// Whether `pointer` is on the tab strip of the frontmost group under it, and that group + /// takes tabs: the strip joins the group, and border bands reach into it only + /// [`STRIP_REACH`] from their lines. Not with [`DockStyle::side_edge_zones`]. + pub(super) fn in_tab_strip(&self, pointer: Pos2) -> bool { + !self.style.side_edge_zones + && self.targets.iter().rev().find(|t| usable(t.rect) && t.rect.contains(pointer)).is_some_and(|t| { + t.accepts_tabs && t.tab_strip.map(|strip| strip.intersect(t.rect)).is_some_and(|strip| usable(strip) && strip.contains(pointer)) + }) + } + + /// The dock target at `pointer`: a band at a border, else a side zone of a dock group's + /// body ([`SIDE_EDGE_ZONE_DEPTH`] deep with [`DockStyle::side_edge_zones`]). The middle of a + /// group that takes tabs is none: the group's own target joins it. + fn dock_drop(&self, pointer: Pos2) -> Option> { + let mut candidates = self.dock_candidates(); + let strip = self.in_tab_strip(pointer); + if strip { + candidates.retain(|t| t.line.distance_to_pos(pointer) <= STRIP_REACH); + } + if let Some(drop) = resolve_dock(pointer, &candidates) { + return Some(drop.clone()); + } + if strip { + return None; + } + let group = self.dock_groups.iter().find(|g| g.body.contains(pointer))?; + let zone = if self.style.side_edge_zones { + drop_zone(group.body, 0.0, SIDE_EDGE_ZONE_DEPTH, pointer)? + } else { + body_zone(group.body, 0.0, self.style.side_zone_fraction, group.center, pointer)? + }; + let (target, line) = group.side(zone)?.clone(); + Some(DockDrop { target, band: group.body, line }) + } + /// The dock target a whole group or column drag would take at `pointer`, with the action /// and its line. A target that leaves the layout unchanged is a no-op: `None` action. pub(super) fn dock_group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option<(Option>, Rect)> { @@ -596,8 +674,7 @@ impl Renderer<'_, P> { if self.dock_covered(pointer, moving) { return None; } - let candidates = self.dock_candidates(); - let drop = resolve_dock(pointer, &candidates)?; + let drop = self.dock_drop(pointer)?; let action = group_action(drag, drop.target.to()); self.checked(action).map(|action| (action, drop.line)) } @@ -609,8 +686,7 @@ impl Renderer<'_, P> { if self.dock_covered(pointer, None) { return None; } - let candidates = self.dock_candidates(); - let drop = resolve_dock(pointer, &candidates)?; + let drop = self.dock_drop(pointer)?; let action = match drop.target.to() { // A target named by the dragged panel is its own group or column: another panel // there names the same one. With none, the drop leaves the panel where it is. @@ -638,8 +714,8 @@ impl Renderer<'_, P> { Some((candidate != *self.layout).then_some(action)) } - /// The insertion line of a dock target. - pub(super) fn paint_dock_line(&self, ctx: &Context, line: Rect) { + /// The line a drop beside a group, column, dock or the layout's edge previews with. + pub(super) fn paint_drop_line(&self, ctx: &Context, line: Rect) { if usable(line) { let painter = self.preview_painter(ctx); self.iconic.outline(&painter, line, self.style); diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 618216ec..74ba98a0 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -168,10 +168,14 @@ impl Renderer<'_, P> { /// 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. + /// moving, the zone and the preview: a line along the layout's whole edge, or with side edge + /// zones the quarter the new frame takes. Without them, on a tab strip that takes tabs, the band reaches + /// only [`crate::docking::targets::STRIP_REACH`] from the line. 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 band = if self.style.side_edge_zones { crate::docking::targets::SIDE_EDGE_BAND } else { self.style.edge_band }; + let band = band.min(area.width() / 4.0).min(area.height() / 4.0); + let line = |zone| crate::docking::targets::edge_line(area, zone, self.style.drop_line_width); let mut edges = vec![(pointer.y - area.top(), Zone::Top), (area.bottom() - pointer.y, Zone::Bottom)]; if self.style.side_edge_zones { edges.extend([(pointer.x - area.left(), Zone::Left), (area.right() - pointer.x, Zone::Right)]); @@ -180,8 +184,15 @@ impl Renderer<'_, P> { if distance > band { return None; } + let reach = |line: Rect| line.distance_to_pos(pointer) <= crate::docking::targets::STRIP_REACH; + if !self.style.side_edge_zones && self.in_tab_strip(pointer) && !line(zone).is_some_and(reach) { + return None; + } let anchor = self.layout.panels().into_iter().find(|p| !members.contains(p) && !matches!(self.place(p), Some(Place::Floating { .. }) | None))?.clone(); + if !self.style.side_edge_zones { + return Some((anchor, zone, line(zone)?)); + } let size = area.size() * 0.25; let preview = match zone { Zone::Left => Rect::from_min_size(area.min, vec2(size.x, area.height())), diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 2bb3e235..fd6af7d1 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -181,7 +181,8 @@ impl Renderer<'_, P> { for (below, ys, line) in [(false, upper_top..=middle, rect.top()), (true, middle..=lower_bottom, rect.bottom())] { let band = Rect::from_x_y_ranges(xs, ys).intersect(view); if usable(band) { - let line = horizontal_line(line.clamp(view.top() + 2.0, view.bottom() - 2.0), xs); + let width = self.style.drop_line_width; + let line = horizontal_line(line.clamp(view.top() + width * 0.5, view.bottom() - width * 0.5), xs, width); self.dock_targets.push(DockDrop { target: DockTarget::GroupBand { anchor: anchor.clone(), below }, band, line }); } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index b669502c..50525fc7 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -102,6 +102,8 @@ pub(super) struct Renderer<'a, P> { pub(super) targets: Vec>, /// [D] Seams, group bands, column edges and new-dock bands, see [`crate::docking::targets::DockDrop`]. pub(super) dock_targets: Vec>, + /// [D] The open groups of dock columns, whose body zones stack or make columns. + pub(super) dock_groups: Vec>, pub(super) owned_interactions: Vec, pub(super) output: DockOutput

, pub(super) frame: FrameState

, diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 5488850a..2defec03 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -6,7 +6,9 @@ use super::{DROP_MARGIN, Drawn, Renderer, clamp_float, dock::group_action}; use crate::docking::chrome::drop_preview; use crate::docking::{ Action, Drag, DropProposal, DropRequest, DropTarget, GroupDrag, GroupGesture, Layout, Node, Placement, Target, Zone, cancel_drag, - dock_model::Place, resolve_drop, surface::TAB_PADDING, + dock_model::Place, + surface::TAB_PADDING, + targets::{SIDE_EDGE_ZONE_DEPTH, resolve_drop_by}, }; use crate::layout::SplitAxis; @@ -15,6 +17,16 @@ fn float_only(ctx: &Context) -> bool { ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd) } +/// Share of the accent that fills a tab drop's outline: light, so it does not read as a line. +const TAB_FILL: f32 = 0.08; + +/// The preview of a drop that joins a group as a tab: a 2 pt accent outline inside `rect`, with +/// a light fill. +fn tab_outline(painter: &egui::Painter, rect: Rect, accent: egui::Color32) { + painter.rect_filled(rect, 0.0, accent.gamma_multiply(TAB_FILL)); + painter.rect_stroke(rect, 0.0, egui::Stroke::new(2.0, accent), egui::StrokeKind::Inside); +} + /// Height of a panel floated out of a dock column when the host gives no floating size. const DOCKED_FLOAT_HEIGHT: f32 = 320.0; @@ -174,10 +186,11 @@ impl Renderer<'_, P> { (proposal.noop || self.layout.clone().apply_with_permissions(group_action(drag, to), |p| self.permissions(p)).is_ok()).then_some(proposal) } - /// [`resolve_drop`], where a split that would leave a group without room becomes a join - /// of the target group. + /// [`crate::docking::resolve_drop_by`] with [`crate::docking::DockStyle::zone_rule`], where a split that would leave + /// a group without room becomes a join of the target group, or no target when the group + /// takes no tabs. fn resolve_roomy(&self, panel: &P, pointer: Pos2, targets: &[DropTarget

], action: impl Fn(Target

) -> Action

) -> Option> { - let proposal = resolve_drop(panel, pointer, targets, self.style.zone_depth)?; + let proposal = resolve_drop_by(panel, pointer, targets, self.style.zone_rule())?; if !matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center) { return Some(proposal); } @@ -185,7 +198,7 @@ impl Renderer<'_, P> { 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() }), self.style.zone_depth) + resolve_drop_by(panel, pointer, std::slice::from_ref(&DropTarget { allow_splits: false, ..target.clone() }), self.style.zone_rule()) } /// Whether `action`, a drop that splits a group or the outer edge, leaves room: every group @@ -285,7 +298,7 @@ impl Renderer<'_, P> { } if let Some((action, line)) = self.dock_group_proposal(drag, pointer) { if action.is_some() { - self.paint_dock_line(ctx, line); + self.paint_drop_line(ctx, line); if drag.gesture != GroupGesture::Window { self.paint_float_hint(ctx, pointer); } @@ -295,12 +308,11 @@ impl Renderer<'_, P> { let proposal = self.group_proposal(drag, pointer).filter(|p| !p.noop); let zones = self.paint_group_zones(&painter, drag, pointer, proposal.as_ref()); if let Some(proposal) = proposal { - let split = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center); - if crate::docking::targets::usable(proposal.preview) && !zones { - drop_preview(&painter, proposal.preview.shrink(1.0), self.style.accent, split); - } - if let Some(marker) = proposal.insertion { - painter.rect_filled(marker, 0.0, self.style.accent); + if zones { + self.paint_marker(&painter, &proposal); + } else { + let split = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center); + self.paint_preview(ctx, &painter, &proposal, split); } if drag.gesture != GroupGesture::Window { self.paint_float_hint(ctx, pointer); @@ -357,7 +369,7 @@ impl Renderer<'_, P> { self.output.focused = Some(drag.panel); } } else if action.is_some() { - self.paint_dock_line(ctx, line); + self.paint_drop_line(ctx, line); self.paint_float_hint(ctx, pointer); } return; @@ -421,17 +433,41 @@ impl Renderer<'_, P> { // Reordering inside the source strip shows only the insertion line. let same_group = proposal.insertion.is_some() && self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel)); - if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) && !zones { - drop_preview(painter, proposal.preview.shrink(1.0), self.style.accent, true); - } - if let Some(marker) = proposal.insertion { - painter.rect_filled(marker, 0.0, self.style.accent); + if !same_group && !proposal.noop && !zones { + self.paint_preview(ctx, painter, proposal, true); + } else { + self.paint_marker(painter, proposal); } if !proposal.noop { self.paint_float_hint(ctx, pointer); } } + /// The preview of a group target, for tab and group drags: a line along the edge of a drop + /// beside the group or the layout, else the tab outline of the whole group, and the + /// insertion marker on a strip. With [`crate::docking::DockStyle::side_edge_zones`], the + /// region the drop takes, filled for a tab drag (`filled`) or a split. + fn paint_preview(&self, ctx: &Context, painter: &egui::Painter, proposal: &DropProposal

, filled: bool) { + if crate::docking::targets::usable(proposal.preview) { + let beside = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center); + if self.style.side_edge_zones { + drop_preview(painter, proposal.preview.shrink(1.0), self.style.accent, filled); + } else if beside { + self.paint_drop_line(ctx, proposal.preview); + } else { + tab_outline(painter, proposal.preview.shrink(1.0), self.style.accent); + } + } + self.paint_marker(painter, proposal); + } + + /// The insertion marker of a drop on a tab strip. + fn paint_marker(&self, painter: &egui::Painter, proposal: &DropProposal

) { + if let Some(marker) = proposal.insertion { + painter.rect_filled(marker, 0.0, self.style.accent); + } + } + /// [`Self::paint_zones`] for a tab drag of `panel`. The center of the group holding the panel /// is not a target: a drop there reorders or changes nothing. pub(super) fn paint_tab_zones(&self, painter: &egui::Painter, panel: &P, pointer: Pos2, proposal: Option<&DropProposal

>) -> bool { @@ -489,7 +525,7 @@ impl Renderer<'_, P> { let takes = |zone: Zone, point: Pos2| at(point).as_ref().and_then(zone_of) == Some(zone); let mut shapes: Vec<(Zone, Vec)> = [Zone::Left, Zone::Right, Zone::Top, Zone::Bottom, Zone::Center] .into_iter() - .filter_map(|zone| Some((zone, zone_shape(target.rect, header, self.style.zone_depth, zone)?))) + .filter_map(|zone| Some((zone, zone_shape(target.rect, header, SIDE_EDGE_ZONE_DEPTH, zone)?))) .filter(|(zone, points)| takes(*zone, centroid(points).to_pos2())) .collect(); let strip = target.tab_strip.map(|strip| strip.intersect(target.rect)).filter(|strip| usable(*strip) && takes(Zone::Center, strip.center())); diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index dbfccf97..8a07c80f 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -780,7 +780,7 @@ fn the_preview_fills_the_zone_under_the_pointer_and_no_result_rect() { assert_eq!(zone_outlines(&d).len(), 6); d.key(egui::Key::Escape); - // The dock-and-canvas arrangement keeps its filled half. + // The dock-and-canvas arrangement shows a line along the group's left edge instead. let mut d = DockDriver::new(editing(), Film::default()); d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN); d.area = d.screen; @@ -788,7 +788,8 @@ fn the_preview_fills_the_zone_under_the_pointer_and_no_result_rect() { d.hold(tab(&d, INFO), pos2(timeline.left() + 8.0, timeline.center().y + 13.0)); assert!(!d.filled_polygon(&zone_shape(timeline, 26.0, 40.0, Zone::Left).unwrap())); assert!(zone_outlines(&d).is_empty()); - assert!(d.filled(zone_rect(timeline, Zone::Left).unwrap().shrink(1.0))); + assert!(!d.filled(zone_rect(timeline, Zone::Left).unwrap().shrink(1.0))); + assert!(d.filled(Rect::from_x_y_ranges(timeline.left()..=(timeline.left() + 4.0), timeline.y_range()))); } #[test] diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index c1af45d2..0b036b2f 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -53,23 +53,38 @@ pub struct DockStyle { pub blocked_dim: Color32, /// 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. + /// Width of the band along the layout's outer edge that splits at the root (28 pt). It is + /// at most a quarter of the layout's width and height. Not used with + /// [`Self::side_edge_zones`], whose band is 8 pt. 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. + /// The split-tree profile. The outer edge band also runs along the left and right edges, + /// where a drop adds a full-height column. A group's side zones are trapezoids + /// [`crate::docking::SIDE_EDGE_ZONE_DEPTH`] deep (see [`crate::docking::drop_zone`]), a drag + /// outlines them, and a split previews the half of the group it takes. Off: only the top and + /// bottom edges add a full-width row, a group's zones are [`Self::side_zone_fraction`] of its + /// body, and every preview is a line or a tab outline. pub side_edge_zones: bool, - /// How far a group's side drop zones reach into its body, below the tab strip. All four - /// zones share one depth: this, or a quarter of the body's smaller side when that is less, - /// so the body keeps a center. - pub zone_depth: f32, + /// The share of a group's body, below the tab strip, that means "beside" on each side: of + /// its width from the left and right edges, of its height from the top and bottom edges + /// (one third, at most one half). The middle joins the group as a tab. Not used with + /// [`Self::side_edge_zones`]. + pub side_zone_fraction: f32, + /// Width of the line a drop beside a group, column, dock or the layout's edge previews with + /// (4 pt, more than zero). + pub drop_line_width: f32, /// [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 a dock column. + /// [D] Height of the band centred on the seam between two stacked groups in a dock column + /// (28 pt). pub seam_band: f32, - /// [D] Width of the band along the docked area's left and right edges that makes a new dock. + /// [D] Width of the band centred on each edge of a dock column, where a drop makes a new + /// column (28 pt). + pub column_edge_band: f32, + /// [D] Width of the band along the docked area's left and right edges, and inside an empty + /// dock, that makes a new dock (28 pt). pub new_dock_band: f32, /// [D] Side of a panel icon in an iconic column; each icon row is 10 pt taller. pub icon_size: f32, @@ -173,13 +188,15 @@ impl DockStyle { floating_stroke: visuals.window_stroke, blocked_dim: visuals.panel_fill.gamma_multiply(0.6), title_height: super::chrome::TITLE_HEIGHT, - edge_band: 8.0, + edge_band: 28.0, side_edge_zones: false, - zone_depth: 40.0, + side_zone_fraction: 1.0 / 3.0, + drop_line_width: 4.0, new_dock_zones: true, column_bar_height: 14.0, - seam_band: 8.0, - new_dock_band: 12.0, + seam_band: 28.0, + column_edge_band: 28.0, + new_dock_band: 28.0, icon_size: 20.0, iconic_width: 36.0, iconic_max_width: 240.0, @@ -210,6 +227,17 @@ impl DockStyle { } } + /// How a group's body divides into drop zones: [`crate::docking::SIDE_EDGE_ZONE_DEPTH`] deep + /// with [`Self::side_edge_zones`], else [`Self::side_zone_fraction`] of it, with + /// [`Self::drop_line_width`] previews. Pass it to [`crate::docking::resolve_drop_by`]. + pub fn zone_rule(&self) -> super::ZoneRule { + if self.side_edge_zones { + super::ZoneRule::Depth(super::SIDE_EDGE_ZONE_DEPTH) + } else { + super::ZoneRule::Fraction { fraction: self.side_zone_fraction, line: self.drop_line_width } + } + } + pub(super) fn valid(&self) -> bool { [ self.tab_height, @@ -222,9 +250,11 @@ impl DockStyle { self.floating_stroke.width, self.title_height, self.edge_band, - self.zone_depth, + self.side_zone_fraction, + self.drop_line_width, self.column_bar_height, self.seam_band, + self.column_edge_band, self.new_dock_band, self.icon_size, self.iconic_width, @@ -236,6 +266,8 @@ impl DockStyle { && self.iconic_width <= self.iconic_max_width && self.tab_height > 0.0 && self.font.size > 0.0 + && self.drop_line_width > 0.0 + && self.side_zone_fraction <= 0.5 } } @@ -1164,6 +1196,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { float_bounds: self.float_bounds.map_or(ui.ctx().content_rect(), |bounds| bounds.intersect(ui.ctx().content_rect())), targets: Vec::new(), dock_targets: Vec::new(), + dock_groups: Vec::new(), owned_interactions: Vec::new(), output: DockOutput::default(), frame, diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index 82eec325..c9ac64a7 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -31,8 +31,18 @@ pub(super) fn usable(rect: Rect) -> bool { rect.is_finite() && rect.width() > 0.0 && rect.height() > 0.0 && rect.width() <= 1_000_000.0 && rect.height() <= 1_000_000.0 } -/// Tab header (the top `tab_height` of `rect`) is the center; otherwise the nearest side zone of -/// the body below it wins. Every side zone reaches the same depth into the body: `depth`, or a +/// Depth of a group's side drop zones with [`crate::docking::DockStyle::side_edge_zones`]: the +/// `depth` that [`drop_zone`], [`zone_shape`] and [`resolve_drop`] take there. +pub const SIDE_EDGE_ZONE_DEPTH: f32 = 40.0; + +/// Width of the band along the layout's outer edge with +/// [`crate::docking::DockStyle::side_edge_zones`], in place of +/// [`crate::docking::DockStyle::edge_band`]. +pub(super) const SIDE_EDGE_BAND: f32 = 8.0; + +/// The picture-frame zones of [`crate::docking::DockStyle::side_edge_zones`]. Tab header (the +/// top `tab_height` of `rect`) is the center; otherwise the nearest side zone of the body below +/// it wins. Every side zone reaches the same depth into the body: `depth`, or a /// quarter of the body's smaller side when that is less, so a body always keeps a center and a /// narrow body's top and bottom zones are no deeper than its left and right ones. A `depth` of /// zero or less leaves only the center. Outside, non-finite, empty and inverted rectangles have @@ -60,6 +70,49 @@ pub fn drop_zone(rect: Rect, tab_height: f32, depth: f32, pointer: Pos2) -> Opti }) } +/// The zones of the default profile. Tab header (the top `tab_height` of `rect`) is the center. +/// In the body below it, the outer `fraction` of its width from the left or right edge, or of +/// its height from the top or bottom edge, is that side's zone; where two sides' zones meet, the +/// side the pointer is relatively nearer wins. The rest is the center. Without `center` (a group +/// that takes no tabs) the whole body goes to the relatively nearest side. A `fraction` of zero +/// or less leaves only the center; more than one half acts as one half. Outside, non-finite, +/// empty and inverted rectangles have no target. +pub fn body_zone(rect: Rect, tab_height: f32, fraction: f32, center: bool, pointer: Pos2) -> Option { + if !usable(rect) || !pointer.is_finite() || !rect.contains(pointer) || !tab_height.is_finite() || tab_height < 0.0 { + return None; + } + let body = body(rect, tab_height); + if pointer.y < body.top() || !usable(body) || fraction.is_nan() || fraction <= 0.0 { + return Some(Zone::Center); + } + let fraction = fraction.min(0.5); + let (w, h) = (body.width(), body.height()); + let sides = [ + ((pointer.x - body.left()) / w, Zone::Left), + ((body.right() - pointer.x) / w, Zone::Right), + ((pointer.y - body.top()) / h, Zone::Top), + ((body.bottom() - pointer.y) / h, Zone::Bottom), + ]; + let (distance, zone) = sides.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; + Some(if center && distance > fraction { Zone::Center } else { zone }) +} + +/// The preview line of a drop beside `rect` on `zone`'s side: `width` thick along that whole +/// edge, inside `rect`. None for the center. +pub(super) fn edge_line(rect: Rect, zone: Zone, width: f32) -> Option { + if !usable(rect) { + return None; + } + let (w, h) = (width.max(1.0).min(rect.width()), width.max(1.0).min(rect.height())); + Some(match zone { + Zone::Left => Rect::from_x_y_ranges(rect.left()..=(rect.left() + w), rect.y_range()), + Zone::Right => Rect::from_x_y_ranges((rect.right() - w)..=rect.right(), rect.y_range()), + Zone::Top => Rect::from_x_y_ranges(rect.x_range(), rect.top()..=(rect.top() + h)), + Zone::Bottom => Rect::from_x_y_ranges(rect.x_range(), (rect.bottom() - h)..=rect.bottom()), + Zone::Center => return None, + }) +} + /// The part of a group drawn at `rect` below its tab header. fn body(rect: Rect, tab_height: f32) -> Rect { Rect::from_min_max(pos2(rect.left(), rect.top() + tab_height.min(rect.height())), rect.max) @@ -106,7 +159,8 @@ pub(super) fn header_height

(target: &DropTarget

) -> f32 { target.tab_strip.map(|strip| strip.intersect(target.rect)).filter(|strip| usable(*strip)).map_or(0.0, |strip| strip.bottom() - target.rect.top()) } -/// Preview of the region occupied after a split or group merge. +/// The region a split or group merge occupies: the half of `rect` on `zone`'s side, or all of +/// it. [`resolve_drop`] previews with it. pub fn zone_rect(rect: Rect, zone: Zone) -> Option { if !usable(rect) { return None; @@ -152,10 +206,29 @@ pub fn tab_insertion_marker(target: &DropTarget

, before: Option<&P>) - /// Resolve a pointer against visible groups. The frontmost containing group occludes groups /// behind it even if it rejects the drop. A strip reorders by midpoint; the body of the source -/// group is a no-op at center. The body's side zones are [`drop_zone`]'s with `zone_depth` (see -/// [`crate::docking::DockStyle::zone_depth`]): one depth on all four sides, at most a quarter of -/// the body's smaller side. Floating groups normally set `allow_splits` to false. +/// group is a no-op at center. The body's side zones are [`drop_zone`]'s with `zone_depth` +/// ([`SIDE_EDGE_ZONE_DEPTH`] in the docking area): one depth on all four sides, at most a quarter +/// of the body's smaller side. A split previews the half of the group it takes ([`zone_rect`]). +/// Floating groups normally set `allow_splits` to false. These are the split-tree zones; a host +/// that resolves drops for a [`crate::docking::DockStyle`] calls [`resolve_drop_by`] with its +/// [`crate::docking::DockStyle::zone_rule`]. pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarget

], zone_depth: f32) -> Option> { + resolve_drop_by(panel, pointer, targets, ZoneRule::Depth(zone_depth)) +} + +/// How [`resolve_drop_by`] divides a group's body. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ZoneRule { + /// [`drop_zone`] with this depth; a split previews the half it takes. + Depth(f32), + /// [`body_zone`] with this fraction; a split previews a `line` thick line along the + /// group's edge on that side. + Fraction { fraction: f32, line: f32 }, +} + +/// [`resolve_drop`] with the body zones of `rule`: [`crate::docking::DockStyle::zone_rule`] +/// gives the ones the dock area uses. +pub fn resolve_drop_by(panel: &P, pointer: Pos2, targets: &[DropTarget

], rule: ZoneRule) -> Option> { if !pointer.is_finite() || targets.len() > super::MAX_PANELS { return None; } @@ -193,7 +266,12 @@ pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarg noop, }); } - let zone = if target.allow_splits { drop_zone(target.rect, header_height(target), zone_depth, pointer)? } else { Zone::Center }; + let header = header_height(target); + let zone = match rule { + _ if !target.allow_splits => Zone::Center, + ZoneRule::Depth(depth) => drop_zone(target.rect, header, depth, pointer)?, + ZoneRule::Fraction { fraction, .. } => body_zone(target.rect, header, fraction, target.accepts_tabs, pointer)?, + }; if zone == Zone::Center && target.accepts_tabs && target.tabs.iter().any(|(p, _)| p == panel) { return Some(DropProposal { anchor: anchor.clone(), @@ -216,7 +294,10 @@ pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarg Some(DropProposal { anchor: anchor.clone(), placement: Placement::Split(zone), - preview: zone_rect(target.rect, zone)?, + preview: match rule { + ZoneRule::Fraction { line, .. } if zone != Zone::Center => edge_line(target.rect, zone, line)?, + _ => zone_rect(target.rect, zone)?, + }, insertion: None, noop: false, }) @@ -238,7 +319,7 @@ pub(super) enum DockTarget

{ ColumnEdge { anchor: P, after: bool }, /// The seam between two stacked groups: a new group below the group of `anchor` (P4). Seam { anchor: P }, - /// The top or bottom quarter of a group body, or the free room under a column's last group (P3). + /// The top or bottom zone of a group body, or the free room under a column's last group (P3). GroupBand { anchor: P, below: bool }, /// A window edge without a dock, or an empty dock (P5, P6). NewDock { side: Side }, @@ -273,23 +354,21 @@ pub(super) fn resolve_dock(pointer: Pos2, targets: &[DockDrop

]) -> targets.iter().filter(|t| usable(t.band) && t.band.contains(pointer)).min_by_key(|t| t.target.rank()) } -/// Width of the insertion lines dock targets preview with. -const LINE: f32 = 4.0; -/// How far a column edge band reaches into the column, and past the edge out of it. -const EDGE_IN: f32 = 6.0; -const EDGE_OUT: f32 = 10.0; +/// How far from its line a border band reaches into a tab strip that takes tabs: the rest of +/// the strip joins the group. +pub(super) const STRIP_REACH: f32 = 4.0; -/// Band and line of a column edge at `x`. `inward` is +1 when the column lies right of the edge, -/// -1 when it lies left of it. The band reaches further out of the column than into it. -pub(super) fn column_edge(x: f32, inward: f32, ys: Rangef) -> (Rect, Rect) { - let (a, b) = (x + inward * EDGE_IN, x - inward * EDGE_OUT); - (Rect::from_x_y_ranges(a.min(b)..=a.max(b), ys), vertical_line(x, ys)) +/// Band and line of a column edge at `x`: the band `band` wide, centred on the edge, and the +/// line `line` wide. +pub(super) fn column_edge(x: f32, ys: Rangef, band: f32, line: f32) -> (Rect, Rect) { + let half = band.max(0.0) * 0.5; + (Rect::from_x_y_ranges((x - half)..=(x + half), ys), vertical_line(x, ys, line)) } -pub(super) fn vertical_line(x: f32, ys: Rangef) -> Rect { - Rect::from_x_y_ranges((x - LINE * 0.5)..=(x + LINE * 0.5), ys) +pub(super) fn vertical_line(x: f32, ys: Rangef, width: f32) -> Rect { + Rect::from_x_y_ranges((x - width * 0.5)..=(x + width * 0.5), ys) } -pub(super) fn horizontal_line(y: f32, xs: Rangef) -> Rect { - Rect::from_x_y_ranges(xs, (y - LINE * 0.5)..=(y + LINE * 0.5)) +pub(super) fn horizontal_line(y: f32, xs: Rangef, width: f32) -> Rect { + Rect::from_x_y_ranges(xs, (y - width * 0.5)..=(y + width * 0.5)) } diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index f8921652..9ad7fefe 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -303,8 +303,8 @@ fn pointer_targets_distinguish_reorder_center_split_and_occlusion() { } #[test] -fn side_zones_are_40_pt_deep_or_a_quarter_of_the_bodys_smaller_side() { - let depth = DockStyle::default().zone_depth; +fn side_edge_zones_are_40_pt_deep_or_a_quarter_of_the_bodys_smaller_side() { + let depth = SIDE_EDGE_ZONE_DEPTH; assert_eq!(depth, 40.0); // The inner corner of the left trapezoid is the depth of both bands. let inner = |rect: Rect| zone_shape(rect, 0.0, depth, Zone::Left).unwrap()[1] - rect.min; @@ -336,6 +336,70 @@ fn side_zones_are_40_pt_deep_or_a_quarter_of_the_bodys_smaller_side() { assert!(zone_shape(rect, 0.0, 0.0, Zone::Left).is_none()); } +#[test] +fn a_bodys_outer_thirds_are_its_sides_and_the_relatively_nearer_side_wins_at_a_corner() { + let fraction = DockStyle::default().side_zone_fraction; + assert!((fraction - 1.0 / 3.0).abs() < 1e-6); + // A 24 pt header over a 600 x 300 body. + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(600.0, 324.0)); + let body = Rect::from_min_max(pos2(10.0, 44.0), rect.max); + let zone = |p| body_zone(rect, 24.0, fraction, true, p); + let y = body.center().y; + assert_eq!(zone(pos2(body.left() + 199.0, y)), Some(Zone::Left)); + assert_eq!(zone(pos2(body.left() + 201.0, y)), Some(Zone::Center)); + assert_eq!(zone(pos2(body.right() - 199.0, y)), Some(Zone::Right)); + assert_eq!(zone(pos2(body.center().x, body.top() + 99.0)), Some(Zone::Top)); + assert_eq!(zone(pos2(body.center().x, body.top() + 101.0)), Some(Zone::Center)); + assert_eq!(zone(pos2(body.center().x, body.bottom() - 99.0)), Some(Zone::Bottom)); + assert_eq!(zone(pos2(body.left() + 2.0, rect.top() + 10.0)), Some(Zone::Center), "the header joins"); + // In a corner: a quarter of the width from the left is further than a fifth of the height + // from the top, a tenth of the width nearer than three tenths of the height. + assert_eq!(zone(pos2(body.left() + 150.0, body.top() + 60.0)), Some(Zone::Top)); + assert_eq!(zone(pos2(body.left() + 60.0, body.top() + 90.0)), Some(Zone::Left)); + // Without a middle, the relatively nearest side takes every point. + let edges = |p| body_zone(rect, 24.0, fraction, false, p); + assert_eq!(edges(body.center() - vec2(10.0, 0.0)), Some(Zone::Left)); + assert_eq!(edges(body.center() - vec2(0.0, 10.0)), Some(Zone::Top)); + assert_eq!(edges(body.center() + vec2(0.0, 10.0)), Some(Zone::Bottom)); + // No fraction: only the middle. Outside or invalid: nothing. + assert_eq!(body_zone(rect, 24.0, 0.0, true, pos2(body.left() + 1.0, y)), Some(Zone::Center)); + assert!(body_zone(rect, 24.0, fraction, true, pos2(0.0, 0.0)).is_none()); + assert!(body_zone(Rect::NOTHING, 24.0, fraction, true, pos2(0.0, 0.0)).is_none()); +} + +#[test] +fn a_drop_beside_a_group_previews_a_line_along_its_edge_and_a_group_without_tabs_has_no_middle() { + let style = DockStyle::from_visuals(&egui::Visuals::dark()); + let rule = style.zone_rule(); + assert_eq!(rule, ZoneRule::Fraction { fraction: 1.0 / 3.0, line: 4.0 }); + assert_eq!(DockStyle { side_edge_zones: true, ..style }.zone_rule(), ZoneRule::Depth(SIDE_EDGE_ZONE_DEPTH)); + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(300.0, 224.0)); + let group = target(2, &[2, 3], rect); + let resolve = |at, group: &DropTarget| resolve_drop_by(&5, at, std::slice::from_ref(group), rule); + let left = resolve(pos2(20.0, 140.0), &group).unwrap(); + assert_eq!(left.placement, Placement::Split(Zone::Left)); + assert_eq!(left.preview, Rect::from_x_y_ranges(10.0..=14.0, rect.y_range()), "the group's whole left edge"); + let bottom = resolve(pos2(160.0, 240.0), &group).unwrap(); + assert_eq!(bottom.placement, Placement::Split(Zone::Bottom)); + assert_eq!(bottom.preview, Rect::from_x_y_ranges(rect.x_range(), 240.0..=244.0)); + let middle = resolve(pos2(160.0, 144.0), &group).unwrap(); + assert_eq!((middle.placement, middle.preview), (Placement::Split(Zone::Center), rect)); + // A canvas: its middle goes to the nearest edge, and its strip takes nothing. + let canvas = DropTarget { accepts_tabs: false, ..group }; + assert_eq!(resolve(pos2(150.0, 144.0), &canvas).unwrap().placement, Placement::Split(Zone::Left)); + assert!(resolve(pos2(90.0, 30.0), &canvas).is_none()); +} + +#[test] +fn a_style_needs_a_drop_line_and_side_zones_of_at_most_half_the_body() { + let style = DockStyle::from_visuals(&egui::Visuals::dark()); + assert!(style.valid()); + assert!(DockStyle { side_zone_fraction: 0.0, ..style.clone() }.valid()); + assert!(DockStyle { side_zone_fraction: 0.5, ..style.clone() }.valid()); + assert!(!DockStyle { side_zone_fraction: 0.6, ..style.clone() }.valid()); + assert!(!DockStyle { drop_line_width: 0.0, ..style }.valid()); +} + #[test] fn long_action_sequence_never_loses_or_duplicates_a_panel() { for (start, ids) in [(sample(), vec![1, 2, 3, 4, 5, 6]), (super::model_tests::docked(), super::model_tests::ALL.to_vec())] { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 9123eaec..39152ee6 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -85,8 +85,7 @@ layout.apply(Action::Move { that side or a new group above or below in a dock. A floating window takes splits as the docked layout does; `Placement::Group` on a floating anchor splits its group above or below. - 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 frame changes size. The new split has its own divider. - Other actions select, minimize, collapse to icons, resize splits (`ResizeSplit` in the docked layout, `ResizeWindowSplit` in the floating window holding a panel), columns and groups, and move a floating window to its own OS window (`SetNative`, and `SetMonitor` for the monitor it @@ -169,8 +168,8 @@ workspace reset hides, closes or moves its panel, and then gives it up. A divide window's resize grip holds focus only while the pointer drags it, so the app's shortcuts and canvas arrow keys work right after; one reached with Tab or F6 keeps its focus, and arrow keys resize it. Hovering focuses nothing. `begin_drag` hands a drag that started in the app's own chrome to the area. -`docking::chrome` has the floating frame, the title bar and `drop_preview` (the drop preview -docking and `craft_ui::panes` both draw) for apps that draw their own windows. +`docking::chrome` has the floating frame, the title bar and `drop_preview` (the drag ghost +and region preview docking and `craft_ui::panes` both draw) for apps that draw their own windows. ## Tab strips @@ -227,16 +226,44 @@ 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. The - side zones are trapezoids along the four edges of the body below the tab strip, like a picture - frame, all four the same depth: `DockStyle::zone_depth` (40 pt), or a quarter of the body's - smaller side when that is less, so every frame keeps a center. A frame too narrow or short for two - halves of `DockStyle::min_pane` still splits when the frames beyond it can give the room, as they - do for a dragged divider; when they cannot, the drop 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. +- Over a group (a frame, a dock column's group or a floating group), the pointer's place + decides: + - The tab strip joins the group's tabs at the insertion slot under the pointer. + - In the body below the strip, the outer `DockStyle::side_zone_fraction` (one third) of its + width from the left or right edge, or of its height from the top or bottom edge, docks + beside the group on that side. Where two sides meet, at a corner, the side the pointer is + relatively nearer wins. + - The middle third joins the group's tabs. + - A group that takes no tabs, such as a protected canvas, has no middle: every point of its + body docks beside it on the relatively nearest side. Its strip takes nothing. +- Beside a frame is a split of that frame. Beside a group in a dock column, the top and bottom + stack a new group above or below it, and the left and right make a new column on that side. +- A frame too narrow or short for two halves of `DockStyle::min_pane` still splits when the + frames beyond it can give the room, as they do for a dragged divider. When they cannot, that + side joins the frame's tabs instead, or takes nothing when the frame takes no tabs. The gap + between two frames is no target. +- Bands along borders come before the group zones. Each is centred on its border where the + pointer can reach both sides: + + | Band | Drop | Field (default) | + |---|---|---| + | The seam between two stacked groups of a dock column | A new group between them | `seam_band` (28 pt) | + | Each edge of a dock column | A new column on that side | `column_edge_band` (28 pt) | + | The docked area's left or right edge without a dock, and an empty dock | A new dock | `new_dock_band` (28 pt) | + | The layout's top and bottom edges (and left and right with `side_edge_zones`) | A new frame across the whole window side, a quarter of the window | `edge_band` (28 pt, 8 pt with `side_edge_zones`; at most a quarter of the layout) | + + Where a band overlaps a group's outer third, both mean the same side. Without + `side_edge_zones`, a band reaches only 4 pt past its line into a tab strip that takes tabs; + the rest of the strip joins. The outer + edge band is offered only when every frame keeps its minimum. +- With `side_edge_zones` (the split-tree arrangement), a frame's zones are a picture frame + instead: trapezoids along the four edges of the body below the tab strip, all four the same + depth, `docking::SIDE_EDGE_ZONE_DEPTH` (40 pt), or a quarter of the body's smaller side when + that is less, so every frame keeps a center. A dock column's groups take the same depth. + `drop_zone` and `resolve_drop` give these zones to hosts that draw their own frames. +- A host that resolves drops over its own groups calls `resolve_drop_by` with + `DockStyle::zone_rule()`, so its zones and previews match the dock area's in either profile. + `resolve_drop` always gives the split-tree zones. - A dragged divider grows the frames that touch it on one side, up to their maximums. On the other side the frame touching it shrinks to its minimum, then the next frame along the same axis, and so on: the divider stops when every frame on that side is at its minimum. Frames @@ -261,13 +288,21 @@ A flyout fits its content the same way, without the title bar. way. Towards the minimums, groups give way in proportion to their room. A group takes the largest default, compact and fill height of its panels. All three are body heights, without the tab strip, like `PanelLimits`. -- Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, - a filled half for a split. With `side_edge_zones`, a drag over a group instead outlines each of - its zones that a drop would change something in (the side trapezoids, the center rectangle and - the tab strip; `zone_shape` gives the outlines) and fills the zone under the pointer. Sides the - group does not take, by its permissions or because a split leaves no room, are not drawn; nor is - the center of the group a tab comes from. Escape or losing window focus cancels - a drag. +- Drop previews have two shapes, the same for tab and group drags: + - A drop beside something shows a `DockStyle::drop_line_width` (4 pt) accent line along the + whole edge it docks to: a group's edge for a split or a new group above or below it, the + column's width for a seam, the dock's height for a new column or dock, and the layout's + whole edge for the outer edge band. The line's length shows what the drop docks beside. + - A drop that joins a group shows a 2 pt accent outline of the whole group, with a light fill, + and on its strip the 2 pt insertion marker. + + A group drag also shows its ghost at the pointer. With `side_edge_zones`, a drag over a group + instead outlines each of its zones that a drop would change something in (the side + trapezoids, the center rectangle and the tab strip; `zone_shape` gives the outlines) and + fills the zone under the pointer. Sides the group does not take, by its permissions or + because a split leaves no room, are not drawn; nor is the center of the group a tab comes + from. A drop there that the zones do not show, such as the outer edge band, fills the region + it takes. Escape or losing window focus cancels a drag. - A drag in progress (a tab, group, column, window bar, native window bar or divider, in any of the app's windows) also ends when the layout passed to the area is not the one it drew last frame, with or without its returned actions applied: the app switched workspaces, ran Reset