From 76883695c3bfc2264fe4ae59097a1450a467298a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:10:36 +0000 Subject: [PATCH] Dock: floating panels, tab groups and adjustable slot heights Panels can now float over the workspace, share a docked slot as tabs, and split an edge at heights the user picks. - Dropping a panel away from the edges floats it at the pointer. Floating panels move by their title bar, resize from a corner grip, and come to the front when clicked. - Dropping in the top or bottom quarter of a docked slot docks the panel above or below it, as before. Dropping in the middle adds the panel as a tab of that slot. Pressing a tab shows that panel; dragging a tab pulls the panel out again. - While dragging, a blue ghost previews the result: the new slot, the slot with its future tab row, or the floating window. - A splitter between stacked slots sets their heights; double-click it to make the slots equal again. Heights persist. - A press only becomes a drag after 5 px of movement, so clicking a title bar or tab never moves the panel. The persisted layout now stores each edge as tab groups with a height weight, plus a list of floating panels. Configs with the old bare-panel-id stacks still load as single-panel slots. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/commands/blocks.rs | 5 +- src/app/commands/count.rs | 2 +- src/app/commands/layerprops.rs | 2 +- src/app/commands/pdf_underlay.rs | 2 +- src/app/commands/sheet_set.rs | 2 +- src/app/mod.rs | 19 +- src/app/node_graph.rs | 2 +- src/app/update/dialog.rs | 576 ++++++++++++++++++++------ src/app/update/mod.rs | 2 +- src/app/view/mod.rs | 689 +++++++++++++++++++++---------- src/ui/dock.rs | 568 ++++++++++++++++++++++--- 11 files changed, 1452 insertions(+), 417 deletions(-) diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 4c10b5e5f..99a86a5c2 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -911,10 +911,9 @@ impl OpenCADStudio { if self.show_external_references { // Always open expanded so the panel is immediately usable; // dock it on the right if the user closed it from the layout. - if self + if !self .dock - .location(crate::ui::dock::PanelId::ExternalReferences) - .is_none() + .is_placed(crate::ui::dock::PanelId::ExternalReferences) { self.dock.dock( crate::ui::dock::PanelId::ExternalReferences, diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs index cdd4243a4..5d54c42a0 100644 --- a/src/app/commands/count.rs +++ b/src/app/commands/count.rs @@ -236,7 +236,7 @@ impl OpenCADStudio { self.count_palette.show = open; self.ribbon.set_count_palette(open); if open { - if self.dock.location(id).is_none() { + if !self.dock.is_placed(id) { self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); } self.dock_expanded = Some(id); diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 6d49ab04d..828140bef 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -679,7 +679,7 @@ impl OpenCADStudio { // rearrange themselves on upgrade for a panel they have // not asked for. let id = crate::ui::dock::PanelId::Browser; - if self.dock.location(id).is_none() { + if !self.dock.is_placed(id) { // dock() clamps an out-of-range index to the end of // the edge, so this appends below whatever is there. self.dock diff --git a/src/app/commands/pdf_underlay.rs b/src/app/commands/pdf_underlay.rs index a45753708..ea01d01c4 100644 --- a/src/app/commands/pdf_underlay.rs +++ b/src/app/commands/pdf_underlay.rs @@ -374,7 +374,7 @@ impl OpenCADStudio { "POINTCLOUDMANAGER" => { let id = crate::ui::dock::PanelId::PointCloudManager; self.pc_manager.show = true; - if self.dock.location(id).is_none() { + if !self.dock.is_placed(id) { self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); } self.dock_expanded = Some(id); diff --git a/src/app/commands/sheet_set.rs b/src/app/commands/sheet_set.rs index 4a228a75f..372ab1554 100644 --- a/src/app/commands/sheet_set.rs +++ b/src/app/commands/sheet_set.rs @@ -577,7 +577,7 @@ impl OpenCADStudio { self.sheet_set.show = on; self.ribbon.set_sheet_set(on); if on { - if self.dock.location(id).is_none() { + if !self.dock.is_placed(id) { self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); } self.dock_expanded = Some(id); diff --git a/src/app/mod.rs b/src/app/mod.rs index 39b4b2260..8f79a88fd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -852,8 +852,19 @@ pub(super) struct OpenCADStudio { pub(crate) dock_resizing: Option, /// Last pointer position during a drag / resize. pub(crate) dock_drag_last: Option, - /// Live drag target (side + index), shown as a highlight while dragging. - pub(crate) dock_drag_target: Option<(crate::app::config::DockSide, usize)>, + /// Live drop target, shown as a preview while dragging. `None` until the + /// pointer has moved far enough to count as a drag, so a click on a title + /// bar or tab never moves the panel. + pub(crate) dock_drag_target: Option, + /// First pointer position of the current panel drag. + pub(crate) dock_drag_origin: Option, + /// Pointer offset from a dragged panel's top-left corner, so a floated + /// panel lands where it is held rather than jumping to the pointer. + pub(crate) dock_grab_offset: iced::Vector, + /// Splitter being dragged between two slots on a side (upper, lower). + pub(crate) dock_split_drag: Option<(crate::app::config::DockSide, usize, usize)>, + /// Floating panel being resized from its corner grip. + pub(crate) dock_float_resizing: Option, /// Reference-table column currently being width-resized (column index), /// with the last pointer position. Mirrors the Layers Name-column drag. pub(crate) xref_col_drag: Option, @@ -4289,6 +4300,10 @@ impl OpenCADStudio { dock_resizing: None, dock_drag_last: None, dock_drag_target: None, + dock_drag_origin: None, + dock_grab_offset: iced::Vector::new(0.0, 0.0), + dock_split_drag: None, + dock_float_resizing: None, xref_col_drag: None, xref_col_last: None, xref_split_drag: false, diff --git a/src/app/node_graph.rs b/src/app/node_graph.rs index 8291329c0..7a7e9bd34 100644 --- a/src/app/node_graph.rs +++ b/src/app/node_graph.rs @@ -173,7 +173,7 @@ impl OpenCADStudio { // The node library docks on the right on first use; after // that it stays wherever the user moved it. let id = crate::ui::dock::PanelId::NodeGraph; - if self.dock.location(id).is_none() { + if !self.dock.is_placed(id) { self.dock .dock(id, crate::app::config::DockSide::Right, usize::MAX); } diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 985e2c424..b5d9b8578 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -20,6 +20,9 @@ use codec::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; +/// Pointer travel (px) before pressing a panel's title bar or tab becomes a +/// drag, so a plain click never moves the panel. +const DOCK_DRAG_THRESHOLD: f32 = 5.0; impl OpenCADStudio { pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task { @@ -542,14 +545,19 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven /// whichever panel the message names. pub(super) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task { use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, PanelId}; + use crate::ui::dock::{DockMsg, DropTarget, FloatPanel, PanelId}; match m { DockMsg::DockGrab(id) => { - self.dock_dragging = Some(id); - self.dock_resizing = None; - self.dock_drag_last = None; - self.dock_drag_target = self.dock.location(id); - self.dock_expanded = Some(id); + self.dock_begin_drag(id); + iced::Task::none() + } + DockMsg::SelectTab(id) => { + // A tab press shows the tab and may also start pulling it out + // of the group; a plain click never passes the drag threshold. + if self.dock.select_tab(id) { + self.save_config(); + } + self.dock_begin_drag(id); iced::Task::none() } DockMsg::ResizeGrab(id) => { @@ -560,6 +568,30 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.dock_expanded = Some(id); iced::Task::none() } + DockMsg::SplitGrab(side, upper, lower) => { + self.dock_split_drag = Some((side, upper, lower)); + self.dock_dragging = None; + self.dock_drag_last = None; + self.dock_drag_target = None; + iced::Task::none() + } + DockMsg::SplitReset(side) => { + self.dock.reset_splits(side); + self.save_config(); + iced::Task::none() + } + DockMsg::FloatResizeGrab(id) => { + self.dock.raise_float(id); + self.dock_float_resizing = Some(id); + self.dock_dragging = None; + self.dock_drag_last = None; + self.dock_drag_target = None; + iced::Task::none() + } + DockMsg::FloatRaise(id) => { + self.dock.raise_float(id); + iced::Task::none() + } DockMsg::WidthReset(id) => { self.dock.reset_width(id); self.save_config(); @@ -639,19 +671,50 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven // tracker reports header-local points, and mixing the two // produced a one-time jump plus a stuck drag. Column moves // arrive via Message::XrefColMove only. - if self.dock_dragging.is_some() { - let avail = self.tabs[self.active_tab].scene.selection.borrow().view.vp_size.1; - let side = if point.x < self.win_size.0 * 0.5 { - DockSide::Left - } else { - DockSide::Right - }; - let index = crate::ui::dock::drop_index( - point.y, - std::cmp::max(self.dock_visible_len(side), 1), - avail, - ); - self.dock_drag_target = Some((side, index)); + if let Some(id) = self.dock_dragging { + let origin = *self.dock_drag_origin.get_or_insert(point); + let moved = (point.x - origin.x).hypot(point.y - origin.y); + if self.dock_drag_target.is_none() && moved >= DOCK_DRAG_THRESHOLD { + // Hold a floating panel where it was grabbed; a docked + // one by its title bar. + self.dock_grab_offset = match self.dock.float_rect(id) { + Some(f) => iced::Vector::new(origin.x - f.x, origin.y - f.y), + None => iced::Vector::new( + (self.dock.width(id, self.win_size.0) * 0.5).min(120.0), + 14.0, + ), + }; + } + if self.dock_drag_target.is_some() || moved >= DOCK_DRAG_THRESHOLD { + self.dock_drag_target = Some(self.dock_drop_target(id, point)); + } + } else if let Some((side, upper, lower)) = self.dock_split_drag { + if let Some(last) = self.dock_drag_last { + let (_, avail) = self.dock_workspace_size(); + let total: f32 = self + .dock_expanded_groups(side) + .iter() + .map(|gi| self.dock.groups(side)[*gi].weight) + .sum(); + if avail > 0.0 && total > 0.0 { + let per_px = total / avail; + self.dock.shift_split( + side, + upper, + lower, + (point.y - last.y) * per_px, + crate::ui::dock::GROUP_MIN_H * per_px, + ); + } + } + } else if let Some(id) = self.dock_float_resizing { + if let (Some(last), Some(f)) = (self.dock_drag_last, self.dock.float_rect(id)) { + self.dock.resize_float( + id, + f.w + point.x - last.x, + f.h + point.y - last.y, + ); + } } else if let Some(id) = self.dock_resizing { if let Some(last) = self.dock_drag_last { let dx = point.x - last.x; @@ -663,22 +726,42 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.dock.set_width(id, cur); } } - if self.dock_dragging.is_some() || self.dock_resizing.is_some() { + if self.dock_dragging.is_some() + || self.dock_resizing.is_some() + || self.dock_split_drag.is_some() + || self.dock_float_resizing.is_some() + { self.dock_drag_last = Some(point); } iced::Task::none() } DockMsg::DragRelease => { - if let Some(id) = self.dock_dragging { - if let Some((side, index)) = self.dock_drag_target { - if self.dock.dock(id, side, index) { - self.save_config(); + let mut changed = self.dock_resizing.is_some() + || self.dock_split_drag.is_some() + || self.dock_float_resizing.is_some(); + if let (Some(id), Some(target)) = (self.dock_dragging, self.dock_drag_target) { + let moved = match target { + DropTarget::Edge { side, index } => self.dock.dock(id, side, index), + DropTarget::Tab { side, group } => self.dock.add_tab(id, side, group), + DropTarget::Float { x, y } => { + let (w, h) = self.dock_float_size(id); + self.dock.float(FloatPanel { id, x, y, w, h }) } + }; + if moved { + self.dock_expanded = Some(id); } + changed |= moved; + } + if changed { + self.save_config(); } self.dock_dragging = None; self.dock_resizing = None; + self.dock_split_drag = None; + self.dock_float_resizing = None; self.dock_drag_last = None; + self.dock_drag_origin = None; self.dock_drag_target = None; self.xref_col_drag = None; self.xref_col_last = None; @@ -688,6 +771,20 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } } + /// Start a (not yet confirmed) drag of `id`: it only moves once the + /// pointer travels past `DOCK_DRAG_THRESHOLD`. + fn dock_begin_drag(&mut self, id: crate::ui::dock::PanelId) { + self.dock.raise_float(id); + self.dock_dragging = Some(id); + self.dock_resizing = None; + self.dock_split_drag = None; + self.dock_float_resizing = None; + self.dock_drag_last = None; + self.dock_drag_origin = None; + self.dock_drag_target = None; + self.dock_expanded = Some(id); + } + /// Whether `id` is currently rendered (not closed, not on the start screen / /// clean-screen viewport). Mirrors the visibility filter the edge column /// renderer applies before building each side's stack. @@ -708,17 +805,150 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } } - /// Number of panels currently rendered on `side`. Closed panels keep their - /// stack slot but take no vertical space, so drag preview geometry must - /// size slots against only the panels that are actually visible. - pub(crate) fn dock_visible_len(&self, side: crate::app::config::DockSide) -> usize { - let ids: &[crate::ui::dock::PanelId] = match side { - crate::app::config::DockSide::Left => &self.dock.left, - crate::app::config::DockSide::Right => &self.dock.right, - }; - ids.iter() - .filter(|id| self.dock_panel_visible(**id)) - .count() + /// Size of the workspace the dock lays out in: window width by viewport + /// height. + pub(crate) fn dock_workspace_size(&self) -> (f32, f32) { + ( + self.win_size.0, + self.tabs[self.active_tab].scene.selection.borrow().view.vp_size.1, + ) + } + + /// Visible tabs of slot `gi` on `side`. Closed panels keep their tab but + /// take no space, so a slot with no visible tab is not rendered at all. + pub(crate) fn dock_group_visible_tabs( + &self, + side: crate::app::config::DockSide, + gi: usize, + ) -> Vec { + self.dock.groups(side)[gi] + .tabs + .iter() + .copied() + .filter(|id| self.dock_panel_visible(*id)) + .collect() + } + + /// The panel slot `gi` on `side` shows: its active tab, or the first + /// visible one when the active panel is closed. + pub(crate) fn dock_group_shown( + &self, + side: crate::app::config::DockSide, + gi: usize, + ) -> Option { + let active = self.dock.groups(side)[gi].active; + if self.dock_panel_visible(active) { + return Some(active); + } + self.dock_group_visible_tabs(side, gi).first().copied() + } + + /// Indices of the slots on `side` that render (hold a visible panel). + pub(crate) fn dock_visible_groups(&self, side: crate::app::config::DockSide) -> Vec { + (0..self.dock.groups(side).len()) + .filter(|gi| self.dock_group_shown(side, *gi).is_some()) + .collect() + } + + /// Visible slots on `side` that are open in the edge column rather than + /// reduced to a rail tab: not auto-collapsing, or hovered. + pub(crate) fn dock_expanded_groups(&self, side: crate::app::config::DockSide) -> Vec { + self.dock_visible_groups(side) + .into_iter() + .filter(|gi| { + let shown = self.dock_group_shown(side, *gi).expect("visible group"); + !self.dock.auto_collapse(shown) + || self + .dock_expanded + .is_some_and(|e| self.dock_group_visible_tabs(side, *gi).contains(&e)) + }) + .collect() + } + + /// Width of the edge column on `side`: its widest shown panel. + pub(crate) fn dock_column_width(&self, side: crate::app::config::DockSide) -> f32 { + self.dock_visible_groups(side) + .into_iter() + .filter_map(|gi| self.dock_group_shown(side, gi)) + .map(|id| self.dock.width(id, self.win_size.0)) + .fold(0.0, f32::max) + } + + /// Visible slots on `side` with their vertical span in the workspace. + pub(crate) fn dock_slot_spans( + &self, + side: crate::app::config::DockSide, + ) -> Vec<(usize, f32, f32)> { + let (_, avail) = self.dock_workspace_size(); + let visible = self.dock_visible_groups(side); + let weights: Vec = visible + .iter() + .map(|gi| self.dock.groups(side)[*gi].weight) + .collect(); + visible + .into_iter() + .zip(crate::ui::dock::slot_spans(&weights, avail)) + .map(|(gi, (top, bottom))| (gi, top, bottom)) + .collect() + } + + /// Size a panel takes when floated: its current floating size, or its + /// docked width by a comfortable share of the workspace height. + pub(crate) fn dock_float_size(&self, id: crate::ui::dock::PanelId) -> (f32, f32) { + if let Some(f) = self.dock.float_rect(id) { + return (f.w, f.h); + } + let (_, avail) = self.dock_workspace_size(); + ( + self.dock.width(id, self.win_size.0), + (avail * 0.6).clamp(crate::ui::dock::FLOAT_MIN_H, 520.0), + ) + } + + /// Where dragged panel `id` lands if released at workspace point `p`: + /// near an edge it docks (above / below a slot, or as a tab in the middle + /// of one), anywhere else it floats. + pub(crate) fn dock_drop_target( + &self, + id: crate::ui::dock::PanelId, + p: iced::Point, + ) -> crate::ui::dock::DropTarget { + use crate::app::config::DockSide; + use crate::ui::dock::{slot_zone, DropTarget, SlotZone, DOCK_EDGE_ZONE}; + let (ww, wh) = self.dock_workspace_size(); + for side in [DockSide::Left, DockSide::Right] { + let zone = self.dock_column_width(side).max(DOCK_EDGE_ZONE); + let inside = match side { + DockSide::Left => p.x <= zone, + DockSide::Right => p.x >= ww - zone, + }; + if !inside { + continue; + } + let spans = self.dock_slot_spans(side); + let Some(&(gi, top, bottom)) = spans + .iter() + .find(|(_, _, bottom)| p.y < *bottom) + .or(spans.last()) + else { + return DropTarget::Edge { + side, + index: self.dock.groups(side).len(), + }; + }; + return match slot_zone(p.y, top, bottom) { + SlotZone::Above => DropTarget::Edge { side, index: gi }, + SlotZone::Below => DropTarget::Edge { side, index: gi + 1 }, + // Tabbing a panel onto its own lone slot changes nothing. + SlotZone::Middle if self.dock_group_visible_tabs(side, gi) == [id] => { + DropTarget::Edge { side, index: gi } + } + SlotZone::Middle => DropTarget::Tab { side, group: gi }, + }; + } + let x = (p.x - self.dock_grab_offset.x).clamp(0.0, (ww - 60.0).max(0.0)); + let y = (p.y - self.dock_grab_offset.y).clamp(0.0, (wh - 30.0).max(0.0)); + DropTarget::Float { x, y } } /// Rebuild the Reference Manager's entry list from the active drawing. @@ -1602,112 +1832,222 @@ mod tests { assert!(!app.show_block_palette, "close dismisses the sidebar"); } - #[test] - fn blockpalette_dock_moves_to_other_side_and_persists() { + /// A hermetic app with a 1600×900 workspace and the default dock layout + /// (Properties left, block palette right), both shown. + fn dock_app() -> OpenCADStudio { let mut app = fresh(); // Tests load the user's persisted config; reset the dock to a known // state so this stays hermetic. app.dock = Default::default(); + app.dock.ensure_settings(); + app.show_properties = true; + app.show_block_palette = true; + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0); + app.win_size = (1600.0, 900.0).into(); + app + } + + /// Press `id`'s title bar at `from`, drag to `to`, and return the target + /// shown just before release. + fn drag_panel( + app: &mut OpenCADStudio, + id: crate::ui::dock::PanelId, + from: iced::Point, + to: iced::Point, + ) -> Option { + use crate::ui::dock::DockMsg; + let _ = app.on_dock(DockMsg::DockGrab(id)); + let _ = app.on_dock(DockMsg::DragMove(from)); + let _ = app.on_dock(DockMsg::DragMove(to)); + let target = app.dock_drag_target; + let _ = app.on_dock(DockMsg::DragRelease); + target + } + + #[test] + fn blockpalette_dock_moves_to_other_side_and_persists() { + use crate::app::config::DockSide; + use crate::ui::dock::DropTarget; + let mut app = dock_app(); let id = crate::ui::dock::PanelId::BlockPalette; - assert_eq!( - app.dock.location(id), - Some((crate::app::config::DockSide::Right, 0)) + assert_eq!(app.dock.location(id), Some((DockSide::Right, 0))); + // Top quarter of the Properties slot: dock above it. + let target = drag_panel( + &mut app, + id, + iced::Point::new(1500.0, 10.0), + iced::Point::new(100.0, 40.0), ); - let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); - app.win_size = (1600.0, 900.0).into(); - let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 100.0))); + assert_eq!(target, Some(DropTarget::Edge { side: DockSide::Left, index: 0 })); + assert_eq!(app.dock.location(id), Some((DockSide::Left, 0))); assert_eq!( - app.dock_drag_target, - Some((crate::app::config::DockSide::Left, 0)) + app.dock.location(crate::ui::dock::PanelId::Properties), + Some((DockSide::Left, 1)) ); + } + + #[test] + fn dock_click_without_drag_changes_nothing() { + let mut app = dock_app(); + let before = app.dock.clone(); + let id = crate::ui::dock::PanelId::BlockPalette; + let p = iced::Point::new(1500.0, 10.0); + let target = drag_panel(&mut app, id, p, iced::Point::new(1502.0, 11.0)); + assert_eq!(target, None, "movement under the threshold is a click"); + assert_eq!(app.dock, before); + } + + #[test] + fn dock_drop_in_slot_middle_adds_a_tab() { + use crate::app::config::DockSide; + use crate::ui::dock::{DropTarget, PanelId}; + let mut app = dock_app(); + let target = drag_panel( + &mut app, + PanelId::BlockPalette, + iced::Point::new(1500.0, 10.0), + iced::Point::new(100.0, 450.0), + ); + assert_eq!(target, Some(DropTarget::Tab { side: DockSide::Left, group: 0 })); + assert_eq!( + app.dock.left[0].tabs, + vec![PanelId::Properties, PanelId::BlockPalette] + ); + assert_eq!(app.dock.left[0].active, PanelId::BlockPalette); + // Clicking the Properties tab shows it again. + let _ = app.on_dock(crate::ui::dock::DockMsg::SelectTab(PanelId::Properties)); let _ = app.on_dock(crate::ui::dock::DockMsg::DragRelease); - assert_eq!( - app.dock.location(id), - Some((crate::app::config::DockSide::Left, 0)) - ); + assert_eq!(app.dock.left[0].active, PanelId::Properties); + assert_eq!(app.dock.left[0].tabs.len(), 2); } #[test] - fn dock_visible_len_counts_only_rendered_panels() { - let mut app = fresh(); - app.dock = Default::default(); - app.dock.left = vec![ - crate::ui::dock::PanelId::BlockPalette, - crate::ui::dock::PanelId::Properties, - ]; - // Left: block palette hidden, properties shown -> 1 visible panel. - app.show_block_palette = false; - app.show_properties = true; - assert_eq!( - app.dock_visible_len(crate::app::config::DockSide::Left), - 1 + fn dock_drop_in_bottom_quarter_docks_below() { + use crate::app::config::DockSide; + use crate::ui::dock::{DropTarget, PanelId}; + let mut app = dock_app(); + let target = drag_panel( + &mut app, + PanelId::BlockPalette, + iced::Point::new(1500.0, 10.0), + iced::Point::new(100.0, 880.0), ); - // Reveal the block palette -> both count. + assert_eq!(target, Some(DropTarget::Edge { side: DockSide::Left, index: 1 })); + assert_eq!(app.dock.location(PanelId::BlockPalette), Some((DockSide::Left, 1))); + } + + #[test] + fn dock_drop_over_the_viewport_floats_the_panel() { + use crate::ui::dock::{DropTarget, PanelId}; + let mut app = dock_app(); + let target = drag_panel( + &mut app, + PanelId::BlockPalette, + iced::Point::new(1500.0, 10.0), + iced::Point::new(800.0, 400.0), + ); + assert!(matches!(target, Some(DropTarget::Float { .. }))); + assert!(app.dock.right.is_empty()); + let f = app.dock.float_rect(PanelId::BlockPalette).expect("floating"); + assert!(f.x > 600.0 && f.x < 800.0 && f.y > 350.0 && f.y < 400.0); + // Dragging a floating panel keeps the grab point under the pointer. + let target = drag_panel( + &mut app, + PanelId::BlockPalette, + iced::Point::new(f.x + 20.0, f.y + 10.0), + iced::Point::new(f.x + 120.0, f.y + 60.0), + ); + assert_eq!(target, Some(DropTarget::Float { x: f.x + 100.0, y: f.y + 50.0 })); + // Opening a floating panel must not re-dock it. + assert!(app.dock.is_placed(PanelId::BlockPalette)); + } + + #[test] + fn dock_drop_on_own_lone_slot_is_a_no_op() { + use crate::app::config::DockSide; + use crate::ui::dock::{DropTarget, PanelId}; + let mut app = dock_app(); + let before = app.dock.clone(); + let target = drag_panel( + &mut app, + PanelId::Properties, + iced::Point::new(100.0, 10.0), + iced::Point::new(100.0, 450.0), + ); + assert_eq!(target, Some(DropTarget::Edge { side: DockSide::Left, index: 0 })); + assert_eq!(app.dock, before); + } + + #[test] + fn dock_visible_groups_skip_closed_panels() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, PanelId}; + let mut app = dock_app(); + app.dock.left = vec![ + DockGroup::single(PanelId::BlockPalette, 1.0), + DockGroup::single(PanelId::Properties, 1.0), + ]; + app.show_block_palette = false; + assert_eq!(app.dock_visible_groups(DockSide::Left), vec![1]); + // The single visible slot spans the full height, so the drop geometry + // never splits around the hidden one. + assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(1, 0.0, 900.0)]); app.show_block_palette = true; - assert_eq!( - app.dock_visible_len(crate::app::config::DockSide::Left), - 2 - ); - // A hidden (closed) panel counts for nothing even when stacked. - app.show_block_palette = false; - assert_eq!( - app.dock_visible_len(crate::app::config::DockSide::Left), - 1 - ); + assert_eq!(app.dock_visible_groups(DockSide::Left), vec![0, 1]); } #[test] - fn dock_drag_target_ignores_hidden_panels_on_the_side() { - // Reproduce the persisted layout that exposed a half-height ghost: two - // panels live in the left stack but the block palette is hidden - // (show_block_palette=false), so only Properties renders. Drag geometry - // must count only panels that are actually visible. - let mut app = fresh(); - app.dock = Default::default(); - app.dock.left = vec![ - crate::ui::dock::PanelId::BlockPalette, - crate::ui::dock::PanelId::Properties, - ]; + fn dock_group_shows_a_visible_tab_when_the_active_one_is_closed() { + use crate::app::config::DockSide; + use crate::ui::dock::PanelId; + let mut app = dock_app(); + app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0); + assert_eq!(app.dock_group_shown(DockSide::Left, 0), Some(PanelId::BlockPalette)); app.show_block_palette = false; - app.show_properties = true; - let i = app.active_tab; - app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0); - app.win_size = (1600.0, 900.0).into(); - let id = crate::ui::dock::PanelId::Properties; - let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); - // Pointer near the bottom of the left edge: one visible panel means a - // single slot, so every y maps to index 0 (no top/bottom split). - let _ = - app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0))); - assert_eq!( - app.dock_drag_target, - Some((crate::app::config::DockSide::Left, 0)) - ); + assert_eq!(app.dock_group_shown(DockSide::Left, 0), Some(PanelId::Properties)); + app.show_properties = false; + assert_eq!(app.dock_group_shown(DockSide::Left, 0), None); } #[test] - fn dock_drag_target_counts_all_visible_panels() { - let mut app = fresh(); - app.dock = Default::default(); + fn dock_splitter_drag_changes_slot_heights() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; + let mut app = dock_app(); app.dock.left = vec![ - crate::ui::dock::PanelId::BlockPalette, - crate::ui::dock::PanelId::Properties, + DockGroup::single(PanelId::BlockPalette, 1.0), + DockGroup::single(PanelId::Properties, 1.0), ]; - // Both panels shown: two real slots on the left edge. A pointer near - // the bottom maps to the append slot (index == 2). - app.show_block_palette = true; - app.show_properties = true; - let i = app.active_tab; - app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0); - app.win_size = (1600.0, 900.0).into(); - let id = crate::ui::dock::PanelId::Properties; - let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); - let _ = - app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0))); - assert_eq!( - app.dock_drag_target, - Some((crate::app::config::DockSide::Left, 2)) - ); + app.dock.right.clear(); + let _ = app.on_dock(DockMsg::SplitGrab(DockSide::Left, 0, 1)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 450.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 675.0))); + let _ = app.on_dock(DockMsg::DragRelease); + // 225 px of a 900 px edge holding 2 weight units = 0.5 units. + assert!((app.dock.left[0].weight - 1.5).abs() < 1e-4); + assert!((app.dock.left[1].weight - 0.5).abs() < 1e-4); + let _ = app.on_dock(DockMsg::SplitReset(DockSide::Left)); + assert_eq!(app.dock.left[0].weight, 1.0); + } + + #[test] + fn dock_float_resize_grows_the_panel() { + use crate::ui::dock::{DockMsg, FloatPanel, PanelId}; + let mut app = dock_app(); + app.dock.float(FloatPanel { + id: PanelId::BlockPalette, + x: 100.0, + y: 100.0, + w: 260.0, + h: 300.0, + }); + let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(360.0, 400.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(400.0, 450.0))); + let _ = app.on_dock(DockMsg::DragRelease); + let f = app.dock.float_rect(PanelId::BlockPalette).unwrap(); + assert_eq!((f.w, f.h), (300.0, 350.0)); } #[test] diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 656af6fad..187d653d0 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2568,7 +2568,7 @@ impl OpenCADStudio { if self.show_external_references { use crate::app::config::DockSide; use crate::ui::dock::PanelId; - if self.dock.location(PanelId::ExternalReferences).is_none() { + if !self.dock.is_placed(PanelId::ExternalReferences) { self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX); } self.dock_expanded = Some(PanelId::ExternalReferences); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3cbd1ec0b..2338ab121 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1877,40 +1877,12 @@ bg={bg_ms:.1}ms n={view_count}" } // Docked side panels (Properties, block palette, future palettes) live - // in an ordered vertical stack on the left/right edge of the drawing - // view. Auto-collapsing (pinned) panels that aren't being hovered - // reduce to a rail sharing 1/N of the edge column's height; the - // hovered or unpinned panel expands to the full column height on top. - let visible_panel = |id: crate::ui::dock::PanelId| -> bool { - if tab.is_start || self.clean_screen { - return false; - } - match id { - crate::ui::dock::PanelId::Properties => self.show_properties, - crate::ui::dock::PanelId::BlockPalette => self.show_block_palette, - crate::ui::dock::PanelId::ExternalReferences => self.show_external_references, - crate::ui::dock::PanelId::Browser => self.show_browser, - crate::ui::dock::PanelId::NodeGraph => self.show_node_graph, - crate::ui::dock::PanelId::PointCloudManager => self.pc_manager.show, - crate::ui::dock::PanelId::Count => self.count_palette.show, - crate::ui::dock::PanelId::SheetSetManager => self.sheet_set.show, - } - }; - let edge_stack = |side: crate::app::config::DockSide| -> Option> { - let ids: Vec = match side { - crate::app::config::DockSide::Left => self.dock.left.clone(), - crate::app::config::DockSide::Right => self.dock.right.clone(), - } - .into_iter() - .filter(|id| visible_panel(*id)) - .collect(); - if ids.is_empty() { - return None; - } - Some(self.build_edge_stack(side, &ids, tab)) - }; - let left_edge = edge_stack(crate::app::config::DockSide::Left); - let right_edge = edge_stack(crate::app::config::DockSide::Right); + // in an ordered vertical stack of slots on the left/right edge of the + // drawing view; a slot may hold several panels as tabs. Auto-collapsing + // (pinned) slots that aren't being hovered reduce to a rail; the rest + // share the column height by their adjustable weights. + let left_edge = self.build_edge_stack(crate::app::config::DockSide::Left, tab); + let right_edge = self.build_edge_stack(crate::app::config::DockSide::Right, tab); // Command-line sits as a bottom-centre overlay on top of the // viewport stack rather than as a separate row in the main @@ -1955,151 +1927,41 @@ bg={bg_ms:.1}ms n={view_count}" } let workspace: Element<'_, Message> = row(parts).width(Fill).height(Fill).into(); - let any_dragging = self.dock_dragging.is_some(); - let any_resizing = - self.dock_resizing.is_some() || self.xref_col_drag.is_some() || self.xref_split_drag; - let workspace = if any_dragging { - let id = self.dock_dragging.expect("guarded by any_dragging"); - let side = self.dock_drag_target.map(|(s, _)| s).unwrap_or( - self.dock - .location(id) - .map(|(s, _)| s) - .unwrap_or(crate::app::config::DockSide::Right), - ); - // Dropping onto an edge joins that edge's stack, whose column is as - // wide as its widest currently-shown panel (the dragged panel - // included). Hidden (closed) panels don't render, so they can't - // widen the column being previewed. - let preview_width = { - let mut widths: Vec = match side { - crate::app::config::DockSide::Left => &self.dock.left, - crate::app::config::DockSide::Right => &self.dock.right, - } - .iter() - .copied() - .filter(|&pid| self.dock_panel_visible(pid)) - .map(|pid| self.dock.width(pid, self.win_size.0)) - .collect(); - widths.push(self.dock.width(id, self.win_size.0)); - widths.into_iter().fold(0.0, f32::max) - }; - // Faint tint over the whole edge column so the target edge reads - // even before the panel-sized ghost snaps to a slot. - let edge_tint = container(Space::new()) - .width(Length::Fixed(preview_width)) - .height(Fill) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color( - palette.primary.weak.color.scale_alpha(0.35), - )), - ..Default::default() - } - }); - // The ghost is the dragged panel at its real size: its own saved - // width and its 1/N share of the edge (N = number of panels shown - // on the edge after the drop), with a header naming which panel is - // being moved. Only visibly-docked panels share the column height: - // a hidden (closed) panel keeps its stack slot but no screen space, - // so it must not split the preview. - let was_here = self.dock.location(id).map(|(s, _)| s) == Some(side); - let visible = self.dock_visible_len(side); - let final_count = if was_here { - std::cmp::max(visible, 1) - } else { - visible + 1 - }; - let edge_h = tab.scene.selection.borrow().view.vp_size.1; - let slot_h = edge_h / final_count as f32; - let index = self.dock_drag_target.map(|(_, i)| i).unwrap_or(0); - let slot = index.min(final_count.saturating_sub(1)); - let ghost_top = slot as f32 * slot_h; - let ghost_header = container(text(id.title()).size(12)) - .width(Fill) - .padding(iced::Padding { - top: 5.0, - right: 8.0, - bottom: 5.0, - left: 8.0, - }) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.primary.base.color)), - text_color: Some(palette.primary.base.text), - ..Default::default() - } - }); - let ghost_panel = container(column![ghost_header, Space::new()]) - .width(Length::Fixed(self.dock.width(id, self.win_size.0))) - .height(Length::Fixed(slot_h)) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.base.color)), - border: Border { - color: palette.primary.base.color, - width: 2.0, - radius: 0.0.into(), - }, - ..Default::default() - } - }); - // Position the ghost at its drop slot, flush against the edge. - let ghost = container(ghost_panel) - .width(Fill) - .height(Fill) - .align_x(match side { - crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left, - crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right, - }) - .align_y(iced::alignment::Vertical::Top) - .padding(iced::Padding { - top: ghost_top, - right: 0.0, - bottom: 0.0, - left: 0.0, - }); - // Thin line across the column at the drop slot's top boundary. - let drop_line = container( - container(Space::new()) - .width(Fill) - .height(Length::Fixed(3.0)) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().primary.base.color)), - ..Default::default() - }), - ) - .width(Fill) - .height(Fill) - .align_y(iced::alignment::Vertical::Top) - .padding(iced::Padding { - top: ghost_top, - right: 0.0, - bottom: 0.0, - left: 0.0, - }); - let preview = iced::widget::stack![edge_tint, ghost, drop_line] - .width(Length::Fixed(preview_width)) - .height(Fill); - let preview = container(preview) - .width(Fill) - .height(Fill) - .align_x(match side { - crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left, - crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right, - }); - stack![workspace, preview].width(Fill).height(Fill).into() + // Floating panels sit over the workspace, back to front. + let mut layers: Vec> = vec![workspace]; + for f in &self.dock.floating { + if self.dock_panel_visible(f.id) { + layers.push(self.floating_panel(*f, tab)); + } + } + // While a panel is dragged, preview where it lands. + if let (Some(id), Some(target)) = (self.dock_dragging, self.dock_drag_target) { + layers.push(self.dock_drop_preview(id, target)); + } + let workspace: Element<'_, Message> = if layers.len() == 1 { + layers.pop().expect("workspace layer") } else { - workspace + iced::widget::Stack::with_children(layers) + .width(Fill) + .height(Fill) + .into() }; - let workspace: Element<'_, Message> = if any_dragging || any_resizing { + let any_dragging = self.dock_dragging.is_some(); + let any_splitting = self.dock_split_drag.is_some(); + let any_resizing = self.dock_resizing.is_some() + || self.dock_float_resizing.is_some() + || self.xref_col_drag.is_some() + || self.xref_split_drag; + let workspace: Element<'_, Message> = if any_dragging || any_resizing || any_splitting { mouse_area(workspace) .on_move(move |p| Message::Dock(crate::ui::dock::DockMsg::DragMove(p))) .on_release(Message::Dock(crate::ui::dock::DockMsg::DragRelease)) - .interaction(if any_resizing { + .interaction(if self.dock_float_resizing.is_some() { + iced::mouse::Interaction::ResizingDiagonallyDown + } else if any_resizing { iced::mouse::Interaction::ResizingHorizontally + } else if any_splitting { + iced::mouse::Interaction::ResizingVertically } else { iced::mouse::Interaction::Grabbing }) @@ -2876,65 +2738,69 @@ impl OpenCADStudio { } /// Build one edge: a narrow tab strip at the very edge plus the expanded - /// panels sliding out beside it. Auto-collapsing (pinned) panels are always - /// visible as equal-height 1/N tabs in the strip (via `Fill` distribution); - /// hovering a tab raises its panel to full column height beside the strip, - /// so switching between stacked panels is a direct tab-to-tab hover with no - /// width-jumping collapse dance. Unpinned panels are always expanded. The - /// whole edge is one hover region: leaving it collapses any auto-collapsing - /// panel, but moving between the tabs and the expanded panel does not. + /// slots sliding out beside it. Auto-collapsing (pinned) slots are always + /// visible as equal-height tabs in the strip (via `Fill` distribution); + /// hovering a tab raises its slot beside the strip, so switching between + /// stacked panels is a direct tab-to-tab hover with no width-jumping + /// collapse dance. Unpinned slots are always expanded and split the column + /// height by their weights, with a draggable splitter between neighbours. + /// The whole edge is one hover region: leaving it collapses any + /// auto-collapsing slot, but moving between the tabs and the expanded slot + /// does not. `None` when nothing on the edge is shown. fn build_edge_stack<'a>( &'a self, side: crate::app::config::DockSide, - ids: &[crate::ui::dock::PanelId], tab: &'a DocumentTab, - ) -> Element<'a, Message> { - let pinned: Vec = ids - .iter() - .copied() - .filter(|id| self.dock.auto_collapse(*id)) - .collect(); - // Expanded = unpinned panels (always open) in stack order, then the - // hovered auto-collapsing one last so it floats on top. - let mut expanded: Vec = ids - .iter() - .copied() - .filter(|id| !self.dock.auto_collapse(*id)) - .collect(); - if let Some(id) = self.dock_expanded { - if ids.contains(&id) && self.dock.auto_collapse(id) { - expanded.push(id); - } + ) -> Option> { + let visible = self.dock_visible_groups(side); + if visible.is_empty() { + return None; } - // Each expanded panel renders at its own saved width; the column is as - // wide as the widest one currently showing. + let shown = |gi: usize| self.dock_group_shown(side, gi).expect("visible group"); + let pinned: Vec = visible + .iter() + .copied() + .filter(|gi| self.dock.auto_collapse(shown(*gi))) + .collect(); + let expanded = self.dock_expanded_groups(side); + // Each expanded slot renders at its shown panel's saved width; the + // column is as wide as the widest one currently showing. let col_w = expanded .iter() - .map(|id| self.dock.width(*id, self.win_size.0)) + .map(|gi| self.dock.width(shown(*gi), self.win_size.0)) .fold(0.0, f32::max); let tab_strip = (!pinned.is_empty()).then(|| { let tabs: Vec> = pinned .iter() - .map(|id| { + .map(|gi| { + let tabs = self.dock_group_visible_tabs(side, *gi); self.rail_slice( - *id, + shown(*gi), + &tabs, side, - self.dock_expanded == Some(*id), - self.dock_dragging == Some(*id), + self.dock_expanded.is_some_and(|e| tabs.contains(&e)), + self.dock_dragging.is_some_and(|d| tabs.contains(&d)), ) }) .collect(); column(tabs).width(DOCK_RAIL_W).height(Fill).into() }); let expanded_stack = (!expanded.is_empty()).then(|| { - // Multiple simultaneously-expanded panels (e.g. two unpinned panels - // on the same edge) stack one below the other, each taking 1/N of - // the column height via equal `Fill` shares — never overlapping. - let layers: Vec> = expanded - .iter() - .map(|id| self.expanded_panel(*id, side, col_w, tab)) - .collect(); + // Expanded slots stack one below the other, each taking its + // weighted share of the column height — never overlapping. + let mut layers: Vec> = Vec::new(); + for (n, gi) in expanded.iter().enumerate() { + if n > 0 { + layers.push(dock_splitter(side, expanded[n - 1], *gi)); + } + let weight = self.dock.groups(side)[*gi].weight; + layers.push( + container(self.expanded_group(side, *gi, col_w, tab)) + .height(Length::FillPortion(crate::ui::dock::portion(weight))) + .into(), + ); + } column(layers).height(Fill).into() }); @@ -2957,22 +2823,30 @@ impl OpenCADStudio { } } } - mouse_area(row(children).height(Fill)) - .on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit)) - .into() + Some( + mouse_area(row(children).height(Fill)) + .on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit)) + .into(), + ) } - /// One narrow tab in the edge strip. Equal `Fill` heights across the tabs - /// give each 1/N of the strip; hovering or clicking it raises its panel. + /// One narrow tab in the edge strip, naming every panel of its slot. + /// Equal `Fill` heights across the tabs give each 1/N of the strip; + /// hovering or clicking it raises its slot. fn rail_slice( &self, id: crate::ui::dock::PanelId, + tabs: &[crate::ui::dock::PanelId], side: crate::app::config::DockSide, is_active: bool, is_dragging: bool, ) -> Element<'_, Message> { let label = canvas(VBarLabel { - text: id.title().to_string(), + text: tabs + .iter() + .map(|t| t.title()) + .collect::>() + .join(" · "), clockwise: side == crate::app::config::DockSide::Left, }) .width(Fill) @@ -3010,19 +2884,15 @@ impl OpenCADStudio { .into() } - /// A panel expanded to the full edge column: the panel body plus a - /// grabbable divider against the viewport. Hovering is handled by the - /// enclosing edge region (see `build_edge_stack`), so the body stays fully - /// interactive without fighting the region's hover tracking. - fn expanded_panel<'a>( + /// The content of panel `id` at `width`. + fn panel_body<'a>( &'a self, id: crate::ui::dock::PanelId, - side: crate::app::config::DockSide, width: f32, tab: &'a DocumentTab, ) -> Element<'a, Message> { let auto_collapse = self.dock.auto_collapse(id); - let panel: Element<'_, Message> = match id { + match id { crate::ui::dock::PanelId::Properties => tab.properties.view(width, auto_collapse), crate::ui::dock::PanelId::BlockPalette => { crate::ui::window::block_palette::view(&self.block_palette, width, auto_collapse) @@ -3059,6 +2929,30 @@ impl OpenCADStudio { crate::ui::dock::PanelId::SheetSetManager => { crate::ui::window::sheet_set::view(&self.sheet_set, width, auto_collapse) } + } + } + + /// An expanded slot: its tab strip (when it holds more than one shown + /// panel), the shown panel, and a grabbable divider against the viewport. + /// Hovering is handled by the enclosing edge region (see + /// `build_edge_stack`), so the body stays fully interactive without + /// fighting the region's hover tracking. + fn expanded_group<'a>( + &'a self, + side: crate::app::config::DockSide, + gi: usize, + width: f32, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + let tabs = self.dock_group_visible_tabs(side, gi); + let id = self.dock_group_shown(side, gi).expect("visible group"); + let body = self.panel_body(id, width, tab); + let panel: Element<'_, Message> = if tabs.len() > 1 { + column![dock_tab_strip(&tabs, id, width), body] + .height(Fill) + .into() + } else { + body }; let divider = dock_divider(id); match side { @@ -3066,6 +2960,181 @@ impl OpenCADStudio { crate::app::config::DockSide::Right => row![divider, panel].height(Fill).into(), } } + + /// A floating panel at its saved place, with a corner grip to resize it. + fn floating_panel<'a>( + &'a self, + f: crate::ui::dock::FloatPanel, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + use crate::ui::dock::DockMsg; + let (ww, wh) = self.dock_workspace_size(); + let grip = mouse_area( + container(crate::ui::icons::themed_secondary(crate::ui::icons::RESIZE, 12.0)) + .width(Length::Fixed(14.0)) + .height(Length::Fixed(14.0)) + .center_x(Length::Fixed(14.0)) + .center_y(Length::Fixed(14.0)), + ) + .on_press(Message::Dock(DockMsg::FloatResizeGrab(f.id))) + .interaction(iced::mouse::Interaction::ResizingDiagonallyDown); + let body = stack![ + self.panel_body(f.id, f.w, tab), + container(grip) + .width(Fill) + .height(Fill) + .align_x(iced::alignment::Horizontal::Right) + .align_y(iced::alignment::Vertical::Bottom), + ] + .width(Length::Fixed(f.w)) + .height(Length::Fixed(f.h)); + let framed = container(body).style(|theme: &Theme| container::Style { + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 0.0.into(), + }, + shadow: iced::Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.35), + offset: iced::Vector::new(0.0, 4.0), + blur_radius: 14.0, + }, + ..Default::default() + }); + // The idle cursor marks the panel as opaque to the pointer, so clicks + // on its empty areas don't fall through to the drawing below. + let panel = mouse_area(framed) + .on_press(Message::Dock(DockMsg::FloatRaise(f.id))) + .interaction(iced::mouse::Interaction::Idle); + // Keep the panel on screen when the window shrank since it was placed. + let x = f.x.min(ww - f.w).max(0.0); + let y = f.y.min(wh - f.h).max(0.0); + place_at(panel.into(), x, y) + } + + /// The live drop preview for dragged panel `id`: a tint over the target + /// edge plus a panel-sized ghost where it lands — a new slot above or + /// below, a new tab over a whole slot, or a floating window. + fn dock_drop_preview( + &self, + id: crate::ui::dock::PanelId, + target: crate::ui::dock::DropTarget, + ) -> Element<'_, Message> { + use crate::app::config::DockSide; + use crate::ui::dock::DropTarget; + let (ww, _) = self.dock_workspace_size(); + let id_w = self.dock.width(id, self.win_size.0); + let edge_w = |side: DockSide| self.dock_column_width(side).max(id_w); + let tint = |side: DockSide| -> Element<'_, Message> { + let w = edge_w(side); + let band = container(Space::new()) + .width(Length::Fixed(w)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().primary.weak.color.scale_alpha(0.35), + )), + ..Default::default() + }); + container(band) + .width(Fill) + .height(Fill) + .align_x(match side { + DockSide::Left => iced::alignment::Horizontal::Left, + DockSide::Right => iced::alignment::Horizontal::Right, + }) + .into() + }; + let edge_x = |side: DockSide, w: f32| match side { + DockSide::Left => 0.0, + DockSide::Right => (ww - w).max(0.0), + }; + let mut layers: Vec> = Vec::new(); + match target { + DropTarget::Edge { side, index } => { + let (top, bottom) = self.dock_edge_preview_span(id, side, index); + let w = edge_w(side); + layers.push(tint(side)); + layers.push(place_at( + dock_ghost(&[id], id, w, bottom - top), + edge_x(side, w), + top, + )); + } + DropTarget::Tab { side, group } => { + let (top, bottom) = self + .dock_slot_spans(side) + .into_iter() + .find(|(gi, _, _)| *gi == group) + .map(|(_, t, b)| (t, b)) + .unwrap_or((0.0, 0.0)); + let mut tabs = self.dock_group_visible_tabs(side, group); + tabs.retain(|t| *t != id); + tabs.push(id); + let w = edge_w(side); + layers.push(tint(side)); + layers.push(place_at( + dock_ghost(&tabs, id, w, bottom - top), + edge_x(side, w), + top, + )); + } + DropTarget::Float { x, y } => { + let (w, h) = self.dock_float_size(id); + layers.push(place_at(dock_ghost(&[id], id, w, h), x, y)); + } + } + iced::widget::Stack::with_children(layers) + .width(Fill) + .height(Fill) + .into() + } + + /// Vertical span the dragged panel `id` would take as a new slot at + /// insertion `index` on `side`, after it leaves its current place. + fn dock_edge_preview_span( + &self, + id: crate::ui::dock::PanelId, + side: crate::app::config::DockSide, + index: usize, + ) -> (f32, f32) { + let (_, avail) = self.dock_workspace_size(); + let groups = self.dock.groups(side); + let lone_here = |gi: usize| self.dock_group_visible_tabs(side, gi) == [id]; + let mut own_weight = None; + // (weight, is_new) for each visible slot after the move. + let mut slots: Vec<(f32, bool)> = Vec::new(); + let mut inserted = false; + for gi in self.dock_visible_groups(side) { + if !inserted && gi >= index { + slots.push((0.0, true)); + inserted = true; + } + if lone_here(gi) { + own_weight = Some(groups[gi].weight); + continue; + } + slots.push((groups[gi].weight, false)); + } + if !inserted { + slots.push((0.0, true)); + } + let others: Vec = slots.iter().filter(|s| !s.1).map(|s| s.0).collect(); + let new_weight = own_weight.unwrap_or(if others.is_empty() { + 1.0 + } else { + others.iter().sum::() / others.len() as f32 + }); + let weights: Vec = slots + .iter() + .map(|(w, is_new)| if *is_new { new_weight } else { *w }) + .collect(); + let at = slots.iter().position(|s| s.1).expect("new slot"); + crate::ui::dock::slot_spans(&weights, avail) + .get(at) + .copied() + .unwrap_or((0.0, avail)) + } } // ── Document tab bar ─────────────────────────────────────────────────────── @@ -3456,6 +3525,176 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { .into() } +/// Height of a docked slot's tab strip. +const DOCK_TAB_H: f32 = 24.0; + +/// The tab row above a slot holding several panels. Pressing a tab shows its +/// panel; dragging it pulls the panel out of the slot. +fn dock_tab_strip( + tabs: &[crate::ui::dock::PanelId], + active: crate::ui::dock::PanelId, + width: f32, +) -> Element<'static, Message> { + let cells: Vec> = tabs + .iter() + .map(|id| { + let id = *id; + let is_active = id == active; + let label = text(id.title()) + .size(11) + .wrapping(iced::widget::text::Wrapping::None) + .ellipsis(iced::advanced::text::Ellipsis::End); + let cell = container(label) + .width(Fill) + .height(Fill) + .padding([0, 8]) + .center_y(Fill) + .clip(true) + .style(move |theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(if is_active { + palette.background.base.color + } else { + palette.background.weak.color + })), + text_color: Some(if is_active { + palette.background.base.text + } else { + palette.background.weak.text.scale_alpha(0.75) + }), + border: Border { + color: if is_active { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + width: if is_active { 1.0 } else { 0.5 }, + radius: iced::border::Radius::default().top(4.0), + }, + ..Default::default() + } + }); + mouse_area(cell) + .on_press(Message::Dock(crate::ui::dock::DockMsg::SelectTab(id))) + .interaction(iced::mouse::Interaction::Pointer) + .into() + }) + .collect(); + container(row(cells).spacing(2).height(Fill)) + .width(Length::Fixed(width)) + .height(Length::Fixed(DOCK_TAB_H)) + .padding(iced::Padding { + top: 3.0, + right: 4.0, + bottom: 0.0, + left: 4.0, + }) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.strong.color)), + ..Default::default() + }) + .into() +} + +/// Draggable bar between two expanded slots on an edge; double-click gives +/// every slot on the edge the same height again. +fn dock_splitter( + side: crate::app::config::DockSide, + upper: usize, + lower: usize, +) -> Element<'static, Message> { + let line = container(Space::new()) + .width(Fill) + .height(Length::Fixed(5.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }); + mouse_area(line) + .on_press(Message::Dock(crate::ui::dock::DockMsg::SplitGrab(side, upper, lower))) + .on_double_click(Message::Dock(crate::ui::dock::DockMsg::SplitReset(side))) + .interaction(iced::mouse::Interaction::ResizingVertically) + .into() +} + +/// The blue drop ghost: a `w`×`h` panel outline whose header shows the tabs +/// the slot will hold, with the dragged panel's tab highlighted. +fn dock_ghost( + tabs: &[crate::ui::dock::PanelId], + dragged: crate::ui::dock::PanelId, + w: f32, + h: f32, +) -> Element<'static, Message> { + let cells: Vec> = tabs + .iter() + .map(|id| { + let is_dragged = *id == dragged; + container( + text(id.title()) + .size(12) + .wrapping(iced::widget::text::Wrapping::None) + .ellipsis(iced::advanced::text::Ellipsis::End), + ) + .width(Fill) + .padding([5, 8]) + .clip(true) + .style(move |theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(if is_dragged { + palette.primary.base.color + } else { + palette.primary.weak.color + })), + text_color: Some(if is_dragged { + palette.primary.base.text + } else { + palette.primary.weak.text + }), + ..Default::default() + } + }) + .into() + }) + .collect(); + container(column![row(cells).spacing(2), Space::new()]) + .width(Length::Fixed(w.max(1.0))) + .height(Length::Fixed(h.max(1.0))) + .clip(true) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color( + palette.background.base.color.scale_alpha(0.85), + )), + border: Border { + color: palette.primary.base.color, + width: 2.0, + radius: 0.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +/// Position `content` with its top-left corner at workspace point (`x`, `y`). +fn place_at(content: Element<'_, Message>, x: f32, y: f32) -> Element<'_, Message> { + container(content) + .width(Fill) + .height(Fill) + .align_x(iced::alignment::Horizontal::Left) + .align_y(iced::alignment::Vertical::Top) + .padding(iced::Padding { + top: y.max(0.0), + right: 0.0, + bottom: 0.0, + left: x.max(0.0), + }) + .into() +} + const START_ACTION_RADIUS: f32 = 6.0; fn start_action_shape(mut style: button::Style) -> button::Style { diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 3cbe3bf94..c564b804c 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -1,11 +1,12 @@ //! General edge-stack docking for side panels. //! //! Any number of dockable panels (Properties, the block palette, future -//! palettes) live in an ordered vertical stack on the left or right edge of -//! the drawing view. This module owns the persisted layout (which panels are -//! docked, on which side, in what vertical order, at what width, and whether -//! each auto-collapses) plus the pure geometry used to render and hover an -//! edge's stacked panels. +//! palettes) live in an ordered vertical stack of slots on the left or right +//! edge of the drawing view, or float over it. A slot holds one panel or +//! several sharing it as tabs, and owns an adjustable share of the edge +//! height. This module owns the persisted layout (which panels are docked or +//! floating, where, at what size, and whether each auto-collapses) plus the +//! pure geometry used to render and hover an edge's stacked slots. use crate::app::config::DockSide; use serde::{Deserialize, Serialize}; @@ -31,6 +32,16 @@ pub enum DockMsg { DragMove(iced::Point), /// Pointer released after a drag / resize. DragRelease, + /// Show `panel`'s tab in its tab group. + SelectTab(PanelId), + /// Begin dragging the splitter between slots `upper` and `lower` on a side. + SplitGrab(DockSide, usize, usize), + /// Give every slot on a side the same height again. + SplitReset(DockSide), + /// Begin resizing floating `panel` from its corner grip. + FloatResizeGrab(PanelId), + /// Bring floating `panel` to the front. + FloatRaise(PanelId), /// The pointer left the edge column; collapse any auto-collapsing panel. HoverExit, } @@ -119,16 +130,111 @@ impl DockPanel { } } -/// The whole dock layout: two ordered per-side stacks plus per-panel settings. -/// Only the persisted layout lives here; transient drag/hover state is app -/// state (see `update::mod`) so it is skipped by serialization. +/// One slot of an edge stack: one or more panels sharing the slot as tabs. +/// A single-panel group renders without a tab strip, exactly like the old +/// one-panel-per-slot stack. `weight` is the slot's share of the edge height +/// relative to the other slots on the same edge (equal weights = equal split). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(from = "GroupRepr")] +pub struct DockGroup { + /// Panels in tab order. + pub tabs: Vec, + /// The tab currently shown. Always one of `tabs`. + pub active: PanelId, + /// Relative share of the edge height. + pub weight: f32, +} + +/// On-disk forms of a [`DockGroup`]: configs written before tab groups stored +/// each slot as a bare panel id. +#[derive(Deserialize)] +#[serde(untagged)] +enum GroupRepr { + Single(PanelId), + Group { + tabs: Vec, + #[serde(default)] + active: Option, + #[serde(default = "default_weight")] + weight: f32, + }, +} + +fn default_weight() -> f32 { + 1.0 +} + +impl From for DockGroup { + fn from(repr: GroupRepr) -> Self { + match repr { + GroupRepr::Single(id) => DockGroup::single(id, 1.0), + GroupRepr::Group { + tabs, + active, + weight, + } => { + // A hand-edited or corrupt group still yields a usable slot. + let tabs = if tabs.is_empty() { + vec![PanelId::Properties] + } else { + tabs + }; + let active = active.filter(|a| tabs.contains(a)).unwrap_or(tabs[0]); + let weight = if weight.is_finite() && weight > 0.0 { + weight.clamp(MIN_WEIGHT, MAX_WEIGHT) + } else { + 1.0 + }; + DockGroup { + tabs, + active, + weight, + } + } + } + } +} + +impl DockGroup { + pub fn single(id: PanelId, weight: f32) -> Self { + Self { + tabs: vec![id], + active: id, + weight, + } + } +} + +/// A panel floating over the workspace, in workspace-local pixels. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct FloatPanel { + pub id: PanelId, + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +/// Smallest floating panel height. +pub const FLOAT_MIN_H: f32 = 160.0; +/// Smallest on-screen height of a docked slot while dragging a splitter. +pub const GROUP_MIN_H: f32 = 80.0; +const MIN_WEIGHT: f32 = 0.05; +const MAX_WEIGHT: f32 = 20.0; + +/// The whole dock layout: two ordered per-side stacks of tab groups, the +/// floating panels, plus per-panel settings. Only the persisted layout lives +/// here; transient drag/hover state is app state (see `update::mod`) so it is +/// skipped by serialization. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct DockState { - /// Panels anchored to the left edge, top → bottom. - pub left: Vec, - /// Panels anchored to the right edge, top → bottom. - pub right: Vec, + /// Slots anchored to the left edge, top → bottom. + pub left: Vec, + /// Slots anchored to the right edge, top → bottom. + pub right: Vec, + /// Floating panels, back → front. + pub floating: Vec, /// Per-panel width / auto-collapse settings, keyed by `PanelId`. pub panels: BTreeMap, } @@ -136,23 +242,24 @@ pub struct DockState { impl Default for DockState { fn default() -> Self { Self { - left: vec![PanelId::Properties], - right: vec![PanelId::BlockPalette], + left: vec![DockGroup::single(PanelId::Properties, 1.0)], + right: vec![DockGroup::single(PanelId::BlockPalette, 1.0)], + floating: Vec::new(), panels: BTreeMap::new(), } } } impl DockState { - /// Edge-collection of the panels ordered for rendering on `side`. - fn stack(&self, side: DockSide) -> &[PanelId] { + /// The slots stacked on `side`, top → bottom. + pub fn groups(&self, side: DockSide) -> &[DockGroup] { match side { DockSide::Left => &self.left, DockSide::Right => &self.right, } } - fn stack_mut(&mut self, side: DockSide) -> &mut Vec { + fn groups_mut(&mut self, side: DockSide) -> &mut Vec { match side { DockSide::Left => &mut self.left, DockSide::Right => &mut self.right, @@ -161,7 +268,8 @@ impl DockState { /// Guarantee every known `PanelId` has a settings entry, so rendering and /// resize never hit a missing configuration. Also a cheap heal for configs - /// written by an older version. + /// written by an older version (or edited by hand): a panel placed twice + /// keeps only its first placement. pub fn ensure_settings(&mut self) { for id in [ PanelId::Properties, @@ -175,18 +283,43 @@ impl DockState { ] { self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); } + let mut seen = std::collections::BTreeSet::new(); + for side in [DockSide::Left, DockSide::Right] { + let groups = self.groups_mut(side); + for g in groups.iter_mut() { + g.tabs.retain(|id| seen.insert(*id)); + if !g.tabs.contains(&g.active) { + if let Some(first) = g.tabs.first() { + g.active = *first; + } + } + } + groups.retain(|g| !g.tabs.is_empty()); + } + self.floating.retain(|f| seen.insert(f.id)); } - /// Where (if anywhere) a panel is currently docked. + /// Where (if anywhere) a panel is docked: its side and slot index. pub fn location(&self, id: PanelId) -> Option<(DockSide, usize)> { for side in [DockSide::Left, DockSide::Right] { - if let Some(i) = self.stack(side).iter().position(|p| *p == id) { + if let Some(i) = self.groups(side).iter().position(|g| g.tabs.contains(&id)) { return Some((side, i)); } } None } + /// The floating placement of `id`, if it floats. + pub fn float_rect(&self, id: PanelId) -> Option { + self.floating.iter().find(|f| f.id == id).copied() + } + + /// Whether `id` has a place in the layout (docked or floating). A panel + /// without one is docked on the right when it is opened. + pub fn is_placed(&self, id: PanelId) -> bool { + self.location(id).is_some() || self.float_rect(id).is_some() + } + pub fn settings(&self, id: PanelId) -> DockPanel { self.panels .get(&id) @@ -222,43 +355,203 @@ impl DockState { entry.auto_collapse = on; } - /// Dock `id` to `side` at `index` (clamped), removing it from any other - /// stack first. Returns whether the layout actually changed. - pub fn dock(&mut self, id: PanelId, side: DockSide, index: usize) -> bool { - if let Some((old_side, old_i)) = self.location(id) { - if old_side == side && old_i == index { - return false; - } - self.stack_mut(old_side).remove(old_i); + /// Take `id` out of the layout. Returns the docked slot it left and + /// whether that slot disappeared (it held no other tab). + fn detach(&mut self, id: PanelId) -> Option<(DockSide, usize, bool)> { + self.floating.retain(|f| f.id != id); + let (side, gi) = self.location(id)?; + let groups = self.groups_mut(side); + let g = &mut groups[gi]; + g.tabs.retain(|t| *t != id); + if g.tabs.is_empty() { + groups.remove(gi); + return Some((side, gi, true)); } - let stack = self.stack_mut(side); - let index = index.min(stack.len()); - stack.insert(index, id); + if g.active == id { + g.active = g.tabs[0]; + } + Some((side, gi, false)) + } + + /// Dock `id` as its own slot on `side` at insertion `index` (0 = top, + /// `len` = bottom), counted in the stack as it is *before* the move. + /// Returns whether the layout actually changed. + pub fn dock(&mut self, id: PanelId, side: DockSide, index: usize) -> bool { + let before = self.clone(); + let weight = { + let groups = self.groups(side); + if groups.is_empty() { + 1.0 + } else { + groups.iter().map(|g| g.weight).sum::() / groups.len() as f32 + } + }; + let mut index = index; + let mut weight = weight; + if let Some((old_side, old_i, removed)) = self.detach(id) { + if old_side == side && removed { + // Moving within an edge keeps the slot's own height share. + weight = before.groups(side)[old_i].weight; + if old_i < index { + index -= 1; + } + } + } + let groups = self.groups_mut(side); + let index = index.min(groups.len()); + groups.insert(index, DockGroup::single(id, weight)); + *self != before + } + + /// Add `id` as a tab of slot `group` on `side` (index counted before the + /// move) and show it. Returns whether the layout changed. + pub fn add_tab(&mut self, id: PanelId, side: DockSide, group: usize) -> bool { + if group >= self.groups(side).len() { + return false; + } + if self.groups(side)[group].tabs.contains(&id) { + return self.select_tab(id); + } + let mut group = group; + if let Some((old_side, old_i, removed)) = self.detach(id) { + if old_side == side && removed && old_i < group { + group -= 1; + } + } + let g = &mut self.groups_mut(side)[group]; + g.tabs.push(id); + g.active = id; true } + + /// Float `id` at `rect`, on top of the other floating panels. + pub fn float(&mut self, rect: FloatPanel) -> bool { + let before = self.clone(); + self.detach(rect.id); + self.floating.push(rect); + *self != before + } + + /// Bring a floating panel to the front. + pub fn raise_float(&mut self, id: PanelId) { + if let Some(i) = self.floating.iter().position(|f| f.id == id) { + let f = self.floating.remove(i); + self.floating.push(f); + } + } + + /// Resize a floating panel, keeping it at least a usable size. + pub fn resize_float(&mut self, id: PanelId, w: f32, h: f32) { + if let Some(f) = self.floating.iter_mut().find(|f| f.id == id) { + f.w = w.clamp(DOCK_MIN_W, id.max_width()); + f.h = h.max(FLOAT_MIN_H); + } + } + + /// Show `id` in its tab group. Returns whether the active tab changed. + pub fn select_tab(&mut self, id: PanelId) -> bool { + let Some((side, gi)) = self.location(id) else { + return false; + }; + let g = &mut self.groups_mut(side)[gi]; + let changed = g.active != id; + g.active = id; + changed + } + + /// Move the boundary between slots `upper` and `lower` on `side` by + /// `delta` weight units, keeping their combined share and each above + /// `min_weight`. + pub fn shift_split( + &mut self, + side: DockSide, + upper: usize, + lower: usize, + delta: f32, + min_weight: f32, + ) { + let groups = self.groups_mut(side); + if upper >= groups.len() || lower >= groups.len() || upper == lower { + return; + } + let total = groups[upper].weight + groups[lower].weight; + let min = min_weight.clamp(MIN_WEIGHT, total * 0.5); + let up = (groups[upper].weight + delta).clamp(min, total - min); + groups[upper].weight = up; + groups[lower].weight = total - up; + } + + /// Give every slot on `side` the same height again. + pub fn reset_splits(&mut self, side: DockSide) { + for g in self.groups_mut(side) { + g.weight = 1.0; + } + } } -/// Insertion index (0..=total) for dropping a panel whose pointer is at -/// screen-local `y` within an edge column of `avail` height holding `total` -/// slots. Used to compute the live drag target index while reordering. -pub fn drop_index(y: f32, total: usize, avail: f32) -> usize { - if total == 0 || avail <= 0.0 { - return 0; +/// Where a dragged panel lands on release. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DropTarget { + /// A new slot on `side` at insertion `index` (0 = top, len = bottom). + Edge { side: DockSide, index: usize }, + /// A new tab in slot `group` on `side`. + Tab { side: DockSide, group: usize }, + /// Floating with its top-left corner at (`x`, `y`). + Float { x: f32, y: f32 }, +} + +/// Which part of a docked slot the pointer is over while dragging: the top +/// and bottom quarters split the slot (dock above / below), the middle joins +/// it as a tab. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SlotZone { + Above, + Middle, + Below, +} + +/// Classify a pointer at `y` within a slot spanning `top..bottom`. +pub fn slot_zone(y: f32, top: f32, bottom: f32) -> SlotZone { + let h = (bottom - top).max(1.0); + let band = (h * 0.25).min(80.0); + if y < top + band { + SlotZone::Above + } else if y > bottom - band { + SlotZone::Below + } else { + SlotZone::Middle } - let h = avail / total as f32; - let idx = (y / h).round() as usize; - // A single full-height panel must not offer a top/bottom split: any drop - // lands in the one available slot. - if total <= 1 { - return 0; +} + +/// Split `avail` pixels of height between slots of `weights`, returning each +/// slot's `(top, bottom)`. +pub fn slot_spans(weights: &[f32], avail: f32) -> Vec<(f32, f32)> { + let total: f32 = weights.iter().sum(); + if total <= 0.0 { + return Vec::new(); } - idx.min(total) + let mut y = 0.0; + weights + .iter() + .map(|w| { + let top = y; + y += avail * w / total; + (top, y) + }) + .collect() +} + +/// Integer share for iced's `FillPortion` from a slot weight. +pub fn portion(weight: f32) -> u16 { + (weight * 1000.0).round().clamp(1.0, u16::MAX as f32) as u16 } /// Smallest docked width a panel may be dragged or sized to. pub const DOCK_MIN_W: f32 = 200.0; /// Largest docked width a panel may be dragged or sized to. pub const DOCK_MAX_W: f32 = 600.0; +/// Width of the band along an empty edge that docks a dragged panel there. +pub const DOCK_EDGE_ZONE: f32 = 48.0; // ── Shared panel chrome ───────────────────────────────────────────────── @@ -460,29 +753,178 @@ mod tests { } #[test] - fn drop_index_maps_between_zero_and_total() { - assert_eq!(drop_index(0.0, 3, 300.0), 0); - assert_eq!(drop_index(100.0, 3, 300.0), 1); - assert_eq!(drop_index(299.0, 3, 300.0), 3); - assert_eq!(drop_index(300.0, 3, 300.0), 3); - assert_eq!(drop_index(50.0, 0, 300.0), 0); + fn legacy_config_with_bare_panel_ids_loads_as_single_slots() { + let json = r#"{"left":["properties","block_palette"],"right":[]}"#; + let state: DockState = serde_json::from_str(json).unwrap(); + assert_eq!(state.left.len(), 2); + assert_eq!(state.left[0], DockGroup::single(PanelId::Properties, 1.0)); + assert_eq!(state.location(PanelId::BlockPalette), Some((DockSide::Left, 1))); + assert!(state.floating.is_empty()); } #[test] - fn drop_index_single_slot_is_always_zero() { - // One panel sharing the full edge height: any pointer y lands in the - // single slot, so the insertion index must be 0 (no top/bottom split). - assert_eq!(drop_index(5.0, 1, 900.0), 0); - assert_eq!(drop_index(450.0, 1, 900.0), 0); - assert_eq!(drop_index(895.0, 1, 900.0), 0); + fn layout_round_trips_through_serde() { + let mut state = DockState::default(); + state.add_tab(PanelId::Browser, DockSide::Left, 0); + state.float(FloatPanel { + id: PanelId::Count, + x: 10.0, + y: 20.0, + w: 300.0, + h: 400.0, + }); + let json = serde_json::to_string(&state).unwrap(); + let back: DockState = serde_json::from_str(&json).unwrap(); + assert_eq!(back, state); } #[test] - fn drop_index_multi_slot_maps_position() { - // Two panels on an edge produce insertion positions 0..=total: pointer - // near the top lands before the first slot (0); pointer near the bottom - // of the last slot lands after the last (== total, append). - assert_eq!(drop_index(10.0, 2, 900.0), 0); - assert_eq!(drop_index(890.0, 2, 900.0), 2); + fn add_tab_joins_a_slot_and_shows_the_new_tab() { + let mut state = DockState::default(); + assert!(state.add_tab(PanelId::BlockPalette, DockSide::Left, 0)); + assert!(state.right.is_empty(), "its old slot disappears"); + assert_eq!(state.left.len(), 1); + assert_eq!( + state.left[0].tabs, + vec![PanelId::Properties, PanelId::BlockPalette] + ); + assert_eq!(state.left[0].active, PanelId::BlockPalette); + assert!(state.select_tab(PanelId::Properties)); + assert_eq!(state.left[0].active, PanelId::Properties); } -} \ No newline at end of file + + #[test] + fn docking_a_tab_out_keeps_the_rest_of_the_group() { + let mut state = DockState::default(); + state.add_tab(PanelId::BlockPalette, DockSide::Left, 0); + // Pull the shown tab out below the group. + assert!(state.dock(PanelId::BlockPalette, DockSide::Left, 1)); + assert_eq!(state.left.len(), 2); + assert_eq!(state.left[0].tabs, vec![PanelId::Properties]); + assert_eq!(state.left[0].active, PanelId::Properties); + assert_eq!(state.location(PanelId::BlockPalette), Some((DockSide::Left, 1))); + } + + #[test] + fn add_tab_index_counts_slots_before_the_move() { + let mut state = DockState::default(); + state.left = vec![ + DockGroup::single(PanelId::BlockPalette, 1.0), + DockGroup::single(PanelId::Properties, 1.0), + ]; + state.right.clear(); + // Slot 1 (Properties) is slot 0 once the palette's slot is gone. + assert!(state.add_tab(PanelId::BlockPalette, DockSide::Left, 1)); + assert_eq!(state.left.len(), 1); + assert_eq!( + state.left[0].tabs, + vec![PanelId::Properties, PanelId::BlockPalette] + ); + } + + #[test] + fn moving_a_slot_down_keeps_its_height_share() { + let mut state = DockState::default(); + state.left = vec![ + DockGroup::single(PanelId::BlockPalette, 0.5), + DockGroup::single(PanelId::Properties, 1.5), + ]; + assert!(state.dock(PanelId::BlockPalette, DockSide::Left, 2)); + assert_eq!(state.left[0].tabs, vec![PanelId::Properties]); + assert_eq!(state.left[1], DockGroup::single(PanelId::BlockPalette, 0.5)); + } + + #[test] + fn float_detaches_and_dock_brings_it_back() { + let mut state = DockState::default(); + let rect = FloatPanel { + id: PanelId::Properties, + x: 100.0, + y: 50.0, + w: 250.0, + h: 300.0, + }; + assert!(state.float(rect)); + assert!(state.left.is_empty()); + assert_eq!(state.location(PanelId::Properties), None); + assert_eq!(state.float_rect(PanelId::Properties), Some(rect)); + assert!(state.is_placed(PanelId::Properties)); + assert!(state.dock(PanelId::Properties, DockSide::Right, 0)); + assert!(state.floating.is_empty()); + assert_eq!(state.location(PanelId::Properties), Some((DockSide::Right, 0))); + } + + #[test] + fn float_resize_respects_minimums() { + let mut state = DockState::default(); + state.float(FloatPanel { + id: PanelId::Count, + x: 0.0, + y: 0.0, + w: 300.0, + h: 300.0, + }); + state.resize_float(PanelId::Count, 10.0, 10.0); + let f = state.float_rect(PanelId::Count).unwrap(); + assert_eq!((f.w, f.h), (DOCK_MIN_W, FLOAT_MIN_H)); + } + + #[test] + fn raise_float_moves_it_to_the_front() { + let mut state = DockState::default(); + for id in [PanelId::Count, PanelId::Browser] { + state.float(FloatPanel { + id, + x: 0.0, + y: 0.0, + w: 300.0, + h: 300.0, + }); + } + state.raise_float(PanelId::Count); + assert_eq!(state.floating.last().map(|f| f.id), Some(PanelId::Count)); + } + + #[test] + fn shift_split_keeps_combined_share_and_minimum() { + let mut state = DockState::default(); + state.left = vec![ + DockGroup::single(PanelId::BlockPalette, 1.0), + DockGroup::single(PanelId::Properties, 1.0), + ]; + state.shift_split(DockSide::Left, 0, 1, 0.5, 0.1); + assert_eq!((state.left[0].weight, state.left[1].weight), (1.5, 0.5)); + state.shift_split(DockSide::Left, 0, 1, 10.0, 0.1); + assert!((state.left[1].weight - 0.1).abs() < 1e-6); + assert!((state.left[0].weight + state.left[1].weight - 2.0).abs() < 1e-6); + state.reset_splits(DockSide::Left); + assert_eq!((state.left[0].weight, state.left[1].weight), (1.0, 1.0)); + } + + #[test] + fn ensure_settings_drops_duplicate_placements() { + let mut state = DockState::default(); + state.right.push(DockGroup::single(PanelId::Properties, 1.0)); + state.ensure_settings(); + assert_eq!(state.location(PanelId::Properties), Some((DockSide::Left, 0))); + assert_eq!(state.right.len(), 1); + } + + #[test] + fn slot_zone_splits_quarters() { + assert_eq!(slot_zone(10.0, 0.0, 400.0), SlotZone::Above); + assert_eq!(slot_zone(200.0, 0.0, 400.0), SlotZone::Middle); + assert_eq!(slot_zone(390.0, 0.0, 400.0), SlotZone::Below); + // Tall slots cap the split bands so the tab zone stays generous. + assert_eq!(slot_zone(100.0, 0.0, 1000.0), SlotZone::Middle); + } + + #[test] + fn slot_spans_follow_weights() { + assert_eq!( + slot_spans(&[1.0, 3.0], 400.0), + vec![(0.0, 100.0), (100.0, 400.0)] + ); + assert!(slot_spans(&[], 400.0).is_empty()); + } +}