From 40c59bb577b225664a1a13e3d13a92df6119ae81 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 16:40:59 +0000 Subject: [PATCH] docking: narrow frames keep usable side drop zones, and seams split A side zone is a quarter of the frame deep, at least 32 pt and at most half the frame, so a 64 pt tool strip offers its left and right edges past the outer edge band. A pointer in the gap between frames resolves to the nearest frame's edge instead of floating. --- crates/ui/src/docking/frame_tests.rs | 120 ++++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 2 + crates/ui/src/docking/render/preview.rs | 20 +++- crates/ui/src/docking/targets.rs | 22 +++-- docs/ui-docking.md | 4 + 5 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 crates/ui/src/docking/frame_tests.rs diff --git a/crates/ui/src/docking/frame_tests.rs b/crates/ui/src/docking/frame_tests.rs new file mode 100644 index 00000000..4d8c55f4 --- /dev/null +++ b/crates/ui/src/docking/frame_tests.rs @@ -0,0 +1,120 @@ +//! 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, 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, +} + +impl DockContent 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; + +fn tabs(panels: &[u32]) -> Node { + Node::Tabs { panels: panels.to_vec(), active: 0 } +} + +fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node { + Node::Split { axis, size, first: Box::new(first), second: Box::new(second) } +} + +fn hsplit(size: SplitSize, first: Node, second: Node) -> Node { + split(SplitAxis::Horizontal, size, first, second) +} + +/// Tools | Timeline | Audio Meters. +fn tools_row() -> Node { + 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 { + 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) -> 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 { + 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 + ); +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index b8462ec3..ba6651d2 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -42,6 +42,8 @@ mod dock_tests; #[cfg(test)] mod floating_tests; #[cfg(test)] +mod frame_tests; +#[cfg(test)] mod group_tests; #[cfg(test)] mod iconic_tests; diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 4349e14d..7fb51f5b 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -88,6 +88,7 @@ impl Renderer<'_, P> { return valid(proposal); } let own = |t: &crate::docking::DropTarget

| 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) { @@ -106,6 +107,22 @@ impl Renderer<'_, P> { valid(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; + } + 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)) + } + /// Release of a group drag: join or split at the target, or float at the ghost with the /// group's size. Over its own docked position nothing changes; a floating group released /// over itself moves there. @@ -230,7 +247,8 @@ impl 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, pointer, &self.targets)) + self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false) + .or_else(|| resolve_drop(&drag.panel, self.seam_pointer(pointer), &self.targets)) }; if released { cancel_drag::

(ctx, self.id); diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index 3f946153..ff515ec0 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -31,8 +31,13 @@ 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 is the center; otherwise the nearest quarter-width/height edge wins. -/// Outside, non-finite, empty and inverted rectangles have no target. +/// Least depth of a side zone, so a narrow frame such as a tool strip keeps side targets wide +/// enough to hit. +const MIN_SIDE_ZONE: f32 = 32.0; + +/// Tab header is the center; otherwise the nearest side zone wins. A side zone is a quarter of +/// the frame deep, at least 32 pt and at most half the frame, so a frame up to 64 pt wide has no +/// center. Outside, non-finite, empty and inverted rectangles have no target. pub fn drop_zone(rect: Rect, tab_height: f32, pointer: Pos2) -> Option { if !usable(rect) || !pointer.is_finite() || !rect.contains(pointer) || !tab_height.is_finite() || tab_height < 0.0 { return None; @@ -40,11 +45,16 @@ pub fn drop_zone(rect: Rect, tab_height: f32, pointer: Pos2) -> Option { if pointer.y < rect.top() + tab_height.min(rect.height()) { return Some(Zone::Center); } - let x = (pointer.x - rect.left()) / rect.width(); - let y = (pointer.y - rect.top()) / rect.height(); - let distances = [(x, Zone::Left), (1.0 - x, Zone::Right), (y, Zone::Top), (1.0 - y, Zone::Bottom)]; + let depth = |extent: f32| (extent * 0.25).max(MIN_SIDE_ZONE).min(extent * 0.5); + let (x, y) = (depth(rect.width()), depth(rect.height())); + let distances = [ + ((pointer.x - rect.left()) / x, Zone::Left), + ((rect.right() - pointer.x) / x, Zone::Right), + ((pointer.y - rect.top()) / y, Zone::Top), + ((rect.bottom() - pointer.y) / y, Zone::Bottom), + ]; let (distance, zone) = distances.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; - Some(if distance > 0.25 { Zone::Center } else { zone }) + Some(if distance > 1.0 { Zone::Center } else { zone }) } /// Preview of the region occupied after a split or group merge. diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 79a0ec71..e3f6255f 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -186,6 +186,10 @@ 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. - 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. - Double-click a tab or drag bar to minimize a group to its strip.