//! 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, ExternalReferences, /// Outline of the drawing's origin planes, open sketch and solid bodies. Browser, /// Node library of the node graph overlay. NodeGraph, /// Regions and scans of the drawing's point clouds. PointCloudManager, } 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", PanelId::ExternalReferences => "External References", PanelId::Browser => "Browser", PanelId::NodeGraph => "Node Graph", PanelId::PointCloudManager => "Point Cloud Manager", } } /// Default dock width for a freshly-created panel instance. fn default_width(self) -> f32 { match self { PanelId::Properties => 250.0, PanelId::BlockPalette => 260.0, PanelId::ExternalReferences => 460.0, PanelId::Browser => 230.0, PanelId::NodeGraph => 220.0, PanelId::PointCloudManager => 280.0, } } /// Widest a panel may be dragged or sized to. The references table is /// column-rich, so it allows double the shared maximum. fn max_width(self) -> f32 { match self { PanelId::ExternalReferences => DOCK_MAX_W * 2.0, _ => DOCK_MAX_W, } } } /// 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, PanelId::ExternalReferences, PanelId::Browser, PanelId::NodeGraph, PanelId::PointCloudManager, ] { 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, id.max_width().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, id.max_width()); } /// 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; // ── Shared panel chrome ───────────────────────────────────────────────── use crate::app::Message; use iced::widget::{button, container, mouse_area, row, text, tooltip, Space}; use iced::{Background, Border, Color, Element, Length, Theme}; /// The row every docked panel starts with: its title, the auto-collapse pin /// and close. Pressing the row starts re-docking the panel. pub fn title_bar<'a>(id: PanelId, title: String, auto_collapse: bool) -> Element<'a, Message> { let pin_icon = if auto_collapse { crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) } else { crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) }; let pin = button(pin_icon) .on_press(Message::Dock(DockMsg::AutoCollapseToggle(id))) .style(move |theme: &Theme, status| { let mut style = button::subtle(theme, status); if auto_collapse { let palette = theme.palette(); style.background = Some(Background::Color(palette.primary.weak.color)); style.text_color = palette.primary.weak.text; style.border.color = palette.primary.base.color; style.border.width = 1.0; } style }) .padding([3, 5]); let pin = tooltip(pin, text(crate::t!("Auto")).size(10), tooltip::Position::Bottom).gap(4); let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0)) .on_press(Message::Dock(DockMsg::Close(id))) .style(button::subtle) .padding([3, 5]); let close = tooltip(close, text(crate::t!("Close")).size(10), tooltip::Position::Bottom).gap(4); mouse_area( container( row![text(title).size(12), Space::new().width(Length::Fill), pin, close] .spacing(3) .align_y(iced::Center), ) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.weak.color)), ..Default::default() }) .width(Length::Fill) .padding([3, 6]), ) .on_press(Message::Dock(DockMsg::DockGrab(id))) .interaction(iced::mouse::Interaction::Grab) .into() } /// Side length of a panel toolbar button's icon. pub const TOOL_H: f32 = 22.0; /// A panel toolbar icon button, placed on the row under the title bar. pub fn tool_button<'a>( icon: Element<'a, Message>, tip: String, message: Message, ) -> Element<'a, Message> { let button = button(icon) .on_press(message) .width(Length::Fixed(TOOL_H + 8.0)) .height(Length::Fixed(TOOL_H + 8.0)) .style(|theme: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => { theme.palette().background.strong.color } _ => Color::TRANSPARENT, })), border: Border { radius: 3.0.into(), ..Default::default() }, text_color: crate::ui::window::block_palette::block_icon_button_text_color( theme, status, ), ..Default::default() }); tooltip(button, text(tip).size(10), tooltip::Position::Bottom) .gap(4) .into() } /// A docked panel's body: fixed width, full height, padded, on the base /// background with a neutral edge. pub fn frame<'a>(content: impl Into>, width: f32) -> Element<'a, Message> { container(content) .padding(6) .width(Length::Fixed(width)) .height(Length::Fill) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.base.color)), border: Border { color: theme.palette().background.neutral.color, width: 1.0, radius: 0.0.into(), }, ..Default::default() }) .into() } #[cfg(test)] mod tests { use super::*; #[test] fn external_references_allows_double_max_width() { let mut state = DockState::default(); state.ensure_settings(); // Wider default for the column-rich references table, double the maximum. // (The 45%-of-window rule still dominates on narrow windows.) assert_eq!(state.width(PanelId::ExternalReferences, 3000.0), 460.0); state.set_width(PanelId::ExternalReferences, 5000.0); assert_eq!(state.width(PanelId::ExternalReferences, 3000.0), DOCK_MAX_W * 2.0); // Other panels keep the shared maximum. state.set_width(PanelId::BlockPalette, 5000.0); assert_eq!(state.width(PanelId::BlockPalette, 3000.0), DOCK_MAX_W); } #[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); } }