docking: float commands without a rect at the drag size inside the dock area

This commit is contained in:
aiongg committed 2026-10-19 13:07:03 +00:00
1 parent d91e5f6aea
commit f46828ea48
10 files changed
+208 -12

No files matched your search

+64 -7
View File
@@ -7,17 +7,18 @@
//! let mut params = params.clone();
//! params["command"] = json!(id);
//! let command: DockCommand<PanelId> = serde_json::from_value(params)?;
//! command.apply(&mut app.workspace, style.right_to_left, |p| app.permissions(p))?;
//! let space = float_space(ctx, DOCK_ID).unwrap_or_else(|| FloatSpace::new(ctx.content_rect()));
//! command.apply_in(&mut app.workspace, style.right_to_left, &space, |p| app.permissions(p))?;
//! ```
use std::hash::Hash;
use serde::{Deserialize, Deserializer, Serialize};
use super::{Action, DockError, Layout, Permissions, Placement, Side, Target, Workspace, Zone, dock_model::Place};
use super::{Action, DockError, FloatSpace, Layout, Permissions, Placement, Side, Target, Workspace, Zone, dock_model::Place};
use crate::layout::SplitSize;
/// Where a panel floats when a command gives no position or size.
/// Where a panel floats when a command gives no position or size and no [`FloatSpace`].
const DEFAULT_FLOAT: [f32; 4] = [48.0, 48.0, 260.0, 320.0];
/// A docking command, tagged by its control id under `"command"`.
@@ -332,7 +333,23 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
/// `left`, `right`, `top` and `bottom` zones follow [`Placement::for_zone`]: beside a docked
/// anchor they make a new column or group, and `right_to_left` is the
/// [`super::DockStyle::right_to_left`] the layout is drawn with.
///
/// A `window.panel.float` without a rect floats at 48, 48 in window coordinates; use
/// [`Self::to_actions_in`] to float inside the dock area instead.
pub fn to_actions(&self, workspace: &Workspace<P>, right_to_left: bool) -> Result<Vec<Action<P>>, DockError> {
self.actions(workspace, right_to_left, None)
}
/// Like [`Self::to_actions`], with `window.panel.float` floating into `space`: what it moves
/// takes the size a drag would float it at, inside the dock area and clear of the app's
/// menu bar. Pass [`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.
pub fn to_actions_in(&self, workspace: &Workspace<P>, right_to_left: bool, space: &FloatSpace<P>) -> Result<Vec<Action<P>>, DockError> {
self.actions(workspace, right_to_left, Some(space))
}
fn actions(&self, workspace: &Workspace<P>, right_to_left: bool, space: Option<&FloatSpace<P>>) -> 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) };
@@ -359,13 +376,32 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
rect
};
let any = given.iter().any(Option::is_some);
match (Unit::of(*group, *column), window) {
let unit = Unit::of(*group, *column);
let whole_window = layout.floating.iter().any(|w| w.contains(panel) && w.groups.len() == 1);
// The rect a drag would float the unit at, inside the area.
let placed = space.map(|space| {
let docked = layout.location(panel).ok().and_then(|l| l.group).and_then(|g| g.column).map(|c| c.width);
let fallback = egui::vec2(docked.filter(|_| !matches!(unit, Unit::Panel)).unwrap_or(DEFAULT_FLOAT[2]), DEFAULT_FLOAT[3]);
let size = match unit {
Unit::Panel => space.panel(panel),
Unit::Group => space.group(panel),
Unit::Column => space.column(panel),
};
space.place(size.unwrap_or(fallback))
});
match (unit, window) {
(Unit::Panel, Some(base)) if layout.floating.iter().any(|w| w.contains(panel) && w.panels().count() == 1) => {
one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) })
}
(Unit::Panel, _) => one(Action::Float { panel: panel.clone(), rect: rect(DEFAULT_FLOAT) }),
(Unit::Panel, _) => one(Action::Float { panel: panel.clone(), rect: rect(placed.unwrap_or(DEFAULT_FLOAT)) }),
(Unit::Column, Some(base)) => one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) }),
(unit, _) => one(unit.to(layout, panel, Target::Float { rect: any.then(|| rect(DEFAULT_FLOAT)) })?),
(Unit::Group, Some(_)) if whole_window => {
one(unit.to(layout, panel, Target::Float { rect: any.then(|| rect(DEFAULT_FLOAT)) })?)
}
(unit, _) => {
let float = placed.map(rect).or_else(|| any.then(|| rect(DEFAULT_FLOAT)));
one(unit.to(layout, panel, Target::Float { rect: float })?)
}
}
}
Self::Dock { panel, anchor, zone, before, group, column, side } => {
@@ -457,6 +493,27 @@ impl<P: Clone + Eq + Hash> DockCommand<P> {
/// location; Lock sets [`Workspace::locked`]. Application-owned commands
/// ([`Self::is_app_owned`]) return [`DockError::NotAllowed`] and change nothing.
pub fn apply(&self, workspace: &mut Workspace<P>, right_to_left: bool, permissions: impl Fn(&P) -> Permissions) -> Result<Vec<P>, DockError> {
self.apply_with(workspace, right_to_left, None, permissions)
}
/// Like [`Self::apply`], floating into `space` as [`Self::to_actions_in`] does.
pub fn apply_in(
&self,
workspace: &mut Workspace<P>,
right_to_left: bool,
space: &FloatSpace<P>,
permissions: impl Fn(&P) -> Permissions,
) -> Result<Vec<P>, DockError> {
self.apply_with(workspace, right_to_left, Some(space), permissions)
}
fn apply_with(
&self,
workspace: &mut Workspace<P>,
right_to_left: bool,
space: Option<&FloatSpace<P>>,
permissions: impl Fn(&P) -> Permissions,
) -> Result<Vec<P>, DockError> {
let show = match self {
Self::Activate { panel }
| Self::Dock { panel, anchor: None, side: None, .. }
@@ -471,7 +528,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)?;
let actions = self.actions(workspace, right_to_left, space)?;
workspace.apply_all(actions, permissions)
}
}
+45
View File
@@ -311,3 +311,48 @@ fn side_zones_map_to_the_placement_the_anchor_container_takes_in_either_directio
assert_eq!(columns.len(), 2);
assert_eq!(columns[1].groups[0].panels, vec![s("history")]);
}
/// `layers` prefers 200x150 when it floats.
struct Sized;
impl DockContent<P> for Sized {
fn body(&mut self, _: &mut egui::Ui, _: &P) {}
fn permissions(&self, panel: &P) -> Permissions {
permissions(panel)
}
fn floating_size(&self, panel: &P) -> Option<egui::Vec2> {
(panel == "layers").then_some(egui::vec2(200.0, 150.0))
}
}
#[test]
fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() {
// The area starts under a 30 pt menu bar.
let area = egui::Rect::from_min_max(egui::pos2(0.0, 30.0), egui::pos2(800.0, 600.0));
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_in(&workspace(), false, &space).unwrap();
let panel = float(json!({"command": "window.panel.float", "panel": "layers"}));
assert_eq!(panel, vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 150.0] }], "floating_size, inset in the area");
let [Action::MoveGroup { to: Target::Float { rect: Some([x, y, width, height]) }, .. }] =
float(json!({"command": "window.panel.float", "panel": "history", "group": true}))[..]
else {
panic!()
};
assert_eq!([x, y], [24.0, 54.0]);
assert!(width > 100.0 && width <= 240.0 && height <= 320.0, "the docked group's size, capped: {width}x{height}");
// Given values stay exactly; a lone floating window keeps its rect.
let given = float(json!({"command": "window.panel.float", "panel": "layers", "x": 5, "y": 6, "width": 100, "height": 90}));
assert_eq!(given, vec![Action::Float { panel: s("layers"), rect: [5.0, 6.0, 100.0, 90.0] }]);
let color = float(json!({"command": "window.panel.float", "panel": "color", "group": true}));
assert_eq!(color, vec![Action::MoveGroup { panel: s("color"), to: Target::Float { rect: None } }]);
// 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_in(&workspace(), false, &small);
assert_eq!(applied, Ok(vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: Some([0.0, 30.0, 200.0, 200.0]) } }]));
}
+2 -2
View File
@@ -27,8 +27,8 @@ pub use hits::{Divider, Hit, ResizeEdge};
pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone};
pub use render::{close_flyout, flyout, open_flyout};
pub use surface::{
DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, HidePanels, PanelLimits, Profile, begin_drag, cancel_drag,
is_dragging,
DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, Profile, begin_drag,
cancel_drag, float_space, is_dragging,
};
pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect};
pub use workspace::Workspace;
+1
View File
@@ -209,6 +209,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if !usable(rect) {
return;
}
self.record_column_float(column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(), rect);
let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom())));
self.column_bar(ui, column, first, bar, rect, edge);
let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max);
+1
View File
@@ -32,6 +32,7 @@ const BORDER_BAND: f32 = 4.0;
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) {
let Some(selected) = panels.get(active) else { return };
self.record_float(panels, rect.size(), rect.size());
let policy = self.permissions(selected);
let splits = kind == GroupKind::Frame && policy.accepts_splits;
let rtl = self.style.right_to_left;
+3 -2
View File
@@ -188,6 +188,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let response = ui.interact(grip, self.id.with(("icon-grip", Some(first))), Sense::drag());
let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response };
self.register_interaction(ui.ctx(), response.id);
let height = group.height.filter(|h| h.is_finite()).unwrap_or(FLYOUT_HEIGHT);
let source = Rect::from_min_size(grip.min, vec2(width.max(self.style.min_pane), height.max(self.style.tab_height)));
self.record_float(&group.panels, vec2(grip.width(), self.row_height()), source.size());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label));
let color = if response.hovered() || response.dragged() { self.style.text } else { self.style.inactive_text };
let painter = ui.painter().with_clip_rect(grip.intersect(ui.clip_rect()));
@@ -195,8 +198,6 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
painter.hline((grip.center().x - 8.0)..=(grip.center().x + 8.0), grip.center().y + 1.0, Stroke::new(1.0, color));
if movable && response.drag_started() {
let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center());
let height = group.height.filter(|h| h.is_finite()).unwrap_or(FLYOUT_HEIGHT);
let source = Rect::from_min_size(grip.min, vec2(width.max(self.style.min_pane), height.max(self.style.tab_height)));
let drag = GroupDrag {
panel: first.clone(),
original: [source.left(), source.top(), source.width(), source.height()],
+2
View File
@@ -41,6 +41,8 @@ pub(super) struct Renderer<'a, P> {
pub(super) frame: FrameState<P>,
/// [D] Flyout and iconic column state for this frame.
pub(super) iconic: Iconic<P>,
/// What a drag would float from each drawn panel, group and column, for commands.
pub(super) float_space: crate::docking::FloatSpace<P>,
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
+17
View File
@@ -289,6 +289,23 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
size.max(limits.min + header).min(limits.max + header)
}
/// Record what a drag floats from `panels`, one group drawn at `group` whose panels drag
/// from `panel` rects, for commands that float without a rect.
pub(super) fn record_float(&mut self, panels: &[P], panel: Vec2, group: Vec2) {
let group = self.group_float_size(panels, group);
for p in panels {
let size = self.float_size(p, panel);
self.float_space.panels.push((p.clone(), size));
self.float_space.groups.push((p.clone(), group));
}
}
/// Record what a drag floats from a dock column drawn at `rect`.
pub(super) fn record_column_float(&mut self, members: Vec<P>, rect: Rect) {
let size = self.group_float_size(&members, rect.size());
self.float_space.columns.extend(members.into_iter().map(|p| (p, size)));
}
/// Size of a group or column floated by a drag or Float Tab Group: the source width, and the
/// tallest member's floating size, else the source height capped like a panel leaving a
/// dock column. A group that already floats keeps its size.
+64
View File
@@ -218,6 +218,68 @@ impl<P> Default for DockOutput<P> {
}
}
/// Room between the dock area's top-left corner and a unit a command floats.
const FLOAT_INSET: f32 = 24.0;
/// Where a docking command floats a panel, group or column when it gives no rect (see
/// `DockCommand::to_actions_in`, 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
/// corner, so it stays clear of the app's menu bar.
pub area: Rect,
/// What a drag floats from each drawn panel: the panel alone, its group and its dock column.
pub(super) panels: Vec<(P, Vec2)>,
pub(super) groups: Vec<(P, Vec2)>,
pub(super) columns: Vec<(P, Vec2)>,
}
impl<P: PartialEq> FloatSpace<P> {
/// An area with no recorded sizes: commands float units at the layout's sizes.
pub fn new(area: Rect) -> Self {
Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new() }
}
}
/// Lookups for `DockCommand`, which needs feature `serde`.
#[cfg_attr(not(feature = "serde"), allow(dead_code))]
impl<P: PartialEq> FloatSpace<P> {
pub(super) fn panel(&self, panel: &P) -> Option<Vec2> {
Self::find(&self.panels, panel)
}
pub(super) fn group(&self, panel: &P) -> Option<Vec2> {
Self::find(&self.groups, panel)
}
pub(super) fn column(&self, panel: &P) -> Option<Vec2> {
Self::find(&self.columns, panel)
}
/// The first record wins: an iconic panel is recorded at its icon before its flyout.
fn find(sizes: &[(P, Vec2)], panel: &P) -> Option<Vec2> {
sizes.iter().find(|(p, _)| p == panel).map(|(_, size)| *size)
}
/// `[x, y, width, height]` of `size` inside the area, inset from its top-left corner and
/// shrunk to fit.
pub(super) fn place(&self, size: Vec2) -> [f32; 4] {
let size = size.min(self.area.size());
let min = (self.area.min + Vec2::splat(FLOAT_INSET)).min(self.area.max - size).max(self.area.min);
[min.x, min.y, size.x, size.y]
}
}
/// The [`FloatSpace`] the area recorded on its last frame.
pub fn float_space<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id) -> Option<FloatSpace<P>> {
ctx.data(|d| d.get_temp(float_space_key(area_id)))
}
fn float_space_key(area: Id) -> Id {
area.with("float-space")
}
/// A request for host state outside the layout.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
@@ -680,6 +742,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::new(rect),
};
if let Some(root) = &view.root {
renderer.node(ui, root, rect, &mut Vec::new());
@@ -690,6 +753,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
renderer.finish_group_drag(ui);
renderer.finish_drag(ui.ctx());
super::hits::record(ui.ctx(), self.id, &view, &renderer.owned_interactions);
ui.ctx().data_mut(|d| d.insert_temp(float_space_key(self.id), renderer.float_space));
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions));
renderer.output
}
+9 -1
View File
@@ -159,7 +159,8 @@ let command: DockCommand<PanelId> = serde_json::from_value(params)?;
if command.is_app_owned() {
return app.workspace_command(command);
}
let closed = command.apply(&mut app.workspace, style.right_to_left, |panel| app.permissions(panel))?;
let space = float_space(ctx, DOCK_ID).unwrap_or_else(|| FloatSpace::new(ctx.content_rect()));
let closed = command.apply_in(&mut app.workspace, style.right_to_left, &space, |panel| app.permissions(panel))?;
```
| Id | Params (`?` is optional) |
@@ -183,6 +184,13 @@ let closed = command.apply(&mut app.workspace, style.right_to_left, |panel| app.
`to_actions` take the layout's `right_to_left` for that.
- `group` moves the panel's whole group; `column` moves its dock column, or its whole window when
it floats. `side` is `Start` or `End`.
- `window.panel.float` without a rect floats what it moves at the size a drag floats it at: the
panel's `floating_size`, else its docked group or column size, capped like a drag. It goes
inside the dock area, 24 points from its top-left corner, so it stays clear of the menu bar.
The area records that geometry in a `FloatSpace` each frame; `float_space(ctx, id)` reads it
and `apply_in` and `to_actions_in` take it. `x`, `y`, `width` and `height` replace the matching
values, and a group or column that is already a whole floating window keeps its rect.
`apply` and `to_actions` have no area and float at 48, 48 in window coordinates.
- Without a value, `minimized`, `iconic` and `locked` toggle.
- `ui.dock` operations are those of LightCraft, PDFCraft, CADCraft and SoundCraft: `open`,
`close`, `activate`, `float {rect}`, `moveFloating {rect}`, `move {target, zone?, before?}`,