diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index e0b26d60..58ef542d 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -7,17 +7,18 @@ //! let mut params = params.clone(); //! params["command"] = json!(id); //! let command: DockCommand = 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 DockCommand

{ /// `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

, right_to_left: bool) -> Result>, 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

, right_to_left: bool, space: &FloatSpace

) -> Result>, DockError> { + self.actions(workspace, right_to_left, Some(space)) + } + + fn actions(&self, workspace: &Workspace

, right_to_left: bool, space: Option<&FloatSpace

>) -> Result>, 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 DockCommand

{ 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 DockCommand

{ /// 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

, right_to_left: bool, permissions: impl Fn(&P) -> Permissions) -> Result, 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

, + right_to_left: bool, + space: &FloatSpace

, + permissions: impl Fn(&P) -> Permissions, + ) -> Result, DockError> { + self.apply_with(workspace, right_to_left, Some(space), permissions) + } + + fn apply_with( + &self, + workspace: &mut Workspace

, + right_to_left: bool, + space: Option<&FloatSpace

>, + permissions: impl Fn(&P) -> Permissions, + ) -> Result, DockError> { let show = match self { Self::Activate { panel } | Self::Dock { panel, anchor: None, side: None, .. } @@ -471,7 +528,7 @@ impl DockCommand

{ 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) } } diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index c84051bf..34f93c5f 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -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

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 { + (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::

(&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]) } }])); +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index f3c8c623..b8462ec3 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -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; diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 32d7240a..86c57be5 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -209,6 +209,7 @@ impl 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); diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 2d1bc6f9..74912e96 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -32,6 +32,7 @@ const BORDER_BAND: f32 = 4.0; impl 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; diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 7b908b4a..59e25124 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -188,6 +188,9 @@ impl 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 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()], diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 37414bc7..d5cdd4b9 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -41,6 +41,8 @@ pub(super) struct Renderer<'a, P> { pub(super) frame: FrameState

, /// [D] Flyout and iconic column state for this frame. pub(super) iconic: Iconic

, + /// What a drag would float from each drawn panel, group and column, for commands. + pub(super) float_space: crate::docking::FloatSpace

, } impl Renderer<'_, P> { diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 2ab0a836..5d662980 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -289,6 +289,23 @@ impl 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

, 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. diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 619a87cf..ce06a9b9 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -218,6 +218,68 @@ impl

Default for DockOutput

{ } } +/// 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

{ + /// 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 FloatSpace

{ + /// 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 FloatSpace

{ + pub(super) fn panel(&self, panel: &P) -> Option { + Self::find(&self.panels, panel) + } + + pub(super) fn group(&self, panel: &P) -> Option { + Self::find(&self.groups, panel) + } + + pub(super) fn column(&self, panel: &P) -> Option { + 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 { + 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(ctx: &Context, area_id: Id) -> Option> { + 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 } diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 0434d20d..3ab542f5 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -159,7 +159,8 @@ let command: DockCommand = 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?}`,