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.
This commit is contained in:
aiongg committed 2026-10-21 16:40:59 +00:00
1 parent 2fa4ad79d7
commit 40c59bb577
5 files changed
+161 -7

No files matched your search

+120
View File
@@ -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<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
);
}
+2
View File
@@ -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;
+19 -1
View File
@@ -88,6 +88,7 @@ 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) {
@@ -106,6 +107,22 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<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, 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::<P>(ctx, self.id);
+16 -6
View File
@@ -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<Zone> {
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<Zone> {
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.
+4
View File
@@ -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.