Merge branch 'mac-b1-edges' into dock-result-preview

This commit is contained in:
aiongg committed 2026-10-25 07:01:22 +00:00
commit cdc6713130
13 files changed
+347 -91

No files matched your search

+27 -9
View File
@@ -340,7 +340,24 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
/// [`super::float_space`] of the area that draws `workspace`. Given `x`, `y`, `width` and
/// `height` replace the matching values, and a group or column that is already a whole
/// floating window keeps its rect.
///
/// An outer edge dock wraps the part between the docks. [`Self::to_actions_with`] also
/// knows which panels are protected, so it wraps the canvas column where a drag would.
pub fn to_actions(&self, workspace: &Workspace<P>, right_to_left: bool, space: &FloatSpace<P>) -> Result<Vec<Action<P>>, DockError> {
self.to_actions_with(workspace, right_to_left, space, |_| Permissions::default())
}
/// [`Self::to_actions`] with the `permissions` the actions are applied with. An outer edge
/// dock wraps what a drag to that edge would: the canvas column where the area narrows its
/// edge band to it, else the part between the docks. `space` says whether the area draws
/// with [`super::DockStyle::side_edge_zones`], which never narrows.
pub fn to_actions_with(
&self,
workspace: &Workspace<P>,
right_to_left: bool,
space: &FloatSpace<P>,
permissions: impl Fn(&P) -> Permissions,
) -> Result<Vec<Action<P>>, DockError> {
let layout = &workspace.layout;
let zoned = Zoned { layout, right_to_left };
let shown = |panel: &P| if layout.contains(panel) { Ok(()) } else { Err(DockError::MissingPanel) };
@@ -418,12 +435,13 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
if before.is_some() {
return Err(DockError::InvalidPlacement);
}
let anchor = layout
.panels()
.into_iter()
.find(|p| !members.contains(p) && matches!(layout.place(p), Some(Place::Dock { .. } | Place::Node(_))))
.cloned()
.ok_or(DockError::MissingTarget)?;
// The model narrows the edge on a protected anchor, as `Workspace::apply` checks it.
let protected = |p: &P| {
let policy = if workspace.locked { permissions(p).locked() } else { permissions(p) };
policy.is_protected()
};
let narrowed = !space.side_edge_zones && layout.edge_narrows(zone, &protected);
let anchor = layout.edge_anchor(narrowed, &protected, &|p| members.contains(p)).ok_or(DockError::MissingTarget)?;
return one(unit.to(layout, panel, Target::At { anchor, placement: Placement::Edge(zone) })?);
}
if !matches!(layout.place(panel), Some(Place::Floating { .. })) {
@@ -504,7 +522,7 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
/// location, and a panel never laid out opens as [`Action::Open`] without an anchor does
/// ([`Workspace::set_visible`]); Lock sets [`Workspace::locked`]. Application-owned commands
/// ([`Self::is_app_owned`]) return [`DockError::NotAllowed`] and change nothing.
/// `space` is where `window.panel.float` floats, as for [`Self::to_actions`].
/// `space` is where `window.panel.float` floats, as for [`Self::to_actions_with`].
pub fn apply(
&self,
workspace: &mut Workspace<P>,
@@ -526,7 +544,7 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
// Shown, then moved to the edge, all or nothing.
let mut shown = workspace.clone();
shown.set_visible(panel.clone(), true, None, &permissions)?;
let closed = shown.apply_all(self.to_actions(&shown, right_to_left, space)?, permissions)?;
let closed = shown.apply_all(self.to_actions_with(&shown, right_to_left, space, &permissions)?, &permissions)?;
*workspace = shown;
return Ok(closed);
}
@@ -534,7 +552,7 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
workspace.locked = locked.unwrap_or(!workspace.locked);
return Ok(Vec::new());
}
let actions = self.to_actions(workspace, right_to_left, space)?;
let actions = self.to_actions_with(workspace, right_to_left, space, &permissions)?;
workspace.apply_all(actions, permissions)
}
}
+41 -6
View File
@@ -45,7 +45,7 @@ fn parse(params: Value) -> Result<DockCommand<P>, serde_json::Error> {
/// The actions `params` makes on a fresh workspace.
fn actions(params: Value) -> Vec<Action<P>> {
let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}"));
command.to_actions(&workspace(), false, &space()).unwrap_or_else(|e| panic!("{params}: {e}"))
command.to_actions_with(&workspace(), false, &space(), permissions).unwrap_or_else(|e| panic!("{params}: {e}"))
}
/// Apply `params` to a fresh workspace; return it and the panels closed.
@@ -294,7 +294,7 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() {
fn side_zones_map_to_the_placement_the_anchor_container_takes_in_either_direction() {
let command =
|zone: &str, anchor: &str| parse(json!({"command": "window.panel.move", "panel": "history", "anchor": anchor, "zone": zone})).unwrap();
let placed = |zone: &str, anchor: &str, rtl: bool| match command(zone, anchor).to_actions(&workspace(), rtl, &space()) {
let placed = |zone: &str, anchor: &str, rtl: bool| match command(zone, anchor).to_actions_with(&workspace(), rtl, &space(), permissions) {
Ok(actions) => match actions.as_slice() {
[Action::Move { placement, .. }] => Ok(placement.clone()),
other => panic!("{other:?}"),
@@ -338,7 +338,7 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() {
let d = testing::DockDriver::new(workspace().layout, Sized).with_area(area);
let space = float_space::<P>(&d.ctx, d.id).expect("the area records its float space");
assert_eq!(space.area, area);
let float = |params: Value| parse(params).unwrap().to_actions(&workspace(), false, &space).unwrap();
let float = |params: Value| parse(params).unwrap().to_actions_with(&workspace(), false, &space, permissions).unwrap();
let panel = float(json!({"command": "window.panel.float", "panel": "layers"}));
// The floating_size width, and an empty body's least fitted height under its 150 points.
@@ -359,19 +359,25 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() {
// Without recorded sizes a unit floats at the layout's size, shrunk into a small area.
let small = FloatSpace::new(egui::Rect::from_min_size(egui::pos2(0.0, 30.0), egui::vec2(200.0, 200.0)));
let applied = parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions(&workspace(), false, &small);
let applied = parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions_with(
&workspace(),
false,
&small,
permissions,
);
assert_eq!(applied, Ok(vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: Some([0.0, 30.0, 200.0, 200.0]) } }]));
// A host seeds the size of a panel the area never drew; a recorded size still wins.
let seeded = FloatSpace::new(area).with_panel_size(s("layers"), egui::vec2(180.0, 240.0));
let applied = parse(json!({"command": "window.panel.float", "panel": "layers"})).unwrap().to_actions(&workspace(), false, &seeded);
let applied =
parse(json!({"command": "window.panel.float", "panel": "layers"})).unwrap().to_actions_with(&workspace(), false, &seeded, permissions);
assert_eq!(applied, Ok(vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 180.0, 240.0] }]));
let recorded = space.clone().with_panel_size(s("layers"), egui::vec2(180.0, 240.0));
assert_eq!(float_with(&recorded, "layers"), vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 120.0] }]);
}
fn float_with(space: &FloatSpace<P>, panel: &str) -> Vec<Action<P>> {
parse(json!({"command": "window.panel.float", "panel": panel})).unwrap().to_actions(&workspace(), false, space).unwrap()
parse(json!({"command": "window.panel.float", "panel": panel})).unwrap().to_actions_with(&workspace(), false, space, permissions).unwrap()
}
#[test]
@@ -411,6 +417,35 @@ fn dock_with_a_side_zone_and_no_anchor_makes_an_outer_edge_frame() {
rejected(json!({"command": "window.panel.dock", "panel": "color", "zone": "left", "before": null}), DockError::InvalidPlacement);
}
/// The frame beside `panel`'s own: what an edge split with `panel` wrapped.
fn wrapped(layout: &Layout<P>, panel: &str) -> Option<Node<P>> {
let Some(dock_model::Place::Node(path)) = layout.place(&s(panel)) else { return None };
let (last, parent) = path.split_last()?;
layout.root.as_ref().and_then(|root| dock_model::node_at(root, &[parent, &[!last]].concat())).cloned()
}
#[test]
fn an_outer_edge_dock_wraps_the_canvas_column_whatever_the_tree_order() {
let tabs = |panel: &str| Node::Tabs { panels: vec![s(panel)], active: 0 };
let side =
|first, second| Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) };
let canvas_first = side(tabs("canvas"), side(tabs("layers"), tabs("history")));
let canvas_later = side(side(tabs("layers"), tabs("canvas")), tabs("history"));
for root in [canvas_first, canvas_later] {
let layout = Layout { root: Some(root), floating: vec![Floating::single(vec![s("color")], 0, [300.0, 200.0, 200.0, 150.0])] };
let command = parse(json!({"command": "window.panel.dock", "panel": "color", "zone": "top"})).unwrap();
let mut workspace = Workspace::new(layout.clone());
command.apply(&mut workspace, false, &space(), permissions).unwrap();
assert_eq!(wrapped(&workspace.layout, "color"), Some(tabs("canvas")), "{:?}", workspace.layout);
// An area drawn with side edge zones never narrows its edge to the canvas column.
let mut workspace = Workspace::new(layout.clone());
command.apply(&mut workspace, false, &space().with_side_edge_zones(true), permissions).unwrap();
let mut whole = layout;
whole.floating.clear();
assert_eq!(wrapped(&workspace.layout, "color"), whole.root, "{:?}", workspace.layout);
}
}
#[test]
fn dock_to_the_side_a_panel_already_leads_the_last_column_of() {
// Layers leads the dock's only column: it moves out to a new column after it.
+34 -10
View File
@@ -543,20 +543,28 @@ impl<P: Clone + Eq + Hash> Layout<P> {
None
}
/// The node an edge split wraps: the root, or the part between the docks. Where neither side
/// of a side-by-side split is a dock and only one holds a `canvas` panel, that side: the
/// canvas column, without the side frames.
pub(in crate::docking) fn edge_path(&self, canvas: &dyn Fn(&P) -> bool) -> Vec<bool> {
/// The node an edge split at `zone` wraps: the root, or the part between the docks. Where
/// neither side of a side-by-side split is a dock and only one holds a `canvas` panel,
/// that side: the canvas column, without the side frames. A top (bottom) edge also
/// descends a split of rows into its first (last) row when only that row holds a `canvas`
/// panel, so a full-width row below (above) the canvas keeps its edge.
pub(in crate::docking) fn edge_path(&self, zone: Zone, canvas: &dyn Fn(&P) -> bool) -> Vec<bool> {
let holds = |node: &Node<P>| tree_panels(node).into_iter().any(canvas);
let mut path = Vec::new();
let mut node = self.root.as_ref();
while let Some(Node::Split { axis: SplitAxis::Horizontal, first, second, .. }) = node {
while let Some(Node::Split { axis, first, second, .. }) = node {
if path.len() > MAX_DEPTH {
break;
}
let second_side = match (&**first, &**second) {
(Node::Dock { .. }, _) => true,
(_, Node::Dock { .. }) => false,
_ => match (tree_panels(first).into_iter().any(canvas), tree_panels(second).into_iter().any(canvas)) {
let second_side = match (axis, &**first, &**second) {
(SplitAxis::Horizontal, Node::Dock { .. }, _) => true,
(SplitAxis::Horizontal, _, Node::Dock { .. }) => false,
(SplitAxis::Vertical, ..) => match (zone, holds(first), holds(second)) {
(Zone::Top, true, false) => false,
(Zone::Bottom, false, true) => true,
_ => break,
},
_ => match (holds(first), holds(second)) {
(true, false) => false,
(false, true) => true,
_ => break,
@@ -568,6 +576,22 @@ impl<P: Clone + Eq + Hash> Layout<P> {
path
}
/// Whether an edge drop at `zone` that narrows by `canvas` wraps less than one that does
/// not: the edge area is narrower than the part between the docks.
pub(in crate::docking) fn edge_narrows(&self, zone: Zone, canvas: &dyn Fn(&P) -> bool) -> bool {
self.edge_path(zone, canvas) != self.edge_path(zone, &|_| false)
}
/// The docked panel, other than `skip`, that a [`Placement::Edge`] anchors on so the drop
/// wraps the edge area it shows: a `protected` one where that area is narrowed to the
/// canvas column (`narrowed`), else one that is not protected, since a protected anchor
/// narrows the model's edge. Any docked panel when every one is protected.
pub(in crate::docking) fn edge_anchor(&self, narrowed: bool, protected: &dyn Fn(&P) -> bool, skip: &dyn Fn(&P) -> bool) -> Option<P> {
let docked: Vec<&P> =
self.panels().into_iter().filter(|p| !skip(p) && matches!(self.place(p), Some(Place::Dock { .. } | Place::Node(_)))).collect();
docked.iter().find(|p| protected(p) == narrowed).or(docked.first()).map(|p| (*p).clone())
}
pub(super) fn columns_mut(&mut self, path: &[bool]) -> Option<&mut Vec<Column<P>>> {
match self.root.as_mut().and_then(|root| node_at_mut(root, path)) {
Some(Node::Dock { columns, .. }) => Some(columns),
@@ -866,7 +890,7 @@ impl<P: Clone + Eq + Hash> Layout<P> {
return Err(DockError::InvalidPlacement);
}
// A protected anchor narrows the edge to the canvas column.
let path = if canvas(&anchor) { self.edge_path(canvas) } else { self.edge_path(&|_| false) };
let path = if canvas(&anchor) { self.edge_path(zone, canvas) } else { self.edge_path(zone, &|_| false) };
let node = self.root.as_mut().and_then(|root| node_at_mut(root, &path)).ok_or(DockError::MissingTarget)?;
if matches!(node, Node::Dock { .. }) {
return Err(DockError::InvalidPlacement);
+53
View File
@@ -262,6 +262,59 @@ fn between_docks_the_outer_edge_band_covers_the_part_between_them() {
assert_eq!(**first, split(SplitAxis::Vertical, SplitSize::Ratio(0.75), middle, tabs(&[3])));
}
/// The frame beside `panel`'s own: what an edge split with `panel` wrapped.
fn wrapped(layout: &Layout<u32>, panel: u32) -> Option<Node<u32>> {
let Some(dock_model::Place::Node(path)) = layout.place(&panel) else { return None };
let (last, parent) = path.split_last()?;
layout.root.as_ref().and_then(|root| dock_model::node_at(root, &[parent, &[!last]].concat())).cloned()
}
#[test]
fn over_a_full_width_row_the_top_edge_band_covers_only_the_canvas_column_above_it() {
// SoundCraft's Edit window: side frame 1, canvas 0 and side frame [2, 3] over a full-width
// editor 4.
let Some(upper) = canvas_columns().root else { panic!() };
let root = split(SplitAxis::Vertical, SplitSize::Ratio(0.7), upper.clone(), tabs(&[4]));
let layout = Layout { root: Some(root), floating: Vec::new() };
let driver = || DockDriver::new(layout.clone(), Bodies { rules: [(0, Permissions::PROTECTED)].into(), ..Bodies::default() });
let d = driver();
let (canvas, _) = group(&d, 0);
let at = pos2(canvas.center().x, 5.0);
let mut d = driver();
d.hold(tab(&d, 3), at);
let preview = result_preview(&d).unwrap_or_else(|| panic!("no result preview"));
assert_eq!(preview.x_range(), canvas.shrink(1.0).x_range(), "the canvas column's width, not the window's");
let out = d.release(at);
d.settle();
assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 0, placement: Placement::Edge(Zone::Top) }]);
assert_eq!(wrapped(&d.layout, 3), Some(tabs(&[0])), "{:?}", d.layout);
assert_eq!(preview, group(&d, 3).1.shrink(1.0));
}
#[test]
fn an_outer_edge_drop_wraps_the_area_it_previews_when_the_app_applies_other_permissions() {
// Frame [2, 3] and canvas 0 beside frame 1, which is protected only while drawing (PDFCraft's
// bare tools mode): drawn, the edge band is not narrowed to the canvas.
let inner = split(SplitAxis::Horizontal, SplitSize::Ratio(0.5), Node::Tabs { panels: vec![2, 3], active: 1 }, tabs(&[0]));
let layout = Layout { root: Some(split(SplitAxis::Horizontal, SplitSize::Ratio(0.7), inner, tabs(&[1]))), floating: Vec::new() };
let drawn = [(0, Permissions::PROTECTED), (1, Permissions::PROTECTED)];
let driver = || DockDriver::new(layout.clone(), Bodies { rules: drawn.into(), ..Bodies::default() });
let d = driver();
let (canvas, _) = group(&d, 0);
let at = pos2(canvas.center().x, 5.0);
let mut d = driver();
d.hold(tab(&d, 3), at);
let preview = result_preview(&d).unwrap_or_else(|| panic!("no result preview"));
assert_eq!(preview.x_range(), Rect::from_x_y_ranges(0.0..=800.0, 0.0..=1.0).shrink2(egui::vec2(1.0, 0.0)).x_range(), "the window's width");
let out = d.release(at);
let mut applied = layout.clone();
let apply = |p: &u32| if *p == 0 { Permissions::PROTECTED } else { Permissions::default() };
applied.apply_all(out.actions.clone(), apply).unwrap_or_else(|e| panic!("{e}: {:?}", out.actions));
let mut rest = layout;
rest.apply(Action::Close { panel: 3 }).unwrap();
assert_eq!(wrapped(&applied, 3), rest.root, "{:?}", out.actions);
}
#[test]
fn drop_edges_limit_the_outer_edges_and_the_canvas_sides() {
let left_right = |style: &mut DockStyle| style.drop_edges = Sides::LEFT_RIGHT;
+3 -2
View File
@@ -328,8 +328,9 @@ pub enum Placement<P> {
/// root is split, or in a layout with docks, the part between them. The anchor names any
/// docked panel. A protected anchor ([`Permissions::is_protected`], applied through
/// [`Layout::apply_with_permissions`]) narrows the part to the canvas column: where neither
/// side of a side-by-side split is a dock and only one holds a protected panel, that side.
/// `Center` is invalid.
/// side of a side-by-side split is a dock and only one holds a protected panel, that side;
/// for `Top` (`Bottom`), also the first (last) row of a split of rows when only that row
/// holds one. `Center` is invalid.
Edge(Zone),
}
+7 -4
View File
@@ -2205,10 +2205,13 @@ fn a_tab_drag_onto_a_native_window_is_no_app_drag() {
#[test]
fn a_native_window_fades_only_while_its_drag_can_dock() {
let drag = |docks| WindowDrag { window: 1, grab: vec2(10.0, 10.0), to: pos2(100.0, 100.0), start: pos2(100.0, 100.0), docks };
assert_eq!(drag_opacity(None, true), 1.0);
assert_eq!(drag_opacity(None, false, true), 1.0);
// A bar drag that only moves the window stays opaque.
assert_eq!(drag_opacity(Some(&drag(false)), false), 1.0);
assert_eq!(drag_opacity(Some(&drag(false)), true), 0.75);
assert_eq!(drag_opacity(Some(&drag(false)), false, false), 1.0);
assert_eq!(drag_opacity(Some(&drag(false)), false, true), 0.75);
// A drag by the window's only tab or group docks either way.
assert_eq!(drag_opacity(Some(&drag(true)), false), 0.75);
assert_eq!(drag_opacity(Some(&drag(true)), false, false), 0.75);
// With Cmd/Ctrl held the drag only moves the window.
assert_eq!(drag_opacity(Some(&drag(false)), true, true), 1.0);
assert_eq!(drag_opacity(Some(&drag(true)), true, false), 1.0);
}
+106 -39
View File
@@ -7,8 +7,8 @@ use egui::{Context, Pos2, Rect, pos2, vec2};
use super::{GroupKind, Renderer, floating::WindowHit, group::TabGroup};
use crate::docking::{
Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Zone,
dock_model::{Place, node_at},
Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Placement, Target, Zone,
dock_model::{Place, node_at, tree_panels},
};
/// How far below the pointer the float hint sits, clear of the cursor.
@@ -20,14 +20,40 @@ pub(in crate::docking) struct FrameState<P> {
paths: Option<HashMap<Vec<bool>, Vec<bool>>>,
/// The same for each floating window's splits, by the window's first shown panel.
window_paths: HashMap<P, HashMap<Vec<bool>, Vec<bool>>>,
/// The view node whose rect is the outer edge area; none while a frame is maximized.
edge_path: Option<Vec<bool>>,
pub(super) edge: Option<Rect>,
/// The outer edge areas of top, bottom, left and right drops; none while a frame is
/// maximized.
edges: Option<[EdgeArea<P>; 4]>,
/// The panels the view leaves out.
hidden: Vec<P>,
pub(super) maximized: Option<P>,
/// Floating windows as drawn, back to front.
pub(super) windows: Vec<WindowHit<P>>,
}
/// The view node an outer edge drop wraps ([`Layout::edge_path`]), and its rect once drawn.
struct EdgeArea<P> {
path: Vec<bool>,
/// The node is narrower than the part between the docks: the canvas column.
narrowed: bool,
panels: Vec<P>,
rect: Option<Rect>,
}
impl<P: Clone + Eq + Hash> EdgeArea<P> {
fn of(view: &Layout<P>, zone: Zone, canvas: &dyn Fn(&P) -> bool) -> Self {
let path = view.edge_path(zone, canvas);
let panels =
view.root.as_ref().and_then(|root| node_at(root, &path)).map_or(Vec::new(), |node| tree_panels(node).into_iter().cloned().collect());
Self { narrowed: view.edge_narrows(zone, canvas), path, panels, rect: None }
}
}
const EDGES: [Zone; 4] = [Zone::Top, Zone::Bottom, Zone::Left, Zone::Right];
fn edge_areas<P: Clone + Eq + Hash>(view: &Layout<P>, canvas: &dyn Fn(&P) -> bool) -> [EdgeArea<P>; 4] {
EDGES.map(|zone| EdgeArea::of(view, zone, canvas))
}
/// The layout as shown: a maximized frame alone, and hidden panels left out. `narrow` narrows
/// the outer edge area to the canvas column ([`Layout::edge_path`]).
pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>(
@@ -54,11 +80,12 @@ pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>(
}
Place::Floating { .. } => None,
});
let mut state = FrameState { paths: None, window_paths: HashMap::new(), edge_path: None, edge: None, maximized: None, windows: Vec::new() };
let mut state = FrameState { paths: None, window_paths: HashMap::new(), edges: None, hidden: Vec::new(), maximized: None, windows: Vec::new() };
if maximized.is_none() && matches!(hide, HidePanels::None) {
state.edge_path = Some(layout.edge_path(&canvas));
state.edges = Some(edge_areas(layout, &canvas));
return (Cow::Borrowed(layout), state);
}
state.hidden = layout.panels().into_iter().filter(|p| hidden(p)).cloned().collect();
let mut paths = HashMap::new();
let view = match maximized {
Some((panel, path, node)) => {
@@ -81,7 +108,7 @@ pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>(
}
};
if state.maximized.is_none() {
state.edge_path = Some(view.edge_path(&canvas));
state.edges = Some(edge_areas(&view, &canvas));
}
state.paths = Some(paths);
(Cow::Owned(view), state)
@@ -156,8 +183,11 @@ fn prune_group<P: Clone + PartialEq>(group: &Group<P>, hidden: &dyn Fn(&P) -> bo
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(super) fn note_edge(&mut self, path: &[bool], rect: Rect) {
if self.tree.is_none() && self.frame.edge_path.as_deref() == Some(path) {
self.frame.edge = Some(rect);
if self.tree.is_some() {
return;
}
for area in self.frame.edges.iter_mut().flatten().filter(|area| area.path == path) {
area.rect = Some(rect);
}
}
@@ -177,38 +207,33 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// 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 = 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 sides = self.style.outer_edges();
let edges = [
(pointer.y - area.top(), Zone::Top),
(area.bottom() - pointer.y, Zone::Bottom),
(pointer.x - area.left(), Zone::Left),
(area.right() - pointer.x, Zone::Right),
];
let (distance, zone) = edges.into_iter().filter(|(_, zone)| sides.contains(*zone)).min_by(|a, b| a.0.total_cmp(&b.0))?;
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;
}
// A protected anchor narrows the model's edge to the canvas column, as `edge_path`
// narrows the band without side edge zones.
let narrow = !self.style.side_edge_zones;
let docked: Vec<&P> = self
.layout
.panels()
let (distance, zone, area) = EDGES
.into_iter()
.filter(|p| !members.contains(p) && !matches!(self.place(p), Some(Place::Floating { .. }) | None))
.collect();
let anchor = docked.iter().find(|p| self.permissions(p).is_protected() == narrow).or(docked.first())?;
let anchor = (*anchor).clone();
.filter(|zone| sides.contains(*zone))
.filter_map(|zone| {
let area = self.edge_area(zone)?.rect.filter(|area| area.contains(pointer))?;
let distance = match zone {
Zone::Top => pointer.y - area.top(),
Zone::Bottom => area.bottom() - pointer.y,
Zone::Left => pointer.x - area.left(),
Zone::Right | Zone::Center => area.right() - pointer.x,
};
Some((distance, zone, area))
})
.min_by(|a, b| a.0.total_cmp(&b.0))?;
let band = if self.style.side_edge_zones { crate::docking::targets::SIDE_EDGE_BAND } else { self.style.edge_band };
if distance > band.min(area.width() / 4.0).min(area.height() / 4.0) {
return None;
}
let line = crate::docking::targets::edge_line(area, zone, self.style.drop_line_width);
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.is_some_and(reach) {
return None;
}
let anchor = self.edge_anchor(members, zone)?;
if !self.style.side_edge_zones {
return Some((anchor, zone, line(zone)?));
return Some((anchor, zone, line?));
}
let size = area.size() * 0.25;
let preview = match zone {
@@ -220,6 +245,48 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
Some((anchor, zone, preview))
}
fn edge_area(&self, zone: Zone) -> Option<&EdgeArea<P>> {
self.frame.edges.as_ref()?.get(EDGES.iter().position(|edge| *edge == zone)?)
}
/// The docked anchor, not one of `members`, of an outer edge drop at `zone` that wraps the
/// edge area drawn: protected where that area is the canvas column, as the model narrows on
/// a protected anchor, else not. The drawn area and the model's can still differ (hidden
/// panels, a canvas test that differs from these permissions), so the drop is tried: an
/// anchor whose drop wraps other panels than the area shows gives way to one that is not
/// protected, and then to no edge target.
fn edge_anchor(&self, members: &[P], zone: Zone) -> Option<P> {
let area = self.edge_area(zone)?;
let protected = |p: &P| self.permissions(p).is_protected();
let skip = |p: &P| members.contains(p);
let lead = members.first()?;
let wraps_area = |anchor: &P| {
let to = Target::At { anchor: anchor.clone(), placement: Placement::Edge(zone) };
let action = match members {
[_] => Action::Move { panel: lead.clone(), anchor: anchor.clone(), placement: Placement::Edge(zone) },
_ => Action::MoveGroup { panel: lead.clone(), to },
};
let mut candidate = self.layout.clone();
if candidate.apply_with_permissions(action, |p| self.permissions(p)).is_err() {
// Left to the model's check.
return true;
}
let Some(Place::Node(path)) = candidate.place(lead) else { return false };
let Some((last, parent)) = path.split_last() else { return false };
let beside = [parent, &[!last]].concat();
let Some(wrapped) = candidate.root.as_ref().and_then(|root| node_at(root, &beside)) else { return false };
let shown = |p: &&P| !self.frame.hidden.contains(p) && !members.contains(p);
let wrapped: Vec<&P> = tree_panels(wrapped).into_iter().filter(shown).collect();
let drawn: Vec<&P> = area.panels.iter().filter(shown).collect();
wrapped.len() == drawn.len() && wrapped.iter().all(|p| drawn.contains(p))
};
let anchor = self.layout.edge_anchor(area.narrowed, &protected, &skip)?;
if wraps_area(&anchor) {
return Some(anchor);
}
self.layout.edge_anchor(false, &protected, &skip).filter(|other| *other != anchor && !protected(other) && wraps_area(other))
}
/// Under the pointer while a drag offers a docking target: which modifier floats the drop
/// instead. With native windows, the window under the pointer draws it, beside its preview.
pub(super) fn paint_float_hint(&self, ctx: &Context, pointer: Pos2) {
+6 -4
View File
@@ -95,11 +95,12 @@ pub(in crate::docking) const OTHER_CLOSE: CloseLayout =
const DRAG_OPACITY: f64 = 0.75;
/// Opacity of the window `drag` moves: faded while the drag can dock (its only tab or group, or
/// a bar with [`crate::docking::DockStyle::title_bar_docks`]), opaque for a plain move.
/// a bar with [`crate::docking::DockStyle::title_bar_docks`]), opaque for a plain move and while
/// Ctrl/Cmd keeps the drop floating (`float_only`).
#[cfg(any(target_os = "macos", test))]
pub(in crate::docking) fn drag_opacity(drag: Option<&WindowDrag>, title_bar_docks: bool) -> f64 {
pub(in crate::docking) fn drag_opacity(drag: Option<&WindowDrag>, float_only: bool, title_bar_docks: bool) -> f64 {
match drag {
Some(drag) if drag.docks || title_bar_docks => DRAG_OPACITY,
Some(drag) if !float_only && (drag.docks || title_bar_docks) => DRAG_OPACITY,
_ => 1.0,
}
}
@@ -594,7 +595,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let drag = ctx.data(|d| d.get_temp::<WindowDrag>(drag_key));
let dragged = drag.as_ref().and_then(|drag| self.layout.floating.iter().find(|w| w.id == drag.window));
let this = dragged.and_then(|w| crate::docking::dock_model::first_active(&w.root)).is_some_and(|p| self.label_of(p) == title);
let opacity = if this { drag_opacity(drag.as_ref(), self.style.title_bar_docks) } else { 1.0 };
let float_only = drag.is_some_and(|drag| self.cross.pointer.is_some_and(|p| p.window == Some(drag.window) && p.command));
let opacity = if this { drag_opacity(drag.as_ref(), float_only, self.style.title_bar_docks) } else { 1.0 };
set_window_opacity(&title, main.as_deref(), opacity);
}
if let Some(placed) = report.placed.filter(|_| !moving) {
+15 -7
View File
@@ -226,10 +226,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
/// The side split of `proposal`'s frame when halving it leaves no room
/// ([`Placement::SplitSized`]): the frame keeps its size, the new frame takes the size the
/// moved panels have now (at least its minimum and [`DROP_MARGIN`]), and the frames beyond
/// the divider on that side give the room. `None` without such a divider, or when those
/// frames cannot give the new frame its minimum.
/// ([`Placement::SplitSized`]): the frame keeps its size, and the frames beyond the divider
/// on that side give the new frame its minimum (with [`DROP_MARGIN`]). A floating panel
/// keeps its size along the split instead, and one in a dock column its column's width or
/// its group's height, when that fits. `None` without such a divider, or when those frames
/// cannot give the new frame its minimum.
fn sized_split(&self, panel: &P, proposal: &DropProposal<P>, action: &impl Fn(Target<P>) -> Action<P>) -> Option<Placement<P>> {
let Placement::Split(zone) = proposal.placement else { return None };
let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal };
@@ -277,11 +278,18 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let room = sizer.travel(far.0, far.1, axis, start).0 - DROP_MARGIN;
let least = along(super::node_limits(new, self.style, &*self.content).min) + DROP_MARGIN;
let gap = self.style.gap;
let wanted = now.iter().find(|(node, _)| holds(node, panel)).map_or(least, |(_, rect)| along(rect.size()));
let size = wanted.max(least).min(along(halved.size()) + room - frame - gap);
if size < least {
// A split frame's width is its share of the room, not one of its own.
let saved = match self.place(panel) {
Some(Place::Floating { .. }) => now.iter().find(|(node, _)| holds(node, panel)).map(|(_, rect)| along(rect.size())),
Some(place @ Place::Dock { .. }) if axis == SplitAxis::Horizontal => self.layout.column(place).map(|column| column.width),
Some(place @ Place::Dock { .. }) => self.layout.group(place).and_then(|group| group.height),
_ => None,
};
let most = along(halved.size()) + room - frame - gap;
if least > most {
return None;
}
let size = saved.filter(|width| (least..=most).contains(width)).unwrap_or(least);
// The anchor's side of the outer split grows by what the halves did not take already.
let grow = frame + gap + size - along(halved.size());
let first = along(a.1.size()) + if start { -grow } else { grow };
+26
View File
@@ -397,6 +397,32 @@ fn a_drop_beside_a_frame_too_narrow_to_halve_takes_room_from_its_neighbour() {
assert!(new_properties.width() < properties.width() - 200.0, "Properties gave the room");
}
#[test]
fn a_docked_panel_dropped_beside_a_frame_too_narrow_to_halve_takes_its_minimum_width() {
// Info docked below Properties, as wide as it, beside a narrow Tools frame.
let root = Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedSecond(60.0),
first: Box::new(split(SplitAxis::Vertical, 0.6, tabs(&PROPERTIES), tabs(&[INFO]))),
second: Box::new(tabs(&[TOOLS])),
};
let mut d = driver(Layout { root: Some(root), floating: Vec::new() }, Film::default());
d.area = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, SCREEN.y));
d.settle();
let (tools, properties) = (group(&d, TOOLS), group(&d, PROPERTIES[0]));
let out = d.drag(tab(&d, INFO), pos2(tools.left() + 4.0, tools.center().y + 13.0));
assert!(
matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::SplitSized { zone: Zone::Left, .. } }]),
"{:?}",
out.actions
);
d.settle();
let (new_tools, new_info, new_properties) = (group(&d, TOOLS), group(&d, INFO), group(&d, PROPERTIES[0]));
assert!((new_tools.width() - tools.width()).abs() < 1.0, "Tools keeps its width: {new_tools:?}");
assert!(new_info.width() < 60.0, "Info takes its minimum, not its docked width: {new_info:?}");
assert!(new_properties.width() > properties.width() - 60.0, "Properties gives only that: {new_properties:?}");
}
#[test]
fn a_drop_beside_a_narrow_frame_joins_it_when_no_neighbour_has_room() {
let mut d = beside_tools(130.0, 60.0);
+15 -5
View File
@@ -366,9 +366,9 @@ const FLOAT_INSET: f32 = 24.0;
#[cfg_attr(not(feature = "serde"), allow(dead_code))]
pub(super) const DEFAULT_FLOAT: Vec2 = Vec2::new(260.0, 320.0);
/// Where a docking command floats a panel, group or column when it gives no rect (see
/// `DockCommand::to_actions`, feature `serde`). [`DockArea`] records one each frame; read it
/// with [`float_space`].
/// Where a docking command floats a panel, group or column when it gives no rect, and how the
/// area draws its outer edge (see `DockCommand::to_actions`, feature `serde`). [`DockArea`]
/// records one each frame; read it with [`float_space`].
#[derive(Clone, Debug, PartialEq)]
pub struct FloatSpace<P> {
/// The rect the area drew in. A floated unit goes inside it, 24 points from its top-left
@@ -381,13 +381,23 @@ pub struct FloatSpace<P> {
/// The height each panel's content used when it was last drawn, which fits a floated group
/// to its content; `None` for content that filled its body.
pub(super) contents: Vec<(P, Option<f32>)>,
/// The area draws with [`DockStyle::side_edge_zones`]: an outer edge drop wraps the part
/// between the docks, never only the canvas column.
pub(super) side_edge_zones: bool,
}
impl<P: PartialEq> FloatSpace<P> {
/// An area with no recorded sizes: commands float a panel at 260x320, and a group or
/// column at its dock column's width (else 260) by 320.
pub fn new(area: Rect) -> Self {
Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new(), contents: Vec::new() }
Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new(), contents: Vec::new(), side_edge_zones: false }
}
/// For an area drawn with [`DockStyle::side_edge_zones`] when the area has not drawn yet
/// (a headless run): an outer edge command then wraps what that profile's edge band wraps.
pub fn with_side_edge_zones(mut self, on: bool) -> Self {
self.side_edge_zones = on;
self
}
/// Adds the size a command floats `panel` at alone when the area has not drawn it, such as
@@ -1189,7 +1199,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
output: DockOutput::default(),
frame,
iconic: super::render::Iconic::new(self.auto_collapse),
float_space: FloatSpace { contents, ..FloatSpace::new(rect) },
float_space: FloatSpace { contents, side_edge_zones: style.side_edge_zones, ..FloatSpace::new(rect) },
places,
contents: content_index,
dividers: Vec::new(),
+2 -1
View File
@@ -417,7 +417,8 @@ mod v1_fixtures {
let float: DockCommand<String> =
serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap();
let space = craft_ui::docking::float_space(&d.ctx, d.id).unwrap();
let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone()), false, &space).unwrap();
let actions =
float.to_actions_with(&craft_ui::docking::Workspace::new(d.layout.clone()), false, &space, |p| d.content.permissions(p)).unwrap();
d.layout.apply_all(actions, |p| d.content.permissions(p)).unwrap_or_else(|e| panic!("{at}: {e}"));
d.settle();
assert!(start_columns(&d.layout).is_empty(), "{at}: floated");
+12 -4
View File
@@ -266,9 +266,16 @@ A flyout fits its content the same way, without the title bar.
edge band is offered only when every frame keeps its minimum.
- The edge area is the root, or the part between the docks. Without `side_edge_zones` it also
narrows to the canvas column: where neither side of a side-by-side split is a dock and only
one side holds a protected panel, that side. Side panels in split frames then get no band
across the whole window, and the drop wraps only the canvas column. A drop on the band
anchors on the canvas, so `Placement::Edge` narrows the same way when applied.
one side holds a protected panel, that side. The top (bottom) band also narrows a split of
rows to its first (last) row when only that row holds the canvas, so a full-width row below
the canvas keeps the bottom band. Side panels in split frames then get no band across the
whole window, and the drop wraps only the canvas column. A drop on a narrowed band anchors
on the canvas, so `Placement::Edge` narrows the same way when applied; on a band that is not
narrowed it anchors on a panel that is not protected. The renderer tries the drop with its
own permissions and offers the band only when the drop wraps the panels the band shows.
`window.panel.dock` to an outer edge picks its anchor the same way, from the permissions
`apply` (or `to_actions`) gets and the area's `FloatSpace` (`with_side_edge_zones` before it
has drawn).
- `DockStyle::drop_edges` (a `Sides`, all four by default) lists which sides of a group that
takes no tabs (a canvas), and of the layout's outer edge, take a drop. A canvas's whole body
goes to the nearest listed side; with none listed it takes nothing. In the docking profile
@@ -400,7 +407,8 @@ let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |pan
it floats. `side` is `Start` or `End`: what moves becomes a new last column of that dock, or
a new dock; when it already is that dock's last column, the command does nothing.
- `window.panel.dock` with `left`, `right`, `top` or `bottom` and no `anchor` or `side` makes a
new frame on that outer edge (`Placement::Edge`). A hidden panel is shown first.
new frame on that outer edge (`Placement::Edge`), wrapping what a drag to that edge would
(see the edge area above). A hidden panel is shown first.
- Showing a panel (`activate`, `dock` without a destination, `ui.dock` `open`) restores a hidden
panel's place; a panel never laid out opens as `Open` without an anchor does.
- `window.panel.float` without a rect floats what it moves at the size a drag floats it at (see