Files
OneCAD-Draft/src/ui/dock.rs
T
ramox81andClaude Opus 5.5 ac9a6b07b9 Point clouds: POINTCLOUDMANAGER palette
A dockable Point Cloud Manager lists each point cloud of the drawing (only
the selected ones while any is selected) with Regions (disabled, region
data is not read), Unassigned Points and Scans, the scans named by their
file stems. Each switch shows or hides points: the cloud, its unassigned
points, all scans or one scan. The hidden set is the cloud's
OCS_POINTCLOUD_VISIBILITY extended data (scan names plus *CLOUD_OFF /
*UNASSIGNED_OFF markers, app id registered), written as one undo step and
kept on save. The drawn points skip hidden scans by their index ranges,
and the hidden set is part of the placed-cloud key. Collapse all / expand
all, row highlight and a search box that filters rows (keeping parents of
matches). POINTCLOUDMANAGERCLOSE closes it; the pc_manager_toggle
automation action flips a switch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 13:25:22 +03:00

478 lines
17 KiB
Rust

//! 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<PanelId>,
/// Panels anchored to the right edge, top → bottom.
pub right: Vec<PanelId>,
/// Per-panel width / auto-collapse settings, keyed by `PanelId`.
pub panels: BTreeMap<PanelId, DockPanel>,
}
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<PanelId> {
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<Element<'a, Message>>, 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);
}
}