mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-08 03:23:24 +00:00
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:
1 parent
2fa4ad79d7
commit
40c59bb577
5 files changed
+161
-7
No files matched your search
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user