//! 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. use crate::app::config::DockSide; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; /// Dock chrome interactions. These are panel-agnostic so any dockable panel /// (Properties, the block palette, future palettes) shares one code path. #[derive(Debug, Clone)] pub enum DockMsg { /// Begin dragging `panel` to another side / position. DockGrab(PanelId), /// Begin resizing `panel`'s width. ResizeGrab(PanelId), /// Reset `panel`'s width to its default. WidthReset(PanelId), /// Toggle `panel`'s auto-collapse (pin) behavior. AutoCollapseToggle(PanelId), /// Close / hide `panel`. Close(PanelId), /// The pointer is over `panel`, raising it to full height. Hover(PanelId), /// Pointer moved while a panel is dragging or resizing. DragMove(iced::Point), /// Pointer released after a drag / resize. DragRelease, /// The pointer left the edge column; collapse any auto-collapsing panel. HoverExit, } /// The dockable panels the application knows about. New palettes add a variant. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PanelId { Properties, BlockPalette, } impl PanelId { /// Localized-friendly display name used by the collapsed/edge chrome. pub fn title(self) -> &'static str { match self { PanelId::Properties => "Properties", PanelId::BlockPalette => "Block Palette", } } /// Default dock width for a freshly-created panel instance. fn default_width(self) -> f32 { match self { PanelId::Properties => 250.0, PanelId::BlockPalette => 260.0, } } } /// The persistent, per-panel dock settings that survive a restart. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct DockPanel { pub width: f32, pub auto_collapse: bool, } impl Default for DockPanel { fn default() -> Self { Self { width: 250.0, auto_collapse: false, } } } impl DockPanel { fn for_id(id: PanelId) -> Self { Self { width: id.default_width(), auto_collapse: false, } } } /// 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. #[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, /// Per-panel width / auto-collapse settings, keyed by `PanelId`. pub panels: BTreeMap, } impl Default for DockState { fn default() -> Self { Self { left: vec![PanelId::Properties], right: vec![PanelId::BlockPalette], panels: BTreeMap::new(), } } } impl DockState { /// Edge-collection of the panels ordered for rendering on `side`. fn stack(&self, side: DockSide) -> &[PanelId] { match side { DockSide::Left => &self.left, DockSide::Right => &self.right, } } fn stack_mut(&mut self, side: DockSide) -> &mut Vec { match side { DockSide::Left => &mut self.left, DockSide::Right => &mut self.right, } } /// 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. pub fn ensure_settings(&mut self) { for id in [PanelId::Properties, PanelId::BlockPalette] { self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); } } /// Where (if anywhere) a panel is currently docked. 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) { return Some((side, i)); } } None } pub fn settings(&self, id: PanelId) -> DockPanel { self.panels .get(&id) .copied() .unwrap_or_else(|| DockPanel::for_id(id)) } /// Docked width for `id`, clamped to sane bounds. pub fn width(&self, id: PanelId, win_w: f32) -> f32 { self.settings(id) .width .clamp(DOCK_MIN_W, DOCK_MAX_W.min(win_w * 0.45).max(DOCK_MIN_W)) } pub fn auto_collapse(&self, id: PanelId) -> bool { self.settings(id).auto_collapse } /// Set the persisted width, clamped. pub fn set_width(&mut self, id: PanelId, width: f32) { let entry = self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); entry.width = width.clamp(DOCK_MIN_W, DOCK_MAX_W); } /// Reset width to the panel's default. pub fn reset_width(&mut self, id: PanelId) { let entry = self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); entry.width = id.default_width(); } pub fn set_auto_collapse(&mut self, id: PanelId, on: bool) { let entry = self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); 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); } let stack = self.stack_mut(side); let index = index.min(stack.len()); stack.insert(index, id); true } } /// 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; } 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; } idx.min(total) } /// 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; #[cfg(test)] mod tests { use super::*; #[test] fn default_docks_each_known_panel_on_an_edge() { let state = DockState::default(); assert_eq!(state.location(PanelId::Properties), Some((DockSide::Left, 0))); assert_eq!( state.location(PanelId::BlockPalette), Some((DockSide::Right, 0)) ); } #[test] fn ensure_settings_seeds_missing_entries_with_defaults() { let mut state = DockState::default(); state.ensure_settings(); assert_eq!(state.width(PanelId::Properties, 1600.0), 250.0); assert_eq!(state.width(PanelId::BlockPalette, 1600.0), 260.0); assert!(!state.auto_collapse(PanelId::Properties)); } #[test] fn dock_moves_between_sides() { let mut state = DockState::default(); assert!(state.dock(PanelId::Properties, DockSide::Right, 0)); assert_eq!( state.location(PanelId::Properties), Some((DockSide::Right, 0)) ); // It no longer occupies the left edge. assert!(state.left.is_empty()); assert_eq!(state.right.len(), 2); } #[test] fn dock_clamps_index() { let mut state = DockState::default(); assert!(state.dock(PanelId::Properties, DockSide::Right, 99)); assert_eq!( state.location(PanelId::Properties), Some((DockSide::Right, 1)) ); } #[test] fn dock_noop_when_same_spot() { let mut state = DockState::default(); assert!(!state.dock(PanelId::Properties, DockSide::Left, 0)); } #[test] fn set_width_clamps() { let mut state = DockState::default(); state.set_width(PanelId::BlockPalette, 10.0); assert_eq!(state.width(PanelId::BlockPalette, 1600.0), DOCK_MIN_W); state.set_width(PanelId::BlockPalette, 5000.0); assert_eq!(state.width(PanelId::BlockPalette, 1600.0), DOCK_MAX_W); } #[test] fn width_respects_maximum_window_fraction() { let mut state = DockState::default(); state.set_width(PanelId::BlockPalette, 500.0); // Window too narrow -> capped by the 0.45 fraction, not DOCK_MAX_W. assert_eq!(state.width(PanelId::BlockPalette, 800.0), DOCK_MAX_W.min(360.0)); } #[test] fn width_does_not_panic_when_window_minimized() { let mut state = DockState::default(); state.set_width(PanelId::BlockPalette, 500.0); // A minimized or not-yet-laid-out window reports width 0; the clamp // must fall back to DOCK_MIN_W instead of panicking on min > max. assert_eq!(state.width(PanelId::BlockPalette, 0.0), DOCK_MIN_W); // Deleted right below the minimum dock width behaves the same way. assert_eq!(state.width(PanelId::BlockPalette, 100.0), DOCK_MIN_W); } #[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); } #[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); } #[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); } }