From 76883695c3bfc2264fe4ae59097a1450a467298a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:10:36 +0000 Subject: [PATCH 01/24] 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()); + } +} From 089f57237327e915bc160993ad38bc33696f82fd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:09:25 +0000 Subject: [PATCH 02/24] Dock: keep the slot splitter at the edge column's width A Fill-width splitter stretched the whole edge column across the workspace when two slots were stacked, squeezing the drawing view. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/view/mod.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 2338ab121..6ef6979fd 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2792,7 +2792,7 @@ impl OpenCADStudio { 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)); + layers.push(dock_splitter(side, expanded[n - 1], *gi, col_w)); } let weight = self.dock.groups(side)[*gi].weight; layers.push( @@ -3505,12 +3505,14 @@ impl canvas::Program for VBarLabel { /// Width of a collapsed (auto-collapsing) panel's tab in the edge strip. const DOCK_RAIL_W: f32 = 28.0; +/// Width of the grabbable divider between a docked panel and the viewport. +const DOCK_DIVIDER_W: f32 = 5.0; /// Grabbable separator for a docked panel managed by the general dock. Same /// visual as the previous per-panel divider but emits generic dock messages. fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { let line = container(Space::new()) - .width(Length::Fixed(5.0)) + .width(Length::Fixed(DOCK_DIVIDER_W)) .height(Fill) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.neutral.color)), @@ -3598,14 +3600,17 @@ fn dock_tab_strip( } /// Draggable bar between two expanded slots on an edge; double-click gives -/// every slot on the edge the same height again. +/// every slot on the edge the same height again. It spans exactly the slots' +/// width (panel plus its divider): a `Fill` width would stretch the whole +/// edge column across the workspace and squeeze the drawing view. fn dock_splitter( side: crate::app::config::DockSide, upper: usize, lower: usize, + col_w: f32, ) -> Element<'static, Message> { let line = container(Space::new()) - .width(Fill) + .width(Length::Fixed(col_w + DOCK_DIVIDER_W)) .height(Length::Fixed(5.0)) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.neutral.color)), From 03763ff19767c21668256ddc4de4396b0336e7a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:11:02 +0000 Subject: [PATCH 03/24] Dock: flush title bars and a vertical title bar for floating panels Every docked panel now shows its title bar flush along the top, like the Properties panel: dock::frame takes the title bar separately and pads only the content below it. A floating panel drops its horizontal title bar for a vertical one on the side facing the nearer workspace edge. Close and the auto-hide pin sit at the top; the panel's icon sits at the bottom with the title reading upward above it. The free part of the bar drags the panel. With auto-hide on, a floating panel shrinks to its title bar until hovered. Panel views take a dock::Chrome (auto-collapse plus floating) in place of the bare auto-collapse flag, so the shared title bar knows when to step aside. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/view/mod.rs | 220 ++++++++++++++++++++++++++------- src/ui/dock.rs | 84 +++++++++++-- src/ui/node_graph.rs | 10 +- src/ui/properties.rs | 4 +- src/ui/window/block_palette.rs | 6 +- src/ui/window/browser.rs | 6 +- src/ui/window/count_palette.rs | 6 +- src/ui/window/pc_manager.rs | 6 +- src/ui/window/sheet_set.rs | 6 +- src/ui/window/xref_manager.rs | 4 +- 10 files changed, 271 insertions(+), 81 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 6ef6979fd..294311ca6 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2848,6 +2848,7 @@ impl OpenCADStudio { .collect::>() .join(" · "), clockwise: side == crate::app::config::DockSide::Left, + from_start: false, }) .width(Fill) .height(Fill); @@ -2884,22 +2885,27 @@ impl OpenCADStudio { .into() } - /// The content of panel `id` at `width`. + /// The content of panel `id` at `width`. A `floating` panel leaves its + /// title to the floating frame's vertical title bar. fn panel_body<'a>( &'a self, id: crate::ui::dock::PanelId, width: f32, + floating: bool, tab: &'a DocumentTab, ) -> Element<'a, Message> { - let auto_collapse = self.dock.auto_collapse(id); + let chrome = crate::ui::dock::Chrome { + auto_collapse: self.dock.auto_collapse(id), + floating, + }; match id { - crate::ui::dock::PanelId::Properties => tab.properties.view(width, auto_collapse), + crate::ui::dock::PanelId::Properties => tab.properties.view(width, chrome), crate::ui::dock::PanelId::BlockPalette => { - crate::ui::window::block_palette::view(&self.block_palette, width, auto_collapse) + crate::ui::window::block_palette::view(&self.block_palette, width, chrome) } crate::ui::dock::PanelId::ExternalReferences => self.xref_manager.view( width, - auto_collapse, + chrome, tab.xref_missing, &tab.scene.document, ), @@ -2907,9 +2913,9 @@ impl OpenCADStudio { &tab.scene.document, tab.sketch_session.as_ref().map(|session| session.name.as_str()), width, - auto_collapse, + chrome, ), - crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, auto_collapse), + crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, chrome), crate::ui::dock::PanelId::Count => crate::ui::window::count_palette::view( &self.count_palette, tab.count.as_ref(), @@ -2917,17 +2923,17 @@ impl OpenCADStudio { tab.id, tab.scene.geometry_epoch, width, - auto_collapse, + chrome, ), crate::ui::dock::PanelId::PointCloudManager => crate::ui::window::pc_manager::view( &self.pc_manager, &tab.scene.document, &tab.scene.selected_handles_in_order(), width, - auto_collapse, + chrome, ), crate::ui::dock::PanelId::SheetSetManager => { - crate::ui::window::sheet_set::view(&self.sheet_set, width, auto_collapse) + crate::ui::window::sheet_set::view(&self.sheet_set, width, chrome) } } } @@ -2946,7 +2952,7 @@ impl OpenCADStudio { ) -> 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 body = self.panel_body(id, width, false, tab); let panel: Element<'_, Message> = if tabs.len() > 1 { column![dock_tab_strip(&tabs, id, width), body] .height(Fill) @@ -2961,7 +2967,10 @@ impl OpenCADStudio { } } - /// A floating panel at its saved place, with a corner grip to resize it. + /// A floating panel at its saved place: a vertical title bar on the side + /// facing the nearer workspace edge, the panel beside it, and a corner + /// grip to resize it. With its pin on, the panel hides down to the title + /// bar until hovered. fn floating_panel<'a>( &'a self, f: crate::ui::dock::FloatPanel, @@ -2969,45 +2978,62 @@ impl OpenCADStudio { ) -> 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() - }); + let bar_left = f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5; + let hidden = self.dock.auto_collapse(f.id) && self.dock_expanded != Some(f.id); + let bar = floating_title_bar(f.id, self.dock.auto_collapse(f.id), bar_left); + let content: Element<'_, Message> = if hidden { + bar + } else { + let grip = mouse_area( + container(crate::ui::icons::themed_secondary(crate::ui::icons::RESIZE, 12.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, true, 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(Fill); + if bar_left { + row![bar, body].into() + } else { + row![body, bar].into() + } + }; + let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; + let framed = container(content) + .height(Length::Fixed(f.h)) + .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))) + .on_enter(Message::Dock(DockMsg::Hover(f.id))) + .on_exit(Message::Dock(DockMsg::HoverExit)) .interaction(iced::mouse::Interaction::Idle); + // A hidden panel keeps its title bar where it sits when shown. + let x = if hidden && !bar_left { f.x + f.w } else { f.x }; // 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 x = x.min(ww - total_w).max(0.0); let y = f.y.min(wh - f.h).max(0.0); place_at(panel.into(), x, y) } @@ -3081,7 +3107,11 @@ impl OpenCADStudio { } DropTarget::Float { x, y } => { let (w, h) = self.dock_float_size(id); - layers.push(place_at(dock_ghost(&[id], id, w, h), x, y)); + layers.push(place_at( + dock_ghost(&[id], id, w + DOCK_FLOAT_BAR_W, h), + x, + y, + )); } } iced::widget::Stack::with_children(layers) @@ -3467,6 +3497,9 @@ fn pane_mouse_area<'a>(idx: usize) -> Element<'a, Message> { struct VBarLabel { text: String, clockwise: bool, + /// Start the text at the reading-start end of the bar instead of + /// centring it. + from_start: bool, } impl canvas::Program for VBarLabel { @@ -3488,12 +3521,22 @@ impl canvas::Program for VBarLabel { } else { -std::f32::consts::FRAC_PI_2 })); + // After the rotation the text runs along local +x, so the + // reading start sits at -height/2. + let (position, align_x) = if self.from_start { + ( + iced::Point::new(-bounds.height / 2.0, 0.0), + iced::advanced::text::Alignment::Left, + ) + } else { + (iced::Point::ORIGIN, iced::advanced::text::Alignment::Center) + }; frame.fill_text(canvas::Text { content: self.text.clone(), - position: iced::Point::ORIGIN, + position, color: theme.palette().background.base.text.scale_alpha(0.72), size: iced::Pixels(13.0), - align_x: iced::advanced::text::Alignment::Center, + align_x, align_y: iced::alignment::Vertical::Center, shaping: iced::advanced::text::Shaping::Advanced, ..Default::default() @@ -3507,6 +3550,87 @@ impl canvas::Program for VBarLabel { const DOCK_RAIL_W: f32 = 28.0; /// Width of the grabbable divider between a docked panel and the viewport. const DOCK_DIVIDER_W: f32 = 5.0; +/// Width of a floating panel's vertical title bar. +const DOCK_FLOAT_BAR_W: f32 = 28.0; + +/// A floating panel's vertical title bar: close then the hide (auto-collapse) +/// pin at the top, the panel's icon at the bottom with its title reading +/// upward above it. Pressing the bar's free area drags the panel. +fn floating_title_bar( + id: crate::ui::dock::PanelId, + auto_collapse: bool, + on_left: bool, +) -> Element<'static, Message> { + use crate::ui::dock::DockMsg; + 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 = iced::widget::tooltip( + close, + text(t!("Close")).size(10), + if on_left { + iced::widget::tooltip::Position::Left + } else { + iced::widget::tooltip::Position::Right + }, + ) + .gap(4); + 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 = iced::widget::tooltip( + pin, + text(t!("Auto-hide")).size(10), + if on_left { + iced::widget::tooltip::Position::Left + } else { + iced::widget::tooltip::Position::Right + }, + ) + .gap(4); + let title = canvas(VBarLabel { + text: id.title().to_string(), + clockwise: false, + from_start: true, + }) + .width(Fill) + .height(Fill); + let bar = column![ + close, + pin, + container(title).width(Fill).height(Fill).padding([6, 0]), + container(crate::ui::icons::themed(id.icon(), 16.0)).center_x(Fill), + ] + .spacing(2) + .padding([4, 0]) + .align_x(iced::Center) + .width(Length::Fixed(DOCK_FLOAT_BAR_W)) + .height(Fill); + mouse_area(container(bar).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + })) + .on_press(Message::Dock(DockMsg::DockGrab(id))) + .interaction(iced::mouse::Interaction::Grab) + .into() +} /// Grabbable separator for a docked panel managed by the general dock. Same /// visual as the previous per-panel divider but emits generic dock messages. diff --git a/src/ui/dock.rs b/src/ui/dock.rs index c564b804c..d3cb445a8 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -80,6 +80,20 @@ impl PanelId { } } + /// The panel's icon: the one on the ribbon tool that opens it. + pub fn icon(self) -> &'static [u8] { + match self { + PanelId::Properties => include_bytes!("../../assets/icons/properties.svg"), + PanelId::BlockPalette => include_bytes!("../../assets/icons/blocks/insert.svg"), + PanelId::ExternalReferences => crate::ui::icons::FOLDER_OPEN, + PanelId::Browser => include_bytes!("../../assets/icons/content_browser.svg"), + PanelId::NodeGraph => crate::ui::icons::NODE_GRAPH, + PanelId::PointCloudManager => include_bytes!("../../assets/icons/pc_attach.svg"), + PanelId::Count => include_bytes!("../../assets/icons/data_extract.svg"), + PanelId::SheetSetManager => include_bytes!("../../assets/icons/sheetset.svg"), + } + } + /// Default dock width for a freshly-created panel instance. fn default_width(self) -> f32 { match self { @@ -559,9 +573,25 @@ use crate::app::Message; use iced::widget::{button, container, mouse_area, row, text, tooltip, Space}; use iced::{Background, Border, Color, Element, Length, Theme}; +/// How a panel is framed where it is shown, passed to its view so the shared +/// chrome can adapt. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Chrome { + /// The panel auto-collapses (its pin is on). + pub auto_collapse: bool, + /// The panel floats; its title lives in the vertical side bar drawn by + /// the floating frame instead of the horizontal title bar. + pub floating: bool, +} + /// 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> { +/// and close. Pressing the row starts re-docking the panel. A floating panel +/// gets nothing here: its frame draws a vertical title bar beside it. +pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, Message> { + if chrome.floating { + return Space::new().width(0).height(0).into(); + } + let auto_collapse = chrome.auto_collapse; let pin_icon = if auto_collapse { crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) } else { @@ -639,11 +669,18 @@ pub fn tool_button<'a>( .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) +/// A docked panel's body: its title bar flush along the top (as on the +/// Properties panel), then the padded content; fixed width, full height, on +/// the base background with a neutral edge. +pub fn frame<'a>( + title_bar: Element<'a, Message>, + content: impl Into>, + width: f32, +) -> Element<'a, Message> { + container(iced::widget::column![ + title_bar, + container(content).padding(6).height(Length::Fill) + ]) .width(Length::Fixed(width)) .height(Length::Fill) .style(|theme: &Theme| container::Style { @@ -910,6 +947,39 @@ mod tests { assert_eq!(state.right.len(), 1); } + #[test] + fn every_panel_has_its_own_icon() { + let ids = [ + PanelId::Properties, + PanelId::BlockPalette, + PanelId::ExternalReferences, + PanelId::Browser, + PanelId::NodeGraph, + PanelId::PointCloudManager, + PanelId::Count, + PanelId::SheetSetManager, + ]; + for (i, a) in ids.iter().enumerate() { + assert!(a.icon().starts_with(b" = title_bar(PanelId::Count, "Count".into(), floating); + assert_eq!( + bar.as_widget().size(), + iced::Size::new(Length::Fixed(0.0), Length::Fixed(0.0)) + ); + } + #[test] fn slot_zone_splits_quarters() { assert_eq!(slot_zone(10.0, 0.0, 400.0), SlotZone::Above); diff --git a/src/ui/node_graph.rs b/src/ui/node_graph.rs index 7b01a9750..3b32fcf6e 100644 --- a/src/ui/node_graph.rs +++ b/src/ui/node_graph.rs @@ -584,7 +584,7 @@ impl Graph { /// The node library as a docked panel: title bar, a search box with the /// graph's New / Open / Save beside it, then the categories. Entries are /// clicked or dragged onto the canvas. - pub fn panel(&self, width: f32, auto_collapse: bool) -> Element<'_, Message> { + pub fn panel(&self, width: f32, chrome: crate::ui::dock::Chrome) -> Element<'_, Message> { use crate::ui::dock::{self, PanelId}; let tool = |icon: &'static [u8], tip: &str, message: GraphMsg| { dock::tool_button( @@ -663,12 +663,8 @@ impl Graph { .height(Length::Fill); dock::frame( - column![ - dock::title_bar(PanelId::NodeGraph, t!("Node Graph").into_owned(), auto_collapse), - toolbar, - body - ] - .spacing(6), + dock::title_bar(PanelId::NodeGraph, t!("Node Graph").into_owned(), chrome), + column![toolbar, body].spacing(6), width, ) } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index fad5e2068..df65816b0 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -542,11 +542,11 @@ impl PropertiesPanel { .unwrap_or_default() } - pub fn view(&self, width: f32, auto_collapse: bool) -> Element<'_, Message> { + pub fn view(&self, width: f32, chrome: crate::ui::dock::Chrome) -> Element<'_, Message> { use crate::ui::dock::PanelId; // ── Header ────────────────────────────────────────────────────────── let header = - crate::ui::dock::title_bar(PanelId::Properties, t!("Properties").into_owned(), auto_collapse); + crate::ui::dock::title_bar(PanelId::Properties, t!("Properties").into_owned(), chrome); // ── Title bar (entity type / "No Selection") ───────────────────── let title_content: Element<'_, Message> = if self.selection_groups.is_empty() { diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index a84fda8e9..e6709c4f0 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -659,8 +659,8 @@ fn options_card<'a>(palette: &'a BlockPalette) -> Element<'a, Message> { } /// Build the docked panel element from the palette state. -pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<'_, Message> { - let title_bar = crate::ui::dock::title_bar(PanelId::BlockPalette, crate::t!("Blocks").into_owned(), auto_collapse); +pub fn view(palette: &BlockPalette, width: f32, chrome: crate::ui::dock::Chrome) -> Element<'_, Message> { + let title_bar = crate::ui::dock::title_bar(PanelId::BlockPalette, crate::t!("Blocks").into_owned(), chrome); let tabs = { let buttons = [Tab::Current, Tab::Recent, Tab::Favorites, Tab::Libraries].map(|t| { @@ -801,7 +801,7 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< .into() }; - crate::ui::dock::frame(column![title_bar, top, body, options_card(palette)].spacing(6), width) + crate::ui::dock::frame(title_bar, column![top, body, options_card(palette)].spacing(6), width) } /// Width of the ribbon's block gallery. diff --git a/src/ui/window/browser.rs b/src/ui/window/browser.rs index 217773a3a..4676f2e43 100644 --- a/src/ui/window/browser.rs +++ b/src/ui/window/browser.rs @@ -97,10 +97,10 @@ pub fn view<'a>( document: &'a CadDocument, open_sketch: Option<&'a str>, width: f32, - auto_collapse: bool, + chrome: crate::ui::dock::Chrome, ) -> Element<'a, Message> { let title_bar = - crate::ui::dock::title_bar(PanelId::Browser, crate::t!("Browser").into_owned(), auto_collapse); + crate::ui::dock::title_bar(PanelId::Browser, crate::t!("Browser").into_owned(), chrome); let mut tree = column![].spacing(1); @@ -143,5 +143,5 @@ pub fn view<'a>( .width(Fill) .height(Fill); - crate::ui::dock::frame(column![title_bar, body].spacing(6), width) + crate::ui::dock::frame(title_bar, column![body].spacing(6), width) } diff --git a/src/ui/window/count_palette.rs b/src/ui/window/count_palette.rs index 8f8e08d70..2b44455bb 100644 --- a/src/ui/window/count_palette.rs +++ b/src/ui/window/count_palette.rs @@ -530,9 +530,9 @@ pub fn view<'a>( doc_id: u64, epoch: u64, width: f32, - auto_collapse: bool, + chrome: crate::ui::dock::Chrome, ) -> Element<'a, Message> { - let title_bar = crate::ui::dock::title_bar(PanelId::Count, crate::t!("Count").into_owned(), auto_collapse); + let title_bar = crate::ui::dock::title_bar(PanelId::Count, crate::t!("Count").into_owned(), chrome); let body = match mode { Some(mode) if mode.target.is_some() => mode_view(palette, mode, doc), _ => { @@ -550,7 +550,7 @@ pub fn view<'a>( list_view(palette, &instances) } }; - crate::ui::dock::frame(column![title_bar, body].spacing(6), width) + crate::ui::dock::frame(title_bar, column![body].spacing(6), width) } fn bar_button<'a>(icon: Element<'a, Message>, tip: String, m: Option) -> Element<'a, Message> { diff --git a/src/ui/window/pc_manager.rs b/src/ui/window/pc_manager.rs index 4d7e4ae74..11d70ddf6 100644 --- a/src/ui/window/pc_manager.rs +++ b/src/ui/window/pc_manager.rs @@ -235,10 +235,10 @@ pub fn view<'a>( document: &'a CadDocument, selected: &[Handle], width: f32, - auto_collapse: bool, + chrome: crate::ui::dock::Chrome, ) -> Element<'a, Message> { let title_bar = - crate::ui::dock::title_bar(PanelId::PointCloudManager, crate::t!("Point Cloud Manager").into_owned(), auto_collapse); + crate::ui::dock::title_bar(PanelId::PointCloudManager, crate::t!("Point Cloud Manager").into_owned(), chrome); let query = state.search.trim().to_lowercase(); let matches = |label: &str| query.is_empty() || label.to_lowercase().contains(&query); // Searching opens every node so the matches show. @@ -354,5 +354,5 @@ pub fn view<'a>( .padding([4, 8]) .size(12); - crate::ui::dock::frame(column![title_bar, card, search].spacing(6), width) + crate::ui::dock::frame(title_bar, column![card, search].spacing(6), width) } diff --git a/src/ui/window/sheet_set.rs b/src/ui/window/sheet_set.rs index 1bf1c0d2a..be0d3682d 100644 --- a/src/ui/window/sheet_set.rs +++ b/src/ui/window/sheet_set.rs @@ -936,9 +936,9 @@ fn heading(label: &str) -> Element<'static, Message> { .into() } -pub fn view<'a>(state: &'a SheetSetManager, width: f32, auto_collapse: bool) -> Element<'a, Message> { +pub fn view<'a>(state: &'a SheetSetManager, width: f32, chrome: crate::ui::dock::Chrome) -> Element<'a, Message> { let title_bar = - crate::ui::dock::title_bar(PanelId::SheetSetManager, t!("Sheet Set Manager").into_owned(), auto_collapse); + crate::ui::dock::title_bar(PanelId::SheetSetManager, t!("Sheet Set Manager").into_owned(), chrome); let mut picks: Vec = state.sets.iter().enumerate().map(|(i, db)| Pick(Some(i), db.name().to_string())).collect(); picks.push(Pick(None, t!("Open...").into_owned())); @@ -1040,7 +1040,7 @@ pub fn view<'a>(state: &'a SheetSetManager, width: f32, auto_collapse: bool) -> let hint = text(t!("Double-click opens a sheet. Right-click for more commands.").into_owned()) .size(10) .style(muted_style); - crate::ui::dock::frame(column![title_bar, combo, tabs, card, hint].spacing(6), width) + crate::ui::dock::frame(title_bar, column![combo, tabs, card, hint].spacing(6), width) } // ── dialogs ────────────────────────────────────────────────────────────────── diff --git a/src/ui/window/xref_manager.rs b/src/ui/window/xref_manager.rs index a79f5328c..df5436abd 100644 --- a/src/ui/window/xref_manager.rs +++ b/src/ui/window/xref_manager.rs @@ -542,7 +542,7 @@ impl XrefManagerPanel { pub fn view<'a>( &'a self, width: f32, - auto_collapse: bool, + chrome: crate::ui::dock::Chrome, missing: usize, doc: &'a CadDocument, ) -> Element<'a, Message> { @@ -550,7 +550,7 @@ impl XrefManagerPanel { let title_bar = crate::ui::dock::title_bar( PanelId::ExternalReferences, format!("{} ({})", crate::t!("External References").as_ref(), self.entries.len()), - auto_collapse, + chrome, ); // Table content width: column widths plus gutters, stretched to the // dock when wider so rows fill the panel; narrower docks sidescroll. From 1de5597216d0707b580b0f11ef4fec994f41af0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:37:11 +0000 Subject: [PATCH 04/24] Dock: hover-only title buttons, tilted pin, mirrored float grip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docked title bars show the pin and close only while the pointer is over the bar; the bar keeps a fixed height so nothing jumps. - Auto-hide on is drawn as a 30° tilted pin (new pin_active.svg) instead of a coloured button background, on docked and floating bars. - A floating panel's resize grip sits in the bottom corner away from its title bar. With the bar on the right it is bottom-left (new mirrored resize_left.svg) and resizing from there keeps the right edge fixed. The bar stays on its side while resizing. - While a floating panel is dragged, only the drop preview shows it; the panel no longer stays drawn at its old place. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- assets/icons/ui/pin_active.svg | 1 + assets/icons/ui/resize_left.svg | 1 + src/app/mod.rs | 8 ++- src/app/update/dialog.rs | 23 +++++-- src/app/view/mod.rs | 92 ++++++++++++------------- src/ui/dock.rs | 117 ++++++++++++++++++++------------ src/ui/icons.rs | 4 ++ 7 files changed, 147 insertions(+), 99 deletions(-) create mode 100644 assets/icons/ui/pin_active.svg create mode 100644 assets/icons/ui/resize_left.svg diff --git a/assets/icons/ui/pin_active.svg b/assets/icons/ui/pin_active.svg new file mode 100644 index 000000000..3a291545c --- /dev/null +++ b/assets/icons/ui/pin_active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/ui/resize_left.svg b/assets/icons/ui/resize_left.svg new file mode 100644 index 000000000..bacde830e --- /dev/null +++ b/assets/icons/ui/resize_left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/mod.rs b/src/app/mod.rs index 8f79a88fd..48cb2fd26 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -863,8 +863,11 @@ pub(super) struct OpenCADStudio { 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, + /// Floating panel being resized from its corner grip, and whether from + /// the bottom-left corner. + pub(crate) dock_float_resizing: Option<(crate::ui::dock::PanelId, bool)>, + /// Docked panel whose title bar the pointer is over (shows pin / close). + pub(crate) dock_title_hover: 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, @@ -4304,6 +4307,7 @@ impl OpenCADStudio { dock_grab_offset: iced::Vector::new(0.0, 0.0), dock_split_drag: None, dock_float_resizing: None, + dock_title_hover: None, xref_col_drag: None, xref_col_last: None, xref_split_drag: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index b5d9b8578..3aeb66db5 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -580,14 +580,18 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.save_config(); iced::Task::none() } - DockMsg::FloatResizeGrab(id) => { + DockMsg::FloatResizeGrab(id, from_left) => { self.dock.raise_float(id); - self.dock_float_resizing = Some(id); + self.dock_float_resizing = Some((id, from_left)); self.dock_dragging = None; self.dock_drag_last = None; self.dock_drag_target = None; iced::Task::none() } + DockMsg::TitleHover(id) => { + self.dock_title_hover = id; + iced::Task::none() + } DockMsg::FloatRaise(id) => { self.dock.raise_float(id); iced::Task::none() @@ -707,12 +711,14 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven ); } } - } else if let Some(id) = self.dock_float_resizing { + } else if let Some((id, from_left)) = self.dock_float_resizing { if let (Some(last), Some(f)) = (self.dock_drag_last, self.dock.float_rect(id)) { + let dx = point.x - last.x; self.dock.resize_float( id, - f.w + point.x - last.x, + if from_left { f.w - dx } else { f.w + dx }, f.h + point.y - last.y, + from_left, ); } } else if let Some(id) = self.dock_resizing { @@ -2042,12 +2048,19 @@ mod tests { w: 260.0, h: 300.0, }); - let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette)); + let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, false)); 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)); + // The bottom-left grip grows the panel leftward, keeping its right edge. + let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, true)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 450.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(60.0, 450.0))); + let _ = app.on_dock(DockMsg::DragRelease); + let g = app.dock.float_rect(PanelId::BlockPalette).unwrap(); + assert_eq!((g.x, g.w, g.h), (f.x - 40.0, 340.0, 350.0)); } #[test] diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 294311ca6..d2cedd212 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1930,7 +1930,9 @@ bg={bg_ms:.1}ms n={view_count}" // 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) { + // Once a drag is under way only its preview shows the panel. + let dragged = self.dock_dragging == Some(f.id) && self.dock_drag_target.is_some(); + if self.dock_panel_visible(f.id) && !dragged { layers.push(self.floating_panel(*f, tab)); } } @@ -1956,8 +1958,12 @@ bg={bg_ms:.1}ms n={view_count}" 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 self.dock_float_resizing.is_some() { - iced::mouse::Interaction::ResizingDiagonallyDown + .interaction(if let Some((_, from_left)) = self.dock_float_resizing { + if from_left { + iced::mouse::Interaction::ResizingDiagonallyUp + } else { + iced::mouse::Interaction::ResizingDiagonallyDown + } } else if any_resizing { iced::mouse::Interaction::ResizingHorizontally } else if any_splitting { @@ -2897,6 +2903,7 @@ impl OpenCADStudio { let chrome = crate::ui::dock::Chrome { auto_collapse: self.dock.auto_collapse(id), floating, + title_hovered: self.dock_title_hover == Some(id), }; match id { crate::ui::dock::PanelId::Properties => tab.properties.view(width, chrome), @@ -2978,25 +2985,48 @@ impl OpenCADStudio { ) -> Element<'a, Message> { use crate::ui::dock::DockMsg; let (ww, wh) = self.dock_workspace_size(); - let bar_left = f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5; + // The bar faces the nearer workspace edge; while resizing, it stays + // opposite the grip being dragged so it cannot flip mid-drag. + let bar_left = match self.dock_float_resizing { + Some((id, from_left)) if id == f.id => !from_left, + _ => f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5, + }; let hidden = self.dock.auto_collapse(f.id) && self.dock_expanded != Some(f.id); let bar = floating_title_bar(f.id, self.dock.auto_collapse(f.id), bar_left); let content: Element<'_, Message> = if hidden { bar } else { + // The resize grip sits in the bottom corner away from the bar. + let grip_left = !bar_left; + let grip_icon = crate::ui::icons::themed_secondary( + if grip_left { + crate::ui::icons::RESIZE_LEFT + } else { + crate::ui::icons::RESIZE + }, + 12.0, + ); let grip = mouse_area( - container(crate::ui::icons::themed_secondary(crate::ui::icons::RESIZE, 12.0)) + container(grip_icon) .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); + .on_press(Message::Dock(DockMsg::FloatResizeGrab(f.id, grip_left))) + .interaction(if grip_left { + iced::mouse::Interaction::ResizingDiagonallyUp + } else { + iced::mouse::Interaction::ResizingDiagonallyDown + }); let body = stack![ self.panel_body(f.id, f.w, true, tab), container(grip) .width(Fill) .height(Fill) - .align_x(iced::alignment::Horizontal::Right) + .align_x(if grip_left { + iced::alignment::Horizontal::Left + } else { + iced::alignment::Horizontal::Right + }) .align_y(iced::alignment::Vertical::Bottom), ] .width(Length::Fixed(f.w)) @@ -3562,49 +3592,13 @@ fn floating_title_bar( on_left: bool, ) -> Element<'static, Message> { use crate::ui::dock::DockMsg; - 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 = iced::widget::tooltip( - close, - text(t!("Close")).size(10), - if on_left { - iced::widget::tooltip::Position::Left - } else { - iced::widget::tooltip::Position::Right - }, - ) - .gap(4); - let pin_icon = if auto_collapse { - crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) + let tip = if on_left { + iced::widget::tooltip::Position::Left } else { - crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) + iced::widget::tooltip::Position::Right }; - 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 = iced::widget::tooltip( - pin, - text(t!("Auto-hide")).size(10), - if on_left { - iced::widget::tooltip::Position::Left - } else { - iced::widget::tooltip::Position::Right - }, - ) - .gap(4); + let close = crate::ui::dock::close_button(id, tip); + let pin = crate::ui::dock::pin_button(id, auto_collapse, tip); let title = canvas(VBarLabel { text: id.title().to_string(), clockwise: false, diff --git a/src/ui/dock.rs b/src/ui/dock.rs index d3cb445a8..c891011e8 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -38,8 +38,11 @@ pub enum DockMsg { 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), + /// Begin resizing floating `panel` from its corner grip; `true` for the + /// bottom-left grip (a panel whose title bar is on the right). + FloatResizeGrab(PanelId, bool), + /// The pointer entered (`Some`) or left (`None`) a docked title bar. + TitleHover(Option), /// Bring floating `panel` to the front. FloatRaise(PanelId), /// The pointer left the edge column; collapse any auto-collapsing panel. @@ -454,10 +457,15 @@ impl DockState { } } - /// Resize a floating panel, keeping it at least a usable size. - pub fn resize_float(&mut self, id: PanelId, w: f32, h: f32) { + /// Resize a floating panel, keeping it at least a usable size. With + /// `keep_right` the right edge stays put (resizing from the left corner). + pub fn resize_float(&mut self, id: PanelId, w: f32, h: f32, keep_right: bool) { if let Some(f) = self.floating.iter_mut().find(|f| f.id == id) { - f.w = w.clamp(DOCK_MIN_W, id.max_width()); + let new_w = w.clamp(DOCK_MIN_W, id.max_width()); + if keep_right { + f.x += f.w - new_w; + } + f.w = new_w; f.h = h.max(FLOAT_MIN_H); } } @@ -582,6 +590,8 @@ pub struct Chrome { /// The panel floats; its title lives in the vertical side bar drawn by /// the floating frame instead of the horizontal title bar. pub floating: bool, + /// The pointer is over the title bar, which shows its pin and close. + pub title_hovered: bool, } /// The row every docked panel starts with: its title, the auto-collapse pin @@ -591,48 +601,65 @@ pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, if chrome.floating { return Space::new().width(0).height(0).into(); } - let auto_collapse = chrome.auto_collapse; - 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) + // The pin and close only show while the pointer is over the bar; the + // fixed height keeps the bar from jumping when they appear. + let mut bar = row![text(title).size(12), Space::new().width(Length::Fill)] + .spacing(3) + .height(Length::Fixed(TITLE_BUTTON_H)) + .align_y(iced::Center); + if chrome.title_hovered { + bar = bar + .push(pin_button(id, chrome.auto_collapse, tooltip::Position::Bottom)) + .push(close_button(id, tooltip::Position::Bottom)); + } + mouse_area( + container(bar) + .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))) + .on_enter(Message::Dock(DockMsg::TitleHover(Some(id)))) + .on_exit(Message::Dock(DockMsg::TitleHover(None))) + .interaction(iced::mouse::Interaction::Grab) + .into() +} + +/// Height of a title bar's button row. +const TITLE_BUTTON_H: f32 = 20.0; + +/// The auto-hide pin. While auto-hide is on the pin is drawn tilted instead +/// of on a coloured background. +pub fn pin_button<'a>(id: PanelId, auto_collapse: bool, tip: tooltip::Position) -> Element<'a, Message> { + let icon = crate::ui::icons::themed_secondary( + if auto_collapse { + crate::ui::icons::PIN_ACTIVE + } else { + crate::ui::icons::PIN + }, + 12.0, + ); + let pin = button(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 - }) + .style(button::subtle) .padding([3, 5]); - let pin = tooltip(pin, text(crate::t!("Auto")).size(10), tooltip::Position::Bottom).gap(4); + tooltip(pin, text(crate::t!("Auto-hide")).size(10), tip) + .gap(4) + .into() +} + +/// The close button of a panel's title bar. +pub fn close_button<'a>(id: PanelId, tip: tooltip::Position) -> Element<'a, Message> { 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() + tooltip(close, text(crate::t!("Close")).size(10), tip) + .gap(4) + .into() } /// Side length of a panel toolbar button's icon. @@ -901,9 +928,13 @@ mod tests { w: 300.0, h: 300.0, }); - state.resize_float(PanelId::Count, 10.0, 10.0); + state.resize_float(PanelId::Count, 10.0, 10.0, false); let f = state.float_rect(PanelId::Count).unwrap(); assert_eq!((f.w, f.h), (DOCK_MIN_W, FLOAT_MIN_H)); + // From the left corner the right edge stays where it was. + state.resize_float(PanelId::Count, 350.0, 300.0, true); + let f = state.float_rect(PanelId::Count).unwrap(); + assert_eq!((f.x, f.w), (DOCK_MIN_W - 350.0, 350.0)); } #[test] @@ -970,8 +1001,8 @@ mod tests { #[test] fn floating_title_bar_replaces_the_horizontal_one() { let floating = Chrome { - auto_collapse: false, floating: true, + ..Default::default() }; let bar: Element<'_, Message> = title_bar(PanelId::Count, "Count".into(), floating); assert_eq!( diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 694b98a07..2330bd179 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -72,7 +72,11 @@ pub static COPY: &[u8] = include_bytes!("../../assets/icons/ui/copy.svg"); pub static MENU: &[u8] = include_bytes!("../../assets/icons/ui/menu.svg"); pub static MOVE: &[u8] = include_bytes!("../../assets/icons/ui/move.svg"); pub static RESIZE: &[u8] = include_bytes!("../../assets/icons/ui/resize.svg"); +/// `RESIZE` mirrored for a bottom-left corner grip. +pub static RESIZE_LEFT: &[u8] = include_bytes!("../../assets/icons/ui/resize_left.svg"); pub static PIN: &[u8] = include_bytes!("../../assets/icons/ui/pin.svg"); +/// `PIN` tilted 30°: the pin while auto-hide is on. +pub static PIN_ACTIVE: &[u8] = include_bytes!("../../assets/icons/ui/pin_active.svg"); pub static SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg"); pub static SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg"); pub static GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg"); From 2b2581eb46e3b7aad154f562647474aea54b1029 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:58:37 +0000 Subject: [PATCH 05/24] Dock: reorder tabs by dragging, one width per tab group, plain rail hover - Dragging a tab along its slot's tab strip moves it to the gap under the pointer; the drop preview shows the new tab order. A panel dragged from elsewhere onto a tab strip lands at that position too. A drop in the middle of the slot still appends a newcomer and leaves a member where it is. - All tabs of a slot share one width: a joining panel takes the slot's width, resizing or resetting one tab sizes them all, and slots sized apart by older configs render at their widest tab. Switching tabs no longer changes the column width. - Hovering an auto-hide rail raises its slot without turning the rail blue. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/update/dialog.rs | 130 ++++++++++++++++++++++++++++++++++++--- src/app/view/mod.rs | 37 +++++------ src/ui/dock.rs | 128 ++++++++++++++++++++++++++++++++++---- 3 files changed, 253 insertions(+), 42 deletions(-) diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 3aeb66db5..1a07ae657 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -598,6 +598,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } DockMsg::WidthReset(id) => { self.dock.reset_width(id); + self.dock.set_group_width(id, self.dock.settings(id).width); self.save_config(); iced::Task::none() } @@ -728,8 +729,12 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven Some((DockSide::Left, _)) => dx, _ => -dx, }; - let cur = self.dock.settings(id).width + delta; - self.dock.set_width(id, cur); + // Every tab of the slot shares one width. + let cur = match self.dock.location(id) { + Some((side, gi)) => self.dock.group_width(side, gi), + None => self.dock.settings(id).width, + } + delta; + self.dock.set_group_width(id, cur); } } if self.dock_dragging.is_some() @@ -748,7 +753,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven 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::Tab { side, group, index } => { + self.dock.add_tab(id, side, group, index) + } DropTarget::Float { x, y } => { let (w, h) = self.dock_float_size(id); self.dock.float(FloatPanel { id, x, y, w, h }) @@ -871,15 +878,44 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .collect() } - /// Width of the edge column on `side`: its widest shown panel. + /// Width of slot `gi` on `side`: all its tabs share the widest one's + /// width, so switching tabs never resizes the column. + pub(crate) fn dock_group_width(&self, side: crate::app::config::DockSide, gi: usize) -> f32 { + self.dock.groups(side)[gi] + .tabs + .iter() + .map(|id| self.dock.width(*id, self.win_size.0)) + .fold(0.0, f32::max) + } + + /// Width of the edge column on `side`: its widest shown slot. 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)) + .map(|gi| self.dock_group_width(side, gi)) .fold(0.0, f32::max) } + /// Left x of the expanded slots on `side` (past the auto-hide rail, if + /// the edge has one) in workspace pixels. + fn dock_column_x0(&self, side: crate::app::config::DockSide) -> f32 { + use crate::app::config::DockSide; + let has_rail = self.dock_visible_groups(side).into_iter().any(|gi| { + self.dock_group_shown(side, gi) + .is_some_and(|id| self.dock.auto_collapse(id)) + }); + let rail = if has_rail { crate::ui::dock::DOCK_RAIL_W } else { 0.0 }; + match side { + DockSide::Left => rail, + DockSide::Right => { + self.dock_workspace_size().0 + - rail + - self.dock_column_width(side) + - crate::ui::dock::DOCK_DIVIDER_W + } + } + } + /// Visible slots on `side` with their vertical span in the workspace. pub(crate) fn dock_slot_spans( &self, @@ -942,6 +978,29 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven index: self.dock.groups(side).len(), }; }; + // Over a tab strip: drop between its tabs (reorders a member). + let tabs = self.dock_group_visible_tabs(side, gi); + if tabs.len() > 1 && p.y < top + crate::ui::dock::DOCK_TAB_H { + let x0 = self.dock_column_x0(side) + + if side == DockSide::Right { crate::ui::dock::DOCK_DIVIDER_W } else { 0.0 }; + let visible_at = crate::ui::dock::tab_insert_index( + p.x, + x0, + self.dock_column_width(side), + tabs.len(), + ); + // Map the gap among visible tabs to a position in all tabs. + let all = &self.dock.groups(side)[gi].tabs; + let index = tabs + .get(visible_at) + .and_then(|t| all.iter().position(|a| a == t)) + .unwrap_or(all.len()); + return DropTarget::Tab { + side, + group: gi, + index: Some(index), + }; + } return match slot_zone(p.y, top, bottom) { SlotZone::Above => DropTarget::Edge { side, index: gi }, SlotZone::Below => DropTarget::Edge { side, index: gi + 1 }, @@ -949,7 +1008,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven SlotZone::Middle if self.dock_group_visible_tabs(side, gi) == [id] => { DropTarget::Edge { side, index: gi } } - SlotZone::Middle => DropTarget::Tab { side, group: gi }, + SlotZone::Middle => DropTarget::Tab { + side, + group: gi, + index: None, + }, }; } let x = (p.x - self.dock_grab_offset.x).clamp(0.0, (ww - 60.0).max(0.0)); @@ -1915,7 +1978,14 @@ mod tests { 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!( + target, + Some(DropTarget::Tab { + side: DockSide::Left, + group: 0, + index: None + }) + ); assert_eq!( app.dock.left[0].tabs, vec![PanelId::Properties, PanelId::BlockPalette] @@ -1928,6 +1998,48 @@ mod tests { assert_eq!(app.dock.left[0].tabs.len(), 2); } + #[test] + fn dock_dragging_a_tab_along_the_strip_reorders_it() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, DropTarget, PanelId}; + let mut app = dock_app(); + app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); + app.dock.right.clear(); + // Tabs: Properties | Blocks across the 250 px column. Drag the + // Properties tab past the end of the strip. + let _ = app.on_dock(DockMsg::SelectTab(PanelId::Properties)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(60.0, 12.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(245.0, 12.0))); + assert_eq!( + app.dock_drag_target, + Some(DropTarget::Tab { + side: DockSide::Left, + group: 0, + index: Some(2) + }) + ); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!( + app.dock.left[0].tabs, + vec![PanelId::BlockPalette, PanelId::Properties] + ); + assert_eq!(app.dock.left[0].active, PanelId::Properties); + } + + #[test] + fn dock_tab_group_resize_applies_to_every_tab() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = dock_app(); + app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); + let _ = app.on_dock(DockMsg::ResizeGrab(PanelId::BlockPalette)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(250.0, 100.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(300.0, 100.0))); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!(app.dock.settings(PanelId::Properties).width, 300.0); + assert_eq!(app.dock.settings(PanelId::BlockPalette).width, 300.0); + } + #[test] fn dock_drop_in_bottom_quarter_docks_below() { use crate::app::config::DockSide; @@ -2008,7 +2120,7 @@ mod tests { use crate::app::config::DockSide; use crate::ui::dock::PanelId; let mut app = dock_app(); - app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0); + app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); assert_eq!(app.dock_group_shown(DockSide::Left, 0), Some(PanelId::BlockPalette)); app.show_block_palette = false; assert_eq!(app.dock_group_shown(DockSide::Left, 0), Some(PanelId::Properties)); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index d2cedd212..687f2ada3 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2773,7 +2773,7 @@ impl OpenCADStudio { // column is as wide as the widest one currently showing. let col_w = expanded .iter() - .map(|gi| self.dock.width(shown(*gi), self.win_size.0)) + .map(|gi| self.dock_group_width(side, *gi)) .fold(0.0, f32::max); let tab_strip = (!pinned.is_empty()).then(|| { @@ -2785,7 +2785,6 @@ impl OpenCADStudio { shown(*gi), &tabs, side, - self.dock_expanded.is_some_and(|e| tabs.contains(&e)), self.dock_dragging.is_some_and(|d| tabs.contains(&d)), ) }) @@ -2844,7 +2843,6 @@ impl OpenCADStudio { 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 { @@ -2858,11 +2856,10 @@ impl OpenCADStudio { }) .width(Fill) .height(Fill); + // Hovering raises the slot but leaves the rail looking as it is. let bg = move |theme: &Theme| { let palette = theme.palette(); - if is_active { - palette.primary.weak.color - } else if is_dragging { + if is_dragging { palette.primary.weak.color.scale_alpha(0.55) } else { palette.background.base.color @@ -2874,11 +2871,7 @@ impl OpenCADStudio { .style(move |theme: &Theme| container::Style { background: Some(Background::Color(bg(theme))), border: Border { - color: if is_active { - theme.palette().primary.base.color - } else { - theme.palette().background.neutral.color - }, + color: theme.palette().background.neutral.color, width: 1.0, radius: 0.0.into(), }, @@ -3117,16 +3110,23 @@ impl OpenCADStudio { top, )); } - DropTarget::Tab { side, group } => { + DropTarget::Tab { side, group, index } => { 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); + // The slot's tabs as they will be after the drop. + let mut preview = self.dock.clone(); + preview.add_tab(id, side, group, index); + let tabs: Vec<_> = preview + .location(id) + .map(|(s, gi)| preview.groups(s)[gi].tabs.clone()) + .unwrap_or_default() + .into_iter() + .filter(|t| *t == id || self.dock_panel_visible(*t)) + .collect(); let w = edge_w(side); layers.push(tint(side)); layers.push(place_at( @@ -3576,10 +3576,6 @@ impl canvas::Program for VBarLabel { } } -/// Width of a collapsed (auto-collapsing) panel's tab in the edge strip. -const DOCK_RAIL_W: f32 = 28.0; -/// Width of the grabbable divider between a docked panel and the viewport. -const DOCK_DIVIDER_W: f32 = 5.0; /// Width of a floating panel's vertical title bar. const DOCK_FLOAT_BAR_W: f32 = 28.0; @@ -3645,8 +3641,7 @@ 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; +use crate::ui::dock::{DOCK_DIVIDER_W, DOCK_RAIL_W, DOCK_TAB_H}; /// The tab row above a slot holding several panels. Pressing a tab shows its /// panel; dragging it pulls the panel out of the slot. diff --git a/src/ui/dock.rs b/src/ui/dock.rs index c891011e8..502d5fd68 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -420,15 +420,35 @@ impl DockState { *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 { + /// Add `id` as a tab of slot `group` on `side` (slot index counted + /// before the move) and show it. `index` is the tab position, counted in + /// the slot's tabs before the move; `None` appends a newcomer and leaves + /// a tab already in the slot where it is. A joining panel takes on the + /// slot's width, so switching tabs never changes the column width. + /// Returns whether the layout changed. + pub fn add_tab( + &mut self, + id: PanelId, + side: DockSide, + group: usize, + index: Option, + ) -> bool { if group >= self.groups(side).len() { return false; } - if self.groups(side)[group].tabs.contains(&id) { - return self.select_tab(id); + let before = self.clone(); + if let Some(pos) = self.groups(side)[group].tabs.iter().position(|t| *t == id) { + // Reordering within the slot. + let g = &mut self.groups_mut(side)[group]; + if let Some(index) = index { + g.tabs.remove(pos); + let index = if pos < index { index - 1 } else { index }; + g.tabs.insert(index.min(g.tabs.len()), id); + } + g.active = id; + return *self != before; } + let width = self.group_width(side, group); let mut group = group; if let Some((old_side, old_i, removed)) = self.detach(id) { if old_side == side && removed && old_i < group { @@ -436,11 +456,34 @@ impl DockState { } } let g = &mut self.groups_mut(side)[group]; - g.tabs.push(id); + let index = index.unwrap_or(g.tabs.len()).min(g.tabs.len()); + g.tabs.insert(index, id); g.active = id; + self.set_width(id, width); true } + /// The saved width a slot's panels share: the widest of its tabs, so a + /// slot whose tabs were sized apart (older configs) renders at one width. + pub fn group_width(&self, side: DockSide, group: usize) -> f32 { + self.groups(side)[group] + .tabs + .iter() + .map(|id| self.settings(*id).width) + .fold(DOCK_MIN_W, f32::max) + } + + /// Set the width of `id` and of every other tab in its slot. + pub fn set_group_width(&mut self, id: PanelId, width: f32) { + let tabs = match self.location(id) { + Some((side, gi)) => self.groups(side)[gi].tabs.clone(), + None => vec![id], + }; + for t in tabs { + self.set_width(t, width); + } + } + /// Float `id` at `rect`, on top of the other floating panels. pub fn float(&mut self, rect: FloatPanel) -> bool { let before = self.clone(); @@ -516,8 +559,13 @@ impl DockState { 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 }, + /// A tab in slot `group` on `side`, at position `index` among the + /// slot's tabs (before the move), or appended / left in place for `None`. + Tab { + side: DockSide, + group: usize, + index: Option, + }, /// Floating with its top-left corner at (`x`, `y`). Float { x: f32, y: f32 }, } @@ -574,6 +622,22 @@ pub const DOCK_MIN_W: f32 = 200.0; 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; +/// Width of a collapsed (auto-collapsing) slot's tab in the edge strip. +pub const DOCK_RAIL_W: f32 = 28.0; +/// Width of the grabbable divider between a docked slot and the viewport. +pub const DOCK_DIVIDER_W: f32 = 5.0; +/// Height of a docked slot's tab strip. +pub const DOCK_TAB_H: f32 = 24.0; + +/// Insertion position (0..=`count`) for a tab dropped at `x` on a strip of +/// `count` equal tabs spanning `x0..x0 + width`. +pub fn tab_insert_index(x: f32, x0: f32, width: f32, count: usize) -> usize { + if count == 0 || width <= 0.0 { + return 0; + } + let cell = width / count as f32; + (((x - x0) / cell).round().max(0.0) as usize).min(count) +} // ── Shared panel chrome ───────────────────────────────────────────────── @@ -829,7 +893,7 @@ mod tests { #[test] fn layout_round_trips_through_serde() { let mut state = DockState::default(); - state.add_tab(PanelId::Browser, DockSide::Left, 0); + state.add_tab(PanelId::Browser, DockSide::Left, 0, None); state.float(FloatPanel { id: PanelId::Count, x: 10.0, @@ -845,7 +909,7 @@ mod tests { #[test] 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.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None)); assert!(state.right.is_empty(), "its old slot disappears"); assert_eq!(state.left.len(), 1); assert_eq!( @@ -857,10 +921,50 @@ mod tests { assert_eq!(state.left[0].active, PanelId::Properties); } + #[test] + fn add_tab_reorders_within_a_slot() { + let mut state = DockState::default(); + state.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); + state.add_tab(PanelId::Browser, DockSide::Left, 0, None); + // Drag the first tab to the end. + assert!(state.add_tab(PanelId::Properties, DockSide::Left, 0, Some(3))); + assert_eq!( + state.left[0].tabs, + vec![PanelId::BlockPalette, PanelId::Browser, PanelId::Properties] + ); + // Drag the last tab to the front. + assert!(state.add_tab(PanelId::Properties, DockSide::Left, 0, Some(0))); + assert_eq!(state.left[0].tabs[0], PanelId::Properties); + // A middle drop (None) leaves a member where it is. + assert!(!state.add_tab(PanelId::Properties, DockSide::Left, 0, None)); + // A newcomer can land at a given position. + state.add_tab(PanelId::Count, DockSide::Left, 0, Some(1)); + assert_eq!(state.left[0].tabs[1], PanelId::Count); + } + + #[test] + fn tabs_share_the_slot_width() { + let mut state = DockState::default(); + state.set_width(PanelId::Properties, 320.0); + state.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); + assert_eq!(state.settings(PanelId::BlockPalette).width, 320.0); + state.set_group_width(PanelId::BlockPalette, 400.0); + assert_eq!(state.settings(PanelId::Properties).width, 400.0); + assert_eq!(state.group_width(DockSide::Left, 0), 400.0); + } + + #[test] + fn tab_insert_index_rounds_to_the_nearest_gap() { + assert_eq!(tab_insert_index(0.0, 0.0, 300.0, 3), 0); + assert_eq!(tab_insert_index(140.0, 0.0, 300.0, 3), 1); + assert_eq!(tab_insert_index(290.0, 0.0, 300.0, 3), 3); + assert_eq!(tab_insert_index(-20.0, 0.0, 300.0, 3), 0); + } + #[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); + state.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); // Pull the shown tab out below the group. assert!(state.dock(PanelId::BlockPalette, DockSide::Left, 1)); assert_eq!(state.left.len(), 2); @@ -878,7 +982,7 @@ mod tests { ]; 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!(state.add_tab(PanelId::BlockPalette, DockSide::Left, 1, None)); assert_eq!(state.left.len(), 1); assert_eq!( state.left[0].tabs, From 1f3fae6d1e2e01f9c54949f1bcf005ec5a8d9068 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:17:31 +0000 Subject: [PATCH 06/24] Dock: icon tabs with a pallet menu, wider stacking zones - Dropping on a docked pallet now stacks it above or below over the top and bottom 35 % of the slot; only the middle 30 % makes it a tab. - Tabs show the pallet icon (title as tooltip), aligned to the left. Every docked slot has the tab row, so its menu is always reachable. - A menu button at the right of the tab row lists every pallet with a check mark for those shown in this slot. Checking one opens it as a tab here (moving it from wherever it was docked); unchecking hides it and keeps its tab for next time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/mod.rs | 3 + src/app/update/dialog.rs | 96 +++++++++++++++++++++++++++--- src/app/view/mod.rs | 125 +++++++++++++++++++++++++++++---------- src/ui/dock.rs | 62 +++++++++++-------- 4 files changed, 224 insertions(+), 62 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 48cb2fd26..2b311596e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -868,6 +868,8 @@ pub(super) struct OpenCADStudio { pub(crate) dock_float_resizing: Option<(crate::ui::dock::PanelId, bool)>, /// Docked panel whose title bar the pointer is over (shows pin / close). pub(crate) dock_title_hover: Option, + /// Slot (side, index) whose pallet menu is open. + pub(crate) dock_tab_menu: Option<(crate::app::config::DockSide, usize)>, /// 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, @@ -4308,6 +4310,7 @@ impl OpenCADStudio { dock_split_drag: None, dock_float_resizing: None, dock_title_hover: None, + dock_tab_menu: None, xref_col_drag: None, xref_col_last: None, xref_split_drag: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 1a07ae657..d8715a2e5 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -592,6 +592,33 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.dock_title_hover = id; iced::Task::none() } + DockMsg::TabMenu(slot) => { + self.dock_tab_menu = slot; + iced::Task::none() + } + DockMsg::TabMenuToggle(side, gi, id) => { + if gi >= self.dock.groups(side).len() { + self.dock_tab_menu = None; + return iced::Task::none(); + } + if self.dock_group_visible_tabs(side, gi).contains(&id) { + // Unchecking hides the pallet; it keeps its tab, so it + // comes back here when opened again. + let task = self.on_dock(DockMsg::Close(id)); + if self.dock_group_shown(side, gi).is_none() { + self.dock_tab_menu = None; + } + self.save_config(); + return task; + } + self.dock.add_tab(id, side, gi, None); + // Joining may have removed an earlier slot on this side. + self.dock_tab_menu = self.dock.location(id); + let task = self.dock_open_panel(id); + self.dock.select_tab(id); + self.save_config(); + task + } DockMsg::FloatRaise(id) => { self.dock.raise_float(id); iced::Task::none() @@ -784,6 +811,35 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } } + /// Show panel `id` where the layout has it, running its usual open path + /// (refreshes, ribbon highlight) when it was closed. + fn dock_open_panel(&mut self, id: crate::ui::dock::PanelId) -> iced::Task { + use crate::ui::dock::PanelId; + if self.dock_panel_visible(id) { + return iced::Task::none(); + } + match id { + PanelId::Properties => { + self.show_properties = true; + self.ribbon.set_properties(true); + } + PanelId::BlockPalette => self.open_blocks_palette(None), + PanelId::ExternalReferences => { + self.show_external_references = true; + self.refresh_xref_manager(); + } + PanelId::Browser => self.show_browser = true, + PanelId::NodeGraph => { + return self.on_graph(crate::ui::node_graph::GraphMsg::Toggle); + } + PanelId::PointCloudManager => self.pc_manager.show = true, + PanelId::Count => self.set_count_palette(true), + PanelId::SheetSetManager => self.show_sheet_set_manager(true), + } + self.dock_expanded = Some(id); + iced::Task::none() + } + /// 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) { @@ -980,13 +1036,16 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven }; // Over a tab strip: drop between its tabs (reorders a member). let tabs = self.dock_group_visible_tabs(side, gi); - if tabs.len() > 1 && p.y < top + crate::ui::dock::DOCK_TAB_H { + if self.dock_expanded_groups(side).contains(&gi) + && p.y < top + crate::ui::dock::DOCK_TAB_H + { let x0 = self.dock_column_x0(side) - + if side == DockSide::Right { crate::ui::dock::DOCK_DIVIDER_W } else { 0.0 }; + + if side == DockSide::Right { crate::ui::dock::DOCK_DIVIDER_W } else { 0.0 } + + crate::ui::dock::DOCK_TAB_INSET; let visible_at = crate::ui::dock::tab_insert_index( p.x, x0, - self.dock_column_width(side), + crate::ui::dock::DOCK_TAB_CELL_W, tabs.len(), ); // Map the gap among visible tabs to a position in all tabs. @@ -2005,11 +2064,11 @@ mod tests { let mut app = dock_app(); app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); app.dock.right.clear(); - // Tabs: Properties | Blocks across the 250 px column. Drag the - // Properties tab past the end of the strip. + // Icon tabs: Properties | Blocks from the left. Drag the Properties + // tab past the Blocks tab. let _ = app.on_dock(DockMsg::SelectTab(PanelId::Properties)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(60.0, 12.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(245.0, 12.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(10.0, 12.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(70.0, 12.0))); assert_eq!( app.dock_drag_target, Some(DropTarget::Tab { @@ -2026,6 +2085,29 @@ mod tests { assert_eq!(app.dock.left[0].active, PanelId::Properties); } + #[test] + fn dock_tab_menu_adds_and_hides_pallets() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = dock_app(); + app.show_browser = false; + let _ = app.on_dock(DockMsg::TabMenu(Some((DockSide::Left, 0)))); + // Checking a closed pallet opens it as a tab of this slot. + let _ = app.on_dock(DockMsg::TabMenuToggle(DockSide::Left, 0, PanelId::Browser)); + assert!(app.show_browser); + assert_eq!(app.dock.left[0].tabs, vec![PanelId::Properties, PanelId::Browser]); + assert_eq!(app.dock.left[0].active, PanelId::Browser); + // Checking a pallet docked elsewhere moves it here. + let _ = app.on_dock(DockMsg::TabMenuToggle(DockSide::Left, 0, PanelId::BlockPalette)); + assert!(app.dock.right.is_empty()); + assert_eq!(app.dock.left[0].tabs.len(), 3); + // Unchecking hides it but keeps its tab. + let _ = app.on_dock(DockMsg::TabMenuToggle(DockSide::Left, 0, PanelId::Browser)); + assert!(!app.show_browser); + assert!(app.dock.left[0].tabs.contains(&PanelId::Browser)); + assert_eq!(app.dock_tab_menu, Some((DockSide::Left, 0))); + } + #[test] fn dock_tab_group_resize_applies_to_every_tab() { use crate::app::config::DockSide; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 687f2ada3..08d373cea 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2953,13 +2953,15 @@ impl OpenCADStudio { 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, false, tab); - let panel: Element<'_, Message> = if tabs.len() > 1 { - column![dock_tab_strip(&tabs, id, width), body] + let menu = (self.dock_tab_menu == Some((side, gi))).then(|| { + crate::ui::dock::PanelId::ALL.map(|p| { + (p, self.dock_panel_visible(p) && tabs.contains(&p)) + }) + }); + let panel: Element<'_, Message> = + column![dock_tab_strip(&tabs, id, width, side, gi, menu), body] .height(Fill) - .into() - } else { - body - }; + .into(); let divider = dock_divider(id); match side { crate::app::config::DockSide::Left => row![panel, divider].height(Fill).into(), @@ -3643,28 +3645,29 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { use crate::ui::dock::{DOCK_DIVIDER_W, DOCK_RAIL_W, DOCK_TAB_H}; -/// The tab row above a slot holding several panels. Pressing a tab shows its -/// panel; dragging it pulls the panel out of the slot. +/// The tab row above a docked slot: one icon tab per shown pallet, from the +/// left, and a menu button on the right listing every pallet. Pressing a tab +/// shows its pallet; dragging it reorders the tabs or pulls the pallet out. +/// `menu` holds each pallet with whether it shows in this slot while the +/// menu is open. fn dock_tab_strip( tabs: &[crate::ui::dock::PanelId], active: crate::ui::dock::PanelId, width: f32, + side: crate::app::config::DockSide, + gi: usize, + menu: Option<[(crate::ui::dock::PanelId, bool); 8]>, ) -> Element<'static, Message> { - let cells: Vec> = tabs + use crate::ui::dock::{DockMsg, DOCK_TAB_CELL_W, DOCK_TAB_INSET}; + const TAB_GAP: f32 = 2.0; + let mut 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]) + let cell = container(crate::ui::icons::themed(id.icon(), 14.0)) + .center_x(Length::Fixed(DOCK_TAB_CELL_W - TAB_GAP)) .center_y(Fill) - .clip(true) .style(move |theme: &Theme| { let palette = theme.palette(); container::Style { @@ -3673,11 +3676,6 @@ fn dock_tab_strip( } 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 @@ -3690,20 +3688,85 @@ fn dock_tab_strip( ..Default::default() } }); - mouse_area(cell) - .on_press(Message::Dock(crate::ui::dock::DockMsg::SelectTab(id))) - .interaction(iced::mouse::Interaction::Pointer) - .into() + let cell = mouse_area(cell) + .on_press(Message::Dock(DockMsg::SelectTab(id))) + .interaction(iced::mouse::Interaction::Pointer); + iced::widget::tooltip( + cell, + text(id.title()).size(10), + iced::widget::tooltip::Position::Bottom, + ) + .gap(4) + .into() }) .collect(); - container(row(cells).spacing(2).height(Fill)) + cells.push(Space::new().width(Fill).into()); + let menu_open = menu.is_some(); + let menu_button = button(crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 12.0)) + .on_press(Message::Dock(DockMsg::TabMenu( + (!menu_open).then_some((side, gi)), + ))) + .style(button::subtle) + .padding([2, 5]); + let rows: Vec> = menu + .iter() + .flatten() + .map(|(id, checked)| { + let id = *id; + let check: Element<'static, Message> = if *checked { + crate::ui::icons::themed(crate::ui::icons::CHECK, 12.0) + } else { + Space::new().width(12).height(12).into() + }; + button( + row![ + check, + crate::ui::icons::themed(id.icon(), 14.0), + text(id.title()).size(12), + ] + .spacing(8) + .align_y(iced::Center), + ) + .on_press(Message::Dock(DockMsg::TabMenuToggle(side, gi, id))) + .style(button::subtle) + .padding([4, 8]) + .width(Fill) + .into() + }) + .collect(); + let popup: Element<'static, Message> = container(column(rows).spacing(1).padding(4)) + .width(Length::Fixed(220.0)) + .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: 3.0.into(), + }, + ..Default::default() + }) + .into(); + cells.push( + iced_aw::DropDown::new(menu_button, popup, menu_open) + // Open away from the window edge the slot is docked against. + .alignment(match side { + crate::app::config::DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, + crate::app::config::DockSide::Right => { + iced_aw::drop_down::Alignment::BottomStart + } + }) + .offset(2.0) + .on_dismiss(Message::Dock(DockMsg::TabMenu(None))) + .into(), + ); + container(row(cells).spacing(TAB_GAP).height(Fill).align_y(iced::Center)) .width(Length::Fixed(width)) - .height(Length::Fixed(DOCK_TAB_H)) + .height(Length::Fixed(crate::ui::dock::DOCK_TAB_H)) .padding(iced::Padding { top: 3.0, - right: 4.0, + right: 2.0, bottom: 0.0, - left: 4.0, + left: DOCK_TAB_INSET, }) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.strong.color)), diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 502d5fd68..167ee0f16 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -43,6 +43,12 @@ pub enum DockMsg { FloatResizeGrab(PanelId, bool), /// The pointer entered (`Some`) or left (`None`) a docked title bar. TitleHover(Option), + /// Open (`Some`) the pallet menu of a slot (side, slot index), or close + /// it (`None`). + TabMenu(Option<(DockSide, usize)>), + /// Pallet menu pick: show `panel` as a tab of the slot, or hide it when + /// it already shows there. + TabMenuToggle(DockSide, usize, PanelId), /// Bring floating `panel` to the front. FloatRaise(PanelId), /// The pointer left the edge column; collapse any auto-collapsing panel. @@ -69,6 +75,18 @@ pub enum PanelId { } impl PanelId { + /// Every panel, in the order the pallet menu lists them. + pub const ALL: [PanelId; 8] = [ + PanelId::Properties, + PanelId::BlockPalette, + PanelId::ExternalReferences, + PanelId::Browser, + PanelId::NodeGraph, + PanelId::PointCloudManager, + PanelId::Count, + PanelId::SheetSetManager, + ]; + /// Localized-friendly display name used by the collapsed/edge chrome. pub fn title(self) -> &'static str { match self { @@ -288,16 +306,7 @@ impl DockState { /// 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, - PanelId::BlockPalette, - PanelId::ExternalReferences, - PanelId::Browser, - PanelId::NodeGraph, - PanelId::PointCloudManager, - PanelId::Count, - PanelId::SheetSetManager, - ] { + for id in PanelId::ALL { self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); } let mut seen = std::collections::BTreeSet::new(); @@ -571,8 +580,8 @@ pub enum DropTarget { } /// 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. +/// and bottom bands (35 % each) split the slot (dock above / below), the +/// middle 30 % joins it as a tab. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SlotZone { Above, @@ -583,7 +592,7 @@ pub enum SlotZone { /// 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); + let band = h * 0.35; if y < top + band { SlotZone::Above } else if y > bottom - band { @@ -629,13 +638,17 @@ pub const DOCK_DIVIDER_W: f32 = 5.0; /// Height of a docked slot's tab strip. pub const DOCK_TAB_H: f32 = 24.0; +/// Pitch of one icon tab in a tab strip (tab plus the gap after it). +pub const DOCK_TAB_CELL_W: f32 = 30.0; +/// Left inset of the first tab in a tab strip. +pub const DOCK_TAB_INSET: f32 = 4.0; + /// Insertion position (0..=`count`) for a tab dropped at `x` on a strip of -/// `count` equal tabs spanning `x0..x0 + width`. -pub fn tab_insert_index(x: f32, x0: f32, width: f32, count: usize) -> usize { - if count == 0 || width <= 0.0 { +/// `count` left-aligned tabs of pitch `cell` starting at `x0`. +pub fn tab_insert_index(x: f32, x0: f32, cell: f32, count: usize) -> usize { + if count == 0 || cell <= 0.0 { return 0; } - let cell = width / count as f32; (((x - x0) / cell).round().max(0.0) as usize).min(count) } @@ -955,10 +968,11 @@ mod tests { #[test] fn tab_insert_index_rounds_to_the_nearest_gap() { - assert_eq!(tab_insert_index(0.0, 0.0, 300.0, 3), 0); - assert_eq!(tab_insert_index(140.0, 0.0, 300.0, 3), 1); - assert_eq!(tab_insert_index(290.0, 0.0, 300.0, 3), 3); - assert_eq!(tab_insert_index(-20.0, 0.0, 300.0, 3), 0); + assert_eq!(tab_insert_index(0.0, 0.0, 30.0, 3), 0); + assert_eq!(tab_insert_index(40.0, 0.0, 30.0, 3), 1); + assert_eq!(tab_insert_index(85.0, 0.0, 30.0, 3), 3); + assert_eq!(tab_insert_index(250.0, 0.0, 30.0, 3), 3); + assert_eq!(tab_insert_index(-20.0, 0.0, 30.0, 3), 0); } #[test] @@ -1116,12 +1130,12 @@ mod tests { } #[test] - fn slot_zone_splits_quarters() { + fn slot_zone_splits_into_stacking_and_tab_bands() { assert_eq!(slot_zone(10.0, 0.0, 400.0), SlotZone::Above); + assert_eq!(slot_zone(130.0, 0.0, 400.0), SlotZone::Above); assert_eq!(slot_zone(200.0, 0.0, 400.0), SlotZone::Middle); + assert_eq!(slot_zone(270.0, 0.0, 400.0), SlotZone::Below); 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] From 09e4792a0a861edeb2242bfe5c56417f62790433 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:24:33 +0000 Subject: [PATCH 07/24] Dock: Layer Manager pallet, double-click to float / dock - LAYERS opens the Layer Manager as a dockable pallet instead of a modal dialog with a darkened backdrop. It floats over the middle of the drawing the first time and keeps its place after that; LAYERS again (or its close button) hides it. The table keeps its natural width and scrolls sideways when the pallet is narrower, and the Name column divider now drags through the dock's pointer capture. Ctrl+A selects all layers while a layer row is selected in the open pallet. - The Layer Manager is listed in the tab-row pallet menu like the others. - Double-clicking a docked title bar floats the panel beside its edge; double-clicking a floating title bar docks the panel on the side its title bar faces. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/mod.rs | 4 ++- src/app/update/dialog.rs | 77 ++++++++++++++++++++++++++++++++++++++++ src/app/update/mod.rs | 47 +++++++++++++++--------- src/app/view/mod.rs | 17 +++++++-- src/app/view/modal.rs | 10 ------ src/ui/dock.rs | 26 +++++++------- src/ui/window/layers.rs | 41 ++++++++++++++++++--- 7 files changed, 177 insertions(+), 45 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 2b311596e..899d0d2c8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -814,6 +814,8 @@ pub(super) struct OpenCADStudio { render_mode_preview: Option, /// Whether the Properties panel is shown on the left (PROPERTIES). show_properties: bool, + /// The Layer Manager pallet (LAYERS) is open. + pub(crate) show_layers: bool, /// Docked Insert Block panel visibility. pub(crate) show_block_palette: bool, /// Node graph overlay over the viewport. @@ -1963,7 +1965,6 @@ pub enum ModalKind { PluginManager, UpdateNotice, DonationPrompt, - Layers, LayerStateManager, LayerTranslator, DrawingUnits, @@ -4285,6 +4286,7 @@ impl OpenCADStudio { render_mode_menu_open: false, render_mode_preview: None, show_properties: true, + show_layers: false, show_block_palette: false, show_node_graph: false, property_target_override: None, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index d8715a2e5..613422a0a 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -592,6 +592,38 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.dock_title_hover = id; iced::Task::none() } + DockMsg::FloatOut(id) => { + // Float beside the edge it was docked on, near the top. + let (ww, _) = self.dock_workspace_size(); + let (w, h) = self.dock_float_size(id); + let x = match self.dock.location(id) { + Some((DockSide::Right, _)) => { + ww - self.dock_column_width(DockSide::Right) - w - 60.0 + } + _ => self.dock_column_width(DockSide::Left) + 40.0, + }; + self.dock_dragging = None; + self.dock_drag_target = None; + if self.dock.float(FloatPanel { + id, + x: x.max(0.0), + y: 40.0, + w, + h, + }) { + self.save_config(); + } + iced::Task::none() + } + DockMsg::DockTo(id, side) => { + self.dock_dragging = None; + self.dock_drag_target = None; + if self.dock.dock(id, side, usize::MAX) { + self.dock_expanded = Some(id); + self.save_config(); + } + iced::Task::none() + } DockMsg::TabMenu(slot) => { self.dock_tab_menu = slot; iced::Task::none() @@ -666,6 +698,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.show_properties = false; self.ribbon.set_properties(false); } + PanelId::Layers => { + self.show_layers = false; + self.ribbon.deactivate_tool_if("LAYERS"); + } } if self.dock_expanded == Some(id) { self.dock_expanded = None; @@ -749,6 +785,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven from_left, ); } + } else if self.layer_col_dragging { + if let Some(last) = self.dock_drag_last { + self.layer_name_col_w = + (self.layer_name_col_w + point.x - last.x).clamp(60.0, 640.0); + } } else if let Some(id) = self.dock_resizing { if let Some(last) = self.dock_drag_last { let dx = point.x - last.x; @@ -768,6 +809,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven || self.dock_resizing.is_some() || self.dock_split_drag.is_some() || self.dock_float_resizing.is_some() + || self.layer_col_dragging { self.dock_drag_last = Some(point); } @@ -806,6 +848,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.xref_col_drag = None; self.xref_col_last = None; self.xref_split_drag = false; + self.layer_col_dragging = false; iced::Task::none() } } @@ -835,6 +878,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven PanelId::PointCloudManager => self.pc_manager.show = true, PanelId::Count => self.set_count_palette(true), PanelId::SheetSetManager => self.show_sheet_set_manager(true), + PanelId::Layers => { + self.sync_ribbon_layers(); + self.show_layers = true; + } } self.dock_expanded = Some(id); iced::Task::none() @@ -871,6 +918,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven PanelId::PointCloudManager => self.pc_manager.show, PanelId::Count => self.count_palette.show, PanelId::SheetSetManager => self.sheet_set.show, + PanelId::Layers => self.show_layers, } } @@ -2108,6 +2156,35 @@ mod tests { assert_eq!(app.dock_tab_menu, Some((DockSide::Left, 0))); } + #[test] + fn layers_command_opens_a_floating_pallet_and_toggles_it_closed() { + use crate::ui::dock::PanelId; + let mut app = dock_app(); + let _ = app.update(Message::ToggleLayers); + assert!(app.show_layers); + assert!(app.active_modal.is_none(), "no modal backdrop"); + let f = app.dock.float_rect(PanelId::Layers).expect("floats on first use"); + assert!(f.x > 0.0 && f.y >= 0.0); + let _ = app.update(Message::ToggleLayers); + assert!(!app.show_layers); + // Its place is kept for next time. + assert!(app.dock.float_rect(PanelId::Layers).is_some()); + } + + #[test] + fn dock_double_clicks_float_and_dock_panels() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = dock_app(); + let _ = app.on_dock(DockMsg::FloatOut(PanelId::Properties)); + assert!(app.dock.left.is_empty()); + let f = app.dock.float_rect(PanelId::Properties).expect("floating"); + assert!(f.x > 0.0, "floats clear of the left edge"); + let _ = app.on_dock(DockMsg::DockTo(PanelId::Properties, DockSide::Right)); + assert!(app.dock.floating.is_empty()); + assert_eq!(app.dock.location(PanelId::Properties), Some((DockSide::Right, 1))); + } + #[test] fn dock_tab_group_resize_applies_to_every_tab() { use crate::app::config::DockSide; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 187d653d0..4f39e772c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2552,14 +2552,28 @@ impl OpenCADStudio { Message::ScriptLine(line) => self.feed_script_line(&line), Message::ToggleLayers => { - if self.active_modal == Some(super::ModalKind::Layers) { - self.ribbon.deactivate_tool_if("LAYERS"); - self.active_modal = None; - self.reset_modal_geometry(); - } else { - self.sync_ribbon_layers(); - self.active_modal = Some(super::ModalKind::Layers); + use crate::ui::dock::PanelId; + if self.show_layers { + return self.on_dock(crate::ui::dock::DockMsg::Close(PanelId::Layers)); } + self.sync_ribbon_layers(); + self.show_layers = true; + if !self.dock.is_placed(PanelId::Layers) { + // First use: float it over the middle of the drawing, + // where the dialog used to open. + let (ww, wh) = self.dock_workspace_size(); + let (w, h) = self.dock_float_size(PanelId::Layers); + self.dock.float(crate::ui::dock::FloatPanel { + id: PanelId::Layers, + x: ((ww - w) * 0.5).max(0.0), + y: ((wh - h) * 0.5).max(0.0), + w, + h, + }); + self.save_config(); + } + self.dock.raise_float(PanelId::Layers); + self.dock_expanded = Some(PanelId::Layers); Task::none() } @@ -6261,8 +6275,11 @@ impl OpenCADStudio { // Ctrl+A in the MText editor selects all of its text. self.mtext_select_all(); Task::none() - } else if self.active_modal == Some(super::ModalKind::Layers) { - // Select every row in the Layer Manager (#236). + } else if self.dock_panel_visible(crate::ui::dock::PanelId::Layers) + && self.tabs[i].layers.selected.is_some() + { + // Select every row in the Layer Manager (#236) while the + // user is working in it (a layer row is selected). let n = self.tabs[i].layers.layers.len(); self.tabs[i].layers.selected_multi = (0..n).collect(); self.tabs[i].layers.selected = (n > 0).then_some(0); @@ -9024,17 +9041,16 @@ impl OpenCADStudio { Task::none() } Message::LayerNameColGrab => { - // Start a Name-column divider drag; rides ModalDragMove. + // Start a Name-column divider drag; the dock's pointer capture + // feeds it (see `on_dock`). self.layer_col_dragging = true; - self.modal_drag_last = None; + self.dock_drag_last = None; Task::none() } Message::ModalDragMove(p) => { if let Some(last) = self.modal_drag_last { let (dx, dy) = (p.x - last.x, p.y - last.y); - if self.layer_col_dragging { - self.layer_name_col_w = (self.layer_name_col_w + dx).clamp(60.0, 640.0); - } else if self.modal_resizing { + if self.modal_resizing { // The grip sits bottom-right, so dragging out grows the // box. The delta is added to each dialog's natural size, // so clamp it at zero — dragging in past the natural size @@ -9068,7 +9084,7 @@ impl OpenCADStudio { } } } - if self.modal_dragging || self.modal_resizing || self.layer_col_dragging { + if self.modal_dragging || self.modal_resizing { self.modal_drag_last = Some(p); } Task::none() @@ -9076,7 +9092,6 @@ impl OpenCADStudio { Message::ModalDragRelease => { self.modal_dragging = false; self.modal_resizing = false; - self.layer_col_dragging = false; self.modal_drag_last = None; Task::none() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 08d373cea..1e02d092a 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1952,6 +1952,7 @@ bg={bg_ms:.1}ms n={view_count}" let any_splitting = self.dock_split_drag.is_some(); let any_resizing = self.dock_resizing.is_some() || self.dock_float_resizing.is_some() + || self.layer_col_dragging || self.xref_col_drag.is_some() || self.xref_split_drag; let workspace: Element<'_, Message> = if any_dragging || any_resizing || any_splitting { @@ -2935,6 +2936,9 @@ impl OpenCADStudio { crate::ui::dock::PanelId::SheetSetManager => { crate::ui::window::sheet_set::view(&self.sheet_set, width, chrome) } + crate::ui::dock::PanelId::Layers => { + tab.layers.view_panel(self.layer_name_col_w, width, chrome) + } } } @@ -3620,6 +3624,15 @@ fn floating_title_bar( ..Default::default() })) .on_press(Message::Dock(DockMsg::DockGrab(id))) + // Double-click docks it against the edge its title bar faces. + .on_double_click(Message::Dock(DockMsg::DockTo( + id, + if on_left { + crate::app::config::DockSide::Left + } else { + crate::app::config::DockSide::Right + }, + ))) .interaction(iced::mouse::Interaction::Grab) .into() } @@ -3643,7 +3656,7 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { .into() } -use crate::ui::dock::{DOCK_DIVIDER_W, DOCK_RAIL_W, DOCK_TAB_H}; +use crate::ui::dock::{DOCK_DIVIDER_W, DOCK_RAIL_W}; /// The tab row above a docked slot: one icon tab per shown pallet, from the /// left, and a menu button on the right listing every pallet. Pressing a tab @@ -3656,7 +3669,7 @@ fn dock_tab_strip( width: f32, side: crate::app::config::DockSide, gi: usize, - menu: Option<[(crate::ui::dock::PanelId, bool); 8]>, + menu: Option<[(crate::ui::dock::PanelId, bool); crate::ui::dock::PanelId::ALL.len()]>, ) -> Element<'static, Message> { use crate::ui::dock::{DockMsg, DOCK_TAB_CELL_W, DOCK_TAB_INSET}; const TAB_GAP: f32 = 2.0; diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index bb2a95378..7f2c286b9 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -19,7 +19,6 @@ impl OpenCADStudio { Some(K::PluginManager) => crate::tr!("modal", "plugin-manager"), Some(K::UpdateNotice) => crate::tr!("modal", "update-available"), Some(K::DonationPrompt) => crate::tr!("donation", "title"), - Some(K::Layers) => crate::tr!("modal", "layer-manager"), Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"), Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(), Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), @@ -507,15 +506,6 @@ impl OpenCADStudio { |flow| crate::ui::window::update_notice::view_window(latest, body, flow), ) } - super::super::ModalKind::Layers => { - let tab = &self.tabs[self.active_tab]; - sized_flow( - ex, - 900, - 360, - |flow| tab.layers.view_window(self.layer_name_col_w, flow), - ) - } super::super::ModalKind::LayerTranslator => { use crate::modules::draw::layers::laytrans; let i = self.active_tab; diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 167ee0f16..26c77bf41 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -49,6 +49,10 @@ pub enum DockMsg { /// Pallet menu pick: show `panel` as a tab of the slot, or hide it when /// it already shows there. TabMenuToggle(DockSide, usize, PanelId), + /// Double-click on a docked title bar: float the panel. + FloatOut(PanelId), + /// Double-click on a floating title bar: dock the panel on that side. + DockTo(PanelId, DockSide), /// Bring floating `panel` to the front. FloatRaise(PanelId), /// The pointer left the edge column; collapse any auto-collapsing panel. @@ -72,11 +76,13 @@ pub enum PanelId { Count, /// The open sheet sets: subsets and sheets. SheetSetManager, + /// The Layer Manager (LAYERS). + Layers, } impl PanelId { /// Every panel, in the order the pallet menu lists them. - pub const ALL: [PanelId; 8] = [ + pub const ALL: [PanelId; 9] = [ PanelId::Properties, PanelId::BlockPalette, PanelId::ExternalReferences, @@ -85,6 +91,7 @@ impl PanelId { PanelId::PointCloudManager, PanelId::Count, PanelId::SheetSetManager, + PanelId::Layers, ]; /// Localized-friendly display name used by the collapsed/edge chrome. @@ -98,6 +105,7 @@ impl PanelId { PanelId::PointCloudManager => "Point Cloud Manager", PanelId::Count => "Count", PanelId::SheetSetManager => "Sheet Set Manager", + PanelId::Layers => "Layer Manager", } } @@ -112,6 +120,7 @@ impl PanelId { PanelId::PointCloudManager => include_bytes!("../../assets/icons/pc_attach.svg"), PanelId::Count => include_bytes!("../../assets/icons/data_extract.svg"), PanelId::SheetSetManager => include_bytes!("../../assets/icons/sheetset.svg"), + PanelId::Layers => include_bytes!("../../assets/icons/layers/panel.svg"), } } @@ -126,6 +135,7 @@ impl PanelId { PanelId::PointCloudManager => 280.0, PanelId::Count => 280.0, PanelId::SheetSetManager => 280.0, + PanelId::Layers => 560.0, } } @@ -133,7 +143,7 @@ impl PanelId { /// column-rich, so it allows double the shared maximum. fn max_width(self) -> f32 { match self { - PanelId::ExternalReferences => DOCK_MAX_W * 2.0, + PanelId::ExternalReferences | PanelId::Layers => DOCK_MAX_W * 2.0, _ => DOCK_MAX_W, } } @@ -699,6 +709,7 @@ pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, .padding([3, 6]), ) .on_press(Message::Dock(DockMsg::DockGrab(id))) + .on_double_click(Message::Dock(DockMsg::FloatOut(id))) .on_enter(Message::Dock(DockMsg::TitleHover(Some(id)))) .on_exit(Message::Dock(DockMsg::TitleHover(None))) .interaction(iced::mouse::Interaction::Grab) @@ -1098,16 +1109,7 @@ mod tests { #[test] fn every_panel_has_its_own_icon() { - let ids = [ - PanelId::Properties, - PanelId::BlockPalette, - PanelId::ExternalReferences, - PanelId::Browser, - PanelId::NodeGraph, - PanelId::PointCloudManager, - PanelId::Count, - PanelId::SheetSetManager, - ]; + let ids = PanelId::ALL; for (i, a) in ids.iter().enumerate() { assert!(a.icon().starts_with(b" Element<'_, Message> { - self.view_content(name_col_w, sizing) + use crate::ui::dock::PanelId; + let title_bar = crate::ui::dock::title_bar( + PanelId::Layers, + crate::tr!("modal", "layer-manager"), + chrome, + ); + let table = self.view_content( + name_col_w, + crate::ui::modal::ModalSizing { + width: Length::Shrink, + height: Length::Fill, + }, + ); + let body = scrollable(table) + .direction(iced::widget::scrollable::Direction::Horizontal( + iced::widget::scrollable::Scrollbar::new(), + )) + .width(Length::Fill) + .height(Length::Fill); + container(column![title_bar, body]) + .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() } fn view_content( From 1176789125e9198b38a17feb6dd7c334c3ee98d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:34:02 +0000 Subject: [PATCH 08/24] Dock: tab row only for tab groups, larger plain icon tabs, working menu - The tab row shows only when at least two pallets share a slot. - Tab icons are larger (18 px). The shown tab gets the grey background without a border; other tabs have no background. - The pallet menu was drawn by a dropdown that squeezed it to one icon wide. It is now its own overlay hanging under the menu button, laid out like the right-click menu: pallet icon, name, and a check mark on the right for the pallets shown in this slot. Any click outside closes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/update/dialog.rs | 7 +- src/app/view/mod.rs | 214 +++++++++++++++++++++++---------------- src/ui/dock.rs | 4 +- 3 files changed, 132 insertions(+), 93 deletions(-) diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 613422a0a..2c3117af3 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -1002,7 +1002,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven /// Left x of the expanded slots on `side` (past the auto-hide rail, if /// the edge has one) in workspace pixels. - fn dock_column_x0(&self, side: crate::app::config::DockSide) -> f32 { + pub(crate) fn dock_column_x0(&self, side: crate::app::config::DockSide) -> f32 { use crate::app::config::DockSide; let has_rail = self.dock_visible_groups(side).into_iter().any(|gi| { self.dock_group_shown(side, gi) @@ -1084,7 +1084,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven }; // Over a tab strip: drop between its tabs (reorders a member). let tabs = self.dock_group_visible_tabs(side, gi); - if self.dock_expanded_groups(side).contains(&gi) + if tabs.len() > 1 + && self.dock_expanded_groups(side).contains(&gi) && p.y < top + crate::ui::dock::DOCK_TAB_H { let x0 = self.dock_column_x0(side) @@ -2116,7 +2117,7 @@ mod tests { // tab past the Blocks tab. let _ = app.on_dock(DockMsg::SelectTab(PanelId::Properties)); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(10.0, 12.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(70.0, 12.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(78.0, 12.0))); assert_eq!( app.dock_drag_target, Some(DropTarget::Tab { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 1e02d092a..1f4451134 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1936,6 +1936,41 @@ bg={bg_ms:.1}ms n={view_count}" layers.push(self.floating_panel(*f, tab)); } } + // An open pallet menu hangs under its slot's menu button, over a + // catcher that closes it on any click elsewhere. + if let Some((side, gi)) = self.dock_tab_menu { + let span = self + .dock_slot_spans(side) + .into_iter() + .find(|(g, _, _)| *g == gi); + if let Some((_, top, _)) = span { + let tabs = self.dock_group_visible_tabs(side, gi); + let shown: Vec<_> = crate::ui::dock::PanelId::ALL + .iter() + .map(|p| (*p, tabs.contains(p))) + .collect(); + let right_pad = 2.0; + let button_right = self.dock_column_x0(side) + + if side == crate::app::config::DockSide::Right { + DOCK_DIVIDER_W + } else { + 0.0 + } + + self.dock_column_width(side) + - right_pad; + layers.push( + mouse_area(Space::new().width(Fill).height(Fill)) + .on_press(Message::Dock(crate::ui::dock::DockMsg::TabMenu(None))) + .on_right_press(Message::Dock(crate::ui::dock::DockMsg::TabMenu(None))) + .into(), + ); + layers.push(place_at( + dock_tab_menu(side, gi, &shown), + (button_right - DOCK_MENU_W).max(0.0), + top + crate::ui::dock::DOCK_TAB_H, + )); + } + } // 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)); @@ -2957,15 +2992,13 @@ impl OpenCADStudio { 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, false, tab); - let menu = (self.dock_tab_menu == Some((side, gi))).then(|| { - crate::ui::dock::PanelId::ALL.map(|p| { - (p, self.dock_panel_visible(p) && tabs.contains(&p)) - }) - }); - let panel: Element<'_, Message> = - column![dock_tab_strip(&tabs, id, width, side, gi, menu), body] + let panel: Element<'_, Message> = if tabs.len() > 1 { + column![dock_tab_strip(&tabs, id, width, side, gi), body] .height(Fill) - .into(); + .into() + } else { + body + }; let divider = dock_divider(id); match side { crate::app::config::DockSide::Left => row![panel, divider].height(Fill).into(), @@ -3658,18 +3691,16 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { use crate::ui::dock::{DOCK_DIVIDER_W, DOCK_RAIL_W}; -/// The tab row above a docked slot: one icon tab per shown pallet, from the -/// left, and a menu button on the right listing every pallet. Pressing a tab -/// shows its pallet; dragging it reorders the tabs or pulls the pallet out. -/// `menu` holds each pallet with whether it shows in this slot while the -/// menu is open. +/// The tab row above a docked slot holding several pallets: one icon tab per +/// shown pallet, from the left, and a menu button on the right that lists +/// every pallet (drawn by `dock_tab_menu`). Pressing a tab shows its pallet; +/// dragging it reorders the tabs or pulls the pallet out. fn dock_tab_strip( tabs: &[crate::ui::dock::PanelId], active: crate::ui::dock::PanelId, width: f32, side: crate::app::config::DockSide, gi: usize, - menu: Option<[(crate::ui::dock::PanelId, bool); crate::ui::dock::PanelId::ALL.len()]>, ) -> Element<'static, Message> { use crate::ui::dock::{DockMsg, DOCK_TAB_CELL_W, DOCK_TAB_INSET}; const TAB_GAP: f32 = 2.0; @@ -3678,28 +3709,18 @@ fn dock_tab_strip( .map(|id| { let id = *id; let is_active = id == active; - let cell = container(crate::ui::icons::themed(id.icon(), 14.0)) + let cell = container(crate::ui::icons::themed(id.icon(), 18.0)) .center_x(Length::Fixed(DOCK_TAB_CELL_W - TAB_GAP)) .center_y(Fill) - .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 - })), - 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), - }, + .style(move |theme: &Theme| container::Style { + // Only the shown tab gets a background. + background: is_active + .then(|| Background::Color(theme.palette().background.weak.color)), + border: Border { + radius: iced::border::Radius::default().top(4.0), ..Default::default() - } + }, + ..Default::default() }); let cell = mouse_area(cell) .on_press(Message::Dock(DockMsg::SelectTab(id))) @@ -3714,62 +3735,11 @@ fn dock_tab_strip( }) .collect(); cells.push(Space::new().width(Fill).into()); - let menu_open = menu.is_some(); - let menu_button = button(crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 12.0)) - .on_press(Message::Dock(DockMsg::TabMenu( - (!menu_open).then_some((side, gi)), - ))) - .style(button::subtle) - .padding([2, 5]); - let rows: Vec> = menu - .iter() - .flatten() - .map(|(id, checked)| { - let id = *id; - let check: Element<'static, Message> = if *checked { - crate::ui::icons::themed(crate::ui::icons::CHECK, 12.0) - } else { - Space::new().width(12).height(12).into() - }; - button( - row![ - check, - crate::ui::icons::themed(id.icon(), 14.0), - text(id.title()).size(12), - ] - .spacing(8) - .align_y(iced::Center), - ) - .on_press(Message::Dock(DockMsg::TabMenuToggle(side, gi, id))) - .style(button::subtle) - .padding([4, 8]) - .width(Fill) - .into() - }) - .collect(); - let popup: Element<'static, Message> = container(column(rows).spacing(1).padding(4)) - .width(Length::Fixed(220.0)) - .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: 3.0.into(), - }, - ..Default::default() - }) - .into(); cells.push( - iced_aw::DropDown::new(menu_button, popup, menu_open) - // Open away from the window edge the slot is docked against. - .alignment(match side { - crate::app::config::DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, - crate::app::config::DockSide::Right => { - iced_aw::drop_down::Alignment::BottomStart - } - }) - .offset(2.0) - .on_dismiss(Message::Dock(DockMsg::TabMenu(None))) + button(crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 14.0)) + .on_press(Message::Dock(DockMsg::TabMenu(Some((side, gi))))) + .style(button::subtle) + .padding([3, 5]) .into(), ); container(row(cells).spacing(TAB_GAP).height(Fill).align_y(iced::Center)) @@ -3788,6 +3758,74 @@ fn dock_tab_strip( .into() } +/// Width of the tab row's pallet menu. +const DOCK_MENU_W: f32 = 230.0; + +/// The pallet menu of slot (`side`, `gi`), styled like the right-click menu: +/// one row per pallet with its icon, its name and a check mark when it shows +/// in this slot. `shown` holds each pallet with that state. +fn dock_tab_menu( + side: crate::app::config::DockSide, + gi: usize, + shown: &[(crate::ui::dock::PanelId, bool)], +) -> Element<'static, Message> { + use crate::ui::dock::DockMsg; + let rows: Vec> = shown + .iter() + .map(|(id, checked)| { + let id = *id; + let check: Element<'static, Message> = if *checked { + crate::ui::icons::themed(crate::ui::icons::CHECK, 12.0) + } else { + Space::new().width(12).height(12).into() + }; + button( + row![ + crate::ui::icons::themed(id.icon(), 16.0), + text(id.title()).size(12).width(Fill), + check, + ] + .spacing(8) + .align_y(iced::Center), + ) + .on_press(Message::Dock(DockMsg::TabMenuToggle(side, gi, id))) + .style(|theme: &Theme, status| { + let palette = theme.palette(); + button::Style { + background: matches!(status, button::Status::Hovered | button::Status::Pressed) + .then(|| Background::Color(palette.background.weak.color)), + text_color: palette.background.base.text, + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + } + }) + .padding([5, 8]) + .width(Fill) + .into() + }) + .collect(); + container(column(rows).spacing(1).padding(4)) + .width(Length::Fixed(DOCK_MENU_W)) + .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: 4.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: 12.0, + }, + ..Default::default() + }) + .into() +} + /// Draggable bar between two expanded slots on an edge; double-click gives /// every slot on the edge the same height again. It spans exactly the slots' /// width (panel plus its divider): a `Fill` width would stretch the whole diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 26c77bf41..3ebe13176 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -646,10 +646,10 @@ pub const DOCK_RAIL_W: f32 = 28.0; /// Width of the grabbable divider between a docked slot and the viewport. pub const DOCK_DIVIDER_W: f32 = 5.0; /// Height of a docked slot's tab strip. -pub const DOCK_TAB_H: f32 = 24.0; +pub const DOCK_TAB_H: f32 = 28.0; /// Pitch of one icon tab in a tab strip (tab plus the gap after it). -pub const DOCK_TAB_CELL_W: f32 = 30.0; +pub const DOCK_TAB_CELL_W: f32 = 34.0; /// Left inset of the first tab in a tab strip. pub const DOCK_TAB_INSET: f32 = 4.0; From f038fcc9a195b1a60fc8de19450373f6a2a9aa01 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:01:31 +0000 Subject: [PATCH 09/24] Dock: Photoshop-style vertical icon strip for docked groups Each dock edge now has a vertical icon strip along the window edge in place of the horizontal tab row and the auto-hide rail. - Every shown group gets a grip bar with its pallets' icons below it; the shown pallet's icon has a grey background. Clicking an icon shows that pallet in its group; hovering the icons of an auto-hiding group opens it. - Dragging an icon within the strip reorders it or moves it into another group; dropping it on a grip or below the last group makes it a new group there. On the pallet area, the top / bottom bands still start a new group above / below and the middle joins the group. - Dragging a grip moves the whole group to another position or edge. - A + button under the groups opens the pallet menu (icon, name, check mark). Opening a pallet from it docks it on that edge as a new group, or in its existing group there; a checked pallet is hidden. - Groups keep their width when moved; the divider against the viewport sizes the whole edge column. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/mod.rs | 10 +- src/app/update/dialog.rs | 277 +++++++++++++++++++++----------- src/app/view/mod.rs | 329 ++++++++++++++++----------------------- src/ui/dock.rs | 158 +++++++++++++++---- 4 files changed, 455 insertions(+), 319 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 899d0d2c8..464074156 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -870,8 +870,11 @@ pub(super) struct OpenCADStudio { pub(crate) dock_float_resizing: Option<(crate::ui::dock::PanelId, bool)>, /// Docked panel whose title bar the pointer is over (shows pin / close). pub(crate) dock_title_hover: Option, - /// Slot (side, index) whose pallet menu is open. - pub(crate) dock_tab_menu: Option<(crate::app::config::DockSide, usize)>, + /// Edge whose icon-strip pallet menu (+) is open. + pub(crate) dock_edge_menu: Option, + /// Group being dragged by its strip grip (side, slot index). While set, + /// `dock_dragging` holds the group's shown panel for the preview. + pub(crate) dock_dragging_group: Option<(crate::app::config::DockSide, usize)>, /// 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, @@ -4312,7 +4315,8 @@ impl OpenCADStudio { dock_split_drag: None, dock_float_resizing: None, dock_title_hover: None, - dock_tab_menu: None, + dock_edge_menu: None, + dock_dragging_group: None, xref_col_drag: None, xref_col_last: None, xref_split_drag: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 2c3117af3..83463273d 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -624,33 +624,33 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } iced::Task::none() } - DockMsg::TabMenu(slot) => { - self.dock_tab_menu = slot; + DockMsg::EdgeMenu(side) => { + self.dock_edge_menu = side; iced::Task::none() } - DockMsg::TabMenuToggle(side, gi, id) => { - if gi >= self.dock.groups(side).len() { - self.dock_tab_menu = None; - return iced::Task::none(); - } - if self.dock_group_visible_tabs(side, gi).contains(&id) { - // Unchecking hides the pallet; it keeps its tab, so it - // comes back here when opened again. + DockMsg::EdgeMenuToggle(side, id) => { + if self.dock_panel_visible(id) { let task = self.on_dock(DockMsg::Close(id)); - if self.dock_group_shown(side, gi).is_none() { - self.dock_tab_menu = None; - } self.save_config(); return task; } - self.dock.add_tab(id, side, gi, None); - // Joining may have removed an earlier slot on this side. - self.dock_tab_menu = self.dock.location(id); + // A pallet opened from an edge's menu lands on that edge: in + // its own group there if it has one, else as a new group. + if self.dock.location(id).map(|(s, _)| s) != Some(side) { + self.dock.dock(id, side, usize::MAX); + } let task = self.dock_open_panel(id); self.dock.select_tab(id); self.save_config(); task } + DockMsg::GroupGrab(side, gi) => { + if let Some(id) = self.dock_group_shown(side, gi) { + self.dock_begin_drag(id); + self.dock_dragging_group = Some((side, gi)); + } + iced::Task::none() + } DockMsg::FloatRaise(id) => { self.dock.raise_float(id); iced::Task::none() @@ -754,7 +754,13 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven }; } if self.dock_drag_target.is_some() || moved >= DOCK_DRAG_THRESHOLD { - self.dock_drag_target = Some(self.dock_drop_target(id, point)); + let target = self.dock_drop_target(id, point); + // A whole group only moves between slots: it cannot + // join another group or float. + self.dock_drag_target = match (self.dock_dragging_group, target) { + (Some(_), DropTarget::Edge { .. }) | (None, _) => Some(target), + (Some(_), _) => None, + }; } } else if let Some((side, upper, lower)) = self.dock_split_drag { if let Some(last) = self.dock_drag_last { @@ -797,12 +803,22 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven Some((DockSide::Left, _)) => dx, _ => -dx, }; - // Every tab of the slot shares one width. - let cur = match self.dock.location(id) { - Some((side, gi)) => self.dock.group_width(side, gi), - None => self.dock.settings(id).width, - } + delta; - self.dock.set_group_width(id, cur); + // The divider sizes the whole edge column: every + // shown group on the edge takes the new width (and + // keeps it if it moves elsewhere later). + match self.dock.location(id) { + Some((side, _)) => { + let cur = self.dock_column_width(side) + delta; + for gi in self.dock_visible_groups(side) { + let shown = self.dock.groups(side)[gi].active; + self.dock.set_group_width(shown, cur); + } + } + None => { + let cur = self.dock.settings(id).width + delta; + self.dock.set_group_width(id, cur); + } + } } } if self.dock_dragging.is_some() @@ -819,7 +835,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven 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) { + if let (Some((side, gi)), Some(DropTarget::Edge { side: to, index })) = + (self.dock_dragging_group, self.dock_drag_target) + { + changed |= self.dock.move_group(side, gi, to, index); + } else 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, index } => { @@ -839,6 +859,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.save_config(); } self.dock_dragging = None; + self.dock_dragging_group = None; self.dock_resizing = None; self.dock_split_drag = None; self.dock_float_resizing = None; @@ -892,6 +913,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven fn dock_begin_drag(&mut self, id: crate::ui::dock::PanelId) { self.dock.raise_float(id); self.dock_dragging = Some(id); + self.dock_dragging_group = None; + self.dock_edge_menu = None; self.dock_resizing = None; self.dock_split_drag = None; self.dock_float_resizing = None; @@ -1000,24 +1023,18 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .fold(0.0, f32::max) } - /// Left x of the expanded slots on `side` (past the auto-hide rail, if - /// the edge has one) in workspace pixels. - pub(crate) fn dock_column_x0(&self, side: crate::app::config::DockSide) -> f32 { - use crate::app::config::DockSide; - let has_rail = self.dock_visible_groups(side).into_iter().any(|gi| { - self.dock_group_shown(side, gi) - .is_some_and(|id| self.dock.auto_collapse(id)) - }); - let rail = if has_rail { crate::ui::dock::DOCK_RAIL_W } else { 0.0 }; - match side { - DockSide::Left => rail, - DockSide::Right => { - self.dock_workspace_size().0 - - rail - - self.dock_column_width(side) - - crate::ui::dock::DOCK_DIVIDER_W - } - } + /// The icon strip of `side`: each shown group's place, and the top of the + /// + button. + pub(crate) fn dock_strip_layout( + &self, + side: crate::app::config::DockSide, + ) -> (Vec, f32) { + let groups: Vec<(usize, usize)> = self + .dock_visible_groups(side) + .into_iter() + .map(|gi| (gi, self.dock_group_visible_tabs(side, gi).len())) + .collect(); + crate::ui::dock::strip_layout(&groups) } /// Visible slots on `side` with their vertical span in the workspace. @@ -1063,7 +1080,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven 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 zone = (self.dock_column_width(side) + crate::ui::dock::DOCK_STRIP_W) + .max(DOCK_EDGE_ZONE); let inside = match side { DockSide::Left => p.x <= zone, DockSide::Right => p.x >= ww - zone, @@ -1071,6 +1089,37 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven if !inside { continue; } + // Over the icon strip: join a group between its icons, or start + // a new group at a grip / below the last group. + let on_strip = !self.dock_visible_groups(side).is_empty() + && match side { + DockSide::Left => p.x <= crate::ui::dock::DOCK_STRIP_W, + DockSide::Right => p.x >= ww - crate::ui::dock::DOCK_STRIP_W, + }; + if on_strip { + let (layout, _) = self.dock_strip_layout(side); + return match crate::ui::dock::strip_hit(p.y, &layout) { + crate::ui::dock::StripHit::NewGroup(at) => DropTarget::Edge { + side, + index: at.unwrap_or(self.dock.groups(side).len()), + }, + crate::ui::dock::StripHit::Tab { group, index } => { + // Map the gap among visible icons to a position in + // all of the group's tabs. + let tabs = self.dock_group_visible_tabs(side, group); + let all = &self.dock.groups(side)[group].tabs; + let index = tabs + .get(index) + .and_then(|t| all.iter().position(|a| a == t)) + .unwrap_or(all.len()); + DropTarget::Tab { + side, + group, + index: Some(index), + } + } + }; + } let spans = self.dock_slot_spans(side); let Some(&(gi, top, bottom)) = spans .iter() @@ -1082,33 +1131,6 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven index: self.dock.groups(side).len(), }; }; - // Over a tab strip: drop between its tabs (reorders a member). - let tabs = self.dock_group_visible_tabs(side, gi); - if tabs.len() > 1 - && self.dock_expanded_groups(side).contains(&gi) - && p.y < top + crate::ui::dock::DOCK_TAB_H - { - let x0 = self.dock_column_x0(side) - + if side == DockSide::Right { crate::ui::dock::DOCK_DIVIDER_W } else { 0.0 } - + crate::ui::dock::DOCK_TAB_INSET; - let visible_at = crate::ui::dock::tab_insert_index( - p.x, - x0, - crate::ui::dock::DOCK_TAB_CELL_W, - tabs.len(), - ); - // Map the gap among visible tabs to a position in all tabs. - let all = &self.dock.groups(side)[gi].tabs; - let index = tabs - .get(visible_at) - .and_then(|t| all.iter().position(|a| a == t)) - .unwrap_or(all.len()); - return DropTarget::Tab { - side, - group: gi, - index: Some(index), - }; - } return match slot_zone(p.y, top, bottom) { SlotZone::Above => DropTarget::Edge { side, index: gi }, SlotZone::Below => DropTarget::Edge { side, index: gi + 1 }, @@ -2107,17 +2129,19 @@ mod tests { } #[test] - fn dock_dragging_a_tab_along_the_strip_reorders_it() { + fn dock_dragging_an_icon_along_the_strip_reorders_it() { use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, DropTarget, PanelId}; + use crate::ui::dock::{DockMsg, DropTarget, PanelId, STRIP_CELL_H}; let mut app = dock_app(); app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); app.dock.right.clear(); - // Icon tabs: Properties | Blocks from the left. Drag the Properties - // tab past the Blocks tab. + // Icons: Properties above Blocks. Drag the Properties icon below the + // Blocks icon. + let (layout, _) = app.dock_strip_layout(DockSide::Left); + let top = layout[0].icons_top; let _ = app.on_dock(DockMsg::SelectTab(PanelId::Properties)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(10.0, 12.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(78.0, 12.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, top + 10.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, top + 2.0 * STRIP_CELL_H))); assert_eq!( app.dock_drag_target, Some(DropTarget::Tab { @@ -2131,30 +2155,101 @@ mod tests { app.dock.left[0].tabs, vec![PanelId::BlockPalette, PanelId::Properties] ); - assert_eq!(app.dock.left[0].active, PanelId::Properties); } #[test] - fn dock_tab_menu_adds_and_hides_pallets() { + fn dock_dropping_an_icon_below_the_strip_groups_starts_a_new_group() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, DropTarget, PanelId}; + let mut app = dock_app(); + app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); + let (layout, plus_top) = app.dock_strip_layout(DockSide::Left); + let _ = app.on_dock(DockMsg::SelectTab(PanelId::BlockPalette)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, layout[0].icons_top + 40.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0))); + assert_eq!( + app.dock_drag_target, + Some(DropTarget::Edge { + side: DockSide::Left, + index: 1 + }) + ); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!(app.dock.left.len(), 2); + assert_eq!(app.dock.left[1].tabs, vec![PanelId::BlockPalette]); + } + + #[test] + fn dock_grip_drag_moves_a_whole_group() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; + let mut app = dock_app(); + app.dock.left = vec![ + DockGroup { + tabs: vec![PanelId::Properties, PanelId::Browser], + active: PanelId::Properties, + weight: 1.0, + }, + DockGroup::single(PanelId::BlockPalette, 1.0), + ]; + app.dock.right.clear(); + app.show_browser = true; + let (layout, plus_top) = app.dock_strip_layout(DockSide::Left); + let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 0)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, layout[0].grip_top + 2.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0))); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!(app.dock.left[0].tabs, vec![PanelId::BlockPalette]); + assert_eq!( + app.dock.left[1].tabs, + vec![PanelId::Properties, PanelId::Browser] + ); + // A group dropped over the viewport does not float or split up. + let before = app.dock.clone(); + let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 1)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, 300.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0))); + assert_eq!(app.dock_drag_target, None); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!(app.dock, before); + } + + #[test] + fn dock_edge_menu_adds_and_hides_pallets() { use crate::app::config::DockSide; use crate::ui::dock::{DockMsg, PanelId}; let mut app = dock_app(); app.show_browser = false; - let _ = app.on_dock(DockMsg::TabMenu(Some((DockSide::Left, 0)))); - // Checking a closed pallet opens it as a tab of this slot. - let _ = app.on_dock(DockMsg::TabMenuToggle(DockSide::Left, 0, PanelId::Browser)); + let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left))); + // Checking a closed pallet opens it as a new group on this edge. + let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); assert!(app.show_browser); - assert_eq!(app.dock.left[0].tabs, vec![PanelId::Properties, PanelId::Browser]); - assert_eq!(app.dock.left[0].active, PanelId::Browser); - // Checking a pallet docked elsewhere moves it here. - let _ = app.on_dock(DockMsg::TabMenuToggle(DockSide::Left, 0, PanelId::BlockPalette)); - assert!(app.dock.right.is_empty()); - assert_eq!(app.dock.left[0].tabs.len(), 3); - // Unchecking hides it but keeps its tab. - let _ = app.on_dock(DockMsg::TabMenuToggle(DockSide::Left, 0, PanelId::Browser)); + assert_eq!(app.dock.left.len(), 2); + assert_eq!(app.dock.left[1].tabs, vec![PanelId::Browser]); + // Unchecking hides it but keeps its place. + let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); assert!(!app.show_browser); - assert!(app.dock.left[0].tabs.contains(&PanelId::Browser)); - assert_eq!(app.dock_tab_menu, Some((DockSide::Left, 0))); + assert_eq!(app.dock.location(PanelId::Browser), Some((DockSide::Left, 1))); + // Opening it again from the same edge reuses that place. + let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); + assert_eq!(app.dock.left.len(), 2); + } + + #[test] + fn dock_divider_resizes_the_whole_column() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; + let mut app = dock_app(); + app.dock.left.push(DockGroup::single(PanelId::BlockPalette, 1.0)); + app.dock.right.clear(); + app.dock.set_width(PanelId::BlockPalette, 300.0); + let _ = app.on_dock(DockMsg::ResizeGrab(PanelId::Properties)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(330.0, 100.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(350.0, 100.0))); + let _ = app.on_dock(DockMsg::DragRelease); + // The column was 300 (the widest group); both groups now take 320. + assert_eq!(app.dock.settings(PanelId::Properties).width, 320.0); + assert_eq!(app.dock.settings(PanelId::BlockPalette).width, 320.0); } #[test] diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 1f4451134..08b181f37 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1936,39 +1936,29 @@ bg={bg_ms:.1}ms n={view_count}" layers.push(self.floating_panel(*f, tab)); } } - // An open pallet menu hangs under its slot's menu button, over a + // An open pallet menu hangs beside its edge's + button, over a // catcher that closes it on any click elsewhere. - if let Some((side, gi)) = self.dock_tab_menu { - let span = self - .dock_slot_spans(side) - .into_iter() - .find(|(g, _, _)| *g == gi); - if let Some((_, top, _)) = span { - let tabs = self.dock_group_visible_tabs(side, gi); + if let Some(side) = self.dock_edge_menu { + if !self.dock_visible_groups(side).is_empty() { let shown: Vec<_> = crate::ui::dock::PanelId::ALL .iter() - .map(|p| (*p, tabs.contains(p))) + .map(|p| (*p, self.dock_panel_visible(*p))) .collect(); - let right_pad = 2.0; - let button_right = self.dock_column_x0(side) - + if side == crate::app::config::DockSide::Right { - DOCK_DIVIDER_W - } else { - 0.0 + let (_, plus_top) = self.dock_strip_layout(side); + let strip = crate::ui::dock::DOCK_STRIP_W; + let x = match side { + crate::app::config::DockSide::Left => strip + 4.0, + crate::app::config::DockSide::Right => { + self.dock_workspace_size().0 - strip - DOCK_MENU_W - 4.0 } - + self.dock_column_width(side) - - right_pad; + }; layers.push( mouse_area(Space::new().width(Fill).height(Fill)) - .on_press(Message::Dock(crate::ui::dock::DockMsg::TabMenu(None))) - .on_right_press(Message::Dock(crate::ui::dock::DockMsg::TabMenu(None))) + .on_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) + .on_right_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) .into(), ); - layers.push(place_at( - dock_tab_menu(side, gi, &shown), - (button_right - DOCK_MENU_W).max(0.0), - top + crate::ui::dock::DOCK_TAB_H, - )); + layers.push(place_at(dock_edge_menu(side, &shown), x.max(0.0), plus_top)); } } // While a panel is dragged, preview where it lands. @@ -2779,56 +2769,32 @@ impl OpenCADStudio { iced::advanced::widget::operate(iced::advanced::widget::operation::focusable::unfocus()) } - /// Build one edge: a narrow tab strip at the very edge plus the expanded - /// 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. + /// Build one edge: the vertical icon strip at the very edge plus the + /// expanded groups beside it. The strip lists every shown group's pallets + /// as icons under a grip bar, with a + button below them; clicking an + /// icon shows that pallet in its group. Unpinned groups are always + /// expanded and split the column height by their weights, with a + /// draggable splitter between neighbours. An auto-hiding (pinned) group + /// lives only in the strip until one of its icons is hovered. The whole + /// edge is one hover region: leaving it collapses any auto-hiding group. + /// `None` when nothing on the edge is shown. fn build_edge_stack<'a>( &'a self, side: crate::app::config::DockSide, tab: &'a DocumentTab, ) -> Option> { - let visible = self.dock_visible_groups(side); - if visible.is_empty() { + if self.dock_visible_groups(side).is_empty() { return None; } - 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(|gi| self.dock_group_width(side, *gi)) .fold(0.0, f32::max); - let tab_strip = (!pinned.is_empty()).then(|| { - let tabs: Vec> = pinned - .iter() - .map(|gi| { - let tabs = self.dock_group_visible_tabs(side, *gi); - self.rail_slice( - shown(*gi), - &tabs, - side, - self.dock_dragging.is_some_and(|d| tabs.contains(&d)), - ) - }) - .collect(); - column(tabs).width(DOCK_RAIL_W).height(Fill).into() - }); + let strip = self.dock_icon_strip(side); let expanded_stack = (!expanded.is_empty()).then(|| { - // Expanded slots stack one below the other, each taking its + // Expanded groups 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() { @@ -2848,20 +2814,12 @@ impl OpenCADStudio { let mut children: Vec> = Vec::new(); match side { crate::app::config::DockSide::Left => { - if let Some(t) = tab_strip { - children.push(t); - } - if let Some(e) = expanded_stack { - children.push(e); - } + children.push(strip); + children.extend(expanded_stack); } crate::app::config::DockSide::Right => { - if let Some(e) = expanded_stack { - children.push(e); - } - if let Some(t) = tab_strip { - children.push(t); - } + children.extend(expanded_stack); + children.push(strip); } } Some( @@ -2871,52 +2829,98 @@ impl OpenCADStudio { ) } - /// 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_dragging: bool, - ) -> Element<'_, Message> { - let label = canvas(VBarLabel { - text: tabs - .iter() - .map(|t| t.title()) - .collect::>() - .join(" · "), - clockwise: side == crate::app::config::DockSide::Left, - from_start: false, - }) - .width(Fill) - .height(Fill); - // Hovering raises the slot but leaves the rail looking as it is. - let bg = move |theme: &Theme| { - let palette = theme.palette(); - if is_dragging { - palette.primary.weak.color.scale_alpha(0.55) - } else { - palette.background.base.color + /// An edge's vertical icon strip: per shown group a grip bar (drag it to + /// move the group) and its pallets' icons (the group's tabs; the shown one + /// on a grey background), then a + button opening the pallet menu. + /// Heights follow `dock::strip_layout`, which drop targeting uses too. + fn dock_icon_strip(&self, side: crate::app::config::DockSide) -> Element<'_, Message> { + use crate::ui::dock::{DockMsg, DOCK_STRIP_W, STRIP_CELL_H, STRIP_GAP, STRIP_GRIP_H}; + let mut items: Vec> = Vec::new(); + for gi in self.dock_visible_groups(side) { + items.push(Space::new().height(STRIP_GAP).into()); + let grip = container( + container(Space::new()) + .width(Length::Fixed(18.0)) + .height(Length::Fixed(3.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.strong.color, + )), + border: Border { + radius: 1.5.into(), + ..Default::default() + }, + ..Default::default() + }), + ) + .center_x(Length::Fixed(DOCK_STRIP_W)) + .center_y(Length::Fixed(STRIP_GRIP_H)); + items.push( + mouse_area(grip) + .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) + .interaction(iced::mouse::Interaction::Grab) + .into(), + ); + let shown = self.dock_group_shown(side, gi); + for id in self.dock_group_visible_tabs(side, gi) { + let is_active = Some(id) == shown; + let icon = container(crate::ui::icons::themed(id.icon(), 20.0)) + .center_x(Length::Fixed(STRIP_CELL_H - 4.0)) + .center_y(Length::Fixed(STRIP_CELL_H - 4.0)) + .style(move |theme: &Theme| container::Style { + // Only the shown pallet of the group gets a background. + background: is_active + .then(|| Background::Color(theme.palette().background.weak.color)), + border: Border { + radius: 4.0.into(), + ..Default::default() + }, + ..Default::default() + }); + let cell = mouse_area( + container(icon) + .center_x(Length::Fixed(DOCK_STRIP_W)) + .center_y(Length::Fixed(STRIP_CELL_H)), + ) + .on_press(Message::Dock(DockMsg::SelectTab(id))) + .on_enter(Message::Dock(DockMsg::Hover(id))) + .interaction(iced::mouse::Interaction::Pointer); + items.push( + iced::widget::tooltip( + cell, + text(id.title()).size(10), + match side { + crate::app::config::DockSide::Left => { + iced::widget::tooltip::Position::Right + } + crate::app::config::DockSide::Right => { + iced::widget::tooltip::Position::Left + } + }, + ) + .gap(4) + .into(), + ); } - }; - let tab = container(label) - .width(Length::Fixed(DOCK_RAIL_W)) + } + items.push(Space::new().height(STRIP_GAP).into()); + let plus = button(crate::ui::icons::themed_secondary(crate::ui::icons::PLUS, 14.0)) + .on_press(Message::Dock(DockMsg::EdgeMenu(Some(side)))) + .style(button::subtle) + .padding([5, 8]); + items.push(container(plus).center_x(Length::Fixed(DOCK_STRIP_W)).into()); + container(column(items)) + .width(Length::Fixed(DOCK_STRIP_W)) .height(Fill) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(bg(theme))), + .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() - }); - mouse_area(tab) - .interaction(iced::mouse::Interaction::Pointer) - .on_press(Message::Dock(crate::ui::dock::DockMsg::DockGrab(id))) - .on_enter(Message::Dock(crate::ui::dock::DockMsg::Hover(id))) + }) .into() } @@ -2977,8 +2981,8 @@ impl OpenCADStudio { } } - /// An expanded slot: its tab strip (when it holds more than one shown - /// panel), the shown panel, and a grabbable divider against the viewport. + /// An expanded slot: its shown panel (the group's tabs live in the icon + /// strip) 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. @@ -2989,16 +2993,8 @@ impl OpenCADStudio { 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, false, tab); - let panel: Element<'_, Message> = if tabs.len() > 1 { - column![dock_tab_strip(&tabs, id, width, side, gi), body] - .height(Fill) - .into() - } else { - body - }; + let panel = self.panel_body(id, width, false, tab); let divider = dock_divider(id); match side { crate::app::config::DockSide::Left => row![panel, divider].height(Fill).into(), @@ -3113,8 +3109,11 @@ impl OpenCADStudio { 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 strip = crate::ui::dock::DOCK_STRIP_W; + // The tint covers the edge's icon strip and column; the ghost sits in + // the column beside the strip. let tint = |side: DockSide| -> Element<'_, Message> { - let w = edge_w(side); + let w = edge_w(side) + strip; let band = container(Space::new()) .width(Length::Fixed(w)) .height(Fill) @@ -3134,8 +3133,8 @@ impl OpenCADStudio { .into() }; let edge_x = |side: DockSide, w: f32| match side { - DockSide::Left => 0.0, - DockSide::Right => (ww - w).max(0.0), + DockSide::Left => strip, + DockSide::Right => (ww - w - strip).max(0.0), }; let mut layers: Vec> = Vec::new(); match target { @@ -3689,84 +3688,16 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { .into() } -use crate::ui::dock::{DOCK_DIVIDER_W, DOCK_RAIL_W}; +use crate::ui::dock::DOCK_DIVIDER_W; -/// The tab row above a docked slot holding several pallets: one icon tab per -/// shown pallet, from the left, and a menu button on the right that lists -/// every pallet (drawn by `dock_tab_menu`). Pressing a tab shows its pallet; -/// dragging it reorders the tabs or pulls the pallet out. -fn dock_tab_strip( - tabs: &[crate::ui::dock::PanelId], - active: crate::ui::dock::PanelId, - width: f32, - side: crate::app::config::DockSide, - gi: usize, -) -> Element<'static, Message> { - use crate::ui::dock::{DockMsg, DOCK_TAB_CELL_W, DOCK_TAB_INSET}; - const TAB_GAP: f32 = 2.0; - let mut cells: Vec> = tabs - .iter() - .map(|id| { - let id = *id; - let is_active = id == active; - let cell = container(crate::ui::icons::themed(id.icon(), 18.0)) - .center_x(Length::Fixed(DOCK_TAB_CELL_W - TAB_GAP)) - .center_y(Fill) - .style(move |theme: &Theme| container::Style { - // Only the shown tab gets a background. - background: is_active - .then(|| Background::Color(theme.palette().background.weak.color)), - border: Border { - radius: iced::border::Radius::default().top(4.0), - ..Default::default() - }, - ..Default::default() - }); - let cell = mouse_area(cell) - .on_press(Message::Dock(DockMsg::SelectTab(id))) - .interaction(iced::mouse::Interaction::Pointer); - iced::widget::tooltip( - cell, - text(id.title()).size(10), - iced::widget::tooltip::Position::Bottom, - ) - .gap(4) - .into() - }) - .collect(); - cells.push(Space::new().width(Fill).into()); - cells.push( - button(crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 14.0)) - .on_press(Message::Dock(DockMsg::TabMenu(Some((side, gi))))) - .style(button::subtle) - .padding([3, 5]) - .into(), - ); - container(row(cells).spacing(TAB_GAP).height(Fill).align_y(iced::Center)) - .width(Length::Fixed(width)) - .height(Length::Fixed(crate::ui::dock::DOCK_TAB_H)) - .padding(iced::Padding { - top: 3.0, - right: 2.0, - bottom: 0.0, - left: DOCK_TAB_INSET, - }) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.strong.color)), - ..Default::default() - }) - .into() -} - -/// Width of the tab row's pallet menu. +/// Width of the icon strip's pallet menu. const DOCK_MENU_W: f32 = 230.0; -/// The pallet menu of slot (`side`, `gi`), styled like the right-click menu: -/// one row per pallet with its icon, its name and a check mark when it shows -/// in this slot. `shown` holds each pallet with that state. -fn dock_tab_menu( +/// The pallet menu of an edge's + button, styled like the right-click menu: +/// one row per pallet with its icon, its name and a check mark when it is +/// shown. `shown` holds each pallet with that state. +fn dock_edge_menu( side: crate::app::config::DockSide, - gi: usize, shown: &[(crate::ui::dock::PanelId, bool)], ) -> Element<'static, Message> { use crate::ui::dock::DockMsg; @@ -3788,7 +3719,7 @@ fn dock_tab_menu( .spacing(8) .align_y(iced::Center), ) - .on_press(Message::Dock(DockMsg::TabMenuToggle(side, gi, id))) + .on_press(Message::Dock(DockMsg::EdgeMenuToggle(side, id))) .style(|theme: &Theme, status| { let palette = theme.palette(); button::Style { diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 3ebe13176..63e88b967 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -43,12 +43,14 @@ pub enum DockMsg { FloatResizeGrab(PanelId, bool), /// The pointer entered (`Some`) or left (`None`) a docked title bar. TitleHover(Option), - /// Open (`Some`) the pallet menu of a slot (side, slot index), or close - /// it (`None`). - TabMenu(Option<(DockSide, usize)>), - /// Pallet menu pick: show `panel` as a tab of the slot, or hide it when - /// it already shows there. - TabMenuToggle(DockSide, usize, PanelId), + /// Open (`Some`) the pallet menu of an edge's icon strip (its + button), + /// or close it (`None`). + EdgeMenu(Option), + /// Pallet menu pick: hide `panel` when it shows, else open it docked on + /// the edge (as a new group unless it already has a group there). + EdgeMenuToggle(DockSide, PanelId), + /// Begin dragging a whole group (slot) by its grip in the icon strip. + GroupGrab(DockSide, usize), /// Double-click on a docked title bar: float the panel. FloatOut(PanelId), /// Double-click on a floating title bar: dock the panel on that side. @@ -503,6 +505,28 @@ impl DockState { } } + /// Move slot `group` on `side` to insertion `index` on `to` (counted + /// before the move). Returns whether the layout changed. + pub fn move_group(&mut self, side: DockSide, group: usize, to: DockSide, index: usize) -> bool { + if group >= self.groups(side).len() { + return false; + } + let mut index = index; + if side == to { + if index == group || index == group + 1 { + return false; + } + if group < index { + index -= 1; + } + } + let g = self.groups_mut(side).remove(group); + let dest = self.groups_mut(to); + let index = index.min(dest.len()); + dest.insert(index, g); + true + } + /// Float `id` at `rect`, on top of the other floating panels. pub fn float(&mut self, rect: FloatPanel) -> bool { let before = self.clone(); @@ -641,25 +665,79 @@ pub const DOCK_MIN_W: f32 = 200.0; 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; -/// Width of a collapsed (auto-collapsing) slot's tab in the edge strip. -pub const DOCK_RAIL_W: f32 = 28.0; /// Width of the grabbable divider between a docked slot and the viewport. pub const DOCK_DIVIDER_W: f32 = 5.0; -/// Height of a docked slot's tab strip. -pub const DOCK_TAB_H: f32 = 28.0; +/// Width of an edge's vertical icon strip (the groups' tabs). +pub const DOCK_STRIP_W: f32 = 36.0; +/// Height of one pallet icon in the strip. +pub const STRIP_CELL_H: f32 = 32.0; +/// Height of the grip bar above each group's icons. +pub const STRIP_GRIP_H: f32 = 10.0; +/// Space above the first group and between groups in the strip. +pub const STRIP_GAP: f32 = 6.0; -/// Pitch of one icon tab in a tab strip (tab plus the gap after it). -pub const DOCK_TAB_CELL_W: f32 = 34.0; -/// Left inset of the first tab in a tab strip. -pub const DOCK_TAB_INSET: f32 = 4.0; +/// Where one group sits in an edge's icon strip, top to bottom. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StripGroup { + /// Slot index on the edge. + pub group: usize, + /// Top of the group's grip bar. + pub grip_top: f32, + /// Top of the group's first icon. + pub icons_top: f32, + /// Number of icons (visible tabs). + pub icons: usize, +} -/// Insertion position (0..=`count`) for a tab dropped at `x` on a strip of -/// `count` left-aligned tabs of pitch `cell` starting at `x0`. -pub fn tab_insert_index(x: f32, x0: f32, cell: f32, count: usize) -> usize { - if count == 0 || cell <= 0.0 { - return 0; +/// Lay out the icon strip for `groups` = (slot index, visible tab count), +/// returning each group's place and the top of the + button below them. +pub fn strip_layout(groups: &[(usize, usize)]) -> (Vec, f32) { + let mut y = STRIP_GAP; + let placed = groups + .iter() + .map(|&(group, icons)| { + let grip_top = y; + let icons_top = grip_top + STRIP_GRIP_H; + y = icons_top + icons as f32 * STRIP_CELL_H + STRIP_GAP; + StripGroup { + group, + grip_top, + icons_top, + icons, + } + }) + .collect(); + (placed, y) +} + +/// What a dragged pallet released at height `y` over the icon strip joins. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StripHit { + /// A new group before slot `group`; `None` = after the last group. + NewGroup(Option), + /// Group `group`, at position `index` among its visible icons. + Tab { group: usize, index: usize }, +} + +/// Classify a pointer at strip height `y`: over a group's grip (or the gap +/// above it) starts a new group there, over its icons joins it between the +/// nearest icons, below every group starts a new last group. +pub fn strip_hit(y: f32, layout: &[StripGroup]) -> StripHit { + for g in layout { + if y < g.icons_top { + return StripHit::NewGroup(Some(g.group)); + } + let bottom = g.icons_top + g.icons as f32 * STRIP_CELL_H + STRIP_GAP * 0.5; + if y < bottom { + let index = (((y - g.icons_top) / STRIP_CELL_H).round().max(0.0) as usize) + .min(g.icons); + return StripHit::Tab { + group: g.group, + index, + }; + } } - (((x - x0) / cell).round().max(0.0) as usize).min(count) + StripHit::NewGroup(None) } // ── Shared panel chrome ───────────────────────────────────────────────── @@ -978,12 +1056,40 @@ mod tests { } #[test] - fn tab_insert_index_rounds_to_the_nearest_gap() { - assert_eq!(tab_insert_index(0.0, 0.0, 30.0, 3), 0); - assert_eq!(tab_insert_index(40.0, 0.0, 30.0, 3), 1); - assert_eq!(tab_insert_index(85.0, 0.0, 30.0, 3), 3); - assert_eq!(tab_insert_index(250.0, 0.0, 30.0, 3), 3); - assert_eq!(tab_insert_index(-20.0, 0.0, 30.0, 3), 0); + fn strip_hit_maps_grips_icons_and_the_end() { + // Two groups: three icons, then one. + let (layout, plus_top) = strip_layout(&[(0, 3), (2, 1)]); + let g0 = layout[0]; + let g1 = layout[1]; + assert_eq!(g0.grip_top, STRIP_GAP); + assert_eq!(g1.icons_top, g1.grip_top + STRIP_GRIP_H); + assert_eq!(plus_top, g1.icons_top + STRIP_CELL_H + STRIP_GAP); + // On the first grip: a new group before slot 0. + assert_eq!(strip_hit(g0.grip_top + 2.0, &layout), StripHit::NewGroup(Some(0))); + // Between the first and second icon of group 0. + assert_eq!( + strip_hit(g0.icons_top + STRIP_CELL_H + 4.0, &layout), + StripHit::Tab { group: 0, index: 1 } + ); + // On the second group's grip: a new group before slot 2. + assert_eq!(strip_hit(g1.grip_top + 1.0, &layout), StripHit::NewGroup(Some(2))); + // Below everything: a new last group. + assert_eq!(strip_hit(plus_top + 20.0, &layout), StripHit::NewGroup(None)); + } + + #[test] + fn move_group_reorders_and_crosses_edges() { + let mut state = DockState::default(); + state.left.push(DockGroup::single(PanelId::Browser, 1.0)); + state.left.push(DockGroup::single(PanelId::Count, 1.0)); + // Same spot (before itself or after itself) changes nothing. + assert!(!state.move_group(DockSide::Left, 1, DockSide::Left, 1)); + assert!(!state.move_group(DockSide::Left, 1, DockSide::Left, 2)); + assert!(state.move_group(DockSide::Left, 0, DockSide::Left, 3)); + assert_eq!(state.left[2].tabs, vec![PanelId::Properties]); + assert!(state.move_group(DockSide::Left, 2, DockSide::Right, 0)); + assert_eq!(state.right[0].tabs, vec![PanelId::Properties]); + assert_eq!(state.left.len(), 2); } #[test] From 47c2e2f2fe515144d139fcf5ea31206316735067 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 17:30:00 +0000 Subject: [PATCH 10/24] Dock: one group per edge at a time, pallets stacked within a group A group is now a set of pallets shown together, stacked top to bottom, and an edge shows one group at a time. The icon strip lists every group (grip bar over its pallets' icons, the shown group on a grey background); clicking any icon of a group shows that whole group. - Dropping a pallet on the top / bottom half of a pallet in the shown group stacks it above / below; dropping on the strip's icons places it in that group at that spot, and on a grip or below the last group starts a new group (which is then shown). - Splitters set the heights of the pallets within a group; double-click makes them equal again. - Each group keeps its own width; the divider sizes only the group it belongs to, and switching groups switches width. - A group whose pallets all auto-hide shows only while hovered. - Layouts saved by older builds load with their stacked pallets as one group. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/mod.rs | 5 +- src/app/update/dialog.rs | 723 ++++++++++++++++++-------------------- src/app/view/mod.rs | 269 ++++++-------- src/ui/dock.rs | 739 +++++++++++++++++++-------------------- 4 files changed, 821 insertions(+), 915 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 464074156..a8279c317 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -863,8 +863,9 @@ pub(super) struct OpenCADStudio { /// 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)>, + /// Splitter being dragged between two pallets of a group (side, group, + /// upper, lower). + pub(crate) dock_split_drag: Option<(crate::app::config::DockSide, usize, usize, usize)>, /// Floating panel being resized from its corner grip, and whether from /// the bottom-left corner. pub(crate) dock_float_resizing: Option<(crate::ui::dock::PanelId, bool)>, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 83463273d..0dd6c1fb9 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -551,10 +551,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven 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) { + DockMsg::IconPress(id) => { + // An icon press shows the icon's group and may also start + // dragging the pallet; a plain click never passes the drag + // threshold. + if self.dock.show_group_of(id) { self.save_config(); } self.dock_begin_drag(id); @@ -568,15 +569,15 @@ 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)); + DockMsg::SplitGrab(side, gi, upper, lower) => { + self.dock_split_drag = Some((side, gi, 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); + DockMsg::SplitReset(side, gi) => { + self.dock.reset_splits(side, gi); self.save_config(); iced::Task::none() } @@ -598,9 +599,12 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven let (w, h) = self.dock_float_size(id); let x = match self.dock.location(id) { Some((DockSide::Right, _)) => { - ww - self.dock_column_width(DockSide::Right) - w - 60.0 + ww - crate::ui::dock::DOCK_STRIP_W + - self.dock_column_width(DockSide::Right) + - w + - 60.0 } - _ => self.dock_column_width(DockSide::Left) + 40.0, + _ => crate::ui::dock::DOCK_STRIP_W + self.dock_column_width(DockSide::Left) + 40.0, }; self.dock_dragging = None; self.dock_drag_target = None; @@ -640,12 +644,12 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.dock.dock(id, side, usize::MAX); } let task = self.dock_open_panel(id); - self.dock.select_tab(id); + self.dock.show_group_of(id); self.save_config(); task } DockMsg::GroupGrab(side, gi) => { - if let Some(id) = self.dock_group_shown(side, gi) { + if let Some(&id) = self.dock_group_visible(side, gi).first() { self.dock_begin_drag(id); self.dock_dragging_group = Some((side, gi)); } @@ -720,6 +724,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven // panel being dragged or repoint the resize target. if self.dock_dragging.is_none() && self.dock_resizing.is_none() { self.dock_expanded = Some(id); + // Hovering an auto-hiding group's icon flies it out. + if self.dock.auto_collapse(id) { + self.dock.show_group_of(id); + } } iced::Task::none() } @@ -754,26 +762,27 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven }; } if self.dock_drag_target.is_some() || moved >= DOCK_DRAG_THRESHOLD { - let target = self.dock_drop_target(id, point); - // A whole group only moves between slots: it cannot - // join another group or float. + let target = self.dock_drop_target(point); + // A whole group only moves between group positions: + // it cannot join another group or float. self.dock_drag_target = match (self.dock_dragging_group, target) { (Some(_), DropTarget::Edge { .. }) | (None, _) => Some(target), (Some(_), _) => None, }; } - } else if let Some((side, upper, lower)) = self.dock_split_drag { + } else if let Some((side, gi, 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) + .dock_slot_spans(side) .iter() - .map(|gi| self.dock.groups(side)[*gi].weight) + .map(|(i, _, _)| self.dock.groups(side)[gi].weights[*i]) .sum(); if avail > 0.0 && total > 0.0 { let per_px = total / avail; self.dock.shift_split( side, + gi, upper, lower, (point.y - last.y) * per_px, @@ -803,22 +812,13 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven Some((DockSide::Left, _)) => dx, _ => -dx, }; - // The divider sizes the whole edge column: every - // shown group on the edge takes the new width (and - // keeps it if it moves elsewhere later). - match self.dock.location(id) { - Some((side, _)) => { - let cur = self.dock_column_width(side) + delta; - for gi in self.dock_visible_groups(side) { - let shown = self.dock.groups(side)[gi].active; - self.dock.set_group_width(shown, cur); - } - } - None => { - let cur = self.dock.settings(id).width + delta; - self.dock.set_group_width(id, cur); - } - } + // The divider sizes the panel's group; every group + // keeps its own width. + let cur = match self.dock.location(id) { + Some((side, gi)) => self.dock_group_width(side, gi), + None => self.dock.settings(id).width, + } + delta; + self.dock.set_group_width(id, cur); } } if self.dock_dragging.is_some() @@ -842,8 +842,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } else 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, index } => { - self.dock.add_tab(id, side, group, index) + DropTarget::Join { side, group, index } => { + self.dock.join_group(id, side, group, index) } DropTarget::Float { x, y } => { let (w, h) = self.dock_float_size(id); @@ -954,77 +954,74 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven ) } - /// 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( + /// The open pallets of group `gi` on `side`, top → bottom. Closed + /// pallets keep their place but take no space, so a group with none open + /// is not drawn at all. + pub(crate) fn dock_group_visible( &self, side: crate::app::config::DockSide, gi: usize, ) -> Vec { self.dock.groups(side)[gi] - .tabs + .panels .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). + /// Indices of the groups on `side` with an open pallet (listed in the + /// icon strip). 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()) + .filter(|gi| !self.dock_group_visible(side, *gi).is_empty()) .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() + /// The group `side` draws: the one it shows, or the first with an open + /// pallet when that one has none. + pub(crate) fn dock_shown_group(&self, side: crate::app::config::DockSide) -> Option { + let visible = self.dock_visible_groups(side); + let shown = self.dock.shown(side); + if visible.contains(&shown) { + Some(shown) + } else { + visible.first().copied() + } } - /// Width of slot `gi` on `side`: all its tabs share the widest one's - /// width, so switching tabs never resizes the column. + /// Whether `side` draws its shown group beside the icon strip: always, + /// unless all its pallets auto-hide and none is hovered. + pub(crate) fn dock_edge_expanded(&self, side: crate::app::config::DockSide) -> bool { + let Some(gi) = self.dock_shown_group(side) else { + return false; + }; + let panels = self.dock_group_visible(side, gi); + panels.iter().any(|id| !self.dock.auto_collapse(*id)) + || self.dock_expanded.is_some_and(|e| panels.contains(&e)) + } + + /// Width of group `gi` on `side`: its pallets share the widest one's + /// width. pub(crate) fn dock_group_width(&self, side: crate::app::config::DockSide, gi: usize) -> f32 { self.dock.groups(side)[gi] - .tabs + .panels .iter() .map(|id| self.dock.width(*id, self.win_size.0)) .fold(0.0, f32::max) } - /// Width of the edge column on `side`: its widest shown slot. + /// Width of the pallet column beside `side`'s icon strip: the shown + /// group's width, or nothing when it is hidden. pub(crate) fn dock_column_width(&self, side: crate::app::config::DockSide) -> f32 { - self.dock_visible_groups(side) - .into_iter() - .map(|gi| self.dock_group_width(side, gi)) - .fold(0.0, f32::max) + match self.dock_shown_group(side) { + Some(gi) if self.dock_edge_expanded(side) => self.dock_group_width(side, gi), + _ => 0.0, + } } - /// The icon strip of `side`: each shown group's place, and the top of the - /// + button. + /// The icon strip of `side`: each listed group's place, and the top of + /// the + button. pub(crate) fn dock_strip_layout( &self, side: crate::app::config::DockSide, @@ -1032,26 +1029,29 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven let groups: Vec<(usize, usize)> = self .dock_visible_groups(side) .into_iter() - .map(|gi| (gi, self.dock_group_visible_tabs(side, gi).len())) + .map(|gi| (gi, self.dock_group_visible(side, gi).len())) .collect(); crate::ui::dock::strip_layout(&groups) } - /// Visible slots on `side` with their vertical span in the workspace. + /// The open pallets of `side`'s shown group, each as (position in the + /// group, top, bottom) in the workspace. pub(crate) fn dock_slot_spans( &self, side: crate::app::config::DockSide, ) -> Vec<(usize, f32, f32)> { + let Some(gi) = self.dock_shown_group(side) else { + return Vec::new(); + }; 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) + let group = &self.dock.groups(side)[gi]; + let open: Vec = (0..group.panels.len()) + .filter(|i| self.dock_panel_visible(group.panels[*i])) .collect(); - visible - .into_iter() + let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); + open.into_iter() .zip(crate::ui::dock::slot_spans(&weights, avail)) - .map(|(gi, (top, bottom))| (gi, top, bottom)) + .map(|(i, (top, bottom))| (i, top, bottom)) .collect() } @@ -1068,82 +1068,68 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven ) } - /// 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 { + /// Where a dragged pallet lands if released at workspace point `p`: + /// on an edge's icon strip it joins a group between its icons or starts a + /// new group; over the shown group's pallets it stacks above / below the + /// one under the pointer; on an empty edge it starts a group; anywhere + /// else it floats. + pub(crate) fn dock_drop_target(&self, p: iced::Point) -> crate::ui::dock::DropTarget { use crate::app::config::DockSide; - use crate::ui::dock::{slot_zone, DropTarget, SlotZone, DOCK_EDGE_ZONE}; + use crate::ui::dock::{DropTarget, StripHit, DOCK_EDGE_ZONE, DOCK_STRIP_W}; let (ww, wh) = self.dock_workspace_size(); for side in [DockSide::Left, DockSide::Right] { - let zone = (self.dock_column_width(side) + crate::ui::dock::DOCK_STRIP_W) - .max(DOCK_EDGE_ZONE); - let inside = match side { - DockSide::Left => p.x <= zone, - DockSide::Right => p.x >= ww - zone, + let listed = !self.dock_visible_groups(side).is_empty(); + let from_edge = match side { + DockSide::Left => p.x, + DockSide::Right => ww - p.x, }; - if !inside { + if !listed { + if from_edge <= DOCK_EDGE_ZONE { + return DropTarget::Edge { + side, + index: self.dock.groups(side).len(), + }; + } continue; } - // Over the icon strip: join a group between its icons, or start - // a new group at a grip / below the last group. - let on_strip = !self.dock_visible_groups(side).is_empty() - && match side { - DockSide::Left => p.x <= crate::ui::dock::DOCK_STRIP_W, - DockSide::Right => p.x >= ww - crate::ui::dock::DOCK_STRIP_W, - }; - if on_strip { + // Over the icon strip. + if from_edge <= DOCK_STRIP_W { let (layout, _) = self.dock_strip_layout(side); return match crate::ui::dock::strip_hit(p.y, &layout) { - crate::ui::dock::StripHit::NewGroup(at) => DropTarget::Edge { + StripHit::NewGroup(at) => DropTarget::Edge { side, index: at.unwrap_or(self.dock.groups(side).len()), }, - crate::ui::dock::StripHit::Tab { group, index } => { - // Map the gap among visible icons to a position in - // all of the group's tabs. - let tabs = self.dock_group_visible_tabs(side, group); - let all = &self.dock.groups(side)[group].tabs; - let index = tabs + StripHit::Tab { group, index } => { + // Map the gap among open pallets to a position among + // all of the group's pallets. + let open = self.dock_group_visible(side, group); + let all = &self.dock.groups(side)[group].panels; + let index = open .get(index) .and_then(|t| all.iter().position(|a| a == t)) .unwrap_or(all.len()); - DropTarget::Tab { - side, - group, - index: Some(index), - } + DropTarget::Join { side, group, index } } }; } - 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 } + // Over the shown group's pallets: above or below the one there. + if from_edge <= DOCK_STRIP_W + self.dock_column_width(side) { + let gi = self.dock_shown_group(side).expect("listed edge"); + let spans = self.dock_slot_spans(side); + if let Some(&(pos, top, bottom)) = spans + .iter() + .find(|(_, _, bottom)| p.y < *bottom) + .or(spans.last()) + { + let index = if p.y < (top + bottom) * 0.5 { pos } else { pos + 1 }; + return DropTarget::Join { + side, + group: gi, + index, + }; } - SlotZone::Middle => DropTarget::Tab { - side, - group: gi, - index: None, - }, - }; + } } 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)); @@ -2065,24 +2051,55 @@ mod tests { } #[test] - fn blockpalette_dock_moves_to_other_side_and_persists() { + fn blockpalette_dropped_on_the_top_half_of_a_pallet_stacks_above_it() { use crate::app::config::DockSide; - use crate::ui::dock::DropTarget; + use crate::ui::dock::{DropTarget, PanelId}; let mut app = dock_app(); - let id = crate::ui::dock::PanelId::BlockPalette; + let id = PanelId::BlockPalette; 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), + iced::Point::new(150.0, 300.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.location(crate::ui::dock::PanelId::Properties), - Some((DockSide::Left, 1)) + target, + Some(DropTarget::Join { + side: DockSide::Left, + group: 0, + index: 0 + }) + ); + assert_eq!( + app.dock.left[0].panels, + vec![PanelId::BlockPalette, PanelId::Properties] + ); + assert!(app.dock.right.is_empty()); + } + + #[test] + fn dock_drop_on_the_bottom_half_stacks_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(150.0, 700.0), + ); + assert_eq!( + target, + Some(DropTarget::Join { + side: DockSide::Left, + group: 0, + index: 1 + }) + ); + assert_eq!( + app.dock.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette] ); } @@ -2098,216 +2115,54 @@ mod tests { } #[test] - fn dock_drop_in_slot_middle_adds_a_tab() { + fn dock_drop_below_the_strip_groups_starts_a_new_shown_group() { use crate::app::config::DockSide; use crate::ui::dock::{DropTarget, PanelId}; let mut app = dock_app(); + let (_, plus_top) = app.dock_strip_layout(DockSide::Left); let target = drag_panel( &mut app, PanelId::BlockPalette, iced::Point::new(1500.0, 10.0), - iced::Point::new(100.0, 450.0), + iced::Point::new(18.0, plus_top + 10.0), ); assert_eq!( target, - Some(DropTarget::Tab { - side: DockSide::Left, - group: 0, - index: None - }) - ); - 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.left[0].active, PanelId::Properties); - assert_eq!(app.dock.left[0].tabs.len(), 2); - } - - #[test] - fn dock_dragging_an_icon_along_the_strip_reorders_it() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, DropTarget, PanelId, STRIP_CELL_H}; - let mut app = dock_app(); - app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); - app.dock.right.clear(); - // Icons: Properties above Blocks. Drag the Properties icon below the - // Blocks icon. - let (layout, _) = app.dock_strip_layout(DockSide::Left); - let top = layout[0].icons_top; - let _ = app.on_dock(DockMsg::SelectTab(PanelId::Properties)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, top + 10.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, top + 2.0 * STRIP_CELL_H))); - assert_eq!( - app.dock_drag_target, - Some(DropTarget::Tab { - side: DockSide::Left, - group: 0, - index: Some(2) - }) - ); - let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!( - app.dock.left[0].tabs, - vec![PanelId::BlockPalette, PanelId::Properties] - ); - } - - #[test] - fn dock_dropping_an_icon_below_the_strip_groups_starts_a_new_group() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, DropTarget, PanelId}; - let mut app = dock_app(); - app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); - let (layout, plus_top) = app.dock_strip_layout(DockSide::Left); - let _ = app.on_dock(DockMsg::SelectTab(PanelId::BlockPalette)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, layout[0].icons_top + 40.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0))); - assert_eq!( - app.dock_drag_target, Some(DropTarget::Edge { side: DockSide::Left, index: 1 }) ); - let _ = app.on_dock(DockMsg::DragRelease); assert_eq!(app.dock.left.len(), 2); - assert_eq!(app.dock.left[1].tabs, vec![PanelId::BlockPalette]); + assert_eq!(app.dock.left[1].panels, vec![PanelId::BlockPalette]); + // The new group is the one shown: the column holds only the palette. + assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); + assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]); } #[test] - fn dock_grip_drag_moves_a_whole_group() { + fn dock_icon_press_switches_the_shown_group() { use crate::app::config::DockSide; use crate::ui::dock::{DockGroup, DockMsg, PanelId}; let mut app = dock_app(); + app.show_browser = true; app.dock.left = vec![ - DockGroup { - tabs: vec![PanelId::Properties, PanelId::Browser], - active: PanelId::Properties, - weight: 1.0, - }, - DockGroup::single(PanelId::BlockPalette, 1.0), + DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]), + DockGroup::single(PanelId::Browser), ]; app.dock.right.clear(); - app.show_browser = true; - let (layout, plus_top) = app.dock_strip_layout(DockSide::Left); - let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 0)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, layout[0].grip_top + 2.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0))); + // Group 0 shows both stacked pallets. + assert_eq!(app.dock_slot_spans(DockSide::Left).len(), 2); + let _ = app.on_dock(DockMsg::IconPress(PanelId::Browser)); let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!(app.dock.left[0].tabs, vec![PanelId::BlockPalette]); - assert_eq!( - app.dock.left[1].tabs, - vec![PanelId::Properties, PanelId::Browser] - ); - // A group dropped over the viewport does not float or split up. - let before = app.dock.clone(); - let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 1)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, 300.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0))); - assert_eq!(app.dock_drag_target, None); + assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); + assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]); + // Each group keeps its own width. + app.dock.set_width(PanelId::Browser, 400.0); + assert_eq!(app.dock_column_width(DockSide::Left), 400.0); + let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette)); let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!(app.dock, before); - } - - #[test] - fn dock_edge_menu_adds_and_hides_pallets() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, PanelId}; - let mut app = dock_app(); - app.show_browser = false; - let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left))); - // Checking a closed pallet opens it as a new group on this edge. - let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); - assert!(app.show_browser); - assert_eq!(app.dock.left.len(), 2); - assert_eq!(app.dock.left[1].tabs, vec![PanelId::Browser]); - // Unchecking hides it but keeps its place. - let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); - assert!(!app.show_browser); - assert_eq!(app.dock.location(PanelId::Browser), Some((DockSide::Left, 1))); - // Opening it again from the same edge reuses that place. - let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); - assert_eq!(app.dock.left.len(), 2); - } - - #[test] - fn dock_divider_resizes_the_whole_column() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockGroup, DockMsg, PanelId}; - let mut app = dock_app(); - app.dock.left.push(DockGroup::single(PanelId::BlockPalette, 1.0)); - app.dock.right.clear(); - app.dock.set_width(PanelId::BlockPalette, 300.0); - let _ = app.on_dock(DockMsg::ResizeGrab(PanelId::Properties)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(330.0, 100.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(350.0, 100.0))); - let _ = app.on_dock(DockMsg::DragRelease); - // The column was 300 (the widest group); both groups now take 320. - assert_eq!(app.dock.settings(PanelId::Properties).width, 320.0); - assert_eq!(app.dock.settings(PanelId::BlockPalette).width, 320.0); - } - - #[test] - fn layers_command_opens_a_floating_pallet_and_toggles_it_closed() { - use crate::ui::dock::PanelId; - let mut app = dock_app(); - let _ = app.update(Message::ToggleLayers); - assert!(app.show_layers); - assert!(app.active_modal.is_none(), "no modal backdrop"); - let f = app.dock.float_rect(PanelId::Layers).expect("floats on first use"); - assert!(f.x > 0.0 && f.y >= 0.0); - let _ = app.update(Message::ToggleLayers); - assert!(!app.show_layers); - // Its place is kept for next time. - assert!(app.dock.float_rect(PanelId::Layers).is_some()); - } - - #[test] - fn dock_double_clicks_float_and_dock_panels() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, PanelId}; - let mut app = dock_app(); - let _ = app.on_dock(DockMsg::FloatOut(PanelId::Properties)); - assert!(app.dock.left.is_empty()); - let f = app.dock.float_rect(PanelId::Properties).expect("floating"); - assert!(f.x > 0.0, "floats clear of the left edge"); - let _ = app.on_dock(DockMsg::DockTo(PanelId::Properties, DockSide::Right)); - assert!(app.dock.floating.is_empty()); - assert_eq!(app.dock.location(PanelId::Properties), Some((DockSide::Right, 1))); - } - - #[test] - fn dock_tab_group_resize_applies_to_every_tab() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, PanelId}; - let mut app = dock_app(); - app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); - let _ = app.on_dock(DockMsg::ResizeGrab(PanelId::BlockPalette)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(250.0, 100.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(300.0, 100.0))); - let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!(app.dock.settings(PanelId::Properties).width, 300.0); - assert_eq!(app.dock.settings(PanelId::BlockPalette).width, 300.0); - } - - #[test] - 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), - ); - assert_eq!(target, Some(DropTarget::Edge { side: DockSide::Left, index: 1 })); - assert_eq!(app.dock.location(PanelId::BlockPalette), Some((DockSide::Left, 1))); + assert_eq!(app.dock_column_width(DockSide::Left), 260.0); } #[test] @@ -2337,71 +2192,111 @@ mod tests { } #[test] - fn dock_drop_on_own_lone_slot_is_a_no_op() { + fn dock_dragging_an_icon_along_the_strip_reorders_its_group() { use crate::app::config::DockSide; - use crate::ui::dock::{DropTarget, PanelId}; + use crate::ui::dock::{DockMsg, DropTarget, PanelId, STRIP_CELL_H}; 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), + app.dock.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1); + let (layout, _) = app.dock_strip_layout(DockSide::Left); + let top = layout[0].icons_top; + // Drag the Properties icon below the Blocks icon. + let _ = app.on_dock(DockMsg::IconPress(PanelId::Properties)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, top + 10.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, top + 2.0 * STRIP_CELL_H))); + assert_eq!( + app.dock_drag_target, + Some(DropTarget::Join { + side: DockSide::Left, + group: 0, + index: 2 + }) ); - assert_eq!(target, Some(DropTarget::Edge { side: DockSide::Left, index: 0 })); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!( + app.dock.left[0].panels, + vec![PanelId::BlockPalette, PanelId::Properties] + ); + } + + #[test] + fn dock_grip_drag_moves_a_whole_group() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; + let mut app = dock_app(); + app.show_browser = true; + app.dock.left = vec![ + DockGroup::stack(vec![PanelId::Properties, PanelId::Browser]), + DockGroup::single(PanelId::BlockPalette), + ]; + app.dock.right.clear(); + let (layout, plus_top) = app.dock_strip_layout(DockSide::Left); + let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 0)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, layout[0].grip_top + 2.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0))); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!(app.dock.left[0].panels, vec![PanelId::BlockPalette]); + assert_eq!( + app.dock.left[1].panels, + vec![PanelId::Properties, PanelId::Browser] + ); + // A group dropped over the viewport does not float or split up. + let before = app.dock.clone(); + let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 1)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, 300.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0))); + assert_eq!(app.dock_drag_target, None); + let _ = app.on_dock(DockMsg::DragRelease); assert_eq!(app.dock, before); } #[test] - fn dock_visible_groups_skip_closed_panels() { + fn dock_closed_pallets_take_no_room_and_empty_groups_hide() { 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), + DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties]), + DockGroup::single(PanelId::Browser), ]; 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. + app.show_browser = false; + // Only Properties is open: one listed group, full height. + assert_eq!(app.dock_visible_groups(DockSide::Left), vec![0]); assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(1, 0.0, 900.0)]); - app.show_block_palette = true; - assert_eq!(app.dock_visible_groups(DockSide::Left), vec![0, 1]); + // A shown group with nothing open falls back to one that has. + app.dock.show_group(DockSide::Left, 1); + assert_eq!(app.dock_shown_group(DockSide::Left), Some(0)); } #[test] - 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, None); - assert_eq!(app.dock_group_shown(DockSide::Left, 0), Some(PanelId::BlockPalette)); - app.show_block_palette = false; - 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_splitter_drag_changes_slot_heights() { + fn dock_splitter_drag_changes_pallet_heights() { use crate::app::config::DockSide; use crate::ui::dock::{DockGroup, DockMsg, PanelId}; let mut app = dock_app(); - app.dock.left = vec![ - DockGroup::single(PanelId::BlockPalette, 1.0), - DockGroup::single(PanelId::Properties, 1.0), - ]; + app.dock.left = vec![DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties])]; app.dock.right.clear(); - let _ = app.on_dock(DockMsg::SplitGrab(DockSide::Left, 0, 1)); + let _ = app.on_dock(DockMsg::SplitGrab(DockSide::Left, 0, 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); + let w = &app.dock.left[0].weights; + assert!((w[0] - 1.5).abs() < 1e-4 && (w[1] - 0.5).abs() < 1e-4); + let _ = app.on_dock(DockMsg::SplitReset(DockSide::Left, 0)); + assert_eq!(app.dock.left[0].weights, vec![1.0, 1.0]); + } + + #[test] + fn dock_auto_hiding_group_shows_while_its_icon_is_hovered() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = dock_app(); + let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties)); + assert!(!app.dock_edge_expanded(DockSide::Left)); + let _ = app.on_dock(DockMsg::Hover(PanelId::Properties)); + assert!(app.dock_edge_expanded(DockSide::Left)); + let _ = app.on_dock(DockMsg::HoverExit); + assert!(!app.dock_edge_expanded(DockSide::Left)); } #[test] @@ -2430,6 +2325,78 @@ mod tests { assert_eq!((g.x, g.w, g.h), (f.x - 40.0, 340.0, 350.0)); } + #[test] + fn dock_edge_menu_adds_and_hides_pallets() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = dock_app(); + app.show_browser = false; + let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left))); + // Checking a closed pallet opens it as a new, shown group here. + let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); + assert!(app.show_browser); + assert_eq!(app.dock.left.len(), 2); + assert_eq!(app.dock.left[1].panels, vec![PanelId::Browser]); + assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); + // Unchecking hides it but keeps its place. + let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); + assert!(!app.show_browser); + assert_eq!(app.dock.location(PanelId::Browser), Some((DockSide::Left, 1))); + // Opening it again from the same edge reuses that place. + let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); + assert_eq!(app.dock.left.len(), 2); + } + + #[test] + fn dock_divider_resizes_only_its_group() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; + let mut app = dock_app(); + app.dock.left = vec![ + DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]), + DockGroup::single(PanelId::Count), + ]; + app.dock.right.clear(); + let _ = app.on_dock(DockMsg::ResizeGrab(PanelId::Properties)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(300.0, 100.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(330.0, 100.0))); + let _ = app.on_dock(DockMsg::DragRelease); + // The group was 260 wide (its widest pallet); both pallets take 290. + assert_eq!(app.dock.settings(PanelId::Properties).width, 290.0); + assert_eq!(app.dock.settings(PanelId::BlockPalette).width, 290.0); + assert_eq!(app.dock.settings(PanelId::Count).width, 280.0); + assert_eq!(app.dock_group_width(DockSide::Left, 0), 290.0); + } + + #[test] + fn layers_command_opens_a_floating_pallet_and_toggles_it_closed() { + use crate::ui::dock::PanelId; + let mut app = dock_app(); + let _ = app.update(Message::ToggleLayers); + assert!(app.show_layers); + assert!(app.active_modal.is_none(), "no modal backdrop"); + let f = app.dock.float_rect(PanelId::Layers).expect("floats on first use"); + assert!(f.x > 0.0 && f.y >= 0.0); + let _ = app.update(Message::ToggleLayers); + assert!(!app.show_layers); + // Its place is kept for next time. + assert!(app.dock.float_rect(PanelId::Layers).is_some()); + } + + #[test] + fn dock_double_clicks_float_and_dock_panels() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = dock_app(); + let _ = app.on_dock(DockMsg::FloatOut(PanelId::Properties)); + assert!(app.dock.left.is_empty()); + let f = app.dock.float_rect(PanelId::Properties).expect("floating"); + assert!(f.x > 0.0, "floats clear of the left edge"); + let _ = app.on_dock(DockMsg::DockTo(PanelId::Properties, DockSide::Right)); + assert!(app.dock.floating.is_empty()); + assert_eq!(app.dock.location(PanelId::Properties), Some((DockSide::Right, 1))); + } + #[test] fn blockpalette_width_reset() { let mut app = fresh(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 08b181f37..c316dccdb 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2770,43 +2770,40 @@ impl OpenCADStudio { } /// Build one edge: the vertical icon strip at the very edge plus the - /// expanded groups beside it. The strip lists every shown group's pallets - /// as icons under a grip bar, with a + button below them; clicking an - /// icon shows that pallet in its group. Unpinned groups are always - /// expanded and split the column height by their weights, with a - /// draggable splitter between neighbours. An auto-hiding (pinned) group - /// lives only in the strip until one of its icons is hovered. The whole - /// edge is one hover region: leaving it collapses any auto-hiding group. - /// `None` when nothing on the edge is shown. + /// edge's shown group beside it. The strip lists every group as a grip + /// bar over its pallets' icons, with a + button below; clicking an icon + /// shows its group. The shown group's pallets stack top to bottom, each + /// at its weighted share of the height, with a draggable splitter between + /// neighbours. A group whose pallets all auto-hide shows only while one + /// of its icons (or the group itself) is hovered; leaving the edge hides + /// it again. `None` when nothing on the edge is open. fn build_edge_stack<'a>( &'a self, side: crate::app::config::DockSide, tab: &'a DocumentTab, ) -> Option> { - if self.dock_visible_groups(side).is_empty() { - return None; - } - let expanded = self.dock_expanded_groups(side); - let col_w = expanded - .iter() - .map(|gi| self.dock_group_width(side, *gi)) - .fold(0.0, f32::max); - + let gi = self.dock_shown_group(side)?; let strip = self.dock_icon_strip(side); - let expanded_stack = (!expanded.is_empty()).then(|| { - // Expanded groups stack one below the other, each taking its - // weighted share of the column height — never overlapping. + let column_el = self.dock_edge_expanded(side).then(|| { + let width = self.dock_group_width(side, gi); + let group = &self.dock.groups(side)[gi]; 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, col_w)); + let mut prev: Option = None; + for (pos, id) in group.panels.iter().enumerate() { + if !self.dock_panel_visible(*id) { + continue; + } + if let Some(upper) = prev { + layers.push(dock_splitter(side, gi, upper, pos, width)); } - 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))) + container(self.expanded_panel(*id, side, width, tab)) + .height(Length::FillPortion(crate::ui::dock::portion( + group.weights[pos], + ))) .into(), ); + prev = Some(pos); } column(layers).height(Fill).into() }); @@ -2815,10 +2812,10 @@ impl OpenCADStudio { match side { crate::app::config::DockSide::Left => { children.push(strip); - children.extend(expanded_stack); + children.extend(column_el); } crate::app::config::DockSide::Right => { - children.extend(expanded_stack); + children.extend(column_el); children.push(strip); } } @@ -2829,12 +2826,13 @@ impl OpenCADStudio { ) } - /// An edge's vertical icon strip: per shown group a grip bar (drag it to - /// move the group) and its pallets' icons (the group's tabs; the shown one - /// on a grey background), then a + button opening the pallet menu. - /// Heights follow `dock::strip_layout`, which drop targeting uses too. + /// An edge's vertical icon strip: per group a grip bar (drag it to move + /// the group) over its open pallets' icons, the shown group framed on a + /// grey background, then a + button opening the pallet menu. Heights + /// follow `dock::strip_layout`, which drop targeting uses too. fn dock_icon_strip(&self, side: crate::app::config::DockSide) -> Element<'_, Message> { use crate::ui::dock::{DockMsg, DOCK_STRIP_W, STRIP_CELL_H, STRIP_GAP, STRIP_GRIP_H}; + let shown = self.dock_shown_group(side); let mut items: Vec> = Vec::new(); for gi in self.dock_visible_groups(side) { items.push(Space::new().height(STRIP_GAP).into()); @@ -2855,37 +2853,20 @@ impl OpenCADStudio { ) .center_x(Length::Fixed(DOCK_STRIP_W)) .center_y(Length::Fixed(STRIP_GRIP_H)); - items.push( - mouse_area(grip) - .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) - .interaction(iced::mouse::Interaction::Grab) - .into(), - ); - let shown = self.dock_group_shown(side, gi); - for id in self.dock_group_visible_tabs(side, gi) { - let is_active = Some(id) == shown; - let icon = container(crate::ui::icons::themed(id.icon(), 20.0)) - .center_x(Length::Fixed(STRIP_CELL_H - 4.0)) - .center_y(Length::Fixed(STRIP_CELL_H - 4.0)) - .style(move |theme: &Theme| container::Style { - // Only the shown pallet of the group gets a background. - background: is_active - .then(|| Background::Color(theme.palette().background.weak.color)), - border: Border { - radius: 4.0.into(), - ..Default::default() - }, - ..Default::default() - }); + let mut group_items: Vec> = vec![mouse_area(grip) + .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) + .interaction(iced::mouse::Interaction::Grab) + .into()]; + for id in self.dock_group_visible(side, gi) { let cell = mouse_area( - container(icon) + container(crate::ui::icons::themed(id.icon(), 20.0)) .center_x(Length::Fixed(DOCK_STRIP_W)) .center_y(Length::Fixed(STRIP_CELL_H)), ) - .on_press(Message::Dock(DockMsg::SelectTab(id))) + .on_press(Message::Dock(DockMsg::IconPress(id))) .on_enter(Message::Dock(DockMsg::Hover(id))) .interaction(iced::mouse::Interaction::Pointer); - items.push( + group_items.push( iced::widget::tooltip( cell, text(id.title()).size(10), @@ -2902,6 +2883,18 @@ impl OpenCADStudio { .into(), ); } + let is_shown = shown == Some(gi); + items.push( + container(column(group_items)) + .width(Length::Fixed(DOCK_STRIP_W)) + .style(move |theme: &Theme| container::Style { + // The shown group sits on the grey background. + background: is_shown + .then(|| Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .into(), + ); } items.push(Space::new().height(STRIP_GAP).into()); let plus = button(crate::ui::icons::themed_secondary(crate::ui::icons::PLUS, 14.0)) @@ -2981,19 +2974,17 @@ impl OpenCADStudio { } } - /// An expanded slot: its shown panel (the group's tabs live in the icon - /// strip) 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 pallet of the shown group: its body plus a grabbable divider + /// against the viewport (sizing the whole group). 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>( &'a self, + id: crate::ui::dock::PanelId, side: crate::app::config::DockSide, - gi: usize, width: f32, tab: &'a DocumentTab, ) -> Element<'a, Message> { - let id = self.dock_group_shown(side, gi).expect("visible group"); let panel = self.panel_body(id, width, false, tab); let divider = dock_divider(id); match side { @@ -3097,8 +3088,9 @@ impl OpenCADStudio { } /// 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. + /// edge plus a panel-sized ghost where it lands — stacked into a group + /// (sized as that group will be), as a new group of its own (full + /// height), or as a floating window. fn dock_drop_preview( &self, id: crate::ui::dock::PanelId, @@ -3106,16 +3098,11 @@ impl OpenCADStudio { ) -> 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 (ww, avail) = self.dock_workspace_size(); let strip = crate::ui::dock::DOCK_STRIP_W; - // The tint covers the edge's icon strip and column; the ghost sits in - // the column beside the strip. - let tint = |side: DockSide| -> Element<'_, Message> { - let w = edge_w(side) + strip; + let tint = |side: DockSide, w: f32| -> Element<'_, Message> { let band = container(Space::new()) - .width(Length::Fixed(w)) + .width(Length::Fixed(w + strip)) .height(Fill) .style(|theme: &Theme| container::Style { background: Some(Background::Color( @@ -3132,46 +3119,17 @@ impl OpenCADStudio { }) .into() }; - let edge_x = |side: DockSide, w: f32| match side { - DockSide::Left => strip, - DockSide::Right => (ww - w - strip).max(0.0), - }; let mut layers: Vec> = Vec::new(); - match target { + let docked = 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, - )); + let mut after = self.dock.clone(); + after.dock(id, side, index); + Some((side, after)) } - DropTarget::Tab { side, group, index } => { - 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)); - // The slot's tabs as they will be after the drop. - let mut preview = self.dock.clone(); - preview.add_tab(id, side, group, index); - let tabs: Vec<_> = preview - .location(id) - .map(|(s, gi)| preview.groups(s)[gi].tabs.clone()) - .unwrap_or_default() - .into_iter() - .filter(|t| *t == id || self.dock_panel_visible(*t)) - .collect(); - 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::Join { side, group, index } => { + let mut after = self.dock.clone(); + after.join_group(id, side, group, index); + Some((side, after)) } DropTarget::Float { x, y } => { let (w, h) = self.dock_float_size(id); @@ -3180,6 +3138,34 @@ impl OpenCADStudio { x, y, )); + None + } + }; + if let Some((side, after)) = docked { + // Lay the group out as it will be after the drop. + if let Some((_, gi)) = after.location(id) { + let group = &after.groups(side)[gi]; + let open: Vec = (0..group.panels.len()) + .filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i])) + .collect(); + let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); + let spans = crate::ui::dock::slot_spans(&weights, avail); + let at = open + .iter() + .position(|i| group.panels[*i] == id) + .expect("dropped panel"); + let (top, bottom) = spans[at]; + let w = group + .panels + .iter() + .map(|p| after.width(*p, self.win_size.0)) + .fold(0.0, f32::max); + let x = match side { + DockSide::Left => strip, + DockSide::Right => (ww - w - strip).max(0.0), + }; + layers.push(tint(side, w)); + layers.push(place_at(dock_ghost(&[id], id, w, bottom - top), x, top)); } } iced::widget::Stack::with_children(layers) @@ -3187,52 +3173,6 @@ impl OpenCADStudio { .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 ─────────────────────────────────────────────────────── @@ -3757,12 +3697,13 @@ fn dock_edge_menu( .into() } -/// Draggable bar between two expanded slots on an edge; double-click gives -/// every slot on the edge the same height again. It spans exactly the slots' -/// width (panel plus its divider): a `Fill` width would stretch the whole -/// edge column across the workspace and squeeze the drawing view. +/// Draggable bar between two stacked pallets of group `gi`; double-click +/// gives every pallet of the group the same height again. It spans exactly +/// the pallets' width (panel plus its divider): a `Fill` width would stretch +/// the whole edge column across the workspace and squeeze the drawing view. fn dock_splitter( side: crate::app::config::DockSide, + gi: usize, upper: usize, lower: usize, col_w: f32, @@ -3775,8 +3716,8 @@ fn dock_splitter( ..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))) + .on_press(Message::Dock(crate::ui::dock::DockMsg::SplitGrab(side, gi, upper, lower))) + .on_double_click(Message::Dock(crate::ui::dock::DockMsg::SplitReset(side, gi))) .interaction(iced::mouse::Interaction::ResizingVertically) .into() } diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 63e88b967..19e9e9678 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -32,12 +32,14 @@ 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), + /// A press on `panel`'s strip icon: show its group, and start a possible + /// drag of the pallet. + IconPress(PanelId), + /// Begin dragging the splitter between pallets `upper` and `lower` of a + /// group (side, group, upper, lower). + SplitGrab(DockSide, usize, usize, usize), + /// Give every pallet of a group the same height again (side, group). + SplitReset(DockSide, usize), /// Begin resizing floating `panel` from its corner grip; `true` for the /// bottom-left grip (a panel whose title bar is on the right). FloatResizeGrab(PanelId, bool), @@ -177,79 +179,103 @@ impl DockPanel { } } -/// 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")] +/// One group of an edge: pallets shown together, stacked top to bottom, each +/// with an adjustable share of the edge height. An edge shows one group at a +/// time; its icon strip lists every group so the user can switch. +#[derive(Debug, Clone, PartialEq, Serialize)] 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, + /// Pallets top → bottom. + pub panels: Vec, + /// Relative height of each pallet, parallel to `panels`. + pub weights: Vec, } -/// On-disk forms of a [`DockGroup`]: configs written before tab groups stored -/// each slot as a bare panel id. +impl DockGroup { + pub fn single(id: PanelId) -> Self { + Self { + panels: vec![id], + weights: vec![1.0], + } + } + + pub fn stack(panels: Vec) -> Self { + let weights = vec![1.0; panels.len()]; + Self { panels, weights } + } + + /// Keep one sane weight per pallet (configs edited by hand, or written + /// by an older build). + fn heal(&mut self) { + self.weights.resize(self.panels.len(), 1.0); + for w in &mut self.weights { + *w = if w.is_finite() && *w > 0.0 { + w.clamp(MIN_WEIGHT, MAX_WEIGHT) + } else { + 1.0 + }; + } + } + + fn insert(&mut self, index: usize, id: PanelId, weight: f32) { + let index = index.min(self.panels.len()); + self.panels.insert(index, id); + self.weights.insert(index, weight); + } + + fn remove(&mut self, id: PanelId) -> Option<(usize, f32)> { + let i = self.panels.iter().position(|p| *p == id)?; + self.panels.remove(i); + Some((i, self.weights.remove(i))) + } + + fn mean_weight(&self) -> f32 { + if self.weights.is_empty() { + 1.0 + } else { + self.weights.iter().sum::() / self.weights.len() as f32 + } + } +} + +/// On-disk forms of one entry of an edge list. Configs from before groups +/// stored each stacked pallet as a bare id; this branch's earlier builds +/// stored tab groups. #[derive(Deserialize)] #[serde(untagged)] enum GroupRepr { Single(PanelId), - Group { - tabs: Vec, + Stack { + panels: Vec, #[serde(default)] - active: Option, - #[serde(default = "default_weight")] - weight: f32, + weights: Vec, }, + Tabs { tabs: Vec }, } -fn default_weight() -> f32 { - 1.0 -} - -impl From for DockGroup { - fn from(repr: GroupRepr) -> Self { +/// Read an edge list. Bare ids were pallets stacked on the edge, all shown +/// at once, so they become one group to keep the old layout. +fn edge_from_reprs<'de, D>(de: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let reprs = Vec::::deserialize(de)?; + let mut groups = Vec::new(); + let mut legacy: Vec = Vec::new(); + for repr in reprs { 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, - } - } + GroupRepr::Single(id) => legacy.push(id), + GroupRepr::Stack { panels, weights } => groups.push(DockGroup { panels, weights }), + GroupRepr::Tabs { tabs } => groups.push(DockGroup::stack(tabs)), } } -} - -impl DockGroup { - pub fn single(id: PanelId, weight: f32) -> Self { - Self { - tabs: vec![id], - active: id, - weight, - } + if !legacy.is_empty() { + groups.insert(0, DockGroup::stack(legacy)); } + for g in &mut groups { + g.heal(); + } + groups.retain(|g| !g.panels.is_empty()); + Ok(groups) } /// A panel floating over the workspace, in workspace-local pixels. @@ -264,33 +290,39 @@ pub struct FloatPanel { /// Smallest floating panel height. pub const FLOAT_MIN_H: f32 = 160.0; -/// Smallest on-screen height of a docked slot while dragging a splitter. +/// Smallest on-screen height of a stacked pallet 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 +/// The whole dock layout: per edge its groups and which one shows, 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 { - /// Slots anchored to the left edge, top → bottom. + /// Groups on the left edge, in icon-strip order. + #[serde(deserialize_with = "edge_from_reprs")] pub left: Vec, - /// Slots anchored to the right edge, top → bottom. + /// Groups on the right edge, in icon-strip order. + #[serde(deserialize_with = "edge_from_reprs")] pub right: Vec, + /// The group each edge shows (left, right). + pub shown: (usize, usize), /// Floating panels, back → front. pub floating: Vec, - /// Per-panel width / auto-collapse settings, keyed by `PanelId`. + /// Per-panel width / auto-collapse settings, keyed by `PanelId`. A + /// group's pallets share one width, so each group keeps its own. pub panels: BTreeMap, } impl Default for DockState { fn default() -> Self { Self { - left: vec![DockGroup::single(PanelId::Properties, 1.0)], - right: vec![DockGroup::single(PanelId::BlockPalette, 1.0)], + left: vec![DockGroup::single(PanelId::Properties)], + right: vec![DockGroup::single(PanelId::BlockPalette)], + shown: (0, 0), floating: Vec::new(), panels: BTreeMap::new(), } @@ -298,7 +330,7 @@ impl Default for DockState { } impl DockState { - /// The slots stacked on `side`, top → bottom. + /// The groups on `side`, in icon-strip order. pub fn groups(&self, side: DockSide) -> &[DockGroup] { match side { DockSide::Left => &self.left, @@ -313,6 +345,34 @@ impl DockState { } } + /// The group `side` shows (it may hold no open pallet; see the app's + /// `dock_shown_group` for the one actually drawn). + pub fn shown(&self, side: DockSide) -> usize { + match side { + DockSide::Left => self.shown.0, + DockSide::Right => self.shown.1, + } + } + + /// Make group `gi` the one `side` shows. Returns whether it changed. + pub fn show_group(&mut self, side: DockSide, gi: usize) -> bool { + let slot = match side { + DockSide::Left => &mut self.shown.0, + DockSide::Right => &mut self.shown.1, + }; + let changed = *slot != gi; + *slot = gi; + changed + } + + /// Show the group holding `id`. Returns whether the shown group changed. + pub fn show_group_of(&mut self, id: PanelId) -> bool { + match self.location(id) { + Some((side, gi)) => self.show_group(side, gi), + None => false, + } + } + /// 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 (or edited by hand): a panel placed twice @@ -325,22 +385,25 @@ impl DockState { 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; - } - } + g.heal(); + let keep: Vec = g.panels.iter().map(|id| seen.insert(*id)).collect(); + let mut k = keep.iter(); + g.weights.retain(|_| *k.next().expect("parallel")); + let mut k = keep.iter(); + g.panels.retain(|_| *k.next().expect("parallel")); } - groups.retain(|g| !g.tabs.is_empty()); + groups.retain(|g| !g.panels.is_empty()); + let len = groups.len(); + let shown = self.shown(side).min(len.saturating_sub(1)); + self.show_group(side, shown); } self.floating.retain(|f| seen.insert(f.id)); } - /// Where (if anywhere) a panel is docked: its side and slot index. + /// Where (if anywhere) a panel is docked: its side and group index. pub fn location(&self, id: PanelId) -> Option<(DockSide, usize)> { for side in [DockSide::Left, DockSide::Right] { - if let Some(i) = self.groups(side).iter().position(|g| g.tabs.contains(&id)) { + if let Some(i) = self.groups(side).iter().position(|g| g.panels.contains(&id)) { return Some((side, i)); } } @@ -393,120 +456,99 @@ impl DockState { entry.auto_collapse = on; } - /// 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)); - } - 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` (slot index counted - /// before the move) and show it. `index` is the tab position, counted in - /// the slot's tabs before the move; `None` appends a newcomer and leaves - /// a tab already in the slot where it is. A joining panel takes on the - /// slot's width, so switching tabs never changes the column width. - /// Returns whether the layout changed. - pub fn add_tab( - &mut self, - id: PanelId, - side: DockSide, - group: usize, - index: Option, - ) -> bool { - if group >= self.groups(side).len() { - return false; - } - let before = self.clone(); - if let Some(pos) = self.groups(side)[group].tabs.iter().position(|t| *t == id) { - // Reordering within the slot. - let g = &mut self.groups_mut(side)[group]; - if let Some(index) = index { - g.tabs.remove(pos); - let index = if pos < index { index - 1 } else { index }; - g.tabs.insert(index.min(g.tabs.len()), id); - } - g.active = id; - return *self != before; - } - let width = self.group_width(side, group); - 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]; - let index = index.unwrap_or(g.tabs.len()).min(g.tabs.len()); - g.tabs.insert(index, id); - g.active = id; - self.set_width(id, width); - true - } - - /// The saved width a slot's panels share: the widest of its tabs, so a - /// slot whose tabs were sized apart (older configs) renders at one width. + /// The saved width a group's pallets share: the widest of them, so a + /// group whose pallets were sized apart renders at one width. pub fn group_width(&self, side: DockSide, group: usize) -> f32 { self.groups(side)[group] - .tabs + .panels .iter() .map(|id| self.settings(*id).width) .fold(DOCK_MIN_W, f32::max) } - /// Set the width of `id` and of every other tab in its slot. + /// Set the width of `id` and of every other pallet in its group. pub fn set_group_width(&mut self, id: PanelId, width: f32) { - let tabs = match self.location(id) { - Some((side, gi)) => self.groups(side)[gi].tabs.clone(), + let panels = match self.location(id) { + Some((side, gi)) => self.groups(side)[gi].panels.clone(), None => vec![id], }; - for t in tabs { - self.set_width(t, width); + for p in panels { + self.set_width(p, width); } } - /// Move slot `group` on `side` to insertion `index` on `to` (counted - /// before the move). Returns whether the layout changed. + /// Take `id` out of the layout. Returns the group it left, its position + /// and weight there, and whether the group disappeared (it held nothing + /// else). + fn detach(&mut self, id: PanelId) -> Option<(DockSide, usize, usize, f32, bool)> { + self.floating.retain(|f| f.id != id); + let (side, gi) = self.location(id)?; + let (pos, weight) = self.groups_mut(side)[gi].remove(id).expect("located"); + if !self.groups(side)[gi].panels.is_empty() { + return Some((side, gi, pos, weight, false)); + } + self.groups_mut(side).remove(gi); + // Keep showing the same group, or its neighbour when it went away. + let shown = self.shown(side); + if shown > gi || (shown == gi && shown > 0 && shown >= self.groups(side).len()) { + self.show_group(side, shown - 1); + } + Some((side, gi, pos, weight, true)) + } + + /// Dock `id` as a new group of its own on `side` at insertion `index` + /// (0 = first, `len` = last), counted before the move, and show it. + /// Returns whether the layout changed. + pub fn dock(&mut self, id: PanelId, side: DockSide, index: usize) -> bool { + let before = self.clone(); + let mut index = index; + if let Some((old_side, old_gi, _, _, removed)) = self.detach(id) { + if old_side == side && removed && old_gi < index { + index -= 1; + } + } + let groups = self.groups_mut(side); + let index = index.min(groups.len()); + groups.insert(index, DockGroup::single(id)); + self.show_group(side, index); + *self != before + } + + /// Stack `id` into group `group` on `side` at position `index` (0 = top, + /// `len` = bottom), both counted before the move, and show the group. A + /// pallet already in the group moves within it. A joining pallet takes on + /// the group's width. Returns whether the layout changed. + pub fn join_group(&mut self, id: PanelId, side: DockSide, group: usize, index: usize) -> bool { + if group >= self.groups(side).len() { + return false; + } + let before = self.clone(); + let width = self.group_width(side, group); + let joining = !self.groups(side)[group].panels.contains(&id); + let mut group = group; + let mut index = index; + let mut weight = self.groups(side)[group].mean_weight(); + if let Some((old_side, old_gi, pos, w, removed)) = self.detach(id) { + if old_side == side && old_gi == group { + // Moving within the group keeps the pallet's height. + weight = w; + if pos < index { + index -= 1; + } + } else if old_side == side && removed && old_gi < group { + group -= 1; + } + } + self.groups_mut(side)[group].insert(index, id, weight); + if joining { + self.set_width(id, width); + } + self.show_group(side, group); + *self != before + } + + /// Move group `group` on `side` to insertion `index` on `to` (counted + /// before the move) and show it there. Returns whether the layout changed. pub fn move_group(&mut self, side: DockSide, group: usize, to: DockSide, index: usize) -> bool { if group >= self.groups(side).len() { return false; @@ -520,10 +562,17 @@ impl DockState { index -= 1; } } + let shown_here = self.shown(side); let g = self.groups_mut(side).remove(group); + if side != to && shown_here > group { + self.show_group(side, shown_here - 1); + } else if side != to && shown_here >= self.groups(side).len() && shown_here > 0 { + self.show_group(side, shown_here - 1); + } let dest = self.groups_mut(to); let index = index.min(dest.len()); dest.insert(index, g); + self.show_group(to, index); true } @@ -556,43 +605,36 @@ impl DockState { } } - /// 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`. + /// Move the boundary between pallets `upper` and `lower` of group + /// `group` on `side` by `delta` weight units, keeping their combined + /// share and each above `min_weight`. pub fn shift_split( &mut self, side: DockSide, + group: usize, upper: usize, lower: usize, delta: f32, min_weight: f32, ) { - let groups = self.groups_mut(side); - if upper >= groups.len() || lower >= groups.len() || upper == lower { + let Some(g) = self.groups_mut(side).get_mut(group) else { + return; + }; + let w = &mut g.weights; + if upper >= w.len() || lower >= w.len() || upper == lower { return; } - let total = groups[upper].weight + groups[lower].weight; + let total = w[upper] + w[lower]; 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; + let up = (w[upper] + delta).clamp(min, total - min); + w[upper] = up; + w[lower] = 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; + /// Give every pallet of group `group` on `side` the same height again. + pub fn reset_splits(&mut self, side: DockSide, group: usize) { + if let Some(g) = self.groups_mut(side).get_mut(group) { + g.weights.iter_mut().for_each(|w| *w = 1.0); } } } @@ -600,42 +642,19 @@ impl DockState { /// 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). + /// A new group on `side` at insertion `index` (0 = first, len = last). Edge { side: DockSide, index: usize }, - /// A tab in slot `group` on `side`, at position `index` among the - /// slot's tabs (before the move), or appended / left in place for `None`. - Tab { + /// Stacked into group `group` on `side` at position `index` (counted + /// before the move; 0 = top). + Join { side: DockSide, group: usize, - index: Option, + index: 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 bands (35 % each) split the slot (dock above / below), the -/// middle 30 % 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.35; - if y < top + band { - SlotZone::Above - } else if y > bottom - band { - SlotZone::Below - } else { - SlotZone::Middle - } -} - /// 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)> { @@ -983,21 +1002,26 @@ mod tests { } #[test] - fn legacy_config_with_bare_panel_ids_loads_as_single_slots() { + fn legacy_bare_ids_load_as_one_stacked_group() { + // Before groups, every id on an edge was shown stacked; keep that. 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_eq!(state.left.len(), 1); + assert_eq!( + state.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette] + ); + assert_eq!(state.left[0].weights, vec![1.0, 1.0]); assert!(state.floating.is_empty()); } #[test] fn layout_round_trips_through_serde() { let mut state = DockState::default(); - state.add_tab(PanelId::Browser, DockSide::Left, 0, None); + state.join_group(PanelId::Browser, DockSide::Left, 0, 1); + state.dock(PanelId::Count, DockSide::Left, 1); state.float(FloatPanel { - id: PanelId::Count, + id: PanelId::SheetSetManager, x: 10.0, y: 20.0, w: 300.0, @@ -1009,50 +1033,113 @@ mod tests { } #[test] - 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, None)); - 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); - } - - #[test] - fn add_tab_reorders_within_a_slot() { - let mut state = DockState::default(); - state.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); - state.add_tab(PanelId::Browser, DockSide::Left, 0, None); - // Drag the first tab to the end. - assert!(state.add_tab(PanelId::Properties, DockSide::Left, 0, Some(3))); - assert_eq!( - state.left[0].tabs, - vec![PanelId::BlockPalette, PanelId::Browser, PanelId::Properties] - ); - // Drag the last tab to the front. - assert!(state.add_tab(PanelId::Properties, DockSide::Left, 0, Some(0))); - assert_eq!(state.left[0].tabs[0], PanelId::Properties); - // A middle drop (None) leaves a member where it is. - assert!(!state.add_tab(PanelId::Properties, DockSide::Left, 0, None)); - // A newcomer can land at a given position. - state.add_tab(PanelId::Count, DockSide::Left, 0, Some(1)); - assert_eq!(state.left[0].tabs[1], PanelId::Count); - } - - #[test] - fn tabs_share_the_slot_width() { + fn join_group_stacks_and_shows_the_group() { let mut state = DockState::default(); state.set_width(PanelId::Properties, 320.0); - state.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); + // Below Properties in the left group. + assert!(state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1)); + assert!(state.right.is_empty(), "its old group disappears"); + assert_eq!( + state.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette] + ); + assert_eq!(state.left[0].weights.len(), 2); + // It takes on the group's width. assert_eq!(state.settings(PanelId::BlockPalette).width, 320.0); - state.set_group_width(PanelId::BlockPalette, 400.0); - assert_eq!(state.settings(PanelId::Properties).width, 400.0); - assert_eq!(state.group_width(DockSide::Left, 0), 400.0); + // Moving it above Properties keeps it in the group. + assert!(state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 0)); + assert_eq!(state.left[0].panels[0], PanelId::BlockPalette); + // Dropping it where it already is changes nothing. + assert!(!state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1)); + } + + #[test] + fn join_group_index_counts_groups_before_the_move() { + let mut state = DockState::default(); + state.left = vec![ + DockGroup::single(PanelId::BlockPalette), + DockGroup::single(PanelId::Properties), + ]; + state.right.clear(); + // Group 1 (Properties) is group 0 once the palette's group is gone. + assert!(state.join_group(PanelId::BlockPalette, DockSide::Left, 1, 1)); + assert_eq!(state.left.len(), 1); + assert_eq!( + state.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette] + ); + assert_eq!(state.shown(DockSide::Left), 0); + } + + #[test] + fn dock_starts_a_new_group_and_shows_it() { + let mut state = DockState::default(); + state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1); + assert!(state.dock(PanelId::BlockPalette, DockSide::Left, 1)); + assert_eq!(state.left.len(), 2); + assert_eq!(state.left[0].panels, vec![PanelId::Properties]); + assert_eq!(state.left[1].panels, vec![PanelId::BlockPalette]); + assert_eq!(state.shown(DockSide::Left), 1); + assert!(state.show_group_of(PanelId::Properties)); + assert_eq!(state.shown(DockSide::Left), 0); + } + + #[test] + fn removing_the_shown_group_keeps_a_valid_shown_index() { + let mut state = DockState::default(); + state.dock(PanelId::Browser, DockSide::Left, 1); + assert_eq!(state.shown(DockSide::Left), 1); + // Floating Browser removes the last group; the edge shows group 0. + state.float(FloatPanel { + id: PanelId::Browser, + x: 0.0, + y: 0.0, + w: 250.0, + h: 300.0, + }); + assert_eq!(state.left.len(), 1); + assert_eq!(state.shown(DockSide::Left), 0); + } + + #[test] + fn move_group_reorders_and_crosses_edges() { + let mut state = DockState::default(); + state.left.push(DockGroup::single(PanelId::Browser)); + state.left.push(DockGroup::single(PanelId::Count)); + // Same spot (before itself or after itself) changes nothing. + assert!(!state.move_group(DockSide::Left, 1, DockSide::Left, 1)); + assert!(!state.move_group(DockSide::Left, 1, DockSide::Left, 2)); + assert!(state.move_group(DockSide::Left, 0, DockSide::Left, 3)); + assert_eq!(state.left[2].panels, vec![PanelId::Properties]); + assert_eq!(state.shown(DockSide::Left), 2, "the moved group shows"); + assert!(state.move_group(DockSide::Left, 2, DockSide::Right, 0)); + assert_eq!(state.right[0].panels, vec![PanelId::Properties]); + assert_eq!(state.left.len(), 2); + assert!(state.shown(DockSide::Left) < 2); + } + + #[test] + fn shift_split_keeps_combined_share_and_minimum() { + let mut state = DockState::default(); + state.left = vec![DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties])]; + state.shift_split(DockSide::Left, 0, 0, 1, 0.5, 0.1); + assert_eq!(state.left[0].weights, vec![1.5, 0.5]); + state.shift_split(DockSide::Left, 0, 0, 1, 10.0, 0.1); + assert!((state.left[0].weights[1] - 0.1).abs() < 1e-6); + assert!((state.left[0].weights.iter().sum::() - 2.0).abs() < 1e-6); + state.reset_splits(DockSide::Left, 0); + assert_eq!(state.left[0].weights, vec![1.0, 1.0]); + } + + #[test] + fn ensure_settings_drops_duplicates_and_clamps_the_shown_group() { + let mut state = DockState::default(); + state.right.push(DockGroup::single(PanelId::Properties)); + state.shown = (5, 7); + state.ensure_settings(); + assert_eq!(state.location(PanelId::Properties), Some((DockSide::Left, 0))); + assert_eq!(state.right.len(), 1); + assert_eq!(state.shown, (0, 0)); } #[test] @@ -1077,62 +1164,6 @@ mod tests { assert_eq!(strip_hit(plus_top + 20.0, &layout), StripHit::NewGroup(None)); } - #[test] - fn move_group_reorders_and_crosses_edges() { - let mut state = DockState::default(); - state.left.push(DockGroup::single(PanelId::Browser, 1.0)); - state.left.push(DockGroup::single(PanelId::Count, 1.0)); - // Same spot (before itself or after itself) changes nothing. - assert!(!state.move_group(DockSide::Left, 1, DockSide::Left, 1)); - assert!(!state.move_group(DockSide::Left, 1, DockSide::Left, 2)); - assert!(state.move_group(DockSide::Left, 0, DockSide::Left, 3)); - assert_eq!(state.left[2].tabs, vec![PanelId::Properties]); - assert!(state.move_group(DockSide::Left, 2, DockSide::Right, 0)); - assert_eq!(state.right[0].tabs, vec![PanelId::Properties]); - assert_eq!(state.left.len(), 2); - } - - #[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, None); - // 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, None)); - 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(); @@ -1188,31 +1219,6 @@ mod tests { 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 every_panel_has_its_own_icon() { let ids = PanelId::ALL; @@ -1237,15 +1243,6 @@ mod tests { ); } - #[test] - fn slot_zone_splits_into_stacking_and_tab_bands() { - assert_eq!(slot_zone(10.0, 0.0, 400.0), SlotZone::Above); - assert_eq!(slot_zone(130.0, 0.0, 400.0), SlotZone::Above); - assert_eq!(slot_zone(200.0, 0.0, 400.0), SlotZone::Middle); - assert_eq!(slot_zone(270.0, 0.0, 400.0), SlotZone::Below); - assert_eq!(slot_zone(390.0, 0.0, 400.0), SlotZone::Below); - } - #[test] fn slot_spans_follow_weights() { assert_eq!( From 258762f9924e1779729030f7c92e349e92d4ab5f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 17:44:52 +0000 Subject: [PATCH 11/24] Dock: auto-hide per edge, hover only flies out when auto-hiding, menu per edge - Auto-hide is a setting of the left / right edge rather than of each pallet: the pin on any docked pallet turns it on for its whole edge, which then shows only its icon strip until an icon is hovered. A floating pallet keeps its own auto-hide. - Hovering the strip icons only shows a group while the edge auto-hides; otherwise groups switch on click. - The + menu checks only the pallets open on that edge. Picking one that is open elsewhere (the other edge, floating) moves it to this edge; unchecking a checked one hides it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/update/dialog.rs | 69 +++++++++++++++++++++++++++++----------- src/app/view/mod.rs | 10 ++++-- src/ui/dock.rs | 57 +++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 21 deletions(-) diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 0dd6c1fb9..dca344425 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -633,7 +633,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven iced::Task::none() } DockMsg::EdgeMenuToggle(side, id) => { - if self.dock_panel_visible(id) { + // Checked = open and docked on this edge: unchecking hides it. + // Anything else (closed, floating, on the other edge) moves + // here and opens. + let here = self.dock.location(id).map(|(s, _)| s) == Some(side); + if here && self.dock_panel_visible(id) { let task = self.on_dock(DockMsg::Close(id)); self.save_config(); return task; @@ -666,8 +670,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven iced::Task::none() } DockMsg::AutoCollapseToggle(id) => { - let on = !self.dock.auto_collapse(id); - self.dock.set_auto_collapse(id, on); + // Docked: auto-hide for the whole edge; floating: this pallet. + let on = self.dock.toggle_auto_hide(id); self.dock_expanded = if on { None } else { Some(id) }; self.save_config(); iced::Task::none() @@ -722,19 +726,22 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven // Ignored while dragging/resizing: the pointer is over the // drag preview, not a rail, so a hover must not collapse the // panel being dragged or repoint the resize target. - if self.dock_dragging.is_none() && self.dock_resizing.is_none() { + // Only an auto-hiding edge (or floating pallet) reacts to + // hover: it flies the hovered group out. Otherwise groups + // switch on click. + if self.dock_dragging.is_none() + && self.dock_resizing.is_none() + && self.dock.auto_hides(id) + { self.dock_expanded = Some(id); - // Hovering an auto-hiding group's icon flies it out. - if self.dock.auto_collapse(id) { - self.dock.show_group_of(id); - } + self.dock.show_group_of(id); } iced::Task::none() } DockMsg::HoverExit => { if self.dock_dragging.is_none() && self.dock_resizing.is_none() { if let Some(id) = self.dock_expanded { - if self.dock.auto_collapse(id) { + if self.dock.auto_hides(id) { self.dock_expanded = None; } } @@ -991,14 +998,15 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } /// Whether `side` draws its shown group beside the icon strip: always, - /// unless all its pallets auto-hide and none is hovered. + /// unless the edge auto-hides and none of the group's icons is hovered. pub(crate) fn dock_edge_expanded(&self, side: crate::app::config::DockSide) -> bool { let Some(gi) = self.dock_shown_group(side) else { return false; }; - let panels = self.dock_group_visible(side, gi); - panels.iter().any(|id| !self.dock.auto_collapse(*id)) - || self.dock_expanded.is_some_and(|e| panels.contains(&e)) + !self.dock.edge_auto_hide(side) + || self + .dock_expanded + .is_some_and(|e| self.dock_group_visible(side, gi).contains(&e)) } /// Width of group `gi` on `side`: its pallets share the widest one's @@ -2010,9 +2018,9 @@ mod tests { app.show_block_palette = true; let id = crate::ui::dock::PanelId::BlockPalette; let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); - assert!(app.dock.auto_collapse(id), "pin enables auto-collapse"); + assert!(app.dock.auto_hides(id), "pin enables auto-hide"); let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); - assert!(!app.dock.auto_collapse(id), "second pin disables auto-collapse"); + assert!(!app.dock.auto_hides(id), "second pin disables auto-hide"); let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id)); assert!(!app.show_block_palette, "close dismisses the sidebar"); } @@ -2287,18 +2295,37 @@ mod tests { } #[test] - fn dock_auto_hiding_group_shows_while_its_icon_is_hovered() { + fn dock_auto_hide_edge_shows_the_hovered_group() { use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, PanelId}; + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; let mut app = dock_app(); + app.show_browser = true; + app.dock.left.push(DockGroup::single(PanelId::Browser)); + // The pin on any docked pallet turns auto-hide on for its edge. let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties)); + assert!(app.dock.edge_auto_hide(DockSide::Left)); + assert!(!app.dock.edge_auto_hide(DockSide::Right)); assert!(!app.dock_edge_expanded(DockSide::Left)); - let _ = app.on_dock(DockMsg::Hover(PanelId::Properties)); + // Hovering an icon flies its group out. + let _ = app.on_dock(DockMsg::Hover(PanelId::Browser)); assert!(app.dock_edge_expanded(DockSide::Left)); + assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); let _ = app.on_dock(DockMsg::HoverExit); assert!(!app.dock_edge_expanded(DockSide::Left)); } + #[test] + fn dock_hover_does_not_switch_groups_without_auto_hide() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; + let mut app = dock_app(); + app.show_browser = true; + app.dock.left.push(DockGroup::single(PanelId::Browser)); + let _ = app.on_dock(DockMsg::Hover(PanelId::Browser)); + assert_eq!(app.dock_shown_group(DockSide::Left), Some(0)); + assert!(app.dock_edge_expanded(DockSide::Left)); + } + #[test] fn dock_float_resize_grows_the_panel() { use crate::ui::dock::{DockMsg, FloatPanel, PanelId}; @@ -2345,6 +2372,12 @@ mod tests { // Opening it again from the same edge reuses that place. let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); assert_eq!(app.dock.left.len(), 2); + // A pallet open on the other edge is not checked here: picking it + // moves it over instead of hiding it. + let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::BlockPalette)); + assert!(app.show_block_palette); + assert!(app.dock.right.is_empty()); + assert_eq!(app.dock.location(PanelId::BlockPalette), Some((DockSide::Left, 2))); } #[test] diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index c316dccdb..c52b94f3f 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1940,9 +1940,13 @@ bg={bg_ms:.1}ms n={view_count}" // catcher that closes it on any click elsewhere. if let Some(side) = self.dock_edge_menu { if !self.dock_visible_groups(side).is_empty() { + // Checked = open and docked on this edge. let shown: Vec<_> = crate::ui::dock::PanelId::ALL .iter() - .map(|p| (*p, self.dock_panel_visible(*p))) + .map(|p| { + let here = self.dock.location(*p).map(|(s, _)| s) == Some(side); + (*p, here && self.dock_panel_visible(*p)) + }) .collect(); let (_, plus_top) = self.dock_strip_layout(side); let strip = crate::ui::dock::DOCK_STRIP_W; @@ -2927,7 +2931,7 @@ impl OpenCADStudio { tab: &'a DocumentTab, ) -> Element<'a, Message> { let chrome = crate::ui::dock::Chrome { - auto_collapse: self.dock.auto_collapse(id), + auto_collapse: self.dock.auto_hides(id), floating, title_hovered: self.dock_title_hover == Some(id), }; @@ -3635,7 +3639,7 @@ const DOCK_MENU_W: f32 = 230.0; /// The pallet menu of an edge's + button, styled like the right-click menu: /// one row per pallet with its icon, its name and a check mark when it is -/// shown. `shown` holds each pallet with that state. +/// open on this edge. `shown` holds each pallet with that state. fn dock_edge_menu( side: crate::app::config::DockSide, shown: &[(crate::ui::dock::PanelId, bool)], diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 19e9e9678..c20513b3d 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -310,6 +310,9 @@ pub struct DockState { pub right: Vec, /// The group each edge shows (left, right). pub shown: (usize, usize), + /// Auto-hide per edge (left, right): the edge shows only its icon strip + /// until an icon is hovered. + pub auto_hide: (bool, bool), /// Floating panels, back → front. pub floating: Vec, /// Per-panel width / auto-collapse settings, keyed by `PanelId`. A @@ -323,6 +326,7 @@ impl Default for DockState { left: vec![DockGroup::single(PanelId::Properties)], right: vec![DockGroup::single(PanelId::BlockPalette)], shown: (0, 0), + auto_hide: (false, false), floating: Vec::new(), panels: BTreeMap::new(), } @@ -435,10 +439,41 @@ impl DockState { .clamp(DOCK_MIN_W, id.max_width().min(win_w * 0.45).max(DOCK_MIN_W)) } + /// The floating auto-hide flag of `id` (a docked pallet follows its + /// edge instead; see [`Self::auto_hides`]). pub fn auto_collapse(&self, id: PanelId) -> bool { self.settings(id).auto_collapse } + /// Whether `side` auto-hides. + pub fn edge_auto_hide(&self, side: DockSide) -> bool { + match side { + DockSide::Left => self.auto_hide.0, + DockSide::Right => self.auto_hide.1, + } + } + + /// Whether `id` auto-hides where it is: by its edge's setting when + /// docked, by its own when floating. + pub fn auto_hides(&self, id: PanelId) -> bool { + match self.location(id) { + Some((side, _)) => self.edge_auto_hide(side), + None => self.auto_collapse(id), + } + } + + /// Flip auto-hide where `id` is: its whole edge when docked, the pallet + /// itself when floating. Returns the new state. + pub fn toggle_auto_hide(&mut self, id: PanelId) -> bool { + let on = !self.auto_hides(id); + match self.location(id) { + Some((DockSide::Left, _)) => self.auto_hide.0 = on, + Some((DockSide::Right, _)) => self.auto_hide.1 = on, + None => self.set_auto_collapse(id, on), + } + on + } + /// 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)); @@ -1131,6 +1166,28 @@ mod tests { assert_eq!(state.left[0].weights, vec![1.0, 1.0]); } + #[test] + fn auto_hide_is_per_edge_for_docked_and_per_pallet_when_floating() { + let mut state = DockState::default(); + state.join_group(PanelId::Browser, DockSide::Left, 0, 1); + assert!(state.toggle_auto_hide(PanelId::Properties)); + // The whole left edge auto-hides, the right edge does not. + assert!(state.auto_hides(PanelId::Browser)); + assert!(!state.auto_hides(PanelId::BlockPalette)); + // A floating pallet keeps its own setting. + state.float(FloatPanel { + id: PanelId::Count, + x: 0.0, + y: 0.0, + w: 250.0, + h: 300.0, + }); + assert!(!state.auto_hides(PanelId::Count)); + assert!(state.toggle_auto_hide(PanelId::Count)); + assert!(state.auto_hides(PanelId::Count)); + assert!(!state.edge_auto_hide(DockSide::Right)); + } + #[test] fn ensure_settings_drops_duplicates_and_clamps_the_shown_group() { let mut state = DockState::default(); From 77ddc59df3fe2631a47580626606eee72884d162 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:29:37 +0000 Subject: [PATCH 12/24] Dock: fix crash dragging a pallet that is alone in its group Dragging a pallet over its own group (e.g. the block palette, alone on the right edge) targeted joining that group. Joining first detached the pallet, which removed the now-empty group, then indexed it: an out-of-bounds panic, hit already by the live drop preview. A pallet alone in the group it would join now just stays put. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/update/dialog.rs | 25 +++++++++++++++++++++++++ src/ui/dock.rs | 11 +++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index dca344425..752f8ab2c 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -2111,6 +2111,31 @@ mod tests { ); } + #[test] + fn dock_dragging_a_lone_pallet_over_its_own_group_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(); + // The right edge's only group holds just the block palette; drag it + // around over itself. + let target = drag_panel( + &mut app, + PanelId::BlockPalette, + iced::Point::new(1500.0, 10.0), + iced::Point::new(1450.0, 600.0), + ); + assert_eq!( + target, + Some(DropTarget::Join { + side: DockSide::Right, + group: 0, + index: 1 + }) + ); + assert_eq!(app.dock, before); + } + #[test] fn dock_click_without_drag_changes_nothing() { let mut app = dock_app(); diff --git a/src/ui/dock.rs b/src/ui/dock.rs index c20513b3d..4d68739f4 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -557,6 +557,11 @@ impl DockState { if group >= self.groups(side).len() { return false; } + // A pallet alone in its group has nowhere to move within it (and + // detaching it would remove the group being joined). + if self.groups(side)[group].panels == [id] { + return self.show_group(side, group); + } let before = self.clone(); let width = self.group_width(side, group); let joining = !self.groups(side)[group].panels.contains(&id); @@ -1086,6 +1091,12 @@ mod tests { assert_eq!(state.left[0].panels[0], PanelId::BlockPalette); // Dropping it where it already is changes nothing. assert!(!state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1)); + // Nor does dropping a pallet that is alone in its group onto it. + state.dock(PanelId::Count, DockSide::Right, 0); + for index in 0..=1 { + assert!(!state.join_group(PanelId::Count, DockSide::Right, 0, index)); + } + assert_eq!(state.right[0].panels, vec![PanelId::Count]); } #[test] From 96b4479f589f06976e188b5c853d9d496ab12d4b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:46:27 +0000 Subject: [PATCH 13/24] Dock: option B icon strip with an edge-band group grip The icon strip follows the agreed "option B" concept: - Flat 40 px strip; groups are rows of their pallets' icons separated by hairline dividers, with no grip lines. The + button is a small round button under the last group. - The shown group is marked by a blue bar on its window-side edge and brighter icons. That 8 px edge band is the group grip: hovering it widens and lights the bar, tints the row, shows a grab cursor and a "Drag to move group" tooltip; dragging it moves the whole group. - Icons still drag single pallets: within their group or into another group (between its icons), or onto a divider to start a new group. Hovering an icon gives it a soft background and shows its name. - Drop feedback: a short accent line with end caps between icons means "join this group here" (the target group's bar lights up); a full-width line on a divider with a "+ New group" badge means "new group here". The dragged icon (or whole group) fades in place and a ghost follows the pointer. - The zone around a divider (a few px into the icons on either side) starts a new group; the lower half of a group's last icon still joins that group at the end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/mod.rs | 6 + src/app/update/dialog.rs | 54 +++++- src/app/view/mod.rs | 363 +++++++++++++++++++++++++++++++++------ src/ui/dock.rs | 104 +++++++---- 4 files changed, 438 insertions(+), 89 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index a8279c317..9f989d392 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -876,6 +876,10 @@ pub(super) struct OpenCADStudio { /// Group being dragged by its strip grip (side, slot index). While set, /// `dock_dragging` holds the group's shown panel for the preview. pub(crate) dock_dragging_group: Option<(crate::app::config::DockSide, usize)>, + /// Group whose edge band (group grip) the pointer is over. + pub(crate) dock_grip_hover: Option<(crate::app::config::DockSide, usize)>, + /// Pallet whose strip icon the pointer is over. + pub(crate) dock_icon_hover: 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, @@ -4318,6 +4322,8 @@ impl OpenCADStudio { dock_title_hover: None, dock_edge_menu: None, dock_dragging_group: None, + dock_grip_hover: None, + dock_icon_hover: None, xref_col_drag: None, xref_col_last: None, xref_split_drag: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 752f8ab2c..d394215e4 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -652,6 +652,14 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.save_config(); task } + DockMsg::GripHover(grip) => { + self.dock_grip_hover = grip; + iced::Task::none() + } + DockMsg::IconHover(id) => { + self.dock_icon_hover = id; + iced::Task::none() + } DockMsg::GroupGrab(side, gi) => { if let Some(&id) = self.dock_group_visible(side, gi).first() { self.dock_begin_drag(id); @@ -2232,10 +2240,15 @@ mod tests { app.dock.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1); let (layout, _) = app.dock_strip_layout(DockSide::Left); let top = layout[0].icons_top; - // Drag the Properties icon below the Blocks icon. + // Drag the Properties icon onto the lower half of the Blocks icon: + // below it, still in the group (the divider under it would start a + // new group). let _ = app.on_dock(DockMsg::IconPress(PanelId::Properties)); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, top + 10.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, top + 2.0 * STRIP_CELL_H))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new( + 18.0, + top + 2.0 * STRIP_CELL_H - 10.0, + ))); assert_eq!( app.dock_drag_target, Some(DropTarget::Join { @@ -2251,6 +2264,41 @@ mod tests { ); } + #[test] + fn dock_dragging_an_icon_into_another_group_lands_between_its_icons() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, DropTarget, PanelId, STRIP_CELL_H}; + let mut app = dock_app(); + app.show_browser = true; + app.dock.left = vec![ + DockGroup::stack(vec![PanelId::Properties, PanelId::Browser]), + DockGroup::single(PanelId::BlockPalette), + ]; + app.dock.right.clear(); + let (layout, _) = app.dock_strip_layout(DockSide::Left); + // Drag the Blocks icon (group 1) to between Properties and Browser. + let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(20.0, layout[1].icons_top + 10.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new( + 20.0, + layout[0].icons_top + STRIP_CELL_H + 2.0, + ))); + assert_eq!( + app.dock_drag_target, + Some(DropTarget::Join { + side: DockSide::Left, + group: 0, + index: 1 + }) + ); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!(app.dock.left.len(), 1, "the emptied group goes away"); + assert_eq!( + app.dock.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette, PanelId::Browser] + ); + } + #[test] fn dock_grip_drag_moves_a_whole_group() { use crate::app::config::DockSide; @@ -2264,7 +2312,7 @@ mod tests { app.dock.right.clear(); let (layout, plus_top) = app.dock_strip_layout(DockSide::Left); let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 0)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, layout[0].grip_top + 2.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, layout[0].top + 2.0))); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0))); let _ = app.on_dock(DockMsg::DragRelease); assert_eq!(app.dock.left[0].panels, vec![PanelId::BlockPalette]); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index c52b94f3f..eb069b62d 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2830,81 +2830,188 @@ impl OpenCADStudio { ) } - /// An edge's vertical icon strip: per group a grip bar (drag it to move - /// the group) over its open pallets' icons, the shown group framed on a - /// grey background, then a + button opening the pallet menu. Heights - /// follow `dock::strip_layout`, which drop targeting uses too. + /// An edge's vertical icon strip: per group its open pallets' icons in a + /// row ended by a hairline divider, then a + button opening the pallet + /// menu. The band along a group's window-side edge is its grip: it + /// carries the blue bar of the shown group, lights up while hovered (or + /// while it is a drop target) and drags the whole group; an icon shows its + /// group on click and drags just that pallet. Heights follow + /// `dock::strip_layout`, which drop targeting uses too. fn dock_icon_strip(&self, side: crate::app::config::DockSide) -> Element<'_, Message> { - use crate::ui::dock::{DockMsg, DOCK_STRIP_W, STRIP_CELL_H, STRIP_GAP, STRIP_GRIP_H}; + use crate::app::config::DockSide; + use crate::ui::dock::{ + DockMsg, DropTarget, DOCK_STRIP_W, STRIP_CELL_H, STRIP_DIVIDER_H, STRIP_GRIP_W, + STRIP_PAD, STRIP_PLUS_GAP, + }; let shown = self.dock_shown_group(side); + let dragging = self.dock_drag_target.is_some(); + let target_group = match self.dock_drag_target { + Some(DropTarget::Join { side: s, group, .. }) if s == side => Some(group), + _ => None, + }; + let tip_side = match side { + DockSide::Left => iced::widget::tooltip::Position::Right, + DockSide::Right => iced::widget::tooltip::Position::Left, + }; let mut items: Vec> = Vec::new(); for gi in self.dock_visible_groups(side) { - items.push(Space::new().height(STRIP_GAP).into()); - let grip = container( - container(Space::new()) - .width(Length::Fixed(18.0)) - .height(Length::Fixed(3.0)) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.strong.color, - )), + let is_shown = shown == Some(gi); + let lit = target_group == Some(gi) + || (!dragging && self.dock_grip_hover == Some((side, gi))); + let group_dragged = dragging && self.dock_dragging_group == Some((side, gi)); + let panels = self.dock_group_visible(side, gi); + let row_h = 2.0 * STRIP_PAD + panels.len() as f32 * STRIP_CELL_H; + + let mut icons: Vec> = vec![Space::new().height(STRIP_PAD).into()]; + for id in panels { + let faded = group_dragged + || (dragging + && self.dock_dragging_group.is_none() + && self.dock_dragging == Some(id)); + let glyph = if faded { + crate::ui::icons::themed_disabled(id.icon(), 20.0) + } else if is_shown { + crate::ui::icons::themed(id.icon(), 20.0) + } else { + crate::ui::icons::themed_secondary(id.icon(), 20.0) + }; + let hovered = !dragging && self.dock_icon_hover == Some(id); + let cell = container(glyph) + .center_x(Length::Fixed(STRIP_CELL_H)) + .center_y(Length::Fixed(STRIP_CELL_H - 2.0)) + .style(move |theme: &Theme| container::Style { + background: hovered + .then(|| Background::Color(theme.palette().background.weak.color)), border: Border { - radius: 1.5.into(), + radius: 5.0.into(), ..Default::default() }, ..Default::default() - }), - ) - .center_x(Length::Fixed(DOCK_STRIP_W)) - .center_y(Length::Fixed(STRIP_GRIP_H)); - let mut group_items: Vec> = vec![mouse_area(grip) - .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) - .interaction(iced::mouse::Interaction::Grab) - .into()]; - for id in self.dock_group_visible(side, gi) { + }); let cell = mouse_area( - container(crate::ui::icons::themed(id.icon(), 20.0)) + container(cell) .center_x(Length::Fixed(DOCK_STRIP_W)) .center_y(Length::Fixed(STRIP_CELL_H)), ) .on_press(Message::Dock(DockMsg::IconPress(id))) - .on_enter(Message::Dock(DockMsg::Hover(id))) + .on_enter(Message::Dock(DockMsg::IconHover(Some(id)))) + .on_exit(Message::Dock(DockMsg::IconHover(None))) .interaction(iced::mouse::Interaction::Pointer); - group_items.push( - iced::widget::tooltip( - cell, - text(id.title()).size(10), - match side { - crate::app::config::DockSide::Left => { - iced::widget::tooltip::Position::Right - } - crate::app::config::DockSide::Right => { - iced::widget::tooltip::Position::Left - } - }, - ) - .gap(4) - .into(), + icons.push( + iced::widget::tooltip(cell, text(id.title()).size(10), tip_side) + .gap(4) + .into(), ); } - let is_shown = shown == Some(gi); + + // The group grip: a band on the window side holding the edge bar. + let bar_w = if lit { 5.0 } else { 3.0 }; + let bar = container(Space::new().width(bar_w).height(Fill)).style(move |theme: &Theme| { + let palette = theme.palette(); + let (color, glow) = if lit { + (Some(palette.primary.weak.color), true) + } else if is_shown { + (Some(palette.primary.base.color), false) + } else { + (None, false) + }; + container::Style { + background: color.map(Background::Color), + border: Border { + radius: match side { + DockSide::Left => iced::border::Radius::default().right(3.0), + DockSide::Right => iced::border::Radius::default().left(3.0), + }, + ..Default::default() + }, + shadow: if glow { + iced::Shadow { + color: palette.primary.base.color.scale_alpha(0.55), + offset: iced::Vector::new(0.0, 0.0), + blur_radius: 8.0, + } + } else { + iced::Shadow::default() + }, + ..Default::default() + } + }); + let band = mouse_area( + container(bar) + .width(Length::Fixed(STRIP_GRIP_W)) + .height(Fill) + .padding(iced::Padding { + top: 5.0, + bottom: 5.0, + left: 0.0, + right: 0.0, + }) + .align_x(match side { + DockSide::Left => iced::alignment::Horizontal::Left, + DockSide::Right => iced::alignment::Horizontal::Right, + }), + ) + .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) + .on_enter(Message::Dock(DockMsg::GripHover(Some((side, gi))))) + .on_exit(Message::Dock(DockMsg::GripHover(None))) + .interaction(iced::mouse::Interaction::Grab); + let band = iced::widget::tooltip( + band, + text(t!("Drag to move group")).size(10), + tip_side, + ) + .gap(4); + let band = container(band) + .width(Fill) + .height(Fill) + .align_x(match side { + DockSide::Left => iced::alignment::Horizontal::Left, + DockSide::Right => iced::alignment::Horizontal::Right, + }); items.push( - container(column(group_items)) + container(stack![column(icons), band]) .width(Length::Fixed(DOCK_STRIP_W)) + .height(Length::Fixed(row_h)) .style(move |theme: &Theme| container::Style { - // The shown group sits on the grey background. - background: is_shown - .then(|| Background::Color(theme.palette().background.weak.color)), + background: lit.then(|| { + Background::Color(theme.palette().primary.weak.color.scale_alpha(0.12)) + }), + ..Default::default() + }) + .into(), + ); + items.push( + container(Space::new()) + .width(Fill) + .height(Length::Fixed(STRIP_DIVIDER_H)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.neutral.color, + )), ..Default::default() }) .into(), ); } - items.push(Space::new().height(STRIP_GAP).into()); + items.push(Space::new().height(STRIP_PLUS_GAP).into()); let plus = button(crate::ui::icons::themed_secondary(crate::ui::icons::PLUS, 14.0)) .on_press(Message::Dock(DockMsg::EdgeMenu(Some(side)))) - .style(button::subtle) - .padding([5, 8]); + .style(|theme: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => { + theme.palette().background.weak.color + } + _ => theme.palette().background.weakest.color, + })), + border: Border { + radius: 14.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .width(Length::Fixed(28.0)) + .height(Length::Fixed(28.0)) + .padding(7); items.push(container(plus).center_x(Length::Fixed(DOCK_STRIP_W)).into()); container(column(items)) .width(Length::Fixed(DOCK_STRIP_W)) @@ -3145,7 +3252,12 @@ impl OpenCADStudio { None } }; - if let Some((side, after)) = docked { + // A whole group shows only the strip feedback and its ghost; the + // column keeps showing what is there. + let group_drag = self.dock_dragging_group; + let docked = docked.map(|(side, after)| (side, group_drag.is_none().then_some(after))); + let marks_side = docked.as_ref().map(|(side, _)| *side); + if let Some((side, Some(after))) = docked { // Lay the group out as it will be after the drop. if let Some((_, gi)) = after.location(id) { let group = &after.groups(side)[gi]; @@ -3172,11 +3284,162 @@ impl OpenCADStudio { layers.push(place_at(dock_ghost(&[id], id, w, bottom - top), x, top)); } } + // On top: the strip's drop marks and a ghost of what is dragged + // following the pointer. + if let Some(side) = marks_side { + layers.extend(self.dock_strip_drop_marks(side, target)); + if let Some(p) = self.dock_drag_last { + let icons = match group_drag { + Some((s, g)) => self.dock_group_visible(s, g), + None => vec![id], + }; + layers.push(place_at(dock_icon_ghost(&icons), p.x - 16.0, p.y - 15.0)); + } + } iced::widget::Stack::with_children(layers) .width(Fill) .height(Fill) .into() } + + /// The icon strip's drop marks for `target` on `side`: a short line with + /// end caps between a group's icons (join that group there), or a + /// full-width line on a divider with a "+ New group" badge. + fn dock_strip_drop_marks( + &self, + side: crate::app::config::DockSide, + target: crate::ui::dock::DropTarget, + ) -> Vec> { + use crate::app::config::DockSide; + use crate::ui::dock::{DropTarget, DOCK_STRIP_W, STRIP_CELL_H}; + let (ww, _) = self.dock_workspace_size(); + let strip_x = match side { + DockSide::Left => 0.0, + DockSide::Right => ww - DOCK_STRIP_W, + }; + let (layout, _) = self.dock_strip_layout(side); + let mut marks: Vec> = Vec::new(); + match target { + DropTarget::Join { group, index, .. } => { + let Some(g) = layout.iter().find(|g| g.group == group) else { + return marks; + }; + // Icons above the insertion point, counted as the strip shows + // them (the dragged icon still sits in its old place). + let dragged = self.dock_dragging; + let above = self.dock.groups(side)[group].panels[..index] + .iter() + .filter(|p| self.dock_panel_visible(**p) || Some(**p) == dragged) + .count(); + let y = g.icons_top + above as f32 * STRIP_CELL_H; + marks.push(place_at(dock_insert_line(), strip_x + 7.0, y - 3.5)); + } + DropTarget::Edge { index, .. } => { + let y = layout + .iter() + .find(|g| g.group >= index) + .map(|g| g.top) + .or_else(|| layout.last().map(|g| g.bottom())) + .unwrap_or(0.0); + marks.push(place_at( + container(Space::new()) + .width(Length::Fixed(DOCK_STRIP_W)) + .height(Length::Fixed(4.0)) + .style(dock_drop_line_style) + .into(), + strip_x, + (y - 2.0).max(0.0), + )); + const BADGE_W: f32 = 96.0; + let badge = container(text(format!("+ {}", t!("New group"))).size(10)) + .width(Length::Fixed(BADGE_W)) + .center_x(Length::Fixed(BADGE_W)) + .padding([2, 6]) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().primary.base.color)), + text_color: Some(theme.palette().primary.base.text), + border: Border { + radius: 9.0.into(), + ..Default::default() + }, + ..Default::default() + }); + let badge_x = match side { + DockSide::Left => DOCK_STRIP_W + 6.0, + DockSide::Right => ww - DOCK_STRIP_W - 6.0 - BADGE_W, + }; + marks.push(place_at(badge.into(), badge_x, (y - 9.0).max(0.0))); + } + DropTarget::Float { .. } => {} + } + marks + } +} + +/// The glowing accent used by the strip's drop lines. +fn dock_drop_line_style(theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(theme.palette().primary.base.color)), + border: Border { + radius: 2.0.into(), + ..Default::default() + }, + shadow: iced::Shadow { + color: theme.palette().primary.base.color.scale_alpha(0.8), + offset: iced::Vector::new(0.0, 0.0), + blur_radius: 6.0, + }, + ..Default::default() + } +} + +/// The short insertion line with end caps shown between a group's icons. +fn dock_insert_line() -> Element<'static, Message> { + let cap = || { + container(Space::new()) + .width(Length::Fixed(3.0)) + .height(Length::Fixed(7.0)) + .style(dock_drop_line_style) + }; + row![ + cap(), + container(Space::new()) + .width(Length::Fixed(20.0)) + .height(Length::Fixed(3.0)) + .style(dock_drop_line_style), + cap(), + ] + .align_y(iced::Center) + .height(Length::Fixed(7.0)) + .into() +} + +/// The ghost following the pointer while pallets are dragged: their icons +/// in an accent-framed box. +fn dock_icon_ghost(icons: &[crate::ui::dock::PanelId]) -> Element<'static, Message> { + let glyphs: Vec> = icons + .iter() + .map(|id| { + container(crate::ui::icons::themed(id.icon(), 18.0)) + .center_x(Length::Fixed(32.0)) + .center_y(Length::Fixed(30.0)) + .into() + }) + .collect(); + container(column(glyphs)) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.primary.base.color.scale_alpha(0.22))), + border: Border { + color: palette.primary.base.color, + width: 1.5, + radius: 6.0.into(), + }, + ..Default::default() + } + }) + .into() } // ── Document tab bar ─────────────────────────────────────────────────────── diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 4d68739f4..7909afc58 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -51,8 +51,12 @@ pub enum DockMsg { /// Pallet menu pick: hide `panel` when it shows, else open it docked on /// the edge (as a new group unless it already has a group there). EdgeMenuToggle(DockSide, PanelId), - /// Begin dragging a whole group (slot) by its grip in the icon strip. + /// Begin dragging a whole group by its edge band in the icon strip. GroupGrab(DockSide, usize), + /// The pointer entered (`Some`) or left (`None`) a group's edge band. + GripHover(Option<(DockSide, usize)>), + /// The pointer entered (`Some`) or left (`None`) a pallet's strip icon. + IconHover(Option), /// Double-click on a docked title bar: float the panel. FloatOut(PanelId), /// Double-click on a floating title bar: dock the panel on that side. @@ -727,67 +731,85 @@ pub const DOCK_EDGE_ZONE: f32 = 48.0; /// Width of the grabbable divider between a docked slot and the viewport. pub const DOCK_DIVIDER_W: f32 = 5.0; /// Width of an edge's vertical icon strip (the groups' tabs). -pub const DOCK_STRIP_W: f32 = 36.0; +pub const DOCK_STRIP_W: f32 = 40.0; /// Height of one pallet icon in the strip. pub const STRIP_CELL_H: f32 = 32.0; -/// Height of the grip bar above each group's icons. -pub const STRIP_GRIP_H: f32 = 10.0; -/// Space above the first group and between groups in the strip. -pub const STRIP_GAP: f32 = 6.0; +/// Space above and below a group's icons, inside the group. +pub const STRIP_PAD: f32 = 6.0; +/// Hairline divider between groups. +pub const STRIP_DIVIDER_H: f32 = 1.0; +/// Space between the last group and the + button. +pub const STRIP_PLUS_GAP: f32 = 8.0; +/// Width of the band along a group's window-side edge that drags the whole +/// group (the icons drag single pallets). +pub const STRIP_GRIP_W: f32 = 8.0; +/// How far the "new group" zone around a divider reaches into the icons on +/// either side. +const STRIP_NEW_GROUP_SLOP: f32 = 4.0; /// Where one group sits in an edge's icon strip, top to bottom. #[derive(Debug, Clone, Copy, PartialEq)] pub struct StripGroup { - /// Slot index on the edge. + /// Group index on the edge. pub group: usize, - /// Top of the group's grip bar. - pub grip_top: f32, + /// Top of the group's row (just below the divider above it). + pub top: f32, /// Top of the group's first icon. pub icons_top: f32, - /// Number of icons (visible tabs). + /// Number of icons (open pallets). pub icons: usize, } -/// Lay out the icon strip for `groups` = (slot index, visible tab count), +impl StripGroup { + /// Bottom of the group's last icon. + pub fn icons_bottom(&self) -> f32 { + self.icons_top + self.icons as f32 * STRIP_CELL_H + } + + /// Bottom of the group's row (where its divider starts). + pub fn bottom(&self) -> f32 { + self.icons_bottom() + STRIP_PAD + } +} + +/// Lay out the icon strip for `groups` = (group index, open pallet count), /// returning each group's place and the top of the + button below them. pub fn strip_layout(groups: &[(usize, usize)]) -> (Vec, f32) { - let mut y = STRIP_GAP; + let mut y = 0.0; let placed = groups .iter() .map(|&(group, icons)| { - let grip_top = y; - let icons_top = grip_top + STRIP_GRIP_H; - y = icons_top + icons as f32 * STRIP_CELL_H + STRIP_GAP; - StripGroup { + let g = StripGroup { group, - grip_top, - icons_top, + top: y, + icons_top: y + STRIP_PAD, icons, - } + }; + y = g.bottom() + STRIP_DIVIDER_H; + g }) .collect(); - (placed, y) + (placed, y + STRIP_PLUS_GAP) } /// What a dragged pallet released at height `y` over the icon strip joins. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StripHit { - /// A new group before slot `group`; `None` = after the last group. + /// A new group before group `group`; `None` = after the last group. NewGroup(Option), - /// Group `group`, at position `index` among its visible icons. + /// Group `group`, at position `index` among its open pallets. Tab { group: usize, index: usize }, } -/// Classify a pointer at strip height `y`: over a group's grip (or the gap -/// above it) starts a new group there, over its icons joins it between the -/// nearest icons, below every group starts a new last group. +/// Classify a pointer at strip height `y`: on a divider (or the gap above +/// the first group) starts a new group there, over a group's icons joins it +/// between the nearest icons, below every group starts a new last group. pub fn strip_hit(y: f32, layout: &[StripGroup]) -> StripHit { for g in layout { - if y < g.icons_top { + if y < g.icons_top + STRIP_NEW_GROUP_SLOP { return StripHit::NewGroup(Some(g.group)); } - let bottom = g.icons_top + g.icons as f32 * STRIP_CELL_H + STRIP_GAP * 0.5; - if y < bottom { + if y < g.icons_bottom() - STRIP_NEW_GROUP_SLOP { let index = (((y - g.icons_top) / STRIP_CELL_H).round().max(0.0) as usize) .min(g.icons); return StripHit::Tab { @@ -1211,23 +1233,33 @@ mod tests { } #[test] - fn strip_hit_maps_grips_icons_and_the_end() { + fn strip_hit_maps_dividers_icons_and_the_end() { // Two groups: three icons, then one. let (layout, plus_top) = strip_layout(&[(0, 3), (2, 1)]); let g0 = layout[0]; let g1 = layout[1]; - assert_eq!(g0.grip_top, STRIP_GAP); - assert_eq!(g1.icons_top, g1.grip_top + STRIP_GRIP_H); - assert_eq!(plus_top, g1.icons_top + STRIP_CELL_H + STRIP_GAP); - // On the first grip: a new group before slot 0. - assert_eq!(strip_hit(g0.grip_top + 2.0, &layout), StripHit::NewGroup(Some(0))); + assert_eq!(g0.icons_top, STRIP_PAD); + assert_eq!(g1.top, g0.bottom() + STRIP_DIVIDER_H); + assert_eq!(plus_top, g1.bottom() + STRIP_DIVIDER_H + STRIP_PLUS_GAP); + // Above the first icon: a new group before group 0. + assert_eq!(strip_hit(2.0, &layout), StripHit::NewGroup(Some(0))); + // Top half of the first icon: into group 0 at the top. + assert_eq!( + strip_hit(g0.icons_top + 10.0, &layout), + StripHit::Tab { group: 0, index: 0 } + ); // Between the first and second icon of group 0. assert_eq!( strip_hit(g0.icons_top + STRIP_CELL_H + 4.0, &layout), StripHit::Tab { group: 0, index: 1 } ); - // On the second group's grip: a new group before slot 2. - assert_eq!(strip_hit(g1.grip_top + 1.0, &layout), StripHit::NewGroup(Some(2))); + // Lower half of the last icon: into group 0 at the end. + assert_eq!( + strip_hit(g0.icons_bottom() - 8.0, &layout), + StripHit::Tab { group: 0, index: 3 } + ); + // On the divider between the groups: a new group before group 2. + assert_eq!(strip_hit(g0.bottom(), &layout), StripHit::NewGroup(Some(2))); // Below everything: a new last group. assert_eq!(strip_hit(plus_top + 20.0, &layout), StripHit::NewGroup(None)); } From 6c00b8bc91b6c978c9dabf3238bb9cb6266c8eb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:03:15 +0000 Subject: [PATCH 14/24] Dock: fix double-click on title bars, centre the strip bar, close + menu - Double-clicking a docked or floating title bar did nothing. iced keeps a widget's state (including the first click of a double-click) only while the widget tree keeps its shape, and pressing a title bar starts a possible drag that wrapped the workspace in an extra pointer-capture layer, rebuilding the title bar and forgetting the first click. The workspace is now always inside the same stack and mouse area; the capture handlers are only attached while dragging. A test drives the real view through iced's UserInterface (keeping its cache between events, as the runtime does) and fails without this fix. - The shown group's edge bar was shorter than its row (the tooltip around the edge band sized it to its content), so it looked off centre. Band and bar now have explicit heights. - Picking a pallet in the + menu closes the menu. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/update/dialog.rs | 70 ++++++++++++++++++++++++++++++++++++++++ src/app/view/mod.rs | 51 +++++++++++++++-------------- 2 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index d394215e4..f799a40b1 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -649,6 +649,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } let task = self.dock_open_panel(id); self.dock.show_group_of(id); + // Adding a pallet is the end of the menu's job. + self.dock_edge_menu = None; self.save_config(); task } @@ -2438,6 +2440,8 @@ mod tests { assert_eq!(app.dock.left.len(), 2); assert_eq!(app.dock.left[1].panels, vec![PanelId::Browser]); assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); + assert_eq!(app.dock_edge_menu, None, "the menu closes after adding"); + let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left))); // Unchecking hides it but keeps its place. let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); assert!(!app.show_browser); @@ -2489,6 +2493,72 @@ mod tests { assert!(app.dock.float_rect(PanelId::Layers).is_some()); } + /// Drive the real view through iced's `UserInterface`: point at the + /// first text `label`, feed `events` one at a time, and between events + /// hand the published messages to the app and rebuild the view while + /// keeping the widget-tree cache, as the runtime does. + fn drive_view(app: &mut OpenCADStudio, label: &str, events: &[iced::Event]) { + use iced_test::core::{mouse, shell, widget, window, Size}; + use iced_test::runtime::user_interface::{Cache, UserInterface}; + use iced_test::selector::Bounded; + use iced_test::Selector; + let mut renderer = iced_test::futures::futures::executor::block_on( + ::new( + iced_test::core::renderer::Settings::default(), + None, + ), + ) + .expect("headless renderer"); + let size = Size::new(1600.0, 900.0); + let mut cache = Cache::default(); + let mut cursor = mouse::Cursor::Unavailable; + { + let mut ui = UserInterface::build(app.view_main(), size, cache, &mut renderer); + let mut find = Selector::find(label); + ui.operate(&renderer, &mut widget::operation::black_box(&mut find)); + if let widget::operation::Outcome::Some(Some(target)) = widget::Operation::finish(&find) { + if let Some(bounds) = target.visible_bounds() { + cursor = mouse::Cursor::Available(bounds.center()); + } + } + cache = ui.into_cache(); + } + assert!(matches!(cursor, mouse::Cursor::Available(_)), "{label} not found"); + for event in events { + let mut messages = Vec::new(); + { + let mut ui = UserInterface::build(app.view_main(), size, cache, &mut renderer); + let _ = ui.update( + &window::Headless, + &shell::Waker::noop(), + std::slice::from_ref(event), + cursor, + &mut renderer, + &mut messages, + ); + cache = ui.into_cache(); + } + for message in messages { + let _ = app.update(message); + } + } + } + + #[test] + fn docked_title_bar_double_click_floats_the_pallet_in_the_real_view() { + use crate::ui::dock::PanelId; + let mut app = dock_app(); + let press = iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)); + let release = + iced::Event::Mouse(iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left)); + // The block palette's title text sits in its docked title bar. + drive_view(&mut app, "Blocks", &[press.clone(), release.clone(), press, release]); + assert!( + app.dock.float_rect(PanelId::BlockPalette).is_some(), + "a double-click on a docked title bar floats the pallet" + ); + } + #[test] fn dock_double_clicks_float_and_dock_panels() { use crate::app::config::DockSide; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index eb069b62d..160c1683e 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1969,14 +1969,14 @@ bg={bg_ms:.1}ms n={view_count}" 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 { - iced::widget::Stack::with_children(layers) - .width(Fill) - .height(Fill) - .into() - }; + // Always a stack (and, below, always a mouse area), even with nothing + // on top: iced keeps a widget's state only while the tree keeps its + // shape, and a title bar's double-click needs its first click to + // survive the drag that pressing the bar starts. + let workspace: Element<'_, Message> = iced::widget::Stack::with_children(layers) + .width(Fill) + .height(Fill) + .into(); 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() @@ -1984,8 +1984,10 @@ bg={bg_ms:.1}ms n={view_count}" || self.layer_col_dragging || self.xref_col_drag.is_some() || self.xref_split_drag; - let workspace: Element<'_, Message> = if any_dragging || any_resizing || any_splitting { - mouse_area(workspace) + let capturing = any_dragging || any_resizing || any_splitting; + let mut capture = mouse_area(workspace); + if capturing { + capture = capture .on_move(move |p| Message::Dock(crate::ui::dock::DockMsg::DragMove(p))) .on_release(Message::Dock(crate::ui::dock::DockMsg::DragRelease)) .interaction(if let Some((_, from_left)) = self.dock_float_resizing { @@ -2000,11 +2002,9 @@ bg={bg_ms:.1}ms n={view_count}" iced::mouse::Interaction::ResizingVertically } else { iced::mouse::Interaction::Grabbing - }) - .into() - } else { - workspace - }; + }); + } + let workspace: Element<'_, Message> = capture.into(); let command_line = self.command_line.view( allow_autocomplete, dyn_capturing, @@ -2905,8 +2905,16 @@ impl OpenCADStudio { } // The group grip: a band on the window side holding the edge bar. + // Explicit heights: the tooltip around the band sizes it to its + // content, so a `Fill` bar would stop short of the row's bottom. let bar_w = if lit { 5.0 } else { 3.0 }; - let bar = container(Space::new().width(bar_w).height(Fill)).style(move |theme: &Theme| { + let bar_inset = 5.0; + let bar = container( + Space::new() + .width(bar_w) + .height(Length::Fixed(row_h - 2.0 * bar_inset)), + ) + .style(move |theme: &Theme| { let palette = theme.palette(); let (color, glow) = if lit { (Some(palette.primary.weak.color), true) @@ -2939,13 +2947,8 @@ impl OpenCADStudio { let band = mouse_area( container(bar) .width(Length::Fixed(STRIP_GRIP_W)) - .height(Fill) - .padding(iced::Padding { - top: 5.0, - bottom: 5.0, - left: 0.0, - right: 0.0, - }) + .height(Length::Fixed(row_h)) + .align_y(iced::alignment::Vertical::Center) .align_x(match side { DockSide::Left => iced::alignment::Horizontal::Left, DockSide::Right => iced::alignment::Horizontal::Right, @@ -2963,7 +2966,7 @@ impl OpenCADStudio { .gap(4); let band = container(band) .width(Fill) - .height(Fill) + .height(Length::Fixed(row_h)) .align_x(match side { DockSide::Left => iced::alignment::Horizontal::Left, DockSide::Right => iced::alignment::Horizontal::Right, From 4db24b3bbd6b2bb898220d893e1a10ff49b30c16 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:15:09 +0000 Subject: [PATCH 15/24] Dock: simplify drag, peek, width and open/close state No intended behaviour change except the fixes noted below. - One drag state: a single `DockDrag` (move pallet / group, width, splitter, floating size, Layer Manager column) replaces nine fields (dragging, dragging group, resizing, split, float resizing, origin, target, grab offset, layer column flag) that had to be kept in sync. - `dock_expanded` becomes `dock_peek` with one meaning: a pallet revealed although it auto-hides. `dock_reveal(id)` shows a pallet's group and peeks it; commands that open a pallet now use it, so opening a pallet also switches its edge to that pallet's group. - Each group stores its own width (falling back to its widest pallet's width until first sized), instead of every pallet's width being kept in sync. Pallet widths now only size floating pallets and new groups. - One `dock_set_open(id, open)` holds every pallet's open and close path. - Leftovers removed: the tab-group config format that never reached main, the multi-tab drag ghost header, the separate icon-hover message. Fixes: - Hovering a strip icon reveals an auto-hiding edge again (the icons had lost the hover message in the option B strip). Covered by a test that moves the pointer over the icon in the real view. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/commands/blocks.rs | 4 +- src/app/commands/count.rs | 6 +- src/app/commands/layerprops.rs | 2 +- src/app/commands/pdf_underlay.rs | 6 +- src/app/commands/sheet_set.rs | 6 +- src/app/mod.rs | 53 +-- src/app/node_graph.rs | 2 +- src/app/update/blocks_palette.rs | 2 +- src/app/update/dialog.rs | 791 +++++++++++++++++-------------- src/app/update/mod.rs | 6 +- src/app/view/mod.rs | 128 +++-- src/ui/dock.rs | 209 ++++++-- 12 files changed, 667 insertions(+), 548 deletions(-) diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 99a86a5c2..f41150459 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -921,7 +921,7 @@ impl OpenCADStudio { usize::MAX, ); } - self.dock_expanded = Some(crate::ui::dock::PanelId::ExternalReferences); + self.dock_reveal(crate::ui::dock::PanelId::ExternalReferences); self.refresh_xref_manager(); } } @@ -1766,7 +1766,7 @@ mod tests { let _ = app.run_command_line("BLOCKPALETTE"); assert!(app.show_block_palette); assert!( - app.dock_expanded == Some(crate::ui::dock::PanelId::BlockPalette), + app.dock_peek == Some(crate::ui::dock::PanelId::BlockPalette), "palette must open expanded, not as the collapsed bar" ); // BLOCKSPALETTE only opens; BLOCKSPALETTECLOSE closes. diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs index 5d54c42a0..ff3bf4854 100644 --- a/src/app/commands/count.rs +++ b/src/app/commands/count.rs @@ -239,9 +239,9 @@ impl OpenCADStudio { if !self.dock.is_placed(id) { self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); } - self.dock_expanded = Some(id); - } else if self.dock_expanded == Some(id) { - self.dock_expanded = None; + self.dock_reveal(id); + } else if self.dock_peek == Some(id) { + self.dock_peek = None; } } diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 828140bef..3c97b2ad3 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -687,7 +687,7 @@ impl OpenCADStudio { } // Open expanded so it is usable straight away; the pin // button still collapses it. - self.dock_expanded = Some(id); + self.dock_reveal(id); self.command_line .push_output(crate::t!("Browser opened.").as_ref()); } else { diff --git a/src/app/commands/pdf_underlay.rs b/src/app/commands/pdf_underlay.rs index ea01d01c4..67f584b14 100644 --- a/src/app/commands/pdf_underlay.rs +++ b/src/app/commands/pdf_underlay.rs @@ -377,12 +377,12 @@ impl OpenCADStudio { if !self.dock.is_placed(id) { self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); } - self.dock_expanded = Some(id); + self.dock_reveal(id); } "POINTCLOUDMANAGERCLOSE" => { self.pc_manager.show = false; - if self.dock_expanded == Some(crate::ui::dock::PanelId::PointCloudManager) { - self.dock_expanded = None; + if self.dock_peek == Some(crate::ui::dock::PanelId::PointCloudManager) { + self.dock_peek = None; } } "_PCUNCROP" =>self.edit_selected_point_clouds(i, "POINTCLOUDUNCROP", |data| data.croppings.clear()), diff --git a/src/app/commands/sheet_set.rs b/src/app/commands/sheet_set.rs index 372ab1554..73fa949fa 100644 --- a/src/app/commands/sheet_set.rs +++ b/src/app/commands/sheet_set.rs @@ -580,9 +580,9 @@ impl OpenCADStudio { if !self.dock.is_placed(id) { self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); } - self.dock_expanded = Some(id); - } else if self.dock_expanded == Some(id) { - self.dock_expanded = None; + self.dock_reveal(id); + } else if self.dock_peek == Some(id) { + self.dock_peek = None; } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 9f989d392..56c0e8628 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -845,41 +845,23 @@ pub(super) struct OpenCADStudio { pub(crate) bg_picker: Option, /// General edge-stack dock layout for the side panels. pub(crate) dock: crate::ui::dock::DockState, - /// Which panel is currently floated at full height (hovered, or a pinned - /// panel on top). - pub(crate) dock_expanded: Option, - /// Panel currently being dragged between sides / reordered. - pub(crate) dock_dragging: Option, - /// Panel currently being width-resized. - pub(crate) dock_resizing: Option, - /// Last pointer position during a drag / resize. + /// Pallet revealed although it auto-hides (its edge's group, or its + /// floating frame): hovered, being dragged, or just opened. Cleared when + /// the pointer leaves. + pub(crate) dock_peek: Option, + /// What the pointer is dragging in the dock (pallet, group, width, + /// splitter, floating size, Layer Manager column). + pub(crate) dock_drag: Option, + /// Pallet whose strip icon (or floating frame) the pointer is over. + pub(crate) dock_icon_hover: Option, + /// Last pointer position during a dock drag. pub(crate) dock_drag_last: Option, - /// 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 pallets of a group (side, group, - /// upper, lower). - pub(crate) dock_split_drag: Option<(crate::app::config::DockSide, usize, usize, usize)>, - /// Floating panel being resized from its corner grip, and whether from - /// the bottom-left corner. - pub(crate) dock_float_resizing: Option<(crate::ui::dock::PanelId, bool)>, /// Docked panel whose title bar the pointer is over (shows pin / close). pub(crate) dock_title_hover: Option, /// Edge whose icon-strip pallet menu (+) is open. pub(crate) dock_edge_menu: Option, - /// Group being dragged by its strip grip (side, slot index). While set, - /// `dock_dragging` holds the group's shown panel for the preview. - pub(crate) dock_dragging_group: Option<(crate::app::config::DockSide, usize)>, /// Group whose edge band (group grip) the pointer is over. pub(crate) dock_grip_hover: Option<(crate::app::config::DockSide, usize)>, - /// Pallet whose strip icon the pointer is over. - pub(crate) dock_icon_hover: 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, @@ -968,9 +950,6 @@ pub(super) struct OpenCADStudio { modal_drag_last: Option, /// True while the modal title bar is held (a drag is in progress). modal_dragging: bool, - /// Layer Manager: dragging the Name-column divider (width follows the - /// shared ModalDragMove flow). - layer_col_dragging: bool, /// Layer Manager Name column width in px, adjusted by the divider drag. layer_name_col_w: f32, /// How far the user has dragged the modal's corner resize grip from the @@ -4310,18 +4289,11 @@ impl OpenCADStudio { block_palette: Default::default(), xref_manager: Default::default(), dock: Default::default(), - dock_expanded: None, - dock_dragging: None, - dock_resizing: None, + dock_peek: None, + dock_drag: 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, dock_title_hover: None, dock_edge_menu: None, - dock_dragging_group: None, dock_grip_hover: None, dock_icon_hover: None, xref_col_drag: None, @@ -4360,7 +4332,6 @@ impl OpenCADStudio { modal_offset: iced::Vector::ZERO, modal_drag_last: None, modal_dragging: false, - layer_col_dragging: false, layer_name_col_w: 130.0, modal_resize: iced::Vector::ZERO, modal_content_size: None, diff --git a/src/app/node_graph.rs b/src/app/node_graph.rs index 7a7e9bd34..3af60f9d7 100644 --- a/src/app/node_graph.rs +++ b/src/app/node_graph.rs @@ -177,7 +177,7 @@ impl OpenCADStudio { self.dock .dock(id, crate::app::config::DockSide::Right, usize::MAX); } - self.dock_expanded = Some(id); + self.dock_reveal(id); return self.graph_run(i); } } diff --git a/src/app/update/blocks_palette.rs b/src/app/update/blocks_palette.rs index 003c97c78..8eb913c8e 100644 --- a/src/app/update/blocks_palette.rs +++ b/src/app/update/blocks_palette.rs @@ -124,7 +124,7 @@ impl OpenCADStudio { /// BLOCKSPALETTE: open (expanded) on `tab`, or on the tab it was left on. pub(crate) fn open_blocks_palette(&mut self, tab: Option) { self.show_block_palette = true; - self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette); + self.dock_reveal(crate::ui::dock::PanelId::BlockPalette); self.show_block_palette_tab(tab.unwrap_or(self.block_palette.tab)); } diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index f799a40b1..0ccc36442 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -545,12 +545,9 @@ 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, DropTarget, FloatPanel, PanelId}; + use crate::ui::dock::{DockDrag, DockMsg, DropTarget, FloatPanel}; match m { - DockMsg::DockGrab(id) => { - self.dock_begin_drag(id); - iced::Task::none() - } + DockMsg::DockGrab(id) => self.dock_begin(DockDrag::moving(id, None), Some(id)), DockMsg::IconPress(id) => { // An icon press shows the icon's group and may also start // dragging the pallet; a plain click never passes the drag @@ -558,56 +555,54 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven if self.dock.show_group_of(id) { self.save_config(); } - self.dock_begin_drag(id); - iced::Task::none() + self.dock_begin(DockDrag::moving(id, None), Some(id)); } - DockMsg::ResizeGrab(id) => { - self.dock_resizing = Some(id); - self.dock_dragging = None; - self.dock_drag_last = None; - self.dock_drag_target = None; - self.dock_expanded = Some(id); - iced::Task::none() + DockMsg::GroupGrab(side, gi) => { + if let Some(&id) = self.dock_group_visible(side, gi).first() { + self.dock_begin(DockDrag::moving(id, Some((side, gi))), Some(id)); + } } - DockMsg::SplitGrab(side, gi, upper, lower) => { - self.dock_split_drag = Some((side, gi, upper, lower)); - self.dock_dragging = None; - self.dock_drag_last = None; - self.dock_drag_target = None; - iced::Task::none() + DockMsg::ResizeGrab(id) => self.dock_begin(DockDrag::Width(id), Some(id)), + DockMsg::SplitGrab(side, group, upper, lower) => self.dock_begin( + DockDrag::Split { + side, + group, + upper, + lower, + }, + None, + ), + DockMsg::FloatResizeGrab(panel, from_left) => { + self.dock.raise_float(panel); + self.dock_begin(DockDrag::FloatSize { panel, from_left }, None); } DockMsg::SplitReset(side, gi) => { self.dock.reset_splits(side, gi); self.save_config(); - iced::Task::none() } - DockMsg::FloatResizeGrab(id, from_left) => { - self.dock.raise_float(id); - self.dock_float_resizing = Some((id, from_left)); - self.dock_dragging = None; - self.dock_drag_last = None; - self.dock_drag_target = None; - iced::Task::none() - } - DockMsg::TitleHover(id) => { - self.dock_title_hover = id; - iced::Task::none() + DockMsg::WidthReset(id) => { + if let Some((side, gi)) = self.dock.location(id) { + self.dock.reset_group_width(side, gi); + } else { + self.dock.reset_width(id); + } + self.save_config(); } + DockMsg::TitleHover(id) => self.dock_title_hover = id, + DockMsg::GripHover(grip) => self.dock_grip_hover = grip, + DockMsg::FloatRaise(id) => self.dock.raise_float(id), DockMsg::FloatOut(id) => { // Float beside the edge it was docked on, near the top. let (ww, _) = self.dock_workspace_size(); let (w, h) = self.dock_float_size(id); + let strip = crate::ui::dock::DOCK_STRIP_W; let x = match self.dock.location(id) { Some((DockSide::Right, _)) => { - ww - crate::ui::dock::DOCK_STRIP_W - - self.dock_column_width(DockSide::Right) - - w - - 60.0 + ww - strip - self.dock_column_width(DockSide::Right) - w - 60.0 } - _ => crate::ui::dock::DOCK_STRIP_W + self.dock_column_width(DockSide::Left) + 40.0, + _ => strip + self.dock_column_width(DockSide::Left) + 40.0, }; - self.dock_dragging = None; - self.dock_drag_target = None; + self.dock_drag = None; if self.dock.float(FloatPanel { id, x: x.max(0.0), @@ -617,328 +612,344 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven }) { self.save_config(); } - iced::Task::none() } DockMsg::DockTo(id, side) => { - self.dock_dragging = None; - self.dock_drag_target = None; + self.dock_drag = None; if self.dock.dock(id, side, usize::MAX) { - self.dock_expanded = Some(id); self.save_config(); } - iced::Task::none() - } - DockMsg::EdgeMenu(side) => { - self.dock_edge_menu = side; - iced::Task::none() } + DockMsg::EdgeMenu(side) => self.dock_edge_menu = side, DockMsg::EdgeMenuToggle(side, id) => { // Checked = open and docked on this edge: unchecking hides it. // Anything else (closed, floating, on the other edge) moves - // here and opens. + // here and opens, in its own group here if it has one, else + // as a new group; adding a pallet ends the menu's job. let here = self.dock.location(id).map(|(s, _)| s) == Some(side); - if here && self.dock_panel_visible(id) { - let task = self.on_dock(DockMsg::Close(id)); + if !here { + self.dock.dock(id, side, usize::MAX); + } else if self.dock_panel_visible(id) { + let task = self.dock_set_open(id, false); self.save_config(); return task; } - // A pallet opened from an edge's menu lands on that edge: in - // its own group there if it has one, else as a new group. - if self.dock.location(id).map(|(s, _)| s) != Some(side) { - self.dock.dock(id, side, usize::MAX); - } - let task = self.dock_open_panel(id); - self.dock.show_group_of(id); - // Adding a pallet is the end of the menu's job. self.dock_edge_menu = None; + let task = self.dock_set_open(id, true); self.save_config(); - task - } - DockMsg::GripHover(grip) => { - self.dock_grip_hover = grip; - iced::Task::none() - } - DockMsg::IconHover(id) => { - self.dock_icon_hover = id; - iced::Task::none() - } - DockMsg::GroupGrab(side, gi) => { - if let Some(&id) = self.dock_group_visible(side, gi).first() { - self.dock_begin_drag(id); - self.dock_dragging_group = Some((side, gi)); - } - iced::Task::none() - } - DockMsg::FloatRaise(id) => { - self.dock.raise_float(id); - iced::Task::none() - } - DockMsg::WidthReset(id) => { - self.dock.reset_width(id); - self.dock.set_group_width(id, self.dock.settings(id).width); - self.save_config(); - iced::Task::none() + return task; } DockMsg::AutoCollapseToggle(id) => { // Docked: auto-hide for the whole edge; floating: this pallet. - let on = self.dock.toggle_auto_hide(id); - self.dock_expanded = if on { None } else { Some(id) }; + if self.dock.toggle_auto_hide(id) { + self.dock_peek = None; + } self.save_config(); - iced::Task::none() - } - DockMsg::Close(id) => { - match id { - PanelId::BlockPalette => { - self.show_block_palette = false; - self.block_palette.placing = None; - } - PanelId::ExternalReferences => { - self.show_external_references = false; - } - PanelId::Browser => { - self.show_browser = false; - } - PanelId::NodeGraph => { - self.show_node_graph = false; - } - PanelId::PointCloudManager => { - self.pc_manager.show = false; - } - PanelId::Count => { - self.count_palette.show = false; - self.ribbon.set_count_palette(false); - } - PanelId::SheetSetManager => { - self.sheet_set.show = false; - self.ribbon.set_sheet_set(false); - } - PanelId::Properties => { - self.show_properties = false; - self.ribbon.set_properties(false); - } - PanelId::Layers => { - self.show_layers = false; - self.ribbon.deactivate_tool_if("LAYERS"); - } - } - if self.dock_expanded == Some(id) { - self.dock_expanded = None; - } - if self.dock_dragging == Some(id) { - self.dock_dragging = None; - } - if self.dock_resizing == Some(id) { - self.dock_resizing = None; - } - iced::Task::none() } + DockMsg::Close(id) => return self.dock_set_open(id, false), DockMsg::Hover(id) => { - // Ignored while dragging/resizing: the pointer is over the - // drag preview, not a rail, so a hover must not collapse the - // panel being dragged or repoint the resize target. + self.dock_icon_hover = id; // Only an auto-hiding edge (or floating pallet) reacts to - // hover: it flies the hovered group out. Otherwise groups - // switch on click. - if self.dock_dragging.is_none() - && self.dock_resizing.is_none() - && self.dock.auto_hides(id) - { - self.dock_expanded = Some(id); - self.dock.show_group_of(id); + // hover: it reveals the hovered pallet's group. Otherwise + // groups switch on click. Ignored mid-drag, when the pointer + // is over the preview rather than the strip. + if let Some(id) = id { + if self.dock_drag.is_none() && self.dock.auto_hides(id) { + self.dock_reveal(id); + } } - iced::Task::none() } DockMsg::HoverExit => { - if self.dock_dragging.is_none() && self.dock_resizing.is_none() { - if let Some(id) = self.dock_expanded { - if self.dock.auto_hides(id) { - self.dock_expanded = None; - } - } - } - iced::Task::none() - } - DockMsg::DragMove(point) => { - // NOTE: xref column drags intentionally do NOT ride this - // path: its points live in workspace space while the header - // 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 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 { - let target = self.dock_drop_target(point); - // A whole group only moves between group positions: - // it cannot join another group or float. - self.dock_drag_target = match (self.dock_dragging_group, target) { - (Some(_), DropTarget::Edge { .. }) | (None, _) => Some(target), - (Some(_), _) => None, - }; - } - } else if let Some((side, gi, 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_slot_spans(side) - .iter() - .map(|(i, _, _)| self.dock.groups(side)[gi].weights[*i]) - .sum(); - if avail > 0.0 && total > 0.0 { - let per_px = total / avail; - self.dock.shift_split( - side, - gi, - upper, - lower, - (point.y - last.y) * per_px, - crate::ui::dock::GROUP_MIN_H * per_px, - ); - } - } - } else if let Some((id, from_left)) = self.dock_float_resizing { - if let (Some(last), Some(f)) = (self.dock_drag_last, self.dock.float_rect(id)) { - let dx = point.x - last.x; - self.dock.resize_float( - id, - if from_left { f.w - dx } else { f.w + dx }, - f.h + point.y - last.y, - from_left, - ); - } - } else if self.layer_col_dragging { - if let Some(last) = self.dock_drag_last { - self.layer_name_col_w = - (self.layer_name_col_w + point.x - last.x).clamp(60.0, 640.0); - } - } else if let Some(id) = self.dock_resizing { - if let Some(last) = self.dock_drag_last { - let dx = point.x - last.x; - let delta = match self.dock.location(id) { - Some((DockSide::Left, _)) => dx, - _ => -dx, - }; - // The divider sizes the panel's group; every group - // keeps its own width. - let cur = match self.dock.location(id) { - Some((side, gi)) => self.dock_group_width(side, gi), - None => self.dock.settings(id).width, - } + delta; - self.dock.set_group_width(id, cur); - } - } - if self.dock_dragging.is_some() - || self.dock_resizing.is_some() - || self.dock_split_drag.is_some() - || self.dock_float_resizing.is_some() - || self.layer_col_dragging + if self.dock_drag.is_none() + && self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) { - self.dock_drag_last = Some(point); + self.dock_peek = None; } - iced::Task::none() } + DockMsg::DragMove(point) => self.dock_drag_move(point), DockMsg::DragRelease => { - let mut changed = self.dock_resizing.is_some() - || self.dock_split_drag.is_some() - || self.dock_float_resizing.is_some(); - if let (Some((side, gi)), Some(DropTarget::Edge { side: to, index })) = - (self.dock_dragging_group, self.dock_drag_target) - { - changed |= self.dock.move_group(side, gi, to, index); - } else 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::Join { side, group, index } => { - self.dock.join_group(id, side, group, index) - } - 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); + let mut changed = false; + match self.dock_drag.take() { + Some(DockDrag::Move { + group: Some((side, gi)), + target: Some(DropTarget::Edge { side: to, index }), + .. + }) => changed = self.dock.move_group(side, gi, to, index), + Some(DockDrag::Move { + panel, + group: None, + target: Some(target), + .. + }) => { + changed = match target { + DropTarget::Edge { side, index } => self.dock.dock(panel, side, index), + DropTarget::Join { side, group, index } => { + self.dock.join_group(panel, side, group, index) + } + DropTarget::Float { x, y } => { + let (w, h) = self.dock_float_size(panel); + self.dock.float(FloatPanel { + id: panel, + x, + y, + w, + h, + }) + } + }; + } + Some(DockDrag::Move { .. } | DockDrag::LayerColumn) | None => {} + Some(DockDrag::Width(_) | DockDrag::Split { .. } | DockDrag::FloatSize { .. }) => { + changed = true; } - changed |= moved; } if changed { self.save_config(); } - self.dock_dragging = None; - self.dock_dragging_group = 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; self.xref_split_drag = false; - self.layer_col_dragging = false; - iced::Task::none() } } - } - - /// Show panel `id` where the layout has it, running its usual open path - /// (refreshes, ribbon highlight) when it was closed. - fn dock_open_panel(&mut self, id: crate::ui::dock::PanelId) -> iced::Task { - use crate::ui::dock::PanelId; - if self.dock_panel_visible(id) { - return iced::Task::none(); - } - match id { - PanelId::Properties => { - self.show_properties = true; - self.ribbon.set_properties(true); - } - PanelId::BlockPalette => self.open_blocks_palette(None), - PanelId::ExternalReferences => { - self.show_external_references = true; - self.refresh_xref_manager(); - } - PanelId::Browser => self.show_browser = true, - PanelId::NodeGraph => { - return self.on_graph(crate::ui::node_graph::GraphMsg::Toggle); - } - PanelId::PointCloudManager => self.pc_manager.show = true, - PanelId::Count => self.set_count_palette(true), - PanelId::SheetSetManager => self.show_sheet_set_manager(true), - PanelId::Layers => { - self.sync_ribbon_layers(); - self.show_layers = true; - } - } - self.dock_expanded = Some(id); iced::Task::none() } - /// 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_dragging_group = None; - self.dock_edge_menu = None; - self.dock_resizing = None; - self.dock_split_drag = None; - self.dock_float_resizing = None; + /// Pointer motion while something in the dock is dragged. + fn dock_drag_move(&mut self, point: iced::Point) { + // NOTE: xref column drags intentionally do NOT ride this path: its + // points live in workspace space while the header 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. + use crate::app::config::DockSide; + use crate::ui::dock::{DockDrag, DropTarget}; + let last = self.dock_drag_last.replace(point); + let Some(drag) = self.dock_drag else { + return; + }; + match drag { + DockDrag::Move { + panel, + group, + origin, + target, + grab, + } => { + let origin = origin.unwrap_or(point); + let moved = (point.x - origin.x).hypot(point.y - origin.y); + let started = target.is_some() || moved >= DOCK_DRAG_THRESHOLD; + // Hold a floating panel where it was grabbed; a docked one by + // its title bar. + let grab = if target.is_none() && started { + match self.dock.float_rect(panel) { + Some(f) => iced::Vector::new(origin.x - f.x, origin.y - f.y), + None => iced::Vector::new( + (self.dock.width(panel, self.win_size.0) * 0.5).min(120.0), + 14.0, + ), + } + } else { + grab + }; + // Store the grab first: the drop target reads it. + self.dock_drag = Some(DockDrag::Move { + panel, + group, + origin: Some(origin), + target, + grab, + }); + if !started { + return; + } + let found = self.dock_drop_target(point); + // A whole group only moves between group positions: it + // cannot join another group or float. + let target = match (group, found) { + (Some(_), DropTarget::Edge { .. }) | (None, _) => Some(found), + (Some(_), _) => None, + }; + self.dock_drag = Some(DockDrag::Move { + panel, + group, + origin: Some(origin), + target, + grab, + }); + } + DockDrag::Split { + side, + group, + upper, + lower, + } => { + let Some(last) = last else { return }; + let (_, avail) = self.dock_workspace_size(); + let total: f32 = self + .dock_slot_spans(side) + .iter() + .map(|(i, _, _)| self.dock.groups(side)[group].weights[*i]) + .sum(); + if avail > 0.0 && total > 0.0 { + let per_px = total / avail; + self.dock.shift_split( + side, + group, + upper, + lower, + (point.y - last.y) * per_px, + crate::ui::dock::GROUP_MIN_H * per_px, + ); + } + } + DockDrag::FloatSize { panel, from_left } => { + let (Some(last), Some(f)) = (last, self.dock.float_rect(panel)) else { + return; + }; + let dx = point.x - last.x; + self.dock.resize_float( + panel, + if from_left { f.w - dx } else { f.w + dx }, + f.h + point.y - last.y, + from_left, + ); + } + DockDrag::LayerColumn => { + let Some(last) = last else { return }; + self.layer_name_col_w = + (self.layer_name_col_w + point.x - last.x).clamp(60.0, 640.0); + } + DockDrag::Width(id) => { + let Some(last) = last else { return }; + let dx = point.x - last.x; + match self.dock.location(id) { + // The divider sizes the panel's group. + Some((side, gi)) => { + let delta = if side == DockSide::Left { dx } else { -dx }; + let cur = self.dock_group_width(side, gi) + delta; + self.dock.set_group_width(side, gi, cur); + } + None => { + let cur = self.dock.settings(id).width + dx; + self.dock.set_width(id, cur); + } + } + } + } + } + + /// Start `drag`, peeking `peek` (a pallet being dragged or resized stays + /// shown on an auto-hiding edge). + fn dock_begin(&mut self, drag: crate::ui::dock::DockDrag, peek: Option) { + if let crate::ui::dock::DockDrag::Move { panel, .. } = drag { + self.dock.raise_float(panel); + } + self.dock_drag = Some(drag); self.dock_drag_last = None; - self.dock_drag_origin = None; - self.dock_drag_target = None; - self.dock_expanded = Some(id); + self.dock_edge_menu = None; + if peek.is_some() { + self.dock_peek = peek; + } + } + + /// Bring `id` into view: show its group on its edge and, where it + /// auto-hides, keep it revealed until the pointer leaves. + pub(crate) fn dock_reveal(&mut self, id: crate::ui::dock::PanelId) { + self.dock.show_group_of(id); + self.dock_peek = Some(id); + } + + /// Open or close pallet `id`, running its usual open path (refreshes, + /// ribbon highlight) or close path. An opened pallet is revealed where + /// the layout has it. + pub(crate) fn dock_set_open( + &mut self, + id: crate::ui::dock::PanelId, + open: bool, + ) -> iced::Task { + use crate::ui::dock::PanelId; + if open { + let task = if self.dock_panel_visible(id) { + iced::Task::none() + } else { + match id { + PanelId::Properties => { + self.show_properties = true; + self.ribbon.set_properties(true); + iced::Task::none() + } + PanelId::BlockPalette => { + self.open_blocks_palette(None); + iced::Task::none() + } + PanelId::ExternalReferences => { + self.show_external_references = true; + self.refresh_xref_manager(); + iced::Task::none() + } + PanelId::Browser => { + self.show_browser = true; + iced::Task::none() + } + PanelId::NodeGraph => self.on_graph(crate::ui::node_graph::GraphMsg::Toggle), + PanelId::PointCloudManager => { + self.pc_manager.show = true; + iced::Task::none() + } + PanelId::Count => { + self.set_count_palette(true); + iced::Task::none() + } + PanelId::SheetSetManager => { + self.show_sheet_set_manager(true); + iced::Task::none() + } + PanelId::Layers => { + self.sync_ribbon_layers(); + self.show_layers = true; + iced::Task::none() + } + } + }; + self.dock_reveal(id); + return task; + } + match id { + PanelId::BlockPalette => { + self.show_block_palette = false; + self.block_palette.placing = None; + } + PanelId::ExternalReferences => self.show_external_references = false, + PanelId::Browser => self.show_browser = false, + PanelId::NodeGraph => self.show_node_graph = false, + PanelId::PointCloudManager => self.pc_manager.show = false, + PanelId::Count => { + self.count_palette.show = false; + self.ribbon.set_count_palette(false); + } + PanelId::SheetSetManager => { + self.sheet_set.show = false; + self.ribbon.set_sheet_set(false); + } + PanelId::Properties => { + self.show_properties = false; + self.ribbon.set_properties(false); + } + PanelId::Layers => { + self.show_layers = false; + self.ribbon.deactivate_tool_if("LAYERS"); + } + } + if self.dock_peek == Some(id) { + self.dock_peek = None; + } + if self + .dock_drag + .is_some_and(|d| d.movement().is_some_and(|(p, _, _)| p == id) || d == crate::ui::dock::DockDrag::Width(id)) + { + self.dock_drag = None; + } + iced::Task::none() } /// Whether `id` is currently rendered (not closed, not on the start screen / @@ -1015,18 +1026,13 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven }; !self.dock.edge_auto_hide(side) || self - .dock_expanded + .dock_peek .is_some_and(|e| self.dock_group_visible(side, gi).contains(&e)) } - /// Width of group `gi` on `side`: its pallets share the widest one's - /// width. + /// On-screen width of group `gi` on `side`. pub(crate) fn dock_group_width(&self, side: crate::app::config::DockSide, gi: usize) -> f32 { - self.dock.groups(side)[gi] - .panels - .iter() - .map(|id| self.dock.width(*id, self.win_size.0)) - .fold(0.0, f32::max) + self.dock.group_width_px(side, gi, self.win_size.0) } /// Width of the pallet column beside `side`'s icon strip: the shown @@ -1149,8 +1155,12 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } } } - 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)); + let grab = match self.dock_drag { + Some(crate::ui::dock::DockDrag::Move { grab, .. }) => grab, + _ => iced::Vector::new(0.0, 0.0), + }; + let x = (p.x - grab.x).clamp(0.0, (ww - 60.0).max(0.0)); + let y = (p.y - grab.y).clamp(0.0, (wh - 30.0).max(0.0)); DropTarget::Float { x, y } } @@ -2051,6 +2061,11 @@ mod tests { app } + /// Where the current dock drag would land, if it has started. + fn drag_target(app: &OpenCADStudio) -> Option { + app.dock_drag.and_then(|d| d.movement()).and_then(|(_, _, target)| target) + } + /// Press `id`'s title bar at `from`, drag to `to`, and return the target /// shown just before release. fn drag_panel( @@ -2063,7 +2078,7 @@ mod tests { 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 target = drag_target(app); let _ = app.on_dock(DockMsg::DragRelease); target } @@ -2252,7 +2267,7 @@ mod tests { top + 2.0 * STRIP_CELL_H - 10.0, ))); assert_eq!( - app.dock_drag_target, + drag_target(&app), Some(DropTarget::Join { side: DockSide::Left, group: 0, @@ -2286,7 +2301,7 @@ mod tests { layout[0].icons_top + STRIP_CELL_H + 2.0, ))); assert_eq!( - app.dock_drag_target, + drag_target(&app), Some(DropTarget::Join { side: DockSide::Left, group: 0, @@ -2327,7 +2342,7 @@ mod tests { let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 1)); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, 300.0))); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0))); - assert_eq!(app.dock_drag_target, None); + assert_eq!(drag_target(&app), None); let _ = app.on_dock(DockMsg::DragRelease); assert_eq!(app.dock, before); } @@ -2382,7 +2397,7 @@ mod tests { assert!(!app.dock.edge_auto_hide(DockSide::Right)); assert!(!app.dock_edge_expanded(DockSide::Left)); // Hovering an icon flies its group out. - let _ = app.on_dock(DockMsg::Hover(PanelId::Browser)); + let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Browser))); assert!(app.dock_edge_expanded(DockSide::Left)); assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); let _ = app.on_dock(DockMsg::HoverExit); @@ -2396,7 +2411,7 @@ mod tests { let mut app = dock_app(); app.show_browser = true; app.dock.left.push(DockGroup::single(PanelId::Browser)); - let _ = app.on_dock(DockMsg::Hover(PanelId::Browser)); + let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Browser))); assert_eq!(app.dock_shown_group(DockSide::Left), Some(0)); assert!(app.dock_edge_expanded(DockSide::Left)); } @@ -2471,11 +2486,11 @@ mod tests { let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(300.0, 100.0))); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(330.0, 100.0))); let _ = app.on_dock(DockMsg::DragRelease); - // The group was 260 wide (its widest pallet); both pallets take 290. - assert_eq!(app.dock.settings(PanelId::Properties).width, 290.0); - assert_eq!(app.dock.settings(PanelId::BlockPalette).width, 290.0); - assert_eq!(app.dock.settings(PanelId::Count).width, 280.0); + // The group was 260 wide (its widest pallet); it is now 290, while + // the other group and the pallets' own widths are untouched. assert_eq!(app.dock_group_width(DockSide::Left, 0), 290.0); + assert_eq!(app.dock_group_width(DockSide::Left, 1), 280.0); + assert_eq!(app.dock.settings(PanelId::Properties).width, 250.0); } #[test] @@ -2493,41 +2508,53 @@ mod tests { assert!(app.dock.float_rect(PanelId::Layers).is_some()); } - /// Drive the real view through iced's `UserInterface`: point at the - /// first text `label`, feed `events` one at a time, and between events - /// hand the published messages to the app and rebuild the view while - /// keeping the widget-tree cache, as the runtime does. - fn drive_view(app: &mut OpenCADStudio, label: &str, events: &[iced::Event]) { - use iced_test::core::{mouse, shell, widget, window, Size}; - use iced_test::runtime::user_interface::{Cache, UserInterface}; - use iced_test::selector::Bounded; - use iced_test::Selector; - let mut renderer = iced_test::futures::futures::executor::block_on( + /// A headless renderer for driving the real view. + fn headless_renderer() -> iced::Renderer { + iced_test::futures::futures::executor::block_on( ::new( iced_test::core::renderer::Settings::default(), None, ), ) - .expect("headless renderer"); - let size = Size::new(1600.0, 900.0); - let mut cache = Cache::default(); - let mut cursor = mouse::Cursor::Unavailable; - { - let mut ui = UserInterface::build(app.view_main(), size, cache, &mut renderer); - let mut find = Selector::find(label); - ui.operate(&renderer, &mut widget::operation::black_box(&mut find)); - if let widget::operation::Outcome::Some(Some(target)) = widget::Operation::finish(&find) { - if let Some(bounds) = target.visible_bounds() { - cursor = mouse::Cursor::Available(bounds.center()); - } - } - cache = ui.into_cache(); + .expect("headless renderer") + } + + /// Size the real view is laid out at. + const VIEW_SIZE: iced::Size = iced::Size::new(1600.0, 900.0); + + /// Centre of the first text `label` in the real view. + fn find_in_view(app: &OpenCADStudio, label: &str) -> iced::Point { + use iced_test::core::widget; + use iced_test::runtime::user_interface::{Cache, UserInterface}; + use iced_test::selector::Bounded; + use iced_test::Selector; + let mut renderer = headless_renderer(); + let mut ui = UserInterface::build(app.view_main(), VIEW_SIZE, Cache::default(), &mut renderer); + let mut find = Selector::find(label); + ui.operate(&renderer, &mut widget::operation::black_box(&mut find)); + match widget::Operation::finish(&find) { + widget::operation::Outcome::Some(Some(target)) => target + .visible_bounds() + .expect("visible") + .center(), + _ => panic!("{label} not found"), } - assert!(matches!(cursor, mouse::Cursor::Available(_)), "{label} not found"); + } + + /// Drive the real view through iced's `UserInterface` with the pointer at + /// `at`: feed `events` one at a time, and between events hand the + /// published messages to the app and rebuild the view while keeping the + /// widget-tree cache, as the runtime does. + fn drive_view(app: &mut OpenCADStudio, at: iced::Point, events: &[iced::Event]) { + use iced_test::core::{mouse, shell, window}; + use iced_test::runtime::user_interface::{Cache, UserInterface}; + let mut renderer = headless_renderer(); + let mut cache = Cache::default(); + let cursor = mouse::Cursor::Available(at); for event in events { let mut messages = Vec::new(); { - let mut ui = UserInterface::build(app.view_main(), size, cache, &mut renderer); + let mut ui = UserInterface::build(app.view_main(), VIEW_SIZE, cache, &mut renderer); let _ = ui.update( &window::Headless, &shell::Waker::noop(), @@ -2552,13 +2579,42 @@ mod tests { let release = iced::Event::Mouse(iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left)); // The block palette's title text sits in its docked title bar. - drive_view(&mut app, "Blocks", &[press.clone(), release.clone(), press, release]); + let title = find_in_view(&app, "Blocks"); + drive_view(&mut app, title, &[press.clone(), release.clone(), press, release]); assert!( app.dock.float_rect(PanelId::BlockPalette).is_some(), "a double-click on a docked title bar floats the pallet" ); } + #[test] + fn hovering_a_strip_icon_reveals_an_auto_hiding_edge_in_the_real_view() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId, STRIP_CELL_H, STRIP_PAD}; + let mut app = dock_app(); + // Mirror the layout onto the left so the block palette's title marks + // where the workspace starts, then auto-hide the right edge. + let title = find_in_view(&app, "Blocks"); + let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::BlockPalette)); + assert!(!app.dock_edge_expanded(DockSide::Right)); + // The title bar text sits ~13 px below the workspace top; the strip's + // first icon is centred STRIP_PAD + half a cell below it. + let workspace_top = title.y - 13.0; + let icon = iced::Point::new( + VIEW_SIZE.width - crate::ui::dock::DOCK_STRIP_W / 2.0, + workspace_top + STRIP_PAD + STRIP_CELL_H / 2.0, + ); + drive_view( + &mut app, + icon, + &[iced::Event::Mouse(iced::mouse::Event::CursorMoved { position: icon })], + ); + assert!( + app.dock_edge_expanded(DockSide::Right), + "hovering the icon reveals the auto-hiding edge" + ); + } + #[test] fn dock_double_clicks_float_and_dock_panels() { use crate::app::config::DockSide; @@ -2575,11 +2631,12 @@ mod tests { #[test] fn blockpalette_width_reset() { - let mut app = fresh(); + use crate::app::config::DockSide; + let mut app = dock_app(); let id = crate::ui::dock::PanelId::BlockPalette; - app.dock.set_width(id, 500.0); + app.dock.set_group_width(DockSide::Right, 0, 500.0); let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id)); - assert_eq!(app.dock.settings(id).width, 260.0); + assert_eq!(app.dock.group_width(DockSide::Right, 0), 260.0); } #[test] diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 4f39e772c..2da46524e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2573,7 +2573,7 @@ impl OpenCADStudio { self.save_config(); } self.dock.raise_float(PanelId::Layers); - self.dock_expanded = Some(PanelId::Layers); + self.dock_reveal(PanelId::Layers); Task::none() } @@ -2585,7 +2585,7 @@ impl OpenCADStudio { if !self.dock.is_placed(PanelId::ExternalReferences) { self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX); } - self.dock_expanded = Some(PanelId::ExternalReferences); + self.dock_reveal(PanelId::ExternalReferences); self.refresh_xref_manager(); } Task::none() @@ -9043,7 +9043,7 @@ impl OpenCADStudio { Message::LayerNameColGrab => { // Start a Name-column divider drag; the dock's pointer capture // feeds it (see `on_dock`). - self.layer_col_dragging = true; + self.dock_drag = Some(crate::ui::dock::DockDrag::LayerColumn); self.dock_drag_last = None; Task::none() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 160c1683e..0b1daa9f9 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1931,7 +1931,10 @@ bg={bg_ms:.1}ms n={view_count}" let mut layers: Vec> = vec![workspace]; for f in &self.dock.floating { // Once a drag is under way only its preview shows the panel. - let dragged = self.dock_dragging == Some(f.id) && self.dock_drag_target.is_some(); + let dragged = self + .dock_drag + .and_then(|d| d.movement()) + .is_some_and(|(panel, _, target)| panel == f.id && target.is_some()); if self.dock_panel_visible(f.id) && !dragged { layers.push(self.floating_panel(*f, tab)); } @@ -1966,7 +1969,7 @@ bg={bg_ms:.1}ms n={view_count}" } } // While a panel is dragged, preview where it lands. - if let (Some(id), Some(target)) = (self.dock_dragging, self.dock_drag_target) { + if let Some((id, _, Some(target))) = self.dock_drag.and_then(|d| d.movement()) { layers.push(self.dock_drop_preview(id, target)); } // Always a stack (and, below, always a mouse area), even with nothing @@ -1977,31 +1980,26 @@ bg={bg_ms:.1}ms n={view_count}" .width(Fill) .height(Fill) .into(); - 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.layer_col_dragging + use crate::ui::dock::DockDrag; + let capturing = self.dock_drag.is_some() || self.xref_col_drag.is_some() || self.xref_split_drag; - let capturing = any_dragging || any_resizing || any_splitting; let mut capture = mouse_area(workspace); if capturing { capture = capture .on_move(move |p| Message::Dock(crate::ui::dock::DockMsg::DragMove(p))) .on_release(Message::Dock(crate::ui::dock::DockMsg::DragRelease)) - .interaction(if let Some((_, from_left)) = self.dock_float_resizing { - if from_left { + .interaction(match self.dock_drag { + Some(DockDrag::FloatSize { from_left: true, .. }) => { iced::mouse::Interaction::ResizingDiagonallyUp - } else { + } + Some(DockDrag::FloatSize { from_left: false, .. }) => { 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 + Some(DockDrag::Split { .. }) => iced::mouse::Interaction::ResizingVertically, + Some(DockDrag::Move { .. }) => iced::mouse::Interaction::Grabbing, + // Widths, the Layer Manager column and the xref drags. + _ => iced::mouse::Interaction::ResizingHorizontally, }); } let workspace: Element<'_, Message> = capture.into(); @@ -2844,8 +2842,10 @@ impl OpenCADStudio { STRIP_PAD, STRIP_PLUS_GAP, }; let shown = self.dock_shown_group(side); - let dragging = self.dock_drag_target.is_some(); - let target_group = match self.dock_drag_target { + let movement = self.dock_drag.and_then(|d| d.movement()); + let move_target = movement.and_then(|(_, _, target)| target); + let dragging = move_target.is_some(); + let target_group = match move_target { Some(DropTarget::Join { side: s, group, .. }) if s == side => Some(group), _ => None, }; @@ -2858,7 +2858,8 @@ impl OpenCADStudio { let is_shown = shown == Some(gi); let lit = target_group == Some(gi) || (!dragging && self.dock_grip_hover == Some((side, gi))); - let group_dragged = dragging && self.dock_dragging_group == Some((side, gi)); + let group_dragged = + dragging && movement.is_some_and(|(_, group, _)| group == Some((side, gi))); let panels = self.dock_group_visible(side, gi); let row_h = 2.0 * STRIP_PAD + panels.len() as f32 * STRIP_CELL_H; @@ -2866,8 +2867,7 @@ impl OpenCADStudio { for id in panels { let faded = group_dragged || (dragging - && self.dock_dragging_group.is_none() - && self.dock_dragging == Some(id)); + && movement.is_some_and(|(panel, group, _)| group.is_none() && panel == id)); let glyph = if faded { crate::ui::icons::themed_disabled(id.icon(), 20.0) } else if is_shown { @@ -2894,8 +2894,8 @@ impl OpenCADStudio { .center_y(Length::Fixed(STRIP_CELL_H)), ) .on_press(Message::Dock(DockMsg::IconPress(id))) - .on_enter(Message::Dock(DockMsg::IconHover(Some(id)))) - .on_exit(Message::Dock(DockMsg::IconHover(None))) + .on_enter(Message::Dock(DockMsg::Hover(Some(id)))) + .on_exit(Message::Dock(DockMsg::Hover(None))) .interaction(iced::mouse::Interaction::Pointer); icons.push( iced::widget::tooltip(cell, text(id.title()).size(10), tip_side) @@ -3120,11 +3120,13 @@ impl OpenCADStudio { let (ww, wh) = self.dock_workspace_size(); // The bar faces the nearer workspace edge; while resizing, it stays // opposite the grip being dragged so it cannot flip mid-drag. - let bar_left = match self.dock_float_resizing { - Some((id, from_left)) if id == f.id => !from_left, + let bar_left = match self.dock_drag { + Some(crate::ui::dock::DockDrag::FloatSize { panel, from_left }) if panel == f.id => { + !from_left + } _ => f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5, }; - let hidden = self.dock.auto_collapse(f.id) && self.dock_expanded != Some(f.id); + let hidden = self.dock.auto_collapse(f.id) && self.dock_peek != Some(f.id); let bar = floating_title_bar(f.id, self.dock.auto_collapse(f.id), bar_left); let content: Element<'_, Message> = if hidden { bar @@ -3190,7 +3192,7 @@ impl OpenCADStudio { // 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))) - .on_enter(Message::Dock(DockMsg::Hover(f.id))) + .on_enter(Message::Dock(DockMsg::Hover(Some(f.id)))) .on_exit(Message::Dock(DockMsg::HoverExit)) .interaction(iced::mouse::Interaction::Idle); // A hidden panel keeps its title bar where it sits when shown. @@ -3248,7 +3250,7 @@ impl OpenCADStudio { DropTarget::Float { x, y } => { let (w, h) = self.dock_float_size(id); layers.push(place_at( - dock_ghost(&[id], id, w + DOCK_FLOAT_BAR_W, h), + dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), x, y, )); @@ -3257,7 +3259,7 @@ impl OpenCADStudio { }; // A whole group shows only the strip feedback and its ghost; the // column keeps showing what is there. - let group_drag = self.dock_dragging_group; + let group_drag = self.dock_drag.and_then(|d| d.movement()).and_then(|(_, g, _)| g); let docked = docked.map(|(side, after)| (side, group_drag.is_none().then_some(after))); let marks_side = docked.as_ref().map(|(side, _)| *side); if let Some((side, Some(after))) = docked { @@ -3284,7 +3286,7 @@ impl OpenCADStudio { DockSide::Right => (ww - w - strip).max(0.0), }; layers.push(tint(side, w)); - layers.push(place_at(dock_ghost(&[id], id, w, bottom - top), x, top)); + layers.push(place_at(dock_ghost(id, w, bottom - top), x, top)); } } // On top: the strip's drop marks and a ghost of what is dragged @@ -3329,7 +3331,7 @@ impl OpenCADStudio { }; // Icons above the insertion point, counted as the strip shows // them (the dragged icon still sits in its old place). - let dragged = self.dock_dragging; + let dragged = self.dock_drag.and_then(|d| d.movement()).map(|(p, _, _)| p); let above = self.dock.groups(side)[group].panels[..index] .iter() .filter(|p| self.dock_panel_visible(**p) || Some(**p) == dragged) @@ -3992,47 +3994,27 @@ fn dock_splitter( .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()]) +/// The blue drop ghost: a `w`×`h` outline of where pallet `id` lands, with +/// its name as the header. +fn dock_ghost(id: crate::ui::dock::PanelId, w: f32, h: f32) -> Element<'static, Message> { + let header = 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(|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() + } + }); + container(column![header, Space::new()]) .width(Length::Fixed(w.max(1.0))) .height(Length::Fixed(h.max(1.0))) .clip(true) diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 7909afc58..480c2c8dc 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -1,12 +1,12 @@ -//! General edge-stack docking for side panels. +//! Docking for side panels (pallets). //! -//! Any number of dockable panels (Properties, the block palette, future -//! 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. +//! Pallets (Properties, the block palette, the Layer Manager, …) dock on the +//! left or right edge of the drawing view, or float over it. Each edge holds +//! an ordered list of groups and shows one group at a time; a group's pallets +//! stack top to bottom, each with an adjustable share of the height, and the +//! group has its own width. An icon strip along the edge lists every group. +//! This module owns the persisted layout and the pure geometry used to draw +//! and hit-test it; the app owns the transient drag / hover state. use crate::app::config::DockSide; use serde::{Deserialize, Serialize}; @@ -26,8 +26,10 @@ pub enum DockMsg { AutoCollapseToggle(PanelId), /// Close / hide `panel`. Close(PanelId), - /// The pointer is over `panel`, raising it to full height. - Hover(PanelId), + /// The pointer entered (`Some`) or left (`None`) a pallet's strip icon or + /// floating frame: highlights the icon and, where the pallet auto-hides, + /// reveals it. + Hover(Option), /// Pointer moved while a panel is dragging or resizing. DragMove(iced::Point), /// Pointer released after a drag / resize. @@ -55,8 +57,7 @@ pub enum DockMsg { GroupGrab(DockSide, usize), /// The pointer entered (`Some`) or left (`None`) a group's edge band. GripHover(Option<(DockSide, usize)>), - /// The pointer entered (`Some`) or left (`None`) a pallet's strip icon. - IconHover(Option), + /// Double-click on a docked title bar: float the panel. FloatOut(PanelId), /// Double-click on a floating title bar: dock the panel on that side. @@ -192,19 +193,24 @@ pub struct DockGroup { pub panels: Vec, /// Relative height of each pallet, parallel to `panels`. pub weights: Vec, + /// The group's docked width; 0 until first sized, meaning "the widest + /// of its pallets' own widths" (see [`DockState::group_width`]). + #[serde(default)] + pub width: f32, } impl DockGroup { pub fn single(id: PanelId) -> Self { - Self { - panels: vec![id], - weights: vec![1.0], - } + Self::stack(vec![id]) } pub fn stack(panels: Vec) -> Self { let weights = vec![1.0; panels.len()]; - Self { panels, weights } + Self { + panels, + weights, + width: 0.0, + } } /// Keep one sane weight per pallet (configs edited by hand, or written @@ -242,8 +248,7 @@ impl DockGroup { } /// On-disk forms of one entry of an edge list. Configs from before groups -/// stored each stacked pallet as a bare id; this branch's earlier builds -/// stored tab groups. +/// stored each stacked pallet as a bare id. #[derive(Deserialize)] #[serde(untagged)] enum GroupRepr { @@ -252,8 +257,9 @@ enum GroupRepr { panels: Vec, #[serde(default)] weights: Vec, + #[serde(default)] + width: f32, }, - Tabs { tabs: Vec }, } /// Read an edge list. Bare ids were pallets stacked on the edge, all shown @@ -268,8 +274,15 @@ where for repr in reprs { match repr { GroupRepr::Single(id) => legacy.push(id), - GroupRepr::Stack { panels, weights } => groups.push(DockGroup { panels, weights }), - GroupRepr::Tabs { tabs } => groups.push(DockGroup::stack(tabs)), + GroupRepr::Stack { + panels, + weights, + width, + } => groups.push(DockGroup { + panels, + weights, + width, + }), } } if !legacy.is_empty() { @@ -319,8 +332,8 @@ pub struct DockState { pub auto_hide: (bool, bool), /// Floating panels, back → front. pub floating: Vec, - /// Per-panel width / auto-collapse settings, keyed by `PanelId`. A - /// group's pallets share one width, so each group keeps its own. + /// Per-panel settings, keyed by `PanelId`: the width a pallet floats at + /// (and starts a new group with) and its floating auto-hide. pub panels: BTreeMap, } @@ -495,27 +508,53 @@ impl DockState { entry.auto_collapse = on; } - /// The saved width a group's pallets share: the widest of them, so a - /// group whose pallets were sized apart renders at one width. + /// Group `group`'s docked width: its own once sized, else the widest of + /// its pallets' own widths. pub fn group_width(&self, side: DockSide, group: usize) -> f32 { - self.groups(side)[group] - .panels + let g = &self.groups(side)[group]; + if g.width > 0.0 { + return g.width; + } + g.panels .iter() .map(|id| self.settings(*id).width) .fold(DOCK_MIN_W, f32::max) } - /// Set the width of `id` and of every other pallet in its group. - pub fn set_group_width(&mut self, id: PanelId, width: f32) { - let panels = match self.location(id) { - Some((side, gi)) => self.groups(side)[gi].panels.clone(), - None => vec![id], - }; - for p in panels { - self.set_width(p, width); + /// The widest group `group` may be: the most any of its pallets allows. + fn group_max_width(&self, side: DockSide, group: usize) -> f32 { + self.groups(side)[group] + .panels + .iter() + .map(|id| id.max_width()) + .fold(DOCK_MIN_W, f32::max) + } + + /// Group `group`'s on-screen width in a `win_w` wide window, clamped like + /// [`Self::width`]. + pub fn group_width_px(&self, side: DockSide, group: usize, win_w: f32) -> f32 { + let max = self.group_max_width(side, group).min(win_w * 0.45).max(DOCK_MIN_W); + self.group_width(side, group).clamp(DOCK_MIN_W, max) + } + + /// Size group `group` on `side`, clamped. + pub fn set_group_width(&mut self, side: DockSide, group: usize, width: f32) { + let max = self.group_max_width(side, group); + if let Some(g) = self.groups_mut(side).get_mut(group) { + g.width = width.clamp(DOCK_MIN_W, max); } } + /// Give group `group` the widest default width of its pallets. + pub fn reset_group_width(&mut self, side: DockSide, group: usize) { + let width = self.groups(side)[group] + .panels + .iter() + .map(|id| id.default_width()) + .fold(DOCK_MIN_W, f32::max); + self.set_group_width(side, group, width); + } + /// Take `id` out of the layout. Returns the group it left, its position /// and weight there, and whether the group disappeared (it held nothing /// else). @@ -555,8 +594,8 @@ impl DockState { /// Stack `id` into group `group` on `side` at position `index` (0 = top, /// `len` = bottom), both counted before the move, and show the group. A - /// pallet already in the group moves within it. A joining pallet takes on - /// the group's width. Returns whether the layout changed. + /// pallet already in the group moves within it; the group keeps its + /// width. Returns whether the layout changed. pub fn join_group(&mut self, id: PanelId, side: DockSide, group: usize, index: usize) -> bool { if group >= self.groups(side).len() { return false; @@ -567,8 +606,6 @@ impl DockState { return self.show_group(side, group); } let before = self.clone(); - let width = self.group_width(side, group); - let joining = !self.groups(side)[group].panels.contains(&id); let mut group = group; let mut index = index; let mut weight = self.groups(side)[group].mean_weight(); @@ -584,9 +621,6 @@ impl DockState { } } self.groups_mut(side)[group].insert(index, id, weight); - if joining { - self.set_width(id, width); - } self.show_group(side, group); *self != before } @@ -683,6 +717,64 @@ impl DockState { } } +/// What the pointer is dragging in the dock, if anything. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DockDrag { + /// Moving pallet `panel`, or with `group` that whole group (`panel` is + /// then its first open pallet, used for the preview). Nothing moves + /// until the pointer has travelled past the drag threshold from + /// `origin`; from then on `target` is where it would land and `grab` the + /// pointer's offset from the dragged frame's top-left corner. + Move { + panel: PanelId, + group: Option<(DockSide, usize)>, + origin: Option, + target: Option, + grab: iced::Vector, + }, + /// Sizing the width of `panel`'s group (or of `panel`, floating). + Width(PanelId), + /// Moving the splitter between pallets `upper` and `lower` of a group. + Split { + side: DockSide, + group: usize, + upper: usize, + lower: usize, + }, + /// Sizing floating `panel` from its bottom-left (`from_left`) or + /// bottom-right corner. + FloatSize { panel: PanelId, from_left: bool }, + /// Sizing the Layer Manager's Name column. + LayerColumn, +} + +impl DockDrag { + /// Start moving `panel` (a whole group with `group`). + pub fn moving(panel: PanelId, group: Option<(DockSide, usize)>) -> Self { + DockDrag::Move { + panel, + group, + origin: None, + target: None, + grab: iced::Vector::new(0.0, 0.0), + } + } + + /// The pallet being moved, the group if a whole group moves, and where + /// it would land (once past the drag threshold). + pub fn movement(&self) -> Option<(PanelId, Option<(DockSide, usize)>, Option)> { + match *self { + DockDrag::Move { + panel, + group, + target, + .. + } => Some((panel, group, target)), + _ => None, + } + } +} + /// Where a dragged panel lands on release. #[derive(Debug, Clone, Copy, PartialEq)] pub enum DropTarget { @@ -699,8 +791,8 @@ pub enum DropTarget { Float { x: f32, y: f32 }, } -/// Split `avail` pixels of height between slots of `weights`, returning each -/// slot's `(top, bottom)`. +/// Split `avail` pixels of height between stacked pallets of `weights`, +/// returning each one's `(top, bottom)`. pub fn slot_spans(weights: &[f32], avail: f32) -> Vec<(f32, f32)> { let total: f32 = weights.iter().sum(); if total <= 0.0 { @@ -717,7 +809,7 @@ pub fn slot_spans(weights: &[f32], avail: f32) -> Vec<(f32, f32)> { .collect() } -/// Integer share for iced's `FillPortion` from a slot weight. +/// Integer share for iced's `FillPortion` from a pallet's weight. pub fn portion(weight: f32) -> u16 { (weight * 1000.0).round().clamp(1.0, u16::MAX as f32) as u16 } @@ -728,9 +820,9 @@ pub const DOCK_MIN_W: f32 = 200.0; 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; -/// Width of the grabbable divider between a docked slot and the viewport. +/// Width of the grabbable divider between a docked group and the viewport. pub const DOCK_DIVIDER_W: f32 = 5.0; -/// Width of an edge's vertical icon strip (the groups' tabs). +/// Width of an edge's vertical icon strip. pub const DOCK_STRIP_W: f32 = 40.0; /// Height of one pallet icon in the strip. pub const STRIP_CELL_H: f32 = 32.0; @@ -1106,8 +1198,8 @@ mod tests { vec![PanelId::Properties, PanelId::BlockPalette] ); assert_eq!(state.left[0].weights.len(), 2); - // It takes on the group's width. - assert_eq!(state.settings(PanelId::BlockPalette).width, 320.0); + // The group keeps its width. + assert_eq!(state.group_width(DockSide::Left, 0), 320.0); // Moving it above Properties keeps it in the group. assert!(state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 0)); assert_eq!(state.left[0].panels[0], PanelId::BlockPalette); @@ -1199,6 +1291,23 @@ mod tests { assert_eq!(state.left[0].weights, vec![1.0, 1.0]); } + #[test] + fn group_width_is_its_own_once_set() { + let mut state = DockState::default(); + state.join_group(PanelId::Browser, DockSide::Left, 0, 1); + // Unset: the widest pallet (Properties 250 > Browser 230). + assert_eq!(state.group_width(DockSide::Left, 0), 250.0); + state.set_group_width(DockSide::Left, 0, 333.0); + assert_eq!(state.group_width(DockSide::Left, 0), 333.0); + // Pallet widths are untouched; other groups keep theirs. + assert_eq!(state.settings(PanelId::Properties).width, 250.0); + assert_eq!(state.group_width(DockSide::Right, 0), 260.0); + state.set_group_width(DockSide::Left, 0, 5000.0); + assert_eq!(state.group_width(DockSide::Left, 0), DOCK_MAX_W); + state.reset_group_width(DockSide::Left, 0); + assert_eq!(state.group_width(DockSide::Left, 0), 250.0); + } + #[test] fn auto_hide_is_per_edge_for_docked_and_per_pallet_when_floating() { let mut state = DockState::default(); From 7448472291613a4a56b4112253a0259696198aed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:25:34 +0000 Subject: [PATCH 16/24] Dock: move app-side dock code into app::dock, trim tests, drop icon tooltip - The dock's state handling and drawing move out of update/dialog.rs and view/mod.rs into src/app/dock/ (update.rs, view.rs, tests.rs). The overlay view_main stacked over the workspace (floating pallets, + menu, drag preview, pointer capture) becomes one `dock_overlay` call. Code is moved, not changed. - Strip icons no longer show the pallet name on hover. - Tests trimmed where they overlapped: the top / bottom half drops are one test, the two hover tests are one, and the in-group icon reorder, raise-float, empty floating title bar and slot-span tests are dropped (each is covered by another test). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/dock/mod.rs | 12 + src/app/dock/tests.rs | 570 ++++++++++++++++++ src/app/dock/update.rs | 633 +++++++++++++++++++ src/app/dock/view.rs | 1045 ++++++++++++++++++++++++++++++++ src/app/mod.rs | 1 + src/app/update/dialog.rs | 1233 -------------------------------------- src/app/view/mod.rs | 1041 +------------------------------- src/ui/dock.rs | 37 -- 8 files changed, 2266 insertions(+), 2306 deletions(-) create mode 100644 src/app/dock/mod.rs create mode 100644 src/app/dock/tests.rs create mode 100644 src/app/dock/update.rs create mode 100644 src/app/dock/view.rs diff --git a/src/app/dock/mod.rs b/src/app/dock/mod.rs new file mode 100644 index 000000000..153c2de95 --- /dev/null +++ b/src/app/dock/mod.rs @@ -0,0 +1,12 @@ +//! The app side of docking: pallets docked on the drawing view's edges (one +//! group shown per edge, listed in an icon strip) or floating over it. +//! +//! The persisted layout and its pure geometry live in [`crate::ui::dock`]; +//! this module holds the transient state handling (`update`) and drawing +//! (`view`). + +mod update; +mod view; + +#[cfg(test)] +mod tests; diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs new file mode 100644 index 000000000..3cac5f413 --- /dev/null +++ b/src/app/dock/tests.rs @@ -0,0 +1,570 @@ +//! Dock behaviour through the app: drags, hover, menus, and the real view. + +use crate::app::{Message, OpenCADStudio}; + +fn fresh() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app +} + +#[test] +fn blockpalette_pin_toggles_autocollapse_and_close_hides() { + let mut app = fresh(); + app.show_block_palette = true; + let id = crate::ui::dock::PanelId::BlockPalette; + let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); + assert!(app.dock.auto_hides(id), "pin enables auto-hide"); + let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); + assert!(!app.dock.auto_hides(id), "second pin disables auto-hide"); + let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id)); + assert!(!app.show_block_palette, "close dismisses the sidebar"); +} + +/// 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 +} + +/// Where the current dock drag would land, if it has started. +fn drag_target(app: &OpenCADStudio) -> Option { + app.dock_drag.and_then(|d| d.movement()).and_then(|(_, _, target)| target) +} + +/// 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 = drag_target(app); + let _ = app.on_dock(DockMsg::DragRelease); + target +} + +#[test] +fn dropping_on_a_pallets_top_or_bottom_half_stacks_above_or_below_it() { + use crate::app::config::DockSide; + use crate::ui::dock::{DropTarget, PanelId}; + let mut app = dock_app(); + let id = PanelId::BlockPalette; + assert_eq!(app.dock.location(id), Some((DockSide::Right, 0))); + let target = drag_panel( + &mut app, + id, + iced::Point::new(1500.0, 10.0), + iced::Point::new(150.0, 300.0), + ); + assert_eq!( + target, + Some(DropTarget::Join { + side: DockSide::Left, + group: 0, + index: 0 + }) + ); + assert_eq!( + app.dock.left[0].panels, + vec![PanelId::BlockPalette, PanelId::Properties] + ); + assert!(app.dock.right.is_empty()); + // The bottom half of a pallet stacks below it. + let target = drag_panel( + &mut app, + id, + iced::Point::new(150.0, 100.0), + iced::Point::new(150.0, 800.0), + ); + assert_eq!( + target, + Some(DropTarget::Join { + side: DockSide::Left, + group: 0, + index: 2 + }) + ); + assert_eq!( + app.dock.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette] + ); +} + +#[test] +fn dock_dragging_a_lone_pallet_over_its_own_group_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(); + // The right edge's only group holds just the block palette; drag it + // around over itself. + let target = drag_panel( + &mut app, + PanelId::BlockPalette, + iced::Point::new(1500.0, 10.0), + iced::Point::new(1450.0, 600.0), + ); + assert_eq!( + target, + Some(DropTarget::Join { + side: DockSide::Right, + group: 0, + index: 1 + }) + ); + assert_eq!(app.dock, before); +} + +#[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_below_the_strip_groups_starts_a_new_shown_group() { + use crate::app::config::DockSide; + use crate::ui::dock::{DropTarget, PanelId}; + let mut app = dock_app(); + let (_, plus_top) = app.dock_strip_layout(DockSide::Left); + let target = drag_panel( + &mut app, + PanelId::BlockPalette, + iced::Point::new(1500.0, 10.0), + iced::Point::new(18.0, plus_top + 10.0), + ); + assert_eq!( + target, + Some(DropTarget::Edge { + side: DockSide::Left, + index: 1 + }) + ); + assert_eq!(app.dock.left.len(), 2); + assert_eq!(app.dock.left[1].panels, vec![PanelId::BlockPalette]); + // The new group is the one shown: the column holds only the palette. + assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); + assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]); +} + +#[test] +fn dock_icon_press_switches_the_shown_group() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; + let mut app = dock_app(); + app.show_browser = true; + app.dock.left = vec![ + DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]), + DockGroup::single(PanelId::Browser), + ]; + app.dock.right.clear(); + // Group 0 shows both stacked pallets. + assert_eq!(app.dock_slot_spans(DockSide::Left).len(), 2); + let _ = app.on_dock(DockMsg::IconPress(PanelId::Browser)); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); + assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]); + // Each group keeps its own width. + app.dock.set_width(PanelId::Browser, 400.0); + assert_eq!(app.dock_column_width(DockSide::Left), 400.0); + let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette)); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!(app.dock_column_width(DockSide::Left), 260.0); +} + +#[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_dragging_an_icon_into_another_group_lands_between_its_icons() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, DropTarget, PanelId, STRIP_CELL_H}; + let mut app = dock_app(); + app.show_browser = true; + app.dock.left = vec![ + DockGroup::stack(vec![PanelId::Properties, PanelId::Browser]), + DockGroup::single(PanelId::BlockPalette), + ]; + app.dock.right.clear(); + let (layout, _) = app.dock_strip_layout(DockSide::Left); + // Drag the Blocks icon (group 1) to between Properties and Browser. + let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(20.0, layout[1].icons_top + 10.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new( + 20.0, + layout[0].icons_top + STRIP_CELL_H + 2.0, + ))); + assert_eq!( + drag_target(&app), + Some(DropTarget::Join { + side: DockSide::Left, + group: 0, + index: 1 + }) + ); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!(app.dock.left.len(), 1, "the emptied group goes away"); + assert_eq!( + app.dock.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette, PanelId::Browser] + ); +} + +#[test] +fn dock_grip_drag_moves_a_whole_group() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; + let mut app = dock_app(); + app.show_browser = true; + app.dock.left = vec![ + DockGroup::stack(vec![PanelId::Properties, PanelId::Browser]), + DockGroup::single(PanelId::BlockPalette), + ]; + app.dock.right.clear(); + let (layout, plus_top) = app.dock_strip_layout(DockSide::Left); + let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 0)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, layout[0].top + 2.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0))); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!(app.dock.left[0].panels, vec![PanelId::BlockPalette]); + assert_eq!( + app.dock.left[1].panels, + vec![PanelId::Properties, PanelId::Browser] + ); + // A group dropped over the viewport does not float or split up. + let before = app.dock.clone(); + let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 1)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, 300.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0))); + assert_eq!(drag_target(&app), None); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!(app.dock, before); +} + +#[test] +fn dock_closed_pallets_take_no_room_and_empty_groups_hide() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, PanelId}; + let mut app = dock_app(); + app.dock.left = vec![ + DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties]), + DockGroup::single(PanelId::Browser), + ]; + app.show_block_palette = false; + app.show_browser = false; + // Only Properties is open: one listed group, full height. + assert_eq!(app.dock_visible_groups(DockSide::Left), vec![0]); + assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(1, 0.0, 900.0)]); + // A shown group with nothing open falls back to one that has. + app.dock.show_group(DockSide::Left, 1); + assert_eq!(app.dock_shown_group(DockSide::Left), Some(0)); +} + +#[test] +fn dock_splitter_drag_changes_pallet_heights() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; + let mut app = dock_app(); + app.dock.left = vec![DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties])]; + app.dock.right.clear(); + let _ = app.on_dock(DockMsg::SplitGrab(DockSide::Left, 0, 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. + let w = &app.dock.left[0].weights; + assert!((w[0] - 1.5).abs() < 1e-4 && (w[1] - 0.5).abs() < 1e-4); + let _ = app.on_dock(DockMsg::SplitReset(DockSide::Left, 0)); + assert_eq!(app.dock.left[0].weights, vec![1.0, 1.0]); +} + +#[test] +fn dock_hover_reveals_groups_only_on_an_auto_hiding_edge() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; + let mut app = dock_app(); + app.show_browser = true; + app.dock.left.push(DockGroup::single(PanelId::Browser)); + // The pin on any docked pallet turns auto-hide on for its edge. + let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties)); + assert!(app.dock.edge_auto_hide(DockSide::Left)); + assert!(!app.dock.edge_auto_hide(DockSide::Right)); + assert!(!app.dock_edge_expanded(DockSide::Left)); + // Hovering an icon flies its group out. + let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Browser))); + assert!(app.dock_edge_expanded(DockSide::Left)); + assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); + let _ = app.on_dock(DockMsg::HoverExit); + assert!(!app.dock_edge_expanded(DockSide::Left)); + // Without auto-hide, hovering does not switch groups (clicks do). + let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties)); + let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Properties))); + assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); + assert!(app.dock_edge_expanded(DockSide::Left)); +} + +#[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, false)); + 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)); + // The bottom-left grip grows the panel leftward, keeping its right edge. + let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, true)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 450.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(60.0, 450.0))); + let _ = app.on_dock(DockMsg::DragRelease); + let g = app.dock.float_rect(PanelId::BlockPalette).unwrap(); + assert_eq!((g.x, g.w, g.h), (f.x - 40.0, 340.0, 350.0)); +} + +#[test] +fn dock_edge_menu_adds_and_hides_pallets() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = dock_app(); + app.show_browser = false; + let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left))); + // Checking a closed pallet opens it as a new, shown group here. + let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); + assert!(app.show_browser); + assert_eq!(app.dock.left.len(), 2); + assert_eq!(app.dock.left[1].panels, vec![PanelId::Browser]); + assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); + assert_eq!(app.dock_edge_menu, None, "the menu closes after adding"); + let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left))); + // Unchecking hides it but keeps its place. + let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); + assert!(!app.show_browser); + assert_eq!(app.dock.location(PanelId::Browser), Some((DockSide::Left, 1))); + // Opening it again from the same edge reuses that place. + let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); + assert_eq!(app.dock.left.len(), 2); + // A pallet open on the other edge is not checked here: picking it + // moves it over instead of hiding it. + let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::BlockPalette)); + assert!(app.show_block_palette); + assert!(app.dock.right.is_empty()); + assert_eq!(app.dock.location(PanelId::BlockPalette), Some((DockSide::Left, 2))); +} + +#[test] +fn dock_divider_resizes_only_its_group() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockGroup, DockMsg, PanelId}; + let mut app = dock_app(); + app.dock.left = vec![ + DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]), + DockGroup::single(PanelId::Count), + ]; + app.dock.right.clear(); + let _ = app.on_dock(DockMsg::ResizeGrab(PanelId::Properties)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(300.0, 100.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(330.0, 100.0))); + let _ = app.on_dock(DockMsg::DragRelease); + // The group was 260 wide (its widest pallet); it is now 290, while + // the other group and the pallets' own widths are untouched. + assert_eq!(app.dock_group_width(DockSide::Left, 0), 290.0); + assert_eq!(app.dock_group_width(DockSide::Left, 1), 280.0); + assert_eq!(app.dock.settings(PanelId::Properties).width, 250.0); +} + +#[test] +fn layers_command_opens_a_floating_pallet_and_toggles_it_closed() { + use crate::ui::dock::PanelId; + let mut app = dock_app(); + let _ = app.update(Message::ToggleLayers); + assert!(app.show_layers); + assert!(app.active_modal.is_none(), "no modal backdrop"); + let f = app.dock.float_rect(PanelId::Layers).expect("floats on first use"); + assert!(f.x > 0.0 && f.y >= 0.0); + let _ = app.update(Message::ToggleLayers); + assert!(!app.show_layers); + // Its place is kept for next time. + assert!(app.dock.float_rect(PanelId::Layers).is_some()); +} + +/// A headless renderer for driving the real view. +fn headless_renderer() -> iced::Renderer { + iced_test::futures::futures::executor::block_on( + ::new( + iced_test::core::renderer::Settings::default(), + None, + ), + ) + .expect("headless renderer") +} + +/// Size the real view is laid out at. +const VIEW_SIZE: iced::Size = iced::Size::new(1600.0, 900.0); + +/// Centre of the first text `label` in the real view. +fn find_in_view(app: &OpenCADStudio, label: &str) -> iced::Point { + use iced_test::core::widget; + use iced_test::runtime::user_interface::{Cache, UserInterface}; + use iced_test::Selector; + let mut renderer = headless_renderer(); + let mut ui = UserInterface::build(app.view_main(), VIEW_SIZE, Cache::default(), &mut renderer); + let mut find = Selector::find(label); + ui.operate(&renderer, &mut widget::operation::black_box(&mut find)); + match widget::Operation::finish(&find) { + widget::operation::Outcome::Some(Some(target)) => target + .visible_bounds() + .expect("visible") + .center(), + _ => panic!("{label} not found"), + } +} + +/// Drive the real view through iced's `UserInterface` with the pointer at +/// `at`: feed `events` one at a time, and between events hand the +/// published messages to the app and rebuild the view while keeping the +/// widget-tree cache, as the runtime does. +fn drive_view(app: &mut OpenCADStudio, at: iced::Point, events: &[iced::Event]) { + use iced_test::core::{mouse, shell, window}; + use iced_test::runtime::user_interface::{Cache, UserInterface}; + let mut renderer = headless_renderer(); + let mut cache = Cache::default(); + let cursor = mouse::Cursor::Available(at); + for event in events { + let mut messages = Vec::new(); + { + let mut ui = UserInterface::build(app.view_main(), VIEW_SIZE, cache, &mut renderer); + let _ = ui.update( + &window::Headless, + &shell::Waker::noop(), + std::slice::from_ref(event), + cursor, + &mut renderer, + &mut messages, + ); + cache = ui.into_cache(); + } + for message in messages { + let _ = app.update(message); + } + } +} + +#[test] +fn docked_title_bar_double_click_floats_the_pallet_in_the_real_view() { + use crate::ui::dock::PanelId; + let mut app = dock_app(); + let press = iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)); + let release = + iced::Event::Mouse(iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left)); + // The block palette's title text sits in its docked title bar. + let title = find_in_view(&app, "Blocks"); + drive_view(&mut app, title, &[press.clone(), release.clone(), press, release]); + assert!( + app.dock.float_rect(PanelId::BlockPalette).is_some(), + "a double-click on a docked title bar floats the pallet" + ); +} + +#[test] +fn hovering_a_strip_icon_reveals_an_auto_hiding_edge_in_the_real_view() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId, STRIP_CELL_H, STRIP_PAD}; + let mut app = dock_app(); + // Mirror the layout onto the left so the block palette's title marks + // where the workspace starts, then auto-hide the right edge. + let title = find_in_view(&app, "Blocks"); + let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::BlockPalette)); + assert!(!app.dock_edge_expanded(DockSide::Right)); + // The title bar text sits ~13 px below the workspace top; the strip's + // first icon is centred STRIP_PAD + half a cell below it. + let workspace_top = title.y - 13.0; + let icon = iced::Point::new( + VIEW_SIZE.width - crate::ui::dock::DOCK_STRIP_W / 2.0, + workspace_top + STRIP_PAD + STRIP_CELL_H / 2.0, + ); + drive_view( + &mut app, + icon, + &[iced::Event::Mouse(iced::mouse::Event::CursorMoved { position: icon })], + ); + assert!( + app.dock_edge_expanded(DockSide::Right), + "hovering the icon reveals the auto-hiding edge" + ); +} + +#[test] +fn dock_double_clicks_float_and_dock_panels() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = dock_app(); + let _ = app.on_dock(DockMsg::FloatOut(PanelId::Properties)); + assert!(app.dock.left.is_empty()); + let f = app.dock.float_rect(PanelId::Properties).expect("floating"); + assert!(f.x > 0.0, "floats clear of the left edge"); + let _ = app.on_dock(DockMsg::DockTo(PanelId::Properties, DockSide::Right)); + assert!(app.dock.floating.is_empty()); + assert_eq!(app.dock.location(PanelId::Properties), Some((DockSide::Right, 1))); +} + +#[test] +fn blockpalette_width_reset() { + use crate::app::config::DockSide; + let mut app = dock_app(); + let id = crate::ui::dock::PanelId::BlockPalette; + app.dock.set_group_width(DockSide::Right, 0, 500.0); + let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id)); + assert_eq!(app.dock.group_width(DockSide::Right, 0), 260.0); +} diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs new file mode 100644 index 000000000..5478b03e8 --- /dev/null +++ b/src/app/dock/update.rs @@ -0,0 +1,633 @@ +//! Dock state changes: drags, hover, open / close, and the queries the view +//! and drop targeting share. + +use crate::app::{Message, OpenCADStudio}; + +/// Pointer travel (px) before pressing a title bar or icon becomes a drag, +/// so a plain click never moves anything. +const DOCK_DRAG_THRESHOLD: f32 = 5.0; + +impl OpenCADStudio { + /// Dock chrome interaction (grab / pin / resize / hover / move) applied to + /// whichever panel the message names. + pub(crate) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task { + use crate::app::config::DockSide; + use crate::ui::dock::{DockDrag, DockMsg, DropTarget, FloatPanel}; + match m { + DockMsg::DockGrab(id) => self.dock_begin(DockDrag::moving(id, None), Some(id)), + DockMsg::IconPress(id) => { + // An icon press shows the icon's group and may also start + // dragging the pallet; a plain click never passes the drag + // threshold. + if self.dock.show_group_of(id) { + self.save_config(); + } + self.dock_begin(DockDrag::moving(id, None), Some(id)); + } + DockMsg::GroupGrab(side, gi) => { + if let Some(&id) = self.dock_group_visible(side, gi).first() { + self.dock_begin(DockDrag::moving(id, Some((side, gi))), Some(id)); + } + } + DockMsg::ResizeGrab(id) => self.dock_begin(DockDrag::Width(id), Some(id)), + DockMsg::SplitGrab(side, group, upper, lower) => self.dock_begin( + DockDrag::Split { + side, + group, + upper, + lower, + }, + None, + ), + DockMsg::FloatResizeGrab(panel, from_left) => { + self.dock.raise_float(panel); + self.dock_begin(DockDrag::FloatSize { panel, from_left }, None); + } + DockMsg::SplitReset(side, gi) => { + self.dock.reset_splits(side, gi); + self.save_config(); + } + DockMsg::WidthReset(id) => { + if let Some((side, gi)) = self.dock.location(id) { + self.dock.reset_group_width(side, gi); + } else { + self.dock.reset_width(id); + } + self.save_config(); + } + DockMsg::TitleHover(id) => self.dock_title_hover = id, + DockMsg::GripHover(grip) => self.dock_grip_hover = grip, + DockMsg::FloatRaise(id) => self.dock.raise_float(id), + DockMsg::FloatOut(id) => { + // Float beside the edge it was docked on, near the top. + let (ww, _) = self.dock_workspace_size(); + let (w, h) = self.dock_float_size(id); + let strip = crate::ui::dock::DOCK_STRIP_W; + let x = match self.dock.location(id) { + Some((DockSide::Right, _)) => { + ww - strip - self.dock_column_width(DockSide::Right) - w - 60.0 + } + _ => strip + self.dock_column_width(DockSide::Left) + 40.0, + }; + self.dock_drag = None; + if self.dock.float(FloatPanel { + id, + x: x.max(0.0), + y: 40.0, + w, + h, + }) { + self.save_config(); + } + } + DockMsg::DockTo(id, side) => { + self.dock_drag = None; + if self.dock.dock(id, side, usize::MAX) { + self.save_config(); + } + } + DockMsg::EdgeMenu(side) => self.dock_edge_menu = side, + DockMsg::EdgeMenuToggle(side, id) => { + // Checked = open and docked on this edge: unchecking hides it. + // Anything else (closed, floating, on the other edge) moves + // here and opens, in its own group here if it has one, else + // as a new group; adding a pallet ends the menu's job. + let here = self.dock.location(id).map(|(s, _)| s) == Some(side); + if !here { + self.dock.dock(id, side, usize::MAX); + } else if self.dock_panel_visible(id) { + let task = self.dock_set_open(id, false); + self.save_config(); + return task; + } + self.dock_edge_menu = None; + let task = self.dock_set_open(id, true); + self.save_config(); + return task; + } + DockMsg::AutoCollapseToggle(id) => { + // Docked: auto-hide for the whole edge; floating: this pallet. + if self.dock.toggle_auto_hide(id) { + self.dock_peek = None; + } + self.save_config(); + } + DockMsg::Close(id) => return self.dock_set_open(id, false), + DockMsg::Hover(id) => { + self.dock_icon_hover = id; + // Only an auto-hiding edge (or floating pallet) reacts to + // hover: it reveals the hovered pallet's group. Otherwise + // groups switch on click. Ignored mid-drag, when the pointer + // is over the preview rather than the strip. + if let Some(id) = id { + if self.dock_drag.is_none() && self.dock.auto_hides(id) { + self.dock_reveal(id); + } + } + } + DockMsg::HoverExit => { + if self.dock_drag.is_none() + && self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) + { + self.dock_peek = None; + } + } + DockMsg::DragMove(point) => self.dock_drag_move(point), + DockMsg::DragRelease => { + let mut changed = false; + match self.dock_drag.take() { + Some(DockDrag::Move { + group: Some((side, gi)), + target: Some(DropTarget::Edge { side: to, index }), + .. + }) => changed = self.dock.move_group(side, gi, to, index), + Some(DockDrag::Move { + panel, + group: None, + target: Some(target), + .. + }) => { + changed = match target { + DropTarget::Edge { side, index } => self.dock.dock(panel, side, index), + DropTarget::Join { side, group, index } => { + self.dock.join_group(panel, side, group, index) + } + DropTarget::Float { x, y } => { + let (w, h) = self.dock_float_size(panel); + self.dock.float(FloatPanel { + id: panel, + x, + y, + w, + h, + }) + } + }; + } + Some(DockDrag::Move { .. } | DockDrag::LayerColumn) | None => {} + Some(DockDrag::Width(_) | DockDrag::Split { .. } | DockDrag::FloatSize { .. }) => { + changed = true; + } + } + if changed { + self.save_config(); + } + self.dock_drag_last = None; + self.xref_col_drag = None; + self.xref_col_last = None; + self.xref_split_drag = false; + } + } + iced::Task::none() + } + + /// Pointer motion while something in the dock is dragged. + fn dock_drag_move(&mut self, point: iced::Point) { + // NOTE: xref column drags intentionally do NOT ride this path: its + // points live in workspace space while the header 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. + use crate::app::config::DockSide; + use crate::ui::dock::{DockDrag, DropTarget}; + let last = self.dock_drag_last.replace(point); + let Some(drag) = self.dock_drag else { + return; + }; + match drag { + DockDrag::Move { + panel, + group, + origin, + target, + grab, + } => { + let origin = origin.unwrap_or(point); + let moved = (point.x - origin.x).hypot(point.y - origin.y); + let started = target.is_some() || moved >= DOCK_DRAG_THRESHOLD; + // Hold a floating panel where it was grabbed; a docked one by + // its title bar. + let grab = if target.is_none() && started { + match self.dock.float_rect(panel) { + Some(f) => iced::Vector::new(origin.x - f.x, origin.y - f.y), + None => iced::Vector::new( + (self.dock.width(panel, self.win_size.0) * 0.5).min(120.0), + 14.0, + ), + } + } else { + grab + }; + // Store the grab first: the drop target reads it. + self.dock_drag = Some(DockDrag::Move { + panel, + group, + origin: Some(origin), + target, + grab, + }); + if !started { + return; + } + let found = self.dock_drop_target(point); + // A whole group only moves between group positions: it + // cannot join another group or float. + let target = match (group, found) { + (Some(_), DropTarget::Edge { .. }) | (None, _) => Some(found), + (Some(_), _) => None, + }; + self.dock_drag = Some(DockDrag::Move { + panel, + group, + origin: Some(origin), + target, + grab, + }); + } + DockDrag::Split { + side, + group, + upper, + lower, + } => { + let Some(last) = last else { return }; + let (_, avail) = self.dock_workspace_size(); + let total: f32 = self + .dock_slot_spans(side) + .iter() + .map(|(i, _, _)| self.dock.groups(side)[group].weights[*i]) + .sum(); + if avail > 0.0 && total > 0.0 { + let per_px = total / avail; + self.dock.shift_split( + side, + group, + upper, + lower, + (point.y - last.y) * per_px, + crate::ui::dock::GROUP_MIN_H * per_px, + ); + } + } + DockDrag::FloatSize { panel, from_left } => { + let (Some(last), Some(f)) = (last, self.dock.float_rect(panel)) else { + return; + }; + let dx = point.x - last.x; + self.dock.resize_float( + panel, + if from_left { f.w - dx } else { f.w + dx }, + f.h + point.y - last.y, + from_left, + ); + } + DockDrag::LayerColumn => { + let Some(last) = last else { return }; + self.layer_name_col_w = + (self.layer_name_col_w + point.x - last.x).clamp(60.0, 640.0); + } + DockDrag::Width(id) => { + let Some(last) = last else { return }; + let dx = point.x - last.x; + match self.dock.location(id) { + // The divider sizes the panel's group. + Some((side, gi)) => { + let delta = if side == DockSide::Left { dx } else { -dx }; + let cur = self.dock_group_width(side, gi) + delta; + self.dock.set_group_width(side, gi, cur); + } + None => { + let cur = self.dock.settings(id).width + dx; + self.dock.set_width(id, cur); + } + } + } + } + } + + /// Start `drag`, peeking `peek` (a pallet being dragged or resized stays + /// shown on an auto-hiding edge). + fn dock_begin(&mut self, drag: crate::ui::dock::DockDrag, peek: Option) { + if let crate::ui::dock::DockDrag::Move { panel, .. } = drag { + self.dock.raise_float(panel); + } + self.dock_drag = Some(drag); + self.dock_drag_last = None; + self.dock_edge_menu = None; + if peek.is_some() { + self.dock_peek = peek; + } + } + + /// Bring `id` into view: show its group on its edge and, where it + /// auto-hides, keep it revealed until the pointer leaves. + pub(crate) fn dock_reveal(&mut self, id: crate::ui::dock::PanelId) { + self.dock.show_group_of(id); + self.dock_peek = Some(id); + } + + /// Open or close pallet `id`, running its usual open path (refreshes, + /// ribbon highlight) or close path. An opened pallet is revealed where + /// the layout has it. + pub(crate) fn dock_set_open( + &mut self, + id: crate::ui::dock::PanelId, + open: bool, + ) -> iced::Task { + use crate::ui::dock::PanelId; + if open { + let task = if self.dock_panel_visible(id) { + iced::Task::none() + } else { + match id { + PanelId::Properties => { + self.show_properties = true; + self.ribbon.set_properties(true); + iced::Task::none() + } + PanelId::BlockPalette => { + self.open_blocks_palette(None); + iced::Task::none() + } + PanelId::ExternalReferences => { + self.show_external_references = true; + self.refresh_xref_manager(); + iced::Task::none() + } + PanelId::Browser => { + self.show_browser = true; + iced::Task::none() + } + PanelId::NodeGraph => self.on_graph(crate::ui::node_graph::GraphMsg::Toggle), + PanelId::PointCloudManager => { + self.pc_manager.show = true; + iced::Task::none() + } + PanelId::Count => { + self.set_count_palette(true); + iced::Task::none() + } + PanelId::SheetSetManager => { + self.show_sheet_set_manager(true); + iced::Task::none() + } + PanelId::Layers => { + self.sync_ribbon_layers(); + self.show_layers = true; + iced::Task::none() + } + } + }; + self.dock_reveal(id); + return task; + } + match id { + PanelId::BlockPalette => { + self.show_block_palette = false; + self.block_palette.placing = None; + } + PanelId::ExternalReferences => self.show_external_references = false, + PanelId::Browser => self.show_browser = false, + PanelId::NodeGraph => self.show_node_graph = false, + PanelId::PointCloudManager => self.pc_manager.show = false, + PanelId::Count => { + self.count_palette.show = false; + self.ribbon.set_count_palette(false); + } + PanelId::SheetSetManager => { + self.sheet_set.show = false; + self.ribbon.set_sheet_set(false); + } + PanelId::Properties => { + self.show_properties = false; + self.ribbon.set_properties(false); + } + PanelId::Layers => { + self.show_layers = false; + self.ribbon.deactivate_tool_if("LAYERS"); + } + } + if self.dock_peek == Some(id) { + self.dock_peek = None; + } + if self + .dock_drag + .is_some_and(|d| d.movement().is_some_and(|(p, _, _)| p == id) || d == crate::ui::dock::DockDrag::Width(id)) + { + self.dock_drag = None; + } + iced::Task::none() + } + + /// 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. + pub(crate) fn dock_panel_visible(&self, id: crate::ui::dock::PanelId) -> bool { + use crate::ui::dock::PanelId; + if self.tabs[self.active_tab].is_start || self.clean_screen { + return false; + } + match id { + PanelId::Properties => self.show_properties, + PanelId::BlockPalette => self.show_block_palette, + PanelId::ExternalReferences => self.show_external_references, + PanelId::Browser => self.show_browser, + PanelId::NodeGraph => self.show_node_graph, + PanelId::PointCloudManager => self.pc_manager.show, + PanelId::Count => self.count_palette.show, + PanelId::SheetSetManager => self.sheet_set.show, + PanelId::Layers => self.show_layers, + } + } + + /// 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, + ) + } + + /// The open pallets of group `gi` on `side`, top → bottom. Closed + /// pallets keep their place but take no space, so a group with none open + /// is not drawn at all. + pub(crate) fn dock_group_visible( + &self, + side: crate::app::config::DockSide, + gi: usize, + ) -> Vec { + self.dock.groups(side)[gi] + .panels + .iter() + .copied() + .filter(|id| self.dock_panel_visible(*id)) + .collect() + } + + /// Indices of the groups on `side` with an open pallet (listed in the + /// icon strip). + pub(crate) fn dock_visible_groups(&self, side: crate::app::config::DockSide) -> Vec { + (0..self.dock.groups(side).len()) + .filter(|gi| !self.dock_group_visible(side, *gi).is_empty()) + .collect() + } + + /// The group `side` draws: the one it shows, or the first with an open + /// pallet when that one has none. + pub(crate) fn dock_shown_group(&self, side: crate::app::config::DockSide) -> Option { + let visible = self.dock_visible_groups(side); + let shown = self.dock.shown(side); + if visible.contains(&shown) { + Some(shown) + } else { + visible.first().copied() + } + } + + /// Whether `side` draws its shown group beside the icon strip: always, + /// unless the edge auto-hides and none of the group's icons is hovered. + pub(crate) fn dock_edge_expanded(&self, side: crate::app::config::DockSide) -> bool { + let Some(gi) = self.dock_shown_group(side) else { + return false; + }; + !self.dock.edge_auto_hide(side) + || self + .dock_peek + .is_some_and(|e| self.dock_group_visible(side, gi).contains(&e)) + } + + /// On-screen width of group `gi` on `side`. + pub(crate) fn dock_group_width(&self, side: crate::app::config::DockSide, gi: usize) -> f32 { + self.dock.group_width_px(side, gi, self.win_size.0) + } + + /// Width of the pallet column beside `side`'s icon strip: the shown + /// group's width, or nothing when it is hidden. + pub(crate) fn dock_column_width(&self, side: crate::app::config::DockSide) -> f32 { + match self.dock_shown_group(side) { + Some(gi) if self.dock_edge_expanded(side) => self.dock_group_width(side, gi), + _ => 0.0, + } + } + + /// The icon strip of `side`: each listed group's place, and the top of + /// the + button. + pub(crate) fn dock_strip_layout( + &self, + side: crate::app::config::DockSide, + ) -> (Vec, f32) { + let groups: Vec<(usize, usize)> = self + .dock_visible_groups(side) + .into_iter() + .map(|gi| (gi, self.dock_group_visible(side, gi).len())) + .collect(); + crate::ui::dock::strip_layout(&groups) + } + + /// The open pallets of `side`'s shown group, each as (position in the + /// group, top, bottom) in the workspace. + pub(crate) fn dock_slot_spans( + &self, + side: crate::app::config::DockSide, + ) -> Vec<(usize, f32, f32)> { + let Some(gi) = self.dock_shown_group(side) else { + return Vec::new(); + }; + let (_, avail) = self.dock_workspace_size(); + let group = &self.dock.groups(side)[gi]; + let open: Vec = (0..group.panels.len()) + .filter(|i| self.dock_panel_visible(group.panels[*i])) + .collect(); + let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); + open.into_iter() + .zip(crate::ui::dock::slot_spans(&weights, avail)) + .map(|(i, (top, bottom))| (i, 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 a dragged pallet lands if released at workspace point `p`: + /// on an edge's icon strip it joins a group between its icons or starts a + /// new group; over the shown group's pallets it stacks above / below the + /// one under the pointer; on an empty edge it starts a group; anywhere + /// else it floats. + pub(crate) fn dock_drop_target(&self, p: iced::Point) -> crate::ui::dock::DropTarget { + use crate::app::config::DockSide; + use crate::ui::dock::{DropTarget, StripHit, DOCK_EDGE_ZONE, DOCK_STRIP_W}; + let (ww, wh) = self.dock_workspace_size(); + for side in [DockSide::Left, DockSide::Right] { + let listed = !self.dock_visible_groups(side).is_empty(); + let from_edge = match side { + DockSide::Left => p.x, + DockSide::Right => ww - p.x, + }; + if !listed { + if from_edge <= DOCK_EDGE_ZONE { + return DropTarget::Edge { + side, + index: self.dock.groups(side).len(), + }; + } + continue; + } + // Over the icon strip. + if from_edge <= DOCK_STRIP_W { + let (layout, _) = self.dock_strip_layout(side); + return match crate::ui::dock::strip_hit(p.y, &layout) { + StripHit::NewGroup(at) => DropTarget::Edge { + side, + index: at.unwrap_or(self.dock.groups(side).len()), + }, + StripHit::Tab { group, index } => { + // Map the gap among open pallets to a position among + // all of the group's pallets. + let open = self.dock_group_visible(side, group); + let all = &self.dock.groups(side)[group].panels; + let index = open + .get(index) + .and_then(|t| all.iter().position(|a| a == t)) + .unwrap_or(all.len()); + DropTarget::Join { side, group, index } + } + }; + } + // Over the shown group's pallets: above or below the one there. + if from_edge <= DOCK_STRIP_W + self.dock_column_width(side) { + let gi = self.dock_shown_group(side).expect("listed edge"); + let spans = self.dock_slot_spans(side); + if let Some(&(pos, top, bottom)) = spans + .iter() + .find(|(_, _, bottom)| p.y < *bottom) + .or(spans.last()) + { + let index = if p.y < (top + bottom) * 0.5 { pos } else { pos + 1 }; + return DropTarget::Join { + side, + group: gi, + index, + }; + } + } + } + let grab = match self.dock_drag { + Some(crate::ui::dock::DockDrag::Move { grab, .. }) => grab, + _ => iced::Vector::new(0.0, 0.0), + }; + let x = (p.x - grab.x).clamp(0.0, (ww - 60.0).max(0.0)); + let y = (p.y - grab.y).clamp(0.0, (wh - 30.0).max(0.0)); + DropTarget::Float { x, y } + } +} diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs new file mode 100644 index 000000000..55972f488 --- /dev/null +++ b/src/app/dock/view.rs @@ -0,0 +1,1045 @@ +//! Dock drawing: the edges (icon strip and shown group), floating pallets, +//! the + menu and the drag preview. + +use crate::app::document::DocumentTab; +use crate::app::{Message, OpenCADStudio}; +use crate::t; +use crate::ui::dock::DOCK_DIVIDER_W; +use iced::widget::{button, canvas, column, container, mouse_area, row, stack, text, Space}; +use iced::{Background, Border, Color, Element, Fill, Length, Theme}; + +impl OpenCADStudio { + /// Everything the dock draws over the workspace (the row of edges and + /// viewport): floating pallets, an open + menu, the drag preview, and + /// the pointer capture that feeds drags. + pub(in crate::app) fn dock_overlay<'a>( + &'a self, + workspace: Element<'a, Message>, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + // Floating panels sit over the workspace, back to front. + let mut layers: Vec> = vec![workspace]; + for f in &self.dock.floating { + // Once a drag is under way only its preview shows the panel. + let dragged = self + .dock_drag + .and_then(|d| d.movement()) + .is_some_and(|(panel, _, target)| panel == f.id && target.is_some()); + if self.dock_panel_visible(f.id) && !dragged { + layers.push(self.floating_panel(*f, tab)); + } + } + // An open pallet menu hangs beside its edge's + button, over a + // catcher that closes it on any click elsewhere. + if let Some(side) = self.dock_edge_menu { + if !self.dock_visible_groups(side).is_empty() { + // Checked = open and docked on this edge. + let shown: Vec<_> = crate::ui::dock::PanelId::ALL + .iter() + .map(|p| { + let here = self.dock.location(*p).map(|(s, _)| s) == Some(side); + (*p, here && self.dock_panel_visible(*p)) + }) + .collect(); + let (_, plus_top) = self.dock_strip_layout(side); + let strip = crate::ui::dock::DOCK_STRIP_W; + let x = match side { + crate::app::config::DockSide::Left => strip + 4.0, + crate::app::config::DockSide::Right => { + self.dock_workspace_size().0 - strip - DOCK_MENU_W - 4.0 + } + }; + layers.push( + mouse_area(Space::new().width(Fill).height(Fill)) + .on_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) + .on_right_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) + .into(), + ); + layers.push(place_at(dock_edge_menu(side, &shown), x.max(0.0), plus_top)); + } + } + // While a panel is dragged, preview where it lands. + if let Some((id, _, Some(target))) = self.dock_drag.and_then(|d| d.movement()) { + layers.push(self.dock_drop_preview(id, target)); + } + // Always a stack (and, below, always a mouse area), even with nothing + // on top: iced keeps a widget's state only while the tree keeps its + // shape, and a title bar's double-click needs its first click to + // survive the drag that pressing the bar starts. + let workspace: Element<'_, Message> = iced::widget::Stack::with_children(layers) + .width(Fill) + .height(Fill) + .into(); + use crate::ui::dock::DockDrag; + let capturing = self.dock_drag.is_some() + || self.xref_col_drag.is_some() + || self.xref_split_drag; + let mut capture = mouse_area(workspace); + if capturing { + capture = capture + .on_move(move |p| Message::Dock(crate::ui::dock::DockMsg::DragMove(p))) + .on_release(Message::Dock(crate::ui::dock::DockMsg::DragRelease)) + .interaction(match self.dock_drag { + Some(DockDrag::FloatSize { from_left: true, .. }) => { + iced::mouse::Interaction::ResizingDiagonallyUp + } + Some(DockDrag::FloatSize { from_left: false, .. }) => { + iced::mouse::Interaction::ResizingDiagonallyDown + } + Some(DockDrag::Split { .. }) => iced::mouse::Interaction::ResizingVertically, + Some(DockDrag::Move { .. }) => iced::mouse::Interaction::Grabbing, + // Widths, the Layer Manager column and the xref drags. + _ => iced::mouse::Interaction::ResizingHorizontally, + }); + } + capture.into() + } + + /// Build one edge: the vertical icon strip at the very edge plus the + /// edge's shown group beside it. The strip lists every group as a grip + /// bar over its pallets' icons, with a + button below; clicking an icon + /// shows its group. The shown group's pallets stack top to bottom, each + /// at its weighted share of the height, with a draggable splitter between + /// neighbours. A group whose pallets all auto-hide shows only while one + /// of its icons (or the group itself) is hovered; leaving the edge hides + /// it again. `None` when nothing on the edge is open. + pub(in crate::app) fn build_edge_stack<'a>( + &'a self, + side: crate::app::config::DockSide, + tab: &'a DocumentTab, + ) -> Option> { + let gi = self.dock_shown_group(side)?; + let strip = self.dock_icon_strip(side); + let column_el = self.dock_edge_expanded(side).then(|| { + let width = self.dock_group_width(side, gi); + let group = &self.dock.groups(side)[gi]; + let mut layers: Vec> = Vec::new(); + let mut prev: Option = None; + for (pos, id) in group.panels.iter().enumerate() { + if !self.dock_panel_visible(*id) { + continue; + } + if let Some(upper) = prev { + layers.push(dock_splitter(side, gi, upper, pos, width)); + } + layers.push( + container(self.expanded_panel(*id, side, width, tab)) + .height(Length::FillPortion(crate::ui::dock::portion( + group.weights[pos], + ))) + .into(), + ); + prev = Some(pos); + } + column(layers).height(Fill).into() + }); + + let mut children: Vec> = Vec::new(); + match side { + crate::app::config::DockSide::Left => { + children.push(strip); + children.extend(column_el); + } + crate::app::config::DockSide::Right => { + children.extend(column_el); + children.push(strip); + } + } + Some( + mouse_area(row(children).height(Fill)) + .on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit)) + .into(), + ) + } + + /// An edge's vertical icon strip: per group its open pallets' icons in a + /// row ended by a hairline divider, then a + button opening the pallet + /// menu. The band along a group's window-side edge is its grip: it + /// carries the blue bar of the shown group, lights up while hovered (or + /// while it is a drop target) and drags the whole group; an icon shows its + /// group on click and drags just that pallet. Heights follow + /// `dock::strip_layout`, which drop targeting uses too. + fn dock_icon_strip(&self, side: crate::app::config::DockSide) -> Element<'_, Message> { + use crate::app::config::DockSide; + use crate::ui::dock::{ + DockMsg, DropTarget, DOCK_STRIP_W, STRIP_CELL_H, STRIP_DIVIDER_H, STRIP_GRIP_W, + STRIP_PAD, STRIP_PLUS_GAP, + }; + let shown = self.dock_shown_group(side); + let movement = self.dock_drag.and_then(|d| d.movement()); + let move_target = movement.and_then(|(_, _, target)| target); + let dragging = move_target.is_some(); + let target_group = match move_target { + Some(DropTarget::Join { side: s, group, .. }) if s == side => Some(group), + _ => None, + }; + let tip_side = match side { + DockSide::Left => iced::widget::tooltip::Position::Right, + DockSide::Right => iced::widget::tooltip::Position::Left, + }; + let mut items: Vec> = Vec::new(); + for gi in self.dock_visible_groups(side) { + let is_shown = shown == Some(gi); + let lit = target_group == Some(gi) + || (!dragging && self.dock_grip_hover == Some((side, gi))); + let group_dragged = + dragging && movement.is_some_and(|(_, group, _)| group == Some((side, gi))); + let panels = self.dock_group_visible(side, gi); + let row_h = 2.0 * STRIP_PAD + panels.len() as f32 * STRIP_CELL_H; + + let mut icons: Vec> = vec![Space::new().height(STRIP_PAD).into()]; + for id in panels { + let faded = group_dragged + || (dragging + && movement.is_some_and(|(panel, group, _)| group.is_none() && panel == id)); + let glyph = if faded { + crate::ui::icons::themed_disabled(id.icon(), 20.0) + } else if is_shown { + crate::ui::icons::themed(id.icon(), 20.0) + } else { + crate::ui::icons::themed_secondary(id.icon(), 20.0) + }; + let hovered = !dragging && self.dock_icon_hover == Some(id); + let cell = container(glyph) + .center_x(Length::Fixed(STRIP_CELL_H)) + .center_y(Length::Fixed(STRIP_CELL_H - 2.0)) + .style(move |theme: &Theme| container::Style { + background: hovered + .then(|| Background::Color(theme.palette().background.weak.color)), + border: Border { + radius: 5.0.into(), + ..Default::default() + }, + ..Default::default() + }); + let cell = mouse_area( + container(cell) + .center_x(Length::Fixed(DOCK_STRIP_W)) + .center_y(Length::Fixed(STRIP_CELL_H)), + ) + .on_press(Message::Dock(DockMsg::IconPress(id))) + .on_enter(Message::Dock(DockMsg::Hover(Some(id)))) + .on_exit(Message::Dock(DockMsg::Hover(None))) + .interaction(iced::mouse::Interaction::Pointer); + icons.push(cell.into()); + } + + // The group grip: a band on the window side holding the edge bar. + // Explicit heights: the tooltip around the band sizes it to its + // content, so a `Fill` bar would stop short of the row's bottom. + let bar_w = if lit { 5.0 } else { 3.0 }; + let bar_inset = 5.0; + let bar = container( + Space::new() + .width(bar_w) + .height(Length::Fixed(row_h - 2.0 * bar_inset)), + ) + .style(move |theme: &Theme| { + let palette = theme.palette(); + let (color, glow) = if lit { + (Some(palette.primary.weak.color), true) + } else if is_shown { + (Some(palette.primary.base.color), false) + } else { + (None, false) + }; + container::Style { + background: color.map(Background::Color), + border: Border { + radius: match side { + DockSide::Left => iced::border::Radius::default().right(3.0), + DockSide::Right => iced::border::Radius::default().left(3.0), + }, + ..Default::default() + }, + shadow: if glow { + iced::Shadow { + color: palette.primary.base.color.scale_alpha(0.55), + offset: iced::Vector::new(0.0, 0.0), + blur_radius: 8.0, + } + } else { + iced::Shadow::default() + }, + ..Default::default() + } + }); + let band = mouse_area( + container(bar) + .width(Length::Fixed(STRIP_GRIP_W)) + .height(Length::Fixed(row_h)) + .align_y(iced::alignment::Vertical::Center) + .align_x(match side { + DockSide::Left => iced::alignment::Horizontal::Left, + DockSide::Right => iced::alignment::Horizontal::Right, + }), + ) + .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) + .on_enter(Message::Dock(DockMsg::GripHover(Some((side, gi))))) + .on_exit(Message::Dock(DockMsg::GripHover(None))) + .interaction(iced::mouse::Interaction::Grab); + let band = iced::widget::tooltip( + band, + text(t!("Drag to move group")).size(10), + tip_side, + ) + .gap(4); + let band = container(band) + .width(Fill) + .height(Length::Fixed(row_h)) + .align_x(match side { + DockSide::Left => iced::alignment::Horizontal::Left, + DockSide::Right => iced::alignment::Horizontal::Right, + }); + items.push( + container(stack![column(icons), band]) + .width(Length::Fixed(DOCK_STRIP_W)) + .height(Length::Fixed(row_h)) + .style(move |theme: &Theme| container::Style { + background: lit.then(|| { + Background::Color(theme.palette().primary.weak.color.scale_alpha(0.12)) + }), + ..Default::default() + }) + .into(), + ); + items.push( + container(Space::new()) + .width(Fill) + .height(Length::Fixed(STRIP_DIVIDER_H)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.neutral.color, + )), + ..Default::default() + }) + .into(), + ); + } + items.push(Space::new().height(STRIP_PLUS_GAP).into()); + let plus = button(crate::ui::icons::themed_secondary(crate::ui::icons::PLUS, 14.0)) + .on_press(Message::Dock(DockMsg::EdgeMenu(Some(side)))) + .style(|theme: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => { + theme.palette().background.weak.color + } + _ => theme.palette().background.weakest.color, + })), + border: Border { + radius: 14.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .width(Length::Fixed(28.0)) + .height(Length::Fixed(28.0)) + .padding(7); + items.push(container(plus).center_x(Length::Fixed(DOCK_STRIP_W)).into()); + container(column(items)) + .width(Length::Fixed(DOCK_STRIP_W)) + .height(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() + } + + /// The content of panel `id` at `width`. A `floating` panel leaves its + /// title to the floating frame's vertical title bar. + pub(in crate::app) fn panel_body<'a>( + &'a self, + id: crate::ui::dock::PanelId, + width: f32, + floating: bool, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + let chrome = crate::ui::dock::Chrome { + auto_collapse: self.dock.auto_hides(id), + floating, + title_hovered: self.dock_title_hover == Some(id), + }; + match id { + crate::ui::dock::PanelId::Properties => tab.properties.view(width, chrome), + crate::ui::dock::PanelId::BlockPalette => { + crate::ui::window::block_palette::view(&self.block_palette, width, chrome) + } + crate::ui::dock::PanelId::ExternalReferences => self.xref_manager.view( + width, + chrome, + tab.xref_missing, + &tab.scene.document, + ), + crate::ui::dock::PanelId::Browser => crate::ui::window::browser::view( + &tab.scene.document, + tab.sketch_session.as_ref().map(|session| session.name.as_str()), + width, + chrome, + ), + crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, chrome), + crate::ui::dock::PanelId::Count => crate::ui::window::count_palette::view( + &self.count_palette, + tab.count.as_ref(), + &tab.scene.document, + tab.id, + tab.scene.geometry_epoch, + width, + chrome, + ), + crate::ui::dock::PanelId::PointCloudManager => crate::ui::window::pc_manager::view( + &self.pc_manager, + &tab.scene.document, + &tab.scene.selected_handles_in_order(), + width, + chrome, + ), + crate::ui::dock::PanelId::SheetSetManager => { + crate::ui::window::sheet_set::view(&self.sheet_set, width, chrome) + } + crate::ui::dock::PanelId::Layers => { + tab.layers.view_panel(self.layer_name_col_w, width, chrome) + } + } + } + + /// A pallet of the shown group: its body plus a grabbable divider + /// against the viewport (sizing the whole group). 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>( + &'a self, + id: crate::ui::dock::PanelId, + side: crate::app::config::DockSide, + width: f32, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + let panel = self.panel_body(id, width, false, tab); + let divider = dock_divider(id); + match side { + crate::app::config::DockSide::Left => row![panel, divider].height(Fill).into(), + crate::app::config::DockSide::Right => row![divider, panel].height(Fill).into(), + } + } + + /// A floating panel at its saved place: a vertical title bar on the side + /// facing the nearer workspace edge, the panel beside it, and a corner + /// grip to resize it. With its pin on, the panel hides down to the title + /// bar until hovered. + 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(); + // The bar faces the nearer workspace edge; while resizing, it stays + // opposite the grip being dragged so it cannot flip mid-drag. + let bar_left = match self.dock_drag { + Some(crate::ui::dock::DockDrag::FloatSize { panel, from_left }) if panel == f.id => { + !from_left + } + _ => f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5, + }; + let hidden = self.dock.auto_collapse(f.id) && self.dock_peek != Some(f.id); + let bar = floating_title_bar(f.id, self.dock.auto_collapse(f.id), bar_left); + let content: Element<'_, Message> = if hidden { + bar + } else { + // The resize grip sits in the bottom corner away from the bar. + let grip_left = !bar_left; + let grip_icon = crate::ui::icons::themed_secondary( + if grip_left { + crate::ui::icons::RESIZE_LEFT + } else { + crate::ui::icons::RESIZE + }, + 12.0, + ); + let grip = mouse_area( + container(grip_icon) + .center_x(Length::Fixed(14.0)) + .center_y(Length::Fixed(14.0)), + ) + .on_press(Message::Dock(DockMsg::FloatResizeGrab(f.id, grip_left))) + .interaction(if grip_left { + iced::mouse::Interaction::ResizingDiagonallyUp + } else { + iced::mouse::Interaction::ResizingDiagonallyDown + }); + let body = stack![ + self.panel_body(f.id, f.w, true, tab), + container(grip) + .width(Fill) + .height(Fill) + .align_x(if grip_left { + iced::alignment::Horizontal::Left + } else { + iced::alignment::Horizontal::Right + }) + .align_y(iced::alignment::Vertical::Bottom), + ] + .width(Length::Fixed(f.w)) + .height(Fill); + if bar_left { + row![bar, body].into() + } else { + row![body, bar].into() + } + }; + let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; + let framed = container(content) + .height(Length::Fixed(f.h)) + .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))) + .on_enter(Message::Dock(DockMsg::Hover(Some(f.id)))) + .on_exit(Message::Dock(DockMsg::HoverExit)) + .interaction(iced::mouse::Interaction::Idle); + // A hidden panel keeps its title bar where it sits when shown. + let x = if hidden && !bar_left { f.x + f.w } else { f.x }; + // Keep the panel on screen when the window shrank since it was placed. + let x = x.min(ww - total_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 — stacked into a group + /// (sized as that group will be), as a new group of its own (full + /// height), or as 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, avail) = self.dock_workspace_size(); + let strip = crate::ui::dock::DOCK_STRIP_W; + let tint = |side: DockSide, w: f32| -> Element<'_, Message> { + let band = container(Space::new()) + .width(Length::Fixed(w + strip)) + .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 mut layers: Vec> = Vec::new(); + let docked = match target { + DropTarget::Edge { side, index } => { + let mut after = self.dock.clone(); + after.dock(id, side, index); + Some((side, after)) + } + DropTarget::Join { side, group, index } => { + let mut after = self.dock.clone(); + after.join_group(id, side, group, index); + Some((side, after)) + } + DropTarget::Float { x, y } => { + let (w, h) = self.dock_float_size(id); + layers.push(place_at( + dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), + x, + y, + )); + None + } + }; + // A whole group shows only the strip feedback and its ghost; the + // column keeps showing what is there. + let group_drag = self.dock_drag.and_then(|d| d.movement()).and_then(|(_, g, _)| g); + let docked = docked.map(|(side, after)| (side, group_drag.is_none().then_some(after))); + let marks_side = docked.as_ref().map(|(side, _)| *side); + if let Some((side, Some(after))) = docked { + // Lay the group out as it will be after the drop. + if let Some((_, gi)) = after.location(id) { + let group = &after.groups(side)[gi]; + let open: Vec = (0..group.panels.len()) + .filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i])) + .collect(); + let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); + let spans = crate::ui::dock::slot_spans(&weights, avail); + let at = open + .iter() + .position(|i| group.panels[*i] == id) + .expect("dropped panel"); + let (top, bottom) = spans[at]; + let w = group + .panels + .iter() + .map(|p| after.width(*p, self.win_size.0)) + .fold(0.0, f32::max); + let x = match side { + DockSide::Left => strip, + DockSide::Right => (ww - w - strip).max(0.0), + }; + layers.push(tint(side, w)); + layers.push(place_at(dock_ghost(id, w, bottom - top), x, top)); + } + } + // On top: the strip's drop marks and a ghost of what is dragged + // following the pointer. + if let Some(side) = marks_side { + layers.extend(self.dock_strip_drop_marks(side, target)); + if let Some(p) = self.dock_drag_last { + let icons = match group_drag { + Some((s, g)) => self.dock_group_visible(s, g), + None => vec![id], + }; + layers.push(place_at(dock_icon_ghost(&icons), p.x - 16.0, p.y - 15.0)); + } + } + iced::widget::Stack::with_children(layers) + .width(Fill) + .height(Fill) + .into() + } + + /// The icon strip's drop marks for `target` on `side`: a short line with + /// end caps between a group's icons (join that group there), or a + /// full-width line on a divider with a "+ New group" badge. + fn dock_strip_drop_marks( + &self, + side: crate::app::config::DockSide, + target: crate::ui::dock::DropTarget, + ) -> Vec> { + use crate::app::config::DockSide; + use crate::ui::dock::{DropTarget, DOCK_STRIP_W, STRIP_CELL_H}; + let (ww, _) = self.dock_workspace_size(); + let strip_x = match side { + DockSide::Left => 0.0, + DockSide::Right => ww - DOCK_STRIP_W, + }; + let (layout, _) = self.dock_strip_layout(side); + let mut marks: Vec> = Vec::new(); + match target { + DropTarget::Join { group, index, .. } => { + let Some(g) = layout.iter().find(|g| g.group == group) else { + return marks; + }; + // Icons above the insertion point, counted as the strip shows + // them (the dragged icon still sits in its old place). + let dragged = self.dock_drag.and_then(|d| d.movement()).map(|(p, _, _)| p); + let above = self.dock.groups(side)[group].panels[..index] + .iter() + .filter(|p| self.dock_panel_visible(**p) || Some(**p) == dragged) + .count(); + let y = g.icons_top + above as f32 * STRIP_CELL_H; + marks.push(place_at(dock_insert_line(), strip_x + 7.0, y - 3.5)); + } + DropTarget::Edge { index, .. } => { + let y = layout + .iter() + .find(|g| g.group >= index) + .map(|g| g.top) + .or_else(|| layout.last().map(|g| g.bottom())) + .unwrap_or(0.0); + marks.push(place_at( + container(Space::new()) + .width(Length::Fixed(DOCK_STRIP_W)) + .height(Length::Fixed(4.0)) + .style(dock_drop_line_style) + .into(), + strip_x, + (y - 2.0).max(0.0), + )); + const BADGE_W: f32 = 96.0; + let badge = container(text(format!("+ {}", t!("New group"))).size(10)) + .width(Length::Fixed(BADGE_W)) + .center_x(Length::Fixed(BADGE_W)) + .padding([2, 6]) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().primary.base.color)), + text_color: Some(theme.palette().primary.base.text), + border: Border { + radius: 9.0.into(), + ..Default::default() + }, + ..Default::default() + }); + let badge_x = match side { + DockSide::Left => DOCK_STRIP_W + 6.0, + DockSide::Right => ww - DOCK_STRIP_W - 6.0 - BADGE_W, + }; + marks.push(place_at(badge.into(), badge_x, (y - 9.0).max(0.0))); + } + DropTarget::Float { .. } => {} + } + marks + } +} + +/// The glowing accent used by the strip's drop lines. +fn dock_drop_line_style(theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(theme.palette().primary.base.color)), + border: Border { + radius: 2.0.into(), + ..Default::default() + }, + shadow: iced::Shadow { + color: theme.palette().primary.base.color.scale_alpha(0.8), + offset: iced::Vector::new(0.0, 0.0), + blur_radius: 6.0, + }, + ..Default::default() + } +} + +/// The short insertion line with end caps shown between a group's icons. +fn dock_insert_line() -> Element<'static, Message> { + let cap = || { + container(Space::new()) + .width(Length::Fixed(3.0)) + .height(Length::Fixed(7.0)) + .style(dock_drop_line_style) + }; + row![ + cap(), + container(Space::new()) + .width(Length::Fixed(20.0)) + .height(Length::Fixed(3.0)) + .style(dock_drop_line_style), + cap(), + ] + .align_y(iced::Center) + .height(Length::Fixed(7.0)) + .into() +} + +/// The ghost following the pointer while pallets are dragged: their icons +/// in an accent-framed box. +fn dock_icon_ghost(icons: &[crate::ui::dock::PanelId]) -> Element<'static, Message> { + let glyphs: Vec> = icons + .iter() + .map(|id| { + container(crate::ui::icons::themed(id.icon(), 18.0)) + .center_x(Length::Fixed(32.0)) + .center_y(Length::Fixed(30.0)) + .into() + }) + .collect(); + container(column(glyphs)) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.primary.base.color.scale_alpha(0.22))), + border: Border { + color: palette.primary.base.color, + width: 1.5, + radius: 6.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +/// Canvas that draws a label rotated 90° (for a collapsed panel's bar). +struct VBarLabel { + text: String, + clockwise: bool, + /// Start the text at the reading-start end of the bar instead of + /// centring it. + from_start: bool, +} + +impl canvas::Program for VBarLabel { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced::Renderer, + theme: &Theme, + bounds: iced::Rectangle, + _cursor: iced::advanced::mouse::Cursor, + ) -> Vec { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + frame.with_save(|frame| { + frame.translate(iced::Vector::new(bounds.width / 2.0, bounds.height / 2.0)); + frame.rotate(iced::Radians(if self.clockwise { + std::f32::consts::FRAC_PI_2 + } else { + -std::f32::consts::FRAC_PI_2 + })); + // After the rotation the text runs along local +x, so the + // reading start sits at -height/2. + let (position, align_x) = if self.from_start { + ( + iced::Point::new(-bounds.height / 2.0, 0.0), + iced::advanced::text::Alignment::Left, + ) + } else { + (iced::Point::ORIGIN, iced::advanced::text::Alignment::Center) + }; + frame.fill_text(canvas::Text { + content: self.text.clone(), + position, + color: theme.palette().background.base.text.scale_alpha(0.72), + size: iced::Pixels(13.0), + align_x, + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + }); + vec![frame.into_geometry()] + } +} + +/// Width of a floating panel's vertical title bar. +const DOCK_FLOAT_BAR_W: f32 = 28.0; + +/// A floating panel's vertical title bar: close then the hide (auto-collapse) +/// pin at the top, the panel's icon at the bottom with its title reading +/// upward above it. Pressing the bar's free area drags the panel. +fn floating_title_bar( + id: crate::ui::dock::PanelId, + auto_collapse: bool, + on_left: bool, +) -> Element<'static, Message> { + use crate::ui::dock::DockMsg; + let tip = if on_left { + iced::widget::tooltip::Position::Left + } else { + iced::widget::tooltip::Position::Right + }; + let close = crate::ui::dock::close_button(id, tip); + let pin = crate::ui::dock::pin_button(id, auto_collapse, tip); + let title = canvas(VBarLabel { + text: id.title().to_string(), + clockwise: false, + from_start: true, + }) + .width(Fill) + .height(Fill); + let bar = column![ + close, + pin, + container(title).width(Fill).height(Fill).padding([6, 0]), + container(crate::ui::icons::themed(id.icon(), 16.0)).center_x(Fill), + ] + .spacing(2) + .padding([4, 0]) + .align_x(iced::Center) + .width(Length::Fixed(DOCK_FLOAT_BAR_W)) + .height(Fill); + mouse_area(container(bar).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + })) + .on_press(Message::Dock(DockMsg::DockGrab(id))) + // Double-click docks it against the edge its title bar faces. + .on_double_click(Message::Dock(DockMsg::DockTo( + id, + if on_left { + crate::app::config::DockSide::Left + } else { + crate::app::config::DockSide::Right + }, + ))) + .interaction(iced::mouse::Interaction::Grab) + .into() +} + +/// Grabbable separator for a docked panel managed by the general dock. Same +/// visual as the previous per-panel divider but emits generic dock messages. +fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { + let line = container(Space::new()) + .width(Length::Fixed(DOCK_DIVIDER_W)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }); + let grab = Message::Dock(crate::ui::dock::DockMsg::ResizeGrab(id)); + let reset = Message::Dock(crate::ui::dock::DockMsg::WidthReset(id)); + mouse_area(line) + .on_press(grab) + .on_double_click(reset) + .interaction(iced::mouse::Interaction::ResizingHorizontally) + .into() +} + + +/// Width of the icon strip's pallet menu. +const DOCK_MENU_W: f32 = 230.0; + +/// The pallet menu of an edge's + button, styled like the right-click menu: +/// one row per pallet with its icon, its name and a check mark when it is +/// open on this edge. `shown` holds each pallet with that state. +fn dock_edge_menu( + side: crate::app::config::DockSide, + shown: &[(crate::ui::dock::PanelId, bool)], +) -> Element<'static, Message> { + use crate::ui::dock::DockMsg; + let rows: Vec> = shown + .iter() + .map(|(id, checked)| { + let id = *id; + let check: Element<'static, Message> = if *checked { + crate::ui::icons::themed(crate::ui::icons::CHECK, 12.0) + } else { + Space::new().width(12).height(12).into() + }; + button( + row![ + crate::ui::icons::themed(id.icon(), 16.0), + text(id.title()).size(12).width(Fill), + check, + ] + .spacing(8) + .align_y(iced::Center), + ) + .on_press(Message::Dock(DockMsg::EdgeMenuToggle(side, id))) + .style(|theme: &Theme, status| { + let palette = theme.palette(); + button::Style { + background: matches!(status, button::Status::Hovered | button::Status::Pressed) + .then(|| Background::Color(palette.background.weak.color)), + text_color: palette.background.base.text, + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + } + }) + .padding([5, 8]) + .width(Fill) + .into() + }) + .collect(); + container(column(rows).spacing(1).padding(4)) + .width(Length::Fixed(DOCK_MENU_W)) + .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: 4.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: 12.0, + }, + ..Default::default() + }) + .into() +} + +/// Draggable bar between two stacked pallets of group `gi`; double-click +/// gives every pallet of the group the same height again. It spans exactly +/// the pallets' width (panel plus its divider): a `Fill` width would stretch +/// the whole edge column across the workspace and squeeze the drawing view. +fn dock_splitter( + side: crate::app::config::DockSide, + gi: usize, + upper: usize, + lower: usize, + col_w: f32, +) -> Element<'static, Message> { + let line = container(Space::new()) + .width(Length::Fixed(col_w + DOCK_DIVIDER_W)) + .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, gi, upper, lower))) + .on_double_click(Message::Dock(crate::ui::dock::DockMsg::SplitReset(side, gi))) + .interaction(iced::mouse::Interaction::ResizingVertically) + .into() +} + +/// The blue drop ghost: a `w`×`h` outline of where pallet `id` lands, with +/// its name as the header. +fn dock_ghost(id: crate::ui::dock::PanelId, w: f32, h: f32) -> Element<'static, Message> { + let header = 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(|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() + } + }); + container(column![header, 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() +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 56c0e8628..706dde860 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod dim_viewport; mod viewport_dimension_tests; #[cfg(test)] mod dimension_preview_tests; +mod dock; mod document; mod drafting_settings; pub(crate) mod expr_eval; diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 0ccc36442..9386406fa 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -20,9 +20,6 @@ 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 { @@ -541,629 +538,6 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven Ok(name) } - /// Dock chrome interaction (grab / pin / resize / hover / move) applied to - /// 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::{DockDrag, DockMsg, DropTarget, FloatPanel}; - match m { - DockMsg::DockGrab(id) => self.dock_begin(DockDrag::moving(id, None), Some(id)), - DockMsg::IconPress(id) => { - // An icon press shows the icon's group and may also start - // dragging the pallet; a plain click never passes the drag - // threshold. - if self.dock.show_group_of(id) { - self.save_config(); - } - self.dock_begin(DockDrag::moving(id, None), Some(id)); - } - DockMsg::GroupGrab(side, gi) => { - if let Some(&id) = self.dock_group_visible(side, gi).first() { - self.dock_begin(DockDrag::moving(id, Some((side, gi))), Some(id)); - } - } - DockMsg::ResizeGrab(id) => self.dock_begin(DockDrag::Width(id), Some(id)), - DockMsg::SplitGrab(side, group, upper, lower) => self.dock_begin( - DockDrag::Split { - side, - group, - upper, - lower, - }, - None, - ), - DockMsg::FloatResizeGrab(panel, from_left) => { - self.dock.raise_float(panel); - self.dock_begin(DockDrag::FloatSize { panel, from_left }, None); - } - DockMsg::SplitReset(side, gi) => { - self.dock.reset_splits(side, gi); - self.save_config(); - } - DockMsg::WidthReset(id) => { - if let Some((side, gi)) = self.dock.location(id) { - self.dock.reset_group_width(side, gi); - } else { - self.dock.reset_width(id); - } - self.save_config(); - } - DockMsg::TitleHover(id) => self.dock_title_hover = id, - DockMsg::GripHover(grip) => self.dock_grip_hover = grip, - DockMsg::FloatRaise(id) => self.dock.raise_float(id), - DockMsg::FloatOut(id) => { - // Float beside the edge it was docked on, near the top. - let (ww, _) = self.dock_workspace_size(); - let (w, h) = self.dock_float_size(id); - let strip = crate::ui::dock::DOCK_STRIP_W; - let x = match self.dock.location(id) { - Some((DockSide::Right, _)) => { - ww - strip - self.dock_column_width(DockSide::Right) - w - 60.0 - } - _ => strip + self.dock_column_width(DockSide::Left) + 40.0, - }; - self.dock_drag = None; - if self.dock.float(FloatPanel { - id, - x: x.max(0.0), - y: 40.0, - w, - h, - }) { - self.save_config(); - } - } - DockMsg::DockTo(id, side) => { - self.dock_drag = None; - if self.dock.dock(id, side, usize::MAX) { - self.save_config(); - } - } - DockMsg::EdgeMenu(side) => self.dock_edge_menu = side, - DockMsg::EdgeMenuToggle(side, id) => { - // Checked = open and docked on this edge: unchecking hides it. - // Anything else (closed, floating, on the other edge) moves - // here and opens, in its own group here if it has one, else - // as a new group; adding a pallet ends the menu's job. - let here = self.dock.location(id).map(|(s, _)| s) == Some(side); - if !here { - self.dock.dock(id, side, usize::MAX); - } else if self.dock_panel_visible(id) { - let task = self.dock_set_open(id, false); - self.save_config(); - return task; - } - self.dock_edge_menu = None; - let task = self.dock_set_open(id, true); - self.save_config(); - return task; - } - DockMsg::AutoCollapseToggle(id) => { - // Docked: auto-hide for the whole edge; floating: this pallet. - if self.dock.toggle_auto_hide(id) { - self.dock_peek = None; - } - self.save_config(); - } - DockMsg::Close(id) => return self.dock_set_open(id, false), - DockMsg::Hover(id) => { - self.dock_icon_hover = id; - // Only an auto-hiding edge (or floating pallet) reacts to - // hover: it reveals the hovered pallet's group. Otherwise - // groups switch on click. Ignored mid-drag, when the pointer - // is over the preview rather than the strip. - if let Some(id) = id { - if self.dock_drag.is_none() && self.dock.auto_hides(id) { - self.dock_reveal(id); - } - } - } - DockMsg::HoverExit => { - if self.dock_drag.is_none() - && self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) - { - self.dock_peek = None; - } - } - DockMsg::DragMove(point) => self.dock_drag_move(point), - DockMsg::DragRelease => { - let mut changed = false; - match self.dock_drag.take() { - Some(DockDrag::Move { - group: Some((side, gi)), - target: Some(DropTarget::Edge { side: to, index }), - .. - }) => changed = self.dock.move_group(side, gi, to, index), - Some(DockDrag::Move { - panel, - group: None, - target: Some(target), - .. - }) => { - changed = match target { - DropTarget::Edge { side, index } => self.dock.dock(panel, side, index), - DropTarget::Join { side, group, index } => { - self.dock.join_group(panel, side, group, index) - } - DropTarget::Float { x, y } => { - let (w, h) = self.dock_float_size(panel); - self.dock.float(FloatPanel { - id: panel, - x, - y, - w, - h, - }) - } - }; - } - Some(DockDrag::Move { .. } | DockDrag::LayerColumn) | None => {} - Some(DockDrag::Width(_) | DockDrag::Split { .. } | DockDrag::FloatSize { .. }) => { - changed = true; - } - } - if changed { - self.save_config(); - } - self.dock_drag_last = None; - self.xref_col_drag = None; - self.xref_col_last = None; - self.xref_split_drag = false; - } - } - iced::Task::none() - } - - /// Pointer motion while something in the dock is dragged. - fn dock_drag_move(&mut self, point: iced::Point) { - // NOTE: xref column drags intentionally do NOT ride this path: its - // points live in workspace space while the header 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. - use crate::app::config::DockSide; - use crate::ui::dock::{DockDrag, DropTarget}; - let last = self.dock_drag_last.replace(point); - let Some(drag) = self.dock_drag else { - return; - }; - match drag { - DockDrag::Move { - panel, - group, - origin, - target, - grab, - } => { - let origin = origin.unwrap_or(point); - let moved = (point.x - origin.x).hypot(point.y - origin.y); - let started = target.is_some() || moved >= DOCK_DRAG_THRESHOLD; - // Hold a floating panel where it was grabbed; a docked one by - // its title bar. - let grab = if target.is_none() && started { - match self.dock.float_rect(panel) { - Some(f) => iced::Vector::new(origin.x - f.x, origin.y - f.y), - None => iced::Vector::new( - (self.dock.width(panel, self.win_size.0) * 0.5).min(120.0), - 14.0, - ), - } - } else { - grab - }; - // Store the grab first: the drop target reads it. - self.dock_drag = Some(DockDrag::Move { - panel, - group, - origin: Some(origin), - target, - grab, - }); - if !started { - return; - } - let found = self.dock_drop_target(point); - // A whole group only moves between group positions: it - // cannot join another group or float. - let target = match (group, found) { - (Some(_), DropTarget::Edge { .. }) | (None, _) => Some(found), - (Some(_), _) => None, - }; - self.dock_drag = Some(DockDrag::Move { - panel, - group, - origin: Some(origin), - target, - grab, - }); - } - DockDrag::Split { - side, - group, - upper, - lower, - } => { - let Some(last) = last else { return }; - let (_, avail) = self.dock_workspace_size(); - let total: f32 = self - .dock_slot_spans(side) - .iter() - .map(|(i, _, _)| self.dock.groups(side)[group].weights[*i]) - .sum(); - if avail > 0.0 && total > 0.0 { - let per_px = total / avail; - self.dock.shift_split( - side, - group, - upper, - lower, - (point.y - last.y) * per_px, - crate::ui::dock::GROUP_MIN_H * per_px, - ); - } - } - DockDrag::FloatSize { panel, from_left } => { - let (Some(last), Some(f)) = (last, self.dock.float_rect(panel)) else { - return; - }; - let dx = point.x - last.x; - self.dock.resize_float( - panel, - if from_left { f.w - dx } else { f.w + dx }, - f.h + point.y - last.y, - from_left, - ); - } - DockDrag::LayerColumn => { - let Some(last) = last else { return }; - self.layer_name_col_w = - (self.layer_name_col_w + point.x - last.x).clamp(60.0, 640.0); - } - DockDrag::Width(id) => { - let Some(last) = last else { return }; - let dx = point.x - last.x; - match self.dock.location(id) { - // The divider sizes the panel's group. - Some((side, gi)) => { - let delta = if side == DockSide::Left { dx } else { -dx }; - let cur = self.dock_group_width(side, gi) + delta; - self.dock.set_group_width(side, gi, cur); - } - None => { - let cur = self.dock.settings(id).width + dx; - self.dock.set_width(id, cur); - } - } - } - } - } - - /// Start `drag`, peeking `peek` (a pallet being dragged or resized stays - /// shown on an auto-hiding edge). - fn dock_begin(&mut self, drag: crate::ui::dock::DockDrag, peek: Option) { - if let crate::ui::dock::DockDrag::Move { panel, .. } = drag { - self.dock.raise_float(panel); - } - self.dock_drag = Some(drag); - self.dock_drag_last = None; - self.dock_edge_menu = None; - if peek.is_some() { - self.dock_peek = peek; - } - } - - /// Bring `id` into view: show its group on its edge and, where it - /// auto-hides, keep it revealed until the pointer leaves. - pub(crate) fn dock_reveal(&mut self, id: crate::ui::dock::PanelId) { - self.dock.show_group_of(id); - self.dock_peek = Some(id); - } - - /// Open or close pallet `id`, running its usual open path (refreshes, - /// ribbon highlight) or close path. An opened pallet is revealed where - /// the layout has it. - pub(crate) fn dock_set_open( - &mut self, - id: crate::ui::dock::PanelId, - open: bool, - ) -> iced::Task { - use crate::ui::dock::PanelId; - if open { - let task = if self.dock_panel_visible(id) { - iced::Task::none() - } else { - match id { - PanelId::Properties => { - self.show_properties = true; - self.ribbon.set_properties(true); - iced::Task::none() - } - PanelId::BlockPalette => { - self.open_blocks_palette(None); - iced::Task::none() - } - PanelId::ExternalReferences => { - self.show_external_references = true; - self.refresh_xref_manager(); - iced::Task::none() - } - PanelId::Browser => { - self.show_browser = true; - iced::Task::none() - } - PanelId::NodeGraph => self.on_graph(crate::ui::node_graph::GraphMsg::Toggle), - PanelId::PointCloudManager => { - self.pc_manager.show = true; - iced::Task::none() - } - PanelId::Count => { - self.set_count_palette(true); - iced::Task::none() - } - PanelId::SheetSetManager => { - self.show_sheet_set_manager(true); - iced::Task::none() - } - PanelId::Layers => { - self.sync_ribbon_layers(); - self.show_layers = true; - iced::Task::none() - } - } - }; - self.dock_reveal(id); - return task; - } - match id { - PanelId::BlockPalette => { - self.show_block_palette = false; - self.block_palette.placing = None; - } - PanelId::ExternalReferences => self.show_external_references = false, - PanelId::Browser => self.show_browser = false, - PanelId::NodeGraph => self.show_node_graph = false, - PanelId::PointCloudManager => self.pc_manager.show = false, - PanelId::Count => { - self.count_palette.show = false; - self.ribbon.set_count_palette(false); - } - PanelId::SheetSetManager => { - self.sheet_set.show = false; - self.ribbon.set_sheet_set(false); - } - PanelId::Properties => { - self.show_properties = false; - self.ribbon.set_properties(false); - } - PanelId::Layers => { - self.show_layers = false; - self.ribbon.deactivate_tool_if("LAYERS"); - } - } - if self.dock_peek == Some(id) { - self.dock_peek = None; - } - if self - .dock_drag - .is_some_and(|d| d.movement().is_some_and(|(p, _, _)| p == id) || d == crate::ui::dock::DockDrag::Width(id)) - { - self.dock_drag = None; - } - iced::Task::none() - } - - /// 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. - pub(crate) fn dock_panel_visible(&self, id: crate::ui::dock::PanelId) -> bool { - use crate::ui::dock::PanelId; - if self.tabs[self.active_tab].is_start || self.clean_screen { - return false; - } - match id { - PanelId::Properties => self.show_properties, - PanelId::BlockPalette => self.show_block_palette, - PanelId::ExternalReferences => self.show_external_references, - PanelId::Browser => self.show_browser, - PanelId::NodeGraph => self.show_node_graph, - PanelId::PointCloudManager => self.pc_manager.show, - PanelId::Count => self.count_palette.show, - PanelId::SheetSetManager => self.sheet_set.show, - PanelId::Layers => self.show_layers, - } - } - - /// 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, - ) - } - - /// The open pallets of group `gi` on `side`, top → bottom. Closed - /// pallets keep their place but take no space, so a group with none open - /// is not drawn at all. - pub(crate) fn dock_group_visible( - &self, - side: crate::app::config::DockSide, - gi: usize, - ) -> Vec { - self.dock.groups(side)[gi] - .panels - .iter() - .copied() - .filter(|id| self.dock_panel_visible(*id)) - .collect() - } - - /// Indices of the groups on `side` with an open pallet (listed in the - /// icon strip). - pub(crate) fn dock_visible_groups(&self, side: crate::app::config::DockSide) -> Vec { - (0..self.dock.groups(side).len()) - .filter(|gi| !self.dock_group_visible(side, *gi).is_empty()) - .collect() - } - - /// The group `side` draws: the one it shows, or the first with an open - /// pallet when that one has none. - pub(crate) fn dock_shown_group(&self, side: crate::app::config::DockSide) -> Option { - let visible = self.dock_visible_groups(side); - let shown = self.dock.shown(side); - if visible.contains(&shown) { - Some(shown) - } else { - visible.first().copied() - } - } - - /// Whether `side` draws its shown group beside the icon strip: always, - /// unless the edge auto-hides and none of the group's icons is hovered. - pub(crate) fn dock_edge_expanded(&self, side: crate::app::config::DockSide) -> bool { - let Some(gi) = self.dock_shown_group(side) else { - return false; - }; - !self.dock.edge_auto_hide(side) - || self - .dock_peek - .is_some_and(|e| self.dock_group_visible(side, gi).contains(&e)) - } - - /// On-screen width of group `gi` on `side`. - pub(crate) fn dock_group_width(&self, side: crate::app::config::DockSide, gi: usize) -> f32 { - self.dock.group_width_px(side, gi, self.win_size.0) - } - - /// Width of the pallet column beside `side`'s icon strip: the shown - /// group's width, or nothing when it is hidden. - pub(crate) fn dock_column_width(&self, side: crate::app::config::DockSide) -> f32 { - match self.dock_shown_group(side) { - Some(gi) if self.dock_edge_expanded(side) => self.dock_group_width(side, gi), - _ => 0.0, - } - } - - /// The icon strip of `side`: each listed group's place, and the top of - /// the + button. - pub(crate) fn dock_strip_layout( - &self, - side: crate::app::config::DockSide, - ) -> (Vec, f32) { - let groups: Vec<(usize, usize)> = self - .dock_visible_groups(side) - .into_iter() - .map(|gi| (gi, self.dock_group_visible(side, gi).len())) - .collect(); - crate::ui::dock::strip_layout(&groups) - } - - /// The open pallets of `side`'s shown group, each as (position in the - /// group, top, bottom) in the workspace. - pub(crate) fn dock_slot_spans( - &self, - side: crate::app::config::DockSide, - ) -> Vec<(usize, f32, f32)> { - let Some(gi) = self.dock_shown_group(side) else { - return Vec::new(); - }; - let (_, avail) = self.dock_workspace_size(); - let group = &self.dock.groups(side)[gi]; - let open: Vec = (0..group.panels.len()) - .filter(|i| self.dock_panel_visible(group.panels[*i])) - .collect(); - let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); - open.into_iter() - .zip(crate::ui::dock::slot_spans(&weights, avail)) - .map(|(i, (top, bottom))| (i, 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 a dragged pallet lands if released at workspace point `p`: - /// on an edge's icon strip it joins a group between its icons or starts a - /// new group; over the shown group's pallets it stacks above / below the - /// one under the pointer; on an empty edge it starts a group; anywhere - /// else it floats. - pub(crate) fn dock_drop_target(&self, p: iced::Point) -> crate::ui::dock::DropTarget { - use crate::app::config::DockSide; - use crate::ui::dock::{DropTarget, StripHit, DOCK_EDGE_ZONE, DOCK_STRIP_W}; - let (ww, wh) = self.dock_workspace_size(); - for side in [DockSide::Left, DockSide::Right] { - let listed = !self.dock_visible_groups(side).is_empty(); - let from_edge = match side { - DockSide::Left => p.x, - DockSide::Right => ww - p.x, - }; - if !listed { - if from_edge <= DOCK_EDGE_ZONE { - return DropTarget::Edge { - side, - index: self.dock.groups(side).len(), - }; - } - continue; - } - // Over the icon strip. - if from_edge <= DOCK_STRIP_W { - let (layout, _) = self.dock_strip_layout(side); - return match crate::ui::dock::strip_hit(p.y, &layout) { - StripHit::NewGroup(at) => DropTarget::Edge { - side, - index: at.unwrap_or(self.dock.groups(side).len()), - }, - StripHit::Tab { group, index } => { - // Map the gap among open pallets to a position among - // all of the group's pallets. - let open = self.dock_group_visible(side, group); - let all = &self.dock.groups(side)[group].panels; - let index = open - .get(index) - .and_then(|t| all.iter().position(|a| a == t)) - .unwrap_or(all.len()); - DropTarget::Join { side, group, index } - } - }; - } - // Over the shown group's pallets: above or below the one there. - if from_edge <= DOCK_STRIP_W + self.dock_column_width(side) { - let gi = self.dock_shown_group(side).expect("listed edge"); - let spans = self.dock_slot_spans(side); - if let Some(&(pos, top, bottom)) = spans - .iter() - .find(|(_, _, bottom)| p.y < *bottom) - .or(spans.last()) - { - let index = if p.y < (top + bottom) * 0.5 { pos } else { pos + 1 }; - return DropTarget::Join { - side, - group: gi, - index, - }; - } - } - } - let grab = match self.dock_drag { - Some(crate::ui::dock::DockDrag::Move { grab, .. }) => grab, - _ => iced::Vector::new(0.0, 0.0), - }; - let x = (p.x - grab.x).clamp(0.0, (ww - 60.0).max(0.0)); - let y = (p.y - grab.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. /// Uses the tab's session sets (unloaded + stat baseline) so CLI and /// palette agree; writes fresh load-time mtimes back into the tab's stat @@ -2032,613 +1406,6 @@ mod tests { ); } - #[test] - fn blockpalette_pin_toggles_autocollapse_and_close_hides() { - let mut app = fresh(); - app.show_block_palette = true; - let id = crate::ui::dock::PanelId::BlockPalette; - let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); - assert!(app.dock.auto_hides(id), "pin enables auto-hide"); - let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); - assert!(!app.dock.auto_hides(id), "second pin disables auto-hide"); - let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id)); - assert!(!app.show_block_palette, "close dismisses the sidebar"); - } - - /// 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 - } - - /// Where the current dock drag would land, if it has started. - fn drag_target(app: &OpenCADStudio) -> Option { - app.dock_drag.and_then(|d| d.movement()).and_then(|(_, _, target)| target) - } - - /// 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 = drag_target(app); - let _ = app.on_dock(DockMsg::DragRelease); - target - } - - #[test] - fn blockpalette_dropped_on_the_top_half_of_a_pallet_stacks_above_it() { - use crate::app::config::DockSide; - use crate::ui::dock::{DropTarget, PanelId}; - let mut app = dock_app(); - let id = PanelId::BlockPalette; - assert_eq!(app.dock.location(id), Some((DockSide::Right, 0))); - let target = drag_panel( - &mut app, - id, - iced::Point::new(1500.0, 10.0), - iced::Point::new(150.0, 300.0), - ); - assert_eq!( - target, - Some(DropTarget::Join { - side: DockSide::Left, - group: 0, - index: 0 - }) - ); - assert_eq!( - app.dock.left[0].panels, - vec![PanelId::BlockPalette, PanelId::Properties] - ); - assert!(app.dock.right.is_empty()); - } - - #[test] - fn dock_drop_on_the_bottom_half_stacks_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(150.0, 700.0), - ); - assert_eq!( - target, - Some(DropTarget::Join { - side: DockSide::Left, - group: 0, - index: 1 - }) - ); - assert_eq!( - app.dock.left[0].panels, - vec![PanelId::Properties, PanelId::BlockPalette] - ); - } - - #[test] - fn dock_dragging_a_lone_pallet_over_its_own_group_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(); - // The right edge's only group holds just the block palette; drag it - // around over itself. - let target = drag_panel( - &mut app, - PanelId::BlockPalette, - iced::Point::new(1500.0, 10.0), - iced::Point::new(1450.0, 600.0), - ); - assert_eq!( - target, - Some(DropTarget::Join { - side: DockSide::Right, - group: 0, - index: 1 - }) - ); - assert_eq!(app.dock, before); - } - - #[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_below_the_strip_groups_starts_a_new_shown_group() { - use crate::app::config::DockSide; - use crate::ui::dock::{DropTarget, PanelId}; - let mut app = dock_app(); - let (_, plus_top) = app.dock_strip_layout(DockSide::Left); - let target = drag_panel( - &mut app, - PanelId::BlockPalette, - iced::Point::new(1500.0, 10.0), - iced::Point::new(18.0, plus_top + 10.0), - ); - assert_eq!( - target, - Some(DropTarget::Edge { - side: DockSide::Left, - index: 1 - }) - ); - assert_eq!(app.dock.left.len(), 2); - assert_eq!(app.dock.left[1].panels, vec![PanelId::BlockPalette]); - // The new group is the one shown: the column holds only the palette. - assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); - assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]); - } - - #[test] - fn dock_icon_press_switches_the_shown_group() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockGroup, DockMsg, PanelId}; - let mut app = dock_app(); - app.show_browser = true; - app.dock.left = vec![ - DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]), - DockGroup::single(PanelId::Browser), - ]; - app.dock.right.clear(); - // Group 0 shows both stacked pallets. - assert_eq!(app.dock_slot_spans(DockSide::Left).len(), 2); - let _ = app.on_dock(DockMsg::IconPress(PanelId::Browser)); - let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); - assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]); - // Each group keeps its own width. - app.dock.set_width(PanelId::Browser, 400.0); - assert_eq!(app.dock_column_width(DockSide::Left), 400.0); - let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette)); - let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!(app.dock_column_width(DockSide::Left), 260.0); - } - - #[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_dragging_an_icon_along_the_strip_reorders_its_group() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, DropTarget, PanelId, STRIP_CELL_H}; - let mut app = dock_app(); - app.dock.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1); - let (layout, _) = app.dock_strip_layout(DockSide::Left); - let top = layout[0].icons_top; - // Drag the Properties icon onto the lower half of the Blocks icon: - // below it, still in the group (the divider under it would start a - // new group). - let _ = app.on_dock(DockMsg::IconPress(PanelId::Properties)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, top + 10.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new( - 18.0, - top + 2.0 * STRIP_CELL_H - 10.0, - ))); - assert_eq!( - drag_target(&app), - Some(DropTarget::Join { - side: DockSide::Left, - group: 0, - index: 2 - }) - ); - let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!( - app.dock.left[0].panels, - vec![PanelId::BlockPalette, PanelId::Properties] - ); - } - - #[test] - fn dock_dragging_an_icon_into_another_group_lands_between_its_icons() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockGroup, DockMsg, DropTarget, PanelId, STRIP_CELL_H}; - let mut app = dock_app(); - app.show_browser = true; - app.dock.left = vec![ - DockGroup::stack(vec![PanelId::Properties, PanelId::Browser]), - DockGroup::single(PanelId::BlockPalette), - ]; - app.dock.right.clear(); - let (layout, _) = app.dock_strip_layout(DockSide::Left); - // Drag the Blocks icon (group 1) to between Properties and Browser. - let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(20.0, layout[1].icons_top + 10.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new( - 20.0, - layout[0].icons_top + STRIP_CELL_H + 2.0, - ))); - assert_eq!( - drag_target(&app), - Some(DropTarget::Join { - side: DockSide::Left, - group: 0, - index: 1 - }) - ); - let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!(app.dock.left.len(), 1, "the emptied group goes away"); - assert_eq!( - app.dock.left[0].panels, - vec![PanelId::Properties, PanelId::BlockPalette, PanelId::Browser] - ); - } - - #[test] - fn dock_grip_drag_moves_a_whole_group() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockGroup, DockMsg, PanelId}; - let mut app = dock_app(); - app.show_browser = true; - app.dock.left = vec![ - DockGroup::stack(vec![PanelId::Properties, PanelId::Browser]), - DockGroup::single(PanelId::BlockPalette), - ]; - app.dock.right.clear(); - let (layout, plus_top) = app.dock_strip_layout(DockSide::Left); - let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 0)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, layout[0].top + 2.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0))); - let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!(app.dock.left[0].panels, vec![PanelId::BlockPalette]); - assert_eq!( - app.dock.left[1].panels, - vec![PanelId::Properties, PanelId::Browser] - ); - // A group dropped over the viewport does not float or split up. - let before = app.dock.clone(); - let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 1)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, 300.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0))); - assert_eq!(drag_target(&app), None); - let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!(app.dock, before); - } - - #[test] - fn dock_closed_pallets_take_no_room_and_empty_groups_hide() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockGroup, PanelId}; - let mut app = dock_app(); - app.dock.left = vec![ - DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties]), - DockGroup::single(PanelId::Browser), - ]; - app.show_block_palette = false; - app.show_browser = false; - // Only Properties is open: one listed group, full height. - assert_eq!(app.dock_visible_groups(DockSide::Left), vec![0]); - assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(1, 0.0, 900.0)]); - // A shown group with nothing open falls back to one that has. - app.dock.show_group(DockSide::Left, 1); - assert_eq!(app.dock_shown_group(DockSide::Left), Some(0)); - } - - #[test] - fn dock_splitter_drag_changes_pallet_heights() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockGroup, DockMsg, PanelId}; - let mut app = dock_app(); - app.dock.left = vec![DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties])]; - app.dock.right.clear(); - let _ = app.on_dock(DockMsg::SplitGrab(DockSide::Left, 0, 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. - let w = &app.dock.left[0].weights; - assert!((w[0] - 1.5).abs() < 1e-4 && (w[1] - 0.5).abs() < 1e-4); - let _ = app.on_dock(DockMsg::SplitReset(DockSide::Left, 0)); - assert_eq!(app.dock.left[0].weights, vec![1.0, 1.0]); - } - - #[test] - fn dock_auto_hide_edge_shows_the_hovered_group() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockGroup, DockMsg, PanelId}; - let mut app = dock_app(); - app.show_browser = true; - app.dock.left.push(DockGroup::single(PanelId::Browser)); - // The pin on any docked pallet turns auto-hide on for its edge. - let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties)); - assert!(app.dock.edge_auto_hide(DockSide::Left)); - assert!(!app.dock.edge_auto_hide(DockSide::Right)); - assert!(!app.dock_edge_expanded(DockSide::Left)); - // Hovering an icon flies its group out. - let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Browser))); - assert!(app.dock_edge_expanded(DockSide::Left)); - assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); - let _ = app.on_dock(DockMsg::HoverExit); - assert!(!app.dock_edge_expanded(DockSide::Left)); - } - - #[test] - fn dock_hover_does_not_switch_groups_without_auto_hide() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockGroup, DockMsg, PanelId}; - let mut app = dock_app(); - app.show_browser = true; - app.dock.left.push(DockGroup::single(PanelId::Browser)); - let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Browser))); - assert_eq!(app.dock_shown_group(DockSide::Left), Some(0)); - assert!(app.dock_edge_expanded(DockSide::Left)); - } - - #[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, false)); - 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)); - // The bottom-left grip grows the panel leftward, keeping its right edge. - let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, true)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 450.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(60.0, 450.0))); - let _ = app.on_dock(DockMsg::DragRelease); - let g = app.dock.float_rect(PanelId::BlockPalette).unwrap(); - assert_eq!((g.x, g.w, g.h), (f.x - 40.0, 340.0, 350.0)); - } - - #[test] - fn dock_edge_menu_adds_and_hides_pallets() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, PanelId}; - let mut app = dock_app(); - app.show_browser = false; - let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left))); - // Checking a closed pallet opens it as a new, shown group here. - let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); - assert!(app.show_browser); - assert_eq!(app.dock.left.len(), 2); - assert_eq!(app.dock.left[1].panels, vec![PanelId::Browser]); - assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); - assert_eq!(app.dock_edge_menu, None, "the menu closes after adding"); - let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left))); - // Unchecking hides it but keeps its place. - let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); - assert!(!app.show_browser); - assert_eq!(app.dock.location(PanelId::Browser), Some((DockSide::Left, 1))); - // Opening it again from the same edge reuses that place. - let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); - assert_eq!(app.dock.left.len(), 2); - // A pallet open on the other edge is not checked here: picking it - // moves it over instead of hiding it. - let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::BlockPalette)); - assert!(app.show_block_palette); - assert!(app.dock.right.is_empty()); - assert_eq!(app.dock.location(PanelId::BlockPalette), Some((DockSide::Left, 2))); - } - - #[test] - fn dock_divider_resizes_only_its_group() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockGroup, DockMsg, PanelId}; - let mut app = dock_app(); - app.dock.left = vec![ - DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]), - DockGroup::single(PanelId::Count), - ]; - app.dock.right.clear(); - let _ = app.on_dock(DockMsg::ResizeGrab(PanelId::Properties)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(300.0, 100.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(330.0, 100.0))); - let _ = app.on_dock(DockMsg::DragRelease); - // The group was 260 wide (its widest pallet); it is now 290, while - // the other group and the pallets' own widths are untouched. - assert_eq!(app.dock_group_width(DockSide::Left, 0), 290.0); - assert_eq!(app.dock_group_width(DockSide::Left, 1), 280.0); - assert_eq!(app.dock.settings(PanelId::Properties).width, 250.0); - } - - #[test] - fn layers_command_opens_a_floating_pallet_and_toggles_it_closed() { - use crate::ui::dock::PanelId; - let mut app = dock_app(); - let _ = app.update(Message::ToggleLayers); - assert!(app.show_layers); - assert!(app.active_modal.is_none(), "no modal backdrop"); - let f = app.dock.float_rect(PanelId::Layers).expect("floats on first use"); - assert!(f.x > 0.0 && f.y >= 0.0); - let _ = app.update(Message::ToggleLayers); - assert!(!app.show_layers); - // Its place is kept for next time. - assert!(app.dock.float_rect(PanelId::Layers).is_some()); - } - - /// A headless renderer for driving the real view. - fn headless_renderer() -> iced::Renderer { - iced_test::futures::futures::executor::block_on( - ::new( - iced_test::core::renderer::Settings::default(), - None, - ), - ) - .expect("headless renderer") - } - - /// Size the real view is laid out at. - const VIEW_SIZE: iced::Size = iced::Size::new(1600.0, 900.0); - - /// Centre of the first text `label` in the real view. - fn find_in_view(app: &OpenCADStudio, label: &str) -> iced::Point { - use iced_test::core::widget; - use iced_test::runtime::user_interface::{Cache, UserInterface}; - use iced_test::selector::Bounded; - use iced_test::Selector; - let mut renderer = headless_renderer(); - let mut ui = UserInterface::build(app.view_main(), VIEW_SIZE, Cache::default(), &mut renderer); - let mut find = Selector::find(label); - ui.operate(&renderer, &mut widget::operation::black_box(&mut find)); - match widget::Operation::finish(&find) { - widget::operation::Outcome::Some(Some(target)) => target - .visible_bounds() - .expect("visible") - .center(), - _ => panic!("{label} not found"), - } - } - - /// Drive the real view through iced's `UserInterface` with the pointer at - /// `at`: feed `events` one at a time, and between events hand the - /// published messages to the app and rebuild the view while keeping the - /// widget-tree cache, as the runtime does. - fn drive_view(app: &mut OpenCADStudio, at: iced::Point, events: &[iced::Event]) { - use iced_test::core::{mouse, shell, window}; - use iced_test::runtime::user_interface::{Cache, UserInterface}; - let mut renderer = headless_renderer(); - let mut cache = Cache::default(); - let cursor = mouse::Cursor::Available(at); - for event in events { - let mut messages = Vec::new(); - { - let mut ui = UserInterface::build(app.view_main(), VIEW_SIZE, cache, &mut renderer); - let _ = ui.update( - &window::Headless, - &shell::Waker::noop(), - std::slice::from_ref(event), - cursor, - &mut renderer, - &mut messages, - ); - cache = ui.into_cache(); - } - for message in messages { - let _ = app.update(message); - } - } - } - - #[test] - fn docked_title_bar_double_click_floats_the_pallet_in_the_real_view() { - use crate::ui::dock::PanelId; - let mut app = dock_app(); - let press = iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)); - let release = - iced::Event::Mouse(iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left)); - // The block palette's title text sits in its docked title bar. - let title = find_in_view(&app, "Blocks"); - drive_view(&mut app, title, &[press.clone(), release.clone(), press, release]); - assert!( - app.dock.float_rect(PanelId::BlockPalette).is_some(), - "a double-click on a docked title bar floats the pallet" - ); - } - - #[test] - fn hovering_a_strip_icon_reveals_an_auto_hiding_edge_in_the_real_view() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, PanelId, STRIP_CELL_H, STRIP_PAD}; - let mut app = dock_app(); - // Mirror the layout onto the left so the block palette's title marks - // where the workspace starts, then auto-hide the right edge. - let title = find_in_view(&app, "Blocks"); - let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::BlockPalette)); - assert!(!app.dock_edge_expanded(DockSide::Right)); - // The title bar text sits ~13 px below the workspace top; the strip's - // first icon is centred STRIP_PAD + half a cell below it. - let workspace_top = title.y - 13.0; - let icon = iced::Point::new( - VIEW_SIZE.width - crate::ui::dock::DOCK_STRIP_W / 2.0, - workspace_top + STRIP_PAD + STRIP_CELL_H / 2.0, - ); - drive_view( - &mut app, - icon, - &[iced::Event::Mouse(iced::mouse::Event::CursorMoved { position: icon })], - ); - assert!( - app.dock_edge_expanded(DockSide::Right), - "hovering the icon reveals the auto-hiding edge" - ); - } - - #[test] - fn dock_double_clicks_float_and_dock_panels() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, PanelId}; - let mut app = dock_app(); - let _ = app.on_dock(DockMsg::FloatOut(PanelId::Properties)); - assert!(app.dock.left.is_empty()); - let f = app.dock.float_rect(PanelId::Properties).expect("floating"); - assert!(f.x > 0.0, "floats clear of the left edge"); - let _ = app.on_dock(DockMsg::DockTo(PanelId::Properties, DockSide::Right)); - assert!(app.dock.floating.is_empty()); - assert_eq!(app.dock.location(PanelId::Properties), Some((DockSide::Right, 1))); - } - - #[test] - fn blockpalette_width_reset() { - use crate::app::config::DockSide; - let mut app = dock_app(); - let id = crate::ui::dock::PanelId::BlockPalette; - app.dock.set_group_width(DockSide::Right, 0, 500.0); - let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id)); - assert_eq!(app.dock.group_width(DockSide::Right, 0), 260.0); - } - #[test] fn block_name_from_file_avoids_collisions() { let mut app = fresh(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 0b1daa9f9..6914a82e2 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -9,11 +9,11 @@ use crate::t; use crate::ui::wrap_bar::DensitySwap; use crate::ui::wrap_bar::WrapFlow; use iced::widget::{ - button, canvas, column, container, mouse_area, pane_grid, responsive, row, scrollable, shader, + button, column, container, mouse_area, pane_grid, responsive, row, scrollable, shader, stack, text, Row, Space, }; use iced::window; -use iced::{keyboard, Background, Border, Color, Element, Fill, Length, Subscription, Task, Theme}; +use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme}; use iced_aw::ContextMenu; use std::sync::Arc; @@ -1876,11 +1876,8 @@ bg={bg_ms:.1}ms n={view_count}" viewport_stack = viewport_stack.push(tab.graph.view(sections)); } - // Docked side panels (Properties, block palette, future palettes) live - // 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. + // Docked pallets: each edge's icon strip and shown group (see + // `app::dock`). 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); @@ -1927,82 +1924,7 @@ bg={bg_ms:.1}ms n={view_count}" } let workspace: Element<'_, Message> = row(parts).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 { - // Once a drag is under way only its preview shows the panel. - let dragged = self - .dock_drag - .and_then(|d| d.movement()) - .is_some_and(|(panel, _, target)| panel == f.id && target.is_some()); - if self.dock_panel_visible(f.id) && !dragged { - layers.push(self.floating_panel(*f, tab)); - } - } - // An open pallet menu hangs beside its edge's + button, over a - // catcher that closes it on any click elsewhere. - if let Some(side) = self.dock_edge_menu { - if !self.dock_visible_groups(side).is_empty() { - // Checked = open and docked on this edge. - let shown: Vec<_> = crate::ui::dock::PanelId::ALL - .iter() - .map(|p| { - let here = self.dock.location(*p).map(|(s, _)| s) == Some(side); - (*p, here && self.dock_panel_visible(*p)) - }) - .collect(); - let (_, plus_top) = self.dock_strip_layout(side); - let strip = crate::ui::dock::DOCK_STRIP_W; - let x = match side { - crate::app::config::DockSide::Left => strip + 4.0, - crate::app::config::DockSide::Right => { - self.dock_workspace_size().0 - strip - DOCK_MENU_W - 4.0 - } - }; - layers.push( - mouse_area(Space::new().width(Fill).height(Fill)) - .on_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) - .on_right_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) - .into(), - ); - layers.push(place_at(dock_edge_menu(side, &shown), x.max(0.0), plus_top)); - } - } - // While a panel is dragged, preview where it lands. - if let Some((id, _, Some(target))) = self.dock_drag.and_then(|d| d.movement()) { - layers.push(self.dock_drop_preview(id, target)); - } - // Always a stack (and, below, always a mouse area), even with nothing - // on top: iced keeps a widget's state only while the tree keeps its - // shape, and a title bar's double-click needs its first click to - // survive the drag that pressing the bar starts. - let workspace: Element<'_, Message> = iced::widget::Stack::with_children(layers) - .width(Fill) - .height(Fill) - .into(); - use crate::ui::dock::DockDrag; - let capturing = self.dock_drag.is_some() - || self.xref_col_drag.is_some() - || self.xref_split_drag; - let mut capture = mouse_area(workspace); - if capturing { - capture = capture - .on_move(move |p| Message::Dock(crate::ui::dock::DockMsg::DragMove(p))) - .on_release(Message::Dock(crate::ui::dock::DockMsg::DragRelease)) - .interaction(match self.dock_drag { - Some(DockDrag::FloatSize { from_left: true, .. }) => { - iced::mouse::Interaction::ResizingDiagonallyUp - } - Some(DockDrag::FloatSize { from_left: false, .. }) => { - iced::mouse::Interaction::ResizingDiagonallyDown - } - Some(DockDrag::Split { .. }) => iced::mouse::Interaction::ResizingVertically, - Some(DockDrag::Move { .. }) => iced::mouse::Interaction::Grabbing, - // Widths, the Layer Manager column and the xref drags. - _ => iced::mouse::Interaction::ResizingHorizontally, - }); - } - let workspace: Element<'_, Message> = capture.into(); + let workspace = self.dock_overlay(workspace, tab); let command_line = self.command_line.view( allow_autocomplete, dyn_capturing, @@ -2770,681 +2692,6 @@ impl OpenCADStudio { pub(super) fn unfocus_widgets(&self) -> Task { iced::advanced::widget::operate(iced::advanced::widget::operation::focusable::unfocus()) } - - /// Build one edge: the vertical icon strip at the very edge plus the - /// edge's shown group beside it. The strip lists every group as a grip - /// bar over its pallets' icons, with a + button below; clicking an icon - /// shows its group. The shown group's pallets stack top to bottom, each - /// at its weighted share of the height, with a draggable splitter between - /// neighbours. A group whose pallets all auto-hide shows only while one - /// of its icons (or the group itself) is hovered; leaving the edge hides - /// it again. `None` when nothing on the edge is open. - fn build_edge_stack<'a>( - &'a self, - side: crate::app::config::DockSide, - tab: &'a DocumentTab, - ) -> Option> { - let gi = self.dock_shown_group(side)?; - let strip = self.dock_icon_strip(side); - let column_el = self.dock_edge_expanded(side).then(|| { - let width = self.dock_group_width(side, gi); - let group = &self.dock.groups(side)[gi]; - let mut layers: Vec> = Vec::new(); - let mut prev: Option = None; - for (pos, id) in group.panels.iter().enumerate() { - if !self.dock_panel_visible(*id) { - continue; - } - if let Some(upper) = prev { - layers.push(dock_splitter(side, gi, upper, pos, width)); - } - layers.push( - container(self.expanded_panel(*id, side, width, tab)) - .height(Length::FillPortion(crate::ui::dock::portion( - group.weights[pos], - ))) - .into(), - ); - prev = Some(pos); - } - column(layers).height(Fill).into() - }); - - let mut children: Vec> = Vec::new(); - match side { - crate::app::config::DockSide::Left => { - children.push(strip); - children.extend(column_el); - } - crate::app::config::DockSide::Right => { - children.extend(column_el); - children.push(strip); - } - } - Some( - mouse_area(row(children).height(Fill)) - .on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit)) - .into(), - ) - } - - /// An edge's vertical icon strip: per group its open pallets' icons in a - /// row ended by a hairline divider, then a + button opening the pallet - /// menu. The band along a group's window-side edge is its grip: it - /// carries the blue bar of the shown group, lights up while hovered (or - /// while it is a drop target) and drags the whole group; an icon shows its - /// group on click and drags just that pallet. Heights follow - /// `dock::strip_layout`, which drop targeting uses too. - fn dock_icon_strip(&self, side: crate::app::config::DockSide) -> Element<'_, Message> { - use crate::app::config::DockSide; - use crate::ui::dock::{ - DockMsg, DropTarget, DOCK_STRIP_W, STRIP_CELL_H, STRIP_DIVIDER_H, STRIP_GRIP_W, - STRIP_PAD, STRIP_PLUS_GAP, - }; - let shown = self.dock_shown_group(side); - let movement = self.dock_drag.and_then(|d| d.movement()); - let move_target = movement.and_then(|(_, _, target)| target); - let dragging = move_target.is_some(); - let target_group = match move_target { - Some(DropTarget::Join { side: s, group, .. }) if s == side => Some(group), - _ => None, - }; - let tip_side = match side { - DockSide::Left => iced::widget::tooltip::Position::Right, - DockSide::Right => iced::widget::tooltip::Position::Left, - }; - let mut items: Vec> = Vec::new(); - for gi in self.dock_visible_groups(side) { - let is_shown = shown == Some(gi); - let lit = target_group == Some(gi) - || (!dragging && self.dock_grip_hover == Some((side, gi))); - let group_dragged = - dragging && movement.is_some_and(|(_, group, _)| group == Some((side, gi))); - let panels = self.dock_group_visible(side, gi); - let row_h = 2.0 * STRIP_PAD + panels.len() as f32 * STRIP_CELL_H; - - let mut icons: Vec> = vec![Space::new().height(STRIP_PAD).into()]; - for id in panels { - let faded = group_dragged - || (dragging - && movement.is_some_and(|(panel, group, _)| group.is_none() && panel == id)); - let glyph = if faded { - crate::ui::icons::themed_disabled(id.icon(), 20.0) - } else if is_shown { - crate::ui::icons::themed(id.icon(), 20.0) - } else { - crate::ui::icons::themed_secondary(id.icon(), 20.0) - }; - let hovered = !dragging && self.dock_icon_hover == Some(id); - let cell = container(glyph) - .center_x(Length::Fixed(STRIP_CELL_H)) - .center_y(Length::Fixed(STRIP_CELL_H - 2.0)) - .style(move |theme: &Theme| container::Style { - background: hovered - .then(|| Background::Color(theme.palette().background.weak.color)), - border: Border { - radius: 5.0.into(), - ..Default::default() - }, - ..Default::default() - }); - let cell = mouse_area( - container(cell) - .center_x(Length::Fixed(DOCK_STRIP_W)) - .center_y(Length::Fixed(STRIP_CELL_H)), - ) - .on_press(Message::Dock(DockMsg::IconPress(id))) - .on_enter(Message::Dock(DockMsg::Hover(Some(id)))) - .on_exit(Message::Dock(DockMsg::Hover(None))) - .interaction(iced::mouse::Interaction::Pointer); - icons.push( - iced::widget::tooltip(cell, text(id.title()).size(10), tip_side) - .gap(4) - .into(), - ); - } - - // The group grip: a band on the window side holding the edge bar. - // Explicit heights: the tooltip around the band sizes it to its - // content, so a `Fill` bar would stop short of the row's bottom. - let bar_w = if lit { 5.0 } else { 3.0 }; - let bar_inset = 5.0; - let bar = container( - Space::new() - .width(bar_w) - .height(Length::Fixed(row_h - 2.0 * bar_inset)), - ) - .style(move |theme: &Theme| { - let palette = theme.palette(); - let (color, glow) = if lit { - (Some(palette.primary.weak.color), true) - } else if is_shown { - (Some(palette.primary.base.color), false) - } else { - (None, false) - }; - container::Style { - background: color.map(Background::Color), - border: Border { - radius: match side { - DockSide::Left => iced::border::Radius::default().right(3.0), - DockSide::Right => iced::border::Radius::default().left(3.0), - }, - ..Default::default() - }, - shadow: if glow { - iced::Shadow { - color: palette.primary.base.color.scale_alpha(0.55), - offset: iced::Vector::new(0.0, 0.0), - blur_radius: 8.0, - } - } else { - iced::Shadow::default() - }, - ..Default::default() - } - }); - let band = mouse_area( - container(bar) - .width(Length::Fixed(STRIP_GRIP_W)) - .height(Length::Fixed(row_h)) - .align_y(iced::alignment::Vertical::Center) - .align_x(match side { - DockSide::Left => iced::alignment::Horizontal::Left, - DockSide::Right => iced::alignment::Horizontal::Right, - }), - ) - .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) - .on_enter(Message::Dock(DockMsg::GripHover(Some((side, gi))))) - .on_exit(Message::Dock(DockMsg::GripHover(None))) - .interaction(iced::mouse::Interaction::Grab); - let band = iced::widget::tooltip( - band, - text(t!("Drag to move group")).size(10), - tip_side, - ) - .gap(4); - let band = container(band) - .width(Fill) - .height(Length::Fixed(row_h)) - .align_x(match side { - DockSide::Left => iced::alignment::Horizontal::Left, - DockSide::Right => iced::alignment::Horizontal::Right, - }); - items.push( - container(stack![column(icons), band]) - .width(Length::Fixed(DOCK_STRIP_W)) - .height(Length::Fixed(row_h)) - .style(move |theme: &Theme| container::Style { - background: lit.then(|| { - Background::Color(theme.palette().primary.weak.color.scale_alpha(0.12)) - }), - ..Default::default() - }) - .into(), - ); - items.push( - container(Space::new()) - .width(Fill) - .height(Length::Fixed(STRIP_DIVIDER_H)) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color, - )), - ..Default::default() - }) - .into(), - ); - } - items.push(Space::new().height(STRIP_PLUS_GAP).into()); - let plus = button(crate::ui::icons::themed_secondary(crate::ui::icons::PLUS, 14.0)) - .on_press(Message::Dock(DockMsg::EdgeMenu(Some(side)))) - .style(|theme: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => { - theme.palette().background.weak.color - } - _ => theme.palette().background.weakest.color, - })), - border: Border { - radius: 14.0.into(), - ..Default::default() - }, - ..Default::default() - }) - .width(Length::Fixed(28.0)) - .height(Length::Fixed(28.0)) - .padding(7); - items.push(container(plus).center_x(Length::Fixed(DOCK_STRIP_W)).into()); - container(column(items)) - .width(Length::Fixed(DOCK_STRIP_W)) - .height(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() - } - - /// The content of panel `id` at `width`. A `floating` panel leaves its - /// title to the floating frame's vertical title bar. - fn panel_body<'a>( - &'a self, - id: crate::ui::dock::PanelId, - width: f32, - floating: bool, - tab: &'a DocumentTab, - ) -> Element<'a, Message> { - let chrome = crate::ui::dock::Chrome { - auto_collapse: self.dock.auto_hides(id), - floating, - title_hovered: self.dock_title_hover == Some(id), - }; - match id { - crate::ui::dock::PanelId::Properties => tab.properties.view(width, chrome), - crate::ui::dock::PanelId::BlockPalette => { - crate::ui::window::block_palette::view(&self.block_palette, width, chrome) - } - crate::ui::dock::PanelId::ExternalReferences => self.xref_manager.view( - width, - chrome, - tab.xref_missing, - &tab.scene.document, - ), - crate::ui::dock::PanelId::Browser => crate::ui::window::browser::view( - &tab.scene.document, - tab.sketch_session.as_ref().map(|session| session.name.as_str()), - width, - chrome, - ), - crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, chrome), - crate::ui::dock::PanelId::Count => crate::ui::window::count_palette::view( - &self.count_palette, - tab.count.as_ref(), - &tab.scene.document, - tab.id, - tab.scene.geometry_epoch, - width, - chrome, - ), - crate::ui::dock::PanelId::PointCloudManager => crate::ui::window::pc_manager::view( - &self.pc_manager, - &tab.scene.document, - &tab.scene.selected_handles_in_order(), - width, - chrome, - ), - crate::ui::dock::PanelId::SheetSetManager => { - crate::ui::window::sheet_set::view(&self.sheet_set, width, chrome) - } - crate::ui::dock::PanelId::Layers => { - tab.layers.view_panel(self.layer_name_col_w, width, chrome) - } - } - } - - /// A pallet of the shown group: its body plus a grabbable divider - /// against the viewport (sizing the whole group). 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>( - &'a self, - id: crate::ui::dock::PanelId, - side: crate::app::config::DockSide, - width: f32, - tab: &'a DocumentTab, - ) -> Element<'a, Message> { - let panel = self.panel_body(id, width, false, tab); - let divider = dock_divider(id); - match side { - crate::app::config::DockSide::Left => row![panel, divider].height(Fill).into(), - crate::app::config::DockSide::Right => row![divider, panel].height(Fill).into(), - } - } - - /// A floating panel at its saved place: a vertical title bar on the side - /// facing the nearer workspace edge, the panel beside it, and a corner - /// grip to resize it. With its pin on, the panel hides down to the title - /// bar until hovered. - 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(); - // The bar faces the nearer workspace edge; while resizing, it stays - // opposite the grip being dragged so it cannot flip mid-drag. - let bar_left = match self.dock_drag { - Some(crate::ui::dock::DockDrag::FloatSize { panel, from_left }) if panel == f.id => { - !from_left - } - _ => f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5, - }; - let hidden = self.dock.auto_collapse(f.id) && self.dock_peek != Some(f.id); - let bar = floating_title_bar(f.id, self.dock.auto_collapse(f.id), bar_left); - let content: Element<'_, Message> = if hidden { - bar - } else { - // The resize grip sits in the bottom corner away from the bar. - let grip_left = !bar_left; - let grip_icon = crate::ui::icons::themed_secondary( - if grip_left { - crate::ui::icons::RESIZE_LEFT - } else { - crate::ui::icons::RESIZE - }, - 12.0, - ); - let grip = mouse_area( - container(grip_icon) - .center_x(Length::Fixed(14.0)) - .center_y(Length::Fixed(14.0)), - ) - .on_press(Message::Dock(DockMsg::FloatResizeGrab(f.id, grip_left))) - .interaction(if grip_left { - iced::mouse::Interaction::ResizingDiagonallyUp - } else { - iced::mouse::Interaction::ResizingDiagonallyDown - }); - let body = stack![ - self.panel_body(f.id, f.w, true, tab), - container(grip) - .width(Fill) - .height(Fill) - .align_x(if grip_left { - iced::alignment::Horizontal::Left - } else { - iced::alignment::Horizontal::Right - }) - .align_y(iced::alignment::Vertical::Bottom), - ] - .width(Length::Fixed(f.w)) - .height(Fill); - if bar_left { - row![bar, body].into() - } else { - row![body, bar].into() - } - }; - let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; - let framed = container(content) - .height(Length::Fixed(f.h)) - .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))) - .on_enter(Message::Dock(DockMsg::Hover(Some(f.id)))) - .on_exit(Message::Dock(DockMsg::HoverExit)) - .interaction(iced::mouse::Interaction::Idle); - // A hidden panel keeps its title bar where it sits when shown. - let x = if hidden && !bar_left { f.x + f.w } else { f.x }; - // Keep the panel on screen when the window shrank since it was placed. - let x = x.min(ww - total_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 — stacked into a group - /// (sized as that group will be), as a new group of its own (full - /// height), or as 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, avail) = self.dock_workspace_size(); - let strip = crate::ui::dock::DOCK_STRIP_W; - let tint = |side: DockSide, w: f32| -> Element<'_, Message> { - let band = container(Space::new()) - .width(Length::Fixed(w + strip)) - .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 mut layers: Vec> = Vec::new(); - let docked = match target { - DropTarget::Edge { side, index } => { - let mut after = self.dock.clone(); - after.dock(id, side, index); - Some((side, after)) - } - DropTarget::Join { side, group, index } => { - let mut after = self.dock.clone(); - after.join_group(id, side, group, index); - Some((side, after)) - } - DropTarget::Float { x, y } => { - let (w, h) = self.dock_float_size(id); - layers.push(place_at( - dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), - x, - y, - )); - None - } - }; - // A whole group shows only the strip feedback and its ghost; the - // column keeps showing what is there. - let group_drag = self.dock_drag.and_then(|d| d.movement()).and_then(|(_, g, _)| g); - let docked = docked.map(|(side, after)| (side, group_drag.is_none().then_some(after))); - let marks_side = docked.as_ref().map(|(side, _)| *side); - if let Some((side, Some(after))) = docked { - // Lay the group out as it will be after the drop. - if let Some((_, gi)) = after.location(id) { - let group = &after.groups(side)[gi]; - let open: Vec = (0..group.panels.len()) - .filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i])) - .collect(); - let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); - let spans = crate::ui::dock::slot_spans(&weights, avail); - let at = open - .iter() - .position(|i| group.panels[*i] == id) - .expect("dropped panel"); - let (top, bottom) = spans[at]; - let w = group - .panels - .iter() - .map(|p| after.width(*p, self.win_size.0)) - .fold(0.0, f32::max); - let x = match side { - DockSide::Left => strip, - DockSide::Right => (ww - w - strip).max(0.0), - }; - layers.push(tint(side, w)); - layers.push(place_at(dock_ghost(id, w, bottom - top), x, top)); - } - } - // On top: the strip's drop marks and a ghost of what is dragged - // following the pointer. - if let Some(side) = marks_side { - layers.extend(self.dock_strip_drop_marks(side, target)); - if let Some(p) = self.dock_drag_last { - let icons = match group_drag { - Some((s, g)) => self.dock_group_visible(s, g), - None => vec![id], - }; - layers.push(place_at(dock_icon_ghost(&icons), p.x - 16.0, p.y - 15.0)); - } - } - iced::widget::Stack::with_children(layers) - .width(Fill) - .height(Fill) - .into() - } - - /// The icon strip's drop marks for `target` on `side`: a short line with - /// end caps between a group's icons (join that group there), or a - /// full-width line on a divider with a "+ New group" badge. - fn dock_strip_drop_marks( - &self, - side: crate::app::config::DockSide, - target: crate::ui::dock::DropTarget, - ) -> Vec> { - use crate::app::config::DockSide; - use crate::ui::dock::{DropTarget, DOCK_STRIP_W, STRIP_CELL_H}; - let (ww, _) = self.dock_workspace_size(); - let strip_x = match side { - DockSide::Left => 0.0, - DockSide::Right => ww - DOCK_STRIP_W, - }; - let (layout, _) = self.dock_strip_layout(side); - let mut marks: Vec> = Vec::new(); - match target { - DropTarget::Join { group, index, .. } => { - let Some(g) = layout.iter().find(|g| g.group == group) else { - return marks; - }; - // Icons above the insertion point, counted as the strip shows - // them (the dragged icon still sits in its old place). - let dragged = self.dock_drag.and_then(|d| d.movement()).map(|(p, _, _)| p); - let above = self.dock.groups(side)[group].panels[..index] - .iter() - .filter(|p| self.dock_panel_visible(**p) || Some(**p) == dragged) - .count(); - let y = g.icons_top + above as f32 * STRIP_CELL_H; - marks.push(place_at(dock_insert_line(), strip_x + 7.0, y - 3.5)); - } - DropTarget::Edge { index, .. } => { - let y = layout - .iter() - .find(|g| g.group >= index) - .map(|g| g.top) - .or_else(|| layout.last().map(|g| g.bottom())) - .unwrap_or(0.0); - marks.push(place_at( - container(Space::new()) - .width(Length::Fixed(DOCK_STRIP_W)) - .height(Length::Fixed(4.0)) - .style(dock_drop_line_style) - .into(), - strip_x, - (y - 2.0).max(0.0), - )); - const BADGE_W: f32 = 96.0; - let badge = container(text(format!("+ {}", t!("New group"))).size(10)) - .width(Length::Fixed(BADGE_W)) - .center_x(Length::Fixed(BADGE_W)) - .padding([2, 6]) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().primary.base.color)), - text_color: Some(theme.palette().primary.base.text), - border: Border { - radius: 9.0.into(), - ..Default::default() - }, - ..Default::default() - }); - let badge_x = match side { - DockSide::Left => DOCK_STRIP_W + 6.0, - DockSide::Right => ww - DOCK_STRIP_W - 6.0 - BADGE_W, - }; - marks.push(place_at(badge.into(), badge_x, (y - 9.0).max(0.0))); - } - DropTarget::Float { .. } => {} - } - marks - } -} - -/// The glowing accent used by the strip's drop lines. -fn dock_drop_line_style(theme: &Theme) -> container::Style { - container::Style { - background: Some(Background::Color(theme.palette().primary.base.color)), - border: Border { - radius: 2.0.into(), - ..Default::default() - }, - shadow: iced::Shadow { - color: theme.palette().primary.base.color.scale_alpha(0.8), - offset: iced::Vector::new(0.0, 0.0), - blur_radius: 6.0, - }, - ..Default::default() - } -} - -/// The short insertion line with end caps shown between a group's icons. -fn dock_insert_line() -> Element<'static, Message> { - let cap = || { - container(Space::new()) - .width(Length::Fixed(3.0)) - .height(Length::Fixed(7.0)) - .style(dock_drop_line_style) - }; - row![ - cap(), - container(Space::new()) - .width(Length::Fixed(20.0)) - .height(Length::Fixed(3.0)) - .style(dock_drop_line_style), - cap(), - ] - .align_y(iced::Center) - .height(Length::Fixed(7.0)) - .into() -} - -/// The ghost following the pointer while pallets are dragged: their icons -/// in an accent-framed box. -fn dock_icon_ghost(icons: &[crate::ui::dock::PanelId]) -> Element<'static, Message> { - let glyphs: Vec> = icons - .iter() - .map(|id| { - container(crate::ui::icons::themed(id.icon(), 18.0)) - .center_x(Length::Fixed(32.0)) - .center_y(Length::Fixed(30.0)) - .into() - }) - .collect(); - container(column(glyphs)) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.primary.base.color.scale_alpha(0.22))), - border: Border { - color: palette.primary.base.color, - width: 1.5, - radius: 6.0.into(), - }, - ..Default::default() - } - }) - .into() } // ── Document tab bar ─────────────────────────────────────────────────────── @@ -3773,284 +3020,6 @@ fn pane_mouse_area<'a>(idx: usize) -> Element<'a, Message> { .into() } -/// Canvas that draws a label rotated 90° (for a collapsed panel's bar). -struct VBarLabel { - text: String, - clockwise: bool, - /// Start the text at the reading-start end of the bar instead of - /// centring it. - from_start: bool, -} - -impl canvas::Program for VBarLabel { - type State = (); - - fn draw( - &self, - _state: &(), - renderer: &iced::Renderer, - theme: &Theme, - bounds: iced::Rectangle, - _cursor: iced::advanced::mouse::Cursor, - ) -> Vec { - let mut frame = canvas::Frame::new(renderer, bounds.size()); - frame.with_save(|frame| { - frame.translate(iced::Vector::new(bounds.width / 2.0, bounds.height / 2.0)); - frame.rotate(iced::Radians(if self.clockwise { - std::f32::consts::FRAC_PI_2 - } else { - -std::f32::consts::FRAC_PI_2 - })); - // After the rotation the text runs along local +x, so the - // reading start sits at -height/2. - let (position, align_x) = if self.from_start { - ( - iced::Point::new(-bounds.height / 2.0, 0.0), - iced::advanced::text::Alignment::Left, - ) - } else { - (iced::Point::ORIGIN, iced::advanced::text::Alignment::Center) - }; - frame.fill_text(canvas::Text { - content: self.text.clone(), - position, - color: theme.palette().background.base.text.scale_alpha(0.72), - size: iced::Pixels(13.0), - align_x, - align_y: iced::alignment::Vertical::Center, - shaping: iced::advanced::text::Shaping::Advanced, - ..Default::default() - }); - }); - vec![frame.into_geometry()] - } -} - -/// Width of a floating panel's vertical title bar. -const DOCK_FLOAT_BAR_W: f32 = 28.0; - -/// A floating panel's vertical title bar: close then the hide (auto-collapse) -/// pin at the top, the panel's icon at the bottom with its title reading -/// upward above it. Pressing the bar's free area drags the panel. -fn floating_title_bar( - id: crate::ui::dock::PanelId, - auto_collapse: bool, - on_left: bool, -) -> Element<'static, Message> { - use crate::ui::dock::DockMsg; - let tip = if on_left { - iced::widget::tooltip::Position::Left - } else { - iced::widget::tooltip::Position::Right - }; - let close = crate::ui::dock::close_button(id, tip); - let pin = crate::ui::dock::pin_button(id, auto_collapse, tip); - let title = canvas(VBarLabel { - text: id.title().to_string(), - clockwise: false, - from_start: true, - }) - .width(Fill) - .height(Fill); - let bar = column![ - close, - pin, - container(title).width(Fill).height(Fill).padding([6, 0]), - container(crate::ui::icons::themed(id.icon(), 16.0)).center_x(Fill), - ] - .spacing(2) - .padding([4, 0]) - .align_x(iced::Center) - .width(Length::Fixed(DOCK_FLOAT_BAR_W)) - .height(Fill); - mouse_area(container(bar).style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.weak.color)), - ..Default::default() - })) - .on_press(Message::Dock(DockMsg::DockGrab(id))) - // Double-click docks it against the edge its title bar faces. - .on_double_click(Message::Dock(DockMsg::DockTo( - id, - if on_left { - crate::app::config::DockSide::Left - } else { - crate::app::config::DockSide::Right - }, - ))) - .interaction(iced::mouse::Interaction::Grab) - .into() -} - -/// Grabbable separator for a docked panel managed by the general dock. Same -/// visual as the previous per-panel divider but emits generic dock messages. -fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { - let line = container(Space::new()) - .width(Length::Fixed(DOCK_DIVIDER_W)) - .height(Fill) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.neutral.color)), - ..Default::default() - }); - let grab = Message::Dock(crate::ui::dock::DockMsg::ResizeGrab(id)); - let reset = Message::Dock(crate::ui::dock::DockMsg::WidthReset(id)); - mouse_area(line) - .on_press(grab) - .on_double_click(reset) - .interaction(iced::mouse::Interaction::ResizingHorizontally) - .into() -} - -use crate::ui::dock::DOCK_DIVIDER_W; - -/// Width of the icon strip's pallet menu. -const DOCK_MENU_W: f32 = 230.0; - -/// The pallet menu of an edge's + button, styled like the right-click menu: -/// one row per pallet with its icon, its name and a check mark when it is -/// open on this edge. `shown` holds each pallet with that state. -fn dock_edge_menu( - side: crate::app::config::DockSide, - shown: &[(crate::ui::dock::PanelId, bool)], -) -> Element<'static, Message> { - use crate::ui::dock::DockMsg; - let rows: Vec> = shown - .iter() - .map(|(id, checked)| { - let id = *id; - let check: Element<'static, Message> = if *checked { - crate::ui::icons::themed(crate::ui::icons::CHECK, 12.0) - } else { - Space::new().width(12).height(12).into() - }; - button( - row![ - crate::ui::icons::themed(id.icon(), 16.0), - text(id.title()).size(12).width(Fill), - check, - ] - .spacing(8) - .align_y(iced::Center), - ) - .on_press(Message::Dock(DockMsg::EdgeMenuToggle(side, id))) - .style(|theme: &Theme, status| { - let palette = theme.palette(); - button::Style { - background: matches!(status, button::Status::Hovered | button::Status::Pressed) - .then(|| Background::Color(palette.background.weak.color)), - text_color: palette.background.base.text, - border: Border { - radius: 3.0.into(), - ..Default::default() - }, - ..Default::default() - } - }) - .padding([5, 8]) - .width(Fill) - .into() - }) - .collect(); - container(column(rows).spacing(1).padding(4)) - .width(Length::Fixed(DOCK_MENU_W)) - .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: 4.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: 12.0, - }, - ..Default::default() - }) - .into() -} - -/// Draggable bar between two stacked pallets of group `gi`; double-click -/// gives every pallet of the group the same height again. It spans exactly -/// the pallets' width (panel plus its divider): a `Fill` width would stretch -/// the whole edge column across the workspace and squeeze the drawing view. -fn dock_splitter( - side: crate::app::config::DockSide, - gi: usize, - upper: usize, - lower: usize, - col_w: f32, -) -> Element<'static, Message> { - let line = container(Space::new()) - .width(Length::Fixed(col_w + DOCK_DIVIDER_W)) - .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, gi, upper, lower))) - .on_double_click(Message::Dock(crate::ui::dock::DockMsg::SplitReset(side, gi))) - .interaction(iced::mouse::Interaction::ResizingVertically) - .into() -} - -/// The blue drop ghost: a `w`×`h` outline of where pallet `id` lands, with -/// its name as the header. -fn dock_ghost(id: crate::ui::dock::PanelId, w: f32, h: f32) -> Element<'static, Message> { - let header = 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(|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() - } - }); - container(column![header, 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 480c2c8dc..58ece0e82 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -1412,22 +1412,6 @@ mod tests { assert_eq!((f.x, f.w), (DOCK_MIN_W - 350.0, 350.0)); } - #[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 every_panel_has_its_own_icon() { let ids = PanelId::ALL; @@ -1439,25 +1423,4 @@ mod tests { } } - #[test] - fn floating_title_bar_replaces_the_horizontal_one() { - let floating = Chrome { - floating: true, - ..Default::default() - }; - let bar: Element<'_, Message> = title_bar(PanelId::Count, "Count".into(), floating); - assert_eq!( - bar.as_widget().size(), - iced::Size::new(Length::Fixed(0.0), Length::Fixed(0.0)) - ); - } - - #[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()); - } } From b12a29b23357af4bed6f5a44c9e549a590c27615 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:53:42 +0000 Subject: [PATCH 17/24] Dock: review fixes - hover, preview width, stale indexes, config healing Fixes: - Hover state no longer drops when the pointer moves up from one strip icon, group grip or title bar to the one above: iced handles the element above first, so the old element's "left" (None) cleared the hover just set. Leave messages now carry the element and only clear their own hover. - The drop preview used the pallets' own widths; it now uses the group's width, matching where the pallet lands. - Group indexes from messages (group grab, splitter) are looked up safely, so a message from before a layout change no longer panics. - Floating placements read from the config are healed (finite, positive, at least the minimum size), like the group weights already were. - The shown group's edge bar is centred in its row: a stack takes its height from its first layer, and the icon column was shorter than the row. The "Drag to move group" tooltip is removed. Simplifications (no behaviour change): - dock_open_at_default / dock_unpeek replace seven copies of "dock on the right if unplaced, then reveal" and four of "stop peeking". - The drop target takes the grab offset as a parameter, so a drag update stores its state once. - dock_set_open, ensure_settings, the shown-index bookkeeping after a group is removed and the on-screen width clamp each lose duplication. - VBarLabel drops options its single caller never varied. - The drop preview's landing computation is one helper. Naming and consistency: pallet names in the + menu, the drag ghost and the floating title bar go through t!(); StripHit::Tab becomes StripHit::Join; stale comments updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/commands/blocks.rs | 12 +-- src/app/commands/count.rs | 9 +- src/app/commands/layerprops.rs | 10 +- src/app/commands/pdf_underlay.rs | 9 +- src/app/commands/sheet_set.rs | 9 +- src/app/dock/tests.rs | 4 +- src/app/dock/update.rs | 152 +++++++++++++++-------------- src/app/dock/view.rs | 159 +++++++++++++------------------ src/app/node_graph.rs | 6 +- src/app/update/mod.rs | 7 +- src/ui/dock.rs | 120 +++++++++++++++-------- 11 files changed, 239 insertions(+), 258 deletions(-) diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index f41150459..6089998e5 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -911,17 +911,7 @@ 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 - .dock - .is_placed(crate::ui::dock::PanelId::ExternalReferences) - { - self.dock.dock( - crate::ui::dock::PanelId::ExternalReferences, - crate::app::config::DockSide::Right, - usize::MAX, - ); - } - self.dock_reveal(crate::ui::dock::PanelId::ExternalReferences); + self.dock_open_at_default(crate::ui::dock::PanelId::ExternalReferences); self.refresh_xref_manager(); } } diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs index ff3bf4854..548b1a67d 100644 --- a/src/app/commands/count.rs +++ b/src/app/commands/count.rs @@ -236,12 +236,9 @@ impl OpenCADStudio { self.count_palette.show = open; self.ribbon.set_count_palette(open); if open { - if !self.dock.is_placed(id) { - self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); - } - self.dock_reveal(id); - } else if self.dock_peek == Some(id) { - self.dock_peek = None; + self.dock_open_at_default(id); + } else { + self.dock_unpeek(id); } } diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 3c97b2ad3..a54dec828 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -679,15 +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.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 - .dock(id, crate::app::config::DockSide::Right, usize::MAX); - } - // Open expanded so it is usable straight away; the pin - // button still collapses it. - self.dock_reveal(id); + self.dock_open_at_default(id); self.command_line .push_output(crate::t!("Browser opened.").as_ref()); } else { diff --git a/src/app/commands/pdf_underlay.rs b/src/app/commands/pdf_underlay.rs index 67f584b14..43335f027 100644 --- a/src/app/commands/pdf_underlay.rs +++ b/src/app/commands/pdf_underlay.rs @@ -374,16 +374,11 @@ impl OpenCADStudio { "POINTCLOUDMANAGER" => { let id = crate::ui::dock::PanelId::PointCloudManager; self.pc_manager.show = true; - if !self.dock.is_placed(id) { - self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); - } - self.dock_reveal(id); + self.dock_open_at_default(id); } "POINTCLOUDMANAGERCLOSE" => { self.pc_manager.show = false; - if self.dock_peek == Some(crate::ui::dock::PanelId::PointCloudManager) { - self.dock_peek = None; - } + self.dock_unpeek(crate::ui::dock::PanelId::PointCloudManager); } "_PCUNCROP" =>self.edit_selected_point_clouds(i, "POINTCLOUDUNCROP", |data| data.croppings.clear()), "_PDFULUNCLIP" => { diff --git a/src/app/commands/sheet_set.rs b/src/app/commands/sheet_set.rs index 73fa949fa..21e2b3245 100644 --- a/src/app/commands/sheet_set.rs +++ b/src/app/commands/sheet_set.rs @@ -577,12 +577,9 @@ impl OpenCADStudio { self.sheet_set.show = on; self.ribbon.set_sheet_set(on); if on { - if !self.dock.is_placed(id) { - self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); - } - self.dock_reveal(id); - } else if self.dock_peek == Some(id) { - self.dock_peek = None; + self.dock_open_at_default(id); + } else { + self.dock_unpeek(id); } } diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 3cac5f413..117376bef 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -335,14 +335,14 @@ fn dock_hover_reveals_groups_only_on_an_auto_hiding_edge() { assert!(!app.dock.edge_auto_hide(DockSide::Right)); assert!(!app.dock_edge_expanded(DockSide::Left)); // Hovering an icon flies its group out. - let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Browser))); + let _ = app.on_dock(DockMsg::Hover(PanelId::Browser)); assert!(app.dock_edge_expanded(DockSide::Left)); assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); let _ = app.on_dock(DockMsg::HoverExit); assert!(!app.dock_edge_expanded(DockSide::Left)); // Without auto-hide, hovering does not switch groups (clicks do). let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties)); - let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Properties))); + let _ = app.on_dock(DockMsg::Hover(PanelId::Properties)); assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); assert!(app.dock_edge_expanded(DockSide::Left)); } diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs index 5478b03e8..2c791fd1d 100644 --- a/src/app/dock/update.rs +++ b/src/app/dock/update.rs @@ -55,8 +55,18 @@ impl OpenCADStudio { } self.save_config(); } - DockMsg::TitleHover(id) => self.dock_title_hover = id, - DockMsg::GripHover(grip) => self.dock_grip_hover = grip, + DockMsg::TitleHover(id) => self.dock_title_hover = Some(id), + DockMsg::TitleHoverEnd(id) => { + if self.dock_title_hover == Some(id) { + self.dock_title_hover = None; + } + } + DockMsg::GripHover(side, gi) => self.dock_grip_hover = Some((side, gi)), + DockMsg::GripHoverEnd(side, gi) => { + if self.dock_grip_hover == Some((side, gi)) { + self.dock_grip_hover = None; + } + } DockMsg::FloatRaise(id) => self.dock.raise_float(id), DockMsg::FloatOut(id) => { // Float beside the edge it was docked on, near the top. @@ -114,15 +124,18 @@ impl OpenCADStudio { } DockMsg::Close(id) => return self.dock_set_open(id, false), DockMsg::Hover(id) => { - self.dock_icon_hover = id; + self.dock_icon_hover = Some(id); // Only an auto-hiding edge (or floating pallet) reacts to // hover: it reveals the hovered pallet's group. Otherwise // groups switch on click. Ignored mid-drag, when the pointer // is over the preview rather than the strip. - if let Some(id) = id { - if self.dock_drag.is_none() && self.dock.auto_hides(id) { - self.dock_reveal(id); - } + if self.dock_drag.is_none() && self.dock.auto_hides(id) { + self.dock_reveal(id); + } + } + DockMsg::HoverEnd(id) => { + if self.dock_icon_hover == Some(id) { + self.dock_icon_hover = None; } } DockMsg::HoverExit => { @@ -218,23 +231,15 @@ impl OpenCADStudio { } else { grab }; - // Store the grab first: the drop target reads it. - self.dock_drag = Some(DockDrag::Move { - panel, - group, - origin: Some(origin), - target, - grab, - }); - if !started { - return; - } - let found = self.dock_drop_target(point); - // A whole group only moves between group positions: it - // cannot join another group or float. - let target = match (group, found) { - (Some(_), DropTarget::Edge { .. }) | (None, _) => Some(found), - (Some(_), _) => None, + let target = if started { + // A whole group only moves between group positions: it + // cannot join another group or float. + match (group, self.dock_drop_target(point, grab)) { + (Some(_), found @ DropTarget::Edge { .. }) | (None, found) => Some(found), + (Some(_), _) => None, + } + } else { + target }; self.dock_drag = Some(DockDrag::Move { panel, @@ -251,11 +256,14 @@ impl OpenCADStudio { lower, } => { let Some(last) = last else { return }; + let Some(weights) = self.dock.groups(side).get(group).map(|g| &g.weights) else { + return; + }; let (_, avail) = self.dock_workspace_size(); let total: f32 = self .dock_slot_spans(side) .iter() - .map(|(i, _, _)| self.dock.groups(side)[group].weights[*i]) + .filter_map(|(i, _, _)| weights.get(*i)) .sum(); if avail > 0.0 && total > 0.0 { let per_px = total / avail; @@ -334,50 +342,33 @@ impl OpenCADStudio { id: crate::ui::dock::PanelId, open: bool, ) -> iced::Task { - use crate::ui::dock::PanelId; + use crate::ui::dock::{DockDrag, PanelId}; + let mut task = iced::Task::none(); if open { - let task = if self.dock_panel_visible(id) { - iced::Task::none() - } else { + if !self.dock_panel_visible(id) { match id { PanelId::Properties => { self.show_properties = true; self.ribbon.set_properties(true); - iced::Task::none() - } - PanelId::BlockPalette => { - self.open_blocks_palette(None); - iced::Task::none() } + PanelId::BlockPalette => self.open_blocks_palette(None), PanelId::ExternalReferences => { self.show_external_references = true; self.refresh_xref_manager(); - iced::Task::none() } - PanelId::Browser => { - self.show_browser = true; - iced::Task::none() - } - PanelId::NodeGraph => self.on_graph(crate::ui::node_graph::GraphMsg::Toggle), - PanelId::PointCloudManager => { - self.pc_manager.show = true; - iced::Task::none() - } - PanelId::Count => { - self.set_count_palette(true); - iced::Task::none() - } - PanelId::SheetSetManager => { - self.show_sheet_set_manager(true); - iced::Task::none() + PanelId::Browser => self.show_browser = true, + PanelId::NodeGraph => { + task = self.on_graph(crate::ui::node_graph::GraphMsg::Toggle); } + PanelId::PointCloudManager => self.pc_manager.show = true, + PanelId::Count => self.set_count_palette(true), + PanelId::SheetSetManager => self.show_sheet_set_manager(true), PanelId::Layers => { self.sync_ribbon_layers(); self.show_layers = true; - iced::Task::none() } } - }; + } self.dock_reveal(id); return task; } @@ -407,16 +398,32 @@ impl OpenCADStudio { self.ribbon.deactivate_tool_if("LAYERS"); } } + self.dock_unpeek(id); + // A closed pallet cannot stay held by the pointer. + let held = match self.dock_drag { + Some(DockDrag::Move { panel, .. } | DockDrag::Width(panel)) => panel == id, + _ => false, + }; + if held { + self.dock_drag = None; + } + task + } + + /// Show `id` where the layout has it, first docking it on the right edge + /// when it has no place yet (opened for the first time, or never moved). + pub(crate) fn dock_open_at_default(&mut self, id: crate::ui::dock::PanelId) { + if !self.dock.is_placed(id) { + self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); + } + self.dock_reveal(id); + } + + /// Stop revealing `id` (it closed). + pub(crate) fn dock_unpeek(&mut self, id: crate::ui::dock::PanelId) { if self.dock_peek == Some(id) { self.dock_peek = None; } - if self - .dock_drag - .is_some_and(|d| d.movement().is_some_and(|(p, _, _)| p == id) || d == crate::ui::dock::DockDrag::Width(id)) - { - self.dock_drag = None; - } - iced::Task::none() } /// Whether `id` is currently rendered (not closed, not on the start screen / @@ -457,10 +464,13 @@ impl OpenCADStudio { side: crate::app::config::DockSide, gi: usize, ) -> Vec { - self.dock.groups(side)[gi] - .panels - .iter() - .copied() + // A stale index (a message from before the layout changed) reads as + // an empty group rather than a crash. + self.dock + .groups(side) + .get(gi) + .into_iter() + .flat_map(|g| g.panels.iter().copied()) .filter(|id| self.dock_panel_visible(*id)) .collect() } @@ -563,8 +573,12 @@ impl OpenCADStudio { /// on an edge's icon strip it joins a group between its icons or starts a /// new group; over the shown group's pallets it stacks above / below the /// one under the pointer; on an empty edge it starts a group; anywhere - /// else it floats. - pub(crate) fn dock_drop_target(&self, p: iced::Point) -> crate::ui::dock::DropTarget { + /// else it floats, held at `grab` from its top-left corner. + pub(crate) fn dock_drop_target( + &self, + p: iced::Point, + grab: iced::Vector, + ) -> crate::ui::dock::DropTarget { use crate::app::config::DockSide; use crate::ui::dock::{DropTarget, StripHit, DOCK_EDGE_ZONE, DOCK_STRIP_W}; let (ww, wh) = self.dock_workspace_size(); @@ -591,7 +605,7 @@ impl OpenCADStudio { side, index: at.unwrap_or(self.dock.groups(side).len()), }, - StripHit::Tab { group, index } => { + StripHit::Join { group, index } => { // Map the gap among open pallets to a position among // all of the group's pallets. let open = self.dock_group_visible(side, group); @@ -622,10 +636,6 @@ impl OpenCADStudio { } } } - let grab = match self.dock_drag { - Some(crate::ui::dock::DockDrag::Move { grab, .. }) => grab, - _ => iced::Vector::new(0.0, 0.0), - }; let x = (p.x - grab.x).clamp(0.0, (ww - 60.0).max(0.0)); let y = (p.y - grab.y).clamp(0.0, (wh - 30.0).max(0.0)); DropTarget::Float { x, y } diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index 55972f488..ef513f202 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -34,7 +34,7 @@ impl OpenCADStudio { if let Some(side) = self.dock_edge_menu { if !self.dock_visible_groups(side).is_empty() { // Checked = open and docked on this edge. - let shown: Vec<_> = crate::ui::dock::PanelId::ALL + let checked: Vec<_> = crate::ui::dock::PanelId::ALL .iter() .map(|p| { let here = self.dock.location(*p).map(|(s, _)| s) == Some(side); @@ -55,7 +55,7 @@ impl OpenCADStudio { .on_right_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) .into(), ); - layers.push(place_at(dock_edge_menu(side, &shown), x.max(0.0), plus_top)); + layers.push(place_at(dock_edge_menu(side, &checked), x.max(0.0), plus_top)); } } // While a panel is dragged, preview where it lands. @@ -173,10 +173,6 @@ impl OpenCADStudio { Some(DropTarget::Join { side: s, group, .. }) if s == side => Some(group), _ => None, }; - let tip_side = match side { - DockSide::Left => iced::widget::tooltip::Position::Right, - DockSide::Right => iced::widget::tooltip::Position::Left, - }; let mut items: Vec> = Vec::new(); for gi in self.dock_visible_groups(side) { let is_shown = shown == Some(gi); @@ -218,15 +214,13 @@ impl OpenCADStudio { .center_y(Length::Fixed(STRIP_CELL_H)), ) .on_press(Message::Dock(DockMsg::IconPress(id))) - .on_enter(Message::Dock(DockMsg::Hover(Some(id)))) - .on_exit(Message::Dock(DockMsg::Hover(None))) + .on_enter(Message::Dock(DockMsg::Hover(id))) + .on_exit(Message::Dock(DockMsg::HoverEnd(id))) .interaction(iced::mouse::Interaction::Pointer); icons.push(cell.into()); } // The group grip: a band on the window side holding the edge bar. - // Explicit heights: the tooltip around the band sizes it to its - // content, so a `Fill` bar would stop short of the row's bottom. let bar_w = if lit { 5.0 } else { 3.0 }; let bar_inset = 5.0; let bar = container( @@ -275,15 +269,9 @@ impl OpenCADStudio { }), ) .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) - .on_enter(Message::Dock(DockMsg::GripHover(Some((side, gi))))) - .on_exit(Message::Dock(DockMsg::GripHover(None))) + .on_enter(Message::Dock(DockMsg::GripHover(side, gi))) + .on_exit(Message::Dock(DockMsg::GripHoverEnd(side, gi))) .interaction(iced::mouse::Interaction::Grab); - let band = iced::widget::tooltip( - band, - text(t!("Drag to move group")).size(10), - tip_side, - ) - .gap(4); let band = container(band) .width(Fill) .height(Length::Fixed(row_h)) @@ -292,7 +280,9 @@ impl OpenCADStudio { DockSide::Right => iced::alignment::Horizontal::Right, }); items.push( - container(stack![column(icons), band]) + // A stack is as tall as its first layer, so the icon column + // spans the whole row; the edge bar then centres on the row. + container(stack![column(icons).height(Length::Fixed(row_h)), band]) .width(Length::Fixed(DOCK_STRIP_W)) .height(Length::Fixed(row_h)) .style(move |theme: &Theme| container::Style { @@ -512,7 +502,7 @@ impl OpenCADStudio { // 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))) - .on_enter(Message::Dock(DockMsg::Hover(Some(f.id)))) + .on_enter(Message::Dock(DockMsg::Hover(f.id))) .on_exit(Message::Dock(DockMsg::HoverExit)) .interaction(iced::mouse::Interaction::Idle); // A hidden panel keeps its title bar where it sits when shown. @@ -534,7 +524,7 @@ impl OpenCADStudio { ) -> Element<'_, Message> { use crate::app::config::DockSide; use crate::ui::dock::DropTarget; - let (ww, avail) = self.dock_workspace_size(); + let (ww, _) = self.dock_workspace_size(); let strip = crate::ui::dock::DOCK_STRIP_W; let tint = |side: DockSide, w: f32| -> Element<'_, Message> { let band = container(Space::new()) @@ -556,51 +546,20 @@ impl OpenCADStudio { .into() }; let mut layers: Vec> = Vec::new(); - let docked = match target { - DropTarget::Edge { side, index } => { - let mut after = self.dock.clone(); - after.dock(id, side, index); - Some((side, after)) - } - DropTarget::Join { side, group, index } => { - let mut after = self.dock.clone(); - after.join_group(id, side, group, index); - Some((side, after)) - } + let group_drag = self.dock_drag.and_then(|d| d.movement()).and_then(|(_, g, _)| g); + let marks_side = match target { + DropTarget::Edge { side, .. } | DropTarget::Join { side, .. } => Some(side), DropTarget::Float { x, y } => { let (w, h) = self.dock_float_size(id); - layers.push(place_at( - dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), - x, - y, - )); + layers.push(place_at(dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), x, y)); None } }; - // A whole group shows only the strip feedback and its ghost; the - // column keeps showing what is there. - let group_drag = self.dock_drag.and_then(|d| d.movement()).and_then(|(_, g, _)| g); - let docked = docked.map(|(side, after)| (side, group_drag.is_none().then_some(after))); - let marks_side = docked.as_ref().map(|(side, _)| *side); - if let Some((side, Some(after))) = docked { - // Lay the group out as it will be after the drop. - if let Some((_, gi)) = after.location(id) { - let group = &after.groups(side)[gi]; - let open: Vec = (0..group.panels.len()) - .filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i])) - .collect(); - let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); - let spans = crate::ui::dock::slot_spans(&weights, avail); - let at = open - .iter() - .position(|i| group.panels[*i] == id) - .expect("dropped panel"); - let (top, bottom) = spans[at]; - let w = group - .panels - .iter() - .map(|p| after.width(*p, self.win_size.0)) - .fold(0.0, f32::max); + // A single pallet also gets a ghost where it lands in the column; a + // whole group shows only the strip feedback, as the column keeps + // showing what is there. + if let (Some(side), None) = (marks_side, group_drag) { + if let Some((top, bottom, w)) = self.dock_landing(id, target) { let x = match side { DockSide::Left => strip, DockSide::Right => (ww - w - strip).max(0.0), @@ -627,6 +586,37 @@ impl OpenCADStudio { .into() } + /// Where docked pallet `id` would sit after dropping on `target`: its + /// span (top, bottom) in its group as laid out after the drop, and the + /// group's width. `None` for a floating target. + fn dock_landing( + &self, + id: crate::ui::dock::PanelId, + target: crate::ui::dock::DropTarget, + ) -> Option<(f32, f32, f32)> { + use crate::ui::dock::DropTarget; + let mut after = self.dock.clone(); + match target { + DropTarget::Edge { side, index } => { + after.dock(id, side, index); + } + DropTarget::Join { side, group, index } => { + after.join_group(id, side, group, index); + } + DropTarget::Float { .. } => return None, + } + let (side, gi) = after.location(id)?; + let group = &after.groups(side)[gi]; + let open: Vec = (0..group.panels.len()) + .filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i])) + .collect(); + let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); + let at = open.iter().position(|i| group.panels[*i] == id)?; + let (_, avail) = self.dock_workspace_size(); + let (top, bottom) = *crate::ui::dock::slot_spans(&weights, avail).get(at)?; + Some((top, bottom, after.group_width_px(side, gi, self.win_size.0))) + } + /// The icon strip's drop marks for `target` on `side`: a short line with /// end caps between a group's icons (join that group there), or a /// full-width line on a divider with a "+ New group" badge. @@ -767,13 +757,10 @@ fn dock_icon_ghost(icons: &[crate::ui::dock::PanelId]) -> Element<'static, Messa .into() } -/// Canvas that draws a label rotated 90° (for a collapsed panel's bar). +/// Canvas that draws a floating title bar's label, reading upward from the +/// bar's bottom end. struct VBarLabel { text: String, - clockwise: bool, - /// Start the text at the reading-start end of the bar instead of - /// centring it. - from_start: bool, } impl canvas::Program for VBarLabel { @@ -790,27 +777,15 @@ impl canvas::Program for VBarLabel { let mut frame = canvas::Frame::new(renderer, bounds.size()); frame.with_save(|frame| { frame.translate(iced::Vector::new(bounds.width / 2.0, bounds.height / 2.0)); - frame.rotate(iced::Radians(if self.clockwise { - std::f32::consts::FRAC_PI_2 - } else { - -std::f32::consts::FRAC_PI_2 - })); - // After the rotation the text runs along local +x, so the - // reading start sits at -height/2. - let (position, align_x) = if self.from_start { - ( - iced::Point::new(-bounds.height / 2.0, 0.0), - iced::advanced::text::Alignment::Left, - ) - } else { - (iced::Point::ORIGIN, iced::advanced::text::Alignment::Center) - }; + frame.rotate(iced::Radians(-std::f32::consts::FRAC_PI_2)); + // After the rotation the text runs along local +x (upward), so + // the bar's bottom end sits at -height/2. frame.fill_text(canvas::Text { content: self.text.clone(), - position, + position: iced::Point::new(-bounds.height / 2.0, 0.0), color: theme.palette().background.base.text.scale_alpha(0.72), size: iced::Pixels(13.0), - align_x, + align_x: iced::advanced::text::Alignment::Left, align_y: iced::alignment::Vertical::Center, shaping: iced::advanced::text::Shaping::Advanced, ..Default::default() @@ -840,9 +815,7 @@ fn floating_title_bar( let close = crate::ui::dock::close_button(id, tip); let pin = crate::ui::dock::pin_button(id, auto_collapse, tip); let title = canvas(VBarLabel { - text: id.title().to_string(), - clockwise: false, - from_start: true, + text: t!(id.title()).into_owned(), }) .width(Fill) .height(Fill); @@ -875,8 +848,8 @@ fn floating_title_bar( .into() } -/// Grabbable separator for a docked panel managed by the general dock. Same -/// visual as the previous per-panel divider but emits generic dock messages. +/// Grabbable separator between a docked pallet and the viewport: drag to +/// size its group, double-click to reset the width. fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { let line = container(Space::new()) .width(Length::Fixed(DOCK_DIVIDER_W)) @@ -900,13 +873,13 @@ const DOCK_MENU_W: f32 = 230.0; /// The pallet menu of an edge's + button, styled like the right-click menu: /// one row per pallet with its icon, its name and a check mark when it is -/// open on this edge. `shown` holds each pallet with that state. +/// open on this edge. `checked` holds each pallet with that state. fn dock_edge_menu( side: crate::app::config::DockSide, - shown: &[(crate::ui::dock::PanelId, bool)], + checked: &[(crate::ui::dock::PanelId, bool)], ) -> Element<'static, Message> { use crate::ui::dock::DockMsg; - let rows: Vec> = shown + let rows: Vec> = checked .iter() .map(|(id, checked)| { let id = *id; @@ -918,7 +891,7 @@ fn dock_edge_menu( button( row![ crate::ui::icons::themed(id.icon(), 16.0), - text(id.title()).size(12).width(Fill), + text(t!(id.title())).size(12).width(Fill), check, ] .spacing(8) @@ -991,7 +964,7 @@ fn dock_splitter( /// its name as the header. fn dock_ghost(id: crate::ui::dock::PanelId, w: f32, h: f32) -> Element<'static, Message> { let header = container( - text(id.title()) + text(t!(id.title())) .size(12) .wrapping(iced::widget::text::Wrapping::None) .ellipsis(iced::advanced::text::Ellipsis::End), diff --git a/src/app/node_graph.rs b/src/app/node_graph.rs index 3af60f9d7..a915e8cbe 100644 --- a/src/app/node_graph.rs +++ b/src/app/node_graph.rs @@ -173,11 +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.is_placed(id) { - self.dock - .dock(id, crate::app::config::DockSide::Right, usize::MAX); - } - self.dock_reveal(id); + self.dock_open_at_default(id); return self.graph_run(i); } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 2da46524e..9ef3b5e9f 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2580,12 +2580,7 @@ impl OpenCADStudio { Message::ToggleXrefManager => { self.show_external_references ^= true; if self.show_external_references { - use crate::app::config::DockSide; - use crate::ui::dock::PanelId; - if !self.dock.is_placed(PanelId::ExternalReferences) { - self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX); - } - self.dock_reveal(PanelId::ExternalReferences); + self.dock_open_at_default(crate::ui::dock::PanelId::ExternalReferences); self.refresh_xref_manager(); } Task::none() diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 58ece0e82..be422776d 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -26,10 +26,12 @@ pub enum DockMsg { AutoCollapseToggle(PanelId), /// Close / hide `panel`. Close(PanelId), - /// The pointer entered (`Some`) or left (`None`) a pallet's strip icon or - /// floating frame: highlights the icon and, where the pallet auto-hides, - /// reveals it. - Hover(Option), + /// The pointer entered a pallet's strip icon or floating frame: + /// highlights the icon and, where the pallet auto-hides, reveals it. + Hover(PanelId), + /// The pointer left that icon or frame. Carries the pallet so a late + /// "left" never clears the hover of the element just entered. + HoverEnd(PanelId), /// Pointer moved while a panel is dragging or resizing. DragMove(iced::Point), /// Pointer released after a drag / resize. @@ -45,8 +47,10 @@ pub enum DockMsg { /// Begin resizing floating `panel` from its corner grip; `true` for the /// bottom-left grip (a panel whose title bar is on the right). FloatResizeGrab(PanelId, bool), - /// The pointer entered (`Some`) or left (`None`) a docked title bar. - TitleHover(Option), + /// The pointer entered a docked title bar (shows its pin and close). + TitleHover(PanelId), + /// The pointer left that title bar. + TitleHoverEnd(PanelId), /// Open (`Some`) the pallet menu of an edge's icon strip (its + button), /// or close it (`None`). EdgeMenu(Option), @@ -55,9 +59,10 @@ pub enum DockMsg { EdgeMenuToggle(DockSide, PanelId), /// Begin dragging a whole group by its edge band in the icon strip. GroupGrab(DockSide, usize), - /// The pointer entered (`Some`) or left (`None`) a group's edge band. - GripHover(Option<(DockSide, usize)>), - + /// The pointer entered a group's edge band (side, group). + GripHover(DockSide, usize), + /// The pointer left that edge band. + GripHoverEnd(DockSide, usize), /// Double-click on a docked title bar: float the panel. FloatOut(PanelId), /// Double-click on a floating title bar: dock the panel on that side. @@ -305,6 +310,18 @@ pub struct FloatPanel { pub h: f32, } +impl FloatPanel { + /// Keep a placement read from disk usable: finite, on the workspace's + /// positive side, and at least the minimum size. + fn heal(&mut self) { + let finite_or = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; + self.x = finite_or(self.x, 0.0).max(0.0); + self.y = finite_or(self.y, 0.0).max(0.0); + self.w = finite_or(self.w, self.id.default_width()).clamp(DOCK_MIN_W, self.id.max_width()); + self.h = finite_or(self.h, FLOAT_MIN_H).max(FLOAT_MIN_H); + } +} + /// Smallest floating panel height. pub const FLOAT_MIN_H: f32 = 160.0; /// Smallest on-screen height of a stacked pallet while dragging a splitter. @@ -407,11 +424,13 @@ impl DockState { let groups = self.groups_mut(side); for g in groups.iter_mut() { g.heal(); - let keep: Vec = g.panels.iter().map(|id| seen.insert(*id)).collect(); - let mut k = keep.iter(); - g.weights.retain(|_| *k.next().expect("parallel")); - let mut k = keep.iter(); - g.panels.retain(|_| *k.next().expect("parallel")); + (g.panels, g.weights) = g + .panels + .iter() + .zip(&g.weights) + .filter(|(id, _)| seen.insert(**id)) + .map(|(id, w)| (*id, *w)) + .unzip(); } groups.retain(|g| !g.panels.is_empty()); let len = groups.len(); @@ -419,6 +438,7 @@ impl DockState { self.show_group(side, shown); } self.floating.retain(|f| seen.insert(f.id)); + self.floating.iter_mut().for_each(FloatPanel::heal); } /// Where (if anywhere) a panel is docked: its side and group index. @@ -451,9 +471,7 @@ impl DockState { /// 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)) + on_screen_width(self.settings(id).width, id.max_width(), win_w) } /// The floating auto-hide flag of `id` (a docked pallet follows its @@ -509,9 +527,12 @@ impl DockState { } /// Group `group`'s docked width: its own once sized, else the widest of - /// its pallets' own widths. + /// its pallets' own widths. A group that no longer exists reads as the + /// minimum width. pub fn group_width(&self, side: DockSide, group: usize) -> f32 { - let g = &self.groups(side)[group]; + let Some(g) = self.groups(side).get(group) else { + return DOCK_MIN_W; + }; if g.width > 0.0 { return g.width; } @@ -523,9 +544,10 @@ impl DockState { /// The widest group `group` may be: the most any of its pallets allows. fn group_max_width(&self, side: DockSide, group: usize) -> f32 { - self.groups(side)[group] - .panels - .iter() + self.groups(side) + .get(group) + .into_iter() + .flat_map(|g| g.panels.iter()) .map(|id| id.max_width()) .fold(DOCK_MIN_W, f32::max) } @@ -533,8 +555,11 @@ impl DockState { /// Group `group`'s on-screen width in a `win_w` wide window, clamped like /// [`Self::width`]. pub fn group_width_px(&self, side: DockSide, group: usize, win_w: f32) -> f32 { - let max = self.group_max_width(side, group).min(win_w * 0.45).max(DOCK_MIN_W); - self.group_width(side, group).clamp(DOCK_MIN_W, max) + on_screen_width( + self.group_width(side, group), + self.group_max_width(side, group), + win_w, + ) } /// Size group `group` on `side`, clamped. @@ -547,9 +572,11 @@ impl DockState { /// Give group `group` the widest default width of its pallets. pub fn reset_group_width(&mut self, side: DockSide, group: usize) { - let width = self.groups(side)[group] - .panels - .iter() + let width = self + .groups(side) + .get(group) + .into_iter() + .flat_map(|g| g.panels.iter()) .map(|id| id.default_width()) .fold(DOCK_MIN_W, f32::max); self.set_group_width(side, group, width); @@ -566,12 +593,17 @@ impl DockState { return Some((side, gi, pos, weight, false)); } self.groups_mut(side).remove(gi); - // Keep showing the same group, or its neighbour when it went away. + self.after_group_removed(side, gi); + Some((side, gi, pos, weight, true)) + } + + /// Group `gi` was taken off `side`: keep showing the same group, or its + /// neighbour when the shown group itself went away. + fn after_group_removed(&mut self, side: DockSide, gi: usize) { let shown = self.shown(side); if shown > gi || (shown == gi && shown > 0 && shown >= self.groups(side).len()) { self.show_group(side, shown - 1); } - Some((side, gi, pos, weight, true)) } /// Dock `id` as a new group of its own on `side` at insertion `index` @@ -640,12 +672,10 @@ impl DockState { index -= 1; } } - let shown_here = self.shown(side); let g = self.groups_mut(side).remove(group); - if side != to && shown_here > group { - self.show_group(side, shown_here - 1); - } else if side != to && shown_here >= self.groups(side).len() && shown_here > 0 { - self.show_group(side, shown_here - 1); + // Moving within an edge shows the moved group below anyway. + if side != to { + self.after_group_removed(side, group); } let dest = self.groups_mut(to); let index = index.min(dest.len()); @@ -814,6 +844,12 @@ pub fn portion(weight: f32) -> u16 { (weight * 1000.0).round().clamp(1.0, u16::MAX as f32) as u16 } +/// `width` clamped to what fits on screen: at least [`DOCK_MIN_W`], at most +/// `max` and 45 % of a `win_w` wide window. +fn on_screen_width(width: f32, max: f32, win_w: f32) -> f32 { + width.clamp(DOCK_MIN_W, max.min(win_w * 0.45).max(DOCK_MIN_W)) +} + /// 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. @@ -889,8 +925,8 @@ pub fn strip_layout(groups: &[(usize, usize)]) -> (Vec, f32) { pub enum StripHit { /// A new group before group `group`; `None` = after the last group. NewGroup(Option), - /// Group `group`, at position `index` among its open pallets. - Tab { group: usize, index: usize }, + /// Join group `group`, at position `index` among its open pallets. + Join { group: usize, index: usize }, } /// Classify a pointer at strip height `y`: on a divider (or the gap above @@ -904,7 +940,7 @@ pub fn strip_hit(y: f32, layout: &[StripGroup]) -> StripHit { if y < g.icons_bottom() - STRIP_NEW_GROUP_SLOP { let index = (((y - g.icons_top) / STRIP_CELL_H).round().max(0.0) as usize) .min(g.icons); - return StripHit::Tab { + return StripHit::Join { group: g.group, index, }; @@ -961,8 +997,8 @@ pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, ) .on_press(Message::Dock(DockMsg::DockGrab(id))) .on_double_click(Message::Dock(DockMsg::FloatOut(id))) - .on_enter(Message::Dock(DockMsg::TitleHover(Some(id)))) - .on_exit(Message::Dock(DockMsg::TitleHover(None))) + .on_enter(Message::Dock(DockMsg::TitleHover(id))) + .on_exit(Message::Dock(DockMsg::TitleHoverEnd(id))) .interaction(iced::mouse::Interaction::Grab) .into() } @@ -1355,17 +1391,17 @@ mod tests { // Top half of the first icon: into group 0 at the top. assert_eq!( strip_hit(g0.icons_top + 10.0, &layout), - StripHit::Tab { group: 0, index: 0 } + StripHit::Join { group: 0, index: 0 } ); // Between the first and second icon of group 0. assert_eq!( strip_hit(g0.icons_top + STRIP_CELL_H + 4.0, &layout), - StripHit::Tab { group: 0, index: 1 } + StripHit::Join { group: 0, index: 1 } ); // Lower half of the last icon: into group 0 at the end. assert_eq!( strip_hit(g0.icons_bottom() - 8.0, &layout), - StripHit::Tab { group: 0, index: 3 } + StripHit::Join { group: 0, index: 3 } ); // On the divider between the groups: a new group before group 2. assert_eq!(strip_hit(g0.bottom(), &layout), StripHit::NewGroup(Some(2))); From 4e210f65b5e8fd61c007e52df79380becf92771d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 10:22:47 +0000 Subject: [PATCH 18/24] Dock: LAYERS opens its pallet like Insert Block, Layer Manager layout fixes, 150 ms auto-hide hover delay - LAYERS ribbon button runs a command that opens the Layer Manager pallet; the ribbon no longer keeps it highlighted. - Layer Manager: search box stays right-aligned, the column header fills the row, no stray contour lines when the pallet is taller than its rows. - Auto-hidden edges don't show the shown-group accent bar. - Auto-hide reveal and hide wait 150 ms of rest, so passing over the strip doesn't flash groups open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/commands/fileops.rs | 4 +- src/app/dock/tests.rs | 27 ++++++++++ src/app/dock/update.rs | 90 ++++++++++++++++++++++++++++++-- src/app/dock/view.rs | 7 ++- src/app/mod.rs | 6 +++ src/app/update/mod.rs | 24 ++------- src/modules/draw/layers/panel.rs | 6 ++- src/ui/dock.rs | 8 +++ src/ui/window/layers.rs | 65 ++++++++++------------- 9 files changed, 172 insertions(+), 65 deletions(-) diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index b5f69b303..4b395f182 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -354,7 +354,9 @@ impl OpenCADStudio { "ORTHO" => return Some(Task::done(Message::ToggleOrtho)), "PARALLEL" => return Some(Task::done(Message::SetProjection(true))), "PERSP" => return Some(Task::done(Message::SetProjection(false))), - "LAYERS" => return Some(Task::done(Message::ToggleLayers)), + // LAYERS opens the Layer Manager pallet (its close button or + // the toggle message close it). + "LAYERS" => self.open_layers_pallet(), // SCRIPT — run a command script: each non-comment // line is fed through the same command path the `--script` startup diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 117376bef..6220927bb 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -336,9 +336,19 @@ fn dock_hover_reveals_groups_only_on_an_auto_hiding_edge() { assert!(!app.dock_edge_expanded(DockSide::Left)); // Hovering an icon flies its group out. let _ = app.on_dock(DockMsg::Hover(PanelId::Browser)); + // ...but only once the pointer rests: the reveal waits for the delay. + assert!(!app.dock_edge_expanded(DockSide::Left)); + let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen)); assert!(app.dock_edge_expanded(DockSide::Left)); assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); + // Leaving and coming back within the delay keeps it open. let _ = app.on_dock(DockMsg::HoverExit); + let stale = app.dock_hover_gen; + let _ = app.on_dock(DockMsg::HoverStay); + let _ = app.on_dock(DockMsg::HoverSettled(stale)); + assert!(app.dock_edge_expanded(DockSide::Left)); + let _ = app.on_dock(DockMsg::HoverExit); + let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen)); assert!(!app.dock_edge_expanded(DockSide::Left)); // Without auto-hide, hovering does not switch groups (clicks do). let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties)); @@ -539,12 +549,29 @@ fn hovering_a_strip_icon_reveals_an_auto_hiding_edge_in_the_real_view() { icon, &[iced::Event::Mouse(iced::mouse::Event::CursorMoved { position: icon })], ); + let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen)); assert!( app.dock_edge_expanded(DockSide::Right), "hovering the icon reveals the auto-hiding edge" ); } +#[test] +fn layers_ribbon_button_opens_the_pallet_without_staying_highlighted() { + let mut app = dock_app(); + // The ribbon's LAYERS button runs the LAYERS command. + let click = || Message::RibbonToolClick { + tool_id: "LAYERS".to_string(), + event: crate::modules::ModuleEvent::Command("LAYERS".to_string()), + }; + let _ = app.update(click()); + assert!(app.show_layers); + assert_eq!(app.ribbon.active_tool(), None); + // Pressing it again keeps it open, like the Blocks palette's button. + let _ = app.update(click()); + assert!(app.show_layers); +} + #[test] fn dock_double_clicks_float_and_dock_panels() { use crate::app::config::DockSide; diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs index 2c791fd1d..5f3a90618 100644 --- a/src/app/dock/update.rs +++ b/src/app/dock/update.rs @@ -7,6 +7,24 @@ use crate::app::{Message, OpenCADStudio}; /// so a plain click never moves anything. const DOCK_DRAG_THRESHOLD: f32 = 5.0; +/// Resolves once the dock hover delay has passed (a sleeping helper thread; +/// the web build has none and resolves at once). +fn hover_delay() -> impl std::future::Future { + #[cfg(not(target_arch = "wasm32"))] + let rx = { + let (tx, rx) = iced::futures::channel::oneshot::channel::<()>(); + std::thread::spawn(move || { + std::thread::sleep(crate::ui::dock::DOCK_HOVER_DELAY); + let _ = tx.send(()); + }); + rx + }; + async move { + #[cfg(not(target_arch = "wasm32"))] + let _ = rx.await; + } +} + impl OpenCADStudio { /// Dock chrome interaction (grab / pin / resize / hover / move) applied to /// whichever panel the message names. @@ -126,11 +144,15 @@ impl OpenCADStudio { DockMsg::Hover(id) => { self.dock_icon_hover = Some(id); // Only an auto-hiding edge (or floating pallet) reacts to - // hover: it reveals the hovered pallet's group. Otherwise - // groups switch on click. Ignored mid-drag, when the pointer - // is over the preview rather than the strip. + // hover: after a short rest it reveals the hovered pallet's + // group. Otherwise groups switch on click. Ignored mid-drag, + // when the pointer is over the preview rather than the strip. if self.dock_drag.is_none() && self.dock.auto_hides(id) { - self.dock_reveal(id); + if self.dock_peek == Some(id) { + self.dock_hover_cancel(); + } else { + return self.dock_hover_arm(Some(id)); + } } } DockMsg::HoverEnd(id) => { @@ -142,7 +164,25 @@ impl OpenCADStudio { if self.dock_drag.is_none() && self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) { - self.dock_peek = None; + return self.dock_hover_arm(None); + } + // Left before a pending reveal ran out: it never happens. + self.dock_hover_cancel(); + } + DockMsg::HoverStay => { + if self.dock_hover_pending == Some(None) { + self.dock_hover_cancel(); + } + } + DockMsg::HoverSettled(gen) => { + if gen == self.dock_hover_gen && self.dock_drag.is_none() { + match self.dock_hover_pending.take() { + Some(Some(id)) if self.dock.auto_hides(id) => self.dock_reveal(id), + Some(None) if self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) => { + self.dock_peek = None; + } + _ => {} + } } } DockMsg::DragMove(point) => self.dock_drag_move(point), @@ -329,6 +369,23 @@ impl OpenCADStudio { /// Bring `id` into view: show its group on its edge and, where it /// auto-hides, keep it revealed until the pointer leaves. + /// Arm a delayed reveal (`Some`) or hide (`None`); it applies when its + /// timer reports back unless a newer hover superseded it. + fn dock_hover_arm(&mut self, target: Option) -> iced::Task { + self.dock_hover_gen = self.dock_hover_gen.wrapping_add(1); + self.dock_hover_pending = Some(target); + let gen = self.dock_hover_gen; + iced::Task::perform(hover_delay(), move |()| { + Message::Dock(crate::ui::dock::DockMsg::HoverSettled(gen)) + }) + } + + /// Drop any pending delayed reveal / hide. + fn dock_hover_cancel(&mut self) { + self.dock_hover_gen = self.dock_hover_gen.wrapping_add(1); + self.dock_hover_pending = None; + } + pub(crate) fn dock_reveal(&mut self, id: crate::ui::dock::PanelId) { self.dock.show_group_of(id); self.dock_peek = Some(id); @@ -419,6 +476,29 @@ impl OpenCADStudio { self.dock_reveal(id); } + /// Open the Layer Manager pallet (LAYERS). The first time it floats over + /// the middle of the drawing, where the dialog it replaced used to open; + /// after that it opens wherever the user left it. + pub(crate) fn open_layers_pallet(&mut self) { + use crate::ui::dock::{FloatPanel, PanelId}; + self.sync_ribbon_layers(); + self.show_layers = true; + if !self.dock.is_placed(PanelId::Layers) { + let (ww, wh) = self.dock_workspace_size(); + let (w, h) = self.dock_float_size(PanelId::Layers); + self.dock.float(FloatPanel { + id: PanelId::Layers, + x: ((ww - w) * 0.5).max(0.0), + y: ((wh - h) * 0.5).max(0.0), + w, + h, + }); + self.save_config(); + } + self.dock.raise_float(PanelId::Layers); + self.dock_reveal(PanelId::Layers); + } + /// Stop revealing `id` (it closed). pub(crate) fn dock_unpeek(&mut self, id: crate::ui::dock::PanelId) { if self.dock_peek == Some(id) { diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index ef513f202..769d062e9 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -147,6 +147,7 @@ impl OpenCADStudio { } Some( mouse_area(row(children).height(Fill)) + .on_enter(Message::Dock(crate::ui::dock::DockMsg::HoverStay)) .on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit)) .into(), ) @@ -165,7 +166,11 @@ impl OpenCADStudio { DockMsg, DropTarget, DOCK_STRIP_W, STRIP_CELL_H, STRIP_DIVIDER_H, STRIP_GRIP_W, STRIP_PAD, STRIP_PLUS_GAP, }; - let shown = self.dock_shown_group(side); + // Only a group actually on screen is marked as shown: on an + // auto-hiding edge nothing is, until a group is revealed. + let shown = self + .dock_shown_group(side) + .filter(|_| self.dock_edge_expanded(side)); let movement = self.dock_drag.and_then(|d| d.movement()); let move_target = movement.and_then(|(_, _, target)| target); let dragging = move_target.is_some(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 706dde860..53bb69fb7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -850,6 +850,10 @@ pub(super) struct OpenCADStudio { /// floating frame): hovered, being dragged, or just opened. Cleared when /// the pointer leaves. pub(crate) dock_peek: Option, + /// Reveal (`Some(pallet)`) or hide (`None`) waiting out the hover delay, + /// with the generation its timer carries; any newer hover supersedes it. + pub(crate) dock_hover_pending: Option>, + pub(crate) dock_hover_gen: u64, /// What the pointer is dragging in the dock (pallet, group, width, /// splitter, floating size, Layer Manager column). pub(crate) dock_drag: Option, @@ -4291,6 +4295,8 @@ impl OpenCADStudio { xref_manager: Default::default(), dock: Default::default(), dock_peek: None, + dock_hover_pending: None, + dock_hover_gen: 0, dock_drag: None, dock_drag_last: None, dock_title_hover: None, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9ef3b5e9f..335af87dd 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2552,28 +2552,12 @@ impl OpenCADStudio { Message::ScriptLine(line) => self.feed_script_line(&line), Message::ToggleLayers => { - use crate::ui::dock::PanelId; if self.show_layers { - return self.on_dock(crate::ui::dock::DockMsg::Close(PanelId::Layers)); + return self.on_dock(crate::ui::dock::DockMsg::Close( + crate::ui::dock::PanelId::Layers, + )); } - self.sync_ribbon_layers(); - self.show_layers = true; - if !self.dock.is_placed(PanelId::Layers) { - // First use: float it over the middle of the drawing, - // where the dialog used to open. - let (ww, wh) = self.dock_workspace_size(); - let (w, h) = self.dock_float_size(PanelId::Layers); - self.dock.float(crate::ui::dock::FloatPanel { - id: PanelId::Layers, - x: ((ww - w) * 0.5).max(0.0), - y: ((wh - h) * 0.5).max(0.0), - w, - h, - }); - self.save_config(); - } - self.dock.raise_float(PanelId::Layers); - self.dock_reveal(PanelId::Layers); + self.open_layers_pallet(); Task::none() } diff --git a/src/modules/draw/layers/panel.rs b/src/modules/draw/layers/panel.rs index 9ea4c9cf6..14212fa1c 100644 --- a/src/modules/draw/layers/panel.rs +++ b/src/modules/draw/layers/panel.rs @@ -1,4 +1,4 @@ -// Layers panel toggle — ribbon definition. +// Layer Manager pallet — ribbon definition. use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -7,6 +7,8 @@ pub fn tool() -> ToolDef { id: "LAYERS", label: "Layers", icon: IconKind::Svg(include_bytes!("../../../../assets/icons/layers/panel.svg")), - event: ModuleEvent::ToggleLayers, + // A command like the Blocks palette's: it opens the pallet, and the + // ribbon drops the button's highlight once it has run. + event: ModuleEvent::Command("LAYERS".to_string()), } } diff --git a/src/ui/dock.rs b/src/ui/dock.rs index be422776d..4697811bc 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -71,8 +71,16 @@ pub enum DockMsg { FloatRaise(PanelId), /// The pointer left the edge column; collapse any auto-collapsing panel. HoverExit, + /// The pointer entered the edge column again: keep its group revealed. + HoverStay, + /// The hover delay of the reveal / hide armed as generation `n` ran out. + HoverSettled(u64), } +/// How long the pointer must rest before an auto-hiding group reveals or +/// hides, so passing over the strip doesn't flash groups open. +pub const DOCK_HOVER_DELAY: std::time::Duration = std::time::Duration::from_millis(150); + /// 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")] diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index feeaa8ec0..6857c8b26 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -323,8 +323,9 @@ impl LayerPanel { self.linetype_items = items; } - /// The Layer Manager as a dock pallet: its title bar, then the table at - /// its natural width, scrolling sideways when the pallet is narrower. + /// The Layer Manager as a dock pallet: its title bar, the toolbar across + /// the pallet (search box on the right), then the table, at least as wide + /// as the pallet and scrolling sideways when the pallet is narrower. pub fn view_panel( &self, name_col_w: f32, @@ -337,19 +338,8 @@ impl LayerPanel { crate::tr!("modal", "layer-manager"), chrome, ); - let table = self.view_content( - name_col_w, - crate::ui::modal::ModalSizing { - width: Length::Shrink, - height: Length::Fill, - }, - ); - let body = scrollable(table) - .direction(iced::widget::scrollable::Direction::Horizontal( - iced::widget::scrollable::Scrollbar::new(), - )) - .width(Length::Fill) - .height(Length::Fill); + // Inside the pallet's 1 px border on either side. + let body = self.view_content(name_col_w, (width - 2.0).max(0.0)); container(column![title_bar, body]) .width(Length::Fixed(width)) .height(Length::Fill) @@ -365,11 +355,8 @@ impl LayerPanel { .into() } - fn view_content( - &self, - name_col_w: f32, - sizing: crate::ui::modal::ModalSizing, - ) -> Element<'_, Message> { + /// Toolbar and table for a pallet `inner_w` wide. + fn view_content(&self, name_col_w: f32, inner_w: f32) -> Element<'_, Message> { let has_sel = self.selected.is_some(); let sel_is_zero = self .selected @@ -396,7 +383,7 @@ impl LayerPanel { Message::LayerSetCurrent, can_set_current, ), - iced::widget::Space::new().width(sizing.width), + iced::widget::Space::new().width(Length::Fill), // Search box: filters rows by name as the user types (#343). text_input(t!("Search…").as_ref(), &self.filter) .on_input(Message::LayerManagerFilterChanged) @@ -414,7 +401,7 @@ impl LayerPanel { )), ..Default::default() }) - .width(sizing.width) + .width(Length::Fill) .padding([4, 8]); // ── Column header ───────────────────────────────────────────────── @@ -452,7 +439,6 @@ impl LayerPanel { ), ] .spacing(4) - .width(sizing.width) .align_y(iced::Center); for vp in &self.vp_cols { @@ -478,7 +464,7 @@ impl LayerPanel { } }) .padding([4, 8]) - .width(sizing.width); + .width(Length::Fill); // ── Layer rows ──────────────────────────────────────────────────── let mut rows_col = column![].spacing(0); @@ -519,19 +505,26 @@ impl LayerPanel { let table = scrollable(rows_col) .id(iced::advanced::widget::Id::new(LAYER_TABLE_SCROLL_ID)) - .height(sizing.height.min(240.0)); + .width(Length::Fill) + .height(Length::Fill); - // ── Full-window frame ───────────────────────────────────────────── - container(column![toolbar, col_header, table].spacing(0)) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.base.color - )), - ..Default::default() - }) - .width(sizing.width) - .height(sizing.height) - .into() + // The header and table stretch to the wider of their columns and the + // pallet (the zero-height spacer), so the header's grey band and + // borders always run to the pallet's edge; a narrower pallet scrolls + // the table sideways while the toolbar stays put. + let grid = column![ + col_header, + table, + iced::widget::Space::new().width(Length::Fixed(inner_w)).height(0), + ] + .height(Length::Fill); + let grid = scrollable(grid) + .direction(iced::widget::scrollable::Direction::Horizontal( + iced::widget::scrollable::Scrollbar::new(), + )) + .width(Length::Fill) + .height(Length::Fill); + column![toolbar, grid].height(Length::Fill).into() } } From f19948bc07bcc92fa2bc00305107fc4925f4c26a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 10:40:55 +0000 Subject: [PATCH 19/24] Dock: floating groups, floating title bar menu, translations - A docked group floats as one window: drag its edge band over the drawing or double-click the band. Floating windows hold a stack of pallets with splitters; the vertical title bar shows each pallet's icon (press one to drag that pallet out). Dragging the bar moves the whole window; dropping it on an edge docks or joins it as a group. - Right-click a floating title bar: Pallets submenu (add or hide pallets in the window), Dock left, Dock right. - Close on a floating bar closes every pallet in the window; its pin sets auto-hide for the whole window. - Older configs with single floating pallets still load. - Translate the new dock texts (Auto-hide, Dock left/right, New group, Pallets, Browser, Node Graph) in all 21 languages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- locales/ar-SA/opencadstudio.ftl | 9 + locales/bg-BG/opencadstudio.ftl | 9 + locales/cs-CZ/opencadstudio.ftl | 9 + locales/de-DE/opencadstudio.ftl | 9 + locales/el-GR/opencadstudio.ftl | 9 + locales/en-US/opencadstudio.ftl | 9 + locales/es-ES/opencadstudio.ftl | 9 + locales/fi-FI/opencadstudio.ftl | 9 + locales/fr-FR/opencadstudio.ftl | 9 + locales/hi-IN/opencadstudio.ftl | 9 + locales/hu-HU/opencadstudio.ftl | 9 + locales/it-IT/opencadstudio.ftl | 9 + locales/ja-JP/opencadstudio.ftl | 9 + locales/ko-KR/opencadstudio.ftl | 9 + locales/nl-NL/opencadstudio.ftl | 9 + locales/pl-PL/opencadstudio.ftl | 9 + locales/pt-BR/opencadstudio.ftl | 9 + locales/ru-RU/opencadstudio.ftl | 9 + locales/tr-TR/opencadstudio.ftl | 9 + locales/zh-CN/opencadstudio.ftl | 9 + locales/zh-TW/opencadstudio.ftl | 9 + src/app/dock/tests.rs | 59 ++++- src/app/dock/update.rs | 209 ++++++++++++---- src/app/dock/view.rs | 371 ++++++++++++++++++++++------- src/app/mod.rs | 6 + src/locale_catalog.rs | 7 + src/ui/dock.rs | 408 +++++++++++++++++++++++++------- 27 files changed, 1024 insertions(+), 225 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index c6a75500f..9e78732e8 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6164,3 +6164,12 @@ count-feature = .invalid-boundary = كائن حد منطقة العد غير صالح. حدد خطًا متعددًا مغلقًا يتكون من قطع مستقيمة ولا يتقاطع مع نفسه. .block-not-found = تعذر العثور على الكتلة "__ocs_fmt_0__". .invalid-point = نقطة غير صالحة. + +dock = + .auto-hide = إخفاء تلقائي + .dock-left = إرساء إلى اليسار + .dock-right = إرساء إلى اليمين + .new-group = مجموعة جديدة + .pallets = اللوحات + .browser = المستعرض + .node-graph = مخطط العقد diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 83f0c2d02..0de11349e 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6152,3 +6152,12 @@ count-feature = .invalid-boundary = Невалиден обект за граница на областта за преброяване. Изберете затворена полилиния от отсечки, която не се самопресича. .block-not-found = Блокът "__ocs_fmt_0__" не може да бъде намерен. .invalid-point = Невалидна точка. + +dock = + .auto-hide = Автоматично скриване + .dock-left = Закачи вляво + .dock-right = Закачи вдясно + .new-group = Нова група + .pallets = Палитри + .browser = Браузър + .node-graph = Граф на възлите diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 7aec030d7..b77e80e31 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6149,3 +6149,12 @@ count-feature = .invalid-boundary = Neplatný objekt hranice oblasti počítání. Vyberte uzavřenou křivku složenou z úseček, která neprotíná sama sebe. .block-not-found = Blok "__ocs_fmt_0__" nelze najít. .invalid-point = Neplatný bod. + +dock = + .auto-hide = Automaticky skrýt + .dock-left = Ukotvit vlevo + .dock-right = Ukotvit vpravo + .new-group = Nová skupina + .pallets = Palety + .browser = Prohlížeč + .node-graph = Graf uzlů diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index d22c55b6c..59a0369b7 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6150,3 +6150,12 @@ count-feature = .invalid-boundary = Ungültiges Begrenzungsobjekt für den Zählbereich. Wählen Sie eine geschlossene Polylinie aus Liniensegmenten, die sich nicht selbst schneidet. .block-not-found = Block "__ocs_fmt_0__" wurde nicht gefunden. .invalid-point = Ungültiger Punkt. + +dock = + .auto-hide = Automatisch ausblenden + .dock-left = Links andocken + .dock-right = Rechts andocken + .new-group = Neue Gruppe + .pallets = Paletten + .browser = Browser + .node-graph = Knotengraph diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index a085923a0..eee2a7975 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6152,3 +6152,12 @@ count-feature = .invalid-boundary = Μη έγκυρο αντικείμενο ορίου περιοχής καταμέτρησης. Επιλέξτε μια κλειστή πολυγραμμή από ευθύγραμμα τμήματα που δεν τέμνει τον εαυτό της. .block-not-found = Το μπλοκ "__ocs_fmt_0__" δεν βρέθηκε. .invalid-point = Μη έγκυρο σημείο. + +dock = + .auto-hide = Αυτόματη απόκρυψη + .dock-left = Πρόσδεση αριστερά + .dock-right = Πρόσδεση δεξιά + .new-group = Νέα ομάδα + .pallets = Παλέτες + .browser = Περιηγητής + .node-graph = Γράφος κόμβων diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 2f8ced110..6ebc848e4 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6153,3 +6153,12 @@ count-feature = .invalid-boundary = Invalid count area boundary object. Select a closed polyline consisting of line segments and does not intersect itself. .block-not-found = Block "__ocs_fmt_0__" cannot be found. .invalid-point = Invalid point. + +dock = + .auto-hide = Auto-hide + .dock-left = Dock left + .dock-right = Dock right + .new-group = New group + .pallets = Pallets + .browser = Browser + .node-graph = Node Graph diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index a61b1c8b4..1f75e7eb7 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -6152,3 +6152,12 @@ count-feature = .invalid-boundary = Objeto de contorno de área de recuento no válido. Seleccione una polilínea cerrada formada por segmentos de línea que no se corte a sí misma. .block-not-found = No se encuentra el bloque "__ocs_fmt_0__". .invalid-point = Punto no válido. + +dock = + .auto-hide = Ocultar automáticamente + .dock-left = Acoplar a la izquierda + .dock-right = Acoplar a la derecha + .new-group = Nuevo grupo + .pallets = Paletas + .browser = Explorador + .node-graph = Grafo de nodos diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 82cf03e99..85f83d9f2 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6149,3 +6149,12 @@ count-feature = .invalid-boundary = Virheellinen laskenta-alueen rajaobjekti. Valitse suljettu, janoista koostuva moniviiva, joka ei leikkaa itseään. .block-not-found = Lohkoa "__ocs_fmt_0__" ei löydy. .invalid-point = Virheellinen piste. + +dock = + .auto-hide = Piilota automaattisesti + .dock-left = Telakoi vasemmalle + .dock-right = Telakoi oikealle + .new-group = Uusi ryhmä + .pallets = Paletit + .browser = Selain + .node-graph = Solmukaavio diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index afb43870f..7507461fd 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6151,3 +6151,12 @@ count-feature = .invalid-boundary = Objet de contour de zone de comptage non valide. Sélectionnez une polyligne fermée composée de segments de ligne et qui ne se recoupe pas. .block-not-found = Le bloc "__ocs_fmt_0__" est introuvable. .invalid-point = Point non valide. + +dock = + .auto-hide = Masquage automatique + .dock-left = Ancrer à gauche + .dock-right = Ancrer à droite + .new-group = Nouveau groupe + .pallets = Palettes + .browser = Navigateur + .node-graph = Graphe de nœuds diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 2c4c06cee..cd1686ba4 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6140,3 +6140,12 @@ count-feature = .invalid-boundary = अमान्य गणना क्षेत्र सीमा ऑब्जेक्ट। रेखा खंडों से बनी ऐसी बंद पॉलीलाइन चुनें जो स्वयं को प्रतिच्छेद न करे। .block-not-found = ब्लॉक "__ocs_fmt_0__" नहीं मिला। .invalid-point = अमान्य बिंदु। + +dock = + .auto-hide = स्वतः छिपाएँ + .dock-left = बाएँ डॉक करें + .dock-right = दाएँ डॉक करें + .new-group = नया समूह + .pallets = पैलेट + .browser = ब्राउज़र + .node-graph = नोड ग्राफ़ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 7393d69a1..a7ba41b25 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6151,3 +6151,12 @@ count-feature = .invalid-boundary = Érvénytelen számlálási terület határobjektum. Válasszon egy zárt, vonalszakaszokból álló, önmagát nem metsző vonalláncot. .block-not-found = A(z) "__ocs_fmt_0__" blokk nem található. .invalid-point = Érvénytelen pont. + +dock = + .auto-hide = Automatikus elrejtés + .dock-left = Dokkolás balra + .dock-right = Dokkolás jobbra + .new-group = Új csoport + .pallets = Paletták + .browser = Böngésző + .node-graph = Csomópontgráf diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 212e19a64..90cc6785e 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6151,3 +6151,12 @@ count-feature = .invalid-boundary = Oggetto di contorno dell'area di conteggio non valido. Selezionare una polilinea chiusa composta da segmenti di linea che non si interseca. .block-not-found = Impossibile trovare il blocco "__ocs_fmt_0__". .invalid-point = Punto non valido. + +dock = + .auto-hide = Nascondi automaticamente + .dock-left = Ancora a sinistra + .dock-right = Ancora a destra + .new-group = Nuovo gruppo + .pallets = Tavolozze + .browser = Browser + .node-graph = Grafo dei nodi diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index cf930fc9b..b8ac215be 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6140,3 +6140,12 @@ count-feature = .invalid-boundary = カウント領域の境界オブジェクトが無効です。線分で構成され、自己交差しない閉じたポリラインを選択してください。 .block-not-found = ブロック "__ocs_fmt_0__" が見つかりません。 .invalid-point = 点が無効です。 + +dock = + .auto-hide = 自動的に隠す + .dock-left = 左にドッキング + .dock-right = 右にドッキング + .new-group = 新しいグループ + .pallets = パレット + .browser = ブラウザー + .node-graph = ノードグラフ diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 1cbff7eaf..28ac8e053 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6151,3 +6151,12 @@ count-feature = .invalid-boundary = 개수 영역 경계 객체가 잘못되었습니다. 선분으로 구성되고 자체 교차하지 않는 닫힌 폴리선을 선택하십시오. .block-not-found = 블록 "__ocs_fmt_0__"을(를) 찾을 수 없습니다. .invalid-point = 점이 잘못되었습니다. + +dock = + .auto-hide = 자동 숨기기 + .dock-left = 왼쪽에 도킹 + .dock-right = 오른쪽에 도킹 + .new-group = 새 그룹 + .pallets = 팔레트 + .browser = 브라우저 + .node-graph = 노드 그래프 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 496c8f95d..91b99f044 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6150,3 +6150,12 @@ count-feature = .invalid-boundary = Ongeldig grensobject voor het telgebied. Selecteer een gesloten polylijn die uit lijnsegmenten bestaat en zichzelf niet snijdt. .block-not-found = Blok "__ocs_fmt_0__" kan niet worden gevonden. .invalid-point = Ongeldig punt. + +dock = + .auto-hide = Automatisch verbergen + .dock-left = Links koppelen + .dock-right = Rechts koppelen + .new-group = Nieuwe groep + .pallets = Paletten + .browser = Browser + .node-graph = Knooppuntgrafiek diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 380533d4a..4ba9b0a52 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6151,3 +6151,12 @@ count-feature = .invalid-boundary = Nieprawidłowy obiekt obwiedni obszaru liczenia. Wybierz zamkniętą polilinię złożoną z odcinków, która nie przecina samej siebie. .block-not-found = Nie można znaleźć bloku "__ocs_fmt_0__". .invalid-point = Nieprawidłowy punkt. + +dock = + .auto-hide = Autoukrywanie + .dock-left = Zadokuj po lewej + .dock-right = Zadokuj po prawej + .new-group = Nowa grupa + .pallets = Palety + .browser = Przeglądarka + .node-graph = Graf węzłów diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 4f32592b7..8c1274a9a 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6152,3 +6152,12 @@ count-feature = .invalid-boundary = Objeto de contorno da área de contagem inválido. Selecione uma polilinha fechada composta de segmentos de linha e que não se cruze. .block-not-found = O bloco "__ocs_fmt_0__" não foi encontrado. .invalid-point = Ponto inválido. + +dock = + .auto-hide = Ocultar automaticamente + .dock-left = Encaixar à esquerda + .dock-right = Encaixar à direita + .new-group = Novo grupo + .pallets = Paletas + .browser = Navegador + .node-graph = Grafo de nós diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 8d980fc7f..a3e79f208 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -6153,3 +6153,12 @@ count-feature = .invalid-boundary = Недопустимый объект границы области подсчёта. Выберите замкнутую полилинию из отрезков, которая не пересекает саму себя. .block-not-found = Блок "__ocs_fmt_0__" не найден. .invalid-point = Недопустимая точка. + +dock = + .auto-hide = Автоскрытие + .dock-left = Закрепить слева + .dock-right = Закрепить справа + .new-group = Новая группа + .pallets = Палитры + .browser = Обозреватель + .node-graph = Граф узлов diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index e7a2d568d..4683551d7 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6075,3 +6075,12 @@ count-feature = .invalid-boundary = Geçersiz sayım alanı sınır nesnesi. Doğru parçalarından oluşan, kendisiyle kesişmeyen kapalı bir çoklu çizgi seçin. .block-not-found = "__ocs_fmt_0__" bloğu bulunamadı. .invalid-point = Geçersiz nokta. + +dock = + .auto-hide = Otomatik gizle + .dock-left = Sola yerleştir + .dock-right = Sağa yerleştir + .new-group = Yeni grup + .pallets = Paletler + .browser = Tarayıcı + .node-graph = Düğüm Grafiği diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 6d36da043..ac07a0696 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6135,3 +6135,12 @@ count-feature = .invalid-boundary = 无效的计数区域边界对象。请选择由直线段组成且不自交的闭合多段线。 .block-not-found = 找不到块 "__ocs_fmt_0__"。 .invalid-point = 无效的点。 + +dock = + .auto-hide = 自动隐藏 + .dock-left = 停靠到左侧 + .dock-right = 停靠到右侧 + .new-group = 新建组 + .pallets = 选项板 + .browser = 浏览器 + .node-graph = 节点图 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index cd3e2a1f1..aedf9a45f 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6147,3 +6147,12 @@ count-feature = .invalid-boundary = 無效的計數區域邊界物件。請選取由線段組成且不自我相交的封閉聚合線。 .block-not-found = 找不到圖塊 "__ocs_fmt_0__"。 .invalid-point = 無效的點。 + +dock = + .auto-hide = 自動隱藏 + .dock-left = 停駐到左側 + .dock-right = 停駐到右側 + .new-group = 新增群組 + .pallets = 選項板 + .browser = 瀏覽器 + .node-graph = 節點圖 diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 6220927bb..30522c614 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -205,7 +205,7 @@ fn dock_drop_over_the_viewport_floats_the_panel() { ); assert!(matches!(target, Some(DropTarget::Float { .. }))); assert!(app.dock.right.is_empty()); - let f = app.dock.float_rect(PanelId::BlockPalette).expect("floating"); + let f = app.dock.float_rect(PanelId::BlockPalette).expect("floating").clone(); 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( @@ -275,14 +275,53 @@ fn dock_grip_drag_moves_a_whole_group() { app.dock.left[1].panels, vec![PanelId::Properties, PanelId::Browser] ); - // A group dropped over the viewport does not float or split up. - let before = app.dock.clone(); + // A group dropped over the viewport floats as one window. let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 1)); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, 300.0))); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0))); - assert_eq!(drag_target(&app), None); let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!(app.dock, before); + assert_eq!(app.dock.left.len(), 1); + let f = app.dock.float_rect(PanelId::Browser).expect("floats"); + assert_eq!(f.group.panels, vec![PanelId::Properties, PanelId::Browser]); + // Dragging the window's title bar onto the strip docks it whole again. + let _ = app.on_dock(DockMsg::FloatGrab(PanelId::Properties)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0))); + let _ = app.on_dock(DockMsg::DragRelease); + assert!(app.dock.floating.is_empty()); + assert_eq!( + app.dock.left[1].panels, + vec![PanelId::Properties, PanelId::Browser] + ); +} + +#[test] +fn floating_menu_adds_pallets_and_docks_the_window() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = dock_app(); + let _ = app.on_dock(DockMsg::FloatOut(PanelId::BlockPalette)); + let _ = app.on_dock(DockMsg::FloatMenu(Some(PanelId::BlockPalette))); + let _ = app.on_dock(DockMsg::FloatMenuPallets(true)); + // Picking a pallet adds it to the window, opens it and closes the menu. + let _ = app.on_dock(DockMsg::FloatMenuToggle(PanelId::BlockPalette, PanelId::Browser)); + assert!(app.show_browser); + assert_eq!(app.dock_float_menu, None); + let f = app.dock.float_rect(PanelId::BlockPalette).unwrap(); + assert_eq!(f.group.panels, vec![PanelId::BlockPalette, PanelId::Browser]); + // Picking a checked one hides it but keeps its place. + let _ = app.on_dock(DockMsg::FloatMenuToggle(PanelId::BlockPalette, PanelId::Browser)); + assert!(!app.show_browser); + assert!(app.dock.float_rect(PanelId::Browser).is_some()); + // Dock left takes the whole window to the left edge. + let _ = app.on_dock(DockMsg::DockTo(PanelId::BlockPalette, DockSide::Left)); + assert!(app.dock.floating.is_empty()); + let (side, gi) = app.dock.location(PanelId::Browser).unwrap(); + assert_eq!(side, DockSide::Left); + assert_eq!( + app.dock.groups(side)[gi].panels, + vec![PanelId::BlockPalette, PanelId::Browser] + ); } #[test] @@ -361,18 +400,12 @@ fn dock_hover_reveals_groups_only_on_an_auto_hiding_edge() { 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, - }); + app.dock.float(FloatPanel::new(PanelId::BlockPalette, 100.0, 100.0, 260.0, 300.0)); let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, false)); 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(); + let f = app.dock.float_rect(PanelId::BlockPalette).unwrap().clone(); assert_eq!((f.w, f.h), (300.0, 350.0)); // The bottom-left grip grows the panel leftward, keeping its right edge. let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, true)); diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs index 5f3a90618..3b43df3ec 100644 --- a/src/app/dock/update.rs +++ b/src/app/dock/update.rs @@ -98,22 +98,79 @@ impl OpenCADStudio { _ => strip + self.dock_column_width(DockSide::Left) + 40.0, }; self.dock_drag = None; - if self.dock.float(FloatPanel { - id, - x: x.max(0.0), - y: 40.0, - w, - h, - }) { + if self.dock.float(FloatPanel::new(id, x.max(0.0), 40.0, w, h)) { + self.save_config(); + } + } + DockMsg::GroupFloat(side, gi) => { + // Float beside the edge it was docked on, near the top. + let (ww, _) = self.dock_workspace_size(); + let w = self.dock_group_width(side, gi); + let strip = crate::ui::dock::DOCK_STRIP_W; + let x = match side { + DockSide::Right => ww - strip - self.dock_column_width(side) - w - 60.0, + DockSide::Left => strip + self.dock_column_width(side) + 40.0, + }; + self.dock_drag = None; + let h = self.dock_default_float_h(); + if self.dock.float_group(side, gi, x.max(0.0), 40.0, w, h) { self.save_config(); } } DockMsg::DockTo(id, side) => { self.dock_drag = None; - if self.dock.dock(id, side, usize::MAX) { + self.dock_float_menu = None; + if self.dock.dock_window(id, side, usize::MAX) { self.save_config(); } } + DockMsg::FloatGrab(id) => self.dock_begin(DockDrag::moving_window(id), Some(id)), + DockMsg::FloatClose(id) => { + let panels = self + .dock + .float_rect(id) + .map(|f| f.group.panels.clone()) + .unwrap_or_default(); + let mut tasks = Vec::new(); + for p in panels { + if self.dock_panel_visible(p) { + tasks.push(self.dock_set_open(p, false)); + } + } + return iced::Task::batch(tasks); + } + DockMsg::FloatSplitGrab(panel, upper, lower) => { + self.dock.raise_float(panel); + self.dock_begin(DockDrag::FloatSplit { panel, upper, lower }, None); + } + DockMsg::FloatSplitReset(id) => { + self.dock.reset_float_splits(id); + self.save_config(); + } + DockMsg::FloatMenu(id) => { + self.dock_float_menu = id; + self.dock_float_menu_pallets = false; + if let Some(id) = id { + self.dock.raise_float(id); + } + } + DockMsg::FloatMenuPallets(open) => self.dock_float_menu_pallets = open, + DockMsg::FloatMenuToggle(window, id) => { + // Checked = open in this window: unchecking hides it. + // Anything else (closed, docked, in another window) moves + // into the window and opens; adding ends the menu's job. + let here = self.dock.float_rect(window).is_some_and(|f| f.contains(id)); + if here && self.dock_panel_visible(id) { + let task = self.dock_set_open(id, false); + self.save_config(); + return task; + } + self.dock.add_to_float(id, window); + self.dock_float_menu = None; + let task = self.dock_set_open(id, true); + self.save_config(); + return task; + } DockMsg::EdgeMenu(side) => self.dock_edge_menu = side, DockMsg::EdgeMenuToggle(side, id) => { // Checked = open and docked on this edge: unchecking hides it. @@ -194,6 +251,31 @@ impl OpenCADStudio { target: Some(DropTarget::Edge { side: to, index }), .. }) => changed = self.dock.move_group(side, gi, to, index), + Some(DockDrag::Move { + group: Some((side, gi)), + target: Some(DropTarget::Float { x, y }), + .. + }) => { + let w = self.dock_group_width(side, gi); + let h = self.dock_default_float_h(); + changed = self.dock.float_group(side, gi, x, y, w, h); + } + Some(DockDrag::Move { + panel, + window: true, + target: Some(target), + .. + }) => { + changed = match target { + DropTarget::Edge { side, index } => { + self.dock.dock_window(panel, side, index) + } + DropTarget::Join { side, group, index } => { + self.dock.join_window(panel, side, group, index) + } + DropTarget::Float { x, y } => self.dock.move_float(panel, x, y), + }; + } Some(DockDrag::Move { panel, group: None, @@ -207,18 +289,17 @@ impl OpenCADStudio { } DropTarget::Float { x, y } => { let (w, h) = self.dock_float_size(panel); - self.dock.float(FloatPanel { - id: panel, - x, - y, - w, - h, - }) + self.dock.float(FloatPanel::new(panel, x, y, w, h)) } }; } Some(DockDrag::Move { .. } | DockDrag::LayerColumn) | None => {} - Some(DockDrag::Width(_) | DockDrag::Split { .. } | DockDrag::FloatSize { .. }) => { + Some( + DockDrag::Width(_) + | DockDrag::Split { .. } + | DockDrag::FloatSize { .. } + | DockDrag::FloatSplit { .. }, + ) => { changed = true; } } @@ -251,6 +332,7 @@ impl OpenCADStudio { DockDrag::Move { panel, group, + window, origin, target, grab, @@ -258,10 +340,12 @@ impl OpenCADStudio { let origin = origin.unwrap_or(point); let moved = (point.x - origin.x).hypot(point.y - origin.y); let started = target.is_some() || moved >= DOCK_DRAG_THRESHOLD; - // Hold a floating panel where it was grabbed; a docked one by - // its title bar. + // Hold a floating window where it was grabbed; a docked + // pallet (or one pulled out of a floating group) by its + // title bar. let grab = if target.is_none() && started { - match self.dock.float_rect(panel) { + let whole = |f: &&crate::ui::dock::FloatPanel| window || f.group.panels.len() == 1; + match self.dock.float_rect(panel).filter(whole) { Some(f) => iced::Vector::new(origin.x - f.x, origin.y - f.y), None => iced::Vector::new( (self.dock.width(panel, self.win_size.0) * 0.5).min(120.0), @@ -272,11 +356,11 @@ impl OpenCADStudio { grab }; let target = if started { - // A whole group only moves between group positions: it - // cannot join another group or float. + // A whole docked group moves between group positions or + // floats; it cannot join another group. match (group, self.dock_drop_target(point, grab)) { - (Some(_), found @ DropTarget::Edge { .. }) | (None, found) => Some(found), - (Some(_), _) => None, + (Some(_), DropTarget::Join { .. }) => None, + (_, found) => Some(found), } } else { target @@ -284,6 +368,7 @@ impl OpenCADStudio { self.dock_drag = Some(DockDrag::Move { panel, group, + window, origin: Some(origin), target, grab, @@ -322,12 +407,30 @@ impl OpenCADStudio { return; }; let dx = point.x - last.x; - self.dock.resize_float( - panel, - if from_left { f.w - dx } else { f.w + dx }, - f.h + point.y - last.y, - from_left, - ); + let (w, h) = (if from_left { f.w - dx } else { f.w + dx }, f.h + point.y - last.y); + self.dock.resize_float(panel, w, h, from_left); + } + DockDrag::FloatSplit { panel, upper, lower } => { + let (Some(last), Some(f)) = (last, self.dock.float_rect(panel)) else { + return; + }; + let total: f32 = self + .dock_float_visible(f) + .iter() + .filter_map(|p| f.group.panels.iter().position(|q| q == p)) + .filter_map(|i| f.group.weights.get(i)) + .sum(); + let avail = f.h; + if avail > 0.0 && total > 0.0 { + let per_px = total / avail; + self.dock.shift_float_split( + panel, + upper, + lower, + (point.y - last.y) * per_px, + crate::ui::dock::GROUP_MIN_H * per_px, + ); + } } DockDrag::LayerColumn => { let Some(last) = last else { return }; @@ -362,13 +465,12 @@ impl OpenCADStudio { self.dock_drag = Some(drag); self.dock_drag_last = None; self.dock_edge_menu = None; + self.dock_float_menu = None; if peek.is_some() { self.dock_peek = peek; } } - /// Bring `id` into view: show its group on its edge and, where it - /// auto-hides, keep it revealed until the pointer leaves. /// Arm a delayed reveal (`Some`) or hide (`None`); it applies when its /// timer reports back unless a newer hover superseded it. fn dock_hover_arm(&mut self, target: Option) -> iced::Task { @@ -386,6 +488,8 @@ impl OpenCADStudio { self.dock_hover_pending = None; } + /// Bring `id` into view: show its group on its edge and, where it + /// auto-hides, keep it revealed until the pointer leaves. pub(crate) fn dock_reveal(&mut self, id: crate::ui::dock::PanelId) { self.dock.show_group_of(id); self.dock_peek = Some(id); @@ -486,13 +590,8 @@ impl OpenCADStudio { if !self.dock.is_placed(PanelId::Layers) { let (ww, wh) = self.dock_workspace_size(); let (w, h) = self.dock_float_size(PanelId::Layers); - self.dock.float(FloatPanel { - id: PanelId::Layers, - x: ((ww - w) * 0.5).max(0.0), - y: ((wh - h) * 0.5).max(0.0), - w, - h, - }); + let (x, y) = (((ww - w) * 0.5).max(0.0), ((wh - h) * 0.5).max(0.0)); + self.dock.float(FloatPanel::new(PanelId::Layers, x, y, w, h)); self.save_config(); } self.dock.raise_float(PanelId::Layers); @@ -642,11 +741,37 @@ impl OpenCADStudio { if let Some(f) = self.dock.float_rect(id) { return (f.w, f.h); } + (self.dock.width(id, self.win_size.0), self.dock_default_float_h()) + } + + /// Height a pallet or group floats at first: a comfortable share of the + /// workspace height. + pub(crate) fn dock_default_float_h(&self) -> f32 { 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), - ) + (avail * 0.6).clamp(crate::ui::dock::FLOAT_MIN_H, 520.0) + } + + /// The pallets floating window `f` draws, top → bottom: its open ones, + /// less one being dragged out of it. + pub(crate) fn dock_float_visible( + &self, + f: &crate::ui::dock::FloatPanel, + ) -> Vec { + let pulled = match self.dock_drag { + Some(crate::ui::dock::DockDrag::Move { + panel, + window: false, + target: Some(_), + .. + }) => Some(panel), + _ => None, + }; + f.group + .panels + .iter() + .copied() + .filter(|p| self.dock_panel_visible(*p) && Some(*p) != pulled) + .collect() } /// Where a dragged pallet lands if released at workspace point `p`: diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index 769d062e9..994b55464 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -17,16 +17,19 @@ impl OpenCADStudio { workspace: Element<'a, Message>, tab: &'a DocumentTab, ) -> Element<'a, Message> { + use crate::ui::dock::DockMsg; // Floating panels sit over the workspace, back to front. let mut layers: Vec> = vec![workspace]; for f in &self.dock.floating { - // Once a drag is under way only its preview shows the panel. - let dragged = self - .dock_drag - .and_then(|d| d.movement()) - .is_some_and(|(panel, _, target)| panel == f.id && target.is_some()); - if self.dock_panel_visible(f.id) && !dragged { - layers.push(self.floating_panel(*f, tab)); + // Once a window drag is under way only its preview shows it. + let dragged = matches!( + self.dock_drag, + Some(crate::ui::dock::DockDrag::Move { panel, window: true, target: Some(_), .. }) + if f.contains(panel) + ); + let visible = self.dock_float_visible(f); + if !visible.is_empty() && !dragged { + layers.push(self.floating_panel(f, visible, tab)); } } // An open pallet menu hangs beside its edge's + button, over a @@ -51,13 +54,23 @@ impl OpenCADStudio { }; layers.push( mouse_area(Space::new().width(Fill).height(Fill)) - .on_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) - .on_right_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) + .on_press(Message::Dock(DockMsg::EdgeMenu(None))) + .on_right_press(Message::Dock(DockMsg::EdgeMenu(None))) .into(), ); - layers.push(place_at(dock_edge_menu(side, &checked), x.max(0.0), plus_top)); + let toggle = move |id| Message::Dock(DockMsg::EdgeMenuToggle(side, id)); + layers.push(place_at(dock_pallet_menu(&checked, toggle), x.max(0.0), plus_top)); } } + if let Some(menu) = self.dock_float_menu_overlay() { + layers.push( + mouse_area(Space::new().width(Fill).height(Fill)) + .on_press(Message::Dock(DockMsg::FloatMenu(None))) + .on_right_press(Message::Dock(DockMsg::FloatMenu(None))) + .into(), + ); + layers.push(menu); + } // While a panel is dragged, preview where it lands. if let Some((id, _, Some(target))) = self.dock_drag.and_then(|d| d.movement()) { layers.push(self.dock_drop_preview(id, target)); @@ -274,6 +287,7 @@ impl OpenCADStudio { }), ) .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) + .on_double_click(Message::Dock(DockMsg::GroupFloat(side, gi))) .on_enter(Message::Dock(DockMsg::GripHover(side, gi))) .on_exit(Message::Dock(DockMsg::GripHoverEnd(side, gi))) .interaction(iced::mouse::Interaction::Grab); @@ -422,27 +436,45 @@ impl OpenCADStudio { } } - /// A floating panel at its saved place: a vertical title bar on the side - /// facing the nearer workspace edge, the panel beside it, and a corner - /// grip to resize it. With its pin on, the panel hides down to the title - /// bar until hovered. - fn floating_panel<'a>( - &'a self, - f: crate::ui::dock::FloatPanel, - tab: &'a DocumentTab, - ) -> Element<'a, Message> { - use crate::ui::dock::DockMsg; + /// Where floating window `f` draws: its top-left corner, whether its + /// title bar is on the left, and whether it is hidden down to that bar + /// (auto-hide, not hovered). + fn dock_float_frame(&self, f: &crate::ui::dock::FloatPanel) -> (f32, f32, bool, bool) { let (ww, wh) = self.dock_workspace_size(); // The bar faces the nearer workspace edge; while resizing, it stays // opposite the grip being dragged so it cannot flip mid-drag. let bar_left = match self.dock_drag { - Some(crate::ui::dock::DockDrag::FloatSize { panel, from_left }) if panel == f.id => { + Some(crate::ui::dock::DockDrag::FloatSize { panel, from_left }) if f.contains(panel) => { !from_left } _ => f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5, }; - let hidden = self.dock.auto_collapse(f.id) && self.dock_peek != Some(f.id); - let bar = floating_title_bar(f.id, self.dock.auto_collapse(f.id), bar_left); + let hidden = self.dock.auto_hides(f.id()) && !self.dock_peek.is_some_and(|p| f.contains(p)); + let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; + // A hidden window keeps its title bar where it sits when shown. + let x = if hidden && !bar_left { f.x + f.w } else { f.x }; + // Keep the window on screen when the workspace shrank since it was + // placed. + let x = x.min(ww - total_w).max(0.0); + let y = f.y.min(wh - f.h).max(0.0); + (x, y, bar_left, hidden) + } + + /// A floating window at its saved place: a vertical title bar on the + /// side facing the nearer workspace edge, its open pallets (`visible`) + /// stacked beside it, and a corner grip to resize it. With its pin on, + /// the window hides down to the title bar until hovered. + fn floating_panel<'a>( + &'a self, + f: &'a crate::ui::dock::FloatPanel, + visible: Vec, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + use crate::ui::dock::DockMsg; + let (x, y, bar_left, hidden) = self.dock_float_frame(f); + // Messages name the window by its first open pallet. + let anchor = visible[0]; + let bar = floating_title_bar(&visible, self.dock.auto_hides(anchor), bar_left); let content: Element<'_, Message> = if hidden { bar } else { @@ -461,14 +493,30 @@ impl OpenCADStudio { .center_x(Length::Fixed(14.0)) .center_y(Length::Fixed(14.0)), ) - .on_press(Message::Dock(DockMsg::FloatResizeGrab(f.id, grip_left))) + .on_press(Message::Dock(DockMsg::FloatResizeGrab(anchor, grip_left))) .interaction(if grip_left { iced::mouse::Interaction::ResizingDiagonallyUp } else { iced::mouse::Interaction::ResizingDiagonallyDown }); + // The pallets stack like a docked group, with splitters between. + let mut pallets: Vec> = Vec::new(); + let mut prev: Option = None; + for id in &visible { + let pos = f.group.panels.iter().position(|p| p == id).unwrap_or(0); + if let Some(upper) = prev { + pallets.push(float_splitter(anchor, upper, pos, f.w)); + } + let weight = f.group.weights.get(pos).copied().unwrap_or(1.0); + pallets.push( + container(self.panel_body(*id, f.w, true, tab)) + .height(Length::FillPortion(crate::ui::dock::portion(weight))) + .into(), + ); + prev = Some(pos); + } let body = stack![ - self.panel_body(f.id, f.w, true, tab), + column(pallets).height(Fill), container(grip) .width(Fill) .height(Fill) @@ -487,7 +535,6 @@ impl OpenCADStudio { row![body, bar].into() } }; - let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; let framed = container(content) .height(Length::Fixed(f.h)) .style(|theme: &Theme| container::Style { @@ -503,21 +550,106 @@ impl OpenCADStudio { }, ..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. + // The idle cursor marks the window 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))) - .on_enter(Message::Dock(DockMsg::Hover(f.id))) + .on_press(Message::Dock(DockMsg::FloatRaise(anchor))) + .on_enter(Message::Dock(DockMsg::Hover(anchor))) .on_exit(Message::Dock(DockMsg::HoverExit)) .interaction(iced::mouse::Interaction::Idle); - // A hidden panel keeps its title bar where it sits when shown. - let x = if hidden && !bar_left { f.x + f.w } else { f.x }; - // Keep the panel on screen when the window shrank since it was placed. - let x = x.min(ww - total_w).max(0.0); - let y = f.y.min(wh - f.h).max(0.0); place_at(panel.into(), x, y) } + /// The open right-click menu of a floating window: Pallets (a submenu + /// to add or hide pallets in the window), Dock left and Dock right. It + /// hangs beside the title bar, toward the window's inside. + fn dock_float_menu_overlay(&self) -> Option> { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let anchor = self.dock_float_menu?; + let f = self.dock.float_rect(anchor)?; + if self.dock_float_visible(f).is_empty() { + return None; + } + let (ww, _) = self.dock_workspace_size(); + let (x, y, bar_left, hidden) = self.dock_float_frame(f); + let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; + let bar_x = if bar_left { x } else { x + total_w - DOCK_FLOAT_BAR_W }; + let max_x = (ww - DOCK_MENU_W).max(0.0); + let menu_x = if bar_left { + bar_x + DOCK_FLOAT_BAR_W + 2.0 + } else { + bar_x - DOCK_MENU_W - 2.0 + } + .clamp(0.0, max_x); + let open = self.dock_float_menu_pallets; + // Hovering a row opens (Pallets) or closes (the others) the submenu. + let item = |label: Element<'static, Message>, press: DockMsg, sub: bool| -> Element<'static, Message> { + mouse_area( + button(label) + .on_press(Message::Dock(press)) + .style(menu_row_style) + .padding([5, 8]) + .width(Fill), + ) + .on_enter(Message::Dock(DockMsg::FloatMenuPallets(sub))) + .into() + }; + let pallets = row![ + text(t!("Pallets")).size(12).width(Fill), + text("\u{203A}").size(13), + ] + .align_y(iced::Center); + let separator = container(Space::new()) + .width(Fill) + .height(Length::Fixed(1.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }); + let main = column![ + item(pallets.into(), DockMsg::FloatMenuPallets(!open), true), + container(separator).padding([3, 4]), + item( + text(t!("Dock left")).size(12).into(), + DockMsg::DockTo(anchor, DockSide::Left), + false + ), + item( + text(t!("Dock right")).size(12).into(), + DockMsg::DockTo(anchor, DockSide::Right), + false + ), + ] + .spacing(1) + .padding(4); + let mut layers = vec![place_at(menu_frame(main.into()), menu_x, y + 4.0)]; + if open { + let checked: Vec<_> = PanelId::ALL + .iter() + .map(|p| (*p, f.contains(*p) && self.dock_panel_visible(*p))) + .collect(); + let toggle = move |id| Message::Dock(DockMsg::FloatMenuToggle(anchor, id)); + // Beside the menu, on the side it opened toward, unless there is + // no room there. + let right = menu_x + 2.0 * DOCK_MENU_W + 2.0 <= ww; + let left = menu_x - DOCK_MENU_W - 2.0 >= 0.0; + let sub_x = if (bar_left && right) || !left { + menu_x + DOCK_MENU_W + 2.0 + } else { + menu_x - DOCK_MENU_W - 2.0 + } + .clamp(0.0, max_x); + layers.push(place_at(dock_pallet_menu(&checked, toggle), sub_x, y + 4.0)); + } + Some( + iced::widget::Stack::with_children(layers) + .width(Fill) + .height(Fill) + .into(), + ) + } + /// The live drop preview for dragged panel `id`: a tint over the target /// edge plus a panel-sized ghost where it lands — stacked into a group /// (sized as that group will be), as a new group of its own (full @@ -552,18 +684,32 @@ impl OpenCADStudio { }; let mut layers: Vec> = Vec::new(); let group_drag = self.dock_drag.and_then(|d| d.movement()).and_then(|(_, g, _)| g); + // The pallets that move: a docked group's, a floating window's, or + // just `id`. + let window_drag = matches!( + self.dock_drag, + Some(crate::ui::dock::DockDrag::Move { window: true, .. }) + ); + let moving = match (group_drag, self.dock.float_rect(id)) { + (Some((s, g)), _) => self.dock_group_visible(s, g), + (None, Some(f)) if window_drag => self.dock_float_visible(f), + _ => vec![id], + }; let marks_side = match target { DropTarget::Edge { side, .. } | DropTarget::Join { side, .. } => Some(side), DropTarget::Float { x, y } => { - let (w, h) = self.dock_float_size(id); + let (w, h) = match group_drag { + Some((s, g)) => (self.dock_group_width(s, g), self.dock_default_float_h()), + None => self.dock_float_size(id), + }; layers.push(place_at(dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), x, y)); None } }; - // A single pallet also gets a ghost where it lands in the column; a - // whole group shows only the strip feedback, as the column keeps - // showing what is there. - if let (Some(side), None) = (marks_side, group_drag) { + // A single pallet also gets a ghost where it lands in the column; + // several show only the strip feedback, as the column keeps showing + // what is there. + if let (Some(side), None, [_]) = (marks_side, group_drag, moving.as_slice()) { if let Some((top, bottom, w)) = self.dock_landing(id, target) { let x = match side { DockSide::Left => strip, @@ -578,11 +724,7 @@ impl OpenCADStudio { if let Some(side) = marks_side { layers.extend(self.dock_strip_drop_marks(side, target)); if let Some(p) = self.dock_drag_last { - let icons = match group_drag { - Some((s, g)) => self.dock_group_visible(s, g), - None => vec![id], - }; - layers.push(place_at(dock_icon_ghost(&icons), p.x - 16.0, p.y - 15.0)); + layers.push(place_at(dock_icon_ghost(&moving), p.x - 16.0, p.y - 15.0)); } } iced::widget::Stack::with_children(layers) @@ -803,46 +945,64 @@ impl canvas::Program for VBarLabel { /// Width of a floating panel's vertical title bar. const DOCK_FLOAT_BAR_W: f32 = 28.0; -/// A floating panel's vertical title bar: close then the hide (auto-collapse) -/// pin at the top, the panel's icon at the bottom with its title reading -/// upward above it. Pressing the bar's free area drags the panel. +/// A floating window's vertical title bar: close then the hide (auto-hide) +/// pin at the top; at the bottom the pallet's icon with its title reading +/// upward, or for a group each pallet's icon (press one to drag that pallet +/// out). Pressing the bar drags the window, double-clicking docks it against +/// the edge the bar faces, right-clicking opens its menu. fn floating_title_bar( - id: crate::ui::dock::PanelId, + visible: &[crate::ui::dock::PanelId], auto_collapse: bool, on_left: bool, ) -> Element<'static, Message> { use crate::ui::dock::DockMsg; + let anchor = visible[0]; let tip = if on_left { iced::widget::tooltip::Position::Left } else { iced::widget::tooltip::Position::Right }; - let close = crate::ui::dock::close_button(id, tip); - let pin = crate::ui::dock::pin_button(id, auto_collapse, tip); - let title = canvas(VBarLabel { - text: t!(id.title()).into_owned(), - }) - .width(Fill) - .height(Fill); - let bar = column![ - close, - pin, - container(title).width(Fill).height(Fill).padding([6, 0]), - container(crate::ui::icons::themed(id.icon(), 16.0)).center_x(Fill), - ] - .spacing(2) - .padding([4, 0]) - .align_x(iced::Center) - .width(Length::Fixed(DOCK_FLOAT_BAR_W)) - .height(Fill); + let close = crate::ui::dock::close_button(DockMsg::FloatClose(anchor), tip); + let pin = crate::ui::dock::pin_button(anchor, auto_collapse, tip); + let mut bar = column![close, pin].spacing(2); + if let [id] = visible { + let title = canvas(VBarLabel { + text: t!(id.title()).into_owned(), + }) + .width(Fill) + .height(Fill); + bar = bar + .push(container(title).width(Fill).height(Fill).padding([6, 0])) + .push(container(crate::ui::icons::themed(id.icon(), 16.0)).center_x(Fill)); + } else { + bar = bar.push(Space::new().height(Fill)); + for id in visible { + let icon = mouse_area( + container(crate::ui::icons::themed(id.icon(), 16.0)) + .center_x(Fill) + .center_y(Length::Fixed(24.0)), + ) + .on_press(Message::Dock(DockMsg::IconPress(*id))) + .interaction(iced::mouse::Interaction::Grab); + bar = bar.push( + iced::widget::tooltip(icon, text(t!(id.title())).size(10), tip).gap(4), + ); + } + } + let bar = bar + .padding([4, 0]) + .align_x(iced::Center) + .width(Length::Fixed(DOCK_FLOAT_BAR_W)) + .height(Fill); mouse_area(container(bar).style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.weak.color)), ..Default::default() })) - .on_press(Message::Dock(DockMsg::DockGrab(id))) + .on_press(Message::Dock(DockMsg::FloatGrab(anchor))) + .on_right_press(Message::Dock(DockMsg::FloatMenu(Some(anchor)))) // Double-click docks it against the edge its title bar faces. .on_double_click(Message::Dock(DockMsg::DockTo( - id, + anchor, if on_left { crate::app::config::DockSide::Left } else { @@ -853,6 +1013,29 @@ fn floating_title_bar( .into() } +/// Draggable bar between floating pallets `upper` and `lower` of the window +/// holding `anchor`; double-click evens their heights. +fn float_splitter( + anchor: crate::ui::dock::PanelId, + upper: usize, + lower: usize, + w: f32, +) -> Element<'static, Message> { + use crate::ui::dock::DockMsg; + let line = container(Space::new()) + .width(Length::Fixed(w)) + .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(DockMsg::FloatSplitGrab(anchor, upper, lower))) + .on_double_click(Message::Dock(DockMsg::FloatSplitReset(anchor))) + .interaction(iced::mouse::Interaction::ResizingVertically) + .into() +} + /// Grabbable separator between a docked pallet and the viewport: drag to /// size its group, double-click to reset the width. fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { @@ -876,14 +1059,14 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { /// Width of the icon strip's pallet menu. const DOCK_MENU_W: f32 = 230.0; -/// The pallet menu of an edge's + button, styled like the right-click menu: -/// one row per pallet with its icon, its name and a check mark when it is -/// open on this edge. `checked` holds each pallet with that state. -fn dock_edge_menu( - side: crate::app::config::DockSide, +/// A pallet menu (an edge's + button, a floating window's Pallets +/// submenu), styled like the right-click menu: one row per pallet with its +/// icon, its name and a check mark when it is open there. `checked` holds +/// each pallet with that state; picking one sends `toggle`. +fn dock_pallet_menu( checked: &[(crate::ui::dock::PanelId, bool)], + toggle: impl Fn(crate::ui::dock::PanelId) -> Message, ) -> Element<'static, Message> { - use crate::ui::dock::DockMsg; let rows: Vec> = checked .iter() .map(|(id, checked)| { @@ -902,26 +1085,34 @@ fn dock_edge_menu( .spacing(8) .align_y(iced::Center), ) - .on_press(Message::Dock(DockMsg::EdgeMenuToggle(side, id))) - .style(|theme: &Theme, status| { - let palette = theme.palette(); - button::Style { - background: matches!(status, button::Status::Hovered | button::Status::Pressed) - .then(|| Background::Color(palette.background.weak.color)), - text_color: palette.background.base.text, - border: Border { - radius: 3.0.into(), - ..Default::default() - }, - ..Default::default() - } - }) + .on_press(toggle(id)) + .style(menu_row_style) .padding([5, 8]) .width(Fill) .into() }) .collect(); - container(column(rows).spacing(1).padding(4)) + menu_frame(column(rows).spacing(1).padding(4).into()) +} + +/// A menu row button: transparent until hovered. +fn menu_row_style(theme: &Theme, status: button::Status) -> button::Style { + let palette = theme.palette(); + button::Style { + background: matches!(status, button::Status::Hovered | button::Status::Pressed) + .then(|| Background::Color(palette.background.weak.color)), + text_color: palette.background.base.text, + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + } +} + +/// The raised box a dock menu sits in. +fn menu_frame(rows: Element<'static, Message>) -> Element<'static, Message> { + container(rows) .width(Length::Fixed(DOCK_MENU_W)) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.base.color)), diff --git a/src/app/mod.rs b/src/app/mod.rs index 53bb69fb7..0887f0718 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -865,6 +865,10 @@ pub(super) struct OpenCADStudio { pub(crate) dock_title_hover: Option, /// Edge whose icon-strip pallet menu (+) is open. pub(crate) dock_edge_menu: Option, + /// Floating window (named by one of its pallets) whose right-click menu + /// is open, and whether its Pallets submenu shows. + pub(crate) dock_float_menu: Option, + pub(crate) dock_float_menu_pallets: bool, /// Group whose edge band (group grip) the pointer is over. pub(crate) dock_grip_hover: Option<(crate::app::config::DockSide, usize)>, /// Reference-table column currently being width-resized (column index), @@ -4297,6 +4301,8 @@ impl OpenCADStudio { dock_peek: None, dock_hover_pending: None, dock_hover_gen: 0, + dock_float_menu: None, + dock_float_menu_pallets: false, dock_drag: None, dock_drag_last: None, dock_title_hover: None, diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 8a8759359..730c2fe0c 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -5754,6 +5754,13 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Invalid count area boundary object. Select a closed polyline consisting of line segments and does not intersect itself." => Some(("count-feature", "invalid-boundary")), "Block \"{part}\" cannot be found." => Some(("count-feature", "block-not-found")), "Invalid point." => Some(("count-feature", "invalid-point")), + "Auto-hide" => Some(("dock", "auto-hide")), + "Dock left" => Some(("dock", "dock-left")), + "Dock right" => Some(("dock", "dock-right")), + "New group" => Some(("dock", "new-group")), + "Pallets" => Some(("dock", "pallets")), + "Browser" => Some(("dock", "browser")), + "Node Graph" => Some(("dock", "node-graph")), _ => None, } } diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 4697811bc..d1fb5dd67 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -65,8 +65,29 @@ pub enum DockMsg { GripHoverEnd(DockSide, usize), /// Double-click on a docked title bar: float the panel. FloatOut(PanelId), - /// Double-click on a floating title bar: dock the panel on that side. + /// Double-click on a group's edge band: float the whole group. + GroupFloat(DockSide, usize), + /// Double-click on a floating title bar (or its menu's Dock left / + /// right): dock the window holding the panel on that side. DockTo(PanelId, DockSide), + /// Begin dragging the floating window holding `panel` by its title bar. + FloatGrab(PanelId), + /// Close every pallet of the floating window holding `panel`. + FloatClose(PanelId), + /// Begin dragging the splitter between pallets `upper` and `lower` of + /// the floating window holding `panel`. + FloatSplitGrab(PanelId, usize, usize), + /// Give every pallet of the floating window holding `panel` the same + /// height again. + FloatSplitReset(PanelId), + /// Open (`Some`) the right-click menu of the floating window holding the + /// pallet, or close it (`None`). + FloatMenu(Option), + /// Show (`true`) or hide the floating menu's Pallets submenu. + FloatMenuPallets(bool), + /// Pallets submenu pick (window, pallet): hide the pallet when it shows + /// in the window, else add it to the window and open it. + FloatMenuToggle(PanelId, PanelId), /// Bring floating `panel` to the front. FloatRaise(PanelId), /// The pointer left the edge column; collapse any auto-collapsing panel. @@ -308,24 +329,93 @@ where Ok(groups) } -/// A panel floating over the workspace, in workspace-local pixels. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +/// A floating window over the workspace, in workspace-local pixels: one +/// pallet, or a group of them stacked like a docked group. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(from = "FloatRepr")] pub struct FloatPanel { - pub id: PanelId, + /// Pallets top → bottom with their heights (`width` is unused: the + /// window has its own). + #[serde(flatten)] + pub group: DockGroup, pub x: f32, pub y: f32, pub w: f32, pub h: f32, } +/// On-disk form of a floating window. Configs from before floating groups +/// stored one pallet as `id`. +#[derive(Deserialize)] +struct FloatRepr { + #[serde(default)] + id: Option, + #[serde(default)] + panels: Vec, + #[serde(default)] + weights: Vec, + x: f32, + y: f32, + w: f32, + h: f32, +} + +impl From for FloatPanel { + fn from(r: FloatRepr) -> Self { + let mut group = DockGroup::stack(r.id.into_iter().chain(r.panels).collect()); + if !r.weights.is_empty() { + group.weights = r.weights; + } + group.heal(); + Self { + group, + x: r.x, + y: r.y, + w: r.w, + h: r.h, + } + } +} + impl FloatPanel { + /// Pallet `id` floating on its own. + pub fn new(id: PanelId, x: f32, y: f32, w: f32, h: f32) -> Self { + Self { + group: DockGroup::single(id), + x, + y, + w, + h, + } + } + + /// The window's first pallet, which names it in messages. + pub fn id(&self) -> PanelId { + self.group.panels[0] + } + + pub fn contains(&self, id: PanelId) -> bool { + self.group.panels.contains(&id) + } + + /// The widest the window may be: the most any of its pallets allows. + fn max_width(&self) -> f32 { + self.group + .panels + .iter() + .map(|id| id.max_width()) + .fold(DOCK_MIN_W, f32::max) + } + /// Keep a placement read from disk usable: finite, on the workspace's - /// positive side, and at least the minimum size. + /// positive side, and at least the minimum size. The window holds at + /// least one pallet. fn heal(&mut self) { + self.group.heal(); let finite_or = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; self.x = finite_or(self.x, 0.0).max(0.0); self.y = finite_or(self.y, 0.0).max(0.0); - self.w = finite_or(self.w, self.id.default_width()).clamp(DOCK_MIN_W, self.id.max_width()); + self.w = finite_or(self.w, self.id().default_width()).clamp(DOCK_MIN_W, self.max_width()); self.h = finite_or(self.h, FLOAT_MIN_H).max(FLOAT_MIN_H); } } @@ -355,7 +445,7 @@ pub struct DockState { /// Auto-hide per edge (left, right): the edge shows only its icon strip /// until an icon is hovered. pub auto_hide: (bool, bool), - /// Floating panels, back → front. + /// Floating windows, back → front. pub floating: Vec, /// Per-panel settings, keyed by `PanelId`: the width a pallet floats at /// (and starts a new group with) and its floating auto-hide. @@ -445,7 +535,18 @@ impl DockState { let shown = self.shown(side).min(len.saturating_sub(1)); self.show_group(side, shown); } - self.floating.retain(|f| seen.insert(f.id)); + for f in &mut self.floating { + f.group.heal(); + (f.group.panels, f.group.weights) = f + .group + .panels + .iter() + .zip(&f.group.weights) + .filter(|(id, _)| seen.insert(**id)) + .map(|(id, w)| (*id, *w)) + .unzip(); + } + self.floating.retain(|f| !f.group.panels.is_empty()); self.floating.iter_mut().for_each(FloatPanel::heal); } @@ -459,9 +560,13 @@ impl DockState { 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() + /// The floating window holding `id`, if it floats. + pub fn float_rect(&self, id: PanelId) -> Option<&FloatPanel> { + self.floating.iter().find(|f| f.contains(id)) + } + + fn float_index(&self, id: PanelId) -> Option { + self.floating.iter().position(|f| f.contains(id)) } /// Whether `id` has a place in the layout (docked or floating). A panel @@ -483,7 +588,7 @@ impl DockState { } /// The floating auto-hide flag of `id` (a docked pallet follows its - /// edge instead; see [`Self::auto_hides`]). + /// edge, a floating one its window; see [`Self::auto_hides`]). pub fn auto_collapse(&self, id: PanelId) -> bool { self.settings(id).auto_collapse } @@ -497,22 +602,31 @@ impl DockState { } /// Whether `id` auto-hides where it is: by its edge's setting when - /// docked, by its own when floating. + /// docked, by its window's (its first pallet's flag) when floating. pub fn auto_hides(&self, id: PanelId) -> bool { - match self.location(id) { - Some((side, _)) => self.edge_auto_hide(side), - None => self.auto_collapse(id), + match (self.location(id), self.float_rect(id)) { + (Some((side, _)), _) => self.edge_auto_hide(side), + (None, Some(f)) => self.auto_collapse(f.id()), + (None, None) => self.auto_collapse(id), } } - /// Flip auto-hide where `id` is: its whole edge when docked, the pallet - /// itself when floating. Returns the new state. + /// Flip auto-hide where `id` is: its whole edge when docked, its whole + /// window when floating. Returns the new state. pub fn toggle_auto_hide(&mut self, id: PanelId) -> bool { let on = !self.auto_hides(id); match self.location(id) { Some((DockSide::Left, _)) => self.auto_hide.0 = on, Some((DockSide::Right, _)) => self.auto_hide.1 = on, - None => self.set_auto_collapse(id, on), + None => { + let panels = match self.float_rect(id) { + Some(f) => f.group.panels.clone(), + None => vec![id], + }; + for p in panels { + self.set_auto_collapse(p, on); + } + } } on } @@ -594,7 +708,13 @@ impl DockState { /// and weight there, and whether the group disappeared (it held nothing /// else). fn detach(&mut self, id: PanelId) -> Option<(DockSide, usize, usize, f32, bool)> { - self.floating.retain(|f| f.id != id); + if let Some(i) = self.float_index(id) { + self.floating[i].group.remove(id); + if self.floating[i].group.panels.is_empty() { + self.floating.remove(i); + } + return None; + } let (side, gi) = self.location(id)?; let (pos, weight) = self.groups_mut(side)[gi].remove(id).expect("located"); if !self.groups(side)[gi].panels.is_empty() { @@ -692,27 +812,111 @@ impl DockState { true } - /// Float `id` at `rect`, on top of the other floating panels. + /// Float `rect`'s pallets in it, on top of the other floating windows. pub fn float(&mut self, rect: FloatPanel) -> bool { let before = self.clone(); - self.detach(rect.id); + for id in &rect.group.panels { + self.detach(*id); + } self.floating.push(rect); *self != before } - /// Bring a floating panel to the front. + /// Float the whole group `group` of `side` as one window at `x`, `y` + /// sized `w` × `h`. Returns whether the layout changed. + pub fn float_group(&mut self, side: DockSide, group: usize, x: f32, y: f32, w: f32, h: f32) -> bool { + if group >= self.groups(side).len() { + return false; + } + let g = self.groups_mut(side).remove(group); + self.after_group_removed(side, group); + let mut f = FloatPanel { group: g, x, y, w, h }; + f.heal(); + self.floating.push(f); + true + } + + /// Move the floating window holding `id` to `x`, `y`. + pub fn move_float(&mut self, id: PanelId, x: f32, y: f32) -> bool { + let Some(i) = self.float_index(id) else { + return false; + }; + let f = &mut self.floating[i]; + let changed = (f.x, f.y) != (x, y); + (f.x, f.y) = (x, y); + changed + } + + /// Dock the floating window holding `id` as a new group on `side` at + /// insertion `index`, keeping its pallets, their heights and its width, + /// and show it. A docked `id` docks alone (see [`Self::dock`]). + pub fn dock_window(&mut self, id: PanelId, side: DockSide, index: usize) -> bool { + let Some(i) = self.float_index(id) else { + return self.dock(id, side, index); + }; + let f = self.floating.remove(i); + let mut g = f.group; + g.width = f.w; + let groups = self.groups_mut(side); + let index = index.min(groups.len()); + groups.insert(index, g); + self.show_group(side, index); + true + } + + /// Stack every pallet of the floating window holding `id` into group + /// `group` on `side` from position `index` on, and show the group. A + /// docked `id` joins alone (see [`Self::join_group`]). + pub fn join_window(&mut self, id: PanelId, side: DockSide, group: usize, index: usize) -> bool { + let Some(i) = self.float_index(id) else { + return self.join_group(id, side, group, index); + }; + if group >= self.groups(side).len() { + return false; + } + let f = self.floating.remove(i); + let target = &mut self.groups_mut(side)[group]; + let weight = target.mean_weight(); + for (k, p) in f.group.panels.into_iter().enumerate() { + target.insert(index.saturating_add(k), p, weight); + } + self.show_group(side, group); + true + } + + /// Add pallet `id` to the bottom of the floating window holding `into`. + /// Returns whether the layout changed. + pub fn add_to_float(&mut self, id: PanelId, into: PanelId) -> bool { + match self.float_rect(into) { + Some(f) if !f.contains(id) => {} + _ => return false, + } + self.detach(id); + // Detaching may have emptied (and removed) another window. + let Some(i) = self.float_index(into) else { + return false; + }; + let g = &mut self.floating[i].group; + let weight = g.mean_weight(); + g.insert(usize::MAX, id, weight); + true + } + + /// Bring the floating window holding `id` to the front. pub fn raise_float(&mut self, id: PanelId) { - if let Some(i) = self.floating.iter().position(|f| f.id == id) { + if let Some(i) = self.float_index(id) { let f = self.floating.remove(i); self.floating.push(f); } } - /// Resize a floating panel, keeping it at least a usable size. With - /// `keep_right` the right edge stays put (resizing from the left corner). + /// Resize the floating window holding `id`, keeping it at least a usable + /// size. With `keep_right` the right edge stays put (resizing from the + /// left corner). pub fn resize_float(&mut self, id: PanelId, w: f32, h: f32, keep_right: bool) { - if let Some(f) = self.floating.iter_mut().find(|f| f.id == id) { - let new_w = w.clamp(DOCK_MIN_W, id.max_width()); + if let Some(i) = self.float_index(id) { + let f = &mut self.floating[i]; + let new_w = w.clamp(DOCK_MIN_W, f.max_width()); if keep_right { f.x += f.w - new_w; } @@ -733,18 +937,16 @@ impl DockState { delta: f32, min_weight: f32, ) { - let Some(g) = self.groups_mut(side).get_mut(group) else { - return; - }; - let w = &mut g.weights; - if upper >= w.len() || lower >= w.len() || upper == lower { - return; + if let Some(g) = self.groups_mut(side).get_mut(group) { + shift_weights(&mut g.weights, upper, lower, delta, min_weight); + } + } + + /// [`Self::shift_split`] for the floating window holding `id`. + pub fn shift_float_split(&mut self, id: PanelId, upper: usize, lower: usize, delta: f32, min_weight: f32) { + if let Some(i) = self.float_index(id) { + shift_weights(&mut self.floating[i].group.weights, upper, lower, delta, min_weight); } - let total = w[upper] + w[lower]; - let min = min_weight.clamp(MIN_WEIGHT, total * 0.5); - let up = (w[upper] + delta).clamp(min, total - min); - w[upper] = up; - w[lower] = total - up; } /// Give every pallet of group `group` on `side` the same height again. @@ -753,6 +955,26 @@ impl DockState { g.weights.iter_mut().for_each(|w| *w = 1.0); } } + + /// [`Self::reset_splits`] for the floating window holding `id`. + pub fn reset_float_splits(&mut self, id: PanelId) { + if let Some(i) = self.float_index(id) { + self.floating[i].group.weights.iter_mut().for_each(|w| *w = 1.0); + } + } +} + +/// Move the boundary between stacked pallets `upper` and `lower` by `delta` +/// weight units, keeping their combined share and each above `min_weight`. +fn shift_weights(w: &mut [f32], upper: usize, lower: usize, delta: f32, min_weight: f32) { + if upper >= w.len() || lower >= w.len() || upper == lower { + return; + } + let total = w[upper] + w[lower]; + let min = min_weight.clamp(MIN_WEIGHT, total * 0.5); + let up = (w[upper] + delta).clamp(min, total - min); + w[upper] = up; + w[lower] = total - up; } /// What the pointer is dragging in the dock, if anything. @@ -766,6 +988,8 @@ pub enum DockDrag { Move { panel: PanelId, group: Option<(DockSide, usize)>, + /// The whole floating window holding `panel` moves. + window: bool, origin: Option, target: Option, grab: iced::Vector, @@ -782,6 +1006,9 @@ pub enum DockDrag { /// Sizing floating `panel` from its bottom-left (`from_left`) or /// bottom-right corner. FloatSize { panel: PanelId, from_left: bool }, + /// Moving the splitter between pallets `upper` and `lower` of the + /// floating window holding `panel`. + FloatSplit { panel: PanelId, upper: usize, lower: usize }, /// Sizing the Layer Manager's Name column. LayerColumn, } @@ -792,6 +1019,19 @@ impl DockDrag { DockDrag::Move { panel, group, + window: false, + origin: None, + target: None, + grab: iced::Vector::new(0.0, 0.0), + } + } + + /// Start moving the floating window holding `panel`. + pub fn moving_window(panel: PanelId) -> Self { + DockDrag::Move { + panel, + group: None, + window: true, origin: None, target: None, grab: iced::Vector::new(0.0, 0.0), @@ -992,7 +1232,7 @@ pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, if chrome.title_hovered { bar = bar .push(pin_button(id, chrome.auto_collapse, tooltip::Position::Bottom)) - .push(close_button(id, tooltip::Position::Bottom)); + .push(close_button(DockMsg::Close(id), tooltip::Position::Bottom)); } mouse_area( container(bar) @@ -1034,10 +1274,10 @@ pub fn pin_button<'a>(id: PanelId, auto_collapse: bool, tip: tooltip::Position) .into() } -/// The close button of a panel's title bar. -pub fn close_button<'a>(id: PanelId, tip: tooltip::Position) -> Element<'a, Message> { +/// The close button of a panel's title bar, sending `close`. +pub fn close_button<'a>(close: DockMsg, tip: tooltip::Position) -> Element<'a, Message> { let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0)) - .on_press(Message::Dock(DockMsg::Close(id))) + .on_press(Message::Dock(close)) .style(button::subtle) .padding([3, 5]); tooltip(close, text(crate::t!("Close")).size(10), tip) @@ -1218,13 +1458,7 @@ mod tests { let mut state = DockState::default(); state.join_group(PanelId::Browser, DockSide::Left, 0, 1); state.dock(PanelId::Count, DockSide::Left, 1); - state.float(FloatPanel { - id: PanelId::SheetSetManager, - x: 10.0, - y: 20.0, - w: 300.0, - h: 400.0, - }); + state.float(FloatPanel::new(PanelId::SheetSetManager, 10.0, 20.0, 300.0, 400.0)); let json = serde_json::to_string(&state).unwrap(); let back: DockState = serde_json::from_str(&json).unwrap(); assert_eq!(back, state); @@ -1294,13 +1528,7 @@ mod tests { state.dock(PanelId::Browser, DockSide::Left, 1); assert_eq!(state.shown(DockSide::Left), 1); // Floating Browser removes the last group; the edge shows group 0. - state.float(FloatPanel { - id: PanelId::Browser, - x: 0.0, - y: 0.0, - w: 250.0, - h: 300.0, - }); + state.float(FloatPanel::new(PanelId::Browser, 0.0, 0.0, 250.0, 300.0)); assert_eq!(state.left.len(), 1); assert_eq!(state.shown(DockSide::Left), 0); } @@ -1361,13 +1589,7 @@ mod tests { assert!(state.auto_hides(PanelId::Browser)); assert!(!state.auto_hides(PanelId::BlockPalette)); // A floating pallet keeps its own setting. - state.float(FloatPanel { - id: PanelId::Count, - x: 0.0, - y: 0.0, - w: 250.0, - h: 300.0, - }); + state.float(FloatPanel::new(PanelId::Count, 0.0, 0.0, 250.0, 300.0)); assert!(!state.auto_hides(PanelId::Count)); assert!(state.toggle_auto_hide(PanelId::Count)); assert!(state.auto_hides(PanelId::Count)); @@ -1420,17 +1642,11 @@ mod tests { #[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)); + let rect = FloatPanel::new(PanelId::Properties, 100.0, 50.0, 250.0, 300.0); + assert!(state.float(rect.clone())); assert!(state.left.is_empty()); assert_eq!(state.location(PanelId::Properties), None); - assert_eq!(state.float_rect(PanelId::Properties), Some(rect)); + 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()); @@ -1440,13 +1656,7 @@ mod tests { #[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.float(FloatPanel::new(PanelId::Count, 0.0, 0.0, 300.0, 300.0)); state.resize_float(PanelId::Count, 10.0, 10.0, false); let f = state.float_rect(PanelId::Count).unwrap(); assert_eq!((f.w, f.h), (DOCK_MIN_W, FLOAT_MIN_H)); @@ -1456,6 +1666,44 @@ mod tests { assert_eq!((f.x, f.w), (DOCK_MIN_W - 350.0, 350.0)); } + #[test] + fn legacy_single_float_loads_as_a_one_pallet_window() { + let json = r#"{"floating":[{"id":"count","x":1.0,"y":2.0,"w":300.0,"h":400.0}]}"#; + let state: DockState = serde_json::from_str(json).unwrap(); + assert_eq!(state.floating, vec![FloatPanel::new(PanelId::Count, 1.0, 2.0, 300.0, 400.0)]); + } + + #[test] + fn a_group_floats_docks_and_takes_pallets_as_one_window() { + let mut state = DockState::default(); + state.join_group(PanelId::Browser, DockSide::Left, 0, 1); + state.dock(PanelId::Count, DockSide::Left, 1); + // Floating group 0 takes both its pallets into one window. + assert!(state.float_group(DockSide::Left, 0, 10.0, 20.0, 300.0, 400.0)); + let f = state.float_rect(PanelId::Browser).unwrap(); + assert_eq!(f.group.panels, vec![PanelId::Properties, PanelId::Browser]); + assert_eq!(state.left.len(), 1); + // Adding a pallet stacks it at the bottom; floating one out leaves + // the rest together. + assert!(state.add_to_float(PanelId::Count, PanelId::Properties)); + assert!(state.left.is_empty()); + assert_eq!(state.float_rect(PanelId::Count).unwrap().group.panels.len(), 3); + state.float(FloatPanel::new(PanelId::Count, 0.0, 0.0, 250.0, 300.0)); + assert_eq!(state.floating.len(), 2); + assert_eq!(state.float_rect(PanelId::Properties).unwrap().group.panels.len(), 2); + // The window docks as one group with its width. + assert!(state.dock_window(PanelId::Browser, DockSide::Right, 0)); + assert_eq!(state.right[0].panels, vec![PanelId::Properties, PanelId::Browser]); + assert_eq!(state.right[0].width, 300.0); + // And a window joins a docked group as a whole. + assert!(state.join_window(PanelId::Count, DockSide::Right, 0, 1)); + assert_eq!( + state.right[0].panels, + vec![PanelId::Properties, PanelId::Count, PanelId::Browser] + ); + assert!(state.floating.is_empty()); + } + #[test] fn every_panel_has_its_own_icon() { let ids = PanelId::ALL; From 75fb9e32ddaeb74a5faf4f932de316db1a71dd51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 11:18:42 +0000 Subject: [PATCH 20/24] Dock: floating menu at the pointer, hover-only bar buttons, Allow docking, clip pallets - The floating title bar's right-click menu opens where the pointer is, flipped to stay on screen; the Pallets submenu sits beside it. - Close and pin on a floating title bar only show while it is hovered, as on docked title bars. - New Allow docking check in that menu: off, dragging the window (or a pallet out of it) only floats it, never docks it on an edge. - Floating windows and stacked pallets clip their content, so a pallet sized below its content no longer draws outside its frame. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- locales/ar-SA/opencadstudio.ftl | 1 + locales/bg-BG/opencadstudio.ftl | 1 + locales/cs-CZ/opencadstudio.ftl | 1 + locales/de-DE/opencadstudio.ftl | 1 + locales/el-GR/opencadstudio.ftl | 1 + locales/en-US/opencadstudio.ftl | 1 + locales/es-ES/opencadstudio.ftl | 1 + locales/fi-FI/opencadstudio.ftl | 1 + locales/fr-FR/opencadstudio.ftl | 1 + locales/hi-IN/opencadstudio.ftl | 1 + locales/hu-HU/opencadstudio.ftl | 1 + locales/it-IT/opencadstudio.ftl | 1 + locales/ja-JP/opencadstudio.ftl | 1 + locales/ko-KR/opencadstudio.ftl | 1 + locales/nl-NL/opencadstudio.ftl | 1 + locales/pl-PL/opencadstudio.ftl | 1 + locales/pt-BR/opencadstudio.ftl | 1 + locales/ru-RU/opencadstudio.ftl | 1 + locales/tr-TR/opencadstudio.ftl | 1 + locales/zh-CN/opencadstudio.ftl | 1 + locales/zh-TW/opencadstudio.ftl | 1 + src/app/dock/tests.rs | 17 +++++++++ src/app/dock/update.rs | 42 +++++++++++++++++++--- src/app/dock/view.rs | 64 ++++++++++++++++++++++----------- src/app/mod.rs | 6 ++++ src/locale_catalog.rs | 1 + src/ui/dock.rs | 35 +++++++++++++++++- 27 files changed, 161 insertions(+), 25 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 9e78732e8..729a484b7 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6166,6 +6166,7 @@ count-feature = .invalid-point = نقطة غير صالحة. dock = + .allow-docking = السماح بالإرساء .auto-hide = إخفاء تلقائي .dock-left = إرساء إلى اليسار .dock-right = إرساء إلى اليمين diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 0de11349e..59d517521 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6154,6 +6154,7 @@ count-feature = .invalid-point = Невалидна точка. dock = + .allow-docking = Разреши закачане .auto-hide = Автоматично скриване .dock-left = Закачи вляво .dock-right = Закачи вдясно diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index b77e80e31..a209e1049 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6151,6 +6151,7 @@ count-feature = .invalid-point = Neplatný bod. dock = + .allow-docking = Povolit ukotvení .auto-hide = Automaticky skrýt .dock-left = Ukotvit vlevo .dock-right = Ukotvit vpravo diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 59a0369b7..a697eca8b 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6152,6 +6152,7 @@ count-feature = .invalid-point = Ungültiger Punkt. dock = + .allow-docking = Andocken zulassen .auto-hide = Automatisch ausblenden .dock-left = Links andocken .dock-right = Rechts andocken diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index eee2a7975..82058463b 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6154,6 +6154,7 @@ count-feature = .invalid-point = Μη έγκυρο σημείο. dock = + .allow-docking = Να επιτρέπεται η πρόσδεση .auto-hide = Αυτόματη απόκρυψη .dock-left = Πρόσδεση αριστερά .dock-right = Πρόσδεση δεξιά diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 6ebc848e4..b1a57b60a 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6155,6 +6155,7 @@ count-feature = .invalid-point = Invalid point. dock = + .allow-docking = Allow docking .auto-hide = Auto-hide .dock-left = Dock left .dock-right = Dock right diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 1f75e7eb7..4947517a1 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -6154,6 +6154,7 @@ count-feature = .invalid-point = Punto no válido. dock = + .allow-docking = Permitir acoplar .auto-hide = Ocultar automáticamente .dock-left = Acoplar a la izquierda .dock-right = Acoplar a la derecha diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 85f83d9f2..35de0cf29 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6151,6 +6151,7 @@ count-feature = .invalid-point = Virheellinen piste. dock = + .allow-docking = Salli telakointi .auto-hide = Piilota automaattisesti .dock-left = Telakoi vasemmalle .dock-right = Telakoi oikealle diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 7507461fd..de078bafb 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6153,6 +6153,7 @@ count-feature = .invalid-point = Point non valide. dock = + .allow-docking = Autoriser l'ancrage .auto-hide = Masquage automatique .dock-left = Ancrer à gauche .dock-right = Ancrer à droite diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index cd1686ba4..cdd91b104 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6142,6 +6142,7 @@ count-feature = .invalid-point = अमान्य बिंदु। dock = + .allow-docking = डॉकिंग की अनुमति दें .auto-hide = स्वतः छिपाएँ .dock-left = बाएँ डॉक करें .dock-right = दाएँ डॉक करें diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index a7ba41b25..a1a260f5c 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6153,6 +6153,7 @@ count-feature = .invalid-point = Érvénytelen pont. dock = + .allow-docking = Dokkolás engedélyezése .auto-hide = Automatikus elrejtés .dock-left = Dokkolás balra .dock-right = Dokkolás jobbra diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 90cc6785e..2e36787be 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6153,6 +6153,7 @@ count-feature = .invalid-point = Punto non valido. dock = + .allow-docking = Consenti ancoraggio .auto-hide = Nascondi automaticamente .dock-left = Ancora a sinistra .dock-right = Ancora a destra diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index b8ac215be..b0ea367e4 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6142,6 +6142,7 @@ count-feature = .invalid-point = 点が無効です。 dock = + .allow-docking = ドッキングを許可 .auto-hide = 自動的に隠す .dock-left = 左にドッキング .dock-right = 右にドッキング diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 28ac8e053..067ddd8f2 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6153,6 +6153,7 @@ count-feature = .invalid-point = 점이 잘못되었습니다. dock = + .allow-docking = 도킹 허용 .auto-hide = 자동 숨기기 .dock-left = 왼쪽에 도킹 .dock-right = 오른쪽에 도킹 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 91b99f044..08632e9c1 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6152,6 +6152,7 @@ count-feature = .invalid-point = Ongeldig punt. dock = + .allow-docking = Koppelen toestaan .auto-hide = Automatisch verbergen .dock-left = Links koppelen .dock-right = Rechts koppelen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 4ba9b0a52..0d0319030 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6153,6 +6153,7 @@ count-feature = .invalid-point = Nieprawidłowy punkt. dock = + .allow-docking = Zezwalaj na dokowanie .auto-hide = Autoukrywanie .dock-left = Zadokuj po lewej .dock-right = Zadokuj po prawej diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 8c1274a9a..550f62bf6 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6154,6 +6154,7 @@ count-feature = .invalid-point = Ponto inválido. dock = + .allow-docking = Permitir encaixe .auto-hide = Ocultar automaticamente .dock-left = Encaixar à esquerda .dock-right = Encaixar à direita diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index a3e79f208..db6cadab3 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -6155,6 +6155,7 @@ count-feature = .invalid-point = Недопустимая точка. dock = + .allow-docking = Разрешить закрепление .auto-hide = Автоскрытие .dock-left = Закрепить слева .dock-right = Закрепить справа diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 4683551d7..266869dfa 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6077,6 +6077,7 @@ count-feature = .invalid-point = Geçersiz nokta. dock = + .allow-docking = Yerleştirmeye izin ver .auto-hide = Otomatik gizle .dock-left = Sola yerleştir .dock-right = Sağa yerleştir diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index ac07a0696..2b2118aac 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6137,6 +6137,7 @@ count-feature = .invalid-point = 无效的点。 dock = + .allow-docking = 允许停靠 .auto-hide = 自动隐藏 .dock-left = 停靠到左侧 .dock-right = 停靠到右侧 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index aedf9a45f..fb5d8d65f 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6149,6 +6149,7 @@ count-feature = .invalid-point = 無效的點。 dock = + .allow-docking = 允許停駐 .auto-hide = 自動隱藏 .dock-left = 停駐到左側 .dock-right = 停駐到右側 diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 30522c614..8c84ac664 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -628,3 +628,20 @@ fn blockpalette_width_reset() { let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id)); assert_eq!(app.dock.group_width(DockSide::Right, 0), 260.0); } + +#[test] +fn a_window_without_allow_docking_only_floats_when_dragged() { + use crate::ui::dock::{DockMsg, DropTarget, PanelId}; + let mut app = dock_app(); + let _ = app.on_dock(DockMsg::FloatOut(PanelId::BlockPalette)); + let _ = app.on_dock(DockMsg::FloatDockingToggle(PanelId::BlockPalette)); + assert!(!app.dock.float_rect(PanelId::BlockPalette).unwrap().docking); + // Dragged onto the left edge's strip, it stays a floating window. + let _ = app.on_dock(DockMsg::FloatGrab(PanelId::BlockPalette)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(500.0, 300.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(10.0, 60.0))); + assert!(matches!(drag_target(&app), Some(DropTarget::Float { .. }))); + let _ = app.on_dock(DockMsg::DragRelease); + assert!(app.dock.float_rect(PanelId::BlockPalette).is_some()); + assert_eq!(app.dock.location(PanelId::BlockPalette), None); +} diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs index 3b43df3ec..9601846fd 100644 --- a/src/app/dock/update.rs +++ b/src/app/dock/update.rs @@ -149,12 +149,20 @@ impl OpenCADStudio { } DockMsg::FloatMenu(id) => { self.dock_float_menu = id; + self.dock_float_menu_at = self.dock_float_pointer; self.dock_float_menu_pallets = false; if let Some(id) = id { self.dock.raise_float(id); } } DockMsg::FloatMenuPallets(open) => self.dock_float_menu_pallets = open, + DockMsg::FloatBarPointer(p) => self.dock_float_pointer = p, + DockMsg::FloatDockingToggle(id) => { + self.dock_float_menu = None; + if self.dock.toggle_float_docking(id) { + self.save_config(); + } + } DockMsg::FloatMenuToggle(window, id) => { // Checked = open in this window: unchecking hides it. // Anything else (closed, docked, in another window) moves @@ -289,7 +297,13 @@ impl OpenCADStudio { } DropTarget::Float { x, y } => { let (w, h) = self.dock_float_size(panel); - self.dock.float(FloatPanel::new(panel, x, y, w, h)) + // Pulled out of a window, it keeps the + // window's docking choice. + let docking = self.dock.float_rect(panel).is_none_or(|f| f.docking); + self.dock.float(FloatPanel { + docking, + ..FloatPanel::new(panel, x, y, w, h) + }) } }; } @@ -356,9 +370,18 @@ impl OpenCADStudio { grab }; let target = if started { + // A window that may not dock (or a pallet pulled out of + // one) only floats. + let floats_only = group.is_none() + && self.dock.float_rect(panel).is_some_and(|f| !f.docking); + let found = if floats_only { + self.dock_float_target(point, grab) + } else { + self.dock_drop_target(point, grab) + }; // A whole docked group moves between group positions or // floats; it cannot join another group. - match (group, self.dock_drop_target(point, grab)) { + match (group, found) { (Some(_), DropTarget::Join { .. }) => None, (_, found) => Some(found), } @@ -786,7 +809,7 @@ impl OpenCADStudio { ) -> crate::ui::dock::DropTarget { use crate::app::config::DockSide; use crate::ui::dock::{DropTarget, StripHit, DOCK_EDGE_ZONE, DOCK_STRIP_W}; - let (ww, wh) = self.dock_workspace_size(); + let (ww, _) = self.dock_workspace_size(); for side in [DockSide::Left, DockSide::Right] { let listed = !self.dock_visible_groups(side).is_empty(); let from_edge = match side { @@ -841,8 +864,19 @@ impl OpenCADStudio { } } } + self.dock_float_target(p, grab) + } + + /// Floating at workspace point `p`, held at `grab` from its top-left + /// corner (kept on the workspace). + pub(crate) fn dock_float_target( + &self, + p: iced::Point, + grab: iced::Vector, + ) -> crate::ui::dock::DropTarget { + let (ww, wh) = self.dock_workspace_size(); let x = (p.x - grab.x).clamp(0.0, (ww - 60.0).max(0.0)); let y = (p.y - grab.y).clamp(0.0, (wh - 30.0).max(0.0)); - DropTarget::Float { x, y } + crate::ui::dock::DropTarget::Float { x, y } } } diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index 994b55464..bea980338 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -140,6 +140,7 @@ impl OpenCADStudio { .height(Length::FillPortion(crate::ui::dock::portion( group.weights[pos], ))) + .clip(true) .into(), ); prev = Some(pos); @@ -474,7 +475,8 @@ impl OpenCADStudio { let (x, y, bar_left, hidden) = self.dock_float_frame(f); // Messages name the window by its first open pallet. let anchor = visible[0]; - let bar = floating_title_bar(&visible, self.dock.auto_hides(anchor), bar_left); + let hovered = self.dock_title_hover.is_some_and(|p| f.contains(p)); + let bar = floating_title_bar(&visible, self.dock.auto_hides(anchor), bar_left, hovered); let content: Element<'_, Message> = if hidden { bar } else { @@ -511,6 +513,7 @@ impl OpenCADStudio { pallets.push( container(self.panel_body(*id, f.w, true, tab)) .height(Length::FillPortion(crate::ui::dock::portion(weight))) + .clip(true) .into(), ); prev = Some(pos); @@ -535,8 +538,11 @@ impl OpenCADStudio { row![body, bar].into() } }; + // Clipped, so a pallet squeezed below its content's height never + // draws outside the window. let framed = container(content) .height(Length::Fixed(f.h)) + .clip(true) .style(|theme: &Theme| container::Style { border: Border { color: theme.palette().background.strong.color, @@ -561,8 +567,8 @@ impl OpenCADStudio { } /// The open right-click menu of a floating window: Pallets (a submenu - /// to add or hide pallets in the window), Dock left and Dock right. It - /// hangs beside the title bar, toward the window's inside. + /// to add or hide pallets in the window), Allow docking, Dock left and + /// Dock right. It opens at the pointer, flipped to stay on screen. fn dock_float_menu_overlay(&self) -> Option> { use crate::app::config::DockSide; use crate::ui::dock::{DockMsg, PanelId}; @@ -571,17 +577,16 @@ impl OpenCADStudio { if self.dock_float_visible(f).is_empty() { return None; } - let (ww, _) = self.dock_workspace_size(); + let (ww, wh) = self.dock_workspace_size(); let (x, y, bar_left, hidden) = self.dock_float_frame(f); let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; let bar_x = if bar_left { x } else { x + total_w - DOCK_FLOAT_BAR_W }; + // Where the right-click happened, in the workspace. + let at = iced::Point::new(bar_x + self.dock_float_menu_at.x, y + self.dock_float_menu_at.y); let max_x = (ww - DOCK_MENU_W).max(0.0); - let menu_x = if bar_left { - bar_x + DOCK_FLOAT_BAR_W + 2.0 - } else { - bar_x - DOCK_MENU_W - 2.0 - } - .clamp(0.0, max_x); + let menu_x = if at.x + DOCK_MENU_W <= ww { at.x } else { at.x - DOCK_MENU_W }; + let menu_x = menu_x.clamp(0.0, max_x); + let menu_y = at.y.min(wh - FLOAT_MENU_H).max(0.0); let open = self.dock_float_menu_pallets; // Hovering a row opens (Pallets) or closes (the others) the submenu. let item = |label: Element<'static, Message>, press: DockMsg, sub: bool| -> Element<'static, Message> { @@ -607,9 +612,17 @@ impl OpenCADStudio { background: Some(Background::Color(theme.palette().background.neutral.color)), ..Default::default() }); + let check: Element<'static, Message> = if f.docking { + crate::ui::icons::themed(crate::ui::icons::CHECK, 12.0) + } else { + Space::new().width(12).height(12).into() + }; + let docking = row![text(t!("Allow docking")).size(12).width(Fill), check] + .align_y(iced::Center); let main = column![ item(pallets.into(), DockMsg::FloatMenuPallets(!open), true), container(separator).padding([3, 4]), + item(docking.into(), DockMsg::FloatDockingToggle(anchor), false), item( text(t!("Dock left")).size(12).into(), DockMsg::DockTo(anchor, DockSide::Left), @@ -623,24 +636,22 @@ impl OpenCADStudio { ] .spacing(1) .padding(4); - let mut layers = vec![place_at(menu_frame(main.into()), menu_x, y + 4.0)]; + let mut layers = vec![place_at(menu_frame(main.into()), menu_x, menu_y)]; if open { let checked: Vec<_> = PanelId::ALL .iter() .map(|p| (*p, f.contains(*p) && self.dock_panel_visible(*p))) .collect(); let toggle = move |id| Message::Dock(DockMsg::FloatMenuToggle(anchor, id)); - // Beside the menu, on the side it opened toward, unless there is - // no room there. - let right = menu_x + 2.0 * DOCK_MENU_W + 2.0 <= ww; - let left = menu_x - DOCK_MENU_W - 2.0 >= 0.0; - let sub_x = if (bar_left && right) || !left { + // To the right of the menu, or its left when there is no room. + let sub_x = if menu_x + 2.0 * DOCK_MENU_W + 2.0 <= ww { menu_x + DOCK_MENU_W + 2.0 } else { menu_x - DOCK_MENU_W - 2.0 } .clamp(0.0, max_x); - layers.push(place_at(dock_pallet_menu(&checked, toggle), sub_x, y + 4.0)); + let sub_y = menu_y.min(wh - PALLET_MENU_H).max(0.0); + layers.push(place_at(dock_pallet_menu(&checked, toggle), sub_x, sub_y)); } Some( iced::widget::Stack::with_children(layers) @@ -954,6 +965,7 @@ fn floating_title_bar( visible: &[crate::ui::dock::PanelId], auto_collapse: bool, on_left: bool, + hovered: bool, ) -> Element<'static, Message> { use crate::ui::dock::DockMsg; let anchor = visible[0]; @@ -962,9 +974,14 @@ fn floating_title_bar( } else { iced::widget::tooltip::Position::Right }; - let close = crate::ui::dock::close_button(DockMsg::FloatClose(anchor), tip); - let pin = crate::ui::dock::pin_button(anchor, auto_collapse, tip); - let mut bar = column![close, pin].spacing(2); + // Close and pin only show while the pointer is over the bar, as on a + // docked title bar. + let mut bar = column![].spacing(2); + if hovered { + bar = bar + .push(crate::ui::dock::close_button(DockMsg::FloatClose(anchor), tip)) + .push(crate::ui::dock::pin_button(anchor, auto_collapse, tip)); + } if let [id] = visible { let title = canvas(VBarLabel { text: t!(id.title()).into_owned(), @@ -1000,6 +1017,9 @@ fn floating_title_bar( })) .on_press(Message::Dock(DockMsg::FloatGrab(anchor))) .on_right_press(Message::Dock(DockMsg::FloatMenu(Some(anchor)))) + .on_enter(Message::Dock(DockMsg::TitleHover(anchor))) + .on_move(|p| Message::Dock(DockMsg::FloatBarPointer(p))) + .on_exit(Message::Dock(DockMsg::TitleHoverEnd(anchor))) // Double-click docks it against the edge its title bar faces. .on_double_click(Message::Dock(DockMsg::DockTo( anchor, @@ -1058,6 +1078,10 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { /// Width of the icon strip's pallet menu. const DOCK_MENU_W: f32 = 230.0; +/// Rough heights of a floating window's menu and of a pallet menu, to keep +/// them on screen. +const FLOAT_MENU_H: f32 = 150.0; +const PALLET_MENU_H: f32 = 270.0; /// A pallet menu (an edge's + button, a floating window's Pallets /// submenu), styled like the right-click menu: one row per pallet with its diff --git a/src/app/mod.rs b/src/app/mod.rs index 0887f0718..bb4190dfe 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -869,6 +869,10 @@ pub(super) struct OpenCADStudio { /// is open, and whether its Pallets submenu shows. pub(crate) dock_float_menu: Option, pub(crate) dock_float_menu_pallets: bool, + /// Pointer position over a floating title bar (bar-local), and where + /// its open menu was asked for. + pub(crate) dock_float_pointer: iced::Point, + pub(crate) dock_float_menu_at: iced::Point, /// Group whose edge band (group grip) the pointer is over. pub(crate) dock_grip_hover: Option<(crate::app::config::DockSide, usize)>, /// Reference-table column currently being width-resized (column index), @@ -4303,6 +4307,8 @@ impl OpenCADStudio { dock_hover_gen: 0, dock_float_menu: None, dock_float_menu_pallets: false, + dock_float_pointer: iced::Point::ORIGIN, + dock_float_menu_at: iced::Point::ORIGIN, dock_drag: None, dock_drag_last: None, dock_title_hover: None, diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 730c2fe0c..91bb128bf 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -5755,6 +5755,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Block \"{part}\" cannot be found." => Some(("count-feature", "block-not-found")), "Invalid point." => Some(("count-feature", "invalid-point")), "Auto-hide" => Some(("dock", "auto-hide")), + "Allow docking" => Some(("dock", "allow-docking")), "Dock left" => Some(("dock", "dock-left")), "Dock right" => Some(("dock", "dock-right")), "New group" => Some(("dock", "new-group")), diff --git a/src/ui/dock.rs b/src/ui/dock.rs index d1fb5dd67..3b8c21621 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -85,6 +85,12 @@ pub enum DockMsg { FloatMenu(Option), /// Show (`true`) or hide the floating menu's Pallets submenu. FloatMenuPallets(bool), + /// Floating menu's Allow docking check: flip it for the window holding + /// the pallet. + FloatDockingToggle(PanelId), + /// The pointer moved over a floating title bar, at this point in it + /// (where its right-click menu opens). + FloatBarPointer(iced::Point), /// Pallets submenu pick (window, pallet): hide the pallet when it shows /// in the window, else add it to the window and open it. FloatMenuToggle(PanelId, PanelId), @@ -342,6 +348,9 @@ pub struct FloatPanel { pub y: f32, pub w: f32, pub h: f32, + /// Dragging the window (or a pallet out of it) may dock it on an edge; + /// off, it only ever floats. + pub docking: bool, } /// On-disk form of a floating window. Configs from before floating groups @@ -358,6 +367,12 @@ struct FloatRepr { y: f32, w: f32, h: f32, + #[serde(default = "docking_default")] + docking: bool, +} + +fn docking_default() -> bool { + true } impl From for FloatPanel { @@ -373,6 +388,7 @@ impl From for FloatPanel { y: r.y, w: r.w, h: r.h, + docking: r.docking, } } } @@ -386,6 +402,7 @@ impl FloatPanel { y, w, h, + docking: true, } } @@ -830,7 +847,14 @@ impl DockState { } let g = self.groups_mut(side).remove(group); self.after_group_removed(side, group); - let mut f = FloatPanel { group: g, x, y, w, h }; + let mut f = FloatPanel { + group: g, + x, + y, + w, + h, + docking: true, + }; f.heal(); self.floating.push(f); true @@ -902,6 +926,15 @@ impl DockState { true } + /// Flip whether dragging the floating window holding `id` may dock it. + pub fn toggle_float_docking(&mut self, id: PanelId) -> bool { + let Some(i) = self.float_index(id) else { + return false; + }; + self.floating[i].docking = !self.floating[i].docking; + true + } + /// Bring the floating window holding `id` to the front. pub fn raise_float(&mut self, id: PanelId) { if let Some(i) = self.float_index(id) { From 2135dd499a9f5ef74290461f81a9bf0884574a33 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 11:32:51 +0000 Subject: [PATCH 21/24] Dock: floating windows hold one pallet again Remove floating groups: a floating window is a single pallet once more, and the Pallets submenu is gone from its title-bar menu. Kept: the menu opens at the pointer with Allow docking, Dock left and Dock right; close / pin show only while the bar is hovered; content is clipped to its frame. A config saved by the floating-group build still loads, its windows floating their first pallet. Drops the unused Pallets string. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- locales/ar-SA/opencadstudio.ftl | 1 - locales/bg-BG/opencadstudio.ftl | 1 - locales/cs-CZ/opencadstudio.ftl | 1 - locales/de-DE/opencadstudio.ftl | 1 - locales/el-GR/opencadstudio.ftl | 1 - locales/en-US/opencadstudio.ftl | 1 - locales/es-ES/opencadstudio.ftl | 1 - locales/fi-FI/opencadstudio.ftl | 1 - locales/fr-FR/opencadstudio.ftl | 1 - locales/hi-IN/opencadstudio.ftl | 1 - locales/hu-HU/opencadstudio.ftl | 1 - locales/it-IT/opencadstudio.ftl | 1 - locales/ja-JP/opencadstudio.ftl | 1 - locales/ko-KR/opencadstudio.ftl | 1 - locales/nl-NL/opencadstudio.ftl | 1 - locales/pl-PL/opencadstudio.ftl | 1 - locales/pt-BR/opencadstudio.ftl | 1 - locales/ru-RU/opencadstudio.ftl | 1 - locales/tr-TR/opencadstudio.ftl | 1 - locales/zh-CN/opencadstudio.ftl | 1 - locales/zh-TW/opencadstudio.ftl | 1 - src/app/dock/tests.rs | 70 ++---- src/app/dock/update.rs | 191 +++------------- src/app/dock/view.rs | 361 ++++++++++------------------- src/app/mod.rs | 5 +- src/locale_catalog.rs | 1 - src/ui/dock.rs | 389 +++++++------------------------- 27 files changed, 245 insertions(+), 793 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 729a484b7..75ac31b28 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6171,6 +6171,5 @@ dock = .dock-left = إرساء إلى اليسار .dock-right = إرساء إلى اليمين .new-group = مجموعة جديدة - .pallets = اللوحات .browser = المستعرض .node-graph = مخطط العقد diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 59d517521..408ae4873 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6159,6 +6159,5 @@ dock = .dock-left = Закачи вляво .dock-right = Закачи вдясно .new-group = Нова група - .pallets = Палитри .browser = Браузър .node-graph = Граф на възлите diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index a209e1049..2c1ca44bd 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6156,6 +6156,5 @@ dock = .dock-left = Ukotvit vlevo .dock-right = Ukotvit vpravo .new-group = Nová skupina - .pallets = Palety .browser = Prohlížeč .node-graph = Graf uzlů diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index a697eca8b..cace0f53f 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6157,6 +6157,5 @@ dock = .dock-left = Links andocken .dock-right = Rechts andocken .new-group = Neue Gruppe - .pallets = Paletten .browser = Browser .node-graph = Knotengraph diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 82058463b..32480969e 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6159,6 +6159,5 @@ dock = .dock-left = Πρόσδεση αριστερά .dock-right = Πρόσδεση δεξιά .new-group = Νέα ομάδα - .pallets = Παλέτες .browser = Περιηγητής .node-graph = Γράφος κόμβων diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index b1a57b60a..2cbfb7d96 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6160,6 +6160,5 @@ dock = .dock-left = Dock left .dock-right = Dock right .new-group = New group - .pallets = Pallets .browser = Browser .node-graph = Node Graph diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 4947517a1..429d931ce 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -6159,6 +6159,5 @@ dock = .dock-left = Acoplar a la izquierda .dock-right = Acoplar a la derecha .new-group = Nuevo grupo - .pallets = Paletas .browser = Explorador .node-graph = Grafo de nodos diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 35de0cf29..3dae8fa03 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6156,6 +6156,5 @@ dock = .dock-left = Telakoi vasemmalle .dock-right = Telakoi oikealle .new-group = Uusi ryhmä - .pallets = Paletit .browser = Selain .node-graph = Solmukaavio diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index de078bafb..9e6c00967 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6158,6 +6158,5 @@ dock = .dock-left = Ancrer à gauche .dock-right = Ancrer à droite .new-group = Nouveau groupe - .pallets = Palettes .browser = Navigateur .node-graph = Graphe de nœuds diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index cdd91b104..f825d5103 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6147,6 +6147,5 @@ dock = .dock-left = बाएँ डॉक करें .dock-right = दाएँ डॉक करें .new-group = नया समूह - .pallets = पैलेट .browser = ब्राउज़र .node-graph = नोड ग्राफ़ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index a1a260f5c..6c00d4e97 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6158,6 +6158,5 @@ dock = .dock-left = Dokkolás balra .dock-right = Dokkolás jobbra .new-group = Új csoport - .pallets = Paletták .browser = Böngésző .node-graph = Csomópontgráf diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 2e36787be..af2b1d7c4 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6158,6 +6158,5 @@ dock = .dock-left = Ancora a sinistra .dock-right = Ancora a destra .new-group = Nuovo gruppo - .pallets = Tavolozze .browser = Browser .node-graph = Grafo dei nodi diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index b0ea367e4..ff7fdc061 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6147,6 +6147,5 @@ dock = .dock-left = 左にドッキング .dock-right = 右にドッキング .new-group = 新しいグループ - .pallets = パレット .browser = ブラウザー .node-graph = ノードグラフ diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 067ddd8f2..d270ca2e6 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6158,6 +6158,5 @@ dock = .dock-left = 왼쪽에 도킹 .dock-right = 오른쪽에 도킹 .new-group = 새 그룹 - .pallets = 팔레트 .browser = 브라우저 .node-graph = 노드 그래프 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 08632e9c1..b485167b3 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6157,6 +6157,5 @@ dock = .dock-left = Links koppelen .dock-right = Rechts koppelen .new-group = Nieuwe groep - .pallets = Paletten .browser = Browser .node-graph = Knooppuntgrafiek diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 0d0319030..092e1134d 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6158,6 +6158,5 @@ dock = .dock-left = Zadokuj po lewej .dock-right = Zadokuj po prawej .new-group = Nowa grupa - .pallets = Palety .browser = Przeglądarka .node-graph = Graf węzłów diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 550f62bf6..8f7d24909 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6159,6 +6159,5 @@ dock = .dock-left = Encaixar à esquerda .dock-right = Encaixar à direita .new-group = Novo grupo - .pallets = Paletas .browser = Navegador .node-graph = Grafo de nós diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index db6cadab3..9e522dff5 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -6160,6 +6160,5 @@ dock = .dock-left = Закрепить слева .dock-right = Закрепить справа .new-group = Новая группа - .pallets = Палитры .browser = Обозреватель .node-graph = Граф узлов diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 266869dfa..1423b863b 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6082,6 +6082,5 @@ dock = .dock-left = Sola yerleştir .dock-right = Sağa yerleştir .new-group = Yeni grup - .pallets = Paletler .browser = Tarayıcı .node-graph = Düğüm Grafiği diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 2b2118aac..33f5a53ee 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6142,6 +6142,5 @@ dock = .dock-left = 停靠到左侧 .dock-right = 停靠到右侧 .new-group = 新建组 - .pallets = 选项板 .browser = 浏览器 .node-graph = 节点图 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index fb5d8d65f..95d417cd5 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6154,6 +6154,5 @@ dock = .dock-left = 停駐到左側 .dock-right = 停駐到右側 .new-group = 新增群組 - .pallets = 選項板 .browser = 瀏覽器 .node-graph = 節點圖 diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 8c84ac664..3f97ec975 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -205,7 +205,7 @@ fn dock_drop_over_the_viewport_floats_the_panel() { ); assert!(matches!(target, Some(DropTarget::Float { .. }))); assert!(app.dock.right.is_empty()); - let f = app.dock.float_rect(PanelId::BlockPalette).expect("floating").clone(); + 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( @@ -275,53 +275,14 @@ fn dock_grip_drag_moves_a_whole_group() { app.dock.left[1].panels, vec![PanelId::Properties, PanelId::Browser] ); - // A group dropped over the viewport floats as one window. + // A group dropped over the viewport does not float or split up. + let before = app.dock.clone(); let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 1)); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, 300.0))); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0))); + assert_eq!(drag_target(&app), None); let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!(app.dock.left.len(), 1); - let f = app.dock.float_rect(PanelId::Browser).expect("floats"); - assert_eq!(f.group.panels, vec![PanelId::Properties, PanelId::Browser]); - // Dragging the window's title bar onto the strip docks it whole again. - let _ = app.on_dock(DockMsg::FloatGrab(PanelId::Properties)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0))); - let _ = app.on_dock(DockMsg::DragRelease); - assert!(app.dock.floating.is_empty()); - assert_eq!( - app.dock.left[1].panels, - vec![PanelId::Properties, PanelId::Browser] - ); -} - -#[test] -fn floating_menu_adds_pallets_and_docks_the_window() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, PanelId}; - let mut app = dock_app(); - let _ = app.on_dock(DockMsg::FloatOut(PanelId::BlockPalette)); - let _ = app.on_dock(DockMsg::FloatMenu(Some(PanelId::BlockPalette))); - let _ = app.on_dock(DockMsg::FloatMenuPallets(true)); - // Picking a pallet adds it to the window, opens it and closes the menu. - let _ = app.on_dock(DockMsg::FloatMenuToggle(PanelId::BlockPalette, PanelId::Browser)); - assert!(app.show_browser); - assert_eq!(app.dock_float_menu, None); - let f = app.dock.float_rect(PanelId::BlockPalette).unwrap(); - assert_eq!(f.group.panels, vec![PanelId::BlockPalette, PanelId::Browser]); - // Picking a checked one hides it but keeps its place. - let _ = app.on_dock(DockMsg::FloatMenuToggle(PanelId::BlockPalette, PanelId::Browser)); - assert!(!app.show_browser); - assert!(app.dock.float_rect(PanelId::Browser).is_some()); - // Dock left takes the whole window to the left edge. - let _ = app.on_dock(DockMsg::DockTo(PanelId::BlockPalette, DockSide::Left)); - assert!(app.dock.floating.is_empty()); - let (side, gi) = app.dock.location(PanelId::Browser).unwrap(); - assert_eq!(side, DockSide::Left); - assert_eq!( - app.dock.groups(side)[gi].panels, - vec![PanelId::BlockPalette, PanelId::Browser] - ); + assert_eq!(app.dock, before); } #[test] @@ -405,7 +366,7 @@ fn dock_float_resize_grows_the_panel() { 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().clone(); + let f = app.dock.float_rect(PanelId::BlockPalette).unwrap(); assert_eq!((f.w, f.h), (300.0, 350.0)); // The bottom-left grip grows the panel leftward, keeping its right edge. let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, true)); @@ -630,18 +591,27 @@ fn blockpalette_width_reset() { } #[test] -fn a_window_without_allow_docking_only_floats_when_dragged() { +fn floating_menu_docks_and_allow_docking_keeps_a_drag_floating() { + use crate::app::config::DockSide; use crate::ui::dock::{DockMsg, DropTarget, PanelId}; let mut app = dock_app(); let _ = app.on_dock(DockMsg::FloatOut(PanelId::BlockPalette)); + // With Allow docking off, a drag onto the left edge's strip stays + // floating. + let _ = app.on_dock(DockMsg::FloatMenu(Some(PanelId::BlockPalette))); let _ = app.on_dock(DockMsg::FloatDockingToggle(PanelId::BlockPalette)); + assert_eq!(app.dock_float_menu, None); assert!(!app.dock.float_rect(PanelId::BlockPalette).unwrap().docking); - // Dragged onto the left edge's strip, it stays a floating window. - let _ = app.on_dock(DockMsg::FloatGrab(PanelId::BlockPalette)); + let _ = app.on_dock(DockMsg::DockGrab(PanelId::BlockPalette)); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(500.0, 300.0))); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(10.0, 60.0))); assert!(matches!(drag_target(&app), Some(DropTarget::Float { .. }))); let _ = app.on_dock(DockMsg::DragRelease); - assert!(app.dock.float_rect(PanelId::BlockPalette).is_some()); - assert_eq!(app.dock.location(PanelId::BlockPalette), None); + let f = app.dock.float_rect(PanelId::BlockPalette).expect("still floating"); + assert!(!f.docking, "moving it keeps the choice"); + // The menu's Dock left still docks it. + let _ = app.on_dock(DockMsg::FloatMenu(Some(PanelId::BlockPalette))); + let _ = app.on_dock(DockMsg::DockTo(PanelId::BlockPalette, DockSide::Left)); + assert_eq!(app.dock_float_menu, None); + assert_eq!(app.dock.location(PanelId::BlockPalette).map(|(s, _)| s), Some(DockSide::Left)); } diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs index 9601846fd..9f0c6eec2 100644 --- a/src/app/dock/update.rs +++ b/src/app/dock/update.rs @@ -102,60 +102,20 @@ impl OpenCADStudio { self.save_config(); } } - DockMsg::GroupFloat(side, gi) => { - // Float beside the edge it was docked on, near the top. - let (ww, _) = self.dock_workspace_size(); - let w = self.dock_group_width(side, gi); - let strip = crate::ui::dock::DOCK_STRIP_W; - let x = match side { - DockSide::Right => ww - strip - self.dock_column_width(side) - w - 60.0, - DockSide::Left => strip + self.dock_column_width(side) + 40.0, - }; - self.dock_drag = None; - let h = self.dock_default_float_h(); - if self.dock.float_group(side, gi, x.max(0.0), 40.0, w, h) { - self.save_config(); - } - } DockMsg::DockTo(id, side) => { self.dock_drag = None; self.dock_float_menu = None; - if self.dock.dock_window(id, side, usize::MAX) { + if self.dock.dock(id, side, usize::MAX) { self.save_config(); } } - DockMsg::FloatGrab(id) => self.dock_begin(DockDrag::moving_window(id), Some(id)), - DockMsg::FloatClose(id) => { - let panels = self - .dock - .float_rect(id) - .map(|f| f.group.panels.clone()) - .unwrap_or_default(); - let mut tasks = Vec::new(); - for p in panels { - if self.dock_panel_visible(p) { - tasks.push(self.dock_set_open(p, false)); - } - } - return iced::Task::batch(tasks); - } - DockMsg::FloatSplitGrab(panel, upper, lower) => { - self.dock.raise_float(panel); - self.dock_begin(DockDrag::FloatSplit { panel, upper, lower }, None); - } - DockMsg::FloatSplitReset(id) => { - self.dock.reset_float_splits(id); - self.save_config(); - } DockMsg::FloatMenu(id) => { self.dock_float_menu = id; self.dock_float_menu_at = self.dock_float_pointer; - self.dock_float_menu_pallets = false; if let Some(id) = id { self.dock.raise_float(id); } } - DockMsg::FloatMenuPallets(open) => self.dock_float_menu_pallets = open, DockMsg::FloatBarPointer(p) => self.dock_float_pointer = p, DockMsg::FloatDockingToggle(id) => { self.dock_float_menu = None; @@ -163,22 +123,6 @@ impl OpenCADStudio { self.save_config(); } } - DockMsg::FloatMenuToggle(window, id) => { - // Checked = open in this window: unchecking hides it. - // Anything else (closed, docked, in another window) moves - // into the window and opens; adding ends the menu's job. - let here = self.dock.float_rect(window).is_some_and(|f| f.contains(id)); - if here && self.dock_panel_visible(id) { - let task = self.dock_set_open(id, false); - self.save_config(); - return task; - } - self.dock.add_to_float(id, window); - self.dock_float_menu = None; - let task = self.dock_set_open(id, true); - self.save_config(); - return task; - } DockMsg::EdgeMenu(side) => self.dock_edge_menu = side, DockMsg::EdgeMenuToggle(side, id) => { // Checked = open and docked on this edge: unchecking hides it. @@ -259,31 +203,6 @@ impl OpenCADStudio { target: Some(DropTarget::Edge { side: to, index }), .. }) => changed = self.dock.move_group(side, gi, to, index), - Some(DockDrag::Move { - group: Some((side, gi)), - target: Some(DropTarget::Float { x, y }), - .. - }) => { - let w = self.dock_group_width(side, gi); - let h = self.dock_default_float_h(); - changed = self.dock.float_group(side, gi, x, y, w, h); - } - Some(DockDrag::Move { - panel, - window: true, - target: Some(target), - .. - }) => { - changed = match target { - DropTarget::Edge { side, index } => { - self.dock.dock_window(panel, side, index) - } - DropTarget::Join { side, group, index } => { - self.dock.join_window(panel, side, group, index) - } - DropTarget::Float { x, y } => self.dock.move_float(panel, x, y), - }; - } Some(DockDrag::Move { panel, group: None, @@ -297,8 +216,7 @@ impl OpenCADStudio { } DropTarget::Float { x, y } => { let (w, h) = self.dock_float_size(panel); - // Pulled out of a window, it keeps the - // window's docking choice. + // A floating panel keeps its docking choice. let docking = self.dock.float_rect(panel).is_none_or(|f| f.docking); self.dock.float(FloatPanel { docking, @@ -308,12 +226,7 @@ impl OpenCADStudio { }; } Some(DockDrag::Move { .. } | DockDrag::LayerColumn) | None => {} - Some( - DockDrag::Width(_) - | DockDrag::Split { .. } - | DockDrag::FloatSize { .. } - | DockDrag::FloatSplit { .. }, - ) => { + Some(DockDrag::Width(_) | DockDrag::Split { .. } | DockDrag::FloatSize { .. }) => { changed = true; } } @@ -346,7 +259,6 @@ impl OpenCADStudio { DockDrag::Move { panel, group, - window, origin, target, grab, @@ -354,12 +266,10 @@ impl OpenCADStudio { let origin = origin.unwrap_or(point); let moved = (point.x - origin.x).hypot(point.y - origin.y); let started = target.is_some() || moved >= DOCK_DRAG_THRESHOLD; - // Hold a floating window where it was grabbed; a docked - // pallet (or one pulled out of a floating group) by its - // title bar. + // Hold a floating panel where it was grabbed; a docked one by + // its title bar. let grab = if target.is_none() && started { - let whole = |f: &&crate::ui::dock::FloatPanel| window || f.group.panels.len() == 1; - match self.dock.float_rect(panel).filter(whole) { + match self.dock.float_rect(panel) { Some(f) => iced::Vector::new(origin.x - f.x, origin.y - f.y), None => iced::Vector::new( (self.dock.width(panel, self.win_size.0) * 0.5).min(120.0), @@ -370,20 +280,16 @@ impl OpenCADStudio { grab }; let target = if started { - // A window that may not dock (or a pallet pulled out of - // one) only floats. - let floats_only = group.is_none() - && self.dock.float_rect(panel).is_some_and(|f| !f.docking); - let found = if floats_only { - self.dock_float_target(point, grab) - } else { - self.dock_drop_target(point, grab) + // A whole group only moves between group positions: it + // cannot join another group or float. + // A floating panel that may not dock only floats. + let found = match self.dock.float_rect(panel) { + Some(f) if !f.docking => self.dock_float_target(point, grab), + _ => self.dock_drop_target(point, grab), }; - // A whole docked group moves between group positions or - // floats; it cannot join another group. match (group, found) { - (Some(_), DropTarget::Join { .. }) => None, - (_, found) => Some(found), + (Some(_), found @ DropTarget::Edge { .. }) | (None, found) => Some(found), + (Some(_), _) => None, } } else { target @@ -391,7 +297,6 @@ impl OpenCADStudio { self.dock_drag = Some(DockDrag::Move { panel, group, - window, origin: Some(origin), target, grab, @@ -430,30 +335,12 @@ impl OpenCADStudio { return; }; let dx = point.x - last.x; - let (w, h) = (if from_left { f.w - dx } else { f.w + dx }, f.h + point.y - last.y); - self.dock.resize_float(panel, w, h, from_left); - } - DockDrag::FloatSplit { panel, upper, lower } => { - let (Some(last), Some(f)) = (last, self.dock.float_rect(panel)) else { - return; - }; - let total: f32 = self - .dock_float_visible(f) - .iter() - .filter_map(|p| f.group.panels.iter().position(|q| q == p)) - .filter_map(|i| f.group.weights.get(i)) - .sum(); - let avail = f.h; - if avail > 0.0 && total > 0.0 { - let per_px = total / avail; - self.dock.shift_float_split( - panel, - upper, - lower, - (point.y - last.y) * per_px, - crate::ui::dock::GROUP_MIN_H * per_px, - ); - } + self.dock.resize_float( + panel, + if from_left { f.w - dx } else { f.w + dx }, + f.h + point.y - last.y, + from_left, + ); } DockDrag::LayerColumn => { let Some(last) = last else { return }; @@ -494,6 +381,8 @@ impl OpenCADStudio { } } + /// Bring `id` into view: show its group on its edge and, where it + /// auto-hides, keep it revealed until the pointer leaves. /// Arm a delayed reveal (`Some`) or hide (`None`); it applies when its /// timer reports back unless a newer hover superseded it. fn dock_hover_arm(&mut self, target: Option) -> iced::Task { @@ -511,8 +400,6 @@ impl OpenCADStudio { self.dock_hover_pending = None; } - /// Bring `id` into view: show its group on its edge and, where it - /// auto-hides, keep it revealed until the pointer leaves. pub(crate) fn dock_reveal(&mut self, id: crate::ui::dock::PanelId) { self.dock.show_group_of(id); self.dock_peek = Some(id); @@ -764,37 +651,11 @@ impl OpenCADStudio { if let Some(f) = self.dock.float_rect(id) { return (f.w, f.h); } - (self.dock.width(id, self.win_size.0), self.dock_default_float_h()) - } - - /// Height a pallet or group floats at first: a comfortable share of the - /// workspace height. - pub(crate) fn dock_default_float_h(&self) -> f32 { let (_, avail) = self.dock_workspace_size(); - (avail * 0.6).clamp(crate::ui::dock::FLOAT_MIN_H, 520.0) - } - - /// The pallets floating window `f` draws, top → bottom: its open ones, - /// less one being dragged out of it. - pub(crate) fn dock_float_visible( - &self, - f: &crate::ui::dock::FloatPanel, - ) -> Vec { - let pulled = match self.dock_drag { - Some(crate::ui::dock::DockDrag::Move { - panel, - window: false, - target: Some(_), - .. - }) => Some(panel), - _ => None, - }; - f.group - .panels - .iter() - .copied() - .filter(|p| self.dock_panel_visible(*p) && Some(*p) != pulled) - .collect() + ( + self.dock.width(id, self.win_size.0), + (avail * 0.6).clamp(crate::ui::dock::FLOAT_MIN_H, 520.0), + ) } /// Where a dragged pallet lands if released at workspace point `p`: diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index bea980338..91ba479ca 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -17,19 +17,16 @@ impl OpenCADStudio { workspace: Element<'a, Message>, tab: &'a DocumentTab, ) -> Element<'a, Message> { - use crate::ui::dock::DockMsg; // Floating panels sit over the workspace, back to front. let mut layers: Vec> = vec![workspace]; for f in &self.dock.floating { - // Once a window drag is under way only its preview shows it. - let dragged = matches!( - self.dock_drag, - Some(crate::ui::dock::DockDrag::Move { panel, window: true, target: Some(_), .. }) - if f.contains(panel) - ); - let visible = self.dock_float_visible(f); - if !visible.is_empty() && !dragged { - layers.push(self.floating_panel(f, visible, tab)); + // Once a drag is under way only its preview shows the panel. + let dragged = self + .dock_drag + .and_then(|d| d.movement()) + .is_some_and(|(panel, _, target)| panel == f.id && target.is_some()); + if self.dock_panel_visible(f.id) && !dragged { + layers.push(self.floating_panel(*f, tab)); } } // An open pallet menu hangs beside its edge's + button, over a @@ -54,19 +51,20 @@ impl OpenCADStudio { }; layers.push( mouse_area(Space::new().width(Fill).height(Fill)) - .on_press(Message::Dock(DockMsg::EdgeMenu(None))) - .on_right_press(Message::Dock(DockMsg::EdgeMenu(None))) + .on_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) + .on_right_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) .into(), ); - let toggle = move |id| Message::Dock(DockMsg::EdgeMenuToggle(side, id)); - layers.push(place_at(dock_pallet_menu(&checked, toggle), x.max(0.0), plus_top)); + layers.push(place_at(dock_edge_menu(side, &checked), x.max(0.0), plus_top)); } } + // An open floating title-bar menu, over its own click catcher. if let Some(menu) = self.dock_float_menu_overlay() { + let close = Message::Dock(crate::ui::dock::DockMsg::FloatMenu(None)); layers.push( mouse_area(Space::new().width(Fill).height(Fill)) - .on_press(Message::Dock(DockMsg::FloatMenu(None))) - .on_right_press(Message::Dock(DockMsg::FloatMenu(None))) + .on_press(close.clone()) + .on_right_press(close) .into(), ); layers.push(menu); @@ -288,7 +286,6 @@ impl OpenCADStudio { }), ) .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) - .on_double_click(Message::Dock(DockMsg::GroupFloat(side, gi))) .on_enter(Message::Dock(DockMsg::GripHover(side, gi))) .on_exit(Message::Dock(DockMsg::GripHoverEnd(side, gi))) .interaction(iced::mouse::Interaction::Grab); @@ -437,46 +434,19 @@ impl OpenCADStudio { } } - /// Where floating window `f` draws: its top-left corner, whether its - /// title bar is on the left, and whether it is hidden down to that bar - /// (auto-hide, not hovered). - fn dock_float_frame(&self, f: &crate::ui::dock::FloatPanel) -> (f32, f32, bool, bool) { - let (ww, wh) = self.dock_workspace_size(); - // The bar faces the nearer workspace edge; while resizing, it stays - // opposite the grip being dragged so it cannot flip mid-drag. - let bar_left = match self.dock_drag { - Some(crate::ui::dock::DockDrag::FloatSize { panel, from_left }) if f.contains(panel) => { - !from_left - } - _ => f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5, - }; - let hidden = self.dock.auto_hides(f.id()) && !self.dock_peek.is_some_and(|p| f.contains(p)); - let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; - // A hidden window keeps its title bar where it sits when shown. - let x = if hidden && !bar_left { f.x + f.w } else { f.x }; - // Keep the window on screen when the workspace shrank since it was - // placed. - let x = x.min(ww - total_w).max(0.0); - let y = f.y.min(wh - f.h).max(0.0); - (x, y, bar_left, hidden) - } - - /// A floating window at its saved place: a vertical title bar on the - /// side facing the nearer workspace edge, its open pallets (`visible`) - /// stacked beside it, and a corner grip to resize it. With its pin on, - /// the window hides down to the title bar until hovered. + /// A floating panel at its saved place: a vertical title bar on the side + /// facing the nearer workspace edge, the panel beside it, and a corner + /// grip to resize it. With its pin on, the panel hides down to the title + /// bar until hovered. fn floating_panel<'a>( &'a self, - f: &'a crate::ui::dock::FloatPanel, - visible: Vec, + f: crate::ui::dock::FloatPanel, tab: &'a DocumentTab, ) -> Element<'a, Message> { use crate::ui::dock::DockMsg; let (x, y, bar_left, hidden) = self.dock_float_frame(f); - // Messages name the window by its first open pallet. - let anchor = visible[0]; - let hovered = self.dock_title_hover.is_some_and(|p| f.contains(p)); - let bar = floating_title_bar(&visible, self.dock.auto_hides(anchor), bar_left, hovered); + let hovered = self.dock_title_hover == Some(f.id); + let bar = floating_title_bar(f.id, self.dock.auto_collapse(f.id), bar_left, hovered); let content: Element<'_, Message> = if hidden { bar } else { @@ -495,31 +465,14 @@ impl OpenCADStudio { .center_x(Length::Fixed(14.0)) .center_y(Length::Fixed(14.0)), ) - .on_press(Message::Dock(DockMsg::FloatResizeGrab(anchor, grip_left))) + .on_press(Message::Dock(DockMsg::FloatResizeGrab(f.id, grip_left))) .interaction(if grip_left { iced::mouse::Interaction::ResizingDiagonallyUp } else { iced::mouse::Interaction::ResizingDiagonallyDown }); - // The pallets stack like a docked group, with splitters between. - let mut pallets: Vec> = Vec::new(); - let mut prev: Option = None; - for id in &visible { - let pos = f.group.panels.iter().position(|p| p == id).unwrap_or(0); - if let Some(upper) = prev { - pallets.push(float_splitter(anchor, upper, pos, f.w)); - } - let weight = f.group.weights.get(pos).copied().unwrap_or(1.0); - pallets.push( - container(self.panel_body(*id, f.w, true, tab)) - .height(Length::FillPortion(crate::ui::dock::portion(weight))) - .clip(true) - .into(), - ); - prev = Some(pos); - } let body = stack![ - column(pallets).height(Fill), + self.panel_body(f.id, f.w, true, tab), container(grip) .width(Fill) .height(Fill) @@ -538,8 +491,8 @@ impl OpenCADStudio { row![body, bar].into() } }; - // Clipped, so a pallet squeezed below its content's height never - // draws outside the window. + // Clipped, so a panel squeezed below its content's height never + // draws outside its frame. let framed = container(content) .height(Length::Fixed(f.h)) .clip(true) @@ -556,25 +509,48 @@ impl OpenCADStudio { }, ..Default::default() }); - // The idle cursor marks the window as opaque to the pointer, so - // clicks on its empty areas don't fall through to the drawing below. + // 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(anchor))) - .on_enter(Message::Dock(DockMsg::Hover(anchor))) + .on_press(Message::Dock(DockMsg::FloatRaise(f.id))) + .on_enter(Message::Dock(DockMsg::Hover(f.id))) .on_exit(Message::Dock(DockMsg::HoverExit)) .interaction(iced::mouse::Interaction::Idle); place_at(panel.into(), x, y) } - /// The open right-click menu of a floating window: Pallets (a submenu - /// to add or hide pallets in the window), Allow docking, Dock left and - /// Dock right. It opens at the pointer, flipped to stay on screen. + /// Where floating panel `f` draws: its top-left corner, whether its + /// title bar is on the left, and whether it is hidden down to that bar + /// (auto-hide, not hovered). + fn dock_float_frame(&self, f: crate::ui::dock::FloatPanel) -> (f32, f32, bool, bool) { + let (ww, wh) = self.dock_workspace_size(); + // The bar faces the nearer workspace edge; while resizing, it stays + // opposite the grip being dragged so it cannot flip mid-drag. + let bar_left = match self.dock_drag { + Some(crate::ui::dock::DockDrag::FloatSize { panel, from_left }) if panel == f.id => { + !from_left + } + _ => f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5, + }; + let hidden = self.dock.auto_collapse(f.id) && self.dock_peek != Some(f.id); + let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; + // A hidden panel keeps its title bar where it sits when shown. + let x = if hidden && !bar_left { f.x + f.w } else { f.x }; + // Keep the panel on screen when the window shrank since it was placed. + let x = x.min(ww - total_w).max(0.0); + let y = f.y.min(wh - f.h).max(0.0); + (x, y, bar_left, hidden) + } + + /// The open right-click menu of a floating title bar: Allow docking, + /// Dock left and Dock right. It opens at the pointer, flipped to stay on + /// screen. fn dock_float_menu_overlay(&self) -> Option> { use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, PanelId}; - let anchor = self.dock_float_menu?; - let f = self.dock.float_rect(anchor)?; - if self.dock_float_visible(f).is_empty() { + use crate::ui::dock::DockMsg; + let id = self.dock_float_menu?; + let f = self.dock.float_rect(id)?; + if !self.dock_panel_visible(id) { return None; } let (ww, wh) = self.dock_workspace_size(); @@ -583,35 +559,17 @@ impl OpenCADStudio { let bar_x = if bar_left { x } else { x + total_w - DOCK_FLOAT_BAR_W }; // Where the right-click happened, in the workspace. let at = iced::Point::new(bar_x + self.dock_float_menu_at.x, y + self.dock_float_menu_at.y); - let max_x = (ww - DOCK_MENU_W).max(0.0); let menu_x = if at.x + DOCK_MENU_W <= ww { at.x } else { at.x - DOCK_MENU_W }; - let menu_x = menu_x.clamp(0.0, max_x); + let menu_x = menu_x.clamp(0.0, (ww - DOCK_MENU_W).max(0.0)); let menu_y = at.y.min(wh - FLOAT_MENU_H).max(0.0); - let open = self.dock_float_menu_pallets; - // Hovering a row opens (Pallets) or closes (the others) the submenu. - let item = |label: Element<'static, Message>, press: DockMsg, sub: bool| -> Element<'static, Message> { - mouse_area( - button(label) - .on_press(Message::Dock(press)) - .style(menu_row_style) - .padding([5, 8]) - .width(Fill), - ) - .on_enter(Message::Dock(DockMsg::FloatMenuPallets(sub))) - .into() + let item = |label: Element<'static, Message>, press: DockMsg| -> Element<'static, Message> { + button(label) + .on_press(Message::Dock(press)) + .style(menu_row_style) + .padding([5, 8]) + .width(Fill) + .into() }; - let pallets = row![ - text(t!("Pallets")).size(12).width(Fill), - text("\u{203A}").size(13), - ] - .align_y(iced::Center); - let separator = container(Space::new()) - .width(Fill) - .height(Length::Fixed(1.0)) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.neutral.color)), - ..Default::default() - }); let check: Element<'static, Message> = if f.docking { crate::ui::icons::themed(crate::ui::icons::CHECK, 12.0) } else { @@ -619,46 +577,22 @@ impl OpenCADStudio { }; let docking = row![text(t!("Allow docking")).size(12).width(Fill), check] .align_y(iced::Center); - let main = column![ - item(pallets.into(), DockMsg::FloatMenuPallets(!open), true), + let separator = container(Space::new()) + .width(Fill) + .height(Length::Fixed(1.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }); + let menu = column![ + item(docking.into(), DockMsg::FloatDockingToggle(id)), container(separator).padding([3, 4]), - item(docking.into(), DockMsg::FloatDockingToggle(anchor), false), - item( - text(t!("Dock left")).size(12).into(), - DockMsg::DockTo(anchor, DockSide::Left), - false - ), - item( - text(t!("Dock right")).size(12).into(), - DockMsg::DockTo(anchor, DockSide::Right), - false - ), + item(text(t!("Dock left")).size(12).into(), DockMsg::DockTo(id, DockSide::Left)), + item(text(t!("Dock right")).size(12).into(), DockMsg::DockTo(id, DockSide::Right)), ] .spacing(1) .padding(4); - let mut layers = vec![place_at(menu_frame(main.into()), menu_x, menu_y)]; - if open { - let checked: Vec<_> = PanelId::ALL - .iter() - .map(|p| (*p, f.contains(*p) && self.dock_panel_visible(*p))) - .collect(); - let toggle = move |id| Message::Dock(DockMsg::FloatMenuToggle(anchor, id)); - // To the right of the menu, or its left when there is no room. - let sub_x = if menu_x + 2.0 * DOCK_MENU_W + 2.0 <= ww { - menu_x + DOCK_MENU_W + 2.0 - } else { - menu_x - DOCK_MENU_W - 2.0 - } - .clamp(0.0, max_x); - let sub_y = menu_y.min(wh - PALLET_MENU_H).max(0.0); - layers.push(place_at(dock_pallet_menu(&checked, toggle), sub_x, sub_y)); - } - Some( - iced::widget::Stack::with_children(layers) - .width(Fill) - .height(Fill) - .into(), - ) + Some(place_at(menu_frame(menu.into()), menu_x, menu_y)) } /// The live drop preview for dragged panel `id`: a tint over the target @@ -695,32 +629,18 @@ impl OpenCADStudio { }; let mut layers: Vec> = Vec::new(); let group_drag = self.dock_drag.and_then(|d| d.movement()).and_then(|(_, g, _)| g); - // The pallets that move: a docked group's, a floating window's, or - // just `id`. - let window_drag = matches!( - self.dock_drag, - Some(crate::ui::dock::DockDrag::Move { window: true, .. }) - ); - let moving = match (group_drag, self.dock.float_rect(id)) { - (Some((s, g)), _) => self.dock_group_visible(s, g), - (None, Some(f)) if window_drag => self.dock_float_visible(f), - _ => vec![id], - }; let marks_side = match target { DropTarget::Edge { side, .. } | DropTarget::Join { side, .. } => Some(side), DropTarget::Float { x, y } => { - let (w, h) = match group_drag { - Some((s, g)) => (self.dock_group_width(s, g), self.dock_default_float_h()), - None => self.dock_float_size(id), - }; + let (w, h) = self.dock_float_size(id); layers.push(place_at(dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), x, y)); None } }; - // A single pallet also gets a ghost where it lands in the column; - // several show only the strip feedback, as the column keeps showing - // what is there. - if let (Some(side), None, [_]) = (marks_side, group_drag, moving.as_slice()) { + // A single pallet also gets a ghost where it lands in the column; a + // whole group shows only the strip feedback, as the column keeps + // showing what is there. + if let (Some(side), None) = (marks_side, group_drag) { if let Some((top, bottom, w)) = self.dock_landing(id, target) { let x = match side { DockSide::Left => strip, @@ -735,7 +655,11 @@ impl OpenCADStudio { if let Some(side) = marks_side { layers.extend(self.dock_strip_drop_marks(side, target)); if let Some(p) = self.dock_drag_last { - layers.push(place_at(dock_icon_ghost(&moving), p.x - 16.0, p.y - 15.0)); + let icons = match group_drag { + Some((s, g)) => self.dock_group_visible(s, g), + None => vec![id], + }; + layers.push(place_at(dock_icon_ghost(&icons), p.x - 16.0, p.y - 15.0)); } } iced::widget::Stack::with_children(layers) @@ -956,73 +880,53 @@ impl canvas::Program for VBarLabel { /// Width of a floating panel's vertical title bar. const DOCK_FLOAT_BAR_W: f32 = 28.0; -/// A floating window's vertical title bar: close then the hide (auto-hide) -/// pin at the top; at the bottom the pallet's icon with its title reading -/// upward, or for a group each pallet's icon (press one to drag that pallet -/// out). Pressing the bar drags the window, double-clicking docks it against -/// the edge the bar faces, right-clicking opens its menu. +/// A floating panel's vertical title bar: close then the hide (auto-collapse) +/// pin at the top, the panel's icon at the bottom with its title reading +/// upward above it. Pressing the bar's free area drags the panel. fn floating_title_bar( - visible: &[crate::ui::dock::PanelId], + id: crate::ui::dock::PanelId, auto_collapse: bool, on_left: bool, hovered: bool, ) -> Element<'static, Message> { use crate::ui::dock::DockMsg; - let anchor = visible[0]; let tip = if on_left { iced::widget::tooltip::Position::Left } else { iced::widget::tooltip::Position::Right }; + let title = canvas(VBarLabel { + text: t!(id.title()).into_owned(), + }) + .width(Fill) + .height(Fill); // Close and pin only show while the pointer is over the bar, as on a // docked title bar. let mut bar = column![].spacing(2); if hovered { bar = bar - .push(crate::ui::dock::close_button(DockMsg::FloatClose(anchor), tip)) - .push(crate::ui::dock::pin_button(anchor, auto_collapse, tip)); - } - if let [id] = visible { - let title = canvas(VBarLabel { - text: t!(id.title()).into_owned(), - }) - .width(Fill) - .height(Fill); - bar = bar - .push(container(title).width(Fill).height(Fill).padding([6, 0])) - .push(container(crate::ui::icons::themed(id.icon(), 16.0)).center_x(Fill)); - } else { - bar = bar.push(Space::new().height(Fill)); - for id in visible { - let icon = mouse_area( - container(crate::ui::icons::themed(id.icon(), 16.0)) - .center_x(Fill) - .center_y(Length::Fixed(24.0)), - ) - .on_press(Message::Dock(DockMsg::IconPress(*id))) - .interaction(iced::mouse::Interaction::Grab); - bar = bar.push( - iced::widget::tooltip(icon, text(t!(id.title())).size(10), tip).gap(4), - ); - } + .push(crate::ui::dock::close_button(id, tip)) + .push(crate::ui::dock::pin_button(id, auto_collapse, tip)); } let bar = bar - .padding([4, 0]) - .align_x(iced::Center) - .width(Length::Fixed(DOCK_FLOAT_BAR_W)) - .height(Fill); + .push(container(title).width(Fill).height(Fill).padding([6, 0])) + .push(container(crate::ui::icons::themed(id.icon(), 16.0)).center_x(Fill)) + .padding([4, 0]) + .align_x(iced::Center) + .width(Length::Fixed(DOCK_FLOAT_BAR_W)) + .height(Fill); mouse_area(container(bar).style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.weak.color)), ..Default::default() })) - .on_press(Message::Dock(DockMsg::FloatGrab(anchor))) - .on_right_press(Message::Dock(DockMsg::FloatMenu(Some(anchor)))) - .on_enter(Message::Dock(DockMsg::TitleHover(anchor))) + .on_press(Message::Dock(DockMsg::DockGrab(id))) + .on_right_press(Message::Dock(DockMsg::FloatMenu(Some(id)))) + .on_enter(Message::Dock(DockMsg::TitleHover(id))) .on_move(|p| Message::Dock(DockMsg::FloatBarPointer(p))) - .on_exit(Message::Dock(DockMsg::TitleHoverEnd(anchor))) + .on_exit(Message::Dock(DockMsg::TitleHoverEnd(id))) // Double-click docks it against the edge its title bar faces. .on_double_click(Message::Dock(DockMsg::DockTo( - anchor, + id, if on_left { crate::app::config::DockSide::Left } else { @@ -1033,29 +937,6 @@ fn floating_title_bar( .into() } -/// Draggable bar between floating pallets `upper` and `lower` of the window -/// holding `anchor`; double-click evens their heights. -fn float_splitter( - anchor: crate::ui::dock::PanelId, - upper: usize, - lower: usize, - w: f32, -) -> Element<'static, Message> { - use crate::ui::dock::DockMsg; - let line = container(Space::new()) - .width(Length::Fixed(w)) - .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(DockMsg::FloatSplitGrab(anchor, upper, lower))) - .on_double_click(Message::Dock(DockMsg::FloatSplitReset(anchor))) - .interaction(iced::mouse::Interaction::ResizingVertically) - .into() -} - /// Grabbable separator between a docked pallet and the viewport: drag to /// size its group, double-click to reset the width. fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { @@ -1078,19 +959,17 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { /// Width of the icon strip's pallet menu. const DOCK_MENU_W: f32 = 230.0; -/// Rough heights of a floating window's menu and of a pallet menu, to keep -/// them on screen. -const FLOAT_MENU_H: f32 = 150.0; -const PALLET_MENU_H: f32 = 270.0; +/// Rough height of a floating title bar's menu, to keep it on screen. +const FLOAT_MENU_H: f32 = 110.0; -/// A pallet menu (an edge's + button, a floating window's Pallets -/// submenu), styled like the right-click menu: one row per pallet with its -/// icon, its name and a check mark when it is open there. `checked` holds -/// each pallet with that state; picking one sends `toggle`. -fn dock_pallet_menu( +/// The pallet menu of an edge's + button, styled like the right-click menu: +/// one row per pallet with its icon, its name and a check mark when it is +/// open on this edge. `checked` holds each pallet with that state. +fn dock_edge_menu( + side: crate::app::config::DockSide, checked: &[(crate::ui::dock::PanelId, bool)], - toggle: impl Fn(crate::ui::dock::PanelId) -> Message, ) -> Element<'static, Message> { + use crate::ui::dock::DockMsg; let rows: Vec> = checked .iter() .map(|(id, checked)| { @@ -1109,7 +988,7 @@ fn dock_pallet_menu( .spacing(8) .align_y(iced::Center), ) - .on_press(toggle(id)) + .on_press(Message::Dock(DockMsg::EdgeMenuToggle(side, id))) .style(menu_row_style) .padding([5, 8]) .width(Fill) diff --git a/src/app/mod.rs b/src/app/mod.rs index bb4190dfe..adbfcb285 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -865,10 +865,8 @@ pub(super) struct OpenCADStudio { pub(crate) dock_title_hover: Option, /// Edge whose icon-strip pallet menu (+) is open. pub(crate) dock_edge_menu: Option, - /// Floating window (named by one of its pallets) whose right-click menu - /// is open, and whether its Pallets submenu shows. + /// Floating pallet whose title-bar right-click menu is open. pub(crate) dock_float_menu: Option, - pub(crate) dock_float_menu_pallets: bool, /// Pointer position over a floating title bar (bar-local), and where /// its open menu was asked for. pub(crate) dock_float_pointer: iced::Point, @@ -4306,7 +4304,6 @@ impl OpenCADStudio { dock_hover_pending: None, dock_hover_gen: 0, dock_float_menu: None, - dock_float_menu_pallets: false, dock_float_pointer: iced::Point::ORIGIN, dock_float_menu_at: iced::Point::ORIGIN, dock_drag: None, diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 91bb128bf..058177a4f 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -5759,7 +5759,6 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Dock left" => Some(("dock", "dock-left")), "Dock right" => Some(("dock", "dock-right")), "New group" => Some(("dock", "new-group")), - "Pallets" => Some(("dock", "pallets")), "Browser" => Some(("dock", "browser")), "Node Graph" => Some(("dock", "node-graph")), _ => None, diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 3b8c21621..6015b694e 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -65,35 +65,17 @@ pub enum DockMsg { GripHoverEnd(DockSide, usize), /// Double-click on a docked title bar: float the panel. FloatOut(PanelId), - /// Double-click on a group's edge band: float the whole group. - GroupFloat(DockSide, usize), /// Double-click on a floating title bar (or its menu's Dock left / - /// right): dock the window holding the panel on that side. + /// right): dock the panel on that side. DockTo(PanelId, DockSide), - /// Begin dragging the floating window holding `panel` by its title bar. - FloatGrab(PanelId), - /// Close every pallet of the floating window holding `panel`. - FloatClose(PanelId), - /// Begin dragging the splitter between pallets `upper` and `lower` of - /// the floating window holding `panel`. - FloatSplitGrab(PanelId, usize, usize), - /// Give every pallet of the floating window holding `panel` the same - /// height again. - FloatSplitReset(PanelId), - /// Open (`Some`) the right-click menu of the floating window holding the - /// pallet, or close it (`None`). + /// Open (`Some`) the right-click menu of a floating title bar, or close + /// it (`None`). FloatMenu(Option), - /// Show (`true`) or hide the floating menu's Pallets submenu. - FloatMenuPallets(bool), - /// Floating menu's Allow docking check: flip it for the window holding - /// the pallet. + /// Floating menu's Allow docking check: flip it for the panel. FloatDockingToggle(PanelId), /// The pointer moved over a floating title bar, at this point in it /// (where its right-click menu opens). FloatBarPointer(iced::Point), - /// Pallets submenu pick (window, pallet): hide the pallet when it shows - /// in the window, else add it to the window and open it. - FloatMenuToggle(PanelId, PanelId), /// Bring floating `panel` to the front. FloatRaise(PanelId), /// The pointer left the edge column; collapse any auto-collapsing panel. @@ -335,34 +317,27 @@ where Ok(groups) } -/// A floating window over the workspace, in workspace-local pixels: one -/// pallet, or a group of them stacked like a docked group. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(from = "FloatRepr")] +/// A panel floating over the workspace, in workspace-local pixels. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "FloatRepr")] pub struct FloatPanel { - /// Pallets top → bottom with their heights (`width` is unused: the - /// window has its own). - #[serde(flatten)] - pub group: DockGroup, + pub id: PanelId, pub x: f32, pub y: f32, pub w: f32, pub h: f32, - /// Dragging the window (or a pallet out of it) may dock it on an edge; - /// off, it only ever floats. + /// Dragging the panel may dock it on an edge; off, it only ever floats. pub docking: bool, } -/// On-disk form of a floating window. Configs from before floating groups -/// stored one pallet as `id`. +/// On-disk form of a floating panel. A short-lived build floated groups, +/// stored as `panels`: its first pallet keeps floating, so such a config +/// still loads (a config that fails to load falls back to all defaults). #[derive(Deserialize)] struct FloatRepr { - #[serde(default)] id: Option, #[serde(default)] panels: Vec, - #[serde(default)] - weights: Vec, x: f32, y: f32, w: f32, @@ -375,29 +350,23 @@ fn docking_default() -> bool { true } -impl From for FloatPanel { - fn from(r: FloatRepr) -> Self { - let mut group = DockGroup::stack(r.id.into_iter().chain(r.panels).collect()); - if !r.weights.is_empty() { - group.weights = r.weights; - } - group.heal(); - Self { - group, - x: r.x, - y: r.y, - w: r.w, - h: r.h, +impl TryFrom for FloatPanel { + type Error = &'static str; + + fn try_from(r: FloatRepr) -> Result { + let id = r.id.or(r.panels.first().copied()).ok_or("floating panel without id")?; + Ok(Self { docking: r.docking, - } + ..Self::new(id, r.x, r.y, r.w, r.h) + }) } } impl FloatPanel { - /// Pallet `id` floating on its own. + /// Panel `id` floating at `x`, `y`, sized `w` × `h`, free to dock. pub fn new(id: PanelId, x: f32, y: f32, w: f32, h: f32) -> Self { Self { - group: DockGroup::single(id), + id, x, y, w, @@ -406,33 +375,13 @@ impl FloatPanel { } } - /// The window's first pallet, which names it in messages. - pub fn id(&self) -> PanelId { - self.group.panels[0] - } - - pub fn contains(&self, id: PanelId) -> bool { - self.group.panels.contains(&id) - } - - /// The widest the window may be: the most any of its pallets allows. - fn max_width(&self) -> f32 { - self.group - .panels - .iter() - .map(|id| id.max_width()) - .fold(DOCK_MIN_W, f32::max) - } - /// Keep a placement read from disk usable: finite, on the workspace's - /// positive side, and at least the minimum size. The window holds at - /// least one pallet. + /// positive side, and at least the minimum size. fn heal(&mut self) { - self.group.heal(); let finite_or = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; self.x = finite_or(self.x, 0.0).max(0.0); self.y = finite_or(self.y, 0.0).max(0.0); - self.w = finite_or(self.w, self.id().default_width()).clamp(DOCK_MIN_W, self.max_width()); + self.w = finite_or(self.w, self.id.default_width()).clamp(DOCK_MIN_W, self.id.max_width()); self.h = finite_or(self.h, FLOAT_MIN_H).max(FLOAT_MIN_H); } } @@ -462,7 +411,7 @@ pub struct DockState { /// Auto-hide per edge (left, right): the edge shows only its icon strip /// until an icon is hovered. pub auto_hide: (bool, bool), - /// Floating windows, back → front. + /// Floating panels, back → front. pub floating: Vec, /// Per-panel settings, keyed by `PanelId`: the width a pallet floats at /// (and starts a new group with) and its floating auto-hide. @@ -552,18 +501,7 @@ impl DockState { let shown = self.shown(side).min(len.saturating_sub(1)); self.show_group(side, shown); } - for f in &mut self.floating { - f.group.heal(); - (f.group.panels, f.group.weights) = f - .group - .panels - .iter() - .zip(&f.group.weights) - .filter(|(id, _)| seen.insert(**id)) - .map(|(id, w)| (*id, *w)) - .unzip(); - } - self.floating.retain(|f| !f.group.panels.is_empty()); + self.floating.retain(|f| seen.insert(f.id)); self.floating.iter_mut().for_each(FloatPanel::heal); } @@ -577,13 +515,9 @@ impl DockState { None } - /// The floating window holding `id`, if it floats. - pub fn float_rect(&self, id: PanelId) -> Option<&FloatPanel> { - self.floating.iter().find(|f| f.contains(id)) - } - - fn float_index(&self, id: PanelId) -> Option { - self.floating.iter().position(|f| f.contains(id)) + /// 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 @@ -605,7 +539,7 @@ impl DockState { } /// The floating auto-hide flag of `id` (a docked pallet follows its - /// edge, a floating one its window; see [`Self::auto_hides`]). + /// edge instead; see [`Self::auto_hides`]). pub fn auto_collapse(&self, id: PanelId) -> bool { self.settings(id).auto_collapse } @@ -619,31 +553,22 @@ impl DockState { } /// Whether `id` auto-hides where it is: by its edge's setting when - /// docked, by its window's (its first pallet's flag) when floating. + /// docked, by its own when floating. pub fn auto_hides(&self, id: PanelId) -> bool { - match (self.location(id), self.float_rect(id)) { - (Some((side, _)), _) => self.edge_auto_hide(side), - (None, Some(f)) => self.auto_collapse(f.id()), - (None, None) => self.auto_collapse(id), + match self.location(id) { + Some((side, _)) => self.edge_auto_hide(side), + None => self.auto_collapse(id), } } - /// Flip auto-hide where `id` is: its whole edge when docked, its whole - /// window when floating. Returns the new state. + /// Flip auto-hide where `id` is: its whole edge when docked, the pallet + /// itself when floating. Returns the new state. pub fn toggle_auto_hide(&mut self, id: PanelId) -> bool { let on = !self.auto_hides(id); match self.location(id) { Some((DockSide::Left, _)) => self.auto_hide.0 = on, Some((DockSide::Right, _)) => self.auto_hide.1 = on, - None => { - let panels = match self.float_rect(id) { - Some(f) => f.group.panels.clone(), - None => vec![id], - }; - for p in panels { - self.set_auto_collapse(p, on); - } - } + None => self.set_auto_collapse(id, on), } on } @@ -725,13 +650,7 @@ impl DockState { /// and weight there, and whether the group disappeared (it held nothing /// else). fn detach(&mut self, id: PanelId) -> Option<(DockSide, usize, usize, f32, bool)> { - if let Some(i) = self.float_index(id) { - self.floating[i].group.remove(id); - if self.floating[i].group.panels.is_empty() { - self.floating.remove(i); - } - return None; - } + self.floating.retain(|f| f.id != id); let (side, gi) = self.location(id)?; let (pos, weight) = self.groups_mut(side)[gi].remove(id).expect("located"); if !self.groups(side)[gi].panels.is_empty() { @@ -829,127 +748,38 @@ impl DockState { true } - /// Float `rect`'s pallets in it, on top of the other floating windows. + /// Float `id` at `rect`, on top of the other floating panels. pub fn float(&mut self, rect: FloatPanel) -> bool { let before = self.clone(); - for id in &rect.group.panels { - self.detach(*id); - } + self.detach(rect.id); self.floating.push(rect); *self != before } - /// Float the whole group `group` of `side` as one window at `x`, `y` - /// sized `w` × `h`. Returns whether the layout changed. - pub fn float_group(&mut self, side: DockSide, group: usize, x: f32, y: f32, w: f32, h: f32) -> bool { - if group >= self.groups(side).len() { - return false; - } - let g = self.groups_mut(side).remove(group); - self.after_group_removed(side, group); - let mut f = FloatPanel { - group: g, - x, - y, - w, - h, - docking: true, - }; - f.heal(); - self.floating.push(f); - true - } - - /// Move the floating window holding `id` to `x`, `y`. - pub fn move_float(&mut self, id: PanelId, x: f32, y: f32) -> bool { - let Some(i) = self.float_index(id) else { - return false; - }; - let f = &mut self.floating[i]; - let changed = (f.x, f.y) != (x, y); - (f.x, f.y) = (x, y); - changed - } - - /// Dock the floating window holding `id` as a new group on `side` at - /// insertion `index`, keeping its pallets, their heights and its width, - /// and show it. A docked `id` docks alone (see [`Self::dock`]). - pub fn dock_window(&mut self, id: PanelId, side: DockSide, index: usize) -> bool { - let Some(i) = self.float_index(id) else { - return self.dock(id, side, index); - }; - let f = self.floating.remove(i); - let mut g = f.group; - g.width = f.w; - let groups = self.groups_mut(side); - let index = index.min(groups.len()); - groups.insert(index, g); - self.show_group(side, index); - true - } - - /// Stack every pallet of the floating window holding `id` into group - /// `group` on `side` from position `index` on, and show the group. A - /// docked `id` joins alone (see [`Self::join_group`]). - pub fn join_window(&mut self, id: PanelId, side: DockSide, group: usize, index: usize) -> bool { - let Some(i) = self.float_index(id) else { - return self.join_group(id, side, group, index); - }; - if group >= self.groups(side).len() { - return false; - } - let f = self.floating.remove(i); - let target = &mut self.groups_mut(side)[group]; - let weight = target.mean_weight(); - for (k, p) in f.group.panels.into_iter().enumerate() { - target.insert(index.saturating_add(k), p, weight); - } - self.show_group(side, group); - true - } - - /// Add pallet `id` to the bottom of the floating window holding `into`. - /// Returns whether the layout changed. - pub fn add_to_float(&mut self, id: PanelId, into: PanelId) -> bool { - match self.float_rect(into) { - Some(f) if !f.contains(id) => {} - _ => return false, - } - self.detach(id); - // Detaching may have emptied (and removed) another window. - let Some(i) = self.float_index(into) else { - return false; - }; - let g = &mut self.floating[i].group; - let weight = g.mean_weight(); - g.insert(usize::MAX, id, weight); - true - } - - /// Flip whether dragging the floating window holding `id` may dock it. + /// Flip whether dragging floating panel `id` may dock it. pub fn toggle_float_docking(&mut self, id: PanelId) -> bool { - let Some(i) = self.float_index(id) else { - return false; - }; - self.floating[i].docking = !self.floating[i].docking; - true + match self.floating.iter_mut().find(|f| f.id == id) { + Some(f) => { + f.docking = !f.docking; + true + } + None => false, + } } - /// Bring the floating window holding `id` to the front. + /// Bring a floating panel to the front. pub fn raise_float(&mut self, id: PanelId) { - if let Some(i) = self.float_index(id) { + if let Some(i) = self.floating.iter().position(|f| f.id == id) { let f = self.floating.remove(i); self.floating.push(f); } } - /// Resize the floating window holding `id`, keeping it at least a usable - /// size. With `keep_right` the right edge stays put (resizing from the - /// left corner). + /// Resize a floating panel, keeping it at least a usable size. With + /// `keep_right` the right edge stays put (resizing from the left corner). pub fn resize_float(&mut self, id: PanelId, w: f32, h: f32, keep_right: bool) { - if let Some(i) = self.float_index(id) { - let f = &mut self.floating[i]; - let new_w = w.clamp(DOCK_MIN_W, f.max_width()); + if let Some(f) = self.floating.iter_mut().find(|f| f.id == id) { + let new_w = w.clamp(DOCK_MIN_W, id.max_width()); if keep_right { f.x += f.w - new_w; } @@ -970,16 +800,18 @@ impl DockState { delta: f32, min_weight: f32, ) { - if let Some(g) = self.groups_mut(side).get_mut(group) { - shift_weights(&mut g.weights, upper, lower, delta, min_weight); - } - } - - /// [`Self::shift_split`] for the floating window holding `id`. - pub fn shift_float_split(&mut self, id: PanelId, upper: usize, lower: usize, delta: f32, min_weight: f32) { - if let Some(i) = self.float_index(id) { - shift_weights(&mut self.floating[i].group.weights, upper, lower, delta, min_weight); + let Some(g) = self.groups_mut(side).get_mut(group) else { + return; + }; + let w = &mut g.weights; + if upper >= w.len() || lower >= w.len() || upper == lower { + return; } + let total = w[upper] + w[lower]; + let min = min_weight.clamp(MIN_WEIGHT, total * 0.5); + let up = (w[upper] + delta).clamp(min, total - min); + w[upper] = up; + w[lower] = total - up; } /// Give every pallet of group `group` on `side` the same height again. @@ -988,26 +820,6 @@ impl DockState { g.weights.iter_mut().for_each(|w| *w = 1.0); } } - - /// [`Self::reset_splits`] for the floating window holding `id`. - pub fn reset_float_splits(&mut self, id: PanelId) { - if let Some(i) = self.float_index(id) { - self.floating[i].group.weights.iter_mut().for_each(|w| *w = 1.0); - } - } -} - -/// Move the boundary between stacked pallets `upper` and `lower` by `delta` -/// weight units, keeping their combined share and each above `min_weight`. -fn shift_weights(w: &mut [f32], upper: usize, lower: usize, delta: f32, min_weight: f32) { - if upper >= w.len() || lower >= w.len() || upper == lower { - return; - } - let total = w[upper] + w[lower]; - let min = min_weight.clamp(MIN_WEIGHT, total * 0.5); - let up = (w[upper] + delta).clamp(min, total - min); - w[upper] = up; - w[lower] = total - up; } /// What the pointer is dragging in the dock, if anything. @@ -1021,8 +833,6 @@ pub enum DockDrag { Move { panel: PanelId, group: Option<(DockSide, usize)>, - /// The whole floating window holding `panel` moves. - window: bool, origin: Option, target: Option, grab: iced::Vector, @@ -1039,9 +849,6 @@ pub enum DockDrag { /// Sizing floating `panel` from its bottom-left (`from_left`) or /// bottom-right corner. FloatSize { panel: PanelId, from_left: bool }, - /// Moving the splitter between pallets `upper` and `lower` of the - /// floating window holding `panel`. - FloatSplit { panel: PanelId, upper: usize, lower: usize }, /// Sizing the Layer Manager's Name column. LayerColumn, } @@ -1052,19 +859,6 @@ impl DockDrag { DockDrag::Move { panel, group, - window: false, - origin: None, - target: None, - grab: iced::Vector::new(0.0, 0.0), - } - } - - /// Start moving the floating window holding `panel`. - pub fn moving_window(panel: PanelId) -> Self { - DockDrag::Move { - panel, - group: None, - window: true, origin: None, target: None, grab: iced::Vector::new(0.0, 0.0), @@ -1265,7 +1059,7 @@ pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, if chrome.title_hovered { bar = bar .push(pin_button(id, chrome.auto_collapse, tooltip::Position::Bottom)) - .push(close_button(DockMsg::Close(id), tooltip::Position::Bottom)); + .push(close_button(id, tooltip::Position::Bottom)); } mouse_area( container(bar) @@ -1307,10 +1101,10 @@ pub fn pin_button<'a>(id: PanelId, auto_collapse: bool, tip: tooltip::Position) .into() } -/// The close button of a panel's title bar, sending `close`. -pub fn close_button<'a>(close: DockMsg, tip: tooltip::Position) -> Element<'a, Message> { +/// The close button of a panel's title bar. +pub fn close_button<'a>(id: PanelId, tip: tooltip::Position) -> Element<'a, Message> { let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0)) - .on_press(Message::Dock(close)) + .on_press(Message::Dock(DockMsg::Close(id))) .style(button::subtle) .padding([3, 5]); tooltip(close, text(crate::t!("Close")).size(10), tip) @@ -1676,10 +1470,10 @@ mod tests { fn float_detaches_and_dock_brings_it_back() { let mut state = DockState::default(); let rect = FloatPanel::new(PanelId::Properties, 100.0, 50.0, 250.0, 300.0); - assert!(state.float(rect.clone())); + 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_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()); @@ -1700,41 +1494,14 @@ mod tests { } #[test] - fn legacy_single_float_loads_as_a_one_pallet_window() { - let json = r#"{"floating":[{"id":"count","x":1.0,"y":2.0,"w":300.0,"h":400.0}]}"#; + fn floating_groups_from_an_older_build_load_as_their_first_pallet() { + let json = r#"{"floating":[{"panels":["count","browser"],"weights":[1.0,1.0],"width":0.0,"x":1.0,"y":2.0,"w":300.0,"h":400.0,"docking":false}]}"#; let state: DockState = serde_json::from_str(json).unwrap(); - assert_eq!(state.floating, vec![FloatPanel::new(PanelId::Count, 1.0, 2.0, 300.0, 400.0)]); - } - - #[test] - fn a_group_floats_docks_and_takes_pallets_as_one_window() { - let mut state = DockState::default(); - state.join_group(PanelId::Browser, DockSide::Left, 0, 1); - state.dock(PanelId::Count, DockSide::Left, 1); - // Floating group 0 takes both its pallets into one window. - assert!(state.float_group(DockSide::Left, 0, 10.0, 20.0, 300.0, 400.0)); - let f = state.float_rect(PanelId::Browser).unwrap(); - assert_eq!(f.group.panels, vec![PanelId::Properties, PanelId::Browser]); - assert_eq!(state.left.len(), 1); - // Adding a pallet stacks it at the bottom; floating one out leaves - // the rest together. - assert!(state.add_to_float(PanelId::Count, PanelId::Properties)); - assert!(state.left.is_empty()); - assert_eq!(state.float_rect(PanelId::Count).unwrap().group.panels.len(), 3); - state.float(FloatPanel::new(PanelId::Count, 0.0, 0.0, 250.0, 300.0)); - assert_eq!(state.floating.len(), 2); - assert_eq!(state.float_rect(PanelId::Properties).unwrap().group.panels.len(), 2); - // The window docks as one group with its width. - assert!(state.dock_window(PanelId::Browser, DockSide::Right, 0)); - assert_eq!(state.right[0].panels, vec![PanelId::Properties, PanelId::Browser]); - assert_eq!(state.right[0].width, 300.0); - // And a window joins a docked group as a whole. - assert!(state.join_window(PanelId::Count, DockSide::Right, 0, 1)); - assert_eq!( - state.right[0].panels, - vec![PanelId::Properties, PanelId::Count, PanelId::Browser] - ); - assert!(state.floating.is_empty()); + let expected = FloatPanel { + docking: false, + ..FloatPanel::new(PanelId::Count, 1.0, 2.0, 300.0, 400.0) + }; + assert_eq!(state.floating, vec![expected]); } #[test] From 02404301a575a4e32d25032702bb94020382e5c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 11:55:23 +0000 Subject: [PATCH 22/24] Dock: review cleanups - hover pending enum, FloatFrame, comment fixes - dock_hover_pending is an Option (Reveal / Hide) instead of a nested Option. - dock_float_frame returns a FloatFrame with width() and bar_x(), so the floating menu no longer repeats the frame-width formula. - Put dock_reveal's doc comment back on dock_reveal, and the group-move comment back above the match it describes. No behaviour change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/dock/update.rs | 30 +++++++++++---------- src/app/dock/view.rs | 59 ++++++++++++++++++++++++++++++++---------- src/app/mod.rs | 6 ++--- src/ui/dock.rs | 9 +++++++ 4 files changed, 75 insertions(+), 29 deletions(-) diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs index 9f0c6eec2..ad0b89cc8 100644 --- a/src/app/dock/update.rs +++ b/src/app/dock/update.rs @@ -30,7 +30,7 @@ impl OpenCADStudio { /// whichever panel the message names. pub(crate) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task { use crate::app::config::DockSide; - use crate::ui::dock::{DockDrag, DockMsg, DropTarget, FloatPanel}; + use crate::ui::dock::{DockDrag, DockMsg, DropTarget, FloatPanel, HoverPending}; match m { DockMsg::DockGrab(id) => self.dock_begin(DockDrag::moving(id, None), Some(id)), DockMsg::IconPress(id) => { @@ -160,7 +160,7 @@ impl OpenCADStudio { if self.dock_peek == Some(id) { self.dock_hover_cancel(); } else { - return self.dock_hover_arm(Some(id)); + return self.dock_hover_arm(HoverPending::Reveal(id)); } } } @@ -173,21 +173,25 @@ impl OpenCADStudio { if self.dock_drag.is_none() && self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) { - return self.dock_hover_arm(None); + return self.dock_hover_arm(HoverPending::Hide); } // Left before a pending reveal ran out: it never happens. self.dock_hover_cancel(); } DockMsg::HoverStay => { - if self.dock_hover_pending == Some(None) { + if self.dock_hover_pending == Some(HoverPending::Hide) { self.dock_hover_cancel(); } } DockMsg::HoverSettled(gen) => { if gen == self.dock_hover_gen && self.dock_drag.is_none() { match self.dock_hover_pending.take() { - Some(Some(id)) if self.dock.auto_hides(id) => self.dock_reveal(id), - Some(None) if self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) => { + Some(HoverPending::Reveal(id)) if self.dock.auto_hides(id) => { + self.dock_reveal(id) + } + Some(HoverPending::Hide) + if self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) => + { self.dock_peek = None; } _ => {} @@ -280,13 +284,13 @@ impl OpenCADStudio { grab }; let target = if started { - // A whole group only moves between group positions: it - // cannot join another group or float. // A floating panel that may not dock only floats. let found = match self.dock.float_rect(panel) { Some(f) if !f.docking => self.dock_float_target(point, grab), _ => self.dock_drop_target(point, grab), }; + // A whole group only moves between group positions: it + // cannot join another group or float. match (group, found) { (Some(_), found @ DropTarget::Edge { .. }) | (None, found) => Some(found), (Some(_), _) => None, @@ -381,11 +385,9 @@ impl OpenCADStudio { } } - /// Bring `id` into view: show its group on its edge and, where it - /// auto-hides, keep it revealed until the pointer leaves. - /// Arm a delayed reveal (`Some`) or hide (`None`); it applies when its - /// timer reports back unless a newer hover superseded it. - fn dock_hover_arm(&mut self, target: Option) -> iced::Task { + /// Arm a delayed reveal or hide; it applies when its timer reports back + /// unless a newer hover superseded it. + fn dock_hover_arm(&mut self, target: crate::ui::dock::HoverPending) -> iced::Task { self.dock_hover_gen = self.dock_hover_gen.wrapping_add(1); self.dock_hover_pending = Some(target); let gen = self.dock_hover_gen; @@ -400,6 +402,8 @@ impl OpenCADStudio { self.dock_hover_pending = None; } + /// Bring `id` into view: show its group on its edge and, where it + /// auto-hides, keep it revealed until the pointer leaves. pub(crate) fn dock_reveal(&mut self, id: crate::ui::dock::PanelId) { self.dock.show_group_of(id); self.dock_peek = Some(id); diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index 91ba479ca..bd5b01864 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -444,7 +444,7 @@ impl OpenCADStudio { tab: &'a DocumentTab, ) -> Element<'a, Message> { use crate::ui::dock::DockMsg; - let (x, y, bar_left, hidden) = self.dock_float_frame(f); + let FloatFrame { x, y, bar_left, hidden } = self.dock_float_frame(f); let hovered = self.dock_title_hover == Some(f.id); let bar = floating_title_bar(f.id, self.dock.auto_collapse(f.id), bar_left, hovered); let content: Element<'_, Message> = if hidden { @@ -519,10 +519,8 @@ impl OpenCADStudio { place_at(panel.into(), x, y) } - /// Where floating panel `f` draws: its top-left corner, whether its - /// title bar is on the left, and whether it is hidden down to that bar - /// (auto-hide, not hovered). - fn dock_float_frame(&self, f: crate::ui::dock::FloatPanel) -> (f32, f32, bool, bool) { + /// Where floating panel `f` draws. + fn dock_float_frame(&self, f: crate::ui::dock::FloatPanel) -> FloatFrame { let (ww, wh) = self.dock_workspace_size(); // The bar faces the nearer workspace edge; while resizing, it stays // opposite the grip being dragged so it cannot flip mid-drag. @@ -533,13 +531,15 @@ impl OpenCADStudio { _ => f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5, }; let hidden = self.dock.auto_collapse(f.id) && self.dock_peek != Some(f.id); - let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; // A hidden panel keeps its title bar where it sits when shown. let x = if hidden && !bar_left { f.x + f.w } else { f.x }; + let frame = FloatFrame { x, y: f.y, bar_left, hidden }; // Keep the panel on screen when the window shrank since it was placed. - let x = x.min(ww - total_w).max(0.0); - let y = f.y.min(wh - f.h).max(0.0); - (x, y, bar_left, hidden) + FloatFrame { + x: x.min(ww - frame.width(f)).max(0.0), + y: f.y.min(wh - f.h).max(0.0), + ..frame + } } /// The open right-click menu of a floating title bar: Allow docking, @@ -554,11 +554,12 @@ impl OpenCADStudio { return None; } let (ww, wh) = self.dock_workspace_size(); - let (x, y, bar_left, hidden) = self.dock_float_frame(f); - let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; - let bar_x = if bar_left { x } else { x + total_w - DOCK_FLOAT_BAR_W }; + let frame = self.dock_float_frame(f); // Where the right-click happened, in the workspace. - let at = iced::Point::new(bar_x + self.dock_float_menu_at.x, y + self.dock_float_menu_at.y); + let at = iced::Point::new( + frame.bar_x(f) + self.dock_float_menu_at.x, + frame.y + self.dock_float_menu_at.y, + ); let menu_x = if at.x + DOCK_MENU_W <= ww { at.x } else { at.x - DOCK_MENU_W }; let menu_x = menu_x.clamp(0.0, (ww - DOCK_MENU_W).max(0.0)); let menu_y = at.y.min(wh - FLOAT_MENU_H).max(0.0); @@ -880,6 +881,38 @@ impl canvas::Program for VBarLabel { /// Width of a floating panel's vertical title bar. const DOCK_FLOAT_BAR_W: f32 = 28.0; +/// Where a floating panel draws: its top-left corner, whether its title bar +/// is on the left, and whether it is hidden down to that bar (auto-hide, not +/// hovered). +#[derive(Debug, Clone, Copy)] +struct FloatFrame { + x: f32, + y: f32, + bar_left: bool, + hidden: bool, +} + +impl FloatFrame { + /// On-screen width of panel `f` in this frame: the bar, plus the panel + /// unless hidden. + fn width(&self, f: crate::ui::dock::FloatPanel) -> f32 { + if self.hidden { + DOCK_FLOAT_BAR_W + } else { + f.w + DOCK_FLOAT_BAR_W + } + } + + /// Left edge of the title bar. + fn bar_x(&self, f: crate::ui::dock::FloatPanel) -> f32 { + if self.bar_left { + self.x + } else { + self.x + self.width(f) - DOCK_FLOAT_BAR_W + } + } +} + /// A floating panel's vertical title bar: close then the hide (auto-collapse) /// pin at the top, the panel's icon at the bottom with its title reading /// upward above it. Pressing the bar's free area drags the panel. diff --git a/src/app/mod.rs b/src/app/mod.rs index adbfcb285..bb5afd86f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -850,9 +850,9 @@ pub(super) struct OpenCADStudio { /// floating frame): hovered, being dragged, or just opened. Cleared when /// the pointer leaves. pub(crate) dock_peek: Option, - /// Reveal (`Some(pallet)`) or hide (`None`) waiting out the hover delay, - /// with the generation its timer carries; any newer hover supersedes it. - pub(crate) dock_hover_pending: Option>, + /// Reveal or hide waiting out the hover delay, with the generation its + /// timer carries; any newer hover supersedes it. + pub(crate) dock_hover_pending: Option, pub(crate) dock_hover_gen: u64, /// What the pointer is dragging in the dock (pallet, group, width, /// splitter, floating size, Layer Manager column). diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 6015b694e..5ef7d1986 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -86,6 +86,15 @@ pub enum DockMsg { HoverSettled(u64), } +/// An auto-hide change waiting out [`DOCK_HOVER_DELAY`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HoverPending { + /// Reveal this pallet (its group, or its floating frame). + Reveal(PanelId), + /// Hide whatever auto-hiding pallet is revealed. + Hide, +} + /// How long the pointer must rest before an auto-hiding group reveals or /// hides, so passing over the strip doesn't flash groups open. pub const DOCK_HOVER_DELAY: std::time::Duration = std::time::Duration::from_millis(150); From cc17d10ca92343b6ddfc3dd921b83f2413544a46 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:34:02 +0000 Subject: [PATCH 23/24] Dock: drop dead pallet-width setters, split on_dock, share span maths - Only docked pallets have a width divider, so the Width drag and WidthReset no longer carry a branch for an undocked pallet; remove the then unused DockState::set_width / reset_width. Per-pallet widths are still read (older configs, floating size). Tests now size groups. - on_dock hands hover messages to dock_hover and the release to dock_drag_release. - dock_slot_spans and dock_landing share dock_open_spans. No behaviour change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/dock/tests.rs | 2 +- src/app/dock/update.rs | 155 ++++++++++++++++++++++++----------------- src/app/dock/view.rs | 11 ++- src/ui/dock.rs | 50 +++++-------- 4 files changed, 111 insertions(+), 107 deletions(-) diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 3f97ec975..8d93c89ce 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -186,7 +186,7 @@ fn dock_icon_press_switches_the_shown_group() { assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]); // Each group keeps its own width. - app.dock.set_width(PanelId::Browser, 400.0); + app.dock.set_group_width(DockSide::Left, 1, 400.0); assert_eq!(app.dock_column_width(DockSide::Left), 400.0); let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette)); let _ = app.on_dock(DockMsg::DragRelease); diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs index ad0b89cc8..15194a7bf 100644 --- a/src/app/dock/update.rs +++ b/src/app/dock/update.rs @@ -30,7 +30,7 @@ impl OpenCADStudio { /// whichever panel the message names. pub(crate) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task { use crate::app::config::DockSide; - use crate::ui::dock::{DockDrag, DockMsg, DropTarget, FloatPanel, HoverPending}; + use crate::ui::dock::{DockDrag, DockMsg, FloatPanel}; match m { DockMsg::DockGrab(id) => self.dock_begin(DockDrag::moving(id, None), Some(id)), DockMsg::IconPress(id) => { @@ -66,12 +66,11 @@ impl OpenCADStudio { self.save_config(); } DockMsg::WidthReset(id) => { + // Only docked pallets have the divider that sends this. if let Some((side, gi)) = self.dock.location(id) { self.dock.reset_group_width(side, gi); - } else { - self.dock.reset_width(id); + self.save_config(); } - self.save_config(); } DockMsg::TitleHover(id) => self.dock_title_hover = Some(id), DockMsg::TitleHoverEnd(id) => { @@ -150,6 +149,22 @@ impl OpenCADStudio { self.save_config(); } DockMsg::Close(id) => return self.dock_set_open(id, false), + DockMsg::Hover(_) + | DockMsg::HoverEnd(_) + | DockMsg::HoverExit + | DockMsg::HoverStay + | DockMsg::HoverSettled(_) => return self.dock_hover(m), + DockMsg::DragMove(point) => self.dock_drag_move(point), + DockMsg::DragRelease => self.dock_drag_release(), + } + iced::Task::none() + } + + /// Pointer entering / leaving strip icons, edge columns and floating + /// frames, and the delayed auto-hide reveal / hide that follows. + fn dock_hover(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task { + use crate::ui::dock::{DockMsg, HoverPending}; + match m { DockMsg::Hover(id) => { self.dock_icon_hover = Some(id); // Only an auto-hiding edge (or floating pallet) reacts to @@ -198,54 +213,57 @@ impl OpenCADStudio { } } } - DockMsg::DragMove(point) => self.dock_drag_move(point), - DockMsg::DragRelease => { - let mut changed = false; - match self.dock_drag.take() { - Some(DockDrag::Move { - group: Some((side, gi)), - target: Some(DropTarget::Edge { side: to, index }), - .. - }) => changed = self.dock.move_group(side, gi, to, index), - Some(DockDrag::Move { - panel, - group: None, - target: Some(target), - .. - }) => { - changed = match target { - DropTarget::Edge { side, index } => self.dock.dock(panel, side, index), - DropTarget::Join { side, group, index } => { - self.dock.join_group(panel, side, group, index) - } - DropTarget::Float { x, y } => { - let (w, h) = self.dock_float_size(panel); - // A floating panel keeps its docking choice. - let docking = self.dock.float_rect(panel).is_none_or(|f| f.docking); - self.dock.float(FloatPanel { - docking, - ..FloatPanel::new(panel, x, y, w, h) - }) - } - }; - } - Some(DockDrag::Move { .. } | DockDrag::LayerColumn) | None => {} - Some(DockDrag::Width(_) | DockDrag::Split { .. } | DockDrag::FloatSize { .. }) => { - changed = true; - } - } - if changed { - self.save_config(); - } - self.dock_drag_last = None; - self.xref_col_drag = None; - self.xref_col_last = None; - self.xref_split_drag = false; - } + _ => {} } iced::Task::none() } + /// Pointer released: land what was dragged and save a changed layout. + fn dock_drag_release(&mut self) { + use crate::ui::dock::{DockDrag, DropTarget, FloatPanel}; + let mut changed = false; + match self.dock_drag.take() { + Some(DockDrag::Move { + group: Some((side, gi)), + target: Some(DropTarget::Edge { side: to, index }), + .. + }) => changed = self.dock.move_group(side, gi, to, index), + Some(DockDrag::Move { + panel, + group: None, + target: Some(target), + .. + }) => { + changed = match target { + DropTarget::Edge { side, index } => self.dock.dock(panel, side, index), + DropTarget::Join { side, group, index } => { + self.dock.join_group(panel, side, group, index) + } + DropTarget::Float { x, y } => { + let (w, h) = self.dock_float_size(panel); + // A floating panel keeps its docking choice. + let docking = self.dock.float_rect(panel).is_none_or(|f| f.docking); + self.dock.float(FloatPanel { + docking, + ..FloatPanel::new(panel, x, y, w, h) + }) + } + }; + } + Some(DockDrag::Move { .. } | DockDrag::LayerColumn) | None => {} + Some(DockDrag::Width(_) | DockDrag::Split { .. } | DockDrag::FloatSize { .. }) => { + changed = true; + } + } + if changed { + self.save_config(); + } + self.dock_drag_last = None; + self.xref_col_drag = None; + self.xref_col_last = None; + self.xref_split_drag = false; + } + /// Pointer motion while something in the dock is dragged. fn dock_drag_move(&mut self, point: iced::Point) { // NOTE: xref column drags intentionally do NOT ride this path: its @@ -353,18 +371,12 @@ impl OpenCADStudio { } DockDrag::Width(id) => { let Some(last) = last else { return }; - let dx = point.x - last.x; - match self.dock.location(id) { - // The divider sizes the panel's group. - Some((side, gi)) => { - let delta = if side == DockSide::Left { dx } else { -dx }; - let cur = self.dock_group_width(side, gi) + delta; - self.dock.set_group_width(side, gi, cur); - } - None => { - let cur = self.dock.settings(id).width + dx; - self.dock.set_width(id, cur); - } + // The divider sizes the docked pallet's group. + if let Some((side, gi)) = self.dock.location(id) { + let dx = point.x - last.x; + let delta = if side == DockSide::Left { dx } else { -dx }; + let cur = self.dock_group_width(side, gi) + delta; + self.dock.set_group_width(side, gi, cur); } } } @@ -634,13 +646,26 @@ impl OpenCADStudio { &self, side: crate::app::config::DockSide, ) -> Vec<(usize, f32, f32)> { - let Some(gi) = self.dock_shown_group(side) else { - return Vec::new(); - }; + match self.dock_shown_group(side) { + Some(gi) => self.dock_open_spans(&self.dock.groups(side)[gi], None), + None => Vec::new(), + } + } + + /// The open pallets of `group` (plus `extra`, a pallet about to land + /// there), each as (position in the group, top, bottom) down the + /// workspace height. + pub(crate) fn dock_open_spans( + &self, + group: &crate::ui::dock::DockGroup, + extra: Option, + ) -> Vec<(usize, f32, f32)> { let (_, avail) = self.dock_workspace_size(); - let group = &self.dock.groups(side)[gi]; let open: Vec = (0..group.panels.len()) - .filter(|i| self.dock_panel_visible(group.panels[*i])) + .filter(|i| { + let id = group.panels[*i]; + Some(id) == extra || self.dock_panel_visible(id) + }) .collect(); let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); open.into_iter() diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index bd5b01864..c9ad9627d 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -690,13 +690,10 @@ impl OpenCADStudio { } let (side, gi) = after.location(id)?; let group = &after.groups(side)[gi]; - let open: Vec = (0..group.panels.len()) - .filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i])) - .collect(); - let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); - let at = open.iter().position(|i| group.panels[*i] == id)?; - let (_, avail) = self.dock_workspace_size(); - let (top, bottom) = *crate::ui::dock::slot_spans(&weights, avail).get(at)?; + let (_, top, bottom) = self + .dock_open_spans(group, Some(id)) + .into_iter() + .find(|(i, _, _)| group.panels[*i] == id)?; Some((top, bottom, after.group_width_px(side, gi, self.win_size.0))) } diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 5ef7d1986..66b342ec1 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -18,9 +18,9 @@ use std::collections::BTreeMap; pub enum DockMsg { /// Begin dragging `panel` to another side / position. DockGrab(PanelId), - /// Begin resizing `panel`'s width. + /// Begin resizing the width of docked `panel`'s group. ResizeGrab(PanelId), - /// Reset `panel`'s width to its default. + /// Reset the width of docked `panel`'s group to its default. WidthReset(PanelId), /// Toggle `panel`'s auto-collapse (pin) behavior. AutoCollapseToggle(PanelId), @@ -582,18 +582,6 @@ impl DockState { on } - /// 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; @@ -846,7 +834,7 @@ pub enum DockDrag { target: Option, grab: iced::Vector, }, - /// Sizing the width of `panel`'s group (or of `panel`, floating). + /// Sizing the width of docked `panel`'s group. Width(PanelId), /// Moving the splitter between pallets `upper` and `lower` of a group. Split { @@ -1192,11 +1180,12 @@ mod tests { // 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); + state.dock(PanelId::ExternalReferences, DockSide::Right, 1); + state.set_group_width(DockSide::Right, 1, 5000.0); + assert_eq!(state.group_width_px(DockSide::Right, 1, 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); + state.set_group_width(DockSide::Right, 0, 5000.0); + assert_eq!(state.group_width_px(DockSide::Right, 0, 3000.0), DOCK_MAX_W); } #[test] @@ -1247,32 +1236,23 @@ mod tests { 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); + state.set_group_width(DockSide::Right, 0, 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)); + assert_eq!(state.group_width_px(DockSide::Right, 0, 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); + state.set_group_width(DockSide::Right, 0, 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); + assert_eq!(state.group_width_px(DockSide::Right, 0, 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); + assert_eq!(state.group_width_px(DockSide::Right, 0, 100.0), DOCK_MIN_W); } #[test] @@ -1303,7 +1283,7 @@ mod tests { #[test] fn join_group_stacks_and_shows_the_group() { let mut state = DockState::default(); - state.set_width(PanelId::Properties, 320.0); + state.set_group_width(DockSide::Left, 0, 320.0); // Below Properties in the left group. assert!(state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1)); assert!(state.right.is_empty(), "its old group disappears"); @@ -1412,6 +1392,8 @@ mod tests { assert_eq!(state.group_width(DockSide::Right, 0), 260.0); state.set_group_width(DockSide::Left, 0, 5000.0); assert_eq!(state.group_width(DockSide::Left, 0), DOCK_MAX_W); + state.set_group_width(DockSide::Left, 0, 10.0); + assert_eq!(state.group_width(DockSide::Left, 0), DOCK_MIN_W); state.reset_group_width(DockSide::Left, 0); assert_eq!(state.group_width(DockSide::Left, 0), 250.0); } From bf1c4ddc746a1f1d1404c68cab71fb806d966f40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:29:37 +0000 Subject: [PATCH 24/24] style: apply cargo fmt across workspace (as on main) Formats the branch the way main's 9cf9ff2 did, so merging main only conflicts where the code itself differs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- benches/command_benchmarks.rs | 36 +- benches/performance_benchmarks.rs | 80 +- build.rs | 8 +- crates/dwg-thumbnailer-win/src/lib.rs | 18 +- crates/dwg-thumbnailer/src/lib.rs | 22 +- crates/ocs_plugin_api/build.rs | 74 +- crates/ocs_plugin_api/src/entity_coverage.rs | 795 ++- .../src/entity_coverage_types.rs | 1 - crates/ocs_plugin_api/src/host.rs | 234 +- crates/ocs_plugin_api/src/ipc/client.rs | 109 +- crates/ocs_plugin_api/src/ipc/mod.rs | 4 +- crates/ocs_plugin_api/src/ipc/protocol.rs | 53 +- crates/ocs_plugin_api/src/ipc/proxy.rs | 22 +- crates/ocs_plugin_api/src/ipc/server.rs | 8 +- crates/ocs_plugin_api/src/ipc/v4/client.rs | 127 +- crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 243 +- crates/ocs_plugin_api/src/ipc/v4/server.rs | 5 +- crates/ocs_plugin_api/src/lib.rs | 14 +- crates/ocs_plugin_api/src/process.rs | 65 +- crates/ocs_plugin_api/src/process/manager.rs | 6 +- crates/ocs_plugin_api/src/process/v4.rs | 158 +- crates/ocs_plugin_api/src/ribbon/owned.rs | 119 +- crates/ocs_plugin_api/src/runner.rs | 42 +- crates/ocs_plugin_api/src/version_info.rs | 17 +- crates/ocs_web_worker/src/lib.rs | 38 +- src/app/alias.rs | 29 +- src/app/annotation_data.rs | 683 +- src/app/automation.rs | 316 +- src/app/command_driver/clipboard.rs | 75 +- src/app/command_driver/constraint.rs | 263 +- src/app/command_driver/dim_edit.rs | 20 +- src/app/command_driver/entity_commit.rs | 318 +- src/app/command_driver/mod.rs | 131 +- src/app/command_driver/modify.rs | 156 +- src/app/command_driver/parameters.rs | 48 +- src/app/command_driver/property_match.rs | 2 - src/app/command_driver/solid3d.rs | 200 +- src/app/command_driver/tests.rs | 48 +- src/app/command_driver/text_edit.rs | 13 +- src/app/command_driver/transform.rs | 60 +- src/app/command_driver/utilities.rs | 71 +- src/app/command_driver/viewport.rs | 44 +- src/app/commands/attdef.rs | 29 +- src/app/commands/blocks.rs | 95 +- src/app/commands/blockvars.rs | 9 +- src/app/commands/count.rs | 273 +- src/app/commands/dim.rs | 165 +- src/app/commands/display.rs | 569 +- src/app/commands/draw.rs | 134 +- src/app/commands/field.rs | 106 +- src/app/commands/fileops.rs | 228 +- src/app/commands/inquiry.rs | 589 +- src/app/commands/layerprops.rs | 625 +- src/app/commands/layers.rs | 168 +- src/app/commands/mod.rs | 55 +- src/app/commands/pc_colormap.rs | 149 +- src/app/commands/pc_extract.rs | 58 +- src/app/commands/pdf_dialogs.rs | 152 +- src/app/commands/pdf_import.rs | 27 +- src/app/commands/pdf_underlay.rs | 156 +- src/app/commands/plotvars.rs | 11 +- src/app/commands/point_cloud.rs | 100 +- src/app/commands/sheet_set.rs | 948 ++- src/app/commands/styleprops.rs | 1318 ++-- src/app/commands/view.rs | 1519 +++-- src/app/commands/xclip.rs | 16 +- src/app/commands/xref_attach.rs | 76 +- src/app/config.rs | 26 +- src/app/control/actions.rs | 393 +- src/app/control/entities.rs | 41 +- src/app/control/http_bridge.rs | 53 +- src/app/control/interactive.rs | 42 +- src/app/control/mod.rs | 192 +- src/app/control/p1_tests.rs | 83 +- src/app/control/sheets.rs | 100 +- src/app/control/spellcheck.rs | 59 +- src/app/control/text_ops.rs | 70 +- src/app/control/vision.rs | 54 +- src/app/dock/tests.rs | 97 +- src/app/dock/update.rs | 26 +- src/app/dock/view.rs | 158 +- src/app/document.rs | 3 +- src/app/expr_eval.rs | 76 +- src/app/find_replace.rs | 68 +- src/app/history.rs | 26 +- src/app/layers.rs | 45 +- src/app/mod.rs | 16 +- src/app/model_ops.rs | 320 +- src/app/mtext_editor.rs | 265 +- src/app/navigation.rs | 3 +- src/app/node_graph.rs | 67 +- src/app/options_session.rs | 12 +- src/app/plugin_host.rs | 5823 ++++++++++++---- src/app/presspull_ops.rs | 203 +- src/app/properties.rs | 726 +- src/app/record_api.rs | 6 +- src/app/settings.rs | 33 +- src/app/shortcuts.rs | 34 +- src/app/startup.rs | 13 +- src/app/style_ops.rs | 172 +- src/app/text_inline.rs | 23 +- src/app/tolerance_dialog.rs | 33 +- src/app/update/blocks_palette.rs | 119 +- src/app/update/command.rs | 6021 ++++++++--------- src/app/update/context_menu.rs | 169 +- src/app/update/dialog.rs | 621 +- src/app/update/dynamic.rs | 84 +- src/app/update/file.rs | 585 +- src/app/update/mod.rs | 211 +- src/app/update/page_setup_import.rs | 52 +- src/app/update/style.rs | 1144 ++-- src/app/update/util.rs | 3 +- src/app/update/viewport.rs | 815 ++- src/app/update/viewport_snap.rs | 3 +- src/app/view/controls.rs | 24 +- src/app/view/mod.rs | 79 +- src/app/view/modal.rs | 1561 +++-- src/app/view/overlay.rs | 405 +- src/app/view/viewcube.rs | 44 +- src/app/viewport_dimension_tests.rs | 36 +- src/discussions.rs | 19 +- src/entities/arc.rs | 38 +- src/entities/attribute.rs | 223 +- src/entities/circle.rs | 41 +- src/entities/common.rs | 41 +- src/entities/curve.rs | 56 +- src/entities/dim_override.rs | 59 +- src/entities/dimension.rs | 1059 +-- src/entities/ellipse.rs | 64 +- src/entities/extended.rs | 807 ++- src/entities/field.rs | 142 +- src/entities/hatch.rs | 127 +- src/entities/helix.rs | 114 +- src/entities/insert.rs | 85 +- src/entities/leader.rs | 59 +- src/entities/light.rs | 43 +- src/entities/line.rs | 85 +- src/entities/lwpolyline.rs | 244 +- src/entities/match_props.rs | 4 +- src/entities/mesh.rs | 281 +- src/entities/mline.rs | 179 +- src/entities/mod.rs | 2 +- src/entities/mtext.rs | 55 +- src/entities/multileader.rs | 452 +- src/entities/names.rs | 22 +- src/entities/object_data.rs | 54 +- src/entities/point.rs | 18 +- src/entities/polyline.rs | 211 +- src/entities/raster_image.rs | 190 +- src/entities/ray.rs | 104 +- src/entities/shape.rs | 36 +- src/entities/solid.rs | 49 +- src/entities/solid3d.rs | 282 +- src/entities/spline.rs | 117 +- src/entities/table.rs | 1593 ++--- src/entities/text.rs | 53 +- src/entities/text_support.rs | 245 +- src/entities/tolerance.rs | 128 +- src/entities/traits.rs | 42 +- src/entities/underlay.rs | 73 +- src/entities/view_symbol.rs | 78 +- src/entities/viewport.rs | 5 +- src/gpu_backend.rs | 36 +- src/i18n.rs | 4 +- src/io/edit_lock.rs | 2 +- src/io/file_association.rs | 40 +- src/io/font_repo.rs | 20 +- src/io/linetypes.rs | 56 +- src/io/mod.rs | 571 +- src/io/pdf_export.rs | 860 +-- src/io/plot_device.rs | 7 +- src/io/plot_style.rs | 81 +- src/io/print_to_printer.rs | 349 +- src/io/recovery.rs | 53 +- src/io/single_instance.rs | 2 +- src/io/thumbnail.rs | 54 +- src/io/update_check.rs | 57 +- src/io/web_recent.rs | 5 +- src/io/web_worker.rs | 15 +- src/io/windows_media.rs | 62 +- src/io/xref.rs | 360 +- src/io/xref_model.rs | 77 +- src/main.rs | 8 +- src/mcp.rs | 324 +- src/mcp_ops.rs | 439 +- src/modules/annotate/aligned_dim.rs | 33 +- src/modules/annotate/angular_dim.rs | 289 +- src/modules/annotate/annotation_scale.rs | 1 - src/modules/annotate/arc_length_dim.rs | 54 +- src/modules/annotate/count_cmd.rs | 103 +- src/modules/annotate/data_link.rs | 18 +- src/modules/annotate/diameter_dim.rs | 35 +- src/modules/annotate/dim_baseline.rs | 58 +- src/modules/annotate/dim_continue.rs | 80 +- src/modules/annotate/dimbreak.rs | 16 +- src/modules/annotate/dimedit.rs | 18 +- src/modules/annotate/dimjogline.rs | 40 +- src/modules/annotate/dimspace.rs | 11 +- src/modules/annotate/dimtedit.rs | 20 +- src/modules/annotate/field_cmd.rs | 29 +- src/modules/annotate/jogged_radius_dim.rs | 39 +- src/modules/annotate/leader_cmd.rs | 43 +- src/modules/annotate/linear_dim.rs | 60 +- src/modules/annotate/mleader_cmd.rs | 93 +- src/modules/annotate/mleader_edit.rs | 89 +- src/modules/annotate/mod.rs | 16 +- src/modules/annotate/mtext.rs | 45 +- src/modules/annotate/ordinate_dim.rs | 43 +- src/modules/annotate/qdim.rs | 593 +- src/modules/annotate/radius_dim.rs | 45 +- src/modules/annotate/table_cmd.rs | 105 +- src/modules/annotate/text.rs | 34 +- src/modules/annotate/textedit.rs | 4 +- src/modules/annotate/tolerance_cmd.rs | 22 +- src/modules/draw/clipboard/copy_base.rs | 2 +- src/modules/draw/clipboard/paste.rs | 27 +- src/modules/draw/defaults.rs | 6 +- src/modules/draw/draw/apollonius.rs | 12 +- src/modules/draw/draw/arc.rs | 478 +- src/modules/draw/draw/attdef.rs | 198 +- src/modules/draw/draw/circle.rs | 252 +- src/modules/draw/draw/dimcenter.rs | 80 +- src/modules/draw/draw/donut.rs | 29 +- src/modules/draw/draw/ellipse.rs | 24 +- src/modules/draw/draw/face3d.rs | 28 +- src/modules/draw/draw/hatch.rs | 249 +- src/modules/draw/draw/hatchedit.rs | 619 +- src/modules/draw/draw/helix.rs | 43 +- src/modules/draw/draw/line.rs | 109 +- src/modules/draw/draw/mesh3d.rs | 10 +- src/modules/draw/draw/mline.rs | 71 +- src/modules/draw/draw/mod.rs | 2 +- src/modules/draw/draw/point.rs | 15 +- src/modules/draw/draw/poly3d.rs | 10 +- src/modules/draw/draw/polyline.rs | 217 +- src/modules/draw/draw/raster_image.rs | 6 +- src/modules/draw/draw/ray.rs | 180 +- src/modules/draw/draw/revcloud.rs | 128 +- src/modules/draw/draw/shapes.rs | 164 +- src/modules/draw/draw/sketch.rs | 25 +- src/modules/draw/draw/solid2d.rs | 2 +- src/modules/draw/draw/spline.rs | 256 +- src/modules/draw/draw/trace.rs | 4 +- src/modules/draw/draw/wipeout.rs | 76 +- src/modules/draw/groups/group.rs | 2 +- src/modules/draw/groups/ungroup.rs | 7 +- src/modules/draw/inquiry/area.rs | 62 +- src/modules/draw/inquiry/dist.rs | 72 +- src/modules/draw/inquiry/divide.rs | 29 +- src/modules/draw/inquiry/id.rs | 13 +- src/modules/draw/inquiry/measuregeom.rs | 14 +- src/modules/draw/layers/layiso.rs | 4 +- src/modules/draw/layers/laytrans.rs | 21 +- src/modules/draw/layers/match_layer.rs | 5 +- src/modules/draw/layers/mod.rs | 6 +- src/modules/draw/mod.rs | 26 +- src/modules/draw/modify/align.rs | 122 +- src/modules/draw/modify/array.rs | 64 +- src/modules/draw/modify/attedit.rs | 6 +- src/modules/draw/modify/blend.rs | 27 +- src/modules/draw/modify/block_edit.rs | 11 +- src/modules/draw/modify/break_cmd.rs | 128 +- src/modules/draw/modify/copy.rs | 32 +- src/modules/draw/modify/explode.rs | 315 +- src/modules/draw/modify/fillet.rs | 200 +- src/modules/draw/modify/flatten.rs | 47 +- src/modules/draw/modify/join.rs | 413 +- src/modules/draw/modify/lengthen.rs | 365 +- src/modules/draw/modify/mirror.rs | 29 +- src/modules/draw/modify/mledit.rs | 141 +- src/modules/draw/modify/mod.rs | 6 +- src/modules/draw/modify/ncopy.rs | 320 +- src/modules/draw/modify/offset.rs | 163 +- src/modules/draw/modify/overkill.rs | 279 +- src/modules/draw/modify/pedit.rs | 1203 +++- src/modules/draw/modify/refedit.rs | 11 +- src/modules/draw/modify/reverse.rs | 6 +- src/modules/draw/modify/rotate.rs | 74 +- src/modules/draw/modify/scale.rs | 28 +- src/modules/draw/modify/setbylayer.rs | 174 +- src/modules/draw/modify/spline_ops.rs | 16 +- src/modules/draw/modify/splinedit.rs | 910 ++- src/modules/draw/modify/stretch.rs | 22 +- src/modules/draw/modify/torient.rs | 32 +- src/modules/draw/modify/translate.rs | 30 +- src/modules/draw/modify/trim.rs | 979 +-- src/modules/draw/properties/match_prop.rs | 5 +- src/modules/draw/select.rs | 29 +- src/modules/insert/image_transparency.rs | 16 +- src/modules/insert/insert_block.rs | 75 +- src/modules/insert/minsert.rs | 52 +- src/modules/insert/mod.rs | 4 +- src/modules/insert/mview_block.rs | 3 +- src/modules/insert/pc_attach.rs | 9 +- src/modules/insert/pc_crop.rs | 77 +- src/modules/insert/pc_extract.rs | 322 +- src/modules/insert/pc_stylize.rs | 38 +- src/modules/insert/pdf_attach.rs | 117 +- src/modules/insert/pdf_clip.rs | 46 +- src/modules/insert/pdf_import.rs | 83 +- src/modules/insert/picker.rs | 61 +- src/modules/insert/solid3d_cmds.rs | 586 +- src/modules/insert/wblock.rs | 14 +- src/modules/insert/xattach.rs | 67 +- src/modules/insert/xclip.rs | 60 +- src/modules/insert/xref_cmd.rs | 10 +- src/modules/layout/mview.rs | 162 +- src/modules/layout/vplayer.rs | 7 +- src/modules/mod.rs | 4 +- src/modules/model/cylinder_cmd.rs | 97 +- src/modules/model/edge_cmd.rs | 111 +- src/modules/model/polysolid_cmd.rs | 95 +- src/modules/model/primitive_cmd.rs | 379 +- src/modules/model/sectionplane_cmd.rs | 106 +- src/modules/model/slice_cmd.rs | 28 +- src/modules/parametric/coincident.rs | 31 +- src/modules/parametric/concentric.rs | 11 +- src/modules/parametric/constraint_bar.rs | 5 +- src/modules/parametric/dim_constraint.rs | 102 +- src/modules/parametric/equal.rs | 10 +- src/modules/parametric/geom_constraint.rs | 5 +- src/modules/parametric/mod.rs | 183 +- src/modules/parametric/parameters_cli.rs | 18 +- src/modules/parametric/smooth.rs | 49 +- src/modules/parametric/symmetric.rs | 8 +- src/modules/parametric/tangent.rs | 12 +- src/modules/view/limits.rs | 2 +- src/modules/view/mod.rs | 6 +- src/modules/view/plot_window.rs | 14 +- src/modules/view/quick_print.rs | 6 +- src/modules/view/ucs_cmd.rs | 10 +- src/modules/view/zoom_window.rs | 24 +- src/patreon.rs | 30 +- src/plugin/external.rs | 44 +- src/plugin/host.rs | 2 +- src/plugin/marketplace.rs | 49 +- src/plugin/mod.rs | 10 +- src/plugin/registry.rs | 21 +- src/plugin/v4_support.rs | 5 +- src/rest.rs | 367 +- src/scene/annotative.rs | 321 +- src/scene/boundary.rs | 226 +- src/scene/cache/block_cache.rs | 400 +- src/scene/cache/properties.rs | 2 +- src/scene/camera_ops.rs | 187 +- src/scene/centerline.rs | 295 +- src/scene/centermark.rs | 123 +- src/scene/convert/acis_export.rs | 9 +- src/scene/convert/acis_kernel.rs | 16 +- src/scene/convert/dgn_linestyle.rs | 15 +- src/scene/convert/mod.rs | 6 +- src/scene/convert/proxy_graphics.rs | 27 +- src/scene/convert/solid3d_tess.rs | 15 +- src/scene/convert/tess.rs | 297 +- src/scene/convert/tessellate.rs | 530 +- src/scene/creation_style.rs | 108 +- src/scene/dimension_assoc.rs | 19 +- src/scene/dimension_assoc_chain.rs | 25 +- src/scene/dwg_native_constraints.rs | 65 +- src/scene/entity.rs | 48 +- src/scene/frame.rs | 30 +- src/scene/group_layer.rs | 5 +- src/scene/hatch_boundary/tests.rs | 10 +- src/scene/layout.rs | 75 +- src/scene/limits.rs | 12 +- src/scene/mod.rs | 347 +- src/scene/model/hatch_model.rs | 8 +- src/scene/model/image_model.rs | 110 +- src/scene/model/loft_command_model.rs | 136 +- src/scene/model/material_model.rs | 36 +- src/scene/model/mesh_model.rs | 13 +- src/scene/model/mod.rs | 16 +- src/scene/model/pdf_layers.rs | 12 +- src/scene/model/pdf_raster.rs | 51 +- src/scene/model/pdf_vector.rs | 111 +- src/scene/model/point_cloud/mod.rs | 378 +- src/scene/model/point_cloud/rcp.rs | 7 +- src/scene/model/point_cloud/rcs.rs | 20 +- src/scene/model/presspull_model.rs | 329 +- src/scene/model/solid_history.rs | 589 +- src/scene/model/solid_model.rs | 56 +- src/scene/model/sweep_command_model.rs | 220 +- src/scene/model/sweep_model.rs | 105 +- src/scene/model/underlay_vector/dgn7.rs | 266 +- src/scene/model/underlay_vector/dgn8.rs | 489 +- src/scene/model/underlay_vector/dwf.rs | 462 +- src/scene/model/underlay_vector/mod.rs | 188 +- src/scene/model/underlay_vector/model.rs | 335 +- src/scene/model/underlay_vector/wires.rs | 46 +- src/scene/model/visual_style_model.rs | 9 +- src/scene/model/wire_model.rs | 280 +- src/scene/modify.rs | 200 +- src/scene/mspace.rs | 80 +- src/scene/page_setup.rs | 57 +- src/scene/paper.rs | 69 +- src/scene/parametric_constraints.rs | 554 +- src/scene/parametric_solve.rs | 884 ++- src/scene/pick/grip.rs | 29 +- src/scene/pick/hit_test.rs | 186 +- src/scene/pick/interaction_index.rs | 184 +- src/scene/pick/quadtree.rs | 13 +- src/scene/pick/xclip.rs | 97 +- src/scene/pipeline/circle_gpu.rs | 130 +- src/scene/pipeline/depth_tests.rs | 50 +- src/scene/pipeline/device_capabilities.rs | 1 - src/scene/pipeline/ellipse_gpu.rs | 93 +- src/scene/pipeline/face3d_gpu.rs | 56 +- src/scene/pipeline/gpu_tests.rs | 210 +- src/scene/pipeline/hatch_gpu/mod.rs | 40 +- src/scene/pipeline/hatch_gpu/storage.rs | 115 +- src/scene/pipeline/hatch_gpu/texture.rs | 51 +- src/scene/pipeline/image_gpu.rs | 48 +- src/scene/pipeline/mesh_gpu.rs | 239 +- src/scene/pipeline/mod.rs | 2885 ++++---- src/scene/pipeline/point_cloud_gpu.rs | 11 +- src/scene/pipeline/text_gpu.rs | 30 +- src/scene/pipeline/viewcube.rs | 72 +- src/scene/pipeline/viewcube_text_atlas.rs | 18 +- src/scene/pipeline/wipeout_gpu.rs | 62 +- src/scene/pipeline/wire_arena.rs | 145 +- src/scene/pipeline/wire_gpu.rs | 299 +- src/scene/preview.rs | 49 +- src/scene/project.rs | 154 +- src/scene/render_graph.rs | 193 +- src/scene/selection.rs | 166 +- src/scene/text/complex_lt.rs | 22 +- src/scene/text/font_face.rs | 18 +- src/scene/text/glyph_quads.rs | 19 +- src/scene/text/lff.rs | 401 +- src/scene/text/mod.rs | 6 +- src/scene/text/sdf_atlas.rs | 40 +- src/scene/text/shx.rs | 17 +- src/scene/text/sysfont.rs | 29 +- src/scene/text/ttf_glyph.rs | 85 +- src/scene/text/web_font.rs | 4 +- src/scene/view/camera.rs | 101 +- src/scene/view/dispatch.rs | 37 +- src/scene/view/mod.rs | 4 +- src/scene/view/render.rs | 941 +-- src/scene/view/transform.rs | 72 +- src/snap.rs | 810 ++- src/sys.rs | 101 +- src/sysinfo.rs | 4 +- src/ui/color_select.rs | 57 +- src/ui/command_line.rs | 61 +- src/ui/dock.rs | 113 +- src/ui/modal.rs | 121 +- src/ui/node_graph.rs | 222 +- src/ui/overlay.rs | 673 +- src/ui/popup/context_menu.rs | 68 +- src/ui/popup/cycle_popup.rs | 15 +- src/ui/popup/isolate_popup.rs | 7 +- src/ui/popup/polar_popup.rs | 16 +- src/ui/popup/scale_popup.rs | 6 +- src/ui/popup/selection_filter_popup.rs | 34 +- src/ui/popup/snap_popup.rs | 16 +- src/ui/properties.rs | 453 +- src/ui/ribbon/collapse.rs | 113 +- src/ui/ribbon/color_dropdown.rs | 50 +- src/ui/ribbon/context_tools.rs | 24 +- src/ui/ribbon/draw_panel.rs | 65 +- src/ui/ribbon/mod.rs | 293 +- src/ui/ribbon/widgets.rs | 278 +- src/ui/side_toolbar.rs | 63 +- src/ui/statusbar/mod.rs | 5 +- src/ui/statusbar/status_menu.rs | 4 +- src/ui/statusbar/statusbar_config.rs | 1 - src/ui/statusbar/statusbar_menu.rs | 15 +- src/ui/style/anno_object_scale.rs | 36 +- src/ui/style/common.rs | 6 +- src/ui/style/dimstyle.rs | 358 +- src/ui/style/mleaderstyle.rs | 476 +- src/ui/style/mlstyle.rs | 143 +- src/ui/style/mod.rs | 23 +- src/ui/style/plotstyle.rs | 211 +- src/ui/style/point_style.rs | 32 +- src/ui/style/row_button.rs | 5 +- src/ui/style/scale_manager.rs | 146 +- src/ui/style/style_list.rs | 9 +- src/ui/style/style_manager.rs | 65 +- src/ui/style/tablestyle.rs | 314 +- src/ui/style/textstyle.rs | 84 +- src/ui/theme_accessibility_tests.rs | 47 +- src/ui/wide_menu.rs | 17 +- src/ui/window/about.rs | 17 +- src/ui/window/alias_editor.rs | 73 +- src/ui/window/annotation_data.rs | 771 ++- src/ui/window/attdef_dialog.rs | 227 +- src/ui/window/attribute_editor.rs | 231 +- src/ui/window/auto_constrain_settings.rs | 8 +- src/ui/window/block_palette.rs | 420 +- src/ui/window/browser.rs | 25 +- src/ui/window/count_palette.rs | 322 +- src/ui/window/drafting_settings.rs | 99 +- src/ui/window/field_dialog.rs | 601 +- src/ui/window/find_replace.rs | 6 +- src/ui/window/geometric_tolerance.rs | 136 +- src/ui/window/layer_state_manager.rs | 152 +- src/ui/window/layer_translator.rs | 4 +- src/ui/window/layers.rs | 290 +- src/ui/window/layout_manager.rs | 10 +- src/ui/window/mod.rs | 52 +- src/ui/window/named_parameters.rs | 10 +- src/ui/window/open_progress.rs | 66 +- src/ui/window/options.rs | 261 +- src/ui/window/options/graphics.rs | 9 +- src/ui/window/pc_manager.rs | 114 +- src/ui/window/pdf_dialogs.rs | 857 ++- src/ui/window/plot.rs | 674 +- src/ui/window/plugin_manager.rs | 134 +- src/ui/window/print_all.rs | 22 +- src/ui/window/recovery.rs | 14 +- src/ui/window/sheet_set.rs | 938 ++- src/ui/window/shortcuts.rs | 63 +- src/ui/window/update_notice.rs | 67 +- src/ui/window/wblock.rs | 40 +- src/ui/window/xref_attach.rs | 29 +- src/ui/window/xref_help.rs | 25 +- src/ui/window/xref_manager.rs | 368 +- src/ui/wrap_bar.rs | 174 +- tests/annotative_context_roundtrip.rs | 40 +- tests/annotative_context_synthesis.rs | 27 +- tests/block_hatch_export.rs | 36 +- tests/block_text_draw_depth.rs | 44 +- tests/dim_copy_check.rs | 10 +- tests/dim_line_color_roundtrip.rs | 7 +- tests/explode_regression.rs | 83 +- tests/flatten_regression.rs | 69 +- tests/page_setup_root_repair.rs | 5 +- tests/parametric_constraints_solve.rs | 6 +- tests/ucs_face_plane.rs | 53 +- tests/xclip_plot_test.rs | 104 +- 532 files changed, 56995 insertions(+), 33416 deletions(-) diff --git a/benches/command_benchmarks.rs b/benches/command_benchmarks.rs index 3cd183a34..c65439c4a 100644 --- a/benches/command_benchmarks.rs +++ b/benches/command_benchmarks.rs @@ -16,9 +16,7 @@ use codec::types::{Vector2, Vector3}; use codec::{CadDocument, EntityType, Handle}; use harness::BenchmarkRunner; -use OpenCADStudio::modules::draw::modify::explode::{ - apply_explode_replacements, explode_batch, -}; +use OpenCADStudio::modules::draw::modify::explode::{apply_explode_replacements, explode_batch}; use OpenCADStudio::modules::draw::modify::flatten; use OpenCADStudio::scene::Scene; @@ -79,10 +77,8 @@ fn push_fixture_entities(doc: &mut CadDocument, quick: bool) -> Vec { let mut pl = LwPolyline::new(); pl.vertices = (0..VERTS_PER_POLYLINE) .map(|k| { - let mut v = LwVertex::new(Vector2::new( - base + k as f64 * 2.0, - (k % 5) as f64 * 0.75, - )); + let mut v = + LwVertex::new(Vector2::new(base + k as f64 * 2.0, (k % 5) as f64 * 0.75)); if k % 2 == 1 { v.bulge = 0.5; } @@ -101,13 +97,13 @@ fn push_fixture_entities(doc: &mut CadDocument, quick: bool) -> Vec { for i in 0..n_dim { let x = (i % 100) as f64 * 12.0; let y = (i / 100) as f64 * 12.0; - let mut d = DimensionLinear::new( - Vector3::new(x, y, 0.0), - Vector3::new(x + 10.0, y, 0.0), - ); + let mut d = DimensionLinear::new(Vector3::new(x, y, 0.0), Vector3::new(x + 10.0, y, 0.0)); d.definition_point = Vector3::new(x + 5.0, y + 3.0, 0.0); d.base.text_middle_point = Vector3::new(x + 5.0, y + 4.0, 0.0); - handles.push(doc.add_entity(EntityType::Dimension(Dimension::Linear(d))).unwrap()); + handles.push( + doc.add_entity(EntityType::Dimension(Dimension::Linear(d))) + .unwrap(), + ); } for i in 0..n_ml { @@ -121,7 +117,10 @@ fn push_fixture_entities(doc: &mut CadDocument, quick: bool) -> Vec { Vector3::new(x + 8.0, y + 8.0, 0.0), Vector3::new(x + 12.0, y + 8.0, 0.0), ]; - handles.push(doc.add_entity(EntityType::MLine(MLine::from_points(&pts))).unwrap()); + handles.push( + doc.add_entity(EntityType::MLine(MLine::from_points(&pts))) + .unwrap(), + ); } handles @@ -274,7 +273,8 @@ fn push_flatten_fixture(doc: &mut CadDocument, quick: bool) -> Vec { handles.push(doc.add_entity(EntityType::Line(line)).unwrap()); } for i in 0..n_circle { - let mut c = Circle::from_center_radius(Vector3::new(i as f64, 0.0, 1.0 + (i % 3) as f64), 2.0); + let mut c = + Circle::from_center_radius(Vector3::new(i as f64, 0.0, 1.0 + (i % 3) as f64), 2.0); if i % 2 == 1 { c.normal = Vector3::new(0.0, 0.6, 0.8); // tilted → ellipse } @@ -296,7 +296,8 @@ fn push_flatten_fixture(doc: &mut CadDocument, quick: bool) -> Vec { let mut pl = Polyline2D::new(); pl.elevation = 3.0 + (i % 5) as f64; for k in 0..6 { - pl.vertices.push(Vertex2D::new(Vector3::new(k as f64, i as f64, 0.0))); + pl.vertices + .push(Vertex2D::new(Vector3::new(k as f64, i as f64, 0.0))); } handles.push(doc.add_entity(EntityType::Polyline2D(pl)).unwrap()); } @@ -321,7 +322,10 @@ fn push_flatten_fixture(doc: &mut CadDocument, quick: bool) -> Vec { handles.push(doc.add_entity(EntityType::Solid(s)).unwrap()); } for i in 0..n_ray { - let r = Ray::new(Vector3::new(i as f64, 0.0, 5.0), Vector3::new(0.0, 0.0, 1.0)); + let r = Ray::new( + Vector3::new(i as f64, 0.0, 5.0), + Vector3::new(0.0, 0.0, 1.0), + ); handles.push(doc.add_entity(EntityType::Ray(r)).unwrap()); } for i in 0..n_flat { diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index dede3f7c8..b795fb201 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -59,8 +59,8 @@ use OpenCADStudio::scene::{ChangeKind, Scene}; use OpenCADStudio::snap::Snapper; use OpenCADStudio::ui::icons::{self, CHECK}; use OpenCADStudio::ui::overlay::{ - grid_segments, selection_overlay, should_reuse, CrosshairOptions, GridCanvasState, - GridKey, GridParams, GridStyle, GripMarker, NavCursor, OstTrackPoint, SelectionVisualOptions, + grid_segments, selection_overlay, should_reuse, CrosshairOptions, GridCanvasState, GridKey, + GridParams, GridStyle, GripMarker, NavCursor, OstTrackPoint, SelectionVisualOptions, UcsIconParams, }; use OpenCADStudio::ui::properties::LinetypeItem; @@ -914,10 +914,26 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { if runner.should_run("ui_grid_geometry_uncached") { for _ in 0..10 { let _ = black_box(grid_segments( - view_rot1, eye1, bounds1, step1, step1, major_every, origin1, axes1, limits1, + view_rot1, + eye1, + bounds1, + step1, + step1, + major_every, + origin1, + axes1, + limits1, )); let _ = black_box(grid_segments( - view_rot2, eye2, bounds2, step2, step2, major_every, origin2, axes2, limits2, + view_rot2, + eye2, + bounds2, + step2, + step2, + major_every, + origin2, + axes2, + limits2, )); } @@ -928,8 +944,28 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { for _ in 0..runs { let t0 = Instant::now(); for _ in 0..n { - let g1 = grid_segments(view_rot1, eye1, bounds1, step1, step1, major_every, origin1, axes1, limits1); - let g2 = grid_segments(view_rot2, eye2, bounds2, step2, step2, major_every, origin2, axes2, limits2); + let g1 = grid_segments( + view_rot1, + eye1, + bounds1, + step1, + step1, + major_every, + origin1, + axes1, + limits1, + ); + let g2 = grid_segments( + view_rot2, + eye2, + bounds2, + step2, + step2, + major_every, + origin2, + axes2, + limits2, + ); black_box(g1); black_box(g2); } @@ -1302,7 +1338,10 @@ fn bench_ui_grip_budget(runner: &mut BenchmarkRunner) { ), "µs", samples, - Some(((capped_grips.len() as f64) / (median_us / 1_000_000.0), "grips/s")), + Some(( + (capped_grips.len() as f64) / (median_us / 1_000_000.0), + "grips/s", + )), Some(500.0), // Target threshold < 500 µs (measured ~132 µs quick / ~147 µs full) ); @@ -1337,7 +1376,10 @@ fn bench_ui_grip_budget(runner: &mut BenchmarkRunner) { ), "µs", build_samples, - Some(((n_fixture_grips as f64) / (build_median_us / 1_000_000.0), "grips/s")), + Some(( + (n_fixture_grips as f64) / (build_median_us / 1_000_000.0), + "grips/s", + )), Some(7_000.0), // Target < 7 ms (selection-change hitch budget; measured ~2.36 ms full / ~0.30 ms quick, ~3x headroom) ); } @@ -1386,18 +1428,13 @@ fn bench_ui_constraint_glyphs(runner: &mut BenchmarkRunner) { let vp = (1920.0_f32, 1080.0_f32); // Prime the cache so every timed call below is a hit (same key, same Arc). - let primed = - scene.cached_glyph_placements(ParametricScope::ModelSpace, vp, true, 3, 4095); - assert!( - !primed.is_empty(), - "glyph fixture must yield placements" - ); + let primed = scene.cached_glyph_placements(ParametricScope::ModelSpace, vp, true, 3, 4095); + assert!(!primed.is_empty(), "glyph fixture must yield placements"); let n_glyphs = primed.len(); // Warm-up for allocator settling. for _ in 0..10 { - let hit = - scene.cached_glyph_placements(ParametricScope::ModelSpace, vp, true, 3, 4095); + let hit = scene.cached_glyph_placements(ParametricScope::ModelSpace, vp, true, 3, 4095); black_box(hit); } @@ -1499,7 +1536,12 @@ fn build_selection_overlay_element( .collect(); let ucs_icons = vec![UcsIconParams { view_proj: Mat4::IDENTITY, - bounds: Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 1080.0 }, + bounds: Rectangle { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }, axes: (Vec3::X, Vec3::Y, Vec3::Z), origin_screen: None, hover: false, @@ -1516,7 +1558,9 @@ fn build_selection_overlay_element( None, None, ucs_icons, - vec![OstTrackPoint { screen: Point::new(500.0, 500.0) }], + vec![OstTrackPoint { + screen: Point::new(500.0, 500.0), + }], vec![(Point::new(0.0, 0.0), Point::new(100.0, 100.0))], None, true, diff --git a/build.rs b/build.rs index 6c69b391a..50cd80b6e 100644 --- a/build.rs +++ b/build.rs @@ -26,8 +26,8 @@ fn main() { println!("cargo:rerun-if-changed=.git/{reference}"); } } - let revision = git_output(&["rev-parse", "--short=12", "HEAD"]) - .unwrap_or_else(|| "unknown".to_string()); + let revision = + git_output(&["rev-parse", "--short=12", "HEAD"]).unwrap_or_else(|| "unknown".to_string()); // diff-index exits 1 for differences and 128 when there is no repository, // so only a real difference marks the build dirty. let dirty = std::process::Command::new("git") @@ -59,8 +59,8 @@ fn main() { "cargo:rustc-env=OCS_TAG_DISTANCE={}", tag_distance.map_or_else(|| "unknown".to_string(), |n| n.to_string()) ); - let commit_date = git_output(&["log", "-1", "--format=%cs", "HEAD"]) - .unwrap_or_else(|| "unknown".to_string()); + let commit_date = + git_output(&["log", "-1", "--format=%cs", "HEAD"]).unwrap_or_else(|| "unknown".to_string()); println!("cargo:rustc-env=OCS_COMMIT_DATE={commit_date}"); // Semver-style build metadata: empty for a clean build on the release diff --git a/crates/dwg-thumbnailer-win/src/lib.rs b/crates/dwg-thumbnailer-win/src/lib.rs index 3d294726d..6c481d0c9 100644 --- a/crates/dwg-thumbnailer-win/src/lib.rs +++ b/crates/dwg-thumbnailer-win/src/lib.rs @@ -40,7 +40,7 @@ use windows::Win32::System::SystemServices::{DLL_PROCESS_ATTACH, DLL_PROCESS_DET use windows::Win32::UI::Shell::PropertiesSystem::{IInitializeWithFile, IInitializeWithFile_Impl}; use windows::Win32::UI::Shell::{ IThumbnailProvider, IThumbnailProvider_Impl, SHChangeNotify, SHCNE_ASSOCCHANGED, SHCNF_IDLIST, - WTS_ALPHATYPE, WTSAT_ARGB, + WTSAT_ARGB, WTS_ALPHATYPE, }; /// CLSID of this thumbnail provider (stable; used in the registry keys). @@ -72,7 +72,8 @@ struct DwgThumbProvider { impl IInitializeWithFile_Impl for DwgThumbProvider_Impl { fn Initialize(&self, pszfilepath: &PCWSTR, _grfmode: u32) -> windows::core::Result<()> { - let path = unsafe { pszfilepath.to_string() }.map_err(|_| windows::core::Error::from(E_INVALIDARG))?; + let path = unsafe { pszfilepath.to_string() } + .map_err(|_| windows::core::Error::from(E_INVALIDARG))?; *self.path.borrow_mut() = Some(path); Ok(()) } @@ -85,7 +86,11 @@ impl IThumbnailProvider_Impl for DwgThumbProvider_Impl { phbmp: *mut HBITMAP, pdwalpha: *mut WTS_ALPHATYPE, ) -> windows::core::Result<()> { - let path = self.path.borrow().clone().ok_or(windows::core::Error::from(E_FAIL))?; + let path = self + .path + .borrow() + .clone() + .ok_or(windows::core::Error::from(E_FAIL))?; let mut img = dwg_thumbnailer::extract(std::path::Path::new(&path), cx.max(1)) .ok_or(windows::core::Error::from(E_FAIL))?; dwg_thumbnailer::badge_dwg(&mut img); // full-width "DWG" band @@ -248,13 +253,14 @@ fn set_value(sub: &str, name: Option<&str>, value: &str) -> windows::core::Resul return Err(E_FAIL.into()); } let val_w = wide(value); - let bytes = - unsafe { std::slice::from_raw_parts(val_w.as_ptr() as *const u8, val_w.len() * 2) }; + let bytes = unsafe { std::slice::from_raw_parts(val_w.as_ptr() as *const u8, val_w.len() * 2) }; let name_w = name.map(wide); let rc = unsafe { RegSetValueExW( hkey, - name_w.as_ref().map_or(PCWSTR::null(), |n| PCWSTR(n.as_ptr())), + name_w + .as_ref() + .map_or(PCWSTR::null(), |n| PCWSTR(n.as_ptr())), 0, REG_SZ, Some(bytes), diff --git a/crates/dwg-thumbnailer/src/lib.rs b/crates/dwg-thumbnailer/src/lib.rs index 28deb0399..a94186049 100644 --- a/crates/dwg-thumbnailer/src/lib.rs +++ b/crates/dwg-thumbnailer/src/lib.rs @@ -74,14 +74,14 @@ pub fn badge_dwg(img: &mut RgbaImage) { let label = label.to_rgba8(); if label.width() > 0 && label.height() > 0 { let mut target_h = ((band_h as f32 * 0.72) as u32).max(1); - let mut target_w = ((label.width() as f32 * target_h as f32 / label.height() as f32) - as u32) - .max(1); + let mut target_w = + ((label.width() as f32 * target_h as f32 / label.height() as f32) as u32).max(1); // Don't let a wide wordmark spill past the thumbnail edges. let max_w = ((w as f32 * 0.90) as u32).max(1); if target_w > max_w { target_w = max_w; - target_h = ((label.height() as f32 * target_w as f32 / label.width() as f32) as u32) + target_h = ((label.height() as f32 * target_w as f32 / label.width() as f32) + as u32) .max(1); } let label = image::imageops::thumbnail(&label, target_w, target_h); @@ -197,7 +197,9 @@ fn parse_preview_container(buf: &[u8], base: u64) -> Option<(Fmt, &[u8])> { fn decode(format: Fmt, data: &[u8]) -> Option { let img = match format { Fmt::Png => image::load_from_memory_with_format(data, ImageFormat::Png).ok()?, - Fmt::Bmp => image::load_from_memory_with_format(&dib_to_bmp(data), ImageFormat::Bmp).ok()?, + Fmt::Bmp => { + image::load_from_memory_with_format(&dib_to_bmp(data), ImageFormat::Bmp).ok()? + } }; Some(img.to_rgba8()) } @@ -235,7 +237,9 @@ pub unsafe extern "C" fn dwg_thumbnail_png( return false; } let cstr = std::ffi::CStr::from_ptr(path_utf8); - let Ok(path) = cstr.to_str() else { return false }; + let Ok(path) = cstr.to_str() else { + return false; + }; let Some(mut img) = extract(Path::new(path), max_dim) else { return false; }; @@ -271,7 +275,11 @@ fn dib_to_bmp(dib: &[u8]) -> Vec { } let bi_size = u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]) as usize; let bpp = u16::from_le_bytes([dib[14], dib[15]]) as usize; - let palette = if (1..=8).contains(&bpp) { (1usize << bpp) * 4 } else { 0 }; + let palette = if (1..=8).contains(&bpp) { + (1usize << bpp) * 4 + } else { + 0 + }; let mut v = Vec::with_capacity(14 + dib.len()); v.extend_from_slice(b"BM"); v.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes()); diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index dda5ba997..0c1e31246 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -68,10 +68,7 @@ fn generate_type_registry(out_dir: &Path) { let types: Vec<(&str, TraceFn)> = vec![ ("EntityType", trace::), ("ObjectType", trace::), - ( - "HeaderVariables", - trace::, - ), + ("HeaderVariables", trace::), ("SummaryInfo", trace::), ("LineType", trace::), ("TextStyle", trace::), @@ -88,10 +85,7 @@ fn generate_type_registry(out_dir: &Path) { trace::, ), ("FieldDef", trace::), - ( - "DgnLsDefinition", - trace::, - ), + ("DgnLsDefinition", trace::), ("DgnLsComponent", trace::), ( "NotificationCollection", @@ -182,10 +176,7 @@ fn generate_type_registry(out_dir: &Path) { "AssocViewObjectActionParamKind", trace_simple::, ), - ( - "AcisVersion", - trace_simple::, - ), + ("AcisVersion", trace_simple::), ( "AssociativeData", trace_simple::, @@ -365,10 +356,7 @@ fn generate_type_registry(out_dir: &Path) { "PlotPaperUnits", trace_simple::, ), - ( - "PlotRotation", - trace_simple::, - ), + ("PlotRotation", trace_simple::), ("PlotType", trace_simple::), ( "PreviewFormat", @@ -403,14 +391,8 @@ fn generate_type_registry(out_dir: &Path) { "SolidHistoryOperation", trace_simple::, ), - ( - "SurfaceData", - trace_simple::, - ), - ( - "SurfaceKind", - trace_simple::, - ), + ("SurfaceData", trace_simple::), + ("SurfaceKind", trace_simple::), ( "SurfaceSmoothType", trace_simple::, @@ -525,7 +507,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { use std::collections::HashSet; let policy: EntityCoveragePolicy = serde_json::from_str(include_str!("entity_coverage_policy.json")) - .expect("valid entity coverage policy"); + .expect("valid entity coverage policy"); let variants = ®istry.types[&TypeId::new("EntityType")].variants; let variant_names: HashSet<&str> = variants.iter().map(|v| v.name.as_str()).collect(); for name in policy @@ -814,7 +796,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | "vertices" ) ) { - "transaction_geometry" + "transaction_geometry" } else { "type_conversion_only" }; @@ -842,8 +824,17 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { optional: false, is_sequence: false, snapshot_readable: true, - validation: if writable { "transaction_geometry" } else { "none" }.into(), - model_access: if writable { ModelAccess::ReadWrite } else { ModelAccess::ReadOnly }, + validation: if writable { + "transaction_geometry" + } else { + "none" + } + .into(), + model_access: if writable { + ModelAccess::ReadWrite + } else { + ModelAccess::ReadOnly + }, }); } assert!( @@ -864,7 +855,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { out_dir.join("entity_coverage.json"), serde_json::to_string_pretty(&catalog).unwrap(), ) - .expect("write entity coverage"); + .expect("write entity coverage"); } fn trace(tracer: &mut Tracer, samples: &Samples) @@ -910,10 +901,7 @@ fn add_enum_samples(tracer: &mut Tracer, samples: &mut Samples) { samples, &codec::entities::SmoothSurfaceType::QuadraticBSpline, ); - let _ = tracer.trace_value( - samples, - &codec::entities::SmoothSurfaceType::CubicBSpline, - ); + let _ = tracer.trace_value(samples, &codec::entities::SmoothSurfaceType::CubicBSpline); let _ = tracer.trace_value(samples, &codec::entities::SmoothSurfaceType::Bezier); let _ = tracer.trace_value(samples, &codec::entities::AttachmentPoint::TopLeft); @@ -938,14 +926,8 @@ fn add_enum_samples(tracer: &mut Tracer, samples: &mut Samples) { samples, &codec::xdata::XDataValue::ControlString(String::new()), ); - let _ = tracer.trace_value( - samples, - &codec::xdata::XDataValue::LayerName(String::new()), - ); - let _ = tracer.trace_value( - samples, - &codec::xdata::XDataValue::BinaryData(Vec::new()), - ); + let _ = tracer.trace_value(samples, &codec::xdata::XDataValue::LayerName(String::new())); + let _ = tracer.trace_value(samples, &codec::xdata::XDataValue::BinaryData(Vec::new())); let _ = tracer.trace_value( samples, &codec::xdata::XDataValue::Handle(codec::Handle::default()), @@ -995,14 +977,8 @@ fn add_enum_samples(tracer: &mut Tracer, samples: &mut Samples) { // Proxy reference kinds pulled in by XRecord.object_references let _ = tracer.trace_value(samples, &codec::objects::ProxyReferenceKind::Undefined); - let _ = tracer.trace_value( - samples, - &codec::objects::ProxyReferenceKind::SoftOwnership, - ); - let _ = tracer.trace_value( - samples, - &codec::objects::ProxyReferenceKind::HardOwnership, - ); + let _ = tracer.trace_value(samples, &codec::objects::ProxyReferenceKind::SoftOwnership); + let _ = tracer.trace_value(samples, &codec::objects::ProxyReferenceKind::HardOwnership); let _ = tracer.trace_value(samples, &codec::objects::ProxyReferenceKind::SoftPointer); let _ = tracer.trace_value(samples, &codec::objects::ProxyReferenceKind::HardPointer); diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index b04b14ace..aa2901297 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -62,7 +62,9 @@ fn insert_scale(name: &str, value: f64) -> Result<(), String> { } #[cfg(feature = "host")] -fn validate_hatch_paths(paths: &[crate::host::codec::entities::BoundaryPath]) -> Result<(), String> { +fn validate_hatch_paths( + paths: &[crate::host::codec::entities::BoundaryPath], +) -> Result<(), String> { use crate::host::codec::entities::BoundaryEdge; use crate::host::codec::types::Vector2; let close = |a: Vector2, b: Vector2| (a.x - b.x).hypot(a.y - b.y) <= 1e-8; @@ -89,11 +91,19 @@ fn validate_hatch_paths(paths: &[crate::host::codec::entities::BoundaryPath]) -> for (ei, edge) in path.edges.iter().enumerate() { let pair = match edge { BoundaryEdge::Line(line) => { - if !line.start.x.is_finite() || !line.start.y.is_finite() || !line.end.x.is_finite() || !line.end.y.is_finite() { - return Err(format!("Hatch boundary path {pi} edge {ei} line has non-finite coordinates")); + if !line.start.x.is_finite() + || !line.start.y.is_finite() + || !line.end.x.is_finite() + || !line.end.y.is_finite() + { + return Err(format!( + "Hatch boundary path {pi} edge {ei} line has non-finite coordinates" + )); } if (line.start.x - line.end.x).hypot(line.start.y - line.end.y) < 1e-12 { - return Err(format!("Hatch boundary path {pi} edge {ei} line has zero length")); + return Err(format!( + "Hatch boundary path {pi} edge {ei} line has zero length" + )); } (line.start, line.end) } @@ -105,34 +115,65 @@ fn validate_hatch_paths(paths: &[crate::host::codec::entities::BoundaryPath]) -> return Err(format!("Hatch boundary path {pi} edge {ei} arc radius must be finite and positive")); } if !arc.start_angle.is_finite() || !arc.end_angle.is_finite() { - return Err(format!("Hatch boundary path {pi} edge {ei} arc angles must be finite")); + return Err(format!( + "Hatch boundary path {pi} edge {ei} arc angles must be finite" + )); } - let point = |angle: f64| Vector2::new( - arc.center.x + arc.radius * angle.cos(), - arc.center.y + arc.radius * angle.sin(), - ); + let point = |angle: f64| { + Vector2::new( + arc.center.x + arc.radius * angle.cos(), + arc.center.y + arc.radius * angle.sin(), + ) + }; let (start, end) = (point(arc.start_angle), point(arc.end_angle)); - if arc.counter_clockwise { (start, end) } else { (end, start) } + if arc.counter_clockwise { + (start, end) + } else { + (end, start) + } } BoundaryEdge::EllipticArc(arc) => { if !arc.center.x.is_finite() || !arc.center.y.is_finite() { return Err(format!("Hatch boundary path {pi} edge {ei} ellipse center has non-finite coordinates")); } - if !arc.major_axis_endpoint.x.is_finite() || !arc.major_axis_endpoint.y.is_finite() { + if !arc.major_axis_endpoint.x.is_finite() + || !arc.major_axis_endpoint.y.is_finite() + { return Err(format!("Hatch boundary path {pi} edge {ei} ellipse major axis has non-finite coordinates")); } if arc.major_axis_endpoint.x.hypot(arc.major_axis_endpoint.y) < 1e-12 { - return Err(format!("Hatch boundary path {pi} edge {ei} ellipse major axis has zero length")); + return Err(format!( + "Hatch boundary path {pi} edge {ei} ellipse major axis has zero length" + )); } - if !arc.minor_axis_ratio.is_finite() || !(0.0..=1.0).contains(&arc.minor_axis_ratio) || arc.minor_axis_ratio == 0.0 { + if !arc.minor_axis_ratio.is_finite() + || !(0.0..=1.0).contains(&arc.minor_axis_ratio) + || arc.minor_axis_ratio == 0.0 + { return Err(format!("Hatch boundary path {pi} edge {ei} ellipse ratio must be within (0, 1]")); } if !arc.start_angle.is_finite() || !arc.end_angle.is_finite() { - return Err(format!("Hatch boundary path {pi} edge {ei} ellipse angles must be finite")); + return Err(format!( + "Hatch boundary path {pi} edge {ei} ellipse angles must be finite" + )); + } + let start = ellipse_point( + arc.center, + arc.major_axis_endpoint, + arc.minor_axis_ratio, + arc.start_angle, + ); + let end = ellipse_point( + arc.center, + arc.major_axis_endpoint, + arc.minor_axis_ratio, + arc.end_angle, + ); + if arc.counter_clockwise { + (start, end) + } else { + (end, start) } - let start = ellipse_point(arc.center, arc.major_axis_endpoint, arc.minor_axis_ratio, arc.start_angle); - let end = ellipse_point(arc.center, arc.major_axis_endpoint, arc.minor_axis_ratio, arc.end_angle); - if arc.counter_clockwise { (start, end) } else { (end, start) } } BoundaryEdge::Polyline(poly) => { if poly.vertices.len() < 3 { @@ -144,14 +185,21 @@ fn validate_hatch_paths(paths: &[crate::host::codec::entities::BoundaryPath]) -> } } if !poly.is_closed { - return Err(format!("Hatch boundary path {pi} edge {ei} polyline is open")); + return Err(format!( + "Hatch boundary path {pi} edge {ei} polyline is open" + )); } let first = poly.vertices.first().unwrap(); - (Vector2::new(first.x, first.y), Vector2::new(first.x, first.y)) + ( + Vector2::new(first.x, first.y), + Vector2::new(first.x, first.y), + ) } BoundaryEdge::Spline(spline) => { if spline.degree < 1 { - return Err(format!("Hatch boundary path {pi} edge {ei} spline degree must be at least 1")); + return Err(format!( + "Hatch boundary path {pi} edge {ei} spline degree must be at least 1" + )); } if spline.control_points.len() < spline.degree as usize + 1 { return Err(format!("Hatch boundary path {pi} edge {ei} spline has too few control points for its degree")); @@ -171,7 +219,9 @@ fn validate_hatch_paths(paths: &[crate::host::codec::entities::BoundaryPath]) -> return Err(format!("Hatch boundary path {pi} edge {ei} fit point {fi} has non-finite coordinates")); } } - for (name, tangent) in [("start", spline.start_tangent), ("end", spline.end_tangent)] { + for (name, tangent) in + [("start", spline.start_tangent), ("end", spline.end_tangent)] + { if !tangent.x.is_finite() || !tangent.y.is_finite() { return Err(format!("Hatch boundary path {pi} edge {ei} spline {name} tangent is non-finite")); } @@ -179,10 +229,16 @@ fn validate_hatch_paths(paths: &[crate::host::codec::entities::BoundaryPath]) -> let points = if spline.fit_points.len() >= 2 { spline.fit_points.clone() } else { - spline.control_points.iter().map(|p| Vector2::new(p.x, p.y)).collect() + spline + .control_points + .iter() + .map(|p| Vector2::new(p.x, p.y)) + .collect() }; if points.len() < 2 { - return Err(format!("Hatch boundary path {pi} edge {ei} spline has too few points")); + return Err(format!( + "Hatch boundary path {pi} edge {ei} spline has too few points" + )); } (*points.first().unwrap(), *points.last().unwrap()) } @@ -192,7 +248,9 @@ fn validate_hatch_paths(paths: &[crate::host::codec::entities::BoundaryPath]) -> for ei in 0..endpoints.len() { let next = (ei + 1) % endpoints.len(); if !close(endpoints[ei].1, endpoints[next].0) { - return Err(format!("Hatch boundary path {pi} is not closed between edges {ei} and {next}")); + return Err(format!( + "Hatch boundary path {pi} is not closed between edges {ei} and {next}" + )); } } } @@ -218,14 +276,20 @@ fn validate_hatch_pattern(value: &crate::host::codec::entities::Hatch) -> Result } for (index, line) in value.pattern.lines.iter().enumerate() { if !line.angle.is_finite() - || !line.base_point.x.is_finite() || !line.base_point.y.is_finite() - || !line.offset.x.is_finite() || !line.offset.y.is_finite() + || !line.base_point.x.is_finite() + || !line.base_point.y.is_finite() + || !line.offset.x.is_finite() + || !line.offset.y.is_finite() || line.dash_lengths.iter().any(|dash| !dash.is_finite()) { - return Err(format!("Hatch.pattern.lines[{index}] contains non-finite values")); + return Err(format!( + "Hatch.pattern.lines[{index}] contains non-finite values" + )); } if line.offset.x.hypot(line.offset.y) < 1e-12 { - return Err(format!("Hatch.pattern.lines[{index}].offset must be nonzero")); + return Err(format!( + "Hatch.pattern.lines[{index}].offset must be nonzero" + )); } } Ok(()) @@ -303,7 +367,11 @@ fn validate_leader( { return Err(format!( "Leader.{name} must be finite and {}", - if name == "text_height" { "greater than zero" } else { "non-negative" } + if name == "text_height" { + "greater than zero" + } else { + "non-negative" + } )); } } @@ -346,7 +414,10 @@ fn validate_mline( } for (vi, vertex) in new.vertices.iter().enumerate() { finite_vector(&format!("MLine.vertices[{vi}].position"), &vertex.position)?; - finite_vector(&format!("MLine.vertices[{vi}].direction"), &vertex.direction)?; + finite_vector( + &format!("MLine.vertices[{vi}].direction"), + &vertex.direction, + )?; finite_vector(&format!("MLine.vertices[{vi}].miter"), &vertex.miter)?; for (si, segment) in vertex.segments.iter().enumerate() { if segment @@ -369,7 +440,9 @@ fn validate_mline( return Err("MLine.vertices must not all coincide".into()); } } - if new.flags.contains(crate::host::codec::entities::MLineFlags::CLOSED) + if new + .flags + .contains(crate::host::codec::entities::MLineFlags::CLOSED) && new.vertices.len() < 3 { return Err("closed MLine requires at least 3 vertices".into()); @@ -398,49 +471,103 @@ fn validate_dimension( if base.style_name.trim().is_empty() { return Err("Dimension.style_name is empty".into()); } - let (points, scalars, distinct): (Vec<(&str, &crate::host::codec::types::Vector3)>, Vec<(&str, f64)>, Vec<(&str, &str)>) = match new { + let (points, scalars, distinct): ( + Vec<(&str, &crate::host::codec::types::Vector3)>, + Vec<(&str, f64)>, + Vec<(&str, &str)>, + ) = match new { D::Aligned(d) => ( - vec![("definition_point", &d.definition_point), ("first_point", &d.first_point), ("second_point", &d.second_point)], + vec![ + ("definition_point", &d.definition_point), + ("first_point", &d.first_point), + ("second_point", &d.second_point), + ], vec![("ext_line_rotation", d.ext_line_rotation)], vec![("first_point", "second_point")], ), D::Linear(d) => ( - vec![("definition_point", &d.definition_point), ("first_point", &d.first_point), ("second_point", &d.second_point)], - vec![("rotation", d.rotation), ("ext_line_rotation", d.ext_line_rotation)], + vec![ + ("definition_point", &d.definition_point), + ("first_point", &d.first_point), + ("second_point", &d.second_point), + ], + vec![ + ("rotation", d.rotation), + ("ext_line_rotation", d.ext_line_rotation), + ], vec![("first_point", "second_point")], ), D::Radius(d) => ( - vec![("definition_point", &d.definition_point), ("angle_vertex", &d.angle_vertex)], + vec![ + ("definition_point", &d.definition_point), + ("angle_vertex", &d.angle_vertex), + ], vec![("leader_length", d.leader_length)], vec![("definition_point", "angle_vertex")], ), D::Diameter(d) => ( - vec![("definition_point", &d.definition_point), ("angle_vertex", &d.angle_vertex)], + vec![ + ("definition_point", &d.definition_point), + ("angle_vertex", &d.angle_vertex), + ], vec![("leader_length", d.leader_length)], vec![("definition_point", "angle_vertex")], ), D::Angular2Ln(d) => ( - vec![("definition_point", &d.definition_point), ("dimension_arc", &d.dimension_arc), ("first_point", &d.first_point), ("second_point", &d.second_point), ("angle_vertex", &d.angle_vertex)], + vec![ + ("definition_point", &d.definition_point), + ("dimension_arc", &d.dimension_arc), + ("first_point", &d.first_point), + ("second_point", &d.second_point), + ("angle_vertex", &d.angle_vertex), + ], vec![], vec![], ), D::Angular3Pt(d) => ( - vec![("definition_point", &d.definition_point), ("first_point", &d.first_point), ("second_point", &d.second_point), ("angle_vertex", &d.angle_vertex)], + vec![ + ("definition_point", &d.definition_point), + ("first_point", &d.first_point), + ("second_point", &d.second_point), + ("angle_vertex", &d.angle_vertex), + ], vec![], - vec![("first_point", "angle_vertex"), ("second_point", "angle_vertex")], + vec![ + ("first_point", "angle_vertex"), + ("second_point", "angle_vertex"), + ], ), D::Ordinate(d) => ( - vec![("definition_point", &d.definition_point), ("feature_location", &d.feature_location), ("leader_endpoint", &d.leader_endpoint)], + vec![ + ("definition_point", &d.definition_point), + ("feature_location", &d.feature_location), + ("leader_endpoint", &d.leader_endpoint), + ], vec![], vec![], ), D::Arc(d) => ( - vec![("definition_point", &d.definition_point), ("first_extension_point", &d.first_extension_point), ("second_extension_point", &d.second_extension_point), ("center_point", &d.center_point), ("first_leader_point", &d.first_leader_point), ("second_leader_point", &d.second_leader_point)], - vec![("arc_start_parameter", d.arc_start_parameter), ("arc_end_parameter", d.arc_end_parameter)], + vec![ + ("definition_point", &d.definition_point), + ("first_extension_point", &d.first_extension_point), + ("second_extension_point", &d.second_extension_point), + ("center_point", &d.center_point), + ("first_leader_point", &d.first_leader_point), + ("second_leader_point", &d.second_leader_point), + ], + vec![ + ("arc_start_parameter", d.arc_start_parameter), + ("arc_end_parameter", d.arc_end_parameter), + ], vec![("first_extension_point", "second_extension_point")], ), D::LargeRadial(d) => ( - vec![("definition_point", &d.definition_point), ("chord_point", &d.chord_point), ("override_center", &d.override_center), ("jog_point", &d.jog_point)], + vec![ + ("definition_point", &d.definition_point), + ("chord_point", &d.chord_point), + ("override_center", &d.override_center), + ("jog_point", &d.jog_point), + ], vec![("jog_angle", d.jog_angle)], vec![("definition_point", "chord_point")], ), @@ -484,7 +611,11 @@ fn validate_multileader( if !value.is_finite() || value < 0.0 || (!allow_zero && value == 0.0) { return Err(format!( "MultiLeader.{name} must be finite and {}", - if allow_zero { "non-negative" } else { "greater than zero" } + if allow_zero { + "non-negative" + } else { + "greater than zero" + } )); } } @@ -515,21 +646,38 @@ fn validate_multileader( ] { finite_vector(&format!("MultiLeader.context.{name}"), point)?; } - if context.transform_matrix.iter().any(|value| !value.is_finite()) { + if context + .transform_matrix + .iter() + .any(|value| !value.is_finite()) + { return Err("MultiLeader.context.transform_matrix must be finite".into()); } for (ri, root) in context.leader_roots.iter().enumerate() { - finite_vector(&format!("MultiLeader.context.leader_roots[{ri}].connection_point"), &root.connection_point)?; - finite_vector(&format!("MultiLeader.context.leader_roots[{ri}].direction"), &root.direction)?; + finite_vector( + &format!("MultiLeader.context.leader_roots[{ri}].connection_point"), + &root.connection_point, + )?; + finite_vector( + &format!("MultiLeader.context.leader_roots[{ri}].direction"), + &root.direction, + )?; if !root.landing_distance.is_finite() { - return Err(format!("MultiLeader.context.leader_roots[{ri}].landing_distance must be finite")); + return Err(format!( + "MultiLeader.context.leader_roots[{ri}].landing_distance must be finite" + )); } for (li, line) in root.lines.iter().enumerate() { if line.points.is_empty() { - return Err(format!("MultiLeader.context.leader_roots[{ri}].lines[{li}] has no points")); + return Err(format!( + "MultiLeader.context.leader_roots[{ri}].lines[{li}] has no points" + )); } for (pi, point) in line.points.iter().enumerate() { - finite_vector(&format!("MultiLeader.context.leader_roots[{ri}].lines[{li}].points[{pi}]"), point)?; + finite_vector( + &format!("MultiLeader.context.leader_roots[{ri}].lines[{li}].points[{pi}]"), + point, + )?; } } } @@ -568,12 +716,16 @@ fn validate_table( } for (ci, column) in new.columns.iter().enumerate() { if !column.width.is_finite() || column.width <= 0.0 { - return Err(format!("Table.columns[{ci}].width must be finite and greater than zero")); + return Err(format!( + "Table.columns[{ci}].width must be finite and greater than zero" + )); } } for (ri, row) in new.rows.iter().enumerate() { if !row.height.is_finite() || row.height <= 0.0 { - return Err(format!("Table.rows[{ri}].height must be finite and greater than zero")); + return Err(format!( + "Table.rows[{ri}].height must be finite and greater than zero" + )); } if row.cells.len() != new.columns.len() { return Err(format!( @@ -583,11 +735,20 @@ fn validate_table( )); } for (ci, cell) in row.cells.iter().enumerate() { - if !cell.rotation.is_finite() || !cell.geometry_scale.is_finite() || !cell.block_scale.is_finite() { - return Err(format!("Table.rows[{ri}].cells[{ci}] has non-finite rotation or scale")); + if !cell.rotation.is_finite() + || !cell.geometry_scale.is_finite() + || !cell.block_scale.is_finite() + { + return Err(format!( + "Table.rows[{ri}].cells[{ci}] has non-finite rotation or scale" + )); } for (ki, content) in cell.contents.iter().enumerate() { - if !content.rotation.is_finite() || !content.scale.is_finite() || !content.text_height.is_finite() || content.text_height < 0.0 { + if !content.rotation.is_finite() + || !content.scale.is_finite() + || !content.text_height.is_finite() + || content.text_height < 0.0 + { return Err(format!( "Table.rows[{ri}].cells[{ci}].contents[{ki}] has invalid rotation, scale or text height" )); @@ -607,7 +768,9 @@ fn validate_table( || range.bottom_row >= rows || range.right_col >= columns { - return Err(format!("Table.merged_ranges[{mi}] lies outside the {rows}x{columns} grid")); + return Err(format!( + "Table.merged_ranges[{mi}] lies outside the {rows}x{columns} grid" + )); } for (mj, other) in new.merged_ranges.iter().enumerate().skip(mi + 1) { let separate = range.bottom_row < other.top_row @@ -615,7 +778,9 @@ fn validate_table( || range.right_col < other.left_col || other.right_col < range.left_col; if !separate { - return Err(format!("Table.merged_ranges[{mi}] overlaps merged_ranges[{mj}]")); + return Err(format!( + "Table.merged_ranges[{mi}] overlaps merged_ranges[{mj}]" + )); } } } @@ -651,7 +816,10 @@ fn validate_polygon_mesh( } solid_normal("PolygonMesh.normal", &new.normal)?; for (index, vertex) in new.vertices.iter().enumerate() { - finite_vector(&format!("PolygonMesh.vertices[{index}].location"), &vertex.location)?; + finite_vector( + &format!("PolygonMesh.vertices[{index}].location"), + &vertex.location, + )?; } Ok(()) } @@ -666,7 +834,11 @@ fn validate_polyface_mesh( if old == Some(new) { return Ok(()); } - if !new.elevation.is_finite() || !new.thickness.is_finite() || !new.start_width.is_finite() || !new.end_width.is_finite() { + if !new.elevation.is_finite() + || !new.thickness.is_finite() + || !new.start_width.is_finite() + || !new.end_width.is_finite() + { return Err("PolyfaceMesh elevation, thickness and widths must be finite".into()); } solid_normal("PolyfaceMesh.normal", &new.normal)?; @@ -674,9 +846,18 @@ fn validate_polyface_mesh( return Err("PolyfaceMesh.vertices requires at least 3 vertices".into()); } for (index, vertex) in new.vertices.iter().enumerate() { - finite_vector(&format!("PolyfaceMesh.vertices[{index}].location"), &vertex.location)?; - if !vertex.bulge.is_finite() || !vertex.start_width.is_finite() || !vertex.end_width.is_finite() || !vertex.curve_tangent.is_finite() { - return Err(format!("PolyfaceMesh.vertices[{index}] has non-finite bulge, width or tangent")); + finite_vector( + &format!("PolyfaceMesh.vertices[{index}].location"), + &vertex.location, + )?; + if !vertex.bulge.is_finite() + || !vertex.start_width.is_finite() + || !vertex.end_width.is_finite() + || !vertex.curve_tangent.is_finite() + { + return Err(format!( + "PolyfaceMesh.vertices[{index}] has non-finite bulge, width or tangent" + )); } } if new.faces.is_empty() { @@ -694,7 +875,9 @@ fn validate_polyface_mesh( } } if indices.iter().filter(|value| **value != 0).count() < 3 { - return Err(format!("PolyfaceMesh.faces[{index}] needs at least 3 vertex indices")); + return Err(format!( + "PolyfaceMesh.faces[{index}] needs at least 3 vertex indices" + )); } } Ok(()) @@ -722,7 +905,11 @@ fn validate_mesh( if face.vertices.len() < 3 { return Err(format!("Mesh.faces[{index}] needs at least 3 vertices")); } - if let Some(bad) = face.vertices.iter().find(|vertex| **vertex >= new.vertices.len()) { + if let Some(bad) = face + .vertices + .iter() + .find(|vertex| **vertex >= new.vertices.len()) + { return Err(format!( "Mesh.faces[{index}] references vertex {bad} but only {} exist", new.vertices.len() @@ -731,13 +918,20 @@ fn validate_mesh( } for (index, edge) in new.edges.iter().enumerate() { if edge.start >= new.vertices.len() || edge.end >= new.vertices.len() { - return Err(format!("Mesh.edges[{index}] references a vertex out of range")); + return Err(format!( + "Mesh.edges[{index}] references a vertex out of range" + )); } if edge.start == edge.end { return Err(format!("Mesh.edges[{index}] has identical endpoints")); } - if edge.crease.is_some_and(|crease| !crease.is_finite() || crease < 0.0) { - return Err(format!("Mesh.edges[{index}].crease must be finite and non-negative")); + if edge + .crease + .is_some_and(|crease| !crease.is_finite() || crease < 0.0) + { + return Err(format!( + "Mesh.edges[{index}].crease must be finite and non-negative" + )); } } Ok(()) @@ -765,7 +959,11 @@ fn validate_helix( finite_vector("Helix.axis_base_point", &new.axis_base_point)?; finite_vector("Helix.start_point", &new.start_point)?; solid_normal("Helix.axis_vector", &new.axis_vector)?; - for (name, value) in [("radius", new.radius), ("turns", new.turns), ("turn_height", new.turn_height)] { + for (name, value) in [ + ("radius", new.radius), + ("turns", new.turns), + ("turn_height", new.turn_height), + ] { if !value.is_finite() || value <= 0.0 { return Err(format!("Helix.{name} must be finite and greater than zero")); } @@ -774,7 +972,9 @@ fn validate_helix( let length = axis.x.hypot(axis.y).hypot(axis.z); let delta = new.start_point - new.axis_base_point; let along = (delta.x * axis.x + delta.y * axis.y + delta.z * axis.z) / length; - let radial = ((delta.x * delta.x + delta.y * delta.y + delta.z * delta.z) - along * along).max(0.0).sqrt(); + let radial = ((delta.x * delta.x + delta.y * delta.y + delta.z * delta.z) - along * along) + .max(0.0) + .sqrt(); if radial <= 1.0e-9 { return Err("Helix.start_point must not lie on the axis".into()); } @@ -793,14 +993,19 @@ fn validate_raster_image( } if let Some(old) = old { if old.file_path != new.file_path { - return Err("RasterImage.file_path is fixed at creation; the image definition owns it".into()); + return Err( + "RasterImage.file_path is fixed at creation; the image definition owns it".into(), + ); } } else if new.file_path.trim().is_empty() { return Err("RasterImage.file_path is empty".into()); } else if new.definition_handle.is_none_or(|handle| handle.is_null()) && !std::path::Path::new(&new.file_path).is_file() { - return Err(format!("RasterImage.file_path {:?} is not a readable file", new.file_path)); + return Err(format!( + "RasterImage.file_path {:?} is not a readable file", + new.file_path + )); } finite_vector("RasterImage.insertion_point", &new.insertion_point)?; finite_vector("RasterImage.u_vector", &new.u_vector)?; @@ -820,7 +1025,9 @@ fn validate_raster_image( } for (index, vertex) in new.clip_boundary.vertices.iter().enumerate() { if !vertex.x.is_finite() || !vertex.y.is_finite() { - return Err(format!("RasterImage.clip_boundary.vertices[{index}] must be finite")); + return Err(format!( + "RasterImage.clip_boundary.vertices[{index}] must be finite" + )); } } if new.clipping_enabled && new.clip_boundary.vertices.len() < 2 { @@ -847,7 +1054,8 @@ fn validate_wipeout( if cross.x.hypot(cross.y).hypot(cross.z) <= 1.0e-12 { return Err("Wipeout.u_vector and v_vector must be nonzero and not parallel".into()); } - if !new.size.x.is_finite() || !new.size.y.is_finite() || new.size.x <= 0.0 || new.size.y <= 0.0 { + if !new.size.x.is_finite() || !new.size.y.is_finite() || new.size.x <= 0.0 || new.size.y <= 0.0 + { return Err("Wipeout.size must be finite and greater than zero".into()); } if new.brightness > 100 || new.contrast > 100 || new.fade > 100 { @@ -858,7 +1066,9 @@ fn validate_wipeout( } for (index, vertex) in new.clip_boundary_vertices.iter().enumerate() { if !vertex.x.is_finite() || !vertex.y.is_finite() { - return Err(format!("Wipeout.clip_boundary_vertices[{index}] must be finite")); + return Err(format!( + "Wipeout.clip_boundary_vertices[{index}] must be finite" + )); } } let count = new.clip_boundary_vertices.len(); @@ -894,7 +1104,11 @@ fn validate_underlay( } finite_vector("Underlay.insertion_point", &new.insertion_point)?; solid_normal("Underlay.normal", &new.normal)?; - for (name, value) in [("x_scale", new.x_scale), ("y_scale", new.y_scale), ("z_scale", new.z_scale)] { + for (name, value) in [ + ("x_scale", new.x_scale), + ("y_scale", new.y_scale), + ("z_scale", new.z_scale), + ] { if !value.is_finite() || value == 0.0 { return Err(format!("Underlay.{name} must be finite and nonzero")); } @@ -910,7 +1124,9 @@ fn validate_underlay( } for (index, vertex) in new.clip_boundary_vertices.iter().enumerate() { if !vertex.x.is_finite() || !vertex.y.is_finite() { - return Err(format!("Underlay.clip_boundary_vertices[{index}] must be finite")); + return Err(format!( + "Underlay.clip_boundary_vertices[{index}] must be finite" + )); } } if !new.clip_boundary_vertices.is_empty() && new.clip_boundary_vertices.len() < 2 { @@ -933,12 +1149,24 @@ fn validate_viewport( finite_vector("Viewport.view_center", &new.view_center)?; finite_vector("Viewport.view_target", &new.view_target)?; solid_normal("Viewport.view_direction", &new.view_direction)?; - for (name, value) in [("width", new.width), ("height", new.height), ("view_height", new.view_height), ("lens_length", new.lens_length), ("custom_scale", new.custom_scale)] { + for (name, value) in [ + ("width", new.width), + ("height", new.height), + ("view_height", new.view_height), + ("lens_length", new.lens_length), + ("custom_scale", new.custom_scale), + ] { if !value.is_finite() || value <= 0.0 { - return Err(format!("Viewport.{name} must be finite and greater than zero")); + return Err(format!( + "Viewport.{name} must be finite and greater than zero" + )); } } - for (name, value) in [("front_clip_z", new.front_clip_z), ("back_clip_z", new.back_clip_z), ("twist_angle", new.twist_angle)] { + for (name, value) in [ + ("front_clip_z", new.front_clip_z), + ("back_clip_z", new.back_clip_z), + ("twist_angle", new.twist_angle), + ] { if !value.is_finite() { return Err(format!("Viewport.{name} must be finite")); } @@ -1077,7 +1305,10 @@ fn basic_entity_errors(new: &crate::host::EntityType) -> Vec { EntityType::Ellipse(v) => { chk!(finite_vector("Ellipse.center", &v.center)); chk!(solid_normal("Ellipse.major_axis", &v.major_axis)); - if !v.minor_axis_ratio.is_finite() || v.minor_axis_ratio <= 0.0 || v.minor_axis_ratio > 1.0 { + if !v.minor_axis_ratio.is_finite() + || v.minor_axis_ratio <= 0.0 + || v.minor_axis_ratio > 1.0 + { errs.push("Ellipse.minor_axis_ratio must be within (0, 1]".into()); } chk!(finite("Ellipse.start_parameter", v.start_parameter)); @@ -1119,8 +1350,13 @@ fn basic_entity_errors(new: &crate::host::EntityType) -> Vec { if !vertex.location.x.is_finite() || !vertex.location.y.is_finite() { errs.push(format!("LwPolyline.vertices[{i}].location must be finite")); } - if !vertex.bulge.is_finite() || !vertex.start_width.is_finite() || !vertex.end_width.is_finite() { - errs.push(format!("LwPolyline.vertices[{i}] has a non-finite bulge or width")); + if !vertex.bulge.is_finite() + || !vertex.start_width.is_finite() + || !vertex.end_width.is_finite() + { + errs.push(format!( + "LwPolyline.vertices[{i}] has a non-finite bulge or width" + )); } } if !v.constant_width.is_finite() || v.constant_width < 0.0 { @@ -1135,12 +1371,25 @@ fn basic_entity_errors(new: &crate::host::EntityType) -> Vec { errs.push("Polyline2D.vertices requires at least 2 vertices".into()); } for (i, vertex) in v.vertices.iter().enumerate() { - chk!(finite_vector(&format!("Polyline2D.vertices[{i}].location"), &vertex.location)); - if !vertex.bulge.is_finite() || !vertex.start_width.is_finite() || !vertex.end_width.is_finite() { - errs.push(format!("Polyline2D.vertices[{i}] has a non-finite bulge or width")); + chk!(finite_vector( + &format!("Polyline2D.vertices[{i}].location"), + &vertex.location + )); + if !vertex.bulge.is_finite() + || !vertex.start_width.is_finite() + || !vertex.end_width.is_finite() + { + errs.push(format!( + "Polyline2D.vertices[{i}] has a non-finite bulge or width" + )); } } - for (name, value) in [("start_width", v.start_width), ("end_width", v.end_width), ("thickness", v.thickness), ("elevation", v.elevation)] { + for (name, value) in [ + ("start_width", v.start_width), + ("end_width", v.end_width), + ("thickness", v.thickness), + ("elevation", v.elevation), + ] { chk!(finite(&format!("Polyline2D.{name}"), value)); } chk!(solid_normal("Polyline2D.normal", &v.normal)); @@ -1150,7 +1399,10 @@ fn basic_entity_errors(new: &crate::host::EntityType) -> Vec { errs.push("Polyline.vertices requires at least 2 vertices".into()); } for (i, vertex) in v.vertices.iter().enumerate() { - chk!(finite_vector(&format!("Polyline.vertices[{i}].location"), &vertex.location)); + chk!(finite_vector( + &format!("Polyline.vertices[{i}].location"), + &vertex.location + )); } } EntityType::Polyline3D(v) => { @@ -1158,9 +1410,16 @@ fn basic_entity_errors(new: &crate::host::EntityType) -> Vec { errs.push("Polyline3D.vertices requires at least 2 vertices".into()); } for (i, vertex) in v.vertices.iter().enumerate() { - chk!(finite_vector(&format!("Polyline3D.vertices[{i}].position"), &vertex.position)); + chk!(finite_vector( + &format!("Polyline3D.vertices[{i}].position"), + &vertex.position + )); } - for (name, value) in [("default_start_width", v.default_start_width), ("default_end_width", v.default_end_width), ("elevation", v.elevation)] { + for (name, value) in [ + ("default_start_width", v.default_start_width), + ("default_end_width", v.default_end_width), + ("elevation", v.elevation), + ] { chk!(finite(&format!("Polyline3D.{name}"), value)); } chk!(solid_normal("Polyline3D.normal", &v.normal)); @@ -1255,7 +1514,10 @@ fn validate_surface( if old.is_some_and(|old| old.u_isolines == new.u_isolines && old.v_isolines == new.v_isolines) { return Ok(()); } - for (name, value) in [("u_isolines", new.u_isolines), ("v_isolines", new.v_isolines)] { + for (name, value) in [ + ("u_isolines", new.u_isolines), + ("v_isolines", new.v_isolines), + ] { if !(0..=200).contains(&value) { return Err(format!("Surface.{name} must be between 0 and 200")); } @@ -1271,7 +1533,9 @@ fn validate_section_symbol( old: Option<&crate::host::codec::entities::SectionSymbol>, new: &crate::host::codec::entities::SectionSymbol, ) -> Result<(), String> { - if old.is_some_and(|old| old.points == new.points && old.symbol_scale.to_bits() == new.symbol_scale.to_bits()) { + if old.is_some_and(|old| { + old.points == new.points && old.symbol_scale.to_bits() == new.symbol_scale.to_bits() + }) { return Ok(()); } if !new.symbol_scale.is_finite() || new.symbol_scale <= 0.0 { @@ -1281,10 +1545,18 @@ fn validate_section_symbol( return Err("SectionSymbol.points requires at least 2 points".into()); } for (index, point) in new.points.iter().enumerate() { - finite_vector(&format!("SectionSymbol.points[{index}].point"), &point.point)?; - finite_vector(&format!("SectionSymbol.points[{index}].label_offset"), &point.label_offset)?; + finite_vector( + &format!("SectionSymbol.points[{index}].point"), + &point.point, + )?; + finite_vector( + &format!("SectionSymbol.points[{index}].label_offset"), + &point.label_offset, + )?; if !point.bulge.is_finite() { - return Err(format!("SectionSymbol.points[{index}].bulge must be finite")); + return Err(format!( + "SectionSymbol.points[{index}].bulge must be finite" + )); } } Ok(()) @@ -1403,10 +1675,18 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } finite_vector("Insert.insert_point", &value.insert_point)?; solid_normal("Insert.normal", &value.normal)?; - for (name, scale) in [("x_scale", value.x_scale()), ("y_scale", value.y_scale()), ("z_scale", value.z_scale())] { + for (name, scale) in [ + ("x_scale", value.x_scale()), + ("y_scale", value.y_scale()), + ("z_scale", value.z_scale()), + ] { insert_scale(&format!("Insert.{name}"), scale)?; } - for (name, number) in [("rotation", value.rotation), ("column_spacing", value.column_spacing), ("row_spacing", value.row_spacing)] { + for (name, number) in [ + ("rotation", value.rotation), + ("column_spacing", value.column_spacing), + ("row_spacing", value.row_spacing), + ] { if !number.is_finite() { return Err(format!("Insert.{name} must be finite")); } @@ -1512,7 +1792,9 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } for (si, sp) in value.seed_points.iter().enumerate() { if !sp.x.is_finite() || !sp.y.is_finite() { - return Err(format!("Hatch.seed_points[{si}] has non-finite coordinates")); + return Err(format!( + "Hatch.seed_points[{si}] has non-finite coordinates" + )); } } validate_hatch_paths(&value.paths)?; @@ -1553,8 +1835,7 @@ pub fn validate_entity_mutation( Err(format!("{name} must contain finite coordinates")) } }; - let same3 = |a: &crate::host::codec::types::Vector3, - b: &crate::host::codec::types::Vector3| { + let same3 = |a: &crate::host::codec::types::Vector3, b: &crate::host::codec::types::Vector3| { a.x.to_bits() == b.x.to_bits() && a.y.to_bits() == b.y.to_bits() && a.z.to_bits() == b.z.to_bits() @@ -1773,18 +2054,34 @@ pub fn validate_entity_mutation( return Err("Shape.style_handle is read-only".into()); } } - (EntityType::Dimension(old), EntityType::Dimension(new)) => validate_dimension(Some(old), new)?, - (EntityType::MultiLeader(old), EntityType::MultiLeader(new)) => validate_multileader(Some(old), new)?, + (EntityType::Dimension(old), EntityType::Dimension(new)) => { + validate_dimension(Some(old), new)? + } + (EntityType::MultiLeader(old), EntityType::MultiLeader(new)) => { + validate_multileader(Some(old), new)? + } (EntityType::Table(old), EntityType::Table(new)) => validate_table(Some(old), new)?, - (EntityType::PolygonMesh(old), EntityType::PolygonMesh(new)) => validate_polygon_mesh(Some(old), new)?, - (EntityType::PolyfaceMesh(old), EntityType::PolyfaceMesh(new)) => validate_polyface_mesh(Some(old), new)?, + (EntityType::PolygonMesh(old), EntityType::PolygonMesh(new)) => { + validate_polygon_mesh(Some(old), new)? + } + (EntityType::PolyfaceMesh(old), EntityType::PolyfaceMesh(new)) => { + validate_polyface_mesh(Some(old), new)? + } (EntityType::Mesh(old), EntityType::Mesh(new)) => validate_mesh(Some(old), new)?, (EntityType::Helix(old), EntityType::Helix(new)) => validate_helix(Some(old), new)?, - (EntityType::RasterImage(old), EntityType::RasterImage(new)) => validate_raster_image(Some(old), new)?, + (EntityType::RasterImage(old), EntityType::RasterImage(new)) => { + validate_raster_image(Some(old), new)? + } (EntityType::Wipeout(old), EntityType::Wipeout(new)) => validate_wipeout(Some(old), new)?, - (EntityType::Underlay(old), EntityType::Underlay(new)) => validate_underlay(Some(old), new)?, - (EntityType::Viewport(old), EntityType::Viewport(new)) => validate_viewport(Some(old), new)?, - (EntityType::ViewBorder(old), EntityType::ViewBorder(new)) => validate_view_border(Some(old), new)?, + (EntityType::Underlay(old), EntityType::Underlay(new)) => { + validate_underlay(Some(old), new)? + } + (EntityType::Viewport(old), EntityType::Viewport(new)) => { + validate_viewport(Some(old), new)? + } + (EntityType::ViewBorder(old), EntityType::ViewBorder(new)) => { + validate_view_border(Some(old), new)? + } (EntityType::Light(old), EntityType::Light(new)) => validate_light(Some(old), new)?, (EntityType::Ole2Frame(old), EntityType::Ole2Frame(new)) => { validate_ole_frame(Some(old), new)?; @@ -1793,7 +2090,9 @@ pub fn validate_entity_mutation( } } (EntityType::Surface(old), EntityType::Surface(new)) => validate_surface(Some(old), new)?, - (EntityType::SectionSymbol(old), EntityType::SectionSymbol(new)) => validate_section_symbol(Some(old), new)?, + (EntityType::SectionSymbol(old), EntityType::SectionSymbol(new)) => { + validate_section_symbol(Some(old), new)? + } (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -1948,7 +2247,9 @@ pub fn validate_entity_mutation( if old.seed_points != new.seed_points { for (si, sp) in new.seed_points.iter().enumerate() { if !sp.x.is_finite() || !sp.y.is_finite() { - return Err(format!("Hatch.seed_points[{si}] has non-finite coordinates")); + return Err(format!( + "Hatch.seed_points[{si}] has non-finite coordinates" + )); } } } @@ -2022,7 +2323,7 @@ pub fn validate_canvas_entity_references( .iter() .any(|style| style.handle == handle) }, - ); + ); if !found { return Err(format!( "Tolerance dimension style {:?} does not exist", @@ -2069,9 +2370,10 @@ pub fn validate_canvas_entity_references( _ => None, } { let name = value.block_name.trim(); - let record = document.block_records.get(name).ok_or_else(|| { - format!("Insert block {:?} does not exist", value.block_name) - })?; + let record = document + .block_records + .get(name) + .ok_or_else(|| format!("Insert block {:?} does not exist", value.block_name))?; if record.is_model_space() || record.is_paper_space() { return Err("Insert cannot reference a model-space or paper-space block".into()); } @@ -2122,19 +2424,30 @@ pub fn validate_canvas_entity_references( _ => false, }; if !matches { - return Err(format!("SectionSymbol {name} {handle:?} is not an existing {wanted} object")); + return Err(format!( + "SectionSymbol {name} {handle:?} is not an existing {wanted} object" + )); } } } if let EntityType::ViewBorder(value) = entity { - if !matches!(document.get_entity(value.active_viewport), Some(EntityType::Viewport(_))) { - return Err(format!("ViewBorder viewport {:?} does not exist", value.active_viewport)); + if !matches!( + document.get_entity(value.active_viewport), + Some(EntityType::Viewport(_)) + ) { + return Err(format!( + "ViewBorder viewport {:?} does not exist", + value.active_viewport + )); } if !matches!( document.objects.get(&value.scale_handle), Some(crate::host::codec::objects::ObjectType::Scale(_)) ) { - return Err(format!("ViewBorder scale {:?} does not exist", value.scale_handle)); + return Err(format!( + "ViewBorder scale {:?} does not exist", + value.scale_handle + )); } } if let EntityType::Viewport(value) = entity { @@ -2177,7 +2490,9 @@ pub fn validate_canvas_entity_references( document.objects.get(&handle), Some(crate::host::codec::objects::ObjectType::ImageDefinition(_)) ) { - return Err(format!("RasterImage image definition {handle:?} does not exist")); + return Err(format!( + "RasterImage image definition {handle:?} does not exist" + )); } } } @@ -2193,13 +2508,23 @@ pub fn validate_canvas_entity_references( for (ri, row) in value.rows.iter().enumerate() { for (ci, cell) in row.cells.iter().enumerate() { for content in &cell.contents { - if let Some(handle) = content.text_style_handle.filter(|handle| !handle.is_null()) { - if !document.text_styles.iter().any(|style| style.handle == handle) { + if let Some(handle) = + content.text_style_handle.filter(|handle| !handle.is_null()) + { + if !document + .text_styles + .iter() + .any(|style| style.handle == handle) + { return Err(format!("Table.rows[{ri}].cells[{ci}] text style handle {handle:?} does not exist")); } } if let Some(handle) = content.block_handle.filter(|handle| !handle.is_null()) { - if !document.block_records.iter().any(|record| record.handle == handle) { + if !document + .block_records + .iter() + .any(|record| record.handle == handle) + { return Err(format!("Table.rows[{ri}].cells[{ci}] block handle {handle:?} does not exist")); } } @@ -2209,34 +2534,63 @@ pub fn validate_canvas_entity_references( } if let EntityType::MultiLeader(value) = entity { use crate::host::codec::objects::ObjectType; - let handle_exists = |handle: Option, what: &str, ok: &dyn Fn(crate::host::codec::Handle) -> bool| { - match handle.filter(|handle| !handle.is_null()) { - Some(handle) if !ok(handle) => Err(format!("MultiLeader {what} handle {handle:?} does not exist")), - _ => Ok(()), - } + let handle_exists = + |handle: Option, + what: &str, + ok: &dyn Fn(crate::host::codec::Handle) -> bool| { + match handle.filter(|handle| !handle.is_null()) { + Some(handle) if !ok(handle) => Err(format!( + "MultiLeader {what} handle {handle:?} does not exist" + )), + _ => Ok(()), + } + }; + let is_block = |h| { + document + .block_records + .iter() + .any(|record| record.handle == h) }; - let is_block = |h| document.block_records.iter().any(|record| record.handle == h); let is_text_style = |h| document.text_styles.iter().any(|style| style.handle == h); let is_linetype = |h| document.line_types.iter().any(|ltype| ltype.handle == h); - let is_mleader_style = |h| matches!(document.objects.get(&h), Some(ObjectType::MultiLeaderStyle(_))); + let is_mleader_style = |h| { + matches!( + document.objects.get(&h), + Some(ObjectType::MultiLeaderStyle(_)) + ) + }; handle_exists(value.style_handle, "style", &is_mleader_style)?; handle_exists(value.text_style_handle, "text style", &is_text_style)?; - handle_exists(value.context.text_style_handle, "context text style", &is_text_style)?; + handle_exists( + value.context.text_style_handle, + "context text style", + &is_text_style, + )?; handle_exists(value.line_type_handle, "line type", &is_linetype)?; handle_exists(value.arrowhead_handle, "arrowhead block", &is_block)?; handle_exists(value.block_content_handle, "block content", &is_block)?; - handle_exists(value.context.block_content_handle, "context block content", &is_block)?; + handle_exists( + value.context.block_content_handle, + "context block content", + &is_block, + )?; } if let EntityType::Dimension(value) = entity { let name = value.base().style_name.trim(); - if !document.dim_styles.iter().any(|style| style.name.eq_ignore_ascii_case(name)) { - return Err(format!("Dimension style {:?} does not exist", value.base().style_name)); + if !document + .dim_styles + .iter() + .any(|style| style.name.eq_ignore_ascii_case(name)) + { + return Err(format!( + "Dimension style {:?} does not exist", + value.base().style_name + )); } } if let EntityType::MLine(value) = entity { - let style = resolve_mline_style(document, value).ok_or_else(|| { - format!("MLine style {:?} does not exist", value.style_name) - })?; + let style = resolve_mline_style(document, value) + .ok_or_else(|| format!("MLine style {:?} does not exist", value.style_name))?; if style.elements.is_empty() { return Err(format!("MLine style {:?} has no elements", style.name)); } @@ -2253,11 +2607,11 @@ pub fn validate_canvas_entity_references( } if let EntityType::Leader(value) = entity { use crate::host::codec::entities::LeaderCreationType; - if !document - .dim_styles - .iter() - .any(|style| style.name.eq_ignore_ascii_case(value.dimension_style.trim())) - { + if !document.dim_styles.iter().any(|style| { + style + .name + .eq_ignore_ascii_case(value.dimension_style.trim()) + }) { return Err(format!( "Leader dimension style {:?} does not exist", value.dimension_style @@ -2277,10 +2631,9 @@ pub fn validate_canvas_entity_references( ) })?; let expected = match value.creation_type { - LeaderCreationType::WithText => matches!( - annotation, - EntityType::Text(_) | EntityType::MText(_) - ), + LeaderCreationType::WithText => { + matches!(annotation, EntityType::Text(_) | EntityType::MText(_)) + } LeaderCreationType::WithTolerance => matches!(annotation, EntityType::Tolerance(_)), LeaderCreationType::WithBlock => matches!(annotation, EntityType::Insert(_)), LeaderCreationType::NoAnnotation => unreachable!(), @@ -2565,8 +2918,17 @@ mod tests { serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); for entry in &catalog.entity_kinds { let mut seen = std::collections::HashSet::new(); - for property in entry.properties.iter().filter(|p| p.model_access != ModelAccess::Unmapped) { - assert!(seen.insert(property.name.clone()), "{}: duplicate exposed name {}", entry.kind, property.name); + for property in entry + .properties + .iter() + .filter(|p| p.model_access != ModelAccess::Unmapped) + { + assert!( + seen.insert(property.name.clone()), + "{}: duplicate exposed name {}", + entry.kind, + property.name + ); } } } @@ -2648,7 +3010,7 @@ mod tests { entry .properties .iter() - .filter(|property| property.validation == "transaction_geometry") + .filter(|property| property.validation == "transaction_geometry") .map(|property| format!("{}.{}", entry.kind, property.name)) }) .collect(); @@ -2676,7 +3038,7 @@ mod tests { "Face3D.second_corner".to_owned(), "Face3D.third_corner".to_owned(), "Face3D.fourth_corner".to_owned(), - "Face3D.invisible_edges".to_owned(), + "Face3D.invisible_edges".to_owned(), "Insert.insert_point".to_owned(), "Insert.x_scale".to_owned(), "Insert.y_scale".to_owned(), @@ -2693,7 +3055,7 @@ mod tests { "Tolerance.text".to_owned(), "Tolerance.dimension_style_name".to_owned(), "Tolerance.text_height".to_owned(), - "Tolerance.dimension_gap".to_owned(), + "Tolerance.dimension_gap".to_owned(), "Shape.insertion_point".to_owned(), "Shape.size".to_owned(), "Shape.shape_name".to_owned(), @@ -2706,24 +3068,24 @@ mod tests { "Shape.style_name".to_owned(), "AttributeDefinition.tag".to_owned(), "AttributeDefinition.prompt".to_owned(), - "AttributeDefinition.default_value".to_owned(), - "AttributeDefinition.insertion_point".to_owned(), - "AttributeDefinition.alignment_point".to_owned(), + "AttributeDefinition.default_value".to_owned(), + "AttributeDefinition.insertion_point".to_owned(), + "AttributeDefinition.alignment_point".to_owned(), "AttributeDefinition.height".to_owned(), "AttributeDefinition.rotation".to_owned(), - "AttributeDefinition.width_factor".to_owned(), - "AttributeDefinition.oblique_angle".to_owned(), - "AttributeDefinition.text_style".to_owned(), - "AttributeDefinition.text_generation_flags".to_owned(), - "AttributeDefinition.horizontal_alignment".to_owned(), - "AttributeDefinition.vertical_alignment".to_owned(), + "AttributeDefinition.width_factor".to_owned(), + "AttributeDefinition.oblique_angle".to_owned(), + "AttributeDefinition.text_style".to_owned(), + "AttributeDefinition.text_generation_flags".to_owned(), + "AttributeDefinition.horizontal_alignment".to_owned(), + "AttributeDefinition.vertical_alignment".to_owned(), "AttributeDefinition.flags".to_owned(), "AttributeDefinition.field_length".to_owned(), "AttributeDefinition.normal".to_owned(), "AttributeDefinition.mtext_flag".to_owned(), "AttributeDefinition.is_multiline".to_owned(), "AttributeDefinition.line_count".to_owned(), - "AttributeDefinition.lock_position".to_owned(), + "AttributeDefinition.lock_position".to_owned(), "AttributeEntity.tag".to_owned(), "AttributeEntity.value".to_owned(), "AttributeEntity.insertion_point".to_owned(), @@ -2982,10 +3344,8 @@ mod tests { fn tolerance_geometry_and_style_references_are_validated() { use crate::host::codec::{self, types::Vector3}; let document = codec::CadDocument::new(); - let mut tolerance = codec::entities::Tolerance::with_text( - Vector3::new(1.0, 2.0, 0.0), - "{\\Fgdt;p}%%v0.1", - ); + let mut tolerance = + codec::entities::Tolerance::with_text(Vector3::new(1.0, 2.0, 0.0), "{\\Fgdt;p}%%v0.1"); let entity = codec::EntityType::Tolerance(tolerance.clone()); validate_new_canvas_entity(&entity).unwrap(); validate_canvas_entity_references(&document, &entity).unwrap(); @@ -3033,22 +3393,25 @@ mod tests { .contains("does not match")); let mut missing = leader.clone(); missing.annotation_handle = codec::Handle::new(0xdead); - assert!(validate_canvas_entity_references( - &document, - &codec::EntityType::Leader(missing) - ) - .unwrap_err() - .contains("does not exist")); + assert!( + validate_canvas_entity_references(&document, &codec::EntityType::Leader(missing)) + .unwrap_err() + .contains("does not exist") + ); let mut none_with_handle = leader.clone(); none_with_handle.creation_type = LeaderCreationType::NoAnnotation; - assert!(validate_new_canvas_entity(&codec::EntityType::Leader(none_with_handle)) - .unwrap_err() - .contains("no annotation")); + assert!( + validate_new_canvas_entity(&codec::EntityType::Leader(none_with_handle)) + .unwrap_err() + .contains("no annotation") + ); let mut one_point = leader.clone(); one_point.vertices.truncate(1); - assert!(validate_new_canvas_entity(&codec::EntityType::Leader(one_point)) - .unwrap_err() - .contains("at least 2")); + assert!( + validate_new_canvas_entity(&codec::EntityType::Leader(one_point)) + .unwrap_err() + .contains("at least 2") + ); let mut bad_style = leader.clone(); bad_style.dimension_style = "Missing".into(); assert!(validate_canvas_entity_references( @@ -3075,7 +3438,9 @@ mod tests { let document = codec::CadDocument::new(); let mut mline = codec::entities::MLine::new(); for x in [0.0, 5.0] { - mline.vertices.push(MLineVertex::new(Vector3::new(x, 0.0, 0.0))); + mline + .vertices + .push(MLineVertex::new(Vector3::new(x, 0.0, 0.0))); } let mut entity = codec::EntityType::MLine(mline.clone()); validate_new_canvas_entity(&entity).unwrap(); @@ -3084,21 +3449,36 @@ mod tests { let mut missing = mline.clone(); missing.style_name = "Missing".into(); - assert!(bind_canvas_entity_references(&document, &mut codec::EntityType::MLine(missing)) - .unwrap_err().contains("does not exist")); + assert!( + bind_canvas_entity_references(&document, &mut codec::EntityType::MLine(missing)) + .unwrap_err() + .contains("does not exist") + ); let mut wrong_segments = mline.clone(); wrong_segments.vertices[0].init_segments(3); - assert!(bind_canvas_entity_references(&document, &mut codec::EntityType::MLine(wrong_segments)) - .unwrap_err().contains("segments but style")); + assert!(bind_canvas_entity_references( + &document, + &mut codec::EntityType::MLine(wrong_segments) + ) + .unwrap_err() + .contains("segments but style")); let mut one = mline.clone(); one.vertices.truncate(1); - assert!(validate_new_canvas_entity(&codec::EntityType::MLine(one)).unwrap_err().contains("at least 2")); + assert!(validate_new_canvas_entity(&codec::EntityType::MLine(one)) + .unwrap_err() + .contains("at least 2")); let mut closed = mline.clone(); closed.flags |= codec::entities::MLineFlags::CLOSED; - assert!(validate_new_canvas_entity(&codec::EntityType::MLine(closed)).unwrap_err().contains("at least 3")); + assert!( + validate_new_canvas_entity(&codec::EntityType::MLine(closed)) + .unwrap_err() + .contains("at least 3") + ); let mut scale = mline.clone(); scale.scale_factor = 0.0; - assert!(validate_new_canvas_entity(&codec::EntityType::MLine(scale)).unwrap_err().contains("nonzero")); + assert!(validate_new_canvas_entity(&codec::EntityType::MLine(scale)) + .unwrap_err() + .contains("nonzero")); } #[cfg(feature = "host")] @@ -3129,11 +3509,9 @@ mod tests { unreachable!() }; bad.size = 0.0; - assert!( - validate_new_canvas_entity(&codec::EntityType::Shape(bad)) - .unwrap_err() - .contains("greater than zero") - ); + assert!(validate_new_canvas_entity(&codec::EntityType::Shape(bad)) + .unwrap_err() + .contains("greater than zero")); } #[test] @@ -3257,11 +3635,9 @@ mod tests { let mut xline = codec::entities::XLine::default(); xline.base_point.x = f64::NAN; - assert!( - validate_new_canvas_entity(&codec::EntityType::XLine(xline)) - .unwrap_err() - .contains("XLine.base_point") - ); + assert!(validate_new_canvas_entity(&codec::EntityType::XLine(xline)) + .unwrap_err() + .contains("XLine.base_point")); let solid = codec::entities::Solid::new( Vector3::ZERO, @@ -3438,35 +3814,42 @@ mod tests { let mut invalid = hatch.clone(); invalid.paths.clear(); - assert!(validate_new_canvas_entity(&codec::EntityType::Hatch(invalid)) - .unwrap_err() - .contains("at least one boundary path")); + assert!( + validate_new_canvas_entity(&codec::EntityType::Hatch(invalid)) + .unwrap_err() + .contains("at least one boundary path") + ); let mut invalid = hatch.clone(); invalid.is_solid = false; invalid.pattern_scale = 0.0; - assert!(validate_new_canvas_entity(&codec::EntityType::Hatch(invalid)) - .unwrap_err() - .contains("greater than zero")); + assert!( + validate_new_canvas_entity(&codec::EntityType::Hatch(invalid)) + .unwrap_err() + .contains("greater than zero") + ); let mut document = codec::CadDocument::new(); - let boundary = document.add_entity(codec::EntityType::Line( - codec::entities::Line::from_points( + let boundary = document + .add_entity(codec::EntityType::Line(codec::entities::Line::from_points( codec::types::Vector3::ZERO, codec::types::Vector3::UNIT_X, - ), - )).unwrap(); + ))) + .unwrap(); let mut associative = hatch.clone(); associative.is_associative = true; associative.paths[0].boundary_handles.push(boundary); validate_canvas_entity_references( &document, &codec::EntityType::Hatch(associative.clone()), - ).unwrap(); + ) + .unwrap(); associative.paths[0].boundary_handles[0] = codec::Handle::new(u64::MAX); assert!(validate_canvas_entity_references( &document, &codec::EntityType::Hatch(associative), - ).unwrap_err().contains("does not exist")); + ) + .unwrap_err() + .contains("does not exist")); } } diff --git a/crates/ocs_plugin_api/src/entity_coverage_types.rs b/crates/ocs_plugin_api/src/entity_coverage_types.rs index 5d101c694..49654d36a 100644 --- a/crates/ocs_plugin_api/src/entity_coverage_types.rs +++ b/crates/ocs_plugin_api/src/entity_coverage_types.rs @@ -1,5 +1,4 @@ /// Stable, language-neutral entity capability schema. - use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 5880a0335..ad9966ff1 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -65,15 +65,37 @@ pub enum LogLevel { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SolidPrimitive { /// Axis-aligned box from its center and full extents. - Box { center: [f64; 3], size: [f64; 3] }, + Box { + center: [f64; 3], + size: [f64; 3], + }, /// Right triangular prism from its minimum corner and extents. - Wedge { origin: [f64; 3], size: [f64; 3] }, + Wedge { + origin: [f64; 3], + size: [f64; 3], + }, /// Cylinder standing on the plane through `center`. - Cylinder { center: [f64; 3], radius: f64, height: f64 }, - Sphere { center: [f64; 3], radius: f64 }, - Torus { center: [f64; 3], major: f64, minor: f64 }, + Cylinder { + center: [f64; 3], + radius: f64, + height: f64, + }, + Sphere { + center: [f64; 3], + radius: f64, + }, + Torus { + center: [f64; 3], + major: f64, + minor: f64, + }, /// Regular pyramid with `sides` base edges. - Pyramid { center: [f64; 3], radius: f64, height: f64, sides: u32 }, + Pyramid { + center: [f64; 3], + radius: f64, + height: f64, + sides: u32, + }, } /// How two solids combine. @@ -159,12 +181,20 @@ pub enum TableOperation { /// Create a dimension style, optionally copying an existing one first. /// `properties` is a JSON object of DimStyle fields (see the Python docs); /// handles and xref fields are managed by the host and refused. - DimStyleCreate { name: String, copy_from: Option, properties: String }, + DimStyleCreate { + name: String, + copy_from: Option, + properties: String, + }, /// Change only the DimStyle fields the JSON object `properties` names. DimStyleModify { name: String, properties: String }, /// Rename a text or dimension style; references to it follow. `Standard` /// is never renamed and a case-only change is refused. - StyleRename { kind: TableStyleKind, from: String, to: String }, + StyleRename { + kind: TableStyleKind, + from: String, + to: String, + }, /// Delete a text or dimension style that is neither current nor in use. /// `Standard` is never deleted. StyleDelete { kind: TableStyleKind, name: String }, @@ -200,9 +230,17 @@ pub enum TableOperation { /// Create a simple linetype. `pattern` is signed lengths in drawing units: /// positive is a dash, negative a gap, zero a dot (2-12 elements with at /// least one dash or dot and one gap). Returns the linetype's handle. - LinetypeCreate { name: String, description: String, pattern: Vec }, + LinetypeCreate { + name: String, + description: String, + pattern: Vec, + }, /// Change a simple linetype's description or pattern in place. - LinetypeModify { name: String, description: Option, pattern: Option> }, + LinetypeModify { + name: String, + description: Option, + pattern: Option>, + }, /// Rename a linetype; layers and entities that use it follow. LinetypeRename { from: String, to: String }, /// Delete a linetype that no layer, entity, dimension style or the current @@ -257,7 +295,10 @@ pub struct TextStyleConfig { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SolidOperation { /// Build a new solid on `layer` (layer `0` when `None`). - Create { primitive: SolidPrimitive, layer: Option }, + Create { + primitive: SolidPrimitive, + layer: Option, + }, /// Apply a rigid transform, given as a column-major 4x4 matrix, to an /// existing solid. Scaling and shear are refused. Transform { handle: Handle, matrix: [f64; 16] }, @@ -265,12 +306,25 @@ pub enum SolidOperation { /// ellipse, closed polyline or spline, ...). The new region goes on /// `layer`, or on the source's layer when `None`; `delete_source` erases /// the profile afterwards, as the REGION command does. - RegionFromProfile { source: Handle, layer: Option, delete_source: bool }, + RegionFromProfile { + source: Handle, + layer: Option, + delete_source: bool, + }, /// Build a plane surface from one closed planar profile entity. - SurfaceFromProfile { source: Handle, layer: Option, delete_source: bool }, + SurfaceFromProfile { + source: Handle, + layer: Option, + delete_source: bool, + }, /// Extrude a planar profile along `direction`: a solid when the profile is /// closed, a surface when it is open. - Extrude { source: Handle, direction: [f64; 3], layer: Option, delete_source: bool }, + Extrude { + source: Handle, + direction: [f64; 3], + layer: Option, + delete_source: bool, + }, /// Combine two solids into a new one. Both operands are consumed unless /// `keep_operands`; the result goes on `layer` or the first operand's /// layer. The kernel refuses some cases (coincident faces, cuts it has no @@ -280,7 +334,12 @@ pub enum SolidOperation { /// `origin`, `width` drawing units wide, keeping the picture's aspect. /// PNG, JPEG and BMP are stored as read; other formats are re-encoded as /// PNG. The frame goes on `layer` (layer `0` when `None`). - EmbedPicture { path: String, origin: [f64; 3], width: f64, layer: Option }, + EmbedPicture { + path: String, + origin: [f64; 3], + width: f64, + layer: Option, + }, Boolean { first: Handle, second: Handle, @@ -302,21 +361,41 @@ pub enum HostSettingValue { #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum HostNotification { - InputLine { line: String }, + InputLine { + line: String, + }, Cancel, - DocumentChanged { version: u64 }, - SelectionChanged { handles: Vec }, + DocumentChanged { + version: u64, + }, + SelectionChanged { + handles: Vec, + }, Raw(Vec), /// V4 snapshot changed for a specific tab. Discriminant 5. - DocumentChangedV4 { tab_id: u64, version: u64 }, + DocumentChangedV4 { + tab_id: u64, + version: u64, + }, /// V4 tab closed notification. Discriminant 6. - DocumentTabClosed { tab_id: u64 }, + DocumentTabClosed { + tab_id: u64, + }, /// V4 selection changed for a specific tab. Discriminant 7. - SelectionChangedV4 { tab_id: u64, handles: Vec }, + SelectionChangedV4 { + tab_id: u64, + handles: Vec, + }, /// V7 active interactive-command state for a tab. Discriminant 8. - CommandStateChanged { tab_id: u64, command: Option }, + CommandStateChanged { + tab_id: u64, + command: Option, + }, /// V7 drawing epoch, broadcast even without an open shared view. Discriminant 9. - DrawingChanged { tab_id: u64, epoch: u64 }, + DrawingChanged { + tab_id: u64, + epoch: u64, + }, /// Fallback for notification variants added in future minor revisions. /// Carries the raw bincode payload so an older peer can ignore it without /// failing deserialization. @@ -329,57 +408,44 @@ impl Serialize for HostNotification { match self { HostNotification::InputLine { line } => { bytes.push(0); - bincode::serialize_into(&mut bytes, line) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, line).map_err(serde::ser::Error::custom)?; } HostNotification::Cancel => bytes.push(1), HostNotification::DocumentChanged { version } => { bytes.push(2); - bincode::serialize_into(&mut bytes, version) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, version).map_err(serde::ser::Error::custom)?; } HostNotification::SelectionChanged { handles } => { bytes.push(3); - bincode::serialize_into(&mut bytes, handles) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, handles).map_err(serde::ser::Error::custom)?; } HostNotification::Raw(data) => { bytes.push(4); - bincode::serialize_into(&mut bytes, data) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, data).map_err(serde::ser::Error::custom)?; } HostNotification::DocumentChangedV4 { tab_id, version } => { bytes.push(5); - bincode::serialize_into(&mut bytes, tab_id) - .map_err(serde::ser::Error::custom)?; - bincode::serialize_into(&mut bytes, version) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, version).map_err(serde::ser::Error::custom)?; } HostNotification::DocumentTabClosed { tab_id } => { bytes.push(6); - bincode::serialize_into(&mut bytes, tab_id) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; } HostNotification::SelectionChangedV4 { tab_id, handles } => { bytes.push(7); - bincode::serialize_into(&mut bytes, tab_id) - .map_err(serde::ser::Error::custom)?; - bincode::serialize_into(&mut bytes, handles) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, handles).map_err(serde::ser::Error::custom)?; } HostNotification::CommandStateChanged { tab_id, command } => { bytes.push(8); - bincode::serialize_into(&mut bytes, tab_id) - .map_err(serde::ser::Error::custom)?; - bincode::serialize_into(&mut bytes, command) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, command).map_err(serde::ser::Error::custom)?; } HostNotification::DrawingChanged { tab_id, epoch } => { bytes.push(9); - bincode::serialize_into(&mut bytes, tab_id) - .map_err(serde::ser::Error::custom)?; - bincode::serialize_into(&mut bytes, epoch) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, epoch).map_err(serde::ser::Error::custom)?; } HostNotification::Unknown(raw) => bytes.extend_from_slice(raw), } @@ -434,14 +500,28 @@ impl<'de> Deserialize<'de> for HostNotification { #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum PluginNotification { - Output { text: String }, - Error { text: String }, - Prompt { text: String }, - Progress { percent: u8 }, - Log { level: LogLevel, text: String }, + Output { + text: String, + }, + Error { + text: String, + }, + Prompt { + text: String, + }, + Progress { + percent: u8, + }, + Log { + level: LogLevel, + text: String, + }, Raw(Vec), /// V4 REPL status update. Discriminant 6. - ReplStatus { status: String, message: String }, + ReplStatus { + status: String, + message: String, + }, /// Fallback for notification variants added in future minor revisions. /// Carries the raw bincode payload so an older peer can ignore it without /// failing deserialization. @@ -454,42 +534,33 @@ impl Serialize for PluginNotification { match self { PluginNotification::Output { text } => { bytes.push(0); - bincode::serialize_into(&mut bytes, text) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, text).map_err(serde::ser::Error::custom)?; } PluginNotification::Error { text } => { bytes.push(1); - bincode::serialize_into(&mut bytes, text) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, text).map_err(serde::ser::Error::custom)?; } PluginNotification::Prompt { text } => { bytes.push(2); - bincode::serialize_into(&mut bytes, text) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, text).map_err(serde::ser::Error::custom)?; } PluginNotification::Progress { percent } => { bytes.push(3); - bincode::serialize_into(&mut bytes, percent) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, percent).map_err(serde::ser::Error::custom)?; } PluginNotification::Log { level, text } => { bytes.push(4); - bincode::serialize_into(&mut bytes, level) - .map_err(serde::ser::Error::custom)?; - bincode::serialize_into(&mut bytes, text) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, level).map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, text).map_err(serde::ser::Error::custom)?; } PluginNotification::Raw(data) => { bytes.push(5); - bincode::serialize_into(&mut bytes, data) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, data).map_err(serde::ser::Error::custom)?; } PluginNotification::ReplStatus { status, message } => { bytes.push(6); - bincode::serialize_into(&mut bytes, status) - .map_err(serde::ser::Error::custom)?; - bincode::serialize_into(&mut bytes, message) - .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, status).map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, message).map_err(serde::ser::Error::custom)?; } PluginNotification::Unknown(raw) => bytes.extend_from_slice(raw), } @@ -519,8 +590,8 @@ impl<'de> Deserialize<'de> for PluginNotification { .map(|percent| PluginNotification::Progress { percent }) .map_err(serde::de::Error::custom), 4 => { - let (level, text): (LogLevel, String) = bincode::deserialize(rest) - .map_err(serde::de::Error::custom)?; + let (level, text): (LogLevel, String) = + bincode::deserialize(rest).map_err(serde::de::Error::custom)?; Ok(PluginNotification::Log { level, text }) } 5 => bincode::deserialize(rest) @@ -641,7 +712,10 @@ pub enum PreviewWire { impl PreviewWire { /// Create a polyline preview wire with default host cyan color. pub fn new(points: Vec<[f64; 3]>) -> Self { - Self::Polyline { points, color: None } + Self::Polyline { + points, + color: None, + } } /// Optional RGBA color (0.0 to 1.0) specified for this preview wire. @@ -869,9 +943,7 @@ pub trait HostApi { /// Returns the optional `command_id` used to correlate the notification /// with a running command, and the notification payload. Long-running /// plugins should call this periodically to drain the bounded queue. - fn try_recv_notification( - &mut self, - ) -> Option<(Option, HostNotification)> { + fn try_recv_notification(&mut self) -> Option<(Option, HostNotification)> { None } @@ -949,7 +1021,9 @@ pub trait HostApi { } /// Current ordered selection in this session's tab (API v7). - fn selection(&self) -> Vec { Vec::new() } + fn selection(&self) -> Vec { + Vec::new() + } /// Replace this tab's selection with exactly these handles, in order. /// Missing or duplicate handles reject the request (API v7). diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index 1d10b1130..829a84255 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -16,7 +16,7 @@ use crate::ipc::protocol::{ HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, RunnerHandshake, }; use crate::ipc::transport::{recv, send}; -use crate::shm::{DocumentViewInfo, SharedDocumentReader, DocumentViewData}; +use crate::shm::{DocumentViewData, DocumentViewInfo, SharedDocumentReader}; /// Shared registry of active interactive commands, keyed by host-assigned id. pub type InteractiveRegistry = Rc>>>; @@ -60,7 +60,10 @@ impl IpcClient { &self, req: PluginRequest, ) -> Result { - send(&mut self.stream.borrow_mut(), &PluginToHost::Request(Box::new(req)))?; + send( + &mut self.stream.borrow_mut(), + &PluginToHost::Request(Box::new(req)), + )?; loop { match recv::(&mut self.stream.borrow_mut())? { HostToPlugin::Response(resp) => return Ok(*resp), @@ -389,16 +392,18 @@ impl HostApi for PluginHostApi { } } match self.doc_view.borrow().as_ref() { - Some(info) => match SharedDocumentReader::::open(Path::new(&info.path)) { - Ok(reader) => Box::new(reader), - Err(e) => { - eprintln!( - "[plugin] failed to open document view at {}: {e}", - info.path - ); - Box::new(EmptyDocumentReader) + Some(info) => { + match SharedDocumentReader::::open(Path::new(&info.path)) { + Ok(reader) => Box::new(reader), + Err(e) => { + eprintln!( + "[plugin] failed to open document view at {}: {e}", + info.path + ); + Box::new(EmptyDocumentReader) + } } - }, + } None => Box::new(EmptyDocumentReader), } } @@ -419,7 +424,9 @@ impl HostApi for PluginHostApi { } fn system_variable(&self, name: &str) -> Option { - match self.client.request(PluginRequest::GetSystemVariable { name: name.to_owned() }) { + match self.client.request(PluginRequest::GetSystemVariable { + name: name.to_owned(), + }) { Ok(PluginResponse::SystemVariable(value)) => value, _ => None, } @@ -451,11 +458,16 @@ impl HostApi for PluginHostApi { label: &str, entities: Vec, ) -> Result<(), String> { - match self.client.request(PluginRequest::UpdateEntitiesTransaction { - label: label.to_owned(), entities, - }) { + match self + .client + .request(PluginRequest::UpdateEntitiesTransaction { + label: label.to_owned(), + entities, + }) { Ok(PluginResponse::EntityTransactionResult(result)) => { - if result.is_ok() { self.document_cache = OnceCell::new(); } + if result.is_ok() { + self.document_cache = OnceCell::new(); + } result } Ok(PluginResponse::Error(error)) => Err(error), @@ -471,10 +483,18 @@ impl HostApi for PluginHostApi { } } - fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result { - match self.client.request(PluginRequest::SolidOperation { operation }) { + fn solid_operation( + &mut self, + operation: crate::host::SolidOperation, + ) -> Result { + match self + .client + .request(PluginRequest::SolidOperation { operation }) + { Ok(PluginResponse::SolidResult(result)) => { - if result.is_ok() { self.document_cache = OnceCell::new(); } + if result.is_ok() { + self.document_cache = OnceCell::new(); + } result } Ok(PluginResponse::Error(error)) => Err(error), @@ -483,7 +503,10 @@ impl HostApi for PluginHostApi { } } - fn run_command(&mut self, request: crate::host::CommandRequest) -> Result { + fn run_command( + &mut self, + request: crate::host::CommandRequest, + ) -> Result { match self.client.request(PluginRequest::RunCommand { request }) { Ok(PluginResponse::CommandResult(result)) => { self.document_cache = OnceCell::new(); @@ -495,10 +518,18 @@ impl HostApi for PluginHostApi { } } - fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result { - match self.client.request(PluginRequest::TableOperation { operation }) { + fn table_operation( + &mut self, + operation: crate::host::TableOperation, + ) -> Result { + match self + .client + .request(PluginRequest::TableOperation { operation }) + { Ok(PluginResponse::TableResult(result)) => { - if result.is_ok() { self.document_cache = OnceCell::new(); } + if result.is_ok() { + self.document_cache = OnceCell::new(); + } result } Ok(PluginResponse::Error(error)) => Err(error), @@ -508,7 +539,9 @@ impl HostApi for PluginHostApi { } fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { - match self.client.request(PluginRequest::SetSelection { handles: handles.to_vec() }) { + match self.client.request(PluginRequest::SetSelection { + handles: handles.to_vec(), + }) { Ok(PluginResponse::SelectionResult(result)) => result, Ok(PluginResponse::Error(error)) => Err(error), Ok(other) => Err(format!("unexpected selection response: {other:?}")), @@ -555,7 +588,10 @@ impl HostApi for PluginHostApi { } fn execute_command(&mut self, cmd: &str) -> bool { - match self.client.request(PluginRequest::ExecuteCommand(cmd.to_string())) { + match self + .client + .request(PluginRequest::ExecuteCommand(cmd.to_string())) + { Ok(PluginResponse::Bool(b)) => b, Ok(other) => { eprintln!("[plugin] unexpected ExecuteCommand response: {other:?}"); @@ -662,7 +698,11 @@ mod tests { }, other => panic!("unexpected: {other:?}"), } - send(&mut peer, &HostToPlugin::Response(Box::new(PluginResponse::Ok))).unwrap(); + send( + &mut peer, + &HostToPlugin::Response(Box::new(PluginResponse::Ok)), + ) + .unwrap(); }); api.push_info("hello host"); peer_handle.join().unwrap(); @@ -719,7 +759,10 @@ mod tests { EntityType::Point(Point::new()), ]); peer_handle.join().unwrap(); - assert_eq!(handles, vec![Handle::new(10), Handle::new(11), Handle::new(12)]); + assert_eq!( + handles, + vec![Handle::new(10), Handle::new(11), Handle::new(12)] + ); } #[test] @@ -734,7 +777,11 @@ mod tests { }, other => panic!("unexpected: {other:?}"), } - send(&mut peer, &HostToPlugin::Response(Box::new(PluginResponse::Bool(true)))).unwrap(); + send( + &mut peer, + &HostToPlugin::Response(Box::new(PluginResponse::Bool(true))), + ) + .unwrap(); }); assert!(api.update_entity(EntityType::Point(Point::new()))); peer_handle.join().unwrap(); @@ -754,7 +801,11 @@ mod tests { }, other => panic!("unexpected: {other:?}"), } - send(&mut peer, &HostToPlugin::Response(Box::new(PluginResponse::Bool(true)))).unwrap(); + send( + &mut peer, + &HostToPlugin::Response(Box::new(PluginResponse::Bool(true))), + ) + .unwrap(); }); assert!(api.remove_entity(Handle::new(7))); peer_handle.join().unwrap(); diff --git a/crates/ocs_plugin_api/src/ipc/mod.rs b/crates/ocs_plugin_api/src/ipc/mod.rs index 971b47e9b..befb23aac 100644 --- a/crates/ocs_plugin_api/src/ipc/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/mod.rs @@ -23,13 +23,13 @@ pub mod client; #[cfg(feature = "host")] pub mod protocol; #[cfg(feature = "host")] +pub mod proxy; +#[cfg(feature = "host")] pub mod server; #[cfg(feature = "host")] pub mod transport; #[cfg(feature = "host")] pub mod v4; -#[cfg(feature = "host")] -pub mod proxy; #[cfg(all(test, feature = "host"))] mod tests { diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index a7f282be7..3832140af 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -21,9 +21,9 @@ use crate::host::{CommandSource, CommandStep, HostSettingValue}; use crate::manifest::ApiVersion; use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup}; +pub use crate::host::PreviewWire; pub use codec::xdata::{ExtendedDataRecord, XDataValue}; pub use codec::{CadDocument, EntityType, Handle}; -pub use crate::host::PreviewWire; /// Events the host forwards to an active plugin `InteractiveCommand`. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -50,7 +50,10 @@ pub enum InteractiveEvent { #[derive(Debug, Serialize, Deserialize)] pub enum RunnerHandshake { Token(String), - TokenV4 { token: String, protocol_version: u32 }, + TokenV4 { + token: String, + protocol_version: u32, + }, } /// Environment variable through which the host passes the pre-shared @@ -83,7 +86,9 @@ pub enum HostRequest { tab_index: usize, }, /// V7: release an interactive command after completion or cancellation. - DropInteractive { command_id: u64 }, + DropInteractive { + command_id: u64, + }, /// V7: cursor move preview update during an interactive command. CursorMove { command_id: u64, @@ -145,13 +150,19 @@ pub enum PluginRequest { AddEntities(Vec), /// V4: ask the host to create/refresh a tab-keyed shared-memory document /// view and return the file path + current version. - OpenDocumentViewV4 { tab_id: u64 }, + OpenDocumentViewV4 { + tab_id: u64, + }, /// V4: ask the host to close the tab-keyed shared-memory document view. - CloseDocumentViewV4 { tab_id: u64 }, + CloseDocumentViewV4 { + tab_id: u64, + }, /// V4: ask the host for the stable tab identifier of the active tab. GetTabId, /// V5: ask the host for the filesystem path of the document in `tab_id`. - DocumentPath { tab_id: u64 }, + DocumentPath { + tab_id: u64, + }, /// Add a layer to the active document with full initial properties. AddLayer(crate::host::LayerConfig), /// Modify specified properties of an existing layer in the active document. @@ -159,20 +170,36 @@ pub enum PluginRequest { /// Run a command on the active document tab's command line (AutoLISP style). ExecuteCommand(String), /// Read a host-managed setting without nested command dispatch. - GetSystemVariable { name: String }, + GetSystemVariable { + name: String, + }, /// Change a host-managed setting without nested command dispatch. - SetSystemVariable { name: String, value: HostSettingValue }, + SetSystemVariable { + name: String, + value: HostSettingValue, + }, /// V7: validate and replace existing entities in a single undo step. - UpdateEntitiesTransaction { label: String, entities: Vec }, + UpdateEntitiesTransaction { + label: String, + entities: Vec, + }, /// V7: synchronous selection read/write for the dispatch tab. GetSelection, - SetSelection { handles: Vec }, + SetSelection { + handles: Vec, + }, /// V7 (additive): kernel-backed solid create or transform. - SolidOperation { operation: crate::host::SolidOperation }, + SolidOperation { + operation: crate::host::SolidOperation, + }, /// V7 (additive): drawing table record create/modify/rename/delete. - TableOperation { operation: crate::host::TableOperation }, + TableOperation { + operation: crate::host::TableOperation, + }, /// V7 (additive): drive an OCS command. - RunCommand { request: crate::host::CommandRequest }, + RunCommand { + request: crate::host::CommandRequest, + }, } /// Responses the host sends back for `PluginRequest`. diff --git a/crates/ocs_plugin_api/src/ipc/proxy.rs b/crates/ocs_plugin_api/src/ipc/proxy.rs index 6b5172b08..7816903d6 100644 --- a/crates/ocs_plugin_api/src/ipc/proxy.rs +++ b/crates/ocs_plugin_api/src/ipc/proxy.rs @@ -50,7 +50,10 @@ impl From for PluginRequestError { } /// Send a length-framed bincode message over a generic stream. -pub fn send_framed(writer: &mut W, msg: &impl serde::Serialize) -> Result<(), ProxyError> { +pub fn send_framed( + writer: &mut W, + msg: &impl serde::Serialize, +) -> Result<(), ProxyError> { let bytes = bincode::serialize(msg)?; if bytes.len() > MAX_MESSAGE_SIZE { return Err(ProxyError::TooLarge(bytes.len())); @@ -63,7 +66,9 @@ pub fn send_framed(writer: &mut W, msg: &impl serde::Serialize) -> Res } /// Receive a length-framed bincode message from a generic stream. -pub fn recv_framed(reader: &mut R) -> Result { +pub fn recv_framed( + reader: &mut R, +) -> Result { let mut len_buf = [0u8; 8]; reader.read_exact(&mut len_buf)?; let len = u64::from_le_bytes(len_buf) as usize; @@ -114,10 +119,12 @@ fn read_with_poll( let mut off = 0; while off < buf.len() { match reader.read(&mut buf[off..]) { - Ok(0) => return Err(PollError::Io(ProxyError::Io(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "proxy disconnected", - )))), + Ok(0) => { + return Err(PollError::Io(ProxyError::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "proxy disconnected", + )))) + } Ok(n) => off += n, Err(e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => { if let Err(err) = poll() { @@ -386,7 +393,8 @@ mod tests { run_request_proxy(listener, sender, token).ok(); }); - let sender = ProxyPluginRequestSender::connect_with_token("127.0.0.1", port, &token).unwrap(); + let sender = + ProxyPluginRequestSender::connect_with_token("127.0.0.1", port, &token).unwrap(); let resp = sender .request(PluginRequest::PushInfo("hello proxy".to_string())) .unwrap(); diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 1941184d2..2856700a2 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -110,8 +110,12 @@ pub fn handle_plugin_request( ), GetSelection => PluginResponse::Selection(host.selection()), SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)), - SolidOperation { operation } => PluginResponse::SolidResult(host.solid_operation(operation)), - TableOperation { operation } => PluginResponse::TableResult(host.table_operation(operation)), + SolidOperation { operation } => { + PluginResponse::SolidResult(host.solid_operation(operation)) + } + TableOperation { operation } => { + PluginResponse::TableResult(host.table_operation(operation)) + } RunCommand { request } => PluginResponse::CommandResult(host.run_command(request)), } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 41834fc05..22cea2f08 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -16,8 +16,8 @@ use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName}; use interprocess::TryClone; use crate::host::{ - DocumentReader, ExecutionResult, HostApi, HostNotification, InteractiveCommand, - PluginNotification, PluginRequestSender, PluginRequestError, ReaderEntity, HostSettingValue, + DocumentReader, ExecutionResult, HostApi, HostNotification, HostSettingValue, + InteractiveCommand, PluginNotification, PluginRequestError, PluginRequestSender, ReaderEntity, }; use crate::ipc::protocol::{ HostRequest, HostResponse, PluginRequest, PluginResponse, RunnerHandshake, @@ -26,7 +26,7 @@ use crate::ipc::transport::{recv, send, TransportError}; use crate::ipc::v4::protocol::{ HostToPluginV4, NotificationEnvelope, PluginToHostV4, V4_PROTOCOL_VERSION, }; -use crate::shm::{DocumentViewInfo, SharedDocumentReader, DocumentViewData}; +use crate::shm::{DocumentViewData, DocumentViewInfo, SharedDocumentReader}; /// Default capacity of the bounded host→plugin notification queue. const DEFAULT_NOTIFY_QUEUE_SIZE: usize = 1024; @@ -89,8 +89,7 @@ impl V4Client { ))) })?; - let (notify_tx, notify_rx) = - mpsc::sync_channel(notify_queue_size()); + let (notify_tx, notify_rx) = mpsc::sync_channel(notify_queue_size()); let (runner_tx, runner_rx) = mpsc::channel(); let shared = Arc::new(Shared { @@ -101,14 +100,7 @@ impl V4Client { let shared_for_reader = Arc::clone(&shared); let notifications = Arc::new(Mutex::new(notify_rx)); - std::thread::spawn(move || { - reader_thread( - reader, - shared_for_reader, - notify_tx, - runner_tx, - ) - }); + std::thread::spawn(move || reader_thread(reader, shared_for_reader, notify_tx, runner_tx)); Ok(Self { shared, @@ -133,7 +125,11 @@ impl V4Client { /// Non-blocking poll for host-to-plugin notifications. pub fn try_recv_notification(&self) -> Option<(Option, HostNotification)> { - self.notifications.lock().unwrap_or_else(|e| e.into_inner()).try_recv().ok() + self.notifications + .lock() + .unwrap_or_else(|e| e.into_inner()) + .try_recv() + .ok() } /// Send a plugin response for a host request. @@ -143,18 +139,11 @@ impl V4Client { resp: HostResponse, ) -> Result<(), crate::ipc::transport::TransportError> { let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner()); - send( - &mut writer, - &PluginToHostV4::Response { id, payload: resp }, - ) + send(&mut writer, &PluginToHostV4::Response { id, payload: resp }) } /// Send a plugin-to-host notification. - pub fn notify_plugin( - &self, - command_id: Option, - notification: PluginNotification, - ) { + pub fn notify_plugin(&self, command_id: Option, notification: PluginNotification) { let envelope = NotificationEnvelope { command_id, payload: notification, @@ -168,10 +157,7 @@ impl V4Client { /// Build a callback that sends a delayed `CodeExecutionResult` response for /// the given request id. Used by `start_execute_code` implementations that /// run REPL code on a background thread. - pub fn execute_code_responder( - &self, - id: u64, - ) -> Box { + pub fn execute_code_responder(&self, id: u64) -> Box { let shared = Arc::clone(&self.shared); Box::new(move |result| { let mut writer = shared.writer.lock().unwrap_or_else(|e| e.into_inner()); @@ -235,7 +221,10 @@ fn reader_thread( } } Ok(HostToPluginV4::Notification(envelope)) => { - if notify_tx.try_send((envelope.command_id, envelope.payload)).is_err() { + if notify_tx + .try_send((envelope.command_id, envelope.payload)) + .is_err() + { let now = Instant::now(); let should_log = last_drop_log .map(|t| now.duration_since(t) >= DROP_LOG_INTERVAL) @@ -247,7 +236,10 @@ fn reader_thread( } } Ok(HostToPluginV4::Request { id, payload }) => { - if runner_tx.send(RunnerFrame::Request { id, payload }).is_err() { + if runner_tx + .send(RunnerFrame::Request { id, payload }) + .is_err() + { // Runner loop has exited; stop reading. break; } @@ -510,9 +502,7 @@ impl HostApi for V4PluginHostApi { fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool { let app = record.application_name.clone(); - match self - .request(PluginRequest::WriteRecord { handle, record }) - { + match self.request(PluginRequest::WriteRecord { handle, record }) { Ok(PluginResponse::Bool(b)) => { if b { self.record_cache.borrow_mut().remove(&(handle, app)); @@ -631,16 +621,18 @@ impl HostApi for V4PluginHostApi { } } match self.doc_view.borrow().as_ref() { - Some(info) => match SharedDocumentReader::::open(Path::new(&info.path)) { - Ok(reader) => Box::new(reader), - Err(e) => { - eprintln!( - "[plugin] failed to open document view at {}: {e}", - info.path - ); - Box::new(EmptyDocumentReader) + Some(info) => { + match SharedDocumentReader::::open(Path::new(&info.path)) { + Ok(reader) => Box::new(reader), + Err(e) => { + eprintln!( + "[plugin] failed to open document view at {}: {e}", + info.path + ); + Box::new(EmptyDocumentReader) + } } - }, + } None => Box::new(EmptyDocumentReader), } } @@ -657,7 +649,11 @@ impl HostApi for V4PluginHostApi { } fn try_recv_notification(&mut self) -> Option<(Option, HostNotification)> { - self.notifications.lock().unwrap_or_else(|e| e.into_inner()).try_recv().ok() + self.notifications + .lock() + .unwrap_or_else(|e| e.into_inner()) + .try_recv() + .ok() } fn plugin_request_sender(&self) -> Option> { @@ -734,7 +730,9 @@ impl HostApi for V4PluginHostApi { } fn system_variable(&self, name: &str) -> Option { - match self.request(PluginRequest::GetSystemVariable { name: name.to_owned() }) { + match self.request(PluginRequest::GetSystemVariable { + name: name.to_owned(), + }) { Ok(PluginResponse::SystemVariable(value)) => value, _ => None, } @@ -767,10 +765,13 @@ impl HostApi for V4PluginHostApi { entities: Vec, ) -> Result<(), String> { match self.request(PluginRequest::UpdateEntitiesTransaction { - label: label.to_owned(), entities, + label: label.to_owned(), + entities, }) { Ok(PluginResponse::EntityTransactionResult(result)) => { - if result.is_ok() { self.document_cache = OnceCell::new(); } + if result.is_ok() { + self.document_cache = OnceCell::new(); + } result } Ok(PluginResponse::Error(error)) => Err(error), @@ -786,10 +787,15 @@ impl HostApi for V4PluginHostApi { } } - fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result { + fn solid_operation( + &mut self, + operation: crate::host::SolidOperation, + ) -> Result { match self.request(PluginRequest::SolidOperation { operation }) { Ok(PluginResponse::SolidResult(result)) => { - if result.is_ok() { self.document_cache = OnceCell::new(); } + if result.is_ok() { + self.document_cache = OnceCell::new(); + } result } Ok(PluginResponse::Error(error)) => Err(error), @@ -798,7 +804,10 @@ impl HostApi for V4PluginHostApi { } } - fn run_command(&mut self, request: crate::host::CommandRequest) -> Result { + fn run_command( + &mut self, + request: crate::host::CommandRequest, + ) -> Result { match self.request(PluginRequest::RunCommand { request }) { Ok(PluginResponse::CommandResult(result)) => { self.document_cache = OnceCell::new(); @@ -810,10 +819,15 @@ impl HostApi for V4PluginHostApi { } } - fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result { + fn table_operation( + &mut self, + operation: crate::host::TableOperation, + ) -> Result { match self.request(PluginRequest::TableOperation { operation }) { Ok(PluginResponse::TableResult(result)) => { - if result.is_ok() { self.document_cache = OnceCell::new(); } + if result.is_ok() { + self.document_cache = OnceCell::new(); + } result } Ok(PluginResponse::Error(error)) => Err(error), @@ -823,7 +837,9 @@ impl HostApi for V4PluginHostApi { } fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { - match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) { + match self.request(PluginRequest::SetSelection { + handles: handles.to_vec(), + }) { Ok(PluginResponse::SelectionResult(result)) => result, Ok(PluginResponse::Error(error)) => Err(error), Ok(other) => Err(format!("unexpected selection response: {other:?}")), @@ -899,9 +915,7 @@ impl V4Client { }); let shared_for_reader = Arc::clone(&shared); let notifications = Arc::new(Mutex::new(notify_rx)); - std::thread::spawn(move || { - reader_thread(reader, shared_for_reader, notify_tx, runner_tx) - }); + std::thread::spawn(move || reader_thread(reader, shared_for_reader, notify_tx, runner_tx)); Self { shared, notifications, @@ -1081,7 +1095,10 @@ mod tests { }); assert!(matches!(first.join().unwrap(), PluginResponse::Bool(true))); - assert!(matches!(second.join().unwrap(), PluginResponse::Bool(false))); + assert!(matches!( + second.join().unwrap(), + PluginResponse::Bool(false) + )); runner.join().unwrap(); } diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 7f5822603..0b4e206d9 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -53,7 +53,10 @@ pub enum PluginToHostV4 { tab_id: Option, payload: PluginRequest, }, - Response { id: u64, payload: HostResponse }, + Response { + id: u64, + payload: HostResponse, + }, Notification(NotificationEnvelope), } @@ -95,11 +98,17 @@ mod tests { let bytes = bincode::serialize(¬ification).unwrap(); let decoded: HostNotification = bincode::deserialize(&bytes).unwrap(); assert_eq!(decoded, notification); - let stopped = HostNotification::CommandStateChanged { tab_id: 42, command: None }; + let stopped = HostNotification::CommandStateChanged { + tab_id: 42, + command: None, + }; let bytes = bincode::serialize(&stopped).unwrap(); let decoded: HostNotification = bincode::deserialize(&bytes).unwrap(); assert_eq!(decoded, stopped); - let changed = HostNotification::DrawingChanged { tab_id: 42, epoch: 7 }; + let changed = HostNotification::DrawingChanged { + tab_id: 42, + epoch: 7, + }; let bytes = bincode::serialize(&changed).unwrap(); let decoded: HostNotification = bincode::deserialize(&bytes).unwrap(); assert_eq!(decoded, changed); @@ -109,19 +118,33 @@ mod tests { fn command_request_and_outcome_roundtrip() { use crate::host::{CommandOutcome, CommandRequest}; for request in [ - CommandRequest::Run { line: "LINE 0,0 1,1".into() }, - CommandRequest::Start { name: "OFFSET".into() }, - CommandRequest::Point { point: [1.0, 2.0, 3.0] }, + CommandRequest::Run { + line: "LINE 0,0 1,1".into(), + }, + CommandRequest::Start { + name: "OFFSET".into(), + }, + CommandRequest::Point { + point: [1.0, 2.0, 3.0], + }, CommandRequest::Text { text: "2".into() }, CommandRequest::Token { text: "R".into() }, - CommandRequest::Entity { handle: codec::Handle::new(9), point: [0.0; 3] }, + CommandRequest::Entity { + handle: codec::Handle::new(9), + point: [0.0; 3], + }, CommandRequest::Selection, CommandRequest::Enter, CommandRequest::Cancel, ] { - let bytes = bincode::serialize(&PluginRequest::RunCommand { request: request.clone() }).unwrap(); - assert!(matches!(bincode::deserialize::(&bytes).unwrap(), - PluginRequest::RunCommand { request: decoded } if decoded == request)); + let bytes = bincode::serialize(&PluginRequest::RunCommand { + request: request.clone(), + }) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::RunCommand { request: decoded } if decoded == request) + ); } let outcome = CommandOutcome { status: "waiting_input".into(), @@ -137,8 +160,10 @@ mod tests { }; for result in [Ok(outcome), Err("refused".to_owned())] { let bytes = bincode::serialize(&PluginResponse::CommandResult(result.clone())).unwrap(); - assert!(matches!(bincode::deserialize::(&bytes).unwrap(), - PluginResponse::CommandResult(decoded) if decoded == result)); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::CommandResult(decoded) if decoded == result) + ); } } @@ -155,19 +180,60 @@ mod tests { ..Default::default() }, }, - TableOperation::LayerModify { config: LayerConfig { name: "Walls".into(), off: Some(true), ..Default::default() } }, - TableOperation::LayerRename { from: "A".into(), to: "B".into() }, - TableOperation::LayerDelete { name: "A".into(), erase_objects: true }, + TableOperation::LayerModify { + config: LayerConfig { + name: "Walls".into(), + off: Some(true), + ..Default::default() + }, + }, + TableOperation::LayerRename { + from: "A".into(), + to: "B".into(), + }, + TableOperation::LayerDelete { + name: "A".into(), + erase_objects: true, + }, TableOperation::LayerSetCurrent { name: "A".into() }, TableOperation::TextStyleCreate { - config: crate::host::TextStyleConfig { name: "T".into(), height: Some(2.5), oblique_angle: Some(0.2), backward: Some(true), ..Default::default() }, + config: crate::host::TextStyleConfig { + name: "T".into(), + height: Some(2.5), + oblique_angle: Some(0.2), + backward: Some(true), + ..Default::default() + }, + }, + TableOperation::TextStyleModify { + config: crate::host::TextStyleConfig { + name: "T".into(), + font_file: Some("romans".into()), + ..Default::default() + }, + }, + TableOperation::DimStyleCreate { + name: "D".into(), + copy_from: Some("Standard".into()), + properties: "{\"dimscale\":2}".into(), + }, + TableOperation::DimStyleModify { + name: "D".into(), + properties: "{}".into(), + }, + TableOperation::StyleRename { + kind: crate::host::TableStyleKind::Text, + from: "A".into(), + to: "B".into(), + }, + TableOperation::StyleDelete { + kind: crate::host::TableStyleKind::Dim, + name: "A".into(), + }, + TableOperation::StyleSetCurrent { + kind: crate::host::TableStyleKind::Dim, + name: "A".into(), }, - TableOperation::TextStyleModify { config: crate::host::TextStyleConfig { name: "T".into(), font_file: Some("romans".into()), ..Default::default() } }, - TableOperation::DimStyleCreate { name: "D".into(), copy_from: Some("Standard".into()), properties: "{\"dimscale\":2}".into() }, - TableOperation::DimStyleModify { name: "D".into(), properties: "{}".into() }, - TableOperation::StyleRename { kind: crate::host::TableStyleKind::Text, from: "A".into(), to: "B".into() }, - TableOperation::StyleDelete { kind: crate::host::TableStyleKind::Dim, name: "A".into() }, - TableOperation::StyleSetCurrent { kind: crate::host::TableStyleKind::Dim, name: "A".into() }, TableOperation::BlockCreate { name: "B".into(), entities: vec![codec::Handle::new(4), codec::Handle::new(5)], @@ -175,31 +241,67 @@ mod tests { erase_originals: true, description: Some("d".into()), }, - TableOperation::BlockModify { name: "B".into(), description: None, explodable: Some(false), scale_uniformly: Some(true) }, - TableOperation::BlockRename { from: "B".into(), to: "C".into() }, + TableOperation::BlockModify { + name: "B".into(), + description: None, + explodable: Some(false), + scale_uniformly: Some(true), + }, + TableOperation::BlockRename { + from: "B".into(), + to: "C".into(), + }, TableOperation::BlockDelete { name: "C".into() }, TableOperation::BlockEntityAdd { block: "C".into(), entity: codec::EntityType::Line(codec::entities::Line::new()), }, - TableOperation::LinetypeCreate { name: "L".into(), description: "d".into(), pattern: vec![1.0, -1.0] }, - TableOperation::LinetypeModify { name: "L".into(), description: None, pattern: Some(vec![2.0, -2.0]) }, - TableOperation::LinetypeRename { from: "L".into(), to: "M".into() }, + TableOperation::LinetypeCreate { + name: "L".into(), + description: "d".into(), + pattern: vec![1.0, -1.0], + }, + TableOperation::LinetypeModify { + name: "L".into(), + description: None, + pattern: Some(vec![2.0, -2.0]), + }, + TableOperation::LinetypeRename { + from: "L".into(), + to: "M".into(), + }, TableOperation::LinetypeDelete { name: "M".into() }, TableOperation::LayoutCreate { name: "S".into() }, - TableOperation::LayoutRename { from: "S".into(), to: "T".into() }, + TableOperation::LayoutRename { + from: "S".into(), + to: "T".into(), + }, TableOperation::LayoutDelete { name: "T".into() }, - TableOperation::LayoutSetCurrent { name: "Model".into() }, - TableOperation::LayoutSetPage { name: "S".into(), paper_size: Some([420.0, 297.0]), rotation: Some(90), scale: Some([1.0, 50.0]) }, + TableOperation::LayoutSetCurrent { + name: "Model".into(), + }, + TableOperation::LayoutSetPage { + name: "S".into(), + paper_size: Some([420.0, 297.0]), + rotation: Some(90), + scale: Some([1.0, 50.0]), + }, ] { - let bytes = bincode::serialize(&PluginRequest::TableOperation { operation: operation.clone() }).unwrap(); - assert!(matches!(bincode::deserialize::(&bytes).unwrap(), - PluginRequest::TableOperation { operation: decoded } if decoded == operation)); + let bytes = bincode::serialize(&PluginRequest::TableOperation { + operation: operation.clone(), + }) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::TableOperation { operation: decoded } if decoded == operation) + ); } for result in [Ok(codec::Handle::new(9)), Err("refused".to_owned())] { let bytes = bincode::serialize(&PluginResponse::TableResult(result.clone())).unwrap(); - assert!(matches!(bincode::deserialize::(&bytes).unwrap(), - PluginResponse::TableResult(decoded) if decoded == result)); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::TableResult(decoded) if decoded == result) + ); } } @@ -208,12 +310,29 @@ mod tests { use crate::host::{SolidOperation, SolidPrimitive}; for operation in [ SolidOperation::Create { - primitive: SolidPrimitive::Pyramid { center: [1.0, 2.0, 3.0], radius: 4.0, height: 5.0, sides: 6 }, + primitive: SolidPrimitive::Pyramid { + center: [1.0, 2.0, 3.0], + radius: 4.0, + height: 5.0, + sides: 6, + }, layer: Some("SOLIDS".into()), }, - SolidOperation::Transform { handle: codec::Handle::new(7), matrix: [1.0; 16] }, - SolidOperation::RegionFromProfile { source: codec::Handle::new(3), layer: None, delete_source: true }, - SolidOperation::EmbedPicture { path: "p.png".into(), origin: [0.0; 3], width: 5.0, layer: None }, + SolidOperation::Transform { + handle: codec::Handle::new(7), + matrix: [1.0; 16], + }, + SolidOperation::RegionFromProfile { + source: codec::Handle::new(3), + layer: None, + delete_source: true, + }, + SolidOperation::EmbedPicture { + path: "p.png".into(), + origin: [0.0; 3], + width: 5.0, + layer: None, + }, SolidOperation::Boolean { first: codec::Handle::new(1), second: codec::Handle::new(2), @@ -221,30 +340,52 @@ mod tests { layer: None, keep_operands: false, }, - SolidOperation::SurfaceFromProfile { source: codec::Handle::new(4), layer: Some("S".into()), delete_source: false }, - SolidOperation::Extrude { source: codec::Handle::new(5), direction: [0.0, 0.0, 2.0], layer: None, delete_source: true }, + SolidOperation::SurfaceFromProfile { + source: codec::Handle::new(4), + layer: Some("S".into()), + delete_source: false, + }, + SolidOperation::Extrude { + source: codec::Handle::new(5), + direction: [0.0, 0.0, 2.0], + layer: None, + delete_source: true, + }, ] { - let bytes = bincode::serialize(&PluginRequest::SolidOperation { operation: operation.clone() }).unwrap(); - assert!(matches!(bincode::deserialize::(&bytes).unwrap(), - PluginRequest::SolidOperation { operation: decoded } if decoded == operation)); + let bytes = bincode::serialize(&PluginRequest::SolidOperation { + operation: operation.clone(), + }) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::SolidOperation { operation: decoded } if decoded == operation) + ); } for result in [Ok(codec::Handle::new(9)), Err("refused".to_owned())] { let bytes = bincode::serialize(&PluginResponse::SolidResult(result.clone())).unwrap(); - assert!(matches!(bincode::deserialize::(&bytes).unwrap(), - PluginResponse::SolidResult(decoded) if decoded == result)); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::SolidResult(decoded) if decoded == result) + ); } } #[test] fn selection_request_and_response_roundtrip() { - let request = PluginRequest::SetSelection { handles: vec![codec::Handle::new(9)] }; + let request = PluginRequest::SetSelection { + handles: vec![codec::Handle::new(9)], + }; let bytes = bincode::serialize(&request).unwrap(); - assert!(matches!(bincode::deserialize::(&bytes).unwrap(), - PluginRequest::SetSelection { handles } if handles == vec![codec::Handle::new(9)])); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::SetSelection { handles } if handles == vec![codec::Handle::new(9)]) + ); let response = PluginResponse::Selection(vec![codec::Handle::new(9)]); let bytes = bincode::serialize(&response).unwrap(); - assert!(matches!(bincode::deserialize::(&bytes).unwrap(), - PluginResponse::Selection(handles) if handles == vec![codec::Handle::new(9)])); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::Selection(handles) if handles == vec![codec::Handle::new(9)]) + ); } #[test] diff --git a/crates/ocs_plugin_api/src/ipc/v4/server.rs b/crates/ocs_plugin_api/src/ipc/v4/server.rs index 08e38c669..d6fdea7d5 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/server.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/server.rs @@ -26,7 +26,10 @@ macro_rules! vlog { /// A frame delivered from the V4 reader thread to the host main thread. pub enum HostIncoming { - Response { id: u64, payload: crate::ipc::protocol::HostResponse }, + Response { + id: u64, + payload: crate::ipc::protocol::HostResponse, + }, Request { id: u64, tab_id: Option, diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index c36f23d49..e315972eb 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -32,12 +32,12 @@ //! - `runner` — child-process runner entry point. //! - `shm` — shared-memory document views. +pub mod entity_coverage; +pub mod entity_coverage_types; pub mod manifest; pub mod ribbon; pub mod type_registry; pub mod type_registry_types; -pub mod entity_coverage; -pub mod entity_coverage_types; pub mod version_info; /// Runtime host surface — only built with the `host` feature (pulls `opencadcodec`). @@ -66,17 +66,19 @@ pub mod shm; #[cfg(feature = "host")] pub mod runner; +pub use entity_coverage::{ + get_embedded_entity_coverage_json, EntityCoverageCatalog, EntityKindCoverage, EntityScope, + ModelAccess, PropertyCoverage, +}; pub use manifest::{ effective_max_api_version, host_accepts_plugin_version, ApiVersion, PluginManifest, API_VERSION, API_VERSION_MIN_SUPPORTED, MAX_API_VERSION_ENV, }; pub use ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef}; pub use type_registry::{ - get_embedded_type_registry_json, EnumVariantInfo, FieldInfo, MethodInfo, ParameterInfo, - TypeId, TypeInfo, TypeKind, TypeRegistry, + get_embedded_type_registry_json, EnumVariantInfo, FieldInfo, MethodInfo, ParameterInfo, TypeId, + TypeInfo, TypeKind, TypeRegistry, }; -pub use entity_coverage::{get_embedded_entity_coverage_json, EntityCoverageCatalog, - EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage}; pub use version_info::get_embedded_version_info_json; #[cfg(test)] diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 760f1353c..117ed330d 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -28,7 +28,9 @@ use std::time::{Duration, Instant}; use interprocess::local_socket::traits::Listener; use interprocess::local_socket::{GenericNamespaced, ListenerOptions, Stream, ToNsName}; -use crate::host::{CommandSource, CommandStep, ExecutionResult, HostApi, HostNotification, PluginNotification}; +use crate::host::{ + CommandSource, CommandStep, ExecutionResult, HostApi, HostNotification, PluginNotification, +}; use crate::ipc::protocol::{ CadDocument, EntityType, Handle, HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, RunnerHandshake, PLUGIN_TOKEN_ENV, @@ -100,7 +102,11 @@ impl HostApi for NullHost { ) -> Option<&crate::ipc::protocol::ExtendedDataRecord> { None } - fn write_record(&mut self, _handle: Handle, _record: crate::ipc::protocol::ExtendedDataRecord) -> bool { + fn write_record( + &mut self, + _handle: Handle, + _record: crate::ipc::protocol::ExtendedDataRecord, + ) -> bool { false } fn remove_record(&mut self, _handle: Handle, _app_name: &str) -> bool { @@ -112,10 +118,7 @@ impl HostApi for NullHost { fn push_output(&mut self, _msg: &str) {} fn push_error(&mut self, _msg: &str) {} fn start_interactive(&mut self, _command: Box) {} - fn plugin_state_any( - &self, - _plugin_id: &str, - ) -> Option<&(dyn std::any::Any + Send + Sync)> { + fn plugin_state_any(&self, _plugin_id: &str) -> Option<&(dyn std::any::Any + Send + Sync)> { None } fn plugin_state_any_mut( @@ -419,7 +422,11 @@ impl PluginProcess { // the V4 reader thread. The notification handler receives an empty // plugin id until we read the manifest below; that brief window is // fine because the plugin has not yet been asked to do work. - let stream = stream.lock().unwrap_or_else(|e| e.into_inner()).take().expect("V4 stream"); + let stream = stream + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + .expect("V4 stream"); let plugin_id_placeholder = Arc::new(std::sync::Mutex::new(String::new())); let handler_for_v4: Arc, PluginNotification) + Send + Sync> = { let plugin_id = Arc::clone(&plugin_id_placeholder); @@ -434,7 +441,9 @@ impl PluginProcess { HostResponse::Manifest(m) => m, other => return Err(PluginError::UnexpectedResponse(Box::new(other))), }; - *plugin_id_placeholder.lock().unwrap_or_else(|e| e.into_inner()) = manifest.id.clone(); + *plugin_id_placeholder + .lock() + .unwrap_or_else(|e| e.into_inner()) = manifest.id.clone(); ribbon = match v4_conn.call(host, HostRequest::GetRibbon, no_op)? { HostResponse::Ribbon(r) => r, other => return Err(PluginError::UnexpectedResponse(Box::new(other))), @@ -626,8 +635,14 @@ impl PluginProcess { /// Release a completed or abandoned V4 interactive command in the runner. pub fn drop_interactive(&self, command_id: u64) -> Result<(), PluginError> { - let Some(v4) = &self.v4 else { return Ok(()); }; - match v4.call(&mut NullHost, HostRequest::DropInteractive { command_id }, &mut |_| {})? { + let Some(v4) = &self.v4 else { + return Ok(()); + }; + match v4.call( + &mut NullHost, + HostRequest::DropInteractive { command_id }, + &mut |_| {}, + )? { HostResponse::Bool(true) => Ok(()), other => Err(PluginError::UnexpectedResponse(Box::new(other))), } @@ -886,10 +901,7 @@ fn format_exit_status(status: Option) -> String { /// Describe why a runner failed before/during connection, including any stderr /// it produced. -fn spawn_failure_status( - child: &Mutex>, - last_stderr: &Mutex, -) -> String { +fn spawn_failure_status(child: &Mutex>, last_stderr: &Mutex) -> String { let mut parts = vec![format_exit_status(child_status(child))]; if let Ok(line) = last_stderr.lock() { if !line.is_empty() { @@ -1259,7 +1271,10 @@ mod timeout_tests { fn push_undo(&mut self, _label: &str) {} fn set_dirty(&mut self) {} fn push_info(&mut self, msg: &str) { - self.push_info_messages.lock().unwrap().push(msg.to_string()); + self.push_info_messages + .lock() + .unwrap() + .push(msg.to_string()); } fn push_output(&mut self, _msg: &str) {} fn push_error(&mut self, _msg: &str) {} @@ -1433,7 +1448,9 @@ mod timeout_tests { ) .expect("send nested request"); let resp = recv::(&mut peer).expect("read nested response"); - assert!(matches!(resp, HostToPlugin::Response(ref r) if matches!(**r, PluginResponse::Ok))); + assert!( + matches!(resp, HostToPlugin::Response(ref r) if matches!(**r, PluginResponse::Ok)) + ); send(&mut peer, &PluginToHost::Response(HostResponse::Bool(true))) .expect("send final response"); }); @@ -1443,7 +1460,11 @@ mod timeout_tests { assert!(result.expect("dispatch succeeds")); assert!(process.is_alive(), "process should still be alive"); let infos = host.take_push_info(); - assert_eq!(infos, vec!["hello".to_string()], "push_info should be delivered to host"); + assert_eq!( + infos, + vec!["hello".to_string()], + "push_info should be delivered to host" + ); // Clean up the helper child so it does not outlive the test. if let Some(mut child) = process @@ -1482,7 +1503,10 @@ mod timeout_tests { RunnerHandshake::Token("correct-token".to_string()), "correct-token", ); - assert!(result.is_ok(), "expected authentication success, got {result:?}"); + assert!( + result.is_ok(), + "expected authentication success, got {result:?}" + ); assert!(!result.unwrap(), "V3 token should select V3 path"); } @@ -1497,7 +1521,10 @@ mod timeout_tests { }, "correct-token", ); - assert!(result.is_ok(), "expected authentication success, got {result:?}"); + assert!( + result.is_ok(), + "expected authentication success, got {result:?}" + ); assert!(result.unwrap(), "TokenV4 should select V4 path"); } diff --git a/crates/ocs_plugin_api/src/process/manager.rs b/crates/ocs_plugin_api/src/process/manager.rs index 90ac264d7..8d1578c9f 100644 --- a/crates/ocs_plugin_api/src/process/manager.rs +++ b/crates/ocs_plugin_api/src/process/manager.rs @@ -97,11 +97,7 @@ impl PluginManager { /// Fan out a host notification to every alive V4 plugin. Per-process errors /// are logged; this is best-effort. - pub fn broadcast_notification( - &self, - command_id: Option, - notification: HostNotification, - ) { + pub fn broadcast_notification(&self, command_id: Option, notification: HostNotification) { for p in &self.plugins { if !p.process.is_alive() { continue; diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index f7e46d02e..868bd2288 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -145,12 +145,7 @@ impl V4Connection { payload, }) => { if tab_id.is_none_or(|request_tab| request_tab == host.tab_id()) { - self.respond_to_plugin_request( - host, - rid, - payload, - on_start_interactive, - )?; + self.respond_to_plugin_request(host, rid, payload, on_start_interactive)?; } else { self.deferred .lock() @@ -167,9 +162,7 @@ impl V4Connection { } Err(mpsc::RecvTimeoutError::Disconnected) => { self.shared.alive.store(false, Ordering::SeqCst); - return Err(PluginError::Runner( - "V4 reader thread disconnected".into(), - )); + return Err(PluginError::Runner("V4 reader thread disconnected".into())); } } } @@ -217,12 +210,7 @@ impl V4Connection { payload, }) => { if tab_id.is_none_or(|request_tab| request_tab == current_tab_id) { - self.respond_to_plugin_request( - host, - id, - payload, - on_start_interactive, - )?; + self.respond_to_plugin_request(host, id, payload, on_start_interactive)?; } else { self.deferred .lock() @@ -238,9 +226,7 @@ impl V4Connection { Err(mpsc::TryRecvError::Empty) => return Ok(()), Err(mpsc::TryRecvError::Disconnected) => { self.shared.alive.store(false, Ordering::SeqCst); - return Err(PluginError::Runner( - "V4 reader thread disconnected".into(), - )); + return Err(PluginError::Runner("V4 reader thread disconnected".into())); } } } @@ -324,7 +310,11 @@ impl V4Connection { while self.shared.alive.load(Ordering::SeqCst) && Instant::now() < deadline { std::thread::sleep(Duration::from_millis(10)); } - let _ = self.reader_handle.lock().unwrap_or_else(|e| e.into_inner()).take(); + let _ = self + .reader_handle + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take(); } } @@ -442,20 +432,23 @@ mod tests { label: &str, entities: Vec, ) -> Result<(), String> { - self.transactions.lock().unwrap().push((label.to_owned(), entities)); + self.transactions + .lock() + .unwrap() + .push((label.to_owned(), entities)); Ok(()) } fn set_dirty(&mut self) {} fn push_info(&mut self, msg: &str) { - self.push_info_messages.lock().unwrap().push(msg.to_string()); + self.push_info_messages + .lock() + .unwrap() + .push(msg.to_string()); } fn push_output(&mut self, _msg: &str) {} fn push_error(&mut self, _msg: &str) {} fn start_interactive(&mut self, _command: Box) {} - fn plugin_state_any( - &self, - _plugin_id: &str, - ) -> Option<&(dyn std::any::Any + Send + Sync)> { + fn plugin_state_any(&self, _plugin_id: &str) -> Option<&(dyn std::any::Any + Send + Sync)> { None } fn plugin_state_any_mut( @@ -537,7 +530,10 @@ mod tests { // Read the host's V4 request and respond. let req = recv::(&mut runner_stream).unwrap(); match req { - HostToPluginV4::Request { id, payload: HostRequest::Dispatch { cmd } } => { + HostToPluginV4::Request { + id, + payload: HostRequest::Dispatch { cmd }, + } => { assert_eq!(cmd, "HELLO"); send( &mut runner_stream, @@ -554,7 +550,13 @@ mod tests { let mut host = DummyHost::new(); let resp = conn - .call(&mut host, HostRequest::Dispatch { cmd: "HELLO".to_string() }, &mut |_| {}) + .call( + &mut host, + HostRequest::Dispatch { + cmd: "HELLO".to_string(), + }, + &mut |_| {}, + ) .unwrap(); assert!(matches!(resp, HostResponse::Bool(true))); runner.join().unwrap(); @@ -573,7 +575,10 @@ mod tests { let runner = thread::spawn(move || { let req = recv::(&mut runner_stream).unwrap(); match req { - HostToPluginV4::Request { id, payload: HostRequest::Dispatch { .. } } => { + HostToPluginV4::Request { + id, + payload: HostRequest::Dispatch { .. }, + } => { // Send a nested plugin request. send( &mut runner_stream, @@ -587,8 +592,10 @@ mod tests { // Read the response to the nested request. let resp = recv::(&mut runner_stream).unwrap(); match resp { - HostToPluginV4::Response { id: rid, payload: PluginResponse::Ok } - if rid == 99 => {} + HostToPluginV4::Response { + id: rid, + payload: PluginResponse::Ok, + } if rid == 99 => {} other => panic!("unexpected nested response: {other:?}"), } // Now respond to the original dispatch. @@ -607,11 +614,21 @@ mod tests { let mut host = DummyHost::new(); let resp = conn - .call(&mut host, HostRequest::Dispatch { cmd: "NESTED".to_string() }, &mut |_| {}) + .call( + &mut host, + HostRequest::Dispatch { + cmd: "NESTED".to_string(), + }, + &mut |_| {}, + ) .unwrap(); assert!(matches!(resp, HostResponse::Bool(true))); let infos = host.take_push_info(); - assert_eq!(infos, vec!["nested".to_string()], "push_info should be delivered to host"); + assert_eq!( + infos, + vec!["nested".to_string()], + "push_info should be delivered to host" + ); runner.join().unwrap(); restore_test_env(); } @@ -632,29 +649,56 @@ mod tests { let runner = thread::spawn(move || { let req = recv::(&mut runner_stream).unwrap(); - let HostToPluginV4::Request { id, payload: HostRequest::Dispatch { .. } } = req else { + let HostToPluginV4::Request { + id, + payload: HostRequest::Dispatch { .. }, + } = req + else { panic!("unexpected host request: {req:?}"); }; - send(&mut runner_stream, &PluginToHostV4::Request { - id: 99, - tab_id: Some(0), - payload: PluginRequest::UpdateEntitiesTransaction { - label: "Move point".into(), - entities: vec![sent], + send( + &mut runner_stream, + &PluginToHostV4::Request { + id: 99, + tab_id: Some(0), + payload: PluginRequest::UpdateEntitiesTransaction { + label: "Move point".into(), + entities: vec![sent], + }, }, - }).unwrap(); - assert!(matches!(recv::(&mut runner_stream).unwrap(), - HostToPluginV4::Response { id: 99, payload: PluginResponse::EntityTransactionResult(Ok(())) })); - send(&mut runner_stream, &PluginToHostV4::Response { - id, - payload: HostResponse::Bool(true), - }).unwrap(); + ) + .unwrap(); + assert!(matches!( + recv::(&mut runner_stream).unwrap(), + HostToPluginV4::Response { + id: 99, + payload: PluginResponse::EntityTransactionResult(Ok(())) + } + )); + send( + &mut runner_stream, + &PluginToHostV4::Response { + id, + payload: HostResponse::Bool(true), + }, + ) + .unwrap(); }); let mut host = DummyHost::new(); - assert!(matches!(conn.call(&mut host, HostRequest::Dispatch { cmd: "MOVE".into() }, &mut |_| {}).unwrap(), - HostResponse::Bool(true))); - assert_eq!(host.take_transactions(), vec![("Move point".into(), vec![expected])]); + assert!(matches!( + conn.call( + &mut host, + HostRequest::Dispatch { cmd: "MOVE".into() }, + &mut |_| {} + ) + .unwrap(), + HostResponse::Bool(true) + )); + assert_eq!( + host.take_transactions(), + vec![("Move point".into(), vec![expected])] + ); runner.join().unwrap(); restore_test_env(); } @@ -735,7 +779,11 @@ mod tests { std::thread::sleep(Duration::from_millis(200)); let got = received.lock().unwrap().clone(); - assert_eq!(got.len(), 1, "second notification should survive handler panic"); + assert_eq!( + got.len(), + 1, + "second notification should survive handler panic" + ); assert_eq!(got[0].0, Some(1)); runner.join().unwrap(); drop(conn); @@ -783,7 +831,10 @@ mod tests { count <= 3, "rate limiter should drop most notifications, got {count}" ); - assert!(conn.is_alive(), "connection should survive rate-limited flood"); + assert!( + conn.is_alive(), + "connection should survive rate-limited flood" + ); runner.join().unwrap(); drop(conn); std::env::remove_var("OCS_PLUGIN_NOTIFY_RATE_LIMIT"); @@ -816,7 +867,10 @@ mod tests { assert_eq!(command_id, 1); assert_eq!(source, CommandSource::Editor); assert_eq!(code, "1+1"); - assert_eq!(tab_index, 0, "ExecuteCode should be tied to the host's tab index"); + assert_eq!( + tab_index, 0, + "ExecuteCode should be tied to the host's tab index" + ); thread::sleep(Duration::from_millis(100)); send( &mut runner_stream, diff --git a/crates/ocs_plugin_api/src/ribbon/owned.rs b/crates/ocs_plugin_api/src/ribbon/owned.rs index 239d94026..4e055a7ce 100644 --- a/crates/ocs_plugin_api/src/ribbon/owned.rs +++ b/crates/ocs_plugin_api/src/ribbon/owned.rs @@ -110,7 +110,9 @@ fn collect_item_command_ids(item: &OwnedRibbonItem, out: &mut Vec) { } } OwnedRibbonItem::PropertiesGroup { match_prop } => push_tool_command(match_prop, out), - OwnedRibbonItem::StyleComboGroup { rows, manager_cmd, .. } => { + OwnedRibbonItem::StyleComboGroup { + rows, manager_cmd, .. + } => { for t in rows.iter().flatten() { push_tool_command(t, out); } @@ -214,17 +216,22 @@ impl From for OwnedRibbonItem { .collect(), default: default.to_string(), }, - RibbonItem::LabeledDropdown { id, label, icon, items, default } => { - OwnedRibbonItem::LabeledDropdown { - id: id.to_string(), - label: label.to_string(), - icon: icon.into(), - items: items.into_iter().map(|(a, b, i)| { - (a.to_string(), b.to_string(), i.into()) - }).collect(), - default: default.to_string(), - } - } + RibbonItem::LabeledDropdown { + id, + label, + icon, + items, + default, + } => OwnedRibbonItem::LabeledDropdown { + id: id.to_string(), + label: label.to_string(), + icon: icon.into(), + items: items + .into_iter() + .map(|(a, b, i)| (a.to_string(), b.to_string(), i.into())) + .collect(), + default: default.to_string(), + }, RibbonItem::LargeDropdown { id, label, @@ -242,9 +249,10 @@ impl From for OwnedRibbonItem { default: default.to_string(), }, RibbonItem::ToolGrid { columns } => OwnedRibbonItem::ToolGrid { - columns: columns.into_iter().map(|column| { - column.into_iter().map(Into::into).collect() - }).collect(), + columns: columns + .into_iter() + .map(|column| column.into_iter().map(Into::into).collect()) + .collect(), }, RibbonItem::LayerComboGroup { row2, row3 } => OwnedRibbonItem::LayerComboGroup { row2: row2.into_iter().map(Into::into).collect(), @@ -297,19 +305,28 @@ impl OwnedRibbonItem { .collect(), default: &*Box::leak(default.into_boxed_str()), }, - OwnedRibbonItem::LabeledDropdown { id, label, icon, items, default } => { - RibbonItem::LabeledDropdown { - id: &*Box::leak(id.into_boxed_str()), - label: &*Box::leak(label.into_boxed_str()), - icon: icon.to_static(), - items: items.into_iter().map(|(a, b, i)| ( - &*Box::leak(a.into_boxed_str()), - &*Box::leak(b.into_boxed_str()), - i.to_static(), - )).collect(), - default: &*Box::leak(default.into_boxed_str()), - } - } + OwnedRibbonItem::LabeledDropdown { + id, + label, + icon, + items, + default, + } => RibbonItem::LabeledDropdown { + id: &*Box::leak(id.into_boxed_str()), + label: &*Box::leak(label.into_boxed_str()), + icon: icon.to_static(), + items: items + .into_iter() + .map(|(a, b, i)| { + ( + &*Box::leak(a.into_boxed_str()), + &*Box::leak(b.into_boxed_str()), + i.to_static(), + ) + }) + .collect(), + default: &*Box::leak(default.into_boxed_str()), + }, OwnedRibbonItem::LargeDropdown { id, label, @@ -333,9 +350,10 @@ impl OwnedRibbonItem { default: &*Box::leak(default.into_boxed_str()), }, OwnedRibbonItem::ToolGrid { columns } => RibbonItem::ToolGrid { - columns: columns.into_iter().map(|column| { - column.into_iter().map(OwnedToolDef::to_static).collect() - }).collect(), + columns: columns + .into_iter() + .map(|column| column.into_iter().map(OwnedToolDef::to_static).collect()) + .collect(), }, OwnedRibbonItem::LayerComboGroup { row2, row3 } => RibbonItem::LayerComboGroup { row2: row2.into_iter().map(|t| t.to_static()).collect(), @@ -382,17 +400,22 @@ impl From<&RibbonItem> for OwnedRibbonItem { .collect(), default: default.to_string(), }, - RibbonItem::LabeledDropdown { id, label, icon, items, default } => { - OwnedRibbonItem::LabeledDropdown { - id: id.to_string(), - label: label.to_string(), - icon: icon.into(), - items: items.iter().map(|(a, b, i)| { - (a.to_string(), b.to_string(), i.into()) - }).collect(), - default: default.to_string(), - } - } + RibbonItem::LabeledDropdown { + id, + label, + icon, + items, + default, + } => OwnedRibbonItem::LabeledDropdown { + id: id.to_string(), + label: label.to_string(), + icon: icon.into(), + items: items + .iter() + .map(|(a, b, i)| (a.to_string(), b.to_string(), i.into())) + .collect(), + default: default.to_string(), + }, RibbonItem::LargeDropdown { id, label, @@ -410,9 +433,10 @@ impl From<&RibbonItem> for OwnedRibbonItem { default: default.to_string(), }, RibbonItem::ToolGrid { columns } => OwnedRibbonItem::ToolGrid { - columns: columns.iter().map(|column| { - column.iter().map(Into::into).collect() - }).collect(), + columns: columns + .iter() + .map(|column| column.iter().map(Into::into).collect()) + .collect(), }, RibbonItem::LayerComboGroup { row2, row3 } => OwnedRibbonItem::LayerComboGroup { row2: row2.iter().map(Into::into).collect(), @@ -622,7 +646,10 @@ mod tests { }; let ids = group.command_ids(); - assert_eq!(ids, ["SHOW_ALL", "SHOW_SELECTED", "PARALLEL", "PERPENDICULAR"]); + assert_eq!( + ids, + ["SHOW_ALL", "SHOW_SELECTED", "PARALLEL", "PERPENDICULAR"] + ); let round_trip: OwnedRibbonGroup = group.to_static().into(); assert_eq!(round_trip.command_ids(), ids); } diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index 237ddc982..e7b17c184 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -76,10 +76,8 @@ fn invoke_on_load( interactive: &InteractiveRegistry, ) -> Result<(), Box> { let mut proxy = client.plugin_host_api(0, interactive.clone()); - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - plugin.on_load(&mut proxy) - })) - .map_err(|_| std::io::Error::other("plugin on_load() panicked"))?; + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.on_load(&mut proxy))) + .map_err(|_| std::io::Error::other("plugin on_load() panicked"))?; Ok(()) } @@ -121,7 +119,9 @@ fn run_v4( match client.recv_runner_frame_timeout(std::time::Duration::from_millis(50)) { Ok(RunnerFrame::Request { id, payload }) => { - if let Some(resp) = handle_host_request_v4(&mut *plugin, interactive, &client, id, payload) { + if let Some(resp) = + handle_host_request_v4(&mut *plugin, interactive, &client, id, payload) + { client.send_response(id, resp)?; } } @@ -283,7 +283,9 @@ fn handle_host_request_v4( HostRequest::GetManifest => { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.manifest())) { Ok(m) => Some(HostResponse::Manifest(m.into())), - Err(_) => Some(HostResponse::Error("plugin manifest() panicked".to_string())), + Err(_) => Some(HostResponse::Error( + "plugin manifest() panicked".to_string(), + )), } } HostRequest::GetRibbon => { @@ -348,7 +350,9 @@ fn handle_host_request_v4( } match step { Ok(s) => Some(HostResponse::CommandStep(Box::new(s))), - Err(_) => Some(HostResponse::Error("interactive command panicked".to_string())), + Err(_) => Some(HostResponse::Error( + "interactive command panicked".to_string(), + )), } } HostRequest::DropInteractive { command_id } => { @@ -365,7 +369,9 @@ fn handle_host_request_v4( match result { Some(Ok(s)) => Some(HostResponse::Text(s)), Some(Err(_)) => Some(HostResponse::Error("prompt() panicked".to_string())), - None => Some(HostResponse::Error(format!("unknown interactive command {command_id}"))), + None => Some(HostResponse::Error(format!( + "unknown interactive command {command_id}" + ))), } } HostRequest::NeedsEntityPick { command_id } => { @@ -379,8 +385,12 @@ fn handle_host_request_v4( }; match result { Some(Ok(b)) => Some(HostResponse::Bool(b)), - Some(Err(_)) => Some(HostResponse::Error("needs_object_pick() panicked".to_string())), - None => Some(HostResponse::Error(format!("unknown interactive command {command_id}"))), + Some(Err(_)) => Some(HostResponse::Error( + "needs_object_pick() panicked".to_string(), + )), + None => Some(HostResponse::Error(format!( + "unknown interactive command {command_id}" + ))), } } HostRequest::CursorMove { command_id, pt } => { @@ -407,13 +417,7 @@ fn handle_host_request_v4( let mut proxy = client.plugin_host_api(tab_index, interactive.clone()); let respond = client.execute_code_responder(id); let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - plugin.start_execute_code( - &mut proxy, - command_id, - &code, - source, - respond, - ) + plugin.start_execute_code(&mut proxy, command_id, &code, source, respond) })); match started { Ok(true) => None, @@ -435,7 +439,9 @@ fn handle_host_request_v4( } } -unsafe fn load_plugin(path: &Path) -> Result<(u32, Box), Box> { +unsafe fn load_plugin( + path: &Path, +) -> Result<(u32, Box), Box> { let lib = libloading::Library::new(path)?; let version: libloading::Symbol u32> = lib diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index da14db317..27dceb773 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -147,14 +147,19 @@ mod tests { value["ocs_plugin_api_version"].as_str(), Some(env!("CARGO_PKG_VERSION")) ); - assert_eq!(value["api_version"].as_i64(), Some(crate::API_VERSION as i64)); + assert_eq!( + value["api_version"].as_i64(), + Some(crate::API_VERSION as i64) + ); assert_eq!( value["api_version_min_supported"].as_i64(), Some(crate::API_VERSION_MIN_SUPPORTED as i64) ); // acadrust_version should have a patch component (e.g. "0.4.0"). - let opencadcodec = value["acadrust_version"].as_str().expect("acadrust_version string"); + let opencadcodec = value["acadrust_version"] + .as_str() + .expect("acadrust_version string"); assert_eq!(opencadcodec.split('.').count(), 3); } @@ -223,10 +228,10 @@ mod tests { assert!(acadrust_source_hash("").is_none()); assert!(acadrust_source_hash("registry+https://crates.io").is_none()); assert!(acadrust_source_hash("git+https://github.com/foo/bar.git#short").is_none()); - assert!( - acadrust_source_hash("git+https://github.com/foo/bar.git#gggggggggggggggggggggggggggggggggggggggg") - .is_none() - ); + assert!(acadrust_source_hash( + "git+https://github.com/foo/bar.git#gggggggggggggggggggggggggggggggggggggggg" + ) + .is_none()); } #[test] diff --git a/crates/ocs_web_worker/src/lib.rs b/crates/ocs_web_worker/src/lib.rs index ed8c657a6..a2950d9a1 100644 --- a/crates/ocs_web_worker/src/lib.rs +++ b/crates/ocs_web_worker/src/lib.rs @@ -54,26 +54,24 @@ pub fn parse_document( "dwg" => { if recovery_mode { DwgReader::from_stream_with_options( - Cursor::new(Arc::clone(&bytes)), - DwgReadOptions::failsafe(), - ) - .read_with_stats() + Cursor::new(Arc::clone(&bytes)), + DwgReadOptions::failsafe(), + ) + .read_with_stats() } else { - DwgReader::from_stream(Cursor::new(Arc::clone(&bytes))) - .read_with_stats() + DwgReader::from_stream(Cursor::new(Arc::clone(&bytes))).read_with_stats() } } "dxf" => { if recovery_mode { - DxfReader::from_reader(Cursor::new(Arc::clone(&bytes))) - .and_then(|reader| { - reader - .with_configuration(DxfReaderConfiguration { - failsafe: true, - ..DxfReaderConfiguration::default() - }) - .read_with_stats() - }) + DxfReader::from_reader(Cursor::new(Arc::clone(&bytes))).and_then(|reader| { + reader + .with_configuration(DxfReaderConfiguration { + failsafe: true, + ..DxfReaderConfiguration::default() + }) + .read_with_stats() + }) } else { DxfReader::from_reader(Cursor::new(Arc::clone(&bytes))) .and_then(|reader| reader.read_with_stats()) @@ -96,9 +94,7 @@ pub fn parse_document( }; if !outcome.stats.has_usable_drawing_data() { let error = if recovery_mode { - format!( - "initial read failed: {initial_error}; recovery found no usable drawing data" - ) + format!("initial read failed: {initial_error}; recovery found no usable drawing data") } else { "initial read returned no source drawing records".to_string() }; @@ -134,8 +130,8 @@ pub fn parse_document( ); outcome.stats.recovered_errors = outcome.stats.recovered_errors.saturating_add(1); } - let source_sha256 = report_fingerprint_needed(&outcome.stats) - .then(|| sha256_document_bytes(&bytes)); + let source_sha256 = + report_fingerprint_needed(&outcome.stats).then(|| sha256_document_bytes(&bytes)); report_stage.call1(&JsValue::NULL, &JsValue::from_str("serialize document"))?; let encoded = encode_result(Ok(outcome), source_sha256, false, &bytes)?; report_stage.call1(&JsValue::NULL, &JsValue::from_str("copy output"))?; @@ -213,7 +209,7 @@ fn encode_result( recoverable_parse_error, runtime_fields, )) - .map_err(|error| worker_error(error.to_string(), bytes, true))?; + .map_err(|error| worker_error(error.to_string(), bytes, true))?; Ok(Uint8Array::from(encoded.as_slice())) } diff --git a/src/app/alias.rs b/src/app/alias.rs index 931e1fa3d..7c6b12cc3 100644 --- a/src/app/alias.rs +++ b/src/app/alias.rs @@ -242,23 +242,16 @@ pub(super) fn load_aliases() -> FxHashMap { // Only advance the version marker once the migrated table is // actually stored, so a failed set_item leaves the marker // behind and the merge is retried next launch. - if storage - .set_item(WEB_ALIAS_KEY, &to_pgp(&map)) - .is_ok() - { - let _ = storage.set_item( - WEB_ALIAS_VERSION_KEY, - &DEFAULT_ALIASES_VERSION.to_string(), - ); + if storage.set_item(WEB_ALIAS_KEY, &to_pgp(&map)).is_ok() { + let _ = storage + .set_item(WEB_ALIAS_VERSION_KEY, &DEFAULT_ALIASES_VERSION.to_string()); } } map } None => { - let _ = storage.set_item( - WEB_ALIAS_VERSION_KEY, - &DEFAULT_ALIASES_VERSION.to_string(), - ); + let _ = + storage.set_item(WEB_ALIAS_VERSION_KEY, &DEFAULT_ALIASES_VERSION.to_string()); default_map() } }; @@ -329,12 +322,7 @@ impl OpenCADStudio { .alias_editor_rows .iter() .filter(|(a, c)| !a.trim().is_empty() && !c.trim().is_empty()) - .map(|(a, c)| { - ( - a.trim().to_uppercase(), - c.trim().to_uppercase(), - ) - }) + .map(|(a, c)| (a.trim().to_uppercase(), c.trim().to_uppercase())) .collect(); self.set_command_aliases(map); } @@ -370,9 +358,8 @@ impl OpenCADStudio { ); } self.apply_alias_editor_rows(); - self.command_line.push_info( - crate::tf!("{} alias(es) applied.", self.command_aliases.len()).as_ref(), - ); + self.command_line + .push_info(crate::tf!("{} alias(es) applied.", self.command_aliases.len()).as_ref()); } /// Reset the working rows and live table to the shipped defaults. diff --git a/src/app/annotation_data.rs b/src/app/annotation_data.rs index dec24df2e..9724147ff 100644 --- a/src/app/annotation_data.rs +++ b/src/app/annotation_data.rs @@ -10,9 +10,9 @@ use iced::Task; use super::{Message, ModalKind, OpenCADStudio}; use crate::command::CadCommand; use crate::ui::window::annotation_data::{ - DataExtractionField, DataExtractionState, DataLinkChoice, DataLinkField, - ExtractionBegin, ExtractionPage, ExtractionSource, LinkPathType, LinkRange, - TableInsertField, TableInsertion, TableSource, + DataExtractionField, DataExtractionState, DataLinkChoice, DataLinkField, ExtractionBegin, + ExtractionPage, ExtractionSource, LinkPathType, LinkRange, TableInsertField, TableInsertion, + TableSource, }; #[derive(Clone)] @@ -132,7 +132,9 @@ fn column_index(text: &str) -> Option { break; } any = true; - value = value.checked_mul(26)?.checked_add((byte.to_ascii_uppercase() - b'A' + 1) as usize)?; + value = value + .checked_mul(26)? + .checked_add((byte.to_ascii_uppercase() - b'A' + 1) as usize)?; } any.then_some(value.saturating_sub(1)) } @@ -157,7 +159,8 @@ fn crop_range(rows: Vec>, range: &str) -> Result>, S // row are empty in every row, so clamp the window to the data width. let widest = rows.iter().map(Vec::len).max().unwrap_or(0); let c1 = c1.min(widest.saturating_sub(1)); - Ok(rows.into_iter() + Ok(rows + .into_iter() .skip(r0) .take(r1 - r0 + 1) .map(|row| { @@ -191,10 +194,7 @@ fn workbook_metadata(path: &Path) -> Result<(Vec, Vec), String> fn named_range_target(formula: &str) -> Option<(String, String)> { let formula = formula.trim().trim_start_matches('='); let (sheet, range) = formula.rsplit_once('!')?; - let sheet = sheet - .trim() - .trim_matches('\'') - .replace("''", "'"); + let sheet = sheet.trim().trim_matches('\'').replace("''", "'"); let range = range.replace('$', ""); (!sheet.is_empty() && !range.is_empty()).then_some((sheet, range)) } @@ -205,7 +205,11 @@ pub(crate) fn read_tabular_file( range_kind: LinkRange, range_value: Option<&str>, ) -> Result>, String> { - let ext = path.extension().and_then(|value| value.to_str()).unwrap_or("").to_ascii_lowercase(); + let ext = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); let (rows, cell_range) = if ext == "csv" || ext == "txt" { if range_kind == LinkRange::NamedRange { return Err(crate::t!("Named ranges require a spreadsheet file.").into_owned()); @@ -231,8 +235,9 @@ pub(crate) fn read_tabular_file( .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) .map(|(_, formula)| formula.clone()) .ok_or_else(|| crate::t!("The named range was not found.").into_owned())?; - named_range_target(&formula) - .ok_or_else(|| crate::t!("The named range does not refer to a worksheet cell range.").into_owned())? + named_range_target(&formula).ok_or_else(|| { + crate::t!("The named range does not refer to a worksheet cell range.").into_owned() + })? } else { let selected_sheet = sheet .filter(|value| !value.trim().is_empty()) @@ -257,7 +262,10 @@ pub(crate) fn read_tabular_file( (!cell_range.is_empty()).then_some(cell_range), ) }; - let rows = match cell_range.as_deref().filter(|value| !value.trim().is_empty()) { + let rows = match cell_range + .as_deref() + .filter(|value| !value.trim().is_empty()) + { Some(value) => crop_range(rows, value)?, None => rows, }; @@ -269,13 +277,19 @@ pub(crate) fn read_tabular_file( } fn table_style_handle(doc: &CadDocument, name: &str) -> Option { - doc.objects.iter().find_map(|(handle, object)| match object { - ObjectType::TableStyle(style) if style.name.eq_ignore_ascii_case(name) => Some(*handle), - _ => None, - }) + doc.objects + .iter() + .find_map(|(handle, object)| match object { + ObjectType::TableStyle(style) if style.name.eq_ignore_ascii_case(name) => Some(*handle), + _ => None, + }) } -fn build_table(rows: &[Vec], style: Option, title: Option<&str>) -> codec::entities::Table { +fn build_table( + rows: &[Vec], + style: Option, + title: Option<&str>, +) -> codec::entities::Table { let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); let title_rows = usize::from(title.is_some_and(|value| !value.trim().is_empty())); let mut table = TableBuilder::new(rows.len().max(1) + title_rows, columns) @@ -336,7 +350,11 @@ fn link_custom_data(sheet: &str, kind: LinkRange, range: Option<&str>) -> Vec Result { +pub(crate) fn data_link_write_path(doc: &CadDocument, handle: Handle) -> Result { let link = data_link(doc, handle) .ok_or_else(|| crate::t!("The data link no longer exists.").into_owned())?; if link.option & 1 == 0 { @@ -381,21 +396,42 @@ pub(crate) fn data_link_write_path( let writable = path .extension() .and_then(|value| value.to_str()) - .is_some_and(|value| value.eq_ignore_ascii_case("csv") || value.eq_ignore_ascii_case("txt")); + .is_some_and(|value| { + value.eq_ignore_ascii_case("csv") || value.eq_ignore_ascii_case("txt") + }); if !writable { - return Err(crate::t!("Writing linked data is supported for CSV and text sources.").into_owned()); + return Err( + crate::t!("Writing linked data is supported for CSV and text sources.").into_owned(), + ); } Ok(path) } fn entity_details(entity: &EntityType) -> String { match entity { - EntityType::Line(value) => format!("({:.3},{:.3},{:.3})-({:.3},{:.3},{:.3})", value.start.x, value.start.y, value.start.z, value.end.x, value.end.y, value.end.z), - EntityType::Circle(value) => format!("C({:.3},{:.3},{:.3}) R={:.3}", value.center.x, value.center.y, value.center.z, value.radius), - EntityType::Arc(value) => format!("C({:.3},{:.3},{:.3}) R={:.3} {:.1}°-{:.1}°", value.center.x, value.center.y, value.center.z, value.radius, value.start_angle.to_degrees(), value.end_angle.to_degrees()), + EntityType::Line(value) => format!( + "({:.3},{:.3},{:.3})-({:.3},{:.3},{:.3})", + value.start.x, value.start.y, value.start.z, value.end.x, value.end.y, value.end.z + ), + EntityType::Circle(value) => format!( + "C({:.3},{:.3},{:.3}) R={:.3}", + value.center.x, value.center.y, value.center.z, value.radius + ), + EntityType::Arc(value) => format!( + "C({:.3},{:.3},{:.3}) R={:.3} {:.1}°-{:.1}°", + value.center.x, + value.center.y, + value.center.z, + value.radius, + value.start_angle.to_degrees(), + value.end_angle.to_degrees() + ), EntityType::Text(value) => value.value.clone(), EntityType::MText(value) => value.value.clone(), - EntityType::Insert(value) => format!("{} @({:.3},{:.3},{:.3})", value.block_name, value.insert_point.x, value.insert_point.y, value.insert_point.z), + EntityType::Insert(value) => format!( + "{} @({:.3},{:.3},{:.3})", + value.block_name, value.insert_point.x, value.insert_point.y, value.insert_point.z + ), EntityType::LwPolyline(value) => format!("{} vertices", value.vertices.len()), EntityType::Polyline(value) => format!("{} vertices", value.vertices.len()), EntityType::Polyline2D(value) => format!("{} vertices", value.vertices.len()), @@ -453,7 +489,9 @@ fn collect_drawing_paths( return Ok(()); } if !path.is_dir() { - return Err(crate::t!("Data source not found: %{path}", path = path.display()).into_owned()); + return Err( + crate::t!("Data source not found: %{path}", path = path.display()).into_owned(), + ); } for entry in std::fs::read_dir(path).map_err(|error| error.to_string())? { let entry = entry.map_err(|error| error.to_string())?; @@ -495,24 +533,45 @@ fn collect_type_counts( } } -fn extraction_preview(state: &DataExtractionState, current: &CadDocument) -> Result>, String> { - let allowed_types = state.objects.iter().filter(|value| value.checked).map(|value| value.name.clone()).collect::>(); - let selected = state.selection_handles.iter().copied().collect::>(); +fn extraction_preview( + state: &DataExtractionState, + current: &CadDocument, +) -> Result>, String> { + let allowed_types = state + .objects + .iter() + .filter(|value| value.checked) + .map(|value| value.name.clone()) + .collect::>(); + let selected = state + .selection_handles + .iter() + .copied() + .collect::>(); let mut records = Vec::new(); match state.source { - ExtractionSource::CurrentDrawing => collect_records(current, None, &allowed_types, &mut records), - ExtractionSource::CurrentSelection => collect_records(current, Some(&selected), &allowed_types, &mut records), + ExtractionSource::CurrentDrawing => { + collect_records(current, None, &allowed_types, &mut records) + } + ExtractionSource::CurrentSelection => { + collect_records(current, Some(&selected), &allowed_types, &mut records) + } ExtractionSource::DrawingsAndFolders => { if state.include_current { collect_records(current, None, &allowed_types, &mut records); } for source in extraction_drawing_paths(state)? { - let doc = crate::io::load_file(&source).map_err(|error| format!("{}: {error}", source.display()))?; + let doc = crate::io::load_file(&source) + .map_err(|error| format!("{}: {error}", source.display()))?; collect_records(&doc, None, &allowed_types, &mut records); } } } - let props = state.properties.iter().filter(|value| value.checked).collect::>(); + let props = state + .properties + .iter() + .filter(|value| value.checked) + .collect::>(); if props.is_empty() { return Err(crate::t!("Select at least one property.").into_owned()); } @@ -520,7 +579,12 @@ fn extraction_preview(state: &DataExtractionState, current: &CadDocument) -> Res if state.show_name { header.push("Name".to_string()); } - header.extend(props.iter().filter(|value| !(state.show_name && value.key == "type")).map(|value| value.name.clone())); + header.extend( + props + .iter() + .filter(|value| !(state.show_name && value.key == "type")) + .map(|value| value.name.clone()), + ); if state.show_count { header.push("Count".to_string()); } @@ -532,7 +596,12 @@ fn extraction_preview(state: &DataExtractionState, current: &CadDocument) -> Res if state.show_name { values.push(record.object_type.clone()); } - values.extend(props.iter().filter(|value| !(state.show_name && value.key == "type")).map(|value| record.value(&value.key))); + values.extend( + props + .iter() + .filter(|value| !(state.show_name && value.key == "type")) + .map(|value| record.value(&value.key)), + ); *grouped.entry(values).or_default() += 1; } for (mut values, count) in grouped { @@ -547,7 +616,12 @@ fn extraction_preview(state: &DataExtractionState, current: &CadDocument) -> Res if state.show_name { values.push(record.object_type.clone()); } - values.extend(props.iter().filter(|value| !(state.show_name && value.key == "type")).map(|value| record.value(&value.key))); + values.extend( + props + .iter() + .filter(|value| !(state.show_name && value.key == "type")) + .map(|value| record.value(&value.key)), + ); if state.show_count { values.push("1".into()); } @@ -570,7 +644,12 @@ fn csv_escape(value: &str) -> String { fn rows_to_csv(rows: &[Vec]) -> String { let mut csv = String::new(); for row in rows { - csv.push_str(&row.iter().map(|value| csv_escape(value)).collect::>().join(",")); + csv.push_str( + &row.iter() + .map(|value| csv_escape(value)) + .collect::>() + .join(","), + ); csv.push('\n'); } csv @@ -580,8 +659,18 @@ fn save_extraction_settings(state: &DataExtractionState) -> Result<(), String> { if state.settings_path.trim().is_empty() { return Ok(()); } - let object_types = state.objects.iter().filter(|value| value.checked).map(|value| value.name.clone()).collect::>(); - let properties = state.properties.iter().filter(|value| value.checked).map(|value| value.key.clone()).collect::>(); + let object_types = state + .objects + .iter() + .filter(|value| value.checked) + .map(|value| value.name.clone()) + .collect::>(); + let properties = state + .properties + .iter() + .filter(|value| value.checked) + .map(|value| value.key.clone()) + .collect::>(); let value = serde_json::json!({ "version": 1, "source": match state.source { @@ -609,36 +698,68 @@ fn save_extraction_settings(state: &DataExtractionState) -> Result<(), String> { fn load_extraction_settings(state: &mut DataExtractionState) -> Result<(), String> { let text = std::fs::read_to_string(&state.settings_path).map_err(|error| error.to_string())?; - let value: serde_json::Value = serde_json::from_str(&text).map_err(|error| error.to_string())?; + let value: serde_json::Value = + serde_json::from_str(&text).map_err(|error| error.to_string())?; state.source = match value["source"].as_str() { Some("currentSelection") => ExtractionSource::CurrentSelection, Some("drawingsAndFolders") => ExtractionSource::DrawingsAndFolders, _ => ExtractionSource::CurrentDrawing, }; - state.include_current = value["includeCurrent"].as_bool().unwrap_or(state.include_current); - state.include_subfolders = value["includeSubfolders"].as_bool().unwrap_or(state.include_subfolders); + state.include_current = value["includeCurrent"] + .as_bool() + .unwrap_or(state.include_current); + state.include_subfolders = value["includeSubfolders"] + .as_bool() + .unwrap_or(state.include_subfolders); if let Some(files) = value["sourceFiles"].as_array() { state.source_files = files .iter() .filter_map(|value| value.as_str().map(str::to_string)) .collect(); } - let object_types = value["objectTypes"].as_array().map(|values| values.iter().filter_map(|value| value.as_str()).collect::>()).unwrap_or_default(); + let object_types = value["objectTypes"] + .as_array() + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str()) + .collect::>() + }) + .unwrap_or_default(); for item in &mut state.objects { item.checked = object_types.is_empty() || object_types.contains(item.name.as_str()); } - let properties = value["properties"].as_array().map(|values| values.iter().filter_map(|value| value.as_str()).collect::>()).unwrap_or_default(); + let properties = value["properties"] + .as_array() + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str()) + .collect::>() + }) + .unwrap_or_default(); for item in &mut state.properties { item.checked = properties.is_empty() || properties.contains(item.key.as_str()); } - state.combine_identical = value["combineIdentical"].as_bool().unwrap_or(state.combine_identical); + state.combine_identical = value["combineIdentical"] + .as_bool() + .unwrap_or(state.combine_identical); state.show_count = value["showCount"].as_bool().unwrap_or(state.show_count); state.show_name = value["showName"].as_bool().unwrap_or(state.show_name); state.output_table = value["outputTable"].as_bool().unwrap_or(state.output_table); state.output_file = value["outputFile"].as_bool().unwrap_or(state.output_file); - state.output_path = value["outputPath"].as_str().unwrap_or(&state.output_path).to_string(); - state.table_style = value["tableStyle"].as_str().unwrap_or(&state.table_style).to_string(); - state.table_title = value["tableTitle"].as_str().unwrap_or(&state.table_title).to_string(); + state.output_path = value["outputPath"] + .as_str() + .unwrap_or(&state.output_path) + .to_string(); + state.table_style = value["tableStyle"] + .as_str() + .unwrap_or(&state.table_style) + .to_string(); + state.table_title = value["tableTitle"] + .as_str() + .unwrap_or(&state.table_title) + .to_string(); Ok(()) } @@ -675,7 +796,14 @@ impl OpenCADStudio { let links = data_link_choices(doc); let selected = selected .and_then(|handle| links.iter().find(|value| value.handle == handle).cloned()) - .or_else(|| self.data_link_manager.selected.as_ref().and_then(|old| links.iter().find(|value| value.handle == old.handle).cloned())) + .or_else(|| { + self.data_link_manager.selected.as_ref().and_then(|old| { + links + .iter() + .find(|value| value.handle == old.handle) + .cloned() + }) + }) .or_else(|| links.first().cloned()); let preview = selected .as_ref() @@ -690,16 +818,32 @@ impl OpenCADStudio { pub(super) fn open_data_extraction(&mut self) { let i = self.active_tab; let doc = &self.tabs[i].scene.document; - let selection_handles = self.tabs[i].scene.selected_entities().into_iter().map(|(handle, _)| handle).collect::>(); + let selection_handles = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(handle, _)| handle) + .collect::>(); let mut counts = BTreeMap::::new(); for entity in doc.entities() { if !matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) { - *counts.entry(crate::entities::names::dxf_name(entity).to_string()).or_default() += 1; + *counts + .entry(crate::entities::names::dxf_name(entity).to_string()) + .or_default() += 1; } } let mut state = DataExtractionState::default(); state.selection_handles = selection_handles; - state.objects = counts.into_iter().map(|(name, count)| crate::ui::window::annotation_data::ExtractionObject { name, checked: true, count }).collect(); + state.objects = counts + .into_iter() + .map( + |(name, count)| crate::ui::window::annotation_data::ExtractionObject { + name, + checked: true, + count, + }, + ) + .collect(); state.table_styles = table_styles(doc); state.table_style = doc.header.current_table_style_name.clone(); self.data_extraction = state; @@ -743,7 +887,8 @@ impl OpenCADStudio { }; let doc = &self.tabs[i].scene.document; let Some(link) = data_link(doc, choice.handle) else { - self.table_insert.error = crate::t!("The selected data link no longer exists.").into_owned(); + self.table_insert.error = + crate::t!("The selected data link no longer exists.").into_owned(); return Task::none(); }; let rows = match read_link_rows(doc, link) { @@ -753,30 +898,57 @@ impl OpenCADStudio { return Task::none(); } }; - let table = build_table(&rows, table_style_handle(doc, &self.table_insert.style), None); - let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::existing(table, choice.handle); + let table = build_table( + &rows, + table_style_handle(doc, &self.table_insert.style), + None, + ); + let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::existing( + table, + choice.handle, + ); self.active_modal = None; self.reset_modal_geometry(); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); return Task::none(); } - let parse_usize = |value: &str| value.trim().parse::().ok().filter(|value| *value > 0); - let parse_number = |value: &str| value.trim().parse::().ok().filter(|value| value.is_finite() && *value > 0.0); + let parse_usize = |value: &str| { + value + .trim() + .parse::() + .ok() + .filter(|value| *value > 0) + }; + let parse_number = |value: &str| { + value + .trim() + .parse::() + .ok() + .filter(|value| value.is_finite() && *value > 0.0) + }; let (Some(columns), Some(data_rows), Some(column_width), Some(row_height)) = ( parse_usize(&self.table_insert.columns), parse_usize(&self.table_insert.data_rows), parse_number(&self.table_insert.column_width), parse_number(&self.table_insert.row_height), ) else { - self.table_insert.error = crate::t!("Columns, rows, width, and height must be positive values.").into_owned(); + self.table_insert.error = + crate::t!("Columns, rows, width, and height must be positive values.").into_owned(); return Task::none(); }; let doc = &self.tabs[i].scene.document; - let style = doc.objects.iter().find_map(|(handle, object)| match object { - ObjectType::TableStyle(style) if style.name.eq_ignore_ascii_case(&self.table_insert.style) => Some((*handle, style)), - _ => None, - }); + let style = doc + .objects + .iter() + .find_map(|(handle, object)| match object { + ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(&self.table_insert.style) => + { + Some((*handle, style)) + } + _ => None, + }); let multiplier = self.tabs[i].scene.creation_annotation_multiplier(); let command = crate::modules::annotate::table_cmd::TableCommand::configured( style, @@ -804,7 +976,12 @@ impl OpenCADStudio { let mut n = 1usize; loop { let name = format!("Data Link {n}"); - if !self.data_link_manager.links.iter().any(|link| link.name.eq_ignore_ascii_case(&name)) { + if !self + .data_link_manager + .links + .iter() + .any(|link| link.name.eq_ignore_ascii_case(&name)) + { self.data_link_manager.name = name; break; } @@ -837,7 +1014,8 @@ impl OpenCADStudio { let Some(choice) = self.data_link_manager.selected.clone() else { return Task::none(); }; - let Some(link) = data_link(&self.tabs[self.active_tab].scene.document, choice.handle) else { + let Some(link) = data_link(&self.tabs[self.active_tab].scene.document, choice.handle) + else { return Task::none(); }; self.data_link_manager.editing = true; @@ -845,13 +1023,22 @@ impl OpenCADStudio { self.data_link_manager.name = link_display_name(link); let path = resolve_link_path(&self.tabs[self.active_tab].scene.document, link); self.data_link_manager.path = path.to_string_lossy().into_owned(); - self.data_link_manager.path_type = match link.path_option { 2 => LinkPathType::Relative, 3 => LinkPathType::FileName, _ => LinkPathType::Full }; + self.data_link_manager.path_type = match link.path_option { + 2 => LinkPathType::Relative, + 3 => LinkPathType::FileName, + _ => LinkPathType::Full, + }; let (sheet, range_kind, range) = link_selection(link); let (sheets, named_ranges) = workbook_metadata(&path).unwrap_or_default(); self.data_link_manager.sheets = sheets; self.data_link_manager.named_ranges = named_ranges; self.data_link_manager.sheet = sheet - .filter(|value| self.data_link_manager.sheets.iter().any(|candidate| candidate == value)) + .filter(|value| { + self.data_link_manager + .sheets + .iter() + .any(|candidate| candidate == value) + }) .or_else(|| self.data_link_manager.sheets.first().cloned()) .unwrap_or_default(); self.data_link_manager.range = range.unwrap_or_default(); @@ -872,8 +1059,12 @@ impl OpenCADStudio { DataLinkField::Range(value) => self.data_link_manager.range = value, DataLinkField::Sheet(value) => self.data_link_manager.sheet = value, DataLinkField::AllowWrite(value) => self.data_link_manager.allow_write = value, - DataLinkField::UseSourceFormatting(value) => self.data_link_manager.use_source_formatting = value, - DataLinkField::UpdateSourceFormatting(value) => self.data_link_manager.update_source_formatting = value, + DataLinkField::UseSourceFormatting(value) => { + self.data_link_manager.use_source_formatting = value + } + DataLinkField::UpdateSourceFormatting(value) => { + self.data_link_manager.update_source_formatting = value + } DataLinkField::InsertTable(value) => self.data_link_manager.insert_table = value, DataLinkField::MoreOptions(value) => self.data_link_manager.more_options = value, } @@ -886,7 +1077,10 @@ impl OpenCADStudio { async { crate::sys::file_dialog() .set_title(crate::t!("Choose a Spreadsheet File").as_ref()) - .add_filter(crate::t!("Spreadsheet Files").as_ref(), &["csv", "txt", "xls", "xlsx", "xlsb", "ods"]) + .add_filter( + crate::t!("Spreadsheet Files").as_ref(), + &["csv", "txt", "xls", "xlsx", "xlsb", "ods"], + ) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .pick_file() .await @@ -937,8 +1131,12 @@ impl OpenCADStudio { self.data_link_manager.status = crate::t!("Enter a data link name.").into_owned(); return Task::none(); } - if self.data_link_manager.links.iter().any(|link| link.name.eq_ignore_ascii_case(&name) && Some(link.handle) != self.data_link_manager.editing_handle) { - self.data_link_manager.status = crate::t!("A data link with that name already exists.").into_owned(); + if self.data_link_manager.links.iter().any(|link| { + link.name.eq_ignore_ascii_case(&name) + && Some(link.handle) != self.data_link_manager.editing_handle + }) { + self.data_link_manager.status = + crate::t!("A data link with that name already exists.").into_owned(); return Task::none(); } let range = (self.data_link_manager.range_kind != LinkRange::EntireSheet) @@ -959,8 +1157,20 @@ impl OpenCADStudio { }; let stored_path = match self.data_link_manager.path_type { LinkPathType::Full => std::fs::canonicalize(&input_path).unwrap_or(input_path.clone()), - LinkPathType::Relative => self.tabs[i].scene.document.source_path.as_deref().map(Path::new).and_then(Path::parent).and_then(|parent| input_path.strip_prefix(parent).ok()).map(Path::to_path_buf).unwrap_or(input_path.clone()), - LinkPathType::FileName => input_path.file_name().map(PathBuf::from).unwrap_or(input_path.clone()), + LinkPathType::Relative => self.tabs[i] + .scene + .document + .source_path + .as_deref() + .map(Path::new) + .and_then(Path::parent) + .and_then(|parent| input_path.strip_prefix(parent).ok()) + .map(Path::to_path_buf) + .unwrap_or(input_path.clone()), + LinkPathType::FileName => input_path + .file_name() + .map(PathBuf::from) + .unwrap_or(input_path.clone()), }; self.push_undo_snapshot(i, "DATALINK"); let handle = if let Some(handle) = self.data_link_manager.editing_handle { @@ -968,9 +1178,14 @@ impl OpenCADStudio { link.description = name; link.tooltip = input_path.to_string_lossy().into_owned(); link.connection_string = stored_path.to_string_lossy().into_owned(); - link.path_option = match self.data_link_manager.path_type { LinkPathType::Full => 1, LinkPathType::Relative => 2, LinkPathType::FileName => 3 }; + link.path_option = match self.data_link_manager.path_type { + LinkPathType::Full => 1, + LinkPathType::Relative => 2, + LinkPathType::FileName => 3, + }; link.option = i32::from(self.data_link_manager.allow_write); - link.flags = i32::from(self.data_link_manager.use_source_formatting) | (i32::from(self.data_link_manager.update_source_formatting) << 1); + link.flags = i32::from(self.data_link_manager.use_source_formatting) + | (i32::from(self.data_link_manager.update_source_formatting) << 1); link.status_flags = 1; link.update_status = "Linked".into(); link.custom_data = link_custom_data( @@ -983,13 +1198,27 @@ impl OpenCADStudio { } else { let handle = self.tabs[i].scene.document.allocate_handle(); let mut object = ClassObject::new(ClassObjectData::DataLink(DataLink { - data_adapter: if input_path.extension().and_then(|value| value.to_str()).is_some_and(|value| value.eq_ignore_ascii_case("csv") || value.eq_ignore_ascii_case("txt")) { "CSV".into() } else { "Spreadsheet".into() }, + data_adapter: if input_path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + value.eq_ignore_ascii_case("csv") || value.eq_ignore_ascii_case("txt") + }) { + "CSV".into() + } else { + "Spreadsheet".into() + }, description: name, tooltip: input_path.to_string_lossy().into_owned(), connection_string: stored_path.to_string_lossy().into_owned(), option: i32::from(self.data_link_manager.allow_write), - flags: i32::from(self.data_link_manager.use_source_formatting) | (i32::from(self.data_link_manager.update_source_formatting) << 1), - path_option: match self.data_link_manager.path_type { LinkPathType::Full => 1, LinkPathType::Relative => 2, LinkPathType::FileName => 3 }, + flags: i32::from(self.data_link_manager.use_source_formatting) + | (i32::from(self.data_link_manager.update_source_formatting) << 1), + path_option: match self.data_link_manager.path_type { + LinkPathType::Full => 1, + LinkPathType::Relative => 2, + LinkPathType::FileName => 3, + }, status_flags: 1, update_status: "Linked".into(), custom_data: link_custom_data( @@ -1000,7 +1229,11 @@ impl OpenCADStudio { ..DataLink::default() })); object.handle = handle; - self.tabs[i].scene.document.objects.insert(handle, ObjectType::ClassObject(object)); + self.tabs[i] + .scene + .document + .objects + .insert(handle, ObjectType::ClassObject(object)); handle }; self.tabs[i].dirty = true; @@ -1009,9 +1242,13 @@ impl OpenCADStudio { self.data_link_manager.preview = preview.clone(); self.refresh_data_link_manager(Some(handle)); if self.data_link_manager.insert_table { - let style = table_style_handle(&self.tabs[i].scene.document, &self.tabs[i].scene.document.header.current_table_style_name); + let style = table_style_handle( + &self.tabs[i].scene.document, + &self.tabs[i].scene.document.header.current_table_style_name, + ); let table = build_table(&preview, style, None); - let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::existing(table, handle); + let command = + crate::modules::annotate::data_link::DataLinkPlaceCommand::existing(table, handle); self.active_modal = None; self.reset_modal_geometry(); self.command_line.push_info(&command.prompt()); @@ -1021,7 +1258,12 @@ impl OpenCADStudio { } pub(super) fn on_data_link_delete(&mut self) -> Task { - let Some(handle) = self.data_link_manager.selected.as_ref().map(|value| value.handle) else { + let Some(handle) = self + .data_link_manager + .selected + .as_ref() + .map(|value| value.handle) + else { return Task::none(); }; let i = self.active_tab; @@ -1035,7 +1277,11 @@ impl OpenCADStudio { cell.has_linked_data = false; cell.data_link_rows = 0; cell.data_link_columns = 0; - cell.state.remove(codec::entities::table::CellStateFlags::LINKED | codec::entities::table::CellStateFlags::CONTENT_LOCKED | codec::entities::table::CellStateFlags::FORMAT_LOCKED); + cell.state.remove( + codec::entities::table::CellStateFlags::LINKED + | codec::entities::table::CellStateFlags::CONTENT_LOCKED + | codec::entities::table::CellStateFlags::FORMAT_LOCKED, + ); } } } @@ -1045,7 +1291,8 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.data_link_manager.selected = None; self.refresh_data_link_manager(None); - self.data_link_manager.status = crate::t!("Data link deleted; existing table values were retained.").into_owned(); + self.data_link_manager.status = + crate::t!("Data link deleted; existing table values were retained.").into_owned(); Task::none() } @@ -1055,7 +1302,9 @@ impl OpenCADStudio { }; let i = self.active_tab; let doc = &self.tabs[i].scene.document; - let Some(link) = data_link(doc, choice.handle) else { return Task::none(); }; + let Some(link) = data_link(doc, choice.handle) else { + return Task::none(); + }; let rows = match read_link_rows(doc, link) { Ok(rows) => rows, Err(error) => { @@ -1064,7 +1313,10 @@ impl OpenCADStudio { } }; let style = table_style_handle(doc, &doc.header.current_table_style_name); - let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::existing(build_table(&rows, style, None), choice.handle); + let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::existing( + build_table(&rows, style, None), + choice.handle, + ); self.active_modal = None; self.reset_modal_geometry(); self.command_line.push_info(&command.prompt()); @@ -1092,12 +1344,32 @@ impl OpenCADStudio { DataExtractionField::Begin(value) => self.data_extraction.begin = value, DataExtractionField::SettingsPath(value) => self.data_extraction.settings_path = value, DataExtractionField::Source(value) => self.data_extraction.source = value, - DataExtractionField::IncludeCurrent(value) => self.data_extraction.include_current = value, - DataExtractionField::IncludeSubfolders(value) => self.data_extraction.include_subfolders = value, - DataExtractionField::Object(index, value) => if let Some(item) = self.data_extraction.objects.get_mut(index) { item.checked = value; }, - DataExtractionField::Property(index, value) => if let Some(item) = self.data_extraction.properties.get_mut(index) { item.checked = value; }, - DataExtractionField::Category(category, value) => if value { self.data_extraction.categories.insert(category); } else { self.data_extraction.categories.remove(&category); }, - DataExtractionField::CombineIdentical(value) => self.data_extraction.combine_identical = value, + DataExtractionField::IncludeCurrent(value) => { + self.data_extraction.include_current = value + } + DataExtractionField::IncludeSubfolders(value) => { + self.data_extraction.include_subfolders = value + } + DataExtractionField::Object(index, value) => { + if let Some(item) = self.data_extraction.objects.get_mut(index) { + item.checked = value; + } + } + DataExtractionField::Property(index, value) => { + if let Some(item) = self.data_extraction.properties.get_mut(index) { + item.checked = value; + } + } + DataExtractionField::Category(category, value) => { + if value { + self.data_extraction.categories.insert(category); + } else { + self.data_extraction.categories.remove(&category); + } + } + DataExtractionField::CombineIdentical(value) => { + self.data_extraction.combine_identical = value + } DataExtractionField::ShowCount(value) => self.data_extraction.show_count = value, DataExtractionField::ShowName(value) => self.data_extraction.show_name = value, DataExtractionField::OutputTable(value) => self.data_extraction.output_table = value, @@ -1146,20 +1418,25 @@ impl OpenCADStudio { .collect::>(); self.data_extraction.objects = counts .into_iter() - .map(|(name, count)| crate::ui::window::annotation_data::ExtractionObject { - checked: previous - .get(&name.to_ascii_lowercase()) - .copied() - .unwrap_or(true), - name, - count, - }) + .map( + |(name, count)| crate::ui::window::annotation_data::ExtractionObject { + checked: previous + .get(&name.to_ascii_lowercase()) + .copied() + .unwrap_or(true), + name, + count, + }, + ) .collect(); Ok(()) } fn update_extraction_preview(&mut self) { - match extraction_preview(&self.data_extraction, &self.tabs[self.active_tab].scene.document) { + match extraction_preview( + &self.data_extraction, + &self.tabs[self.active_tab].scene.document, + ) { Ok(rows) => { self.data_extraction.preview = rows; self.data_extraction.error.clear(); @@ -1177,7 +1454,9 @@ impl OpenCADStudio { ExtractionPage::Refine => ExtractionPage::Properties, ExtractionPage::Output => ExtractionPage::Refine, ExtractionPage::TableStyle => ExtractionPage::Output, - ExtractionPage::Finish if self.data_extraction.output_table => ExtractionPage::TableStyle, + ExtractionPage::Finish if self.data_extraction.output_table => { + ExtractionPage::TableStyle + } ExtractionPage::Finish => ExtractionPage::Output, }; self.data_extraction.error.clear(); @@ -1200,12 +1479,19 @@ impl OpenCADStudio { self.data_extraction.page = ExtractionPage::Source; } ExtractionPage::Source => { - if self.data_extraction.source == ExtractionSource::CurrentSelection && self.data_extraction.selection_handles.is_empty() { - self.data_extraction.error = crate::t!("No objects were selected when the wizard opened.").into_owned(); + if self.data_extraction.source == ExtractionSource::CurrentSelection + && self.data_extraction.selection_handles.is_empty() + { + self.data_extraction.error = + crate::t!("No objects were selected when the wizard opened.").into_owned(); return Task::none(); } - if self.data_extraction.source == ExtractionSource::DrawingsAndFolders && !self.data_extraction.include_current && self.data_extraction.source_files.is_empty() { - self.data_extraction.error = crate::t!("Add a drawing or include the current drawing.").into_owned(); + if self.data_extraction.source == ExtractionSource::DrawingsAndFolders + && !self.data_extraction.include_current + && self.data_extraction.source_files.is_empty() + { + self.data_extraction.error = + crate::t!("Add a drawing or include the current drawing.").into_owned(); return Task::none(); } if let Err(error) = self.refresh_extraction_objects() { @@ -1213,21 +1499,35 @@ impl OpenCADStudio { return Task::none(); } if self.data_extraction.objects.is_empty() { - self.data_extraction.error = crate::t!("No extractable objects were found in the selected source.").into_owned(); + self.data_extraction.error = + crate::t!("No extractable objects were found in the selected source.") + .into_owned(); return Task::none(); } self.data_extraction.page = ExtractionPage::Objects; } ExtractionPage::Objects => { - if !self.data_extraction.objects.iter().any(|value| value.checked) { - self.data_extraction.error = crate::t!("Select at least one object type.").into_owned(); + if !self + .data_extraction + .objects + .iter() + .any(|value| value.checked) + { + self.data_extraction.error = + crate::t!("Select at least one object type.").into_owned(); return Task::none(); } self.data_extraction.page = ExtractionPage::Properties; } ExtractionPage::Properties => { - if !self.data_extraction.properties.iter().any(|value| value.checked) { - self.data_extraction.error = crate::t!("Select at least one property.").into_owned(); + if !self + .data_extraction + .properties + .iter() + .any(|value| value.checked) + { + self.data_extraction.error = + crate::t!("Select at least one property.").into_owned(); return Task::none(); } self.data_extraction.page = ExtractionPage::Refine; @@ -1241,13 +1541,20 @@ impl OpenCADStudio { } ExtractionPage::Output => { if !self.data_extraction.output_table && !self.data_extraction.output_file { - self.data_extraction.error = crate::t!("Choose at least one output.").into_owned(); + self.data_extraction.error = + crate::t!("Choose at least one output.").into_owned(); return Task::none(); } - if self.data_extraction.output_file && self.data_extraction.output_path.trim().is_empty() { + if self.data_extraction.output_file + && self.data_extraction.output_path.trim().is_empty() + { return self.on_data_extraction_browse_output(); } - self.data_extraction.page = if self.data_extraction.output_table { ExtractionPage::TableStyle } else { ExtractionPage::Finish }; + self.data_extraction.page = if self.data_extraction.output_table { + ExtractionPage::TableStyle + } else { + ExtractionPage::Finish + }; } ExtractionPage::TableStyle => self.data_extraction.page = ExtractionPage::Finish, ExtractionPage::Finish => return self.on_data_extraction_finish(), @@ -1257,17 +1564,33 @@ impl OpenCADStudio { pub(super) fn on_data_extraction_browse_settings(&mut self) -> Task { let save = self.data_extraction.begin == ExtractionBegin::New; - Task::perform(async move { - let dialog = crate::sys::file_dialog().set_title(crate::t!("Data Extraction Settings").as_ref()).add_filter(crate::t!("Data Extraction Settings").as_ref(), &["dxex"]).add_filter(crate::t!("All Files").as_ref(), &["*"]); - if save { - dialog.set_file_name("extraction.dxex").save_file().await.map(|handle| crate::sys::handle_path(&handle)) - } else { - dialog.pick_file().await.map(|handle| crate::sys::handle_path(&handle)) - } - }, Message::DataExtractionBrowseSettingsResult) + Task::perform( + async move { + let dialog = crate::sys::file_dialog() + .set_title(crate::t!("Data Extraction Settings").as_ref()) + .add_filter(crate::t!("Data Extraction Settings").as_ref(), &["dxex"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]); + if save { + dialog + .set_file_name("extraction.dxex") + .save_file() + .await + .map(|handle| crate::sys::handle_path(&handle)) + } else { + dialog + .pick_file() + .await + .map(|handle| crate::sys::handle_path(&handle)) + } + }, + Message::DataExtractionBrowseSettingsResult, + ) } - pub(super) fn on_data_extraction_browse_settings_result(&mut self, path: Option) -> Task { + pub(super) fn on_data_extraction_browse_settings_result( + &mut self, + path: Option, + ) -> Task { if let Some(path) = path { self.data_extraction.settings_path = path.to_string_lossy().into_owned(); if self.data_extraction.begin != ExtractionBegin::New { @@ -1282,24 +1605,38 @@ impl OpenCADStudio { } pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task { - Task::perform(async { - crate::sys::file_dialog() - .set_title(crate::t!("Add Drawings").as_ref()) - .add_filter(crate::t!("CAD Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) - .add_filter(crate::t!("All Files").as_ref(), &["*"]) - .pick_files() - .await - .unwrap_or_default() - .into_iter() - .map(|handle| crate::sys::handle_path(&handle)) - .collect() - }, Message::DataExtractionAddDrawingsResult) + Task::perform( + async { + crate::sys::file_dialog() + .set_title(crate::t!("Add Drawings").as_ref()) + .add_filter( + crate::t!("CAD Files").as_ref(), + &["dwg", "dxf", "DWG", "DXF"], + ) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_files() + .await + .unwrap_or_default() + .into_iter() + .map(|handle| crate::sys::handle_path(&handle)) + .collect() + }, + Message::DataExtractionAddDrawingsResult, + ) } - pub(super) fn on_data_extraction_add_drawings_result(&mut self, paths: Vec) -> Task { + pub(super) fn on_data_extraction_add_drawings_result( + &mut self, + paths: Vec, + ) -> Task { for path in paths { let text = path.to_string_lossy().into_owned(); - if !self.data_extraction.source_files.iter().any(|value| value.eq_ignore_ascii_case(&text)) { + if !self + .data_extraction + .source_files + .iter() + .any(|value| value.eq_ignore_ascii_case(&text)) + { self.data_extraction.source_files.push(text); } } @@ -1344,25 +1681,35 @@ impl OpenCADStudio { } pub(super) fn on_data_extraction_browse_output(&mut self) -> Task { - Task::perform(async { - crate::sys::file_dialog() - .set_title(crate::t!("Save Data Extraction").as_ref()) - .set_file_name("extraction.csv") - .add_filter(crate::t!("CSV").as_ref(), &["csv"]) - .add_filter(crate::t!("Tab-separated text").as_ref(), &["txt"]) - .add_filter(crate::t!("All Files").as_ref(), &["*"]) - .save_file() - .await - .map(|handle| crate::sys::handle_path(&handle)) - }, Message::DataExtractionBrowseOutputResult) + Task::perform( + async { + crate::sys::file_dialog() + .set_title(crate::t!("Save Data Extraction").as_ref()) + .set_file_name("extraction.csv") + .add_filter(crate::t!("CSV").as_ref(), &["csv"]) + .add_filter(crate::t!("Tab-separated text").as_ref(), &["txt"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .save_file() + .await + .map(|handle| crate::sys::handle_path(&handle)) + }, + Message::DataExtractionBrowseOutputResult, + ) } - pub(super) fn on_data_extraction_browse_output_result(&mut self, path: Option) -> Task { + pub(super) fn on_data_extraction_browse_output_result( + &mut self, + path: Option, + ) -> Task { if let Some(path) = path { self.data_extraction.output_path = path.to_string_lossy().into_owned(); self.data_extraction.output_file = true; if self.data_extraction.page == ExtractionPage::Output { - self.data_extraction.page = if self.data_extraction.output_table { ExtractionPage::TableStyle } else { ExtractionPage::Finish }; + self.data_extraction.page = if self.data_extraction.output_table { + ExtractionPage::TableStyle + } else { + ExtractionPage::Finish + }; } } Task::none() @@ -1380,8 +1727,16 @@ impl OpenCADStudio { let rows = self.data_extraction.preview.clone(); if self.data_extraction.output_file { let path = PathBuf::from(self.data_extraction.output_path.trim()); - let text = if path.extension().and_then(|value| value.to_str()).is_some_and(|value| value.eq_ignore_ascii_case("txt")) { - rows.iter().map(|row| row.join("\t")).collect::>().join("\n") + "\n" + let text = if path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("txt")) + { + rows.iter() + .map(|row| row.join("\t")) + .collect::>() + .join("\n") + + "\n" } else { rows_to_csv(&rows) }; @@ -1392,9 +1747,13 @@ impl OpenCADStudio { } let i = self.active_tab; if self.data_extraction.output_table { - let style = table_style_handle(&self.tabs[i].scene.document, &self.data_extraction.table_style); + let style = table_style_handle( + &self.tabs[i].scene.document, + &self.data_extraction.table_style, + ); let table = build_table(&rows, style, Some(&self.data_extraction.table_title)); - let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::unlinked(table); + let command = + crate::modules::annotate::data_link::DataLinkPlaceCommand::unlinked(table); self.active_modal = None; self.reset_modal_geometry(); self.command_line.push_info(&command.prompt()); @@ -1403,7 +1762,8 @@ impl OpenCADStudio { self.active_modal = None; self.reset_modal_geometry(); self.ribbon.deactivate_tool(); - self.command_line.push_output(crate::t!("DATAEXTRACTION: external file created.").as_ref()); + self.command_line + .push_output(crate::t!("DATAEXTRACTION: external file created.").as_ref()); } Task::none() } @@ -1415,10 +1775,7 @@ mod tests { #[test] fn cell_ranges_are_normalized_and_missing_cells_are_blank() { - let rows = vec![ - vec!["a".into(), "b".into(), "c".into()], - vec!["d".into()], - ]; + let rows = vec![vec!["a".into(), "b".into(), "c".into()], vec!["d".into()]]; assert_eq!( crop_range(rows, "C2:A1").unwrap(), vec![ diff --git a/src/app/automation.rs b/src/app/automation.rs index a9d588ecd..e3a99fb5f 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -16,9 +16,9 @@ #[cfg(not(target_arch = "wasm32"))] use crate::io::line_read::{lines_capped, MAX_LINE_BYTES}; +use std::collections::{BTreeMap, HashSet}; #[cfg(not(target_arch = "wasm32"))] use std::io::Write; -use std::collections::{BTreeMap, HashSet}; use std::path::PathBuf; use serde_json::{json, Value}; @@ -202,9 +202,7 @@ fn serve_socket_with_idle( /// The entities inside block definitions: owned by (or listed in) a block /// record that is not a layout's model or paper space. -fn definition_members( - document: &codec::CadDocument, -) -> std::collections::HashSet { +fn definition_members(document: &codec::CadDocument) -> std::collections::HashSet { let mut layouts: std::collections::HashSet = document .objects .values() @@ -297,7 +295,9 @@ pub(crate) fn entity_json(e: &codec::EntityType, detail: &str) -> Value { map.insert("value".into(), json!(t.value)); map.insert( "text".into(), - json!(crate::entities::text_support::resolve_dxf_special_chars(&t.value)), + json!(crate::entities::text_support::resolve_dxf_special_chars( + &t.value + )), ); map.insert("position".into(), v3(t.insertion_point)); map.insert("height".into(), json!(t.height)); @@ -306,8 +306,7 @@ pub(crate) fn entity_json(e: &codec::EntityType, detail: &str) -> Value { map.insert("value".into(), json!(t.value)); map.insert( "text".into(), - json!(codec::entities::mtext_format::parse_mtext(&t.value, true) - .to_plain_text()), + json!(codec::entities::mtext_format::parse_mtext(&t.value, true).to_plain_text()), ); map.insert("position".into(), v3(t.insertion_point)); map.insert("height".into(), json!(t.height)); @@ -341,13 +340,11 @@ pub(crate) fn entity_json(e: &codec::EntityType, detail: &str) -> Value { .edges .iter() .filter_map(|edge| match edge { - codec::entities::BoundaryEdge::Polyline(polyline) => Some( - json!(polyline - .vertices - .iter() - .map(|v| [v.x, v.y]) - .collect::>()), - ), + codec::entities::BoundaryEdge::Polyline(polyline) => Some(json!(polyline + .vertices + .iter() + .map(|v| [v.x, v.y]) + .collect::>())), _ => None, }) .collect(); @@ -435,7 +432,11 @@ pub(super) fn requested_save_target( let requested_format = match req["target_format"].as_str() { Some(value) if value.eq_ignore_ascii_case("dxf") => Some("dxf".to_string()), Some(value) if value.eq_ignore_ascii_case("dwg") => Some("dwg".to_string()), - Some(value) => return Err(format!("unsupported target format {value:?}; use dwg or dxf")), + Some(value) => { + return Err(format!( + "unsupported target format {value:?}; use dwg or dxf" + )) + } None => None, }; if let (Some(requested), Some(extension)) = (&requested_format, &path_format) { @@ -485,7 +486,9 @@ fn audit_ascii_dxf_references(path: &std::path::Path) -> Result { let text = String::from_utf8_lossy(&bytes).replace("\r\n", "\n"); let lines: Vec<&str> = text.lines().collect(); if lines.len() % 2 != 0 { - return Ok(json!({"ok":false,"malformed_pairs":true,"duplicate_handles":[],"dangling_handles":[]})); + return Ok( + json!({"ok":false,"malformed_pairs":true,"duplicate_handles":[],"dangling_handles":[]}), + ); } let mut declared = HashSet::from(["0".to_string()]); let mut duplicate = std::collections::BTreeSet::new(); @@ -495,7 +498,9 @@ fn audit_ascii_dxf_references(path: &std::path::Path) -> Result { let mut variable = None; for pair in lines.chunks_exact(2) { let Ok(code) = pair[0].trim().parse::() else { - return Ok(json!({"ok":false,"malformed_pairs":true,"duplicate_handles":[],"dangling_handles":[]})); + return Ok( + json!({"ok":false,"malformed_pairs":true,"duplicate_handles":[],"dangling_handles":[]}), + ); }; let value = pair[1].trim().to_ascii_uppercase(); pairs.push((code, value.clone())); @@ -947,23 +952,16 @@ impl OpenCADStudio { Ok(target) => target, Err(error) => return err(format!("save: {error}")), }; - let dropped = crate::io::dropped_on_save_count( - &self.tabs[i].scene.document, - version, - is_dxf, - ); + let dropped = + crate::io::dropped_on_save_count(&self.tabs[i].scene.document, version, is_dxf); if dropped > 0 && req["allow_lossy"].as_bool() != Some(true) { return err(format!( "save: conversion would drop {dropped} unsupported record(s); set allow_lossy=true to acknowledge" )); } #[cfg(not(target_arch = "wasm32"))] - let result = self.save_tab_synchronously_protected_as( - i, - path.clone(), - version, - true, - ); + let result = + self.save_tab_synchronously_protected_as(i, path.clone(), version, true); #[cfg(target_arch = "wasm32")] let result = crate::io::save(&self.tabs[i].scene.document, &path) .map_err(crate::io::SaveFailure::other); @@ -1131,8 +1129,18 @@ impl OpenCADStudio { let offset = req["offset"].as_u64().unwrap_or(0) as usize; const WHERE_OPS: &[&str] = &[ - "eq", "ne", "lt", "lte", "gt", "gte", "contains", - "starts_with", "ends_with", "in", "exists", "not_exists", + "eq", + "ne", + "lt", + "lte", + "gt", + "gte", + "contains", + "starts_with", + "ends_with", + "in", + "exists", + "not_exists", ]; if let Some(filters) = req["where"].as_array() { for filter in filters { @@ -1166,7 +1174,9 @@ impl OpenCADStudio { // Filters address with RFC 6901 pointers, the // same contract as the records op. let properties = serde_json::to_value(e).ok().and_then(|wrapper| { - wrapper.as_object().and_then(|object| object.values().next().cloned()) + wrapper + .as_object() + .and_then(|object| object.values().next().cloned()) }); let mut matches = true; for filter in filters { @@ -1174,7 +1184,9 @@ impl OpenCADStudio { let actual = if path.is_empty() { properties.as_ref() } else { - properties.as_ref().and_then(|properties| properties.pointer(path)) + properties + .as_ref() + .and_then(|properties| properties.pointer(path)) }; match crate::app::record_api::compare( actual, @@ -1194,7 +1206,8 @@ impl OpenCADStudio { } } if let Some(bounds) = bounds { - let (min, max) = crate::scene::convert::tess::entity_bounds_in(&tab.scene.document, e); + let (min, max) = + crate::scene::convert::tess::entity_bounds_in(&tab.scene.document, e); if max[0] < bounds[0] || max[1] < bounds[1] || min[0] > bounds[2] @@ -1276,15 +1289,11 @@ impl OpenCADStudio { .as_str() .map(std::path::Path::new) .or(tab.current_path.as_deref()); - let (target_version, target_is_dxf) = match requested_save_target( - req, - document.version, - source_is_dxf, - path, - ) { - Ok(target) => target, - Err(error) => return err(format!("audit: {error}")), - }; + let (target_version, target_is_dxf) = + match requested_save_target(req, document.version, source_is_dxf, path) { + Ok(target) => target, + Err(error) => return err(format!("audit: {error}")), + }; let declared_layers: HashSet = document .layers @@ -1346,14 +1355,12 @@ impl OpenCADStudio { } } - let dropped = crate::io::dropped_on_save_count( - document, - target_version, - target_is_dxf, - ); + let dropped = crate::io::dropped_on_save_count(document, target_version, target_is_dxf); let mut issues = Vec::new(); if !missing_layers.is_empty() { - issues.push(json!({"severity":"error","code":"undeclared_layer","values":missing_layers})); + issues.push( + json!({"severity":"error","code":"undeclared_layer","values":missing_layers}), + ); } if !missing_blocks.is_empty() { issues.push(json!({"severity":"error","code":"missing_block","values":missing_blocks})); @@ -1365,7 +1372,9 @@ impl OpenCADStudio { issues.push(json!({"severity":"error","code":"non_serializable_record","values":serialization_errors})); } if !non_finite_bounds.is_empty() { - issues.push(json!({"severity":"error","code":"non_finite_bounds","handles":non_finite_bounds})); + issues.push( + json!({"severity":"error","code":"non_finite_bounds","handles":non_finite_bounds}), + ); } if dropped > 0 { issues.push(json!({ @@ -1394,10 +1403,19 @@ impl OpenCADStudio { "details":source_dxf_structure.clone(), })); } - let errors = issues.iter().filter(|issue| issue["severity"] == "error").count(); - let warnings = issues.iter().filter(|issue| issue["severity"] == "warning").count(); + let errors = issues + .iter() + .filter(|issue| issue["severity"] == "error") + .count(); + let warnings = issues + .iter() + .filter(|issue| issue["severity"] == "warning") + .count(); #[cfg(not(target_arch = "wasm32"))] - let source_sha256 = tab.current_path.as_deref().filter(|path| path.is_file()) + let source_sha256 = tab + .current_path + .as_deref() + .filter(|path| path.is_file()) .and_then(|path| sha256_file(path).ok()); #[cfg(target_arch = "wasm32")] let source_sha256: Option = None; @@ -1430,31 +1448,37 @@ impl OpenCADStudio { pub(super) fn save_verified_request(&mut self, req: &Value) -> Result { let i = self.active_tab; let Some(raw_path) = req["path"].as_str() else { - return Err(json!({"ok":false,"status":"failed","code":"path_required","error":"save_verified requires an explicit absolute path"})); + return Err( + json!({"ok":false,"status":"failed","code":"path_required","error":"save_verified requires an explicit absolute path"}), + ); }; let path = std::path::PathBuf::from(raw_path); if !path.is_absolute() { - return Err(json!({"ok":false,"status":"failed","code":"absolute_path_required","error":"save_verified path must be absolute"})); + return Err( + json!({"ok":false,"status":"failed","code":"absolute_path_required","error":"save_verified path must be absolute"}), + ); } let extension = path .extension() .and_then(|value| value.to_str()) .map(str::to_ascii_lowercase); if !matches!(extension.as_deref(), Some("dwg" | "dxf")) { - return Err(json!({"ok":false,"status":"failed","code":"unsupported_format","error":"save_verified path must end in .dwg or .dxf"})); + return Err( + json!({"ok":false,"status":"failed","code":"unsupported_format","error":"save_verified path must end in .dwg or .dxf"}), + ); } if path.exists() && req["overwrite"].as_bool() != Some(true) { - return Err(json!({"ok":false,"status":"failed","code":"destination_exists","error":"destination exists; set overwrite=true to replace it"})); + return Err( + json!({"ok":false,"status":"failed","code":"destination_exists","error":"destination exists; set overwrite=true to replace it"}), + ); } let document = &self.tabs[i].scene.document; - let source_is_dxf = crate::io::source_is_dxf(self.tabs[i].current_path.as_deref(), document); - let (version, is_dxf) = requested_save_target( - req, - document.version, - source_is_dxf, - Some(&path), - ) - .map_err(|error| json!({"ok":false,"status":"failed","code":"invalid_target","error":error}))?; + let source_is_dxf = + crate::io::source_is_dxf(self.tabs[i].current_path.as_deref(), document); + let (version, is_dxf) = + requested_save_target(req, document.version, source_is_dxf, Some(&path)).map_err( + |error| json!({"ok":false,"status":"failed","code":"invalid_target","error":error}), + )?; let audit = self.document_audit(req); if audit["summary"]["errors"].as_u64().unwrap_or(0) > 0 { return Err(json!({ @@ -1482,19 +1506,24 @@ impl OpenCADStudio { let mut baked_view = snapshot.clone(); crate::modules::draw::modify::explode::bake_dimension_blocks(&mut baked_view); let before = document_manifest(&baked_view); - crate::io::save_as_version(&snapshot, &path, version) - .map_err(|error| json!({ + crate::io::save_as_version(&snapshot, &path, version).map_err(|error| { + json!({ "ok":false,"status":"failed","code":"save_failed","error":error, - }))?; - let sha256 = sha256_file(&path).map_err(|error| json!({ - "ok":false,"status":"failed","code":"hash_failed","error":error, - "saved":path, - }))?; + }) + })?; + let sha256 = sha256_file(&path).map_err(|error| { + json!({ + "ok":false,"status":"failed","code":"hash_failed","error":error, + "saved":path, + }) + })?; let dxf_structure = if is_dxf { - Some(audit_ascii_dxf_references(&path).map_err(|error| json!({ - "ok":false,"status":"failed","code":"dxf_audit_failed","error":error, - "saved":path,"sha256":sha256, - }))?) + Some(audit_ascii_dxf_references(&path).map_err(|error| { + json!({ + "ok":false,"status":"failed","code":"dxf_audit_failed","error":error, + "saved":path,"sha256":sha256, + }) + })?) } else { None }; @@ -1509,10 +1538,12 @@ impl OpenCADStudio { "target_version":format!("{version:?}"), })); } - let reopened = crate::io::load_file(&path).map_err(|error| json!({ - "ok":false,"status":"failed","code":"reopen_failed","error":error, - "saved":path,"sha256":sha256, - }))?; + let reopened = crate::io::load_file(&path).map_err(|error| { + json!({ + "ok":false,"status":"failed","code":"reopen_failed","error":error, + "saved":path,"sha256":sha256, + }) + })?; if reopened.version != version { return Err(json!({ "ok":false,"status":"failed","code":"version_mismatch", @@ -1617,7 +1648,8 @@ mod tests { .expect("line") .common_mut() .layer = "NOT_DECLARED".into(); - let audit = app.automation_op(r#"{"op":"audit","target_format":"dwg","target_version":"R14"}"#); + let audit = + app.automation_op(r#"{"op":"audit","target_format":"dwg","target_version":"R14"}"#); assert_eq!(audit["ok"], false, "{audit}"); assert_eq!(audit["issues"][0]["code"], "undeclared_layer", "{audit}"); } @@ -1708,10 +1740,8 @@ mod tests { #[test] fn raw_dxf_audit_detects_dangling_handle_references() { - let path = std::env::temp_dir().join(format!( - "ocs_dangling_handle_{}.dxf", - std::process::id() - )); + let path = + std::env::temp_dir().join(format!("ocs_dangling_handle_{}.dxf", std::process::id())); std::fs::write(&path, " 0\nLINE\n 5\n1\n330\n2\n").unwrap(); let audit = super::audit_ascii_dxf_references(&path).unwrap(); assert_eq!(audit["ok"], false, "{audit}"); @@ -1721,10 +1751,7 @@ mod tests { #[test] fn raw_dxf_audit_marks_binary_input_as_skipped() { - let path = std::env::temp_dir().join(format!( - "ocs_binary_dxf_{}.dxf", - std::process::id() - )); + let path = std::env::temp_dir().join(format!("ocs_binary_dxf_{}.dxf", std::process::id())); std::fs::write(&path, b"AutoCAD Binary DXF\r\n\x1a\0").unwrap(); let audit = super::audit_ascii_dxf_references(&path).unwrap(); assert_eq!(audit["ok"], true, "{audit}"); @@ -1734,10 +1761,8 @@ mod tests { #[test] fn raw_dxf_audit_detects_objects_orphaned_from_the_root_dictionary() { - let path = std::env::temp_dir().join(format!( - "ocs_orphaned_object_{}.dxf", - std::process::id() - )); + let path = + std::env::temp_dir().join(format!("ocs_orphaned_object_{}.dxf", std::process::id())); std::fs::write( &path, "0\nSECTION\n2\nOBJECTS\n0\nDICTIONARY\n5\nC\n330\n0\n0\nDICTIONARY\n5\n23\n330\nC\n0\nENDSEC\n0\nEOF\n", @@ -1827,9 +1852,8 @@ mod tests { let line = app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 200,0"}"#); assert_eq!(line["added"], 1, "{line}"); - let text = app.automation_op( - r#"{"op":"run","cmd":"TEXT 100,135 8 0 OPEN CAD MCP ACCEPTANCE"}"#, - ); + let text = + app.automation_op(r#"{"op":"run","cmd":"TEXT 100,135 8 0 OPEN CAD MCP ACCEPTANCE"}"#); assert_eq!(text["added"], 1, "{text}"); app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 800.0); @@ -1927,7 +1951,11 @@ mod tests { _ => None, }) .collect(); - assert_eq!(texts.len(), 1, "expected exactly one Text entity, got {texts:?}"); + assert_eq!( + texts.len(), + 1, + "expected exactly one Text entity, got {texts:?}" + ); texts.into_iter().next().unwrap() } @@ -2072,8 +2100,7 @@ mod tests { .map(|e| format!("\"{}\"", e["handle"].as_str().unwrap())) .collect(); - let path = - std::env::temp_dir().join(format!("ocs_wblock_{}.dxf", std::process::id())); + let path = std::env::temp_dir().join(format!("ocs_wblock_{}.dxf", std::process::id())); let _ = std::fs::remove_file(&path); let p = path.to_string_lossy().replace('\\', "\\\\"); let r = app.automation_op(&format!( @@ -2105,7 +2132,11 @@ mod tests { let mut line = codec::entities::Line::new(); line.start = codec::types::Vector3::new(0.0, 0.0, 0.0); line.end = codec::types::Vector3::new(10.0, 0.0, 0.0); - let handle = app.tabs[i].scene.document.add_entity(codec::EntityType::Line(line)).unwrap(); + let handle = app.tabs[i] + .scene + .document + .add_entity(codec::EntityType::Line(line)) + .unwrap(); let mut br = codec::tables::BlockRecord::new("A_CPT"); br.handle = app.tabs[i].scene.document.allocate_handle(); br.entity_handles = vec![handle]; @@ -2113,7 +2144,8 @@ mod tests { let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); let doc = state["document_id"].as_u64().unwrap(); - let path = std::env::temp_dir().join(format!("ocs_wblock_block_{}.dxf", std::process::id())); + let path = + std::env::temp_dir().join(format!("ocs_wblock_block_{}.dxf", std::process::id())); let _ = std::fs::remove_file(&path); let p = path.to_string_lossy().replace('\\', "\\\\"); let r = app.automation_op(&format!( @@ -2178,7 +2210,10 @@ mod tests { app.automation_op(r#"{"op":"new"}"#); let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); let doc = state["document_id"].as_u64().unwrap(); - let p = std::env::temp_dir().join("ocs_plot_bad.pdf").to_string_lossy().replace('\\', "\\\\"); + let p = std::env::temp_dir() + .join("ocs_plot_bad.pdf") + .to_string_lossy() + .replace('\\', "\\\\"); let r = app.automation_op(&format!( r#"{{"protocol":1,"op":"plot","request_id":"plot-2","document_id":{doc},"path":"{p}","layout":"NOPE"}}"# )); @@ -2196,11 +2231,11 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; - let mtext = codec::MText::with_value( - "\\A1;10.5000", - codec::types::Vector3::new(10.0, 20.0, 0.0), - ); - app.tabs[i].scene.add_entity(codec::EntityType::MText(mtext)); + let mtext = + codec::MText::with_value("\\A1;10.5000", codec::types::Vector3::new(10.0, 20.0, 0.0)); + app.tabs[i] + .scene + .add_entity(codec::EntityType::MText(mtext)); let q = app.automation_op( r#"{"op":"query","type":"MTEXT","detail":"full","fields":["value","text","height","bounds"]}"#, @@ -2241,7 +2276,9 @@ mod tests { } fn count_type(app: &mut OpenCADStudio, kind: &str) -> u64 { - let q = app.automation_op(&format!(r#"{{"op":"query","type":"{kind}","detail":"summary"}}"#)); + let q = app.automation_op(&format!( + r#"{{"op":"query","type":"{kind}","detail":"summary"}}"# + )); q["count"].as_u64().unwrap() } @@ -2360,8 +2397,8 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#); - let handle = app.automation_op(r#"{"op":"query","type":"Line","detail":"summary"}"#)["entities"][0] - ["handle"] + let handle = app.automation_op(r#"{"op":"query","type":"Line","detail":"summary"}"#) + ["entities"][0]["handle"] .as_str() .unwrap() .to_owned(); @@ -2390,7 +2427,12 @@ mod tests { .as_array() .unwrap() .iter() - .map(|e| (e["start"][0].as_f64().unwrap(), e["start"][1].as_f64().unwrap())) + .map(|e| { + ( + e["start"][0].as_f64().unwrap(), + e["start"][1].as_f64().unwrap(), + ) + }) .collect(); assert!(starts.contains(&(5.0, 0.0))); assert!(starts.contains(&(5.0, 10.0))); @@ -2401,8 +2443,8 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.automation_op(r#"{"op":"run","cmd":"LINE 10,0 20,0"}"#); - let handle = app.automation_op(r#"{"op":"query","type":"Line","detail":"summary"}"#)["entities"][0] - ["handle"] + let handle = app.automation_op(r#"{"op":"query","type":"Line","detail":"summary"}"#) + ["entities"][0]["handle"] .as_str() .unwrap() .to_owned(); @@ -2465,8 +2507,8 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 1"}"#); - let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#)["entities"][0] - ["handle"] + let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#) + ["entities"][0]["handle"] .as_str() .unwrap() .to_owned(); @@ -2500,8 +2542,8 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#); - let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#)["entities"][0] - ["handle"] + let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#) + ["entities"][0]["handle"] .as_str() .unwrap() .to_owned(); @@ -2595,8 +2637,14 @@ mod tests { let path = std::env::temp_dir().join(format!("ocs_block_{}.dxf", std::process::id())); let _ = std::fs::remove_file(&path); let p = path.to_string_lossy().replace('\\', "\\\\"); - assert_eq!(app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"], true); - assert_eq!(app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#))["ok"], true); + assert_eq!( + app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"], + true + ); + assert_eq!( + app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#))["ok"], + true + ); assert_eq!(count_type(&mut app, "Insert"), 1); drop(app); let sidecar = path.with_file_name(format!( @@ -2612,8 +2660,8 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#); - let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#)["entities"][0] - ["handle"] + let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#) + ["entities"][0]["handle"] .as_str() .unwrap() .to_owned(); @@ -2894,16 +2942,24 @@ mod tests { } { let sel = app.tabs[i].scene.selection.borrow(); - assert!(sel.input.left_dragging, "drag must start (add={add} rect={rect})"); + assert!( + sel.input.left_dragging, + "drag must start (add={add} rect={rect})" + ); if rect { // Rectangle mode drives the box machinery, not the lasso. assert!( - sel.gesture.box_anchor.is_some() && sel.gesture.box_current.is_some() && !sel.gesture.poly_active, + sel.gesture.box_anchor.is_some() + && sel.gesture.box_current.is_some() + && !sel.gesture.poly_active, "rect marquee must arm the box (add={add})" ); } else { assert!(sel.gesture.poly_active, "lasso must start (add={add})"); - assert!(sel.gesture.poly_points.len() >= 3, "lasso points (add={add})"); + assert!( + sel.gesture.poly_points.len() >= 3, + "lasso points (add={add})" + ); } } let _ = app.update(Message::ViewportLeftRelease); @@ -3182,8 +3238,7 @@ mod tests { corrupt.center = codec::types::Vector3::new(1.0, 1.0, 0.0); // An absurd radius is rejected; a zero radius is valid. corrupt.radius = 1.0e11; - doc.add_entity(codec::EntityType::Circle(corrupt)) - .unwrap(); + doc.add_entity(codec::EntityType::Circle(corrupt)).unwrap(); let bytes = crate::io::save_to_bytes(&doc, "dxf", doc.version) .expect("save a document containing a corrupt entity"); std::fs::write(&path, bytes).unwrap(); @@ -3536,12 +3591,7 @@ mod tests { let listen_bound = bound.clone(); std::thread::spawn(move || { let mut app = super::OpenCADStudio::new_for_test(); - super::serve_socket_with_idle( - &mut app, - 0, - &listen_bound, - Duration::from_millis(300), - ); + super::serve_socket_with_idle(&mut app, 0, &listen_bound, Duration::from_millis(300)); }); let mut port = 0; for _ in 0..50 { @@ -3577,7 +3627,9 @@ mod tests { .expect("read timeout"); let mut reader2 = BufReader::new(second.try_clone().expect("clone")); let mut banner2 = String::new(); - reader2.read_line(&mut banner2).expect("second ready banner"); + reader2 + .read_line(&mut banner2) + .expect("second ready banner"); assert!(banner2.contains("ready"), "{banner2}"); } } diff --git a/src/app/command_driver/clipboard.rs b/src/app/command_driver/clipboard.rs index 74738f0f7..46f5ca284 100644 --- a/src/app/command_driver/clipboard.rs +++ b/src/app/command_driver/clipboard.rs @@ -327,9 +327,7 @@ impl OpenCADStudio { } if matches!( self.tabs[i].scene.document.get_entity(handle), - Some( - codec::EntityType::Block(_) | codec::EntityType::BlockEnd(_) - ) + Some(codec::EntityType::Block(_) | codec::EntityType::BlockEnd(_)) ) { needs_geometry_bump = true; } else { @@ -361,9 +359,7 @@ impl OpenCADStudio { self.tabs[i].scene.document.get_entity(handle), Some(codec::EntityType::Leader(_)) ) { - let _ = self.tabs[i] - .scene - .sync_displayed_annotation_context(handle); + let _ = self.tabs[i].scene.sync_displayed_annotation_context(handle); } } // Recreate any group whose whole membership was copied, so a pasted @@ -373,7 +369,9 @@ impl OpenCADStudio { // clipboard clone to its new handle. Same shared `recreate_groups` // the in-drawing COPY path uses. (#440) if !deps.groups.is_empty() { - self.tabs[i].scene.recreate_groups(deps.groups.clone(), &handle_map); + self.tabs[i] + .scene + .recreate_groups(deps.groups.clone(), &handle_map); } // Constraints wholly within the pasted selection follow it. self.tabs[i] @@ -455,7 +453,10 @@ pub(crate) fn merge_table_records(doc: &mut codec::CadDocument, deps: &crate::ap /// document lacks. Shared by `merge_clipboard_blocks` and /// `paste_entities_kernel`. Returns how many definitions were created, so /// render-cache callers can bump once instead of per definition. -pub(crate) fn merge_block_defs(doc: &mut codec::CadDocument, blocks: &[crate::app::BlockDef]) -> usize { +pub(crate) fn merge_block_defs( + doc: &mut codec::CadDocument, + blocks: &[crate::app::BlockDef], +) -> usize { let mut created = 0; for def in blocks { let before = doc.block_records.len(); @@ -477,10 +478,7 @@ pub(crate) fn paste_entities_kernel( entities: &[codec::EntityType], deps: &crate::app::ClipboardDeps, translate: Option<&crate::command::EntityTransform>, -) -> ( - Vec, - rustc_hash::FxHashMap, -) { +) -> (Vec, rustc_hash::FxHashMap) { // Shared table-record merge (also used by `merge_dependencies`): recreate // missing records with fresh handles from the target document. merge_table_records(doc, deps); @@ -539,7 +537,11 @@ pub(crate) fn paste_entities_kernel( if let Some(e) = doc.get_entity_mut(new_entity) { e.common_mut().xdictionary_handle = Some(new_root); } - crate::scene::annotative::translate_annotation_contexts(doc, new_entity, annotation_delta); + crate::scene::annotative::translate_annotation_contexts( + doc, + new_entity, + annotation_delta, + ); } } @@ -978,10 +980,7 @@ mod clipboard_xdict_clone_tests { let new_root = recreate_ext_subtree(&mut dst, &cap, Some(dst_entity)) .expect("recreated xdictionary root"); assert!(!new_root.is_null()); - assert!( - !before.contains(&new_root), - "clone must use a fresh handle" - ); + assert!(!before.contains(&new_root), "clone must use a fresh handle"); assert_eq!(dst.objects.len(), before.len() + 2); let leaf = leaf_of(&dst, new_root); @@ -1015,8 +1014,7 @@ mod clipboard_xdict_clone_tests { let mut dst = codec::CadDocument::new(); let before_len = dst.objects.len(); - let root = - recreate_ext_subtree(&mut dst, &cap, None).expect("recreated xdictionary root"); + let root = recreate_ext_subtree(&mut dst, &cap, None).expect("recreated xdictionary root"); let leaf = leaf_of(&dst, root); match dst.objects.get(&leaf) { Some(ObjectType::XRecord(x)) => assert!( @@ -1145,9 +1143,7 @@ mod paste_entities_kernel_tests { codec::types::Vector3::new(10.0, 0.0, 0.0), ); seed_line.common.layer = "WALLS".to_string(); - let seed_handle = seed - .add_entity(codec::EntityType::Line(seed_line)) - .unwrap(); + let seed_handle = seed.add_entity(codec::EntityType::Line(seed_line)).unwrap(); let (entities, deps) = copy_to_clipboard_kernel(&seed, &[seed_handle]); let mut dst = codec::CadDocument::new(); @@ -1165,8 +1161,16 @@ mod paste_entities_kernel_tests { Some(codec::EntityType::Line(l)) => l, other => panic!("expected pasted Line, got {other:?}"), }; - assert!((line.start.x - 5.0).abs() < 1e-9, "start.x = {}", line.start.x); - assert!((line.start.y - 7.0).abs() < 1e-9, "start.y = {}", line.start.y); + assert!( + (line.start.x - 5.0).abs() < 1e-9, + "start.x = {}", + line.start.x + ); + assert!( + (line.start.y - 7.0).abs() < 1e-9, + "start.y = {}", + line.start.y + ); assert!((line.end.x - 15.0).abs() < 1e-9, "end.x = {}", line.end.x); // The layer dependency must have been recreated in the target document. assert!(dst.layers.contains("WALLS")); @@ -1181,9 +1185,7 @@ mod paste_entities_kernel_tests { .unwrap(); let mut leader = codec::entities::Leader::default(); leader.annotation_handle = note; - let leader_handle = seed - .add_entity(codec::EntityType::Leader(leader)) - .unwrap(); + let leader_handle = seed.add_entity(codec::EntityType::Leader(leader)).unwrap(); let (entities, deps) = copy_to_clipboard_kernel(&seed, &[leader_handle, note]); let mut dst = codec::CadDocument::new(); @@ -1216,10 +1218,8 @@ mod paste_entities_kernel_tests { let mut xrec = XRecord::new(); xrec.handle = src_leaf; xrec.owner = src_root; - xrec.entries.push(XRecordEntry::new( - 340, - XRecordValue::Handle(seed_entity), - )); + xrec.entries + .push(XRecordEntry::new(340, XRecordValue::Handle(seed_entity))); seed.objects.insert(src_root, ObjectType::Dictionary(dict)); seed.objects.insert(src_leaf, ObjectType::XRecord(xrec)); if let Some(e) = seed.get_entity_mut(seed_entity) { @@ -1269,7 +1269,7 @@ mod headless_reuse_proof_tests { // (`copy_to_clipboard_kernel`, `paste_entities_kernel`, // `match_layer_kernel`, `match_properties_kernel` + `MatchOpts`). use super::{copy_to_clipboard_kernel, paste_entities_kernel}; - use crate::entities::match_props::{MatchOpts, match_layer_kernel, match_properties_kernel}; + use crate::entities::match_props::{match_layer_kernel, match_properties_kernel, MatchOpts}; #[test] fn headless_copy_paste_then_match_kernels_share_one_path() { @@ -1293,8 +1293,7 @@ mod headless_reuse_proof_tests { assert_eq!(entities[0].common().layer, "WALLS"); let mut doc_b = codec::CadDocument::new(); - let translate = - crate::command::EntityTransform::Translate(glam::DVec3::new(5.0, 7.0, 0.0)); + let translate = crate::command::EntityTransform::Translate(glam::DVec3::new(5.0, 7.0, 0.0)); let (handles, handle_map) = paste_entities_kernel(&mut doc_b, &entities, &deps, Some(&translate)); @@ -1319,16 +1318,12 @@ mod headless_reuse_proof_tests { src_line.common.layer = "WALLS".to_string(); src_line.common.linetype_scale = 2.5; src_line.thickness = 1.25; - let src = doc_b - .add_entity(codec::EntityType::Line(src_line)) - .unwrap(); + let src = doc_b.add_entity(codec::EntityType::Line(src_line)).unwrap(); let mut dst_line = codec::entities::Line::new(); dst_line.common.layer = "0".to_string(); dst_line.common.linetype_scale = 1.0; dst_line.thickness = 0.0; - let dst = doc_b - .add_entity(codec::EntityType::Line(dst_line)) - .unwrap(); + let dst = doc_b.add_entity(codec::EntityType::Line(dst_line)).unwrap(); let count = match_layer_kernel(&mut doc_b, &[dst], src).expect("match layer"); assert_eq!(count, 1); diff --git a/src/app/command_driver/constraint.rs b/src/app/command_driver/constraint.rs index 7a567540d..bc16e2b1e 100644 --- a/src/app/command_driver/constraint.rs +++ b/src/app/command_driver/constraint.rs @@ -11,7 +11,10 @@ impl OpenCADStudio { self.active_modal = Some(super::super::ModalKind::AutoConstrainSettings); } - pub(super) fn handle_add_parametric_constraint(&mut self, result: CmdResult) -> Option> { + pub(super) fn handle_add_parametric_constraint( + &mut self, + result: CmdResult, + ) -> Option> { let i = self.active_tab; let CmdResult::AddParametricConstraint { kind, @@ -28,19 +31,21 @@ impl OpenCADStudio { (kind == ConstraintKind::Smooth && command.name() == "GCSMOOTH") || (kind == ConstraintKind::Fixed && command.name() == "FXCONSTRAINT") }); - if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - kind, - &refs, - driving_param.as_ref(), - ) { + if let Err(message) = + self.tabs[i] + .scene + .validate_parametric_constraint(kind, &refs, driving_param.as_ref()) + { if !keep_command { self.tabs[i].active_cmd = None; } self.tabs[i].snap_result = None; self.command_line.push_error(message); if keep_command { - if let Some(prompt) = - self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + if let Some(prompt) = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()) { self.command_line.push_info(&prompt); } @@ -77,11 +82,11 @@ impl OpenCADStudio { .scene .record_undo_parametric_constraints_before(scope, constraints_before); let retain_size = self.constraint_solve_mode && driving_param.is_none(); - let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( - kind, - refs, - driving_param, - ); + let id = + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, driving_param); self.tabs[i].scene.note_parametric_constraint_applied( scope, id, @@ -91,11 +96,9 @@ impl OpenCADStudio { .into_iter() .map(|h| (h, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities_with_parametric_policy( - &changes, - &[], - retain_size, - ); + self.tabs[i] + .scene + .bump_entities_with_parametric_policy(&changes, &[], retain_size); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -107,7 +110,10 @@ impl OpenCADStudio { None } - pub(super) fn handle_add_equal_constraint(&mut self, result: CmdResult) -> Option> { + pub(super) fn handle_add_equal_constraint( + &mut self, + result: CmdResult, + ) -> Option> { let i = self.active_tab; let CmdResult::AddEqualConstraint { first, @@ -130,13 +136,11 @@ impl OpenCADStudio { for other in others { let refs = [first, other]; if other == first - || self - .tabs[i] + || self.tabs[i] .scene .validate_parametric_constraint(ConstraintKind::Equal, &refs, None) .is_err() - || equal_size_follower(&self.tabs[i].scene.document, first, other) - .is_none() + || equal_size_follower(&self.tabs[i].scene.document, first, other).is_none() { // The reference names the kind the first object // asks for. @@ -168,8 +172,10 @@ impl OpenCADStudio { // reference; Multiple keeps asking anyway. let keep = (multiple && !finishing) || (!multiple && followers.is_empty()); if keep { - if let Some(prompt) = - self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + if let Some(prompt) = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()) { self.command_line.push_info(&prompt); } @@ -214,10 +220,11 @@ impl OpenCADStudio { { self.tabs[i].scene.update_entity(resized); } - let id = self.tabs[i] - .scene - .parametric_constraint_set_mut(scope) - .add(ConstraintKind::Equal, vec![first, *follower], None); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Equal, + vec![first, *follower], + None, + ); self.tabs[i].scene.note_parametric_constraint_applied( scope, id, @@ -252,12 +259,9 @@ impl OpenCADStudio { let scope = self.tabs[i].current_parametric_scope(); let resolved = { let document = &self.tabs[i].scene.document; - let world = - codec::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z); + let world = codec::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z); let reference = match pick.handle { - Some(handle) => { - nearest_parametric_point_on_entity(document, scope, handle, world) - } + Some(handle) => nearest_parametric_point_on_entity(document, scope, handle, world), None => nearest_parametric_point(document, scope, world, None), }; reference.map(|reference| { @@ -284,7 +288,10 @@ impl OpenCADStudio { } } - pub(super) fn handle_add_dimensional_constraint(&mut self, result: CmdResult) -> Option> { + pub(super) fn handle_add_dimensional_constraint( + &mut self, + result: CmdResult, + ) -> Option> { let i = self.active_tab; let CmdResult::AddDimensionalConstraint { kind, @@ -395,12 +402,7 @@ impl OpenCADStudio { None, ); let mut entity = if kind == ConstraintKind::Distance { - aligned_dim::aligned_dimension_entity( - first_point, - second_point, - location, - Some(text), - ) + aligned_dim::aligned_dimension_entity(first_point, second_point, location, Some(text)) } else { linear_dim::linear_dimension_entity( first_point, @@ -456,8 +458,7 @@ impl OpenCADStudio { let id = set.add(kind, refs, Some(DrivingValue::Named(name))); if direction.is_some() { if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { - constraint.distance_direction_type = - distance_direction_type::PERPENDICULAR_TO_LINE; + constraint.distance_direction_type = distance_direction_type::PERPENDICULAR_TO_LINE; } } set.dimensions.insert(id, dimension); @@ -466,10 +467,9 @@ impl OpenCADStudio { id, self.constraint_bar_display, ); - self.tabs[i].scene.attach_dimension_association( - dimension, - vec![Some(first.entity), Some(second.entity)], - ); + self.tabs[i] + .scene + .attach_dimension_association(dimension, vec![Some(first.entity), Some(second.entity)]); let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) @@ -494,7 +494,10 @@ impl OpenCADStudio { None } - pub(super) fn handle_add_radial_constraint(&mut self, result: CmdResult) -> Option> { + pub(super) fn handle_add_radial_constraint( + &mut self, + result: CmdResult, + ) -> Option> { let i = self.active_tab; let CmdResult::AddRadialConstraint { circle, @@ -530,10 +533,7 @@ impl OpenCADStudio { .is_some_and(|set| { set.constraints.iter().any(|c| { c.enabled - && matches!( - c.kind, - ConstraintKind::Radius | ConstraintKind::Diameter - ) + && matches!(c.kind, ConstraintKind::Radius | ConstraintKind::Diameter) && c.refs.iter().any(|r| r.entity == circle.entity) }) }); @@ -672,7 +672,10 @@ impl OpenCADStudio { None } - pub(super) fn handle_add_angular_constraint(&mut self, result: CmdResult) -> Option> { + pub(super) fn handle_add_angular_constraint( + &mut self, + result: CmdResult, + ) -> Option> { let i = self.active_tab; let CmdResult::AddAngularConstraint { refs, @@ -771,11 +774,20 @@ impl OpenCADStudio { ); let entity = if kind == ConstraintKind::Angle { angular_dim::angular_two_line_entity( - points[0], points[1], points[2], points[3], location, Some(text), + points[0], + points[1], + points[2], + points[3], + location, + Some(text), ) } else { angular_dim::angular_three_point_entity( - points[0], points[1], points[2], location, Some(text), + points[0], + points[1], + points[2], + location, + Some(text), ) }; let Some(mut entity) = entity else { @@ -927,9 +939,7 @@ impl OpenCADStudio { .parametric_constraint_set(scope) .cloned() .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new( - scope, - ) + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) }); let pending = self.begin_undo(i, "Parallel constraint", 1, true); if let Some(entity) = placed_entity { @@ -999,13 +1009,10 @@ impl OpenCADStudio { let scope = self.tabs[i].current_parametric_scope(); let document = &self.tabs[i].scene.document; - let world = - codec::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z); + let world = codec::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z); let resolved = match (pick.whole_curve, pick.handle) { - (true, Some(handle)) => { - parametric_curve_ref_for_pick(document, scope, handle, world) - .ok_or(FixConstraintCommand::INVALID_OBJECT) - } + (true, Some(handle)) => parametric_curve_ref_for_pick(document, scope, handle, world) + .ok_or(FixConstraintCommand::INVALID_OBJECT), (true, None) => Err(FixConstraintCommand::NO_OBJECT), (false, Some(handle)) => { nearest_parametric_point_on_entity(document, scope, handle, world) @@ -1020,11 +1027,7 @@ impl OpenCADStudio { let supported = !pick.whole_curve || self.tabs[i] .scene - .validate_parametric_constraint( - ConstraintKind::Fixed, - &[reference], - None, - ) + .validate_parametric_constraint(ConstraintKind::Fixed, &[reference], None) .is_ok(); supported .then_some(reference) @@ -1038,9 +1041,7 @@ impl OpenCADStudio { label: "Fixed constraint", }), // A miss re-prompts: `ReportError` keeps the command. - Err(message) => { - self.apply_cmd_result(CmdResult::ReportError(message.to_string())) - } + Err(message) => self.apply_cmd_result(CmdResult::ReportError(message.to_string())), } } @@ -1075,7 +1076,10 @@ impl OpenCADStudio { Task::none() } - pub(super) fn handle_add_horizontal_constraint(&mut self, result: CmdResult) -> Option> { + pub(super) fn handle_add_horizontal_constraint( + &mut self, + result: CmdResult, + ) -> Option> { let i = self.active_tab; let CmdResult::AddHorizontalConstraint { kind, @@ -1096,9 +1100,7 @@ impl OpenCADStudio { let vertical = kind == ConstraintKind::Vertical; let axis = if vertical { "Vertical" } else { "Horizontal" }; let scope = self.tabs[i].current_parametric_scope(); - let to_world = |point: glam::DVec3| { - codec::types::Vector3::new(point.x, point.y, point.z) - }; + let to_world = |point: glam::DVec3| codec::types::Vector3::new(point.x, point.y, point.z); let (refs, initial_fixed) = match selection { HorizontalConstraintSelection::Reference(reference) => { // The reference turns the object about its first @@ -1175,19 +1177,16 @@ impl OpenCADStudio { )); return Some(Task::none()); } - let direction = codec::types::Vector3::new( - direction.x / axis_length, - direction.y / axis_length, - 0.0, - ); - if let Err(message) = - self.tabs[i].scene.validate_parametric_constraint(kind, &refs, None) + let direction = + codec::types::Vector3::new(direction.x / axis_length, direction.y / axis_length, 0.0); + if let Err(message) = self.tabs[i] + .scene + .validate_parametric_constraint(kind, &refs, None) { self.command_line.push_error(message); return Some(Task::none()); } - if self - .tabs[i] + if self.tabs[i] .scene .parametric_constraint_set(scope) .is_some_and(|set| set.contains_axis_constraint(kind, &refs, direction)) @@ -1271,8 +1270,7 @@ impl OpenCADStudio { if length > 1.0e-9 && angle.abs() > 1.0e-9 { match vertex { Some(index) => { - let moved = - self.tabs[i].scene.document.get_entity(handle).cloned(); + let moved = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(mut entity) = moved { if crate::scene::parametric_constraints::set_polyline_vertex( &mut entity, @@ -1336,9 +1334,7 @@ impl OpenCADStudio { use crate::scene::parametric_constraints::ConstraintKind; let scope = self.tabs[i].current_parametric_scope(); - let to_world = |point: glam::DVec3| { - codec::types::Vector3::new(point.x, point.y, point.z) - }; + let to_world = |point: glam::DVec3| codec::types::Vector3::new(point.x, point.y, point.z); let resolve_point = |pick: crate::command::CoincidentPick| { if let Some(handle) = pick.handle { crate::scene::parametric_constraints::nearest_parametric_point_on_entity( @@ -1359,8 +1355,7 @@ impl OpenCADStudio { let (first, second) = match selection { SymmetricConstraintSelection::Objects(first, second) => (first, second), SymmetricConstraintSelection::Points(first, second) => { - let (Some(first), Some(second)) = - (resolve_point(first), resolve_point(second)) + let (Some(first), Some(second)) = (resolve_point(first), resolve_point(second)) else { self.command_line.push_error( "Symmetric: select supported endpoints, centers, midpoints, or vertices.", @@ -1370,10 +1365,7 @@ impl OpenCADStudio { (first, second) } }; - if first == second - || axis.entity == first.entity - || axis.entity == second.entity - { + if first == second || axis.entity == first.entity || axis.entity == second.entity { self.command_line .push_error("Symmetric: select two different references and a separate line axis."); return Some(Task::none()); @@ -1438,11 +1430,9 @@ impl OpenCADStudio { .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect::>(); - self.tabs[i].scene.bump_entities_with_initial_parametric_policy( - &changes, - &[first, axis], - false, - ); + self.tabs[i] + .scene + .bump_entities_with_initial_parametric_policy(&changes, &[first, axis], false); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -1455,7 +1445,10 @@ impl OpenCADStudio { None } - pub(super) fn handle_add_perpendicular_constraint(&mut self, result: CmdResult) -> Option> { + pub(super) fn handle_add_perpendicular_constraint( + &mut self, + result: CmdResult, + ) -> Option> { let i = self.active_tab; let CmdResult::AddPerpendicularConstraint { first, @@ -1511,11 +1504,13 @@ impl OpenCADStudio { .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect::>(); - self.tabs[i].scene.bump_entities_with_initial_parametric_policy( - &changes, - &[first_fixed, second_start], - true, - ); + self.tabs[i] + .scene + .bump_entities_with_initial_parametric_policy( + &changes, + &[first_fixed, second_start], + true, + ); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -1537,11 +1532,11 @@ impl OpenCADStudio { use crate::scene::parametric_constraints::ConstraintKind; let refs = vec![first, second]; - if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - ConstraintKind::Tangent, - &refs, - None, - ) { + if let Err(message) = + self.tabs[i] + .scene + .validate_parametric_constraint(ConstraintKind::Tangent, &refs, None) + { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.command_line.push_error(message); @@ -1578,11 +1573,9 @@ impl OpenCADStudio { .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect::>(); - self.tabs[i].scene.bump_entities_with_initial_parametric_policy( - &changes, - &[first], - true, - ); + self.tabs[i] + .scene + .bump_entities_with_initial_parametric_policy(&changes, &[first], true); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -1664,11 +1657,9 @@ impl OpenCADStudio { .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect::>(); - self.tabs[i].scene.bump_entities_with_initial_parametric_policy( - &changes, - &[first], - true, - ); + self.tabs[i] + .scene + .bump_entities_with_initial_parametric_policy(&changes, &[first], true); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -1680,7 +1671,10 @@ impl OpenCADStudio { None } - pub(super) fn handle_add_coincident_constraint(&mut self, result: CmdResult) -> Option> { + pub(super) fn handle_add_coincident_constraint( + &mut self, + result: CmdResult, + ) -> Option> { let i = self.active_tab; let CmdResult::AddCoincidentConstraint { first, @@ -1693,8 +1687,7 @@ impl OpenCADStudio { }; let scope = self.tabs[i].current_parametric_scope(); let to_world = |p: glam::DVec3| codec::types::Vector3::new(p.x, p.y, p.z); - let resolve = |pick: crate::command::CoincidentPick, - exclude: Option| { + let resolve = |pick: crate::command::CoincidentPick, exclude: Option| { if pick.whole_curve { pick.handle.and_then(|handle| { crate::scene::parametric_constraints::parametric_curve_ref_for_pick( @@ -1721,8 +1714,8 @@ impl OpenCADStudio { } }; let resolved_first = resolve(first, None); - let resolved_second = resolved_first - .and_then(|reference| resolve(second, Some(reference.entity))); + let resolved_second = + resolved_first.and_then(|reference| resolve(second, Some(reference.entity))); let (Some(first_ref), Some(second_ref)) = (resolved_first, resolved_second) else { self.command_line.push_error( "Coincident: select a supported endpoint, center, midpoint, vertex, or curve.", @@ -1791,7 +1784,8 @@ impl OpenCADStudio { if !multiple { self.tabs[i].active_cmd = None; } - self.command_line.push_output("Coincident constraint applied."); + self.command_line + .push_output("Coincident constraint applied."); if multiple { if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|cmd| cmd.prompt()) { self.command_line.push_info(&prompt); @@ -1825,19 +1819,18 @@ impl OpenCADStudio { .parametric_constraint_set(scope) .cloned() .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new( - scope, - ) + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) }); let pending = self.begin_undo(i, "Coincident auto constrain", handles.len(), true); self.tabs[i] .scene .record_undo_parametric_constraints_before(scope, constraints_before); for refs in inferred { - let id = self.tabs[i] - .scene - .parametric_constraint_set_mut(scope) - .add(ConstraintKind::Coincident, refs, None); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Coincident, + refs, + None, + ); self.tabs[i].scene.note_parametric_constraint_applied( scope, id, diff --git a/src/app/command_driver/dim_edit.rs b/src/app/command_driver/dim_edit.rs index a074c8285..906b4c636 100644 --- a/src/app/command_driver/dim_edit.rs +++ b/src/app/command_driver/dim_edit.rs @@ -29,13 +29,16 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; } - pub(super) fn handle_edit_dimension_jog(&mut self, dimension: Handle, point: Option) { + pub(super) fn handle_edit_dimension_jog( + &mut self, + dimension: Handle, + point: Option, + ) { let i = self.active_tab; let valid = matches!( self.tabs[i].scene.document.get_entity(dimension), Some(codec::EntityType::Dimension( - codec::entities::Dimension::Linear(_) - | codec::entities::Dimension::Aligned(_) + codec::entities::Dimension::Linear(_) | codec::entities::Dimension::Aligned(_) )) ); if !valid { @@ -50,9 +53,7 @@ impl OpenCADStudio { XDataValue::Integer16(387), XDataValue::Integer16(3), XDataValue::Integer16(389), - XDataValue::Point3D(codec::types::Vector3::new( - point.x, point.y, point.z, - )), + XDataValue::Point3D(codec::types::Vector3::new(point.x, point.y, point.z)), ] }); crate::scene::view::dispatch::set_entity_xdata( @@ -79,7 +80,12 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; } - pub(super) fn handle_space_dimensions(&mut self, base: Handle, others: Vec, spacing: Option) { + pub(super) fn handle_space_dimensions( + &mut self, + base: Handle, + others: Vec, + spacing: Option, + ) { let i = self.active_tab; self.push_undo_snapshot(i, "DIMSPACE"); if apply_dimspace(&mut self.tabs[i].scene, base, &others, spacing) { diff --git a/src/app/command_driver/entity_commit.rs b/src/app/command_driver/entity_commit.rs index 8caae44ad..2ff842393 100644 --- a/src/app/command_driver/entity_commit.rs +++ b/src/app/command_driver/entity_commit.rs @@ -41,12 +41,10 @@ impl OpenCADStudio { let is_associative_dimension = matches!( entity, codec::EntityType::Dimension( - codec::entities::Dimension::Linear(_) - | codec::entities::Dimension::Aligned(_) + codec::entities::Dimension::Linear(_) | codec::entities::Dimension::Aligned(_) ) ); - let association_enabled = - self.tabs[i].scene.document.header.dimension_associativity == 2; + let association_enabled = self.tabs[i].scene.document.header.dimension_associativity == 2; let committed = if preserve_commit_layer { self.commit_entity_handle_preserve_layer(entity) } else { @@ -99,8 +97,8 @@ impl OpenCADStudio { .as_ref() .and_then(|command| command.nested_copy_symbol_names()) .cloned(); - let delta_safe = symbol_names.is_none() - && entities.iter().all(|entity| self.delta_add_safe(i, entity)); + let delta_safe = + symbol_names.is_none() && entities.iter().all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); if let Some(names) = symbol_names { let retained = self.tabs[i] @@ -162,8 +160,8 @@ impl OpenCADStudio { .as_ref() .and_then(|command| command.nested_copy_symbol_names()) .cloned(); - let delta_safe = symbol_names.is_none() - && entities.iter().all(|entity| self.delta_add_safe(i, entity)); + let delta_safe = + symbol_names.is_none() && entities.iter().all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); if let Some(names) = symbol_names { let retained = self.tabs[i] @@ -223,24 +221,35 @@ impl OpenCADStudio { self.update_cont_anchor(&entity); // ATTDEF's height becomes the next text height (TEXTSIZE), as TEXT's does. if let codec::EntityType::AttributeDefinition(attribute) = &entity { - if self.tabs[i].active_cmd.as_ref().is_some_and(|cmd| cmd.name().ends_with("ATTDEF")) { + if self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|cmd| cmd.name().ends_with("ATTDEF")) + { self.tabs[i].scene.document.header.text_height = attribute.height; } } // A field the committed text shows (FIELD, an ATTDEF Default from the // Field dialog) is attached once the entity has its handle; the field // objects are not part of the add delta, so the step is a snapshot. - let text_field = self.tabs[i].active_cmd.as_ref().and_then(|cmd| cmd.text_field()); + let text_field = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|cmd| cmd.text_field()); let label = self.history_label_from_active_cmd(i, "ENTITY"); // A block reference whose attribute definitions carry fields gets // them copied onto its attributes (field objects: snapshot step). let attribute_fields = match &entity { codec::EntityType::Insert(insert) if !insert.attributes.is_empty() => { - crate::entities::field::block_has_attribute_fields(&self.tabs[i].scene.document, &insert.block_name) + crate::entities::field::block_has_attribute_fields( + &self.tabs[i].scene.document, + &insert.block_name, + ) } _ => false, }; - let delta_safe = text_field.is_none() && !attribute_fields && self.delta_add_safe(i, &entity); + let delta_safe = + text_field.is_none() && !attribute_fields && self.delta_add_safe(i, &entity); let field_value = match &entity { codec::EntityType::MText(m) => m.value.clone(), codec::EntityType::AttributeDefinition(a) => a.default_value.clone(), @@ -250,12 +259,10 @@ impl OpenCADStudio { let is_associative_dimension = matches!( entity, codec::EntityType::Dimension( - codec::entities::Dimension::Linear(_) - | codec::entities::Dimension::Aligned(_) + codec::entities::Dimension::Linear(_) | codec::entities::Dimension::Aligned(_) ) ); - let association_enabled = - self.tabs[i].scene.document.header.dimension_associativity == 2; + let association_enabled = self.tabs[i].scene.document.header.dimension_associativity == 2; let committed = if preserve_commit_layer { self.commit_entity_handle_preserve_layer(entity) } else { @@ -278,8 +285,13 @@ impl OpenCADStudio { ); } if let (Some(handle), true) = (committed, attribute_fields) { - crate::entities::field::attach_attribute_fields(&mut self.tabs[i].scene.document, handle); - self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + crate::entities::field::attach_attribute_fields( + &mut self.tabs[i].scene.document, + handle, + ); + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); } if let Some(handle) = committed { self.apply_continuous_constraints(i, &[handle]); @@ -340,99 +352,99 @@ impl OpenCADStudio { } else { None }; - let pending = if association_mode == 0 { - let layer = self.tabs[i].active_layer.clone(); - if layer != "0" || entity.as_entity().layer().is_empty() { - entity.as_entity_mut().set_layer(layer); - } - crate::scene::view::dispatch::apply_color( - &mut entity, - self.ribbon.active_color, - ); - crate::scene::view::dispatch::apply_common_prop( - &mut entity, - "linetype", - &self.ribbon.active_linetype.clone(), - ); - crate::scene::view::dispatch::apply_line_weight( - &mut entity, - self.ribbon.active_lineweight, - ); - let celtscale = self.tabs[i] - .scene - .document - .header - .current_entity_linetype_scale; - if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { - entity.common_mut().linetype_scale = celtscale; - } - crate::scene::creation_style::apply_current_creation_styles( - &self.tabs[i].scene.document, - &mut entity, - ); - if let (Some((layer, style_name)), codec::EntityType::Dimension(dimension)) = - (inherited_dimension, &mut entity) - { - dimension.base_mut().common.layer = layer; - dimension.base_mut().style_name = style_name; - } - let pieces = crate::modules::draw::modify::explode::explode_entity( - &entity, - &self.tabs[i].scene.document, - ); - let delta_safe = pieces.iter().all(|piece| self.delta_add_safe(i, piece)); - let pending = self.begin_undo(i, label, pieces.len(), delta_safe); - for piece in pieces { - self.tabs[i].scene.add_entity(piece); - } - pending - } else { - let delta_safe = self.delta_add_safe(i, &entity); - let pending = self.begin_undo(i, label, 1, delta_safe); - if let Some(handle) = - self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style) - { - if association_mode == 2 && association_allowed { - let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; - match association { - crate::command::DimensionAssociationInput::Infer(source) => { - let sources: Vec<_> = source.map_or_else( - || self.infer_dimension_sources_guarded(i, handle), - |source| { - if single_source_dimension { - vec![Some(source)] - } else { - vec![Some(source), Some(source)] - } - }, - ); - self.tabs[i] - .scene - .attach_dimension_association(handle, sources.clone()); - changes.extend(sources.into_iter().flatten().map(|source| { - (source, crate::scene::ChangeKind::Modified) - })); - } - crate::command::DimensionAssociationInput::Explicit(sources) => { - self.tabs[i].scene.attach_dimension_association_sources( - handle, - sources.clone(), - ); - changes.extend(sources.into_iter().flatten().map(|source| { - (source.handle, crate::scene::ChangeKind::Modified) - })); - } - } - changes.sort_by_key(|(handle, _)| handle.value()); - changes.dedup_by_key(|(handle, _)| handle.value()); - self.tabs[i].scene.bump_entities(&changes); - } else if association_mode == 2 { - // Preserve the acquired viewport and source paths. - self.attach_viewport_dimension_association(i, handle); + let pending = + if association_mode == 0 { + let layer = self.tabs[i].active_layer.clone(); + if layer != "0" || entity.as_entity().layer().is_empty() { + entity.as_entity_mut().set_layer(layer); } - } - pending - }; + crate::scene::view::dispatch::apply_color(&mut entity, self.ribbon.active_color); + crate::scene::view::dispatch::apply_common_prop( + &mut entity, + "linetype", + &self.ribbon.active_linetype.clone(), + ); + crate::scene::view::dispatch::apply_line_weight( + &mut entity, + self.ribbon.active_lineweight, + ); + let celtscale = self.tabs[i] + .scene + .document + .header + .current_entity_linetype_scale; + if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { + entity.common_mut().linetype_scale = celtscale; + } + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + if let (Some((layer, style_name)), codec::EntityType::Dimension(dimension)) = + (inherited_dimension, &mut entity) + { + dimension.base_mut().common.layer = layer; + dimension.base_mut().style_name = style_name; + } + let pieces = crate::modules::draw::modify::explode::explode_entity( + &entity, + &self.tabs[i].scene.document, + ); + let delta_safe = pieces.iter().all(|piece| self.delta_add_safe(i, piece)); + let pending = self.begin_undo(i, label, pieces.len(), delta_safe); + for piece in pieces { + self.tabs[i].scene.add_entity(piece); + } + pending + } else { + let delta_safe = self.delta_add_safe(i, &entity); + let pending = self.begin_undo(i, label, 1, delta_safe); + if let Some(handle) = + self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style) + { + if association_mode == 2 && association_allowed { + let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; + match association { + crate::command::DimensionAssociationInput::Infer(source) => { + let sources: Vec<_> = source.map_or_else( + || self.infer_dimension_sources_guarded(i, handle), + |source| { + if single_source_dimension { + vec![Some(source)] + } else { + vec![Some(source), Some(source)] + } + }, + ); + self.tabs[i] + .scene + .attach_dimension_association(handle, sources.clone()); + changes.extend( + sources + .into_iter() + .flatten() + .map(|source| (source, crate::scene::ChangeKind::Modified)), + ); + } + crate::command::DimensionAssociationInput::Explicit(sources) => { + self.tabs[i] + .scene + .attach_dimension_association_sources(handle, sources.clone()); + changes.extend(sources.into_iter().flatten().map(|source| { + (source.handle, crate::scene::ChangeKind::Modified) + })); + } + } + changes.sort_by_key(|(handle, _)| handle.value()); + changes.dedup_by_key(|(handle, _)| handle.value()); + self.tabs[i].scene.bump_entities(&changes); + } else if association_mode == 2 { + // Preserve the acquired viewport and source paths. + self.attach_viewport_dimension_association(i, handle); + } + } + pending + }; self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].snap_result = None; @@ -452,10 +464,7 @@ impl OpenCADStudio { pub(super) fn handle_commit_dimensions_and_exit( &mut self, - dimensions: Vec<( - codec::EntityType, - crate::command::DimensionAssociationInput, - )>, + dimensions: Vec<(codec::EntityType, crate::command::DimensionAssociationInput)>, ) { let i = self.active_tab; let association_mode = self.tabs[i].scene.document.header.dimension_associativity; @@ -467,10 +476,7 @@ impl OpenCADStudio { if layer != "0" || entity.as_entity().layer().is_empty() { entity.as_entity_mut().set_layer(layer); } - crate::scene::view::dispatch::apply_color( - &mut entity, - self.ribbon.active_color, - ); + crate::scene::view::dispatch::apply_color(&mut entity, self.ribbon.active_color); crate::scene::view::dispatch::apply_common_prop( &mut entity, "linetype", @@ -521,30 +527,22 @@ impl OpenCADStudio { continue; } let sources = match association { - crate::command::DimensionAssociationInput::Infer(source) => { - source.map_or_else( - || { - self.infer_dimension_sources_guarded(i, handle) - .into_iter() - .map(|source| { - source.map( - crate::command::DimensionAssociationSource::inferred, - ) - }) - .collect() - }, - |source| { - vec![Some( - crate::command::DimensionAssociationSource::inferred( - source, - ), - )] - }, - ) - } - crate::command::DimensionAssociationInput::Explicit(sources) => { - sources - } + crate::command::DimensionAssociationInput::Infer(source) => source.map_or_else( + || { + self.infer_dimension_sources_guarded(i, handle) + .into_iter() + .map(|source| { + source.map(crate::command::DimensionAssociationSource::inferred) + }) + .collect() + }, + |source| { + vec![Some(crate::command::DimensionAssociationSource::inferred( + source, + ))] + }, + ), + crate::command::DimensionAssociationInput::Explicit(sources) => sources, }; self.tabs[i] .scene @@ -591,8 +589,7 @@ impl OpenCADStudio { let erase_source = erase_source.filter(|handle| { self.delete_objects != 0 && !self.tabs[i].scene.is_layer_locked(*handle) }); - let pending = - self.begin_undo(i, label, 1 + usize::from(erase_source.is_some()), true); + let pending = self.begin_undo(i, label, 1 + usize::from(erase_source.is_some()), true); let handle = self.add_solid_model(entity, *solid, history); if !handle.is_null() { if let Some(source) = erase_source { @@ -767,7 +764,9 @@ impl OpenCADStudio { mut options: Box, ) -> Option> { let i = self.active_tab; - options.handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + options + .handles + .retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); if options.handles.is_empty() { self.command_line .push_info(crate::t!("No editable objects selected.").as_ref()); @@ -865,8 +864,7 @@ impl OpenCADStudio { sources.push(handles); } if let Some(paths) = hatch.boundary_paths.as_mut() { - for (path, handles) in std::sync::Arc::make_mut(paths).iter_mut().zip(&sources) - { + for (path, handles) in std::sync::Arc::make_mut(paths).iter_mut().zip(&sources) { path.boundary_handles = handles.clone(); path.flags.set_external(!handles.is_empty()); } @@ -942,9 +940,8 @@ impl OpenCADStudio { .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; } else { - self.command_line.push_error( - crate::t!("The selected object is no longer available.").as_ref(), - ); + self.command_line + .push_error(crate::t!("The selected object is no longer available.").as_ref()); } self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -969,13 +966,11 @@ impl OpenCADStudio { .reassociate_center_mark(target, source, point) { self.tabs[i].dirty = true; - self.command_line.push_output( - crate::t!("CENTERREASSOCIATE: center mark associated.").as_ref(), - ); + self.command_line + .push_output(crate::t!("CENTERREASSOCIATE: center mark associated.").as_ref()); } else { self.command_line.push_error( - crate::t!("CENTERREASSOCIATE: the selected source is not circular.") - .as_ref(), + crate::t!("CENTERREASSOCIATE: the selected source is not circular.").as_ref(), ); } self.tabs[i].active_cmd = None; @@ -995,8 +990,7 @@ impl OpenCADStudio { br.entity_handles .iter() .filter_map(|&h| { - if let Some(codec::EntityType::AttributeDefinition(ad)) = - doc.get_entity(h) + if let Some(codec::EntityType::AttributeDefinition(ad)) = doc.get_entity(h) { Some(ad.clone()) } else { @@ -1033,7 +1027,10 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); // A repeating insertion keeps its command for the next block. - let keep = self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.attreq_continue()); + let keep = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.attreq_continue()); if keep { let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { @@ -1061,7 +1058,10 @@ impl OpenCADStudio { let name = ins.block_name.clone(); self.record_block_insert(&name); } - let keep = self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.attreq_continue()); + let keep = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.attreq_continue()); return Some(self.apply_cmd_result(if keep { CmdResult::CommitEntity(entity) } else { diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index b82bef978..70ff6a113 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -80,19 +80,19 @@ use crate::command::{CmdResult, SelectionEntity, StepInput}; use codec::Handle; use iced::Task; -mod utilities; -mod parameters; -mod tests; -mod property_match; -mod entity_commit; -mod solid3d; -mod viewport; -mod transform; mod clipboard; -mod modify; -mod text_edit; mod constraint; mod dim_edit; +mod entity_commit; +mod modify; +mod parameters; +mod property_match; +mod solid3d; +mod tests; +mod text_edit; +mod transform; +mod utilities; +mod viewport; pub(crate) use clipboard::{ copy_to_clipboard_kernel, paste_entities_kernel, remap_ext_subtree_reference, @@ -300,7 +300,11 @@ impl OpenCADStudio { /// Reports a refused command-line value and shows the prompt again. fn reprompt_active_command(&mut self, i: usize, message: &str) { self.command_line.push_error(message); - if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) { + if let Some(prompt) = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()) + { self.command_line.push_info(&prompt); } } @@ -407,7 +411,6 @@ impl OpenCADStudio { handles } - fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; if let Some(bind) = self.tabs[i] @@ -480,7 +483,9 @@ impl OpenCADStudio { } => { if !target.is_null() { self.handle_vpclip(target, boundary, boundary_handle); - } else if let Some(task) = self.handle_mview_create_clipped(boundary, boundary_handle) { + } else if let Some(task) = + self.handle_mview_create_clipped(boundary, boundary_handle) + { return task; } } @@ -886,7 +891,11 @@ impl OpenCADStudio { result @ CmdResult::AlignSelected { .. } => { self.handle_align_selected(result); } - CmdResult::LengthenEntity { handle, pick_pt, mode } => { + CmdResult::LengthenEntity { + handle, + pick_pt, + mode, + } => { if let Some(task) = self.handle_lengthen_entity(handle, pick_pt, mode) { return task; } @@ -920,7 +929,11 @@ impl OpenCADStudio { CmdResult::StretchWindow { handles, windows } => { self.handle_stretch_window(handles, windows); } - CmdResult::StretchEntities { handles, windows, delta } => { + CmdResult::StretchEntities { + handles, + windows, + delta, + } => { if let Some(task) = self.handle_stretch_entities(handles, windows, delta) { return task; } @@ -941,7 +954,11 @@ impl OpenCADStudio { result @ CmdResult::PresspullPick { .. } => { self.handle_presspull_pick(result); } - CmdResult::PresspullApply { targets, distance, color: _ } => { + CmdResult::PresspullApply { + targets, + distance, + color: _, + } => { self.handle_presspull_apply(targets, distance); } @@ -963,15 +980,33 @@ impl OpenCADStudio { } } - result @ CmdResult::SolidEdgeBlend { .. } => return self.handle_solid_edge_blend(result), + result @ CmdResult::SolidEdgeBlend { .. } => { + return self.handle_solid_edge_blend(result) + } - CmdResult::SolidShell { handle, actions, distance } => return self.handle_solid_shell(handle, actions, distance), + CmdResult::SolidShell { + handle, + actions, + distance, + } => return self.handle_solid_shell(handle, actions, distance), - CmdResult::SolidSubtract { bases, cutters, convert_meshes } => return self.handle_solid_subtract(bases, cutters, convert_meshes), + CmdResult::SolidSubtract { + bases, + cutters, + convert_meshes, + } => return self.handle_solid_subtract(bases, cutters, convert_meshes), - CmdResult::SliceEntities { targets, plane, keep_point } => return self.handle_slice_entities(targets, plane, keep_point), + CmdResult::SliceEntities { + targets, + plane, + keep_point, + } => return self.handle_slice_entities(targets, plane, keep_point), - CmdResult::SliceSurfaceEntities { targets, cutter, keep_point } => return self.handle_slice_surface_entities(targets, cutter, keep_point), + CmdResult::SliceSurfaceEntities { + targets, + cutter, + keep_point, + } => return self.handle_slice_surface_entities(targets, cutter, keep_point), result @ CmdResult::HatcheditApply { .. } => { if let Some(task) = self.handle_hatchedit_apply(result) { @@ -1498,19 +1533,14 @@ impl OpenCADStudio { } // --- C2a handlers: property_match (thin-router targets, bodies moved verbatim) --- - // --- C2a handlers: viewport (thin-router targets, bodies moved verbatim) --- - // --- C2a handlers: text_edit (thin-router targets, bodies moved verbatim) --- - // --- C2b handlers: entity_commit (thin-router targets, bodies moved verbatim) --- - // --- C2b handlers: clipboard (thin-router targets, bodies moved verbatim) --- - /// Restore the tangent-snap / ortho state that was in effect before the command started. /// Recreate clipboard-dependency records (layer / linetype / text + dim /// style) in tab `i`'s document for any the copied entities reference but @@ -1525,13 +1555,10 @@ impl OpenCADStudio { clipboard::merge_table_records(&mut self.tabs[i].scene.document, deps); } - // --- C2d handlers: constraint (thin-router targets, bodies moved verbatim) --- - // --- C2d handlers: dim_edit (thin-router targets, bodies moved verbatim) --- - pub(super) fn restore_pre_cmd_tangent(&mut self) { if let Some(was_on) = self.pre_cmd_tangent.take() { if !was_on { @@ -1570,23 +1597,28 @@ fn entity_at_typed_point( extent = extent .max((bounds.max.x - bounds.min.x).abs()) .max((bounds.max.y - bounds.min.y).abs()); - let distance = match crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) { - Some(distance) => distance, - // A text or an ellipse answers by its extent, the way a click - // inside it picks it. - None if matches!( - entity, - codec::EntityType::Text(_) - | codec::EntityType::MText(_) - | codec::EntityType::Ellipse(_) - ) => - { - let dx = (bounds.min.x - point.x).max(point.x - bounds.max.x).max(0.0); - let dy = (bounds.min.y - point.y).max(point.y - bounds.max.y).max(0.0); - dx.hypot(dy) - } - None => continue, - }; + let distance = + match crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) { + Some(distance) => distance, + // A text or an ellipse answers by its extent, the way a click + // inside it picks it. + None if matches!( + entity, + codec::EntityType::Text(_) + | codec::EntityType::MText(_) + | codec::EntityType::Ellipse(_) + ) => + { + let dx = (bounds.min.x - point.x) + .max(point.x - bounds.max.x) + .max(0.0); + let dy = (bounds.min.y - point.y) + .max(point.y - bounds.max.y) + .max(0.0); + dx.hypot(dy) + } + None => continue, + }; if nearest.is_none_or(|(best, _)| distance < best) { nearest = Some((distance, common.handle)); } @@ -1597,10 +1629,8 @@ fn entity_at_typed_point( .map(|(_, handle)| handle) } - // ── DIMSPACE helper ─────────────────────────────────────────────────────────── - fn apply_mleader_align( scene: &mut crate::scene::Scene, handles: &[codec::Handle], @@ -1693,8 +1723,7 @@ fn apply_mleader_collect( } let px = point.x; let py = point.y; - let Some(codec::EntityType::MultiLeader(base)) = scene.document.get_entity(handles[0]) - else { + let Some(codec::EntityType::MultiLeader(base)) = scene.document.get_entity(handles[0]) else { return false; }; let base_block = base.block_content_handle; @@ -1745,5 +1774,3 @@ fn apply_mleader_collect( scene.bump_entities(&[(handles[0], crate::scene::ChangeKind::Modified)]); true } - - diff --git a/src/app/command_driver/modify.rs b/src/app/command_driver/modify.rs index 070d06c3b..c832155b1 100644 --- a/src/app/command_driver/modify.rs +++ b/src/app/command_driver/modify.rs @@ -1,7 +1,12 @@ use super::*; impl OpenCADStudio { - pub(super) fn handle_lengthen_entity(&mut self, handle: Handle, pick_pt: glam::DVec3, mode: crate::modules::draw::modify::lengthen::LenMode) -> Option> { + pub(super) fn handle_lengthen_entity( + &mut self, + handle: Handle, + pick_pt: glam::DVec3, + mode: crate::modules::draw::modify::lengthen::LenMode, + ) -> Option> { let i = self.active_tab; if self.reject_locked_edit(i, handle) { return Some(Task::none()); @@ -35,7 +40,12 @@ impl OpenCADStudio { None } - pub(super) fn handle_divide_entity(&mut self, handle: Handle, n: usize, marker: Option) { + pub(super) fn handle_divide_entity( + &mut self, + handle: Handle, + n: usize, + marker: Option, + ) { let i = self.active_tab; use crate::modules::draw::inquiry::divide::divide_entity; let pts = self.tabs[i] @@ -56,9 +66,8 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("DIVIDE: {count} marker(s) placed.").as_ref()); } else { - self.command_line.push_error( - crate::t!("DIVIDE: entity type not supported or N < 2.").as_ref(), - ); + self.command_line + .push_error(crate::t!("DIVIDE: entity type not supported or N < 2.").as_ref()); } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -105,12 +114,20 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); } - pub(super) fn handle_pedit_op(&mut self, handle: Handle, op: crate::modules::draw::modify::pedit::PeditOp) -> Task { + pub(super) fn handle_pedit_op( + &mut self, + handle: Handle, + op: crate::modules::draw::modify::pedit::PeditOp, + ) -> Task { let i = self.active_tab; self.apply_pedit_result(i, handle, op) } - pub(super) fn handle_join_to_source(&mut self, source: Handle, handles: Vec) -> Option> { + pub(super) fn handle_join_to_source( + &mut self, + source: Handle, + handles: Vec, + ) -> Option> { let i = self.active_tab; if self.reject_locked_edit(i, source) { return Some(Task::none()); @@ -192,8 +209,7 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); self.command_line.push_output( - crate::tf!("JOIN: {count_in} object(s) joined into {count_out}.") - .as_ref(), + crate::tf!("JOIN: {count_in} object(s) joined into {count_out}.").as_ref(), ); self.refresh_properties(); } @@ -210,7 +226,12 @@ impl OpenCADStudio { None } - pub(super) fn handle_break_entity(&mut self, handle: Handle, p1: glam::DVec3, p2: glam::DVec3) -> Option> { + pub(super) fn handle_break_entity( + &mut self, + handle: Handle, + p1: glam::DVec3, + p2: glam::DVec3, + ) -> Option> { let i = self.active_tab; if self.reject_locked_edit(i, handle) { return Some(Task::none()); @@ -224,13 +245,15 @@ impl OpenCADStudio { match replacement { Some(frags) => { let unchanged = frags.len() == 1 - && self.tabs[i].scene.document.get_entity(handle).is_some_and( - |original| { + && self.tabs[i] + .scene + .document + .get_entity(handle) + .is_some_and(|original| { let mut fragment = frags[0].clone(); fragment.common_mut().handle = handle; &fragment == original - }, - ); + }); if unchanged { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -282,7 +305,6 @@ impl OpenCADStudio { None } - pub(super) fn handle_hatchedit_apply(&mut self, result: CmdResult) -> Option> { let i = self.active_tab; let CmdResult::HatcheditApply { @@ -315,8 +337,7 @@ impl OpenCADStudio { } else { let plane = match self.tabs[i].scene.document.get_entity(handle) { Some(codec::EntityType::Hatch(h)) => { - let storage = - crate::entities::curve::ocs_plane(h.normal, h.elevation); + let storage = crate::entities::curve::ocs_plane(h.normal, h.elevation); crate::command::WorkingPlane::new( glam::DVec3::from_array(storage.origin), glam::DVec3::from_array(storage.x_axis), @@ -325,10 +346,12 @@ impl OpenCADStudio { } _ => self.tabs[i].ucs_xform().working_plane(), }; - let mut sources = - self.tabs[i].scene.boundary_sources_on_plane(plane, 1e-6); + let mut sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1e-6); sources.remove(&handle); - let command=crate::modules::draw::draw::hatchedit::HatcheditCommand::for_association(handle,name,scale,angle,plane,sources); + let command = + crate::modules::draw::draw::hatchedit::HatcheditCommand::for_association( + handle, name, scale, angle, plane, sources, + ); self.tabs[i].scene.deselect_all(); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); @@ -336,19 +359,16 @@ impl OpenCADStudio { return Some(Task::none()); } if let HatchEditOperation::DrawOrderBoundary { above } = &operation { - let references: Vec<_> = - match self.tabs[i].scene.document.get_entity(handle) { - Some(codec::EntityType::Hatch(h)) => h - .paths - .iter() - .flat_map(|p| p.boundary_handles.iter()) - .filter(|h| { - self.tabs[i].scene.document.get_entity(**h).is_some() - }) - .map(|h| format!("{:X}", h.value())) - .collect(), - _ => Vec::new(), - }; + let references: Vec<_> = match self.tabs[i].scene.document.get_entity(handle) { + Some(codec::EntityType::Hatch(h)) => h + .paths + .iter() + .flat_map(|p| p.boundary_handles.iter()) + .filter(|h| self.tabs[i].scene.document.get_entity(**h).is_some()) + .map(|h| format!("{:X}", h.value())) + .collect(), + _ => Vec::new(), + }; self.tabs[i].active_cmd = None; if references.is_empty() { self.command_line @@ -392,15 +412,15 @@ impl OpenCADStudio { name } }); - if layer.as_ref().is_some_and(|name| { - self.tabs[i].scene.document.layers.get(name).is_none() - }) { + if layer + .as_ref() + .is_some_and(|name| self.tabs[i].scene.document.layers.get(name).is_none()) + { self.discard_last_undo_entry(i); self.command_line.push_error("HATCHEDIT: layer not found."); return Some(Task::none()); } - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { let common = entity.common_mut(); if let Some(value) = color { common.color = value; @@ -440,13 +460,9 @@ impl OpenCADStudio { self.tabs[i].scene.document.get_entity_mut(handle) { if !name.is_empty() && name != hatch.pattern.name { - if let Some(entry) = - crate::scene::model::hatch_patterns::find(&name) - { + if let Some(entry) = crate::scene::model::hatch_patterns::find(&name) { let mut pattern = - crate::scene::model::hatch_patterns::build_dxf_pattern( - entry, - ); + crate::scene::model::hatch_patterns::build_dxf_pattern(entry); crate::entities::hatch::scale_pattern_geometry( &mut pattern, scale.max(1.0e-6) as f64, @@ -466,8 +482,7 @@ impl OpenCADStudio { entry.gpu, crate::scene::model::hatch_model::HatchPattern::Solid ); - hatch.pattern_type = - codec::entities::HatchPatternType::Predefined; + hatch.pattern_type = codec::entities::HatchPatternType::Predefined; hatch.gradient_color.enabled = false; } } else { @@ -533,18 +548,13 @@ impl OpenCADStudio { || paths.iter().any(|path| { path.boundary_handles.is_empty() || path.boundary_handles.iter().any(|source| { - self.tabs[i] - .scene - .document - .get_entity(*source) - .is_none() + self.tabs[i].scene.document.get_entity(*source).is_none() }) }) { self.discard_last_undo_entry(i); - self.command_line.push_error( - "HATCHEDIT: associated boundary is no longer available.", - ); + self.command_line + .push_error("HATCHEDIT: associated boundary is no longer available."); return Some(Task::none()); } self.tabs[i].scene.replace_hatch_association(handle, paths); @@ -552,10 +562,8 @@ impl OpenCADStudio { HatchEditOperation::RecreateBoundary { associate, region } => { let source = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(codec::EntityType::Hatch(source)) = source { - let storage = crate::entities::curve::ocs_plane( - source.normal, - source.elevation, - ); + let storage = + crate::entities::curve::ocs_plane(source.normal, source.elevation); let plane = crate::command::WorkingPlane::new( glam::DVec3::from_array(storage.origin), glam::DVec3::from_array(storage.x_axis), @@ -573,13 +581,11 @@ impl OpenCADStudio { }) .collect::>>(); let Some(loops) = loops.filter(|loops| { - !loops.is_empty() - && loops.iter().all(|ring| !ring.is_empty()) + !loops.is_empty() && loops.iter().all(|ring| !ring.is_empty()) }) else { self.discard_last_undo_entry(i); - self.command_line.push_error( - "HATCHEDIT: boundary cannot form a region.", - ); + self.command_line + .push_error("HATCHEDIT: boundary cannot form a region."); return Some(Task::none()); }; let contained = loops @@ -612,8 +618,7 @@ impl OpenCADStudio { .map(|(outer, depth)| { let mut indices = vec![outer]; indices.extend((0..loops.len()).filter(|inner| { - depths[*inner] == *depth + 1 - && contained[*inner][outer] + depths[*inner] == *depth + 1 && contained[*inner][outer] })); indices }) @@ -632,9 +637,8 @@ impl OpenCADStudio { .collect::>>(); let Some(entities) = entities else { self.discard_last_undo_entry(i); - self.command_line.push_error( - "HATCHEDIT: boundary cannot form a region.", - ); + self.command_line + .push_error("HATCHEDIT: boundary cannot form a region."); return Some(Task::none()); }; (entities, groups) @@ -642,21 +646,18 @@ impl OpenCADStudio { let rings = crate::scene::hatch_boundary_rings(&source); if rings.len() != source.paths.len() { self.discard_last_undo_entry(i); - self.command_line.push_error( - "HATCHEDIT: boundary cannot form a polyline.", - ); + self.command_line + .push_error("HATCHEDIT: boundary cannot form a polyline."); return Some(Task::none()); } let entities = crate::scene::boundary_entities(&rings, plane); if entities.len() != source.paths.len() { self.discard_last_undo_entry(i); - self.command_line.push_error( - "HATCHEDIT: boundary cannot form a polyline.", - ); + self.command_line + .push_error("HATCHEDIT: boundary cannot form a polyline."); return Some(Task::none()); } - let groups = - (0..entities.len()).map(|index| vec![index]).collect(); + let groups = (0..entities.len()).map(|index| vec![index]).collect(); (entities, groups) }; let mut path_handles = vec![None; source.paths.len()]; @@ -681,8 +682,7 @@ impl OpenCADStudio { path.flags.set_external(true); } } - hatch.is_associative = - path_handles.iter().all(Option::is_some); + hatch.is_associative = path_handles.iter().all(Option::is_some); } } self.tabs[i] diff --git a/src/app/command_driver/parameters.rs b/src/app/command_driver/parameters.rs index cab5fc6f7..5f71698e2 100644 --- a/src/app/command_driver/parameters.rs +++ b/src/app/command_driver/parameters.rs @@ -129,10 +129,7 @@ impl OpenCADStudio { self.command_line.push_error(&error.to_string()); return; } - if !table - .resolve(&target) - .is_ok_and(|value| value.is_finite()) - { + if !table.resolve(&target).is_ok_and(|value| value.is_finite()) { self.command_line.push_error("Invalid expression."); return; } @@ -171,7 +168,12 @@ impl OpenCADStudio { /// Renames a parameter everywhere: the table, the expressions that use /// it and the constraints it drives. - pub(crate) fn rename_parameter(&mut self, i: usize, old: &str, new: &str) -> Result<(), String> { + pub(crate) fn rename_parameter( + &mut self, + i: usize, + old: &str, + new: &str, + ) -> Result<(), String> { use crate::scene::named_parameters::{is_reserved_name, is_valid_name, DrivingValue}; let table = self.tabs[i].scene.named_parameters().clone(); let Some(source) = table.get(old).map(|parameter| parameter.source.clone()) else { @@ -181,7 +183,9 @@ impl OpenCADStudio { return Err(format!("Invalid parameter name {new}.")); } let mut renamed = table.clone(); - renamed.set(new, &source).map_err(|error| error.to_string())?; + renamed + .set(new, &source) + .map_err(|error| error.to_string())?; for parameter in table.iter() { if parameter.name == old { continue; @@ -221,7 +225,8 @@ impl OpenCADStudio { .record_undo_parametric_constraints_before(scope, before); let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); for constraint in &mut set.constraints { - if matches!(&constraint.driving_param, Some(DrivingValue::Named(name)) if name == old) { + if matches!(&constraint.driving_param, Some(DrivingValue::Named(name)) if name == old) + { constraint.driving_param = Some(DrivingValue::Named(new.to_string())); } } @@ -902,22 +907,19 @@ impl OpenCADStudio { } pub(super) fn apply_continuous_constraints(&mut self, i: usize, new_handles: &[codec::Handle]) { - let supported_creation = self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|command| { - matches!( - command.name(), - "LINE" - | "PLINE" - | "POLYLINE" - | "RECTANG" - | "RECTANGLE" - | "POLYGON" - | "CIRCLE" - | "ARC" - ) - }); + let supported_creation = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { + matches!( + command.name(), + "LINE" + | "PLINE" + | "POLYLINE" + | "RECTANG" + | "RECTANGLE" + | "POLYGON" + | "CIRCLE" + | "ARC" + ) + }); if supported_creation { self.apply_inferred_constraints(i, new_handles); } diff --git a/src/app/command_driver/property_match.rs b/src/app/command_driver/property_match.rs index fb41bc776..11601f17f 100644 --- a/src/app/command_driver/property_match.rs +++ b/src/app/command_driver/property_match.rs @@ -41,7 +41,6 @@ impl OpenCADStudio { } } - pub(super) fn handle_match_properties( &mut self, mut dest: Vec, @@ -163,5 +162,4 @@ impl OpenCADStudio { } None } - } diff --git a/src/app/command_driver/solid3d.rs b/src/app/command_driver/solid3d.rs index d9a78dc21..656023534 100644 --- a/src/app/command_driver/solid3d.rs +++ b/src/app/command_driver/solid3d.rs @@ -45,8 +45,7 @@ impl OpenCADStudio { let mut consumed = Vec::new(); let mut failed = 0usize; for handle in &handles { - let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() - else { + let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { failed += 1; continue; }; @@ -56,11 +55,7 @@ impl OpenCADStudio { }; let direction = match extent { ExtrudeExtent::Height(height) => profile.plane.normal().map(|normal| { - glam::DVec3::new( - normal[0] * height, - normal[1] * height, - normal[2] * height, - ) + glam::DVec3::new(normal[0] * height, normal[1] * height, normal[2] * height) }), ExtrudeExtent::Direction(direction) => Some(direction), ExtrudeExtent::Path(_) => None, @@ -76,22 +71,14 @@ impl OpenCADStudio { (false, Some((_, path)), _) => { sweep_model::extruded_along_path(&entity, path, taper_angle) } - (false, None, Some(direction)) => sweep_model::extruded_direction( - &entity, - direction.to_array(), - taper_angle, - ), - (true, None, Some(direction)) => sweep_model::extruded_surface( - &entity, - direction.to_array(), - taper_angle, - ), + (false, None, Some(direction)) => { + sweep_model::extruded_direction(&entity, direction.to_array(), taper_angle) + } + (true, None, Some(direction)) => { + sweep_model::extruded_surface(&entity, direction.to_array(), taper_angle) + } (true, Some(_), _) => path_direction.and_then(|direction| { - sweep_model::extruded_surface( - &entity, - direction.to_array(), - taper_angle, - ) + sweep_model::extruded_surface(&entity, direction.to_array(), taper_angle) }), _ => None, }; @@ -126,9 +113,7 @@ impl OpenCADStudio { } } match history { - Some(history) => { - self.add_surface_model_with_history(surface, body, history) - } + Some(history) => self.add_surface_model_with_history(surface, body, history), None => self.add_surface_model(surface, body), } } else { @@ -136,12 +121,7 @@ impl OpenCADStudio { let history = path .as_ref() .and_then(|(_, path)| { - sweep_model::sweep_history( - &entity, - path, - taper_angle, - profile.plane.origin, - ) + sweep_model::sweep_history(&entity, path, taper_angle, profile.plane.origin) }) .or_else(|| { sweep_model::extrusion_history( @@ -187,7 +167,12 @@ impl OpenCADStudio { self.commit_undo_delta(i, pending); } } else { - self.command_line.push_error(crate::t!("EXTRUDE: no selected object could be extruded with the requested options.").as_ref()); + self.command_line.push_error( + crate::t!( + "EXTRUDE: no selected object could be extruded with the requested options." + ) + .as_ref(), + ); if let Some(pending) = pending { self.commit_undo_delta(i, pending); } @@ -200,7 +185,11 @@ impl OpenCADStudio { None } - pub(super) fn handle_thicken_entities(&mut self, handles: Vec, distance: f64) -> Option> { + pub(super) fn handle_thicken_entities( + &mut self, + handles: Vec, + distance: f64, + ) -> Option> { let i = self.active_tab; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -233,20 +222,17 @@ impl OpenCADStudio { failed += 1; continue; }; - let kernel_distance = - if surface.kind == codec::entities::SurfaceKind::Revolved { - -distance - } else { - distance - }; + let kernel_distance = if surface.kind == codec::entities::SurfaceKind::Revolved { + -distance + } else { + distance + }; let body = self.tabs[i] .scene .solid_models .get(&handle) .cloned() - .or_else(|| { - crate::scene::convert::solid3d_tess::kernel_surface_body(surface) - }); + .or_else(|| crate::scene::convert::solid3d_tess::kernel_surface_body(surface)); let Some(body) = body else { failed += 1; continue; @@ -286,7 +272,8 @@ impl OpenCADStudio { if failed > 0 { self.command_line.push_output(&crate::tf!( "THICKEN: created {} solid(s); {} surface(s) could not be thickened exactly.", - created, failed + created, + failed )); } if let Some(pending) = pending { @@ -309,7 +296,11 @@ impl OpenCADStudio { self.presspull_pick(handle, point, offset); } - pub(super) fn handle_presspull_apply(&mut self, targets: Vec, distance: f64) { + pub(super) fn handle_presspull_apply( + &mut self, + targets: Vec, + distance: f64, + ) { self.presspull_apply(targets, distance); } @@ -357,8 +348,7 @@ impl OpenCADStudio { let mut created_handles = Vec::new(); let mut consumed = Vec::new(); for handle in &editable_handles { - let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() - else { + let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { failed += 1; continue; }; @@ -375,31 +365,22 @@ impl OpenCADStudio { continue; }; self.add_surface_model( - empty_revolved_surface( - &entity, - axis_start, - axis_end, - angle, - start_angle, - ), + empty_revolved_surface(&entity, axis_start, axis_end, angle, start_angle), body, ) } else { - let result = - sweep_model::revolve_history(&entity, from, to, angle, start_angle) - .and_then(|history| { - kernel::acis::rebuild_body(&history) - .ok() - .map(|solid| (solid, history)) + let result = sweep_model::revolve_history(&entity, from, to, angle, start_angle) + .and_then(|history| { + kernel::acis::rebuild_body(&history) + .ok() + .map(|solid| (solid, history)) + }) + .or_else(|| { + sweep_model::revolved(&entity, from, to, angle, start_angle).map(|solid| { + let history = crate::scene::model::solid_history::brep_op(&solid); + (solid, history) }) - .or_else(|| { - sweep_model::revolved(&entity, from, to, angle, start_angle) - .map(|solid| { - let history = - crate::scene::model::solid_history::brep_op(&solid); - (solid, history) - }) - }); + }); let Some((solid, history)) = result else { failed += 1; continue; @@ -436,8 +417,10 @@ impl OpenCADStudio { ); } else { self.command_line.push_error( - crate::t!("REVOLVE: no selected object could be revolved with the requested options.") - .as_ref(), + crate::t!( + "REVOLVE: no selected object could be revolved with the requested options." + ) + .as_ref(), ); } if let Some(pending) = pending { @@ -504,7 +487,8 @@ impl OpenCADStudio { let (path, options) = path.as_ref().zip(options).ok_or(None)?; if sweep_model::spatial_profile(&profile).is_some() { let record = sweep_model::spatial_sweep_record(&profile, path, options)?; - let body = kernel::acis::rebuild_sweep_with_mode(&record, true).map_err(|_| None)?; + let body = + kernel::acis::rebuild_sweep_with_mode(&record, true).map_err(|_| None)?; return Ok((body, record, true)); } let mut record = sweep_model::sweep_record(&profile, path, options).ok_or(None)?; @@ -515,25 +499,32 @@ impl OpenCADStudio { ) .map_err(|_| None)?; let surface = mode == ExtrudeMode::Surface || !closed; - let body = kernel::acis::rebuild_sweep_with_mode(&record, surface).map_err(|error| match error { - kernel::acis::HistoryRebuildError::Refused(why) => Some(sweep_model::sweep_refusal_code(why)), - _ => None, - })?; + let body = + kernel::acis::rebuild_sweep_with_mode(&record, surface).map_err(|error| { + match error { + kernel::acis::HistoryRebuildError::Refused(why) => { + Some(sweep_model::sweep_refusal_code(why)) + } + _ => None, + } + })?; Ok((body, record, surface)) })(); let (body, record, surface) = match result { Ok(value) => value, Err(code) => { if let Some(code) = code { - self.command_line.push_output(crate::t!("Modeling Operation Error:").as_ref()); - self.command_line.push_output(crate::tf!("Error Code Number is {code}", code = code).as_ref()); + self.command_line + .push_output(crate::t!("Modeling Operation Error:").as_ref()); + self.command_line.push_output( + crate::tf!("Error Code Number is {code}", code = code).as_ref(), + ); } failed += 1; continue; } }; - let deletes_path = - crate::app::delobj_deletes_auxiliary(delete_objects, surface); + let deletes_path = crate::app::delobj_deletes_auxiliary(delete_objects, surface); if deletes_path && self.tabs[i].scene.is_layer_locked(path_handle) { failed += 1; continue; @@ -577,7 +568,13 @@ impl OpenCADStudio { self.tabs[i].dirty = true; } if failed > 0 { - self.command_line.push_output(crate::tf!("Unable to sweep {failed} selected objects.", failed = failed).as_ref()); + self.command_line.push_output( + crate::tf!( + "Unable to sweep {failed} selected objects.", + failed = failed + ) + .as_ref(), + ); } if let Some(pending) = pending { self.commit_undo_delta(i, pending); @@ -636,12 +633,14 @@ impl OpenCADStudio { }) .collect::>(); let result = if section_locked { - Err("LOFT: a source is on a locked layer; disable source deletion or unlock it.".to_string()) + Err( + "LOFT: a source is on a locked layer; disable source deletion or unlock it." + .to_string(), + ) } else { loft_command_model::record(§ions, &guides, path, &available, mode, options) .and_then(|record| { - kernel::acis::rebuild_loft_with_options(&record) - .map(|body| (body, record)) + kernel::acis::rebuild_loft_with_options(&record).map(|body| (body, record)) }) }; match result { @@ -667,10 +666,8 @@ impl OpenCADStudio { let dirty_before = self.tabs[i].dirty; let pending = self.begin_undo(i, "LOFT", 1, true); let created = if surface { - let handle = self.add_surface_model( - loft_command_model::surface_entity(&record), - body, - ); + let handle = + self.add_surface_model(loft_command_model::surface_entity(&record), body); if !handle.is_null() { self.tabs[i].scene.create_solid_history( handle, @@ -745,8 +742,7 @@ impl OpenCADStudio { else { unreachable!("router only routes the matching variant"); }; - let task = - self.solid_edge_blend(handle, &edges, base_face, value, other_value, fillet); + let task = self.solid_edge_blend(handle, &edges, base_face, value, other_value, fillet); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); @@ -754,7 +750,12 @@ impl OpenCADStudio { task } - pub(super) fn handle_solid_shell(&mut self, handle: Handle, actions: Vec, distance: f64) -> Task { + pub(super) fn handle_solid_shell( + &mut self, + handle: Handle, + actions: Vec, + distance: f64, + ) -> Task { let i = self.active_tab; let task = self.solid_shell(handle, &actions, distance); self.tabs[i].active_cmd = None; @@ -764,7 +765,12 @@ impl OpenCADStudio { task } - pub(super) fn handle_solid_subtract(&mut self, bases: Vec, cutters: Vec, convert_meshes: bool) -> Task { + pub(super) fn handle_solid_subtract( + &mut self, + bases: Vec, + cutters: Vec, + convert_meshes: bool, + ) -> Task { let i = self.active_tab; let task = self.solid_subtract(&bases, &cutters, convert_meshes); self.tabs[i].active_cmd = None; @@ -774,7 +780,12 @@ impl OpenCADStudio { task } - pub(super) fn handle_slice_entities(&mut self, targets: Vec, plane: kernel::space::Plane, keep_point: Option) -> Task { + pub(super) fn handle_slice_entities( + &mut self, + targets: Vec, + plane: kernel::space::Plane, + keep_point: Option, + ) -> Task { let i = self.active_tab; let task = self.solid_slice(&targets, plane, keep_point); self.tabs[i].active_cmd = None; @@ -784,7 +795,12 @@ impl OpenCADStudio { task } - pub(super) fn handle_slice_surface_entities(&mut self, targets: Vec, cutter: Box, keep_point: Option) -> Task { + pub(super) fn handle_slice_surface_entities( + &mut self, + targets: Vec, + cutter: Box, + keep_point: Option, + ) -> Task { let i = self.active_tab; let task = self.solid_slice_surface(&targets, *cutter, keep_point); self.tabs[i].active_cmd = None; diff --git a/src/app/command_driver/tests.rs b/src/app/command_driver/tests.rs index c5a250660..1b780200a 100644 --- a/src/app/command_driver/tests.rs +++ b/src/app/command_driver/tests.rs @@ -94,12 +94,10 @@ mod parametric_constraint_undo_tests { fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle { app.tabs[app.active_tab] .scene - .add_entity(codec::EntityType::Line( - codec::entities::Line::from_points( - codec::types::Vector3::new(x1, y1, 0.0), - codec::types::Vector3::new(x2, y2, 0.0), - ), - )) + .add_entity(codec::EntityType::Line(codec::entities::Line::from_points( + codec::types::Vector3::new(x1, y1, 0.0), + codec::types::Vector3::new(x2, y2, 0.0), + ))) } fn line_angle_deg(app: &OpenCADStudio, handle: Handle) -> f64 { @@ -211,14 +209,11 @@ mod parametric_constraint_undo_tests { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let handle = add_line(&mut app, 0.0, 0.0, 5.0, 2.0); - let (original_start, original_end, original_length) = match app.tabs[app.active_tab] - .scene - .document - .get_entity(handle) - { - Some(codec::EntityType::Line(line)) => (line.start, line.end, line.length()), - other => panic!("expected a Line, got {other:?}"), - }; + let (original_start, original_end, original_length) = + match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(codec::EntityType::Line(line)) => (line.start, line.end, line.length()), + other => panic!("expected a Line, got {other:?}"), + }; let direction = codec::types::Vector3::new(3.0, 4.0, 0.0).normalize(); let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { @@ -264,7 +259,13 @@ mod parametric_constraint_undo_tests { Some(codec::EntityType::Line(line)) => line, other => panic!("expected a Line after redo, got {other:?}"), }; - assert!((line.end - line.start).normalize().cross(&direction).length() < 1.0e-7); + assert!( + (line.end - line.start) + .normalize() + .cross(&direction) + .length() + < 1.0e-7 + ); } #[test] @@ -832,9 +833,7 @@ mod parametric_constraint_undo_tests { else { panic!("expected second line"); }; - assert!( - (first.end - codec::types::Vector3::new(5.0, 0.0, 0.0)).length() < 1.0e-7 - ); + assert!((first.end - codec::types::Vector3::new(5.0, 0.0, 0.0)).length() < 1.0e-7); assert!((second.start - first.end).length() < 1.0e-7); assert!((second.length() - 5.0).abs() < 1.0e-7); } @@ -889,13 +888,12 @@ mod delobj_tests { let profile = app.tabs[i] .scene .add_entity(codec::EntityType::Circle(circle)); - let path = - app.tabs[i] - .scene - .add_entity(codec::EntityType::Line(codec::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(0.0, 0.0, 5.0), - ))); + let path = app.tabs[i] + .scene + .add_entity(codec::EntityType::Line(codec::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(0.0, 0.0, 5.0), + ))); app.delete_objects = value; let _ = app.apply_cmd_result(CmdResult::SweepEntities { diff --git a/src/app/command_driver/text_edit.rs b/src/app/command_driver/text_edit.rs index 1bc8f0f5f..86cb2b713 100644 --- a/src/app/command_driver/text_edit.rs +++ b/src/app/command_driver/text_edit.rs @@ -18,7 +18,12 @@ impl OpenCADStudio { self.open_mtext_editor(pos, handle, &initial, height, template.map(|m| *m)); } - pub(super) fn handle_suspend_for_mtext_input(&mut self, pos: glam::DVec3, initial: String, height: f64) { + pub(super) fn handle_suspend_for_mtext_input( + &mut self, + pos: glam::DVec3, + initial: String, + height: f64, + ) { let i = self.active_tab; self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); self.tabs[i].snap_result = None; @@ -185,7 +190,11 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); } - pub(super) fn handle_ddedit_entity(&mut self, handle: Handle, new_text: String) -> Option> { + pub(super) fn handle_ddedit_entity( + &mut self, + handle: Handle, + new_text: String, + ) -> Option> { let i = self.active_tab; if self.reject_locked_edit(i, handle) { self.tabs[i].active_cmd = None; diff --git a/src/app/command_driver/transform.rs b/src/app/command_driver/transform.rs index c31d015fd..2a158a622 100644 --- a/src/app/command_driver/transform.rs +++ b/src/app/command_driver/transform.rs @@ -1,7 +1,11 @@ use super::*; impl OpenCADStudio { - pub(super) fn handle_transform_selected(&mut self, mut handles: Vec, transform: crate::command::EntityTransform) -> Option> { + pub(super) fn handle_transform_selected( + &mut self, + mut handles: Vec, + transform: crate::command::EntityTransform, + ) -> Option> { let i = self.active_tab; handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); if handles.is_empty() { @@ -34,7 +38,11 @@ impl OpenCADStudio { None } - pub(super) fn handle_copy_selected(&mut self, mut handles: Vec, transform: crate::command::EntityTransform) -> Option> { + pub(super) fn handle_copy_selected( + &mut self, + mut handles: Vec, + transform: crate::command::EntityTransform, + ) -> Option> { let i = self.active_tab; handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); if handles.is_empty() { @@ -88,10 +96,9 @@ impl OpenCADStudio { // it's delta-safe like a single transform. let pending = self.begin_undo(i, label, handles.len(), true); // Step 1: translate so src1 is at origin - self.tabs[i].scene.transform_entities( - &handles, - &crate::command::EntityTransform::Translate(-src1), - ); + self.tabs[i] + .scene + .transform_entities(&handles, &crate::command::EntityTransform::Translate(-src1)); // Step 2: uniform scale (only when != 1) if (scale - 1.0).abs() > 1e-4 { self.tabs[i].scene.transform_entities( @@ -114,10 +121,9 @@ impl OpenCADStudio { ); } // Step 4: translate to dst1 - self.tabs[i].scene.transform_entities( - &handles, - &crate::command::EntityTransform::Translate(dst1), - ); + self.tabs[i] + .scene + .transform_entities(&handles, &crate::command::EntityTransform::Translate(dst1)); self.tabs[i].dirty = true; self.tabs[i].scene.deselect_all(); for h in &handles { @@ -136,7 +142,11 @@ impl OpenCADStudio { } } - pub(super) fn handle_stretch_window(&mut self, mut handles: Vec, windows: Vec<(glam::DVec3, glam::DVec3)>) { + pub(super) fn handle_stretch_window( + &mut self, + mut handles: Vec, + windows: Vec<(glam::DVec3, glam::DVec3)>, + ) { let i = self.active_tab; // Accumulate every entity touched by any crossing window. Keep STRETCH // in its selection stage; Enter is what advances to the base point. @@ -249,7 +259,12 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } - pub(super) fn handle_stretch_entities(&mut self, mut handles: Vec, windows: Vec<(glam::DVec3, glam::DVec3)>, delta: glam::DVec3) -> Option> { + pub(super) fn handle_stretch_entities( + &mut self, + mut handles: Vec, + windows: Vec<(glam::DVec3, glam::DVec3)>, + delta: glam::DVec3, + ) -> Option> { let i = self.active_tab; handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); if handles.is_empty() { @@ -296,15 +311,10 @@ impl OpenCADStudio { } } codec::EntityType::LwPolyline(polyline) => { - if let Some(world) = - crate::entities::curve::lwpolyline_world_xy(polyline) - { + if let Some(world) = crate::entities::curve::lwpolyline_world_xy(polyline) { for (index, vertex) in world.vertices.iter().enumerate() { if in_win(vertex.location.x, vertex.location.y) { - driven_refs.push(ParametricRef::point( - *handle, - index as i32, - )); + driven_refs.push(ParametricRef::point(*handle, index as i32)); } } } @@ -312,10 +322,7 @@ impl OpenCADStudio { codec::EntityType::Polyline2D(polyline) => { for (index, vertex) in polyline.vertices.iter().enumerate() { if in_win(vertex.location.x, vertex.location.y) { - driven_refs.push(ParametricRef::point( - *handle, - index as i32, - )); + driven_refs.push(ParametricRef::point(*handle, index as i32)); } } } @@ -324,9 +331,7 @@ impl OpenCADStudio { { driven_refs.push(ParametricRef::center(*handle)); } - codec::EntityType::Arc(arc) - if in_win(arc.center.x, arc.center.y) => - { + codec::EntityType::Arc(arc) if in_win(arc.center.x, arc.center.y) => { driven_refs.push(ParametricRef::center(*handle)); } _ => {} @@ -354,8 +359,7 @@ impl OpenCADStudio { } } codec::EntityType::LwPolyline(p) => { - let Some(mut world) = crate::entities::curve::lwpolyline_world_xy(p) - else { + let Some(mut world) = crate::entities::curve::lwpolyline_world_xy(p) else { continue; }; for v in &mut world.vertices { diff --git a/src/app/command_driver/utilities.rs b/src/app/command_driver/utilities.rs index 84ee63d47..a06695e54 100644 --- a/src/app/command_driver/utilities.rs +++ b/src/app/command_driver/utilities.rs @@ -43,16 +43,10 @@ impl OpenCADStudio { return Some(glam::DVec3::new(line.end.x, line.end.y, line.end.z)); } codec::EntityType::Arc(_) => Some(2), - codec::EntityType::LwPolyline(polyline) => { - polyline.vertices.len().checked_sub(1) - } + codec::EntityType::LwPolyline(polyline) => polyline.vertices.len().checked_sub(1), codec::EntityType::Polyline(polyline) => polyline.vertices.len().checked_sub(1), - codec::EntityType::Polyline2D(polyline) => { - polyline.vertices.len().checked_sub(1) - } - codec::EntityType::Polyline3D(polyline) => { - polyline.vertices.len().checked_sub(1) - } + codec::EntityType::Polyline2D(polyline) => polyline.vertices.len().checked_sub(1), + codec::EntityType::Polyline3D(polyline) => polyline.vertices.len().checked_sub(1), _ => None, }?; crate::scene::view::dispatch::grips(entity) @@ -127,10 +121,16 @@ impl OpenCADStudio { ) { let touched: Vec<_> = grip.targets.iter().map(|target| target.handle).collect(); let connected = self.tabs[i].scene.parametric_connected_handles( - self.tabs[i].current_parametric_scope(), &touched, true, + self.tabs[i].current_parametric_scope(), + &touched, + true, ); for handle in connected { - if !self.grip_originals.iter().any(|(original, _)| *original == handle) { + if !self + .grip_originals + .iter() + .any(|(original, _)| *original == handle) + { if let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() { self.grip_originals.push((handle, entity)); } @@ -142,15 +142,29 @@ impl OpenCADStudio { } } } - let driven_refs: Vec<_> = grip.targets.iter().flat_map(|target| { - self.tabs[i].scene.document.get_entity(target.handle) - .map(|entity| crate::scene::parametric_constraints::grip_solve_anchor_refs( - entity, target.handle, target.grip_id, - )).unwrap_or_default() - }).collect(); + let driven_refs: Vec<_> = grip + .targets + .iter() + .flat_map(|target| { + self.tabs[i] + .scene + .document + .get_entity(target.handle) + .map(|entity| { + crate::scene::parametric_constraints::grip_solve_anchor_refs( + entity, + target.handle, + target.grip_id, + ) + }) + .unwrap_or_default() + }) + .collect(); let retain_size = self.constraint_solve_mode && !driven_refs.is_empty() - && driven_refs.iter().all(|reference| reference.marker.is_some()); + && driven_refs + .iter() + .all(|reference| reference.marker.is_some()); let solved = self.tabs[i].scene.solve_parametric_constraints_preview( &touched, &driven_refs, @@ -267,7 +281,9 @@ impl OpenCADStudio { .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities_after_parametric_solve(&changes); + self.tabs[i] + .scene + .bump_entities_after_parametric_solve(&changes); if let Some(dirty_before) = self.grip_dirty_before.take() { self.tabs[i].dirty = dirty_before; } @@ -535,7 +551,10 @@ impl OpenCADStudio { /// Only a `Text` token can come back unclaimed (`on_text_input` returning /// `None`), which is what lets the caller read it as something else — a /// typed distance, say — instead of guessing beforehand. - pub(in crate::app) fn feed_command_consumed(&mut self, input: StepInput) -> (Task, bool) { + pub(in crate::app) fn feed_command_consumed( + &mut self, + input: StepInput, + ) -> (Task, bool) { // A command that takes associative dimensions only keeps just those: // the rest leave the selection and the count of what went is reported, // as the reference does while gathering. @@ -769,7 +788,11 @@ impl OpenCADStudio { /// false: the line is one instalment and the tool stays at its next prompt /// for the following one. Only a plugin streaming a command feeds it that /// way; the command line and the automation feeder always finish. - pub(in crate::app) fn run_command_line_streaming(&mut self, cmd: &str, finish: bool) -> Task { + pub(in crate::app) fn run_command_line_streaming( + &mut self, + cmd: &str, + finish: bool, + ) -> Task { let i = self.active_tab; self.command_line.unconsumed.clear(); let tokens: Vec<&str> = cmd.split_whitespace().collect(); @@ -828,7 +851,11 @@ impl OpenCADStudio { /// silently dropped and `TEXT 0,0 5 0 hi` created nothing at all. /// * Anything still left over was never claimed by any prompt; it is /// recorded in [`CommandLine::unconsumed`] instead of vanishing. - pub(in crate::app) fn finish_active_command(&mut self, tokens: &[String], finish: bool) -> Task { + pub(in crate::app) fn finish_active_command( + &mut self, + tokens: &[String], + finish: bool, + ) -> Task { let i = self.active_tab; if self.tabs[i].active_cmd.is_none() { return Task::none(); diff --git a/src/app/command_driver/viewport.rs b/src/app/command_driver/viewport.rs index 8218d1a37..455960fd9 100644 --- a/src/app/command_driver/viewport.rs +++ b/src/app/command_driver/viewport.rs @@ -1,7 +1,11 @@ use super::*; impl OpenCADStudio { - pub(super) fn handle_mview_create(&mut self, viewport: codec::entities::Viewport, preserve_view: bool) { + pub(super) fn handle_mview_create( + &mut self, + viewport: codec::entities::Viewport, + preserve_view: bool, + ) { let i = self.active_tab; let saved_view = preserve_view.then(|| { ( @@ -58,21 +62,25 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; - let Some(codec::EntityType::Viewport(vp)) = self.tabs[i].scene.document.get_entity(viewport).cloned() + let Some(codec::EntityType::Viewport(vp)) = + self.tabs[i].scene.document.get_entity(viewport).cloned() else { return; }; if boundary.is_none() && !boundary_handle.is_null() { let scene = &self.tabs[i].scene; let closed = scene.entity_belongs_to_current_layout(boundary_handle) - && scene.document.get_entity(boundary_handle).is_some_and(|entity| match entity { - codec::EntityType::Circle(_) => true, - codec::EntityType::Ellipse(ellipse) => ellipse.is_full(), - codec::EntityType::LwPolyline(polyline) => polyline.is_closed, - codec::EntityType::Polyline(polyline) => polyline.is_closed(), - codec::EntityType::Polyline2D(polyline) => polyline.is_closed(), - _ => false, - }); + && scene + .document + .get_entity(boundary_handle) + .is_some_and(|entity| match entity { + codec::EntityType::Circle(_) => true, + codec::EntityType::Ellipse(ellipse) => ellipse.is_full(), + codec::EntityType::LwPolyline(polyline) => polyline.is_closed, + codec::EntityType::Polyline(polyline) => polyline.is_closed(), + codec::EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => false, + }); if !closed { self.command_line .push_error(crate::t!("Object is not a closed curve.").as_ref()); @@ -100,13 +108,18 @@ impl OpenCADStudio { if created { scene.erase_entities(&[clip]); } - self.command_line - .push_error(crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref()); + self.command_line.push_error( + crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref(), + ); return; } // Model units per paper unit; the view centre follows the paper // centre so the model does not move on the sheet. - let scale = if vp.height.abs() > 1e-12 { vp.view_height / vp.height } else { 1.0 }; + let scale = if vp.height.abs() > 1e-12 { + vp.view_height / vp.height + } else { + 1.0 + }; let center = (lo + hi) * 0.5; let (dx, dy) = (center.x - vp.center.x, center.y - vp.center.y); let (s, c) = (-vp.twist_angle).sin_cos(); @@ -492,7 +505,10 @@ impl OpenCADStudio { /// Paper-space extents of a viewport clip boundary: exact for lines, arcs /// and circles (bulges included), else from its outline. -fn clip_boundary_extents(scene: &crate::scene::Scene, clip: Handle) -> Option<([f64; 2], [f64; 2])> { +fn clip_boundary_extents( + scene: &crate::scene::Scene, + clip: Handle, +) -> Option<([f64; 2], [f64; 2])> { let exact = scene .document .get_entity(clip) diff --git a/src/app/commands/attdef.rs b/src/app/commands/attdef.rs index 3fa2deef5..6126765ce 100644 --- a/src/app/commands/attdef.rs +++ b/src/app/commands/attdef.rs @@ -28,9 +28,16 @@ impl OpenCADStudio { Some(Task::none()) } "-ATTDEF" => { - let defaults = - crate::scene::creation_style::current_text_defaults(&self.tabs[i].scene.document); - let styles: Vec<_> = self.tabs[i].scene.document.text_styles.iter().cloned().collect(); + let defaults = crate::scene::creation_style::current_text_defaults( + &self.tabs[i].scene.document, + ); + let styles: Vec<_> = self.tabs[i] + .scene + .document + .text_styles + .iter() + .cloned() + .collect(); let command = AttdefCommand::new( "-ATTDEF", defaults.style_name, @@ -97,7 +104,8 @@ impl OpenCADStudio { Some(v) => match v.parse::().ok().filter(|v| *v <= 63) { Some(v) => attdef::session().aflags = v, None => { - self.command_line.push_error("Requires an integer between 0 and 63."); + self.command_line + .push_error("Requires an integer between 0 and 63."); self.command_line .push_output(&format!("Enter new value for AFLAGS <{current}>:")); self.pending_setvar = Some("AFLAGS".to_string()); @@ -145,7 +153,8 @@ impl OpenCADStudio { /// The Edit Attribute Definition dialog for `handle`. pub(in crate::app) fn open_attdef_edit(&mut self, handle: codec::Handle) { let i = self.active_tab; - let Some(codec::EntityType::AttributeDefinition(a)) = self.tabs[i].scene.document.get_entity(handle) + let Some(codec::EntityType::AttributeDefinition(a)) = + self.tabs[i].scene.document.get_entity(handle) else { return; }; @@ -175,11 +184,15 @@ impl OpenCADStudio { } AttdefDialogMsg::EditInsertField => { self.active_modal = None; - self.open_field_dialog(crate::ui::window::field_dialog::FieldTarget::AttdefEdit); + self.open_field_dialog( + crate::ui::window::field_dialog::FieldTarget::AttdefEdit, + ); } AttdefDialogMsg::Help => self.command_line.push_info( - crate::t!("Changes the tag, prompt and default value of an attribute definition.") - .as_ref(), + crate::t!( + "Changes the tag, prompt and default value of an attribute definition." + ) + .as_ref(), ), AttdefDialogMsg::EditOk => return self.attdef_edit_ok(i), AttdefDialogMsg::DismissError => edit.error = None, diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 6089998e5..b89871f4e 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -38,8 +38,10 @@ impl OpenCADStudio { base: glam::DVec3, ) -> usize { // Clone + dep-capture live in the shared kernel; storage stays here. - let (entities, deps) = - super::super::command_driver::copy_to_clipboard_kernel(&self.tabs[i].scene.document, handles); + let (entities, deps) = super::super::command_driver::copy_to_clipboard_kernel( + &self.tabs[i].scene.document, + handles, + ); let count = entities.len(); self.clipboard_base = base; self.clipboard = entities; @@ -492,7 +494,8 @@ impl OpenCADStudio { // INSERT opens the Blocks palette on the drawing's blocks; // -INSERT runs on the command line. "INSERT" => { - self.command_line.push_output("*Insert a block from the Blocks palette"); + self.command_line + .push_output("*Insert a block from the Blocks palette"); self.open_blocks_palette(Some(crate::ui::window::block_palette::Tab::Current)); } @@ -519,7 +522,8 @@ impl OpenCADStudio { cmd if cmd.starts_with("_-INSERTFILE ") => { use crate::command::CadCommand; use crate::modules::insert::insert_block::InsertBlockCommand; - let path = std::path::PathBuf::from(cmd["_-INSERTFILE ".len()..].trim().trim_matches('"')); + let path = + std::path::PathBuf::from(cmd["_-INSERTFILE ".len()..].trim().trim_matches('"')); match self.import_file_as_block(path) { Ok(name) => { let units = self @@ -645,11 +649,10 @@ impl OpenCADStudio { .iter() .any(|a| a.tag.eq_ignore_ascii_case(tag)) { - ins.attributes - .push(codec::entities::AttributeEntity::new( - tag.clone(), - default.clone(), - )); + ins.attributes.push(codec::entities::AttributeEntity::new( + tag.clone(), + default.clone(), + )); } } synced += 1; @@ -702,7 +705,12 @@ impl OpenCADStudio { // A block picked in the ribbon gallery. cmd if cmd.starts_with("_BLOCKINSERT ") => { let name = cmd["_BLOCKINSERT ".len()..].trim().to_string(); - return Some(self.palette_insert(crate::ui::window::block_palette::Item::Current(name), None)); + return Some( + self.palette_insert( + crate::ui::window::block_palette::Item::Current(name), + None, + ), + ); } cmd if cmd.starts_with("_BLOCKSPALETTE ") => { use crate::ui::window::block_palette::Tab; @@ -737,10 +745,16 @@ impl OpenCADStudio { } // A file named after it skips the picker. cmd if cmd.starts_with("_PDFIMPORTFILE ") => { - let path = cmd["_PDFIMPORTFILE ".len()..].trim().trim_matches('"').to_string(); + let path = cmd["_PDFIMPORTFILE ".len()..] + .trim() + .trim_matches('"') + .to_string(); match std::fs::read(&path) { Ok(bytes) => { - crate::scene::model::pdf_raster::register_source(&path, std::sync::Arc::new(bytes)); + crate::scene::model::pdf_raster::register_source( + &path, + std::sync::Arc::new(bytes), + ); self.open_pdf_import_file(&path); } Err(_) => self.command_line.push_error(&format!("{path} not found.")), @@ -760,8 +774,11 @@ impl OpenCADStudio { "POINTCLOUDCROP" | "POINTCLOUDUNCROP" => { use crate::command::CadCommand; use crate::modules::insert::pc_crop::PointCloudCropCommand; - let command = - if cmd == "POINTCLOUDUNCROP" { PointCloudCropCommand::uncrop() } else { PointCloudCropCommand::new() }; + let command = if cmd == "POINTCLOUDUNCROP" { + PointCloudCropCommand::uncrop() + } else { + PointCloudCropCommand::new() + }; self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } @@ -810,7 +827,10 @@ impl OpenCADStudio { // PDFATTACH picks the file; -PDFATTACH asks for it on the command // line. A path after either names the file at once. cmd if matches!( - cmd.split_whitespace().next().map(|v| v.to_ascii_uppercase()).as_deref(), + cmd.split_whitespace() + .next() + .map(|v| v.to_ascii_uppercase()) + .as_deref(), Some("PDFATTACH") | Some("-PDFATTACH") ) => { @@ -859,14 +879,12 @@ impl OpenCADStudio { } else { format!("{}/new_block.dwg", default_folder.replace('\\', "/")) }; - self.wblock = Some( - crate::ui::window::wblock::WblockState::new( - existing_blocks, - handles, - default_unit, - default_path, - ), - ); + self.wblock = Some(crate::ui::window::wblock::WblockState::new( + existing_blocks, + handles, + default_unit, + default_path, + )); self.active_modal = Some(crate::app::ModalKind::WriteBlock); } @@ -1068,8 +1086,7 @@ impl OpenCADStudio { self.tabs[i].xref_unloaded.remove(key); self.tabs[i].xref_stat_cache.remove(key); } - let handles: rustc_hash::FxHashSet = self.tabs - [i] + let handles: rustc_hash::FxHashSet = self.tabs[i] .scene .document .block_records @@ -1917,12 +1934,8 @@ mod tests { .objects .insert(h, ObjectType::ImageDefinition(def)); // Reference it so collect_entries lists it. - let mut img = codec::entities::RasterImage::new( - "img.png", - codec::types::Vector3::ZERO, - 8.0, - 8.0, - ); + let mut img = + codec::entities::RasterImage::new("img.png", codec::types::Vector3::ZERO, 8.0, 8.0); img.definition_handle = Some(h); app.tabs[i] .scene @@ -1952,12 +1965,8 @@ mod tests { .document .objects .insert(h, ObjectType::ImageDefinition(def)); - let mut img = codec::entities::RasterImage::new( - "img.png", - codec::types::Vector3::ZERO, - 8.0, - 8.0, - ); + let mut img = + codec::entities::RasterImage::new("img.png", codec::types::Vector3::ZERO, 8.0, 8.0); img.definition_handle = Some(h); app.tabs[i] .scene @@ -2058,9 +2067,7 @@ mod tests { .unwrap(); let mut line = codec::entities::Line::new(); line.common.layer = "WALLS".to_string(); - xref_doc - .add_entity(codec::EntityType::Line(line)) - .unwrap(); + xref_doc.add_entity(codec::EntityType::Line(line)).unwrap(); let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap(); let xref_path = dir.join("plan.dwg"); std::fs::write(&xref_path, &bytes).unwrap(); @@ -2093,9 +2100,7 @@ mod tests { let mut line = codec::entities::Line::new(); line.common.plotstyle_flags = 0b11; line.common.plotstyle_handle = Some(xref_doc.allocate_handle()); - xref_doc - .add_entity(codec::EntityType::Line(line)) - .unwrap(); + xref_doc.add_entity(codec::EntityType::Line(line)).unwrap(); let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap(); let xref_path = dir.join("plan.dwg"); std::fs::write(&xref_path, &bytes).unwrap(); @@ -2336,9 +2341,7 @@ mod tests { .unwrap(); let mut line = codec::entities::Line::new(); line.common.layer = "WALLS".to_string(); - xref_doc - .add_entity(codec::EntityType::Line(line)) - .unwrap(); + xref_doc.add_entity(codec::EntityType::Line(line)).unwrap(); let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap(); let xref_path = dir.join("plan.dwg"); std::fs::write(&xref_path, &bytes).unwrap(); diff --git a/src/app/commands/blockvars.rs b/src/app/commands/blockvars.rs index fae4f4eae..8a19cde58 100644 --- a/src/app/commands/blockvars.rs +++ b/src/app/commands/blockvars.rs @@ -42,7 +42,8 @@ impl OpenCADStudio { match name.as_str() { "BLOCKSTATE" => { let state = self.tabs[i].refedit_session.is_some() as u8; - self.command_line.push_output(&format!("BLOCKSTATE = {state} (read only)")); + self.command_line + .push_output(&format!("BLOCKSTATE = {state} (read only)")); } "INSNAME" | "BLOCKNAVIGATE" => { let current = if name == "INSNAME" { @@ -52,7 +53,11 @@ impl OpenCADStudio { }; match value { Some(v) => { - let v = if v == "." { String::new() } else { v.trim_matches('"').to_string() }; + let v = if v == "." { + String::new() + } else { + v.trim_matches('"').to_string() + }; if name == "INSNAME" { self.insname = v; } else { diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs index 548b1a67d..88674128c 100644 --- a/src/app/commands/count.rs +++ b/src/app/commands/count.rs @@ -6,14 +6,21 @@ use crate::app::{Message, OpenCADStudio}; use crate::command::CadCommand; use crate::modules::annotate::count_cmd::{CountCommand, CountTableCommand, UpdateFieldCommand}; use crate::modules::annotate::field_cmd::FieldPlaceCommand; -use crate::ui::window::count_palette::{aci_rgba, CountMode, CountMsg, CountTarget, RowAction, AREA_LAYER}; +use crate::ui::window::count_palette::{ + aci_rgba, CountMode, CountMsg, CountTarget, RowAction, AREA_LAYER, +}; use codec::count::{area_json, block_json, single_json, CountKey}; use codec::{EntityType, Handle}; use iced::Task; /// The variables this family answers for. -pub(super) const COUNT_SYSVARS: &[&str] = - &["COUNTNUMBER", "COUNTSERVICE", "COUNTPALETTESTATE", "COUNTCOLOR", "COUNTERRORCOLOR"]; +pub(super) const COUNT_SYSVARS: &[&str] = &[ + "COUNTNUMBER", + "COUNTSERVICE", + "COUNTPALETTESTATE", + "COUNTCOLOR", + "COUNTERRORCOLOR", +]; fn hex(h: Handle) -> String { format!("{:X}", h.value()) @@ -56,18 +63,27 @@ impl OpenCADStudio { match verb.as_str() { "COUNT" => { let preselected = self.tabs[i].scene.selected_handles_in_order(); - let current = matches!(rest.trim_start_matches('_').to_ascii_uppercase().as_str(), "C"); + let current = matches!( + rest.trim_start_matches('_').to_ascii_uppercase().as_str(), + "C" + ); if current && !preselected.is_empty() { // Count Selection: the selection is the target. let found = preselected.len(); self.command_line.push_output(&crate::tf!("{found} found")); let handles: Vec = preselected.iter().map(|h| hex(*h)).collect(); self.tabs[i].scene.deselect_all(); - return Some(self.dispatch_command(&format!("_COUNTRUN C T {}", handles.join(",")))); + return Some( + self.dispatch_command(&format!("_COUNTRUN C T {}", handles.join(","))), + ); } // Plain COUNT asks for its targets itself. self.tabs[i].scene.deselect_all(); - let command = if current { CountCommand::with_area("C") } else { CountCommand::new(false) }; + let command = if current { + CountCommand::with_area("C") + } else { + CountCommand::new(false) + }; Some(self.start_count_command(i, Box::new(command))) } "COUNTAREA" => Some(self.start_count_command(i, Box::new(CountCommand::new(true)))), @@ -92,9 +108,9 @@ impl OpenCADStudio { match self.count_field_code(i) { Some(code) => return Some(self.start_count_field(i, code)), None if self.tabs[i].count.is_some() => {} - None => self - .command_line - .push_error(&crate::t!("** COUNTFIELD command only available during Count. **")), + None => self.command_line.push_error(&crate::t!( + "** COUNTFIELD command only available during Count. **" + )), } Some(Task::none()) } @@ -150,7 +166,8 @@ impl OpenCADStudio { mode.cursor = None; self.tabs[i].count = Some(mode); self.apply_count_display(i); - self.command_line.push_output(&crate::t!("count area is active.")); + self.command_line + .push_output(&crate::t!("count area is active.")); } Some(Task::none()) } @@ -158,7 +175,11 @@ impl OpenCADStudio { let (point, names) = rest.split_once(' ').unwrap_or((rest, "")); let p: Vec = point.split(',').filter_map(|v| v.parse().ok()).collect(); if p.len() == 3 { - let names: Vec = names.split('|').filter(|n| !n.is_empty()).map(str::to_string).collect(); + let names: Vec = names + .split('|') + .filter(|n| !n.is_empty()) + .map(str::to_string) + .collect(); self.place_count_table(i, [p[0], p[1], p[2]], names); } Some(Task::none()) @@ -193,7 +214,8 @@ impl OpenCADStudio { _ => None, }; if let Some(v) = read_only { - self.command_line.push_output(&crate::tf!("{name} = {v} (read only)")); + self.command_line + .push_output(&crate::tf!("{name} = {v} (read only)")); return; } let (current, min, max) = match name { @@ -202,7 +224,8 @@ impl OpenCADStudio { _ => (self.count_palette.error_color as i32, 1, 255), }; let ask = |app: &mut Self| { - app.command_line.push_output(&crate::tf!("Enter new value for {name} <{current}>:")); + app.command_line + .push_output(&crate::tf!("Enter new value for {name} <{current}>:")); app.pending_setvar = Some(name.to_string()); }; let Some(value) = value else { @@ -245,19 +268,26 @@ impl OpenCADStudio { /// Recolour the drawing for count mode: counted references, duplicates, /// the rest faded (nothing changes without a target). pub(in crate::app) fn apply_count_display(&mut self, i: usize) { - let computed = self.tabs[i].count.as_ref().map(|m| m.compute(&self.tabs[i].scene.document)); + let computed = self.tabs[i] + .count + .as_ref() + .map(|m| m.compute(&self.tabs[i].scene.document)); if let (Some(mode), Some(c)) = (self.tabs[i].count.as_mut(), computed) { mode.cache = Some(c); } - let display = self.tabs[i].count.as_ref().filter(|m| m.target.is_some()).map(|m| { - let r = m.result(&self.tabs[i].scene.document); - crate::scene::CountDisplay { - counted: r.counted.iter().copied().collect(), - errors: r.errors.iter().chain(&r.overlapped).copied().collect(), - color: aci_rgba(self.count_palette.color), - error_color: aci_rgba(self.count_palette.error_color), - } - }); + let display = self.tabs[i] + .count + .as_ref() + .filter(|m| m.target.is_some()) + .map(|m| { + let r = m.result(&self.tabs[i].scene.document); + crate::scene::CountDisplay { + counted: r.counted.iter().copied().collect(), + errors: r.errors.iter().chain(&r.overlapped).copied().collect(), + color: aci_rgba(self.count_palette.color), + error_color: aci_rgba(self.count_palette.error_color), + } + }); self.tabs[i].scene.set_count_display(display); let epoch = self.tabs[i].scene.geometry_epoch; if let Some(mode) = self.tabs[i].count.as_mut() { @@ -268,7 +298,11 @@ impl OpenCADStudio { /// After the drawing changes, count mode colours its references again. pub(in crate::app) fn refresh_count_if_stale(&mut self) { let i = self.active_tab; - let Some(mode) = self.tabs[i].count.as_ref().filter(|m| m.epoch != self.tabs[i].scene.geometry_epoch) else { + let Some(mode) = self.tabs[i] + .count + .as_ref() + .filter(|m| m.epoch != self.tabs[i].scene.geometry_epoch) + else { return; }; if let Some((h, _)) = mode.boundary { @@ -291,7 +325,11 @@ impl OpenCADStudio { } // An edited boundary moves the area with it. Some(EntityType::LwPolyline(pl)) => { - let ring: Vec<[f64; 2]> = pl.vertices.iter().map(|v| [v.location.x, v.location.y]).collect(); + let ring: Vec<[f64; 2]> = pl + .vertices + .iter() + .map(|v| [v.location.x, v.location.y]) + .collect(); if let Some(mode) = self.tabs[i].count.as_mut() { mode.area = Some(ring); } @@ -385,12 +423,20 @@ impl OpenCADStudio { tokens: &[&str], ) -> Option<(Option>, Option<(Handle, bool)>)> { let kind = tokens.first().copied().unwrap_or("E"); - let points = || tokens[1..].iter().map_while(|t| parse_xy(t)).collect::>(); + let points = || { + tokens[1..] + .iter() + .map_while(|t| parse_xy(t)) + .collect::>() + }; match kind { "E" => Some((None, None)), "K" => { let mode = self.tabs[i].count.as_ref(); - Some((mode.and_then(|m| m.area.clone()), mode.and_then(|m| m.boundary))) + Some(( + mode.and_then(|m| m.area.clone()), + mode.and_then(|m| m.boundary), + )) } // Current area counts all of model space (the view and an area // in use take no part). @@ -447,7 +493,10 @@ impl OpenCADStudio { /// counts the targets and enters count mode. fn count_run(&mut self, i: usize, rest: &str) { let tokens: Vec<&str> = rest.split_whitespace().collect(); - let split = tokens.iter().position(|t| *t == "L" || *t == "T").unwrap_or(tokens.len()); + let split = tokens + .iter() + .position(|t| *t == "L" || *t == "T") + .unwrap_or(tokens.len()); let (area_tokens, tail) = tokens.split_at(split); let targets: Vec = match tail { ["T", handles, ..] => handles @@ -476,9 +525,21 @@ impl OpenCADStudio { }) .collect(); let target = match blocks.first() { - Some(first) if blocks.len() == targets.len() && blocks.iter().all(|b| b.eq_ignore_ascii_case(first)) => { - let name = doc.block_records.get(first).map(|b| b.name.clone()).unwrap_or_else(|| first.to_string()); - Some(CountTarget::Block { name, reference: targets.first().copied(), matching: [false; 3], picked: true }) + Some(first) + if blocks.len() == targets.len() + && blocks.iter().all(|b| b.eq_ignore_ascii_case(first)) => + { + let name = doc + .block_records + .get(first) + .map(|b| b.name.clone()) + .unwrap_or_else(|| first.to_string()); + Some(CountTarget::Block { + name, + reference: targets.first().copied(), + matching: [false; 3], + picked: true, + }) } None if targets.is_empty() => None, _ => Some(CountTarget::Group(targets.clone())), @@ -489,10 +550,16 @@ impl OpenCADStudio { if let Some(old) = self.tabs[i].count.take().filter(|o| o.boundary != boundary) { self.drop_count_boundary(i, old.boundary); } - let mode = CountMode { area, boundary, target, ..CountMode::default() }; + let mode = CountMode { + area, + boundary, + target, + ..CountMode::default() + }; if let Some(name) = mode.target_name() { let n = mode.count(&self.tabs[i].scene.document); - self.command_line.push_output(&crate::tf!("{name} ...... {n}")); + self.command_line + .push_output(&crate::tf!("{name} ...... {n}")); } self.tabs[i].count = Some(mode); self.apply_count_display(i); @@ -505,11 +572,12 @@ impl OpenCADStudio { let mode = self.tabs[i].count.as_ref()?; match mode.target.as_ref()? { CountTarget::Group(handles) => Some(format!("\\AcCount {}", single_json(handles))), - CountTarget::Block { reference: Some(r), matching: [false, false, false], picked: true, .. } - if mode.boundary.is_none() => - { - Some(format!("\\AcCount {}", single_json(&[*r]))) - } + CountTarget::Block { + reference: Some(r), + matching: [false, false, false], + picked: true, + .. + } if mode.boundary.is_none() => Some(format!("\\AcCount {}", single_json(&[*r]))), CountTarget::Block { name, .. } => { let instances = mode.instances(&self.tabs[i].scene.document); Some(self.block_field_code(i, name, &mode.key(&instances))) @@ -528,13 +596,21 @@ impl OpenCADStudio { /// Place a count field as multi-line text (`Specify start point or [Height/Justify]:`). pub(in crate::app) fn start_count_field(&mut self, i: usize, code: String) -> Task { let doc = &self.tabs[i].scene.document; - let value = crate::entities::field::evaluate(doc, &code, &[], None).unwrap_or_else(|| "####".into()); + let value = crate::entities::field::evaluate(doc, &code, &[], None) + .unwrap_or_else(|| "####".into()); let defaults = crate::scene::creation_style::current_text_defaults(doc); self.command_line.push_output(&crate::tf!( "MTEXT Current text style: \"{}\" Text height: {:.4}", - defaults.style_name, defaults.height + defaults.style_name, + defaults.height )); - let command = FieldPlaceCommand::new(value, (code, Vec::new()), defaults.style_name, defaults.height).named("MTEXT"); + let command = FieldPlaceCommand::new( + value, + (code, Vec::new()), + defaults.style_name, + defaults.height, + ) + .named("MTEXT"); self.start_count_command(i, Box::new(command)) } @@ -560,7 +636,11 @@ impl OpenCADStudio { Some(EntityType::Table(t)) => t .block_record_handle .and_then(|r| doc.block_records.iter().find(|b| b.handle == r)) - .or_else(|| doc.block_records.get(&t.block_name).filter(|_| !t.block_name.is_empty())), + .or_else(|| { + doc.block_records + .get(&t.block_name) + .filter(|_| !t.block_name.is_empty()) + }), _ => None, }; if let Some(record) = record { @@ -570,7 +650,9 @@ impl OpenCADStudio { let attributes: Vec<&codec::entities::EntityCommon> = handles .iter() .filter_map(|h| match doc.get_entity(*h) { - Some(EntityType::Insert(insert)) => Some(insert.attributes.iter().map(|a| &a.common)), + Some(EntityType::Insert(insert)) => { + Some(insert.attributes.iter().map(|a| &a.common)) + } _ => None, }) .flatten() @@ -582,8 +664,10 @@ impl OpenCADStudio { }), }); if !hosting { - self.command_line.push_output(&crate::tf!("{found} field(s) found.", found = 0)); - self.command_line.push_output(&crate::tf!("{updated} field(s) updated.", updated = 0)); + self.command_line + .push_output(&crate::tf!("{found} field(s) found.", found = 0)); + self.command_line + .push_output(&crate::tf!("{updated} field(s) updated.", updated = 0)); return; } // The reference counts fields, not the texts holding them, and @@ -592,8 +676,10 @@ impl OpenCADStudio { if changed > 0 { self.tabs[i].dirty = true; } - self.command_line.push_output(&crate::tf!("{found} field(s) found.")); - self.command_line.push_output(&crate::tf!("{updated} field(s) updated.", updated = found)); + self.command_line + .push_output(&crate::tf!("{found} field(s) found.")); + self.command_line + .push_output(&crate::tf!("{updated} field(s) updated.", updated = found)); } /// A count table at `point`: `Item` | `Count`, one row per block (all @@ -604,11 +690,19 @@ impl OpenCADStudio { let area = self.tabs[i].count.as_ref().and_then(|m| m.area.clone()); let instances = codec::count::block_instances(doc, area.as_deref()); let mut names: Vec = if names.is_empty() { - codec::count::block_counts(&instances).into_iter().map(|c| c.name).collect() + codec::count::block_counts(&instances) + .into_iter() + .map(|c| c.name) + .collect() } else { names .iter() - .map(|n| doc.block_records.get(n).map(|b| b.name.clone()).unwrap_or_else(|| n.clone())) + .map(|n| { + doc.block_records + .get(n) + .map(|b| b.name.clone()) + .unwrap_or_else(|| n.clone()) + }) .collect() }; names.sort_by_key(|n| n.to_ascii_uppercase()); @@ -656,8 +750,10 @@ impl OpenCADStudio { } } } - let codes: Vec = - names.iter().map(|n| self.block_field_code(i, n, &CountKey::default())).collect(); + let codes: Vec = names + .iter() + .map(|n| self.block_field_code(i, n, &CountKey::default())) + .collect(); self.push_undo_snapshot(i, "COUNTTABLE"); let Some(handle) = self.commit_entity_handle(EntityType::Table(Box::new(table))) else { return; @@ -669,19 +765,29 @@ impl OpenCADStudio { .iter() .enumerate() .map(|(r, code)| { - let value = crate::entities::field::evaluate(doc, code, &[], None).unwrap_or_else(|| "####".into()); - (r + 1, 1, r"%<\_FldIdx 0>%".to_string(), vec![codec::fields::NewField::new(code.clone(), value)]) + let value = crate::entities::field::evaluate(doc, code, &[], None) + .unwrap_or_else(|| "####".into()); + ( + r + 1, + 1, + r"%<\_FldIdx 0>%".to_string(), + vec![codec::fields::NewField::new(code.clone(), value)], + ) }) .collect(); doc.build_table_block(handle, fields); - self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; } /// Zoom to a counted object (← / →, an error of the report). fn zoom_to_insert(&mut self, i: usize, h: Handle) { let doc = &self.tabs[i].scene.document; - let Some(entity) = doc.get_entity(h) else { return }; + let Some(entity) = doc.get_entity(h) else { + return; + }; let outline = codec::count::entity_outline(doc, entity); if outline.is_empty() { return; @@ -701,12 +807,20 @@ impl OpenCADStudio { let instances = mode.instances(&self.tabs[i].scene.document); let reference = instances .iter() - .find(|inst| inst.duplicate_of.is_none() && inst.name.eq_ignore_ascii_case(&name) && key.matches(inst)) + .find(|inst| { + inst.duplicate_of.is_none() + && inst.name.eq_ignore_ascii_case(&name) + && key.matches(inst) + }) .map(|inst| inst.handle); mode.target = Some(CountTarget::Block { name, reference, - matching: [key.layer.is_some(), key.scale.is_some(), key.mirror_state.is_some()], + matching: [ + key.layer.is_some(), + key.scale.is_some(), + key.mirror_state.is_some(), + ], picked: false, }); mode.cursor = None; @@ -718,9 +832,15 @@ impl OpenCADStudio { /// expression counts. pub(in crate::app) fn show_count_instances(&mut self, i: usize, json: &str) { match codec::count::parse_query(json) { - Some(codec::count::CountQuery::Block { name, key, boundary }) => { + Some(codec::count::CountQuery::Block { + name, + key, + boundary, + }) => { if let Some(b) = boundary { - if let Some(ring) = codec::count::boundary_polygon(&self.tabs[i].scene.document, b) { + if let Some(ring) = + codec::count::boundary_polygon(&self.tabs[i].scene.document, b) + { let mut mode = self.tabs[i].count.take().unwrap_or_default(); if mode.boundary.map(|(h, _)| h) != Some(b) { self.drop_count_boundary(i, mode.boundary); @@ -734,7 +854,11 @@ impl OpenCADStudio { } Some(codec::count::CountQuery::Single(handles)) if !handles.is_empty() => { let hexes: Vec = handles.iter().map(|h| hex(*h)).collect(); - let area = if self.tabs[i].count.is_some() { "K" } else { "E" }; + let area = if self.tabs[i].count.is_some() { + "K" + } else { + "E" + }; self.count_run(i, &format!("{area} T {}", hexes.join(","))); } _ => {} @@ -790,7 +914,10 @@ impl OpenCADStudio { CountMsg::TableAll(on) => { let names: Vec = if on { let area = self.tabs[i].count.as_ref().and_then(|m| m.area.clone()); - let instances = codec::count::block_instances(&self.tabs[i].scene.document, area.as_deref()); + let instances = codec::count::block_instances( + &self.tabs[i].scene.document, + area.as_deref(), + ); crate::ui::window::count_palette::rows(&instances, &self.count_palette) .into_iter() .map(|r| r.name.to_ascii_uppercase()) @@ -810,16 +937,26 @@ impl OpenCADStudio { } CountMsg::Back | CountMsg::Close => self.close_count(i), CountMsg::Match(k, on) => { - if let Some(CountMode { target: Some(CountTarget::Block { matching, .. }), .. }) = self.tabs[i].count.as_mut() { + if let Some(CountMode { + target: Some(CountTarget::Block { matching, .. }), + .. + }) = self.tabs[i].count.as_mut() + { matching[k] = on; } self.apply_count_display(i); } - CountMsg::ToggleDetails => self.count_palette.details_closed = !self.count_palette.details_closed, - CountMsg::ToggleErrors => self.count_palette.errors_closed = !self.count_palette.errors_closed, + CountMsg::ToggleDetails => { + self.count_palette.details_closed = !self.count_palette.details_closed + } + CountMsg::ToggleErrors => { + self.count_palette.errors_closed = !self.count_palette.errors_closed + } CountMsg::ShowError(h) => self.zoom_to_insert(i, h), CountMsg::Prev | CountMsg::Next => { - let Some(mode) = self.tabs[i].count.as_ref() else { return Task::none() }; + let Some(mode) = self.tabs[i].count.as_ref() else { + return Task::none(); + }; let result = mode.result(&self.tabs[i].scene.document); let n = result.counted.len(); if n == 0 { @@ -840,7 +977,11 @@ impl OpenCADStudio { CountMsg::Area => return self.dispatch_command("COUNTAREA"), CountMsg::Select => { self.tabs[i].scene.deselect_all(); - let command = CountCommand::with_area(if self.tabs[i].count.is_some() { "K" } else { "C" }); + let command = CountCommand::with_area(if self.tabs[i].count.is_some() { + "K" + } else { + "C" + }); return self.start_count_command(i, Box::new(command)); } CountMsg::Field => return self.dispatch_command("COUNTFIELD"), diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 8aa821cd5..734cbb357 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -30,7 +30,7 @@ fn solid_edge_sources( entity, codec::EntityType::Solid3D(_) | codec::EntityType::Surface(_) ) - .then_some(entity.common().handle) + .then_some(entity.common().handle) }) .collect::>(); app.tabs[tab].scene.restore_solid_models(&handles); @@ -199,8 +199,9 @@ impl OpenCADStudio { _ => None, }; if text_align.is_none() && mtext_ap.is_none() { - self.command_line - .push_error(crate::t!("JUSTIFYTEXT: unknown justification option.").as_ref()); + self.command_line.push_error( + crate::t!("JUSTIFYTEXT: unknown justification option.").as_ref(), + ); return Some(Task::none()); } self.push_undo_snapshot(i, "JUSTIFYTEXT"); @@ -358,10 +359,7 @@ impl OpenCADStudio { .selected_entities() .iter() .filter(|(_, e)| { - matches!( - e, - codec::EntityType::Text(_) | codec::EntityType::MText(_) - ) + matches!(e, codec::EntityType::Text(_) | codec::EntityType::MText(_)) }) .map(|(h, _)| *h) .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) @@ -421,8 +419,7 @@ impl OpenCADStudio { "TEXTFIT" => { use crate::command::SelectThenValueCommand; let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); - let c = - SelectThenValueCommand::new("TEXTFIT", "TEXTFIT target width:", has_sel); + let c = SelectThenValueCommand::new("TEXTFIT", "TEXTFIT target width:", has_sel); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -485,9 +482,9 @@ impl OpenCADStudio { .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); - self.command_line.push_output(crate::tf!( - "TEXTFIT: fitted {n} text object(s) to width {target}." - ).as_ref()); + self.command_line.push_output( + crate::tf!("TEXTFIT: fitted {n} text object(s) to width {target}.").as_ref(), + ); } // (text sequential numbering) @@ -505,20 +502,11 @@ impl OpenCADStudio { cmd if cmd.starts_with("TCOUNT ") => { let mut args = cmd.split_whitespace().skip(1); - let start: i64 = args - .next() - .and_then(|s| s.parse().ok()) - .unwrap_or(1); + let start: i64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1); - let increment: i64 = args - .next() - .and_then(|s| s.parse().ok()) - .unwrap_or(1); + let increment: i64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1); - let placement = args - .next() - .unwrap_or("O") - .to_uppercase(); + let placement = args.next().unwrap_or("O").to_uppercase(); let placement = match placement.as_str() { "O" | "OVERWRITE" => "O", @@ -538,9 +526,7 @@ impl OpenCADStudio { .selected_entities() .iter() .filter_map(|(h, e)| match e { - codec::EntityType::Text(t) - if !self.tabs[i].scene.is_layer_locked(*h) => - { + codec::EntityType::Text(t) if !self.tabs[i].scene.is_layer_locked(*h) => { Some((*h, t.insertion_point.x, t.insertion_point.y)) } _ => None, @@ -558,10 +544,7 @@ impl OpenCADStudio { texts.sort_by(|a, b| { b.2.partial_cmp(&a.2) .unwrap_or(std::cmp::Ordering::Equal) - .then( - a.1.partial_cmp(&b.1) - .unwrap_or(std::cmp::Ordering::Equal), - ) + .then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) }); self.push_undo_snapshot(i, "TCOUNT"); @@ -589,9 +572,7 @@ impl OpenCADStudio { let changes: Vec<_> = texts .iter() - .map(|(handle, _, _)| { - (*handle, crate::scene::ChangeKind::Modified) - }) + .map(|(handle, _, _)| (*handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); @@ -618,21 +599,22 @@ impl OpenCADStudio { .header .current_mleader_style_name .clone(); - let style = self.tabs[i] - .scene - .document - .objects - .iter() - .find_map(|(handle, object)| match object { - codec::objects::ObjectType::MultiLeaderStyle(style) - if style.name.eq_ignore_ascii_case(&name) => - { - let mut style = style.clone(); - style.handle = *handle; - Some(style) - } - _ => None, - }); + let style = + self.tabs[i] + .scene + .document + .objects + .iter() + .find_map(|(handle, object)| match object { + codec::objects::ObjectType::MultiLeaderStyle(style) + if style.name.eq_ignore_ascii_case(&name) => + { + let mut style = style.clone(); + style.handle = *handle; + Some(style) + } + _ => None, + }); let multiplier = self.tabs[i].scene.creation_annotation_multiplier(); let block_sources = self.tabs[i] .scene @@ -656,9 +638,11 @@ impl OpenCADStudio { .iter() .map(|style| (style.name.clone(), style.handle)) .collect(); - let new_cmd = style.map_or_else(MLeaderCommand::new, |style| { - MLeaderCommand::with_style(style, multiplier) - }).with_drawing_resources(block_sources, layers, text_styles); + let new_cmd = style + .map_or_else(MLeaderCommand::new, |style| { + MLeaderCommand::with_style(style, multiplier) + }) + .with_drawing_resources(block_sources, layers, text_styles); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -686,10 +670,7 @@ impl OpenCADStudio { .filter(|handle| scene.entity_belongs_to_active_space(*handle)) .and_then(|handle| scene.document.get_entity(handle)) .cloned(); - let cmd = DimContinueCommand::new( - recent, - self.dimension_continue_mode == 1, - ); + let cmd = DimContinueCommand::new(recent, self.dimension_continue_mode == 1); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } @@ -754,11 +735,8 @@ impl OpenCADStudio { surface_area: None, }) .collect(); - let cmd = QdimCommand::new( - selection, - dim_spacing, - self.quick_dimension_snap_priority, - ); + let cmd = + QdimCommand::new(selection, dim_spacing, self.quick_dimension_snap_priority); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } @@ -829,13 +807,15 @@ impl OpenCADStudio { "ZOOM EXTENTS ALL" | "ZOOM EXTENTS ALL VIEWPORTS" | "ZOOM EA" | "ZEA" => { self.tabs[i].scene.remember_current_view(); self.tabs[i].scene.fit_all_model_viewports(); - self.command_line.push_output(crate::t!("Zoom Extents — All Viewports").as_ref()); + self.command_line + .push_output(crate::t!("Zoom Extents — All Viewports").as_ref()); } "ZOOM EXTENTS" | "ZOOM E" | "ZOOMEXTENTS" | "ZE" => { self.tabs[i].scene.remember_current_view(); self.tabs[i].scene.fit_all(); - self.command_line.push_output(crate::t!("Zoom Extents").as_ref()); + self.command_line + .push_output(crate::t!("Zoom Extents").as_ref()); } "ZOOM IN" | "ZOOM I" | "ZI" => { @@ -847,19 +827,22 @@ impl OpenCADStudio { "ZOOM OUT" | "ZO" => { self.tabs[i].scene.remember_current_view(); self.tabs[i].scene.zoom_camera(1.5); - self.command_line.push_output(crate::t!("Zoom Out").as_ref()); + self.command_line + .push_output(crate::t!("Zoom Out").as_ref()); } // ZOOM ALL — fit the configured drawing limits. "ZOOM ALL" | "ZOOM A" | "ZA" => { self.tabs[i].scene.remember_current_view(); self.tabs[i].scene.fit_all_with_limits(); - self.command_line.push_output(crate::t!("Zoom All").as_ref()); + self.command_line + .push_output(crate::t!("Zoom All").as_ref()); } "ZOOM PREVIOUS" | "ZOOM P" | "ZP" => { if self.tabs[i].scene.restore_previous_view() { - self.command_line.push_output(crate::t!("Zoom Previous").as_ref()); + self.command_line + .push_output(crate::t!("Zoom Previous").as_ref()); } else { self.command_line .push_error(crate::t!("ZOOM: no previous view.").as_ref()); @@ -881,7 +864,8 @@ impl OpenCADStudio { } else { self.tabs[i].scene.remember_current_view(); if self.tabs[i].scene.zoom_to_entities(&handles) { - self.command_line.push_output(crate::t!("Zoom Object").as_ref()); + self.command_line + .push_output(crate::t!("Zoom Object").as_ref()); } else { self.command_line.push_error( crate::t!("ZOOM: selected objects have no visible bounds.").as_ref(), @@ -986,7 +970,11 @@ impl OpenCADStudio { }; (!handles.is_empty() && handles.iter().all(|h| { - self.tabs[i].scene.wire_models_for(&[*h]).iter().any(touches) + self.tabs[i] + .scene + .wire_models_for(&[*h]) + .iter() + .any(touches) })) .then(|| { ( @@ -1257,16 +1245,21 @@ impl OpenCADStudio { } else { // EXPLMODE 0 keeps references that are not uniformly and // positively scaled whole. - let selected: Vec<_> = if super::blockvars::explmode(&self.tabs[i].scene.document) == 0 { + let selected: Vec<_> = if super::blockvars::explmode( + &self.tabs[i].scene.document, + ) == 0 + { selected .into_iter() .filter(|(_, entity)| match entity { codec::EntityType::Insert(ins) => { let (x, y, z) = (ins.x_scale(), ins.y_scale(), ins.z_scale()); - let uniform = x > 0.0 && (x - y).abs() <= 1e-10 && (x - z).abs() <= 1e-10; + let uniform = + x > 0.0 && (x - y).abs() <= 1e-10 && (x - z).abs() <= 1e-10; if !uniform { - self.command_line - .push_output("The object is not uniformly and positively scaled."); + self.command_line.push_output( + "The object is not uniformly and positively scaled.", + ); } uniform } @@ -1288,8 +1281,9 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("{exploded} object(s) exploded.").as_ref()); } else { - self.command_line - .push_info(crate::t!("EXPLODE: no explodable objects selected.").as_ref()); + self.command_line.push_info( + crate::t!("EXPLODE: no explodable objects selected.").as_ref(), + ); } } } @@ -1350,8 +1344,7 @@ impl OpenCADStudio { }) .collect(); let all_entities: Vec<_> = entities.into_iter().map(|(_, e)| e).collect(); - let new_cmd = - TrimCommand::with_cutting_edges(all_entities, initial_edges); + let new_cmd = TrimCommand::with_cutting_edges(all_entities, initial_edges); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -1364,7 +1357,12 @@ impl OpenCADStudio { .iter() .filter_map(|w| { let h = Scene::handle_from_wire_name(&w.name)?; - self.tabs[i].scene.document.get_entity(h).cloned().map(|e| (h, e)) + self.tabs[i] + .scene + .document + .get_entity(h) + .cloned() + .map(|e| (h, e)) }) .collect(); let new_cmd = ExtrimCommand::new(all); @@ -1413,7 +1411,10 @@ impl OpenCADStudio { let text = cmd.strip_prefix("ARCTEXT").unwrap_or("").trim().to_string(); if text.is_empty() { self.command_line.push_info( - crate::t!("Usage: ARCTEXT (select an arc first; the text follows it)").as_ref(), + crate::t!( + "Usage: ARCTEXT (select an arc first; the text follows it)" + ) + .as_ref(), ); return None; } @@ -1433,7 +1434,8 @@ impl OpenCADStudio { }; let chars: Vec = text.chars().filter(|c| !c.is_control()).collect(); if chars.is_empty() { - self.command_line.push_error(crate::t!("ARCTEXT: no printable text.").as_ref()); + self.command_line + .push_error(crate::t!("ARCTEXT: no printable text.").as_ref()); return None; } let n = chars.len(); @@ -1456,8 +1458,9 @@ impl OpenCADStudio { self.tabs[i].scene.add_entity(codec::EntityType::Text(t)); } self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("ARCTEXT: placed {n} character(s) along the arc.").as_ref()); + self.command_line.push_output( + crate::tf!("ARCTEXT: placed {n} character(s) along the arc.").as_ref(), + ); } _ => return None, diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index b194a71f9..16c4cbd07 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -8,8 +8,9 @@ impl OpenCADStudio { "PAN" => { self.tabs[i].pan_mode = true; self.clear_navigation_hover(i); - self.command_line - .push_output(crate::t!("PAN: drag with the left mouse button. Press Esc to exit.").as_ref()); + self.command_line.push_output( + crate::t!("PAN: drag with the left mouse button. Press Esc to exit.").as_ref(), + ); } // ── TABLE structure and cell editing ─────────────────────────────── @@ -33,12 +34,17 @@ impl OpenCADStudio { } let words: Vec<&str> = rest.split_whitespace().collect(); let integer = |index: usize| { - words.get(index).and_then(|value| value.parse::().ok()) + words + .get(index) + .and_then(|value| value.parse::().ok()) }; - let real = |index: usize| { - words.get(index).and_then(|value| value.parse::().ok()) - }; - let action = match words.first().map(|word| word.to_ascii_uppercase()).as_deref() { + let real = + |index: usize| words.get(index).and_then(|value| value.parse::().ok()); + let action = match words + .first() + .map(|word| word.to_ascii_uppercase()) + .as_deref() + { Some("CELL") => { let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); match (integer(1), integer(2)) { @@ -117,11 +123,7 @@ impl OpenCADStudio { u64::from_str_radix(value.trim_start_matches("0x"), 16) .ok() .map(|handle| { - TableAction::Field( - row, - column, - codec::Handle::new(handle), - ) + TableAction::Field(row, column, codec::Handle::new(handle)) }) } _ => None, @@ -158,16 +160,19 @@ impl OpenCADStudio { None }; if matches!(action, TableAction::Block(_, _, _)) && block_handle.is_none() { - self.command_line.push_error( - crate::t!("TABLE: block definition not found.").as_ref(), - ); + self.command_line + .push_error(crate::t!("TABLE: block definition not found.").as_ref()); return Some(Task::none()); } if let TableAction::Field(_, _, field_handle) = &action { - if !self.tabs[i].scene.document.fields.contains_key(field_handle) { - self.command_line.push_error( - crate::t!("TABLE: field handle not found.").as_ref(), - ); + if !self.tabs[i] + .scene + .document + .fields + .contains_key(field_handle) + { + self.command_line + .push_error(crate::t!("TABLE: field handle not found.").as_ref()); return Some(Task::none()); } } @@ -337,10 +342,10 @@ impl OpenCADStudio { self.command_line .push_output(crate::t!("TABLE: edit applied.").as_ref()); } else { - self.command_line - .push_error(crate::t!( - "TABLE: row/column is out of range or the cell is locked." - ).as_ref()); + self.command_line.push_error( + crate::t!("TABLE: row/column is out of range or the cell is locked.") + .as_ref(), + ); } } @@ -388,9 +393,10 @@ impl OpenCADStudio { } } self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "UCSICON {sub}: updated {count} viewport(s) + model space." - ).as_ref()); + self.command_line.push_output( + crate::tf!("UCSICON {sub}: updated {count} viewport(s) + model space.") + .as_ref(), + ); } "" => { // Bare UCSICON toggles visibility. @@ -405,11 +411,13 @@ impl OpenCADStudio { } self.tabs[i].dirty = true; let state = if visible { "ON" } else { "OFF" }; - self.command_line.push_output(crate::tf!("UCSICON {state}").as_ref()); + self.command_line + .push_output(crate::tf!("UCSICON {state}").as_ref()); } _ => { - self.command_line - .push_info(crate::t!("Usage: UCSICON ON | OFF | NOORIGIN | ORIGIN").as_ref()); + self.command_line.push_info( + crate::t!("Usage: UCSICON ON | OFF | NOORIGIN | ORIGIN").as_ref(), + ); } } } @@ -437,40 +445,52 @@ impl OpenCADStudio { self.tabs[i].scene.set_drawing_limit_check(enabled); self.tabs[i].dirty = true; } - self.command_line.push_output(crate::t!(if enabled { - "Limits checking ON." - } else { - "Limits checking OFF." - }).as_ref()); + self.command_line.push_output( + crate::t!(if enabled { + "Limits checking ON." + } else { + "Limits checking OFF." + }) + .as_ref(), + ); } cmd if cmd.starts_with("LIMITS SET ") => { let tokens: Vec<&str> = cmd["LIMITS SET ".len()..].split_whitespace().collect(); let values: Result, _> = tokens.iter().map(|value| value.parse()).collect(); let Ok(values) = values else { - self.command_line - .push_error(crate::t!("LIMITS: four numeric coordinates required.").as_ref()); + self.command_line.push_error( + crate::t!("LIMITS: four numeric coordinates required.").as_ref(), + ); return Some(Task::none()); }; if tokens.len() != 4 || !values.iter().all(|value| value.is_finite()) { - self.command_line - .push_error(crate::t!("LIMITS: four finite numeric coordinates required.").as_ref()); + self.command_line.push_error( + crate::t!("LIMITS: four finite numeric coordinates required.").as_ref(), + ); } else { let first = glam::DVec2::new(values[0], values[1]); let opposite = glam::DVec2::new(values[2], values[3]); let min = first.min(opposite); let max = first.max(opposite); if min.x == max.x || min.y == max.y { - self.command_line - .push_error(crate::t!("LIMITS: corners must define a non-zero area.").as_ref()); + self.command_line.push_error( + crate::t!("LIMITS: corners must define a non-zero area.").as_ref(), + ); } else { self.push_undo_snapshot(i, "LIMITS"); self.tabs[i].scene.set_current_drawing_limits(min, max); self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "Drawing limits: {:.4},{:.4} to {:.4},{:.4}.", - min.x, min.y, max.x, max.y - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "Drawing limits: {:.4},{:.4} to {:.4},{:.4}.", + min.x, + min.y, + max.x, + max.y + ) + .as_ref(), + ); } } } @@ -499,15 +519,21 @@ impl OpenCADStudio { // it only queues a per-viewport cache invalidation. "REDRAW" => { use crate::scene::ViewportRefreshScope; - self.tabs[i].scene.request_refresh(ViewportRefreshScope::Active); - self.command_line.push_output(crate::t!("REDRAW: viewport refreshed.").as_ref()); + self.tabs[i] + .scene + .request_refresh(ViewportRefreshScope::Active); + self.command_line + .push_output(crate::t!("REDRAW: viewport refreshed.").as_ref()); return Some(Task::none()); } // REDRAWALL — force re-rasterize of every generated viewport. "REDRAWALL" => { use crate::scene::ViewportRefreshScope; - self.tabs[i].scene.request_refresh(ViewportRefreshScope::All); - self.command_line.push_output(crate::t!("REDRAWALL: viewports refreshed.").as_ref()); + self.tabs[i] + .scene + .request_refresh(ViewportRefreshScope::All); + self.command_line + .push_output(crate::t!("REDRAWALL: viewports refreshed.").as_ref()); return Some(Task::none()); } // Rebuild tessellation caches without modifying the document. @@ -515,7 +541,8 @@ impl OpenCADStudio { // A regen is a field evaluation event (FIELDEVAL bit 16). self.tabs[i].scene.update_fields(16, None); self.tabs[i].scene.populate_meshes_from_document(); - self.command_line.push_output(crate::t!("REGEN: regenerated model.").as_ref()); + self.command_line + .push_output(crate::t!("REGEN: regenerated model.").as_ref()); return Some(Task::none()); } @@ -593,8 +620,9 @@ impl OpenCADStudio { // ── TOOLPALETTES — not yet implemented ─────────────────────────────── "TOOLPALETTES" => { - self.command_line - .push_info(crate::t!("TOOLPALETTES: Tool Palettes not yet implemented.").as_ref()); + self.command_line.push_info( + crate::t!("TOOLPALETTES: Tool Palettes not yet implemented.").as_ref(), + ); } // ── XDATA — read/write extended entity data ────────────────────────── @@ -635,19 +663,24 @@ impl OpenCADStudio { if let Some(entity) = self.tabs[i].scene.document.get_entity(*sh) { let xd = &entity.common().extended_data; if xd.is_empty() { - self.command_line - .push_output(crate::tf!(" {:x}: no xdata.", sh.value()).as_ref()); + self.command_line.push_output( + crate::tf!(" {:x}: no xdata.", sh.value()).as_ref(), + ); } else { for rec in xd.records() { - self.command_line.push_output(crate::tf!( - " {:x} [{}]: {} value(s)", - sh.value(), - rec.application_name, - rec.values.len() - ).as_ref()); + self.command_line.push_output( + crate::tf!( + " {:x} [{}]: {} value(s)", + sh.value(), + rec.application_name, + rec.values.len() + ) + .as_ref(), + ); for v in &rec.values { - self.command_line - .push_output(crate::tf!(" {:?}", v).as_ref()); + self.command_line.push_output( + crate::tf!(" {:?}", v).as_ref(), + ); } } } @@ -680,10 +713,13 @@ impl OpenCADStudio { } } self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "XDATA: set [{app}] = \"{val}\" on {} entity/entities.", - editable.len() - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "XDATA: set [{app}] = \"{val}\" on {} entity/entities.", + editable.len() + ) + .as_ref(), + ); } "CLEAR" => { let app_filter = parts.get(1).copied(); @@ -723,11 +759,14 @@ impl OpenCADStudio { } } self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("XDATA: cleared.").as_ref()); + self.command_line + .push_output(crate::t!("XDATA: cleared.").as_ref()); } _ => { - self.command_line - .push_info(crate::t!("Usage: XDATA LIST | SET | CLEAR [app]").as_ref()); + self.command_line.push_info( + crate::t!("Usage: XDATA LIST | SET | CLEAR [app]") + .as_ref(), + ); } } } @@ -786,7 +825,9 @@ impl OpenCADStudio { use crate::modules::insert::solid3d_cmds::PresspullCommand; let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); let mut command = PresspullCommand::new(color); - command.set_isolines(crate::entities::solid3d::clamp_header_isolines(self.tabs[i].scene.document.header.isolines)); + command.set_isolines(crate::entities::solid3d::clamp_header_isolines( + self.tabs[i].scene.document.header.isolines, + )); command.set_preselection(self.presspull_preselection()); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); @@ -797,7 +838,9 @@ impl OpenCADStudio { use crate::modules::insert::solid3d_cmds::RevolveCommand; let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - let isolines = crate::entities::solid3d::clamp_header_isolines(self.tabs[i].scene.document.header.isolines); + let isolines = crate::entities::solid3d::clamp_header_isolines( + self.tabs[i].scene.document.header.isolines, + ); let mut cmd = RevolveCommand::new(color, isolines); if !selected.is_empty() { cmd.set_preselection( @@ -815,13 +858,23 @@ impl OpenCADStudio { "SWEEP" => { use crate::modules::insert::solid3d_cmds::SweepCommand; let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - let isolines = crate::entities::solid3d::clamp_header_isolines(self.tabs[i].scene.document.header.isolines); + let isolines = crate::entities::solid3d::clamp_header_isolines( + self.tabs[i].scene.document.header.isolines, + ); let mut cmd = SweepCommand::new(color, isolines); cmd.set_parameters(self.tabs[i].scene.named_parameters().clone()); - let selected = self.tabs[i].scene.selected_handles_in_order() + let selected = self.tabs[i] + .scene + .selected_handles_in_order() .into_iter() - .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) - .cloned().map(|entity| (handle, entity))) + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(handle) + .cloned() + .map(|entity| (handle, entity)) + }) .collect::>(); if !selected.is_empty() { cmd.set_preselection(selected); @@ -834,13 +887,35 @@ impl OpenCADStudio { "LOFT" => { use crate::modules::insert::solid3d_cmds::LoftCommand; let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - let isolines = crate::entities::solid3d::clamp_header_isolines(self.tabs[i].scene.document.header.isolines); - let selected = self.tabs[i].scene.selected_handles_in_order().into_iter() - .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) - .cloned().map(|entity| (handle, entity))).collect(); - let available = self.tabs[i].scene.document.entities() - .map(|entity| (entity.common().handle, entity.clone())).collect(); - let cmd = LoftCommand::new(color, isolines, crate::command::ExtrudeMode::Solid, selected, available); + let isolines = crate::entities::solid3d::clamp_header_isolines( + self.tabs[i].scene.document.header.isolines, + ); + let selected = self.tabs[i] + .scene + .selected_handles_in_order() + .into_iter() + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(handle) + .cloned() + .map(|entity| (handle, entity)) + }) + .collect(); + let available = self.tabs[i] + .scene + .document + .entities() + .map(|entity| (entity.common().handle, entity.clone())) + .collect(); + let cmd = LoftCommand::new( + color, + isolines, + crate::command::ExtrudeMode::Solid, + selected, + available, + ); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } @@ -923,14 +998,18 @@ impl OpenCADStudio { "Plot style: {} ({} color overrides)", t.name, t.aci_entries.iter().filter(|e| e.color.is_some()).count() - ).into_owned() + ) + .into_owned() }) - .unwrap_or_else(|| crate::t!("No plot style table loaded.").into_owned()); + .unwrap_or_else(|| { + crate::t!("No plot style table loaded.").into_owned() + }); self.command_line.push_output(&msg); } _ => { - self.command_line - .push_error(crate::t!("Usage: PLOTSTYLE [LOAD | CLEAR | STATUS]").as_ref()); + self.command_line.push_error( + crate::t!("Usage: PLOTSTYLE [LOAD | CLEAR | STATUS]").as_ref(), + ); } } } @@ -948,17 +1027,21 @@ impl OpenCADStudio { .selected_entities() .iter() .filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle)) - .filter_map(|(handle, _)| match self.tabs[i].scene.document.get_entity(*handle) { - Some(codec::EntityType::RasterImage(image)) => { - Some((*handle, image.flags.contains(ImageDisplayFlags::TRANSPARENCY_ON))) + .filter_map(|(handle, _)| { + match self.tabs[i].scene.document.get_entity(*handle) { + Some(codec::EntityType::RasterImage(image)) => Some(( + *handle, + image.flags.contains(ImageDisplayFlags::TRANSPARENCY_ON), + )), + _ => None, } - _ => None, }) .collect(); let Some(&(_, first_on)) = images.first() else { return Some(Task::none()); }; - let handles: Vec = images.iter().map(|(handle, _)| *handle).collect(); + let handles: Vec = + images.iter().map(|(handle, _)| *handle).collect(); if cmd == "TRANSPARENCY MODE" { let c = crate::modules::insert::image_transparency::TransparencyCommand::mode( handles, first_on, @@ -1022,8 +1105,9 @@ impl OpenCADStudio { .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { - self.command_line - .push_error(crate::t!("UNDERLAY: select underlay entities first.").as_ref()); + self.command_line.push_error( + crate::t!("UNDERLAY: select underlay entities first.").as_ref(), + ); } else { let parts: Vec<&str> = sub.splitn(2, char::is_whitespace).collect(); let action = parts.first().copied().unwrap_or(""); @@ -1060,8 +1144,7 @@ impl OpenCADStudio { } "CLIP" => match arg { "ON" => { - ul.flags |= - codec::entities::UnderlayDisplayFlags::CLIPPING; + ul.flags |= codec::entities::UnderlayDisplayFlags::CLIPPING; changed += 1; } "OFF" => { @@ -1140,8 +1223,10 @@ impl OpenCADStudio { return Some(Task::none()); } let Some(scale) = self.tabs[i].scene.creation_annotation_scale_handle() else { - self.command_line - .push_error(crate::t!("OBJECTSCALE: the active annotation scale is unavailable.").as_ref()); + self.command_line.push_error( + crate::t!("OBJECTSCALE: the active annotation scale is unavailable.") + .as_ref(), + ); return Some(Task::none()); }; @@ -1156,11 +1241,9 @@ impl OpenCADStudio { let member = memberships.iter().any(|(_, current)| *current == scale); if adding { !member - && self.tabs[i] - .scene - .document - .get_entity(*handle) - .is_some_and(crate::scene::annotative::supports_annotation_context) + && self.tabs[i].scene.document.get_entity(*handle).is_some_and( + crate::scene::annotative::supports_annotation_context, + ) } else { member } @@ -1213,11 +1296,14 @@ impl OpenCADStudio { .collect(); self.tabs[i].scene.bump_entities(&changes); self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "OBJECTSCALE: {} the current scale on {} object(s).", - if adding { "added" } else { "removed" }, - changed.len() - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "OBJECTSCALE: {} the current scale on {} object(s).", + if adding { "added" } else { "removed" }, + changed.len() + ) + .as_ref(), + ); return Some(Task::none()); } @@ -1226,19 +1312,22 @@ impl OpenCADStudio { "HYPERLINK" => { use crate::command::SelectThenValueCommand; let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); - let c = SelectThenValueCommand::new( - "HYPERLINK", - "HYPERLINK URL to attach:", - has_sel, - ); + let c = + SelectThenValueCommand::new("HYPERLINK", "HYPERLINK URL to attach:", has_sel); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("HYPERLINK ") => { use codec::xdata::XDataValue; - let url = cmd.strip_prefix("HYPERLINK").unwrap_or("").trim().to_string(); + let url = cmd + .strip_prefix("HYPERLINK") + .unwrap_or("") + .trim() + .to_string(); if url.is_empty() { - self.command_line.push_info(crate::t!("Usage: HYPERLINK (select objects first)").as_ref()); + self.command_line.push_info( + crate::t!("Usage: HYPERLINK (select objects first)").as_ref(), + ); return Some(Task::none()); } let handles: Vec = self.tabs[i] @@ -1249,7 +1338,8 @@ impl OpenCADStudio { .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { - self.command_line.push_error(crate::t!("HYPERLINK: select objects first.").as_ref()); + self.command_line + .push_error(crate::t!("HYPERLINK: select objects first.").as_ref()); return Some(Task::none()); } self.push_undo_snapshot(i, "HYPERLINK"); @@ -1282,7 +1372,11 @@ impl OpenCADStudio { "ADJUST", "ADJUST [Brightness / Contrast / Fade]:", vec![ - ("Brightness", "BRIGHTNESS", Some("ADJUST brightness 0-100:")), + ( + "Brightness", + "BRIGHTNESS", + Some("ADJUST brightness 0-100:"), + ), ("Contrast", "CONTRAST", Some("ADJUST contrast 0-100:")), ("Fade", "FADE", Some("ADJUST fade 0-100:")), ], @@ -1307,8 +1401,9 @@ impl OpenCADStudio { self.command_line .push_error(crate::t!("ADJUST: select raster image(s) first.").as_ref()); } else if action.is_empty() { - self.command_line - .push_info(crate::t!("Usage: ADJUST BRIGHTNESS|CONTRAST|FADE <0-100>").as_ref()); + self.command_line.push_info( + crate::t!("Usage: ADJUST BRIGHTNESS|CONTRAST|FADE <0-100>").as_ref(), + ); } else if let Ok(v) = arg.parse::() { let v = v.min(100); self.push_undo_snapshot(i, "ADJUST"); @@ -1351,15 +1446,17 @@ impl OpenCADStudio { .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); - self.command_line - .push_output(crate::tf!("ADJUST: {action} = {v} on {changed} image(s).").as_ref()); + self.command_line.push_output( + crate::tf!("ADJUST: {action} = {v} on {changed} image(s).").as_ref(), + ); } else { self.command_line.push_error( crate::t!("ADJUST: no raster images selected, or unknown property (use BRIGHTNESS|CONTRAST|FADE).").as_ref(), ); } } else { - self.command_line.push_error(crate::t!("ADJUST: value must be 0-100.").as_ref()); + self.command_line + .push_error(crate::t!("ADJUST: value must be 0-100.").as_ref()); } } @@ -1377,10 +1474,8 @@ impl OpenCADStudio { } "ANNOALLVISIBLE" => { use crate::command::ValuePromptCommand; - let c = ValuePromptCommand::new( - "ANNOALLVISIBLE", - "ANNOALLVISIBLE new value [0/1]:", - ); + let c = + ValuePromptCommand::new("ANNOALLVISIBLE", "ANNOALLVISIBLE new value [0/1]:"); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -1402,10 +1497,8 @@ impl OpenCADStudio { } "ANNOAUTOSCALE" => { use crate::command::ValuePromptCommand; - let c = ValuePromptCommand::new( - "ANNOAUTOSCALE", - "ANNOAUTOSCALE new value [-4..4]:", - ); + let c = + ValuePromptCommand::new("ANNOAUTOSCALE", "ANNOAUTOSCALE new value [-4..4]:"); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -1427,8 +1520,9 @@ impl OpenCADStudio { .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { - self.command_line - .push_error(crate::t!("ANNOUPDATE: select annotation objects first.").as_ref()); + self.command_line.push_error( + crate::t!("ANNOUPDATE: select annotation objects first.").as_ref(), + ); return Some(Task::none()); } self.push_undo_snapshot(i, "ANNOUPDATE"); @@ -1487,9 +1581,9 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("Annotation scale: {arg}").as_ref()); } - None => self - .command_line - .push_error(crate::t!("Usage: ANNOSCALE e.g. 1:50, 2:1, or a factor").as_ref()), + None => self.command_line.push_error( + crate::t!("Usage: ANNOSCALE e.g. 1:50, 2:1, or a factor").as_ref(), + ), } } @@ -1503,8 +1597,16 @@ impl OpenCADStudio { "SCALELISTEDIT", "SCALELISTEDIT [Add / Delete]:", vec![ - ("Add", "ADD", Some("SCALELISTEDIT ADD new scale ratio (e.g. 1:50):")), - ("Delete", "DELETE", Some("SCALELISTEDIT DELETE scale ratio to remove:")), + ( + "Add", + "ADD", + Some("SCALELISTEDIT ADD new scale ratio (e.g. 1:50):"), + ), + ( + "Delete", + "DELETE", + Some("SCALELISTEDIT DELETE scale ratio to remove:"), + ), ], ); self.command_line.push_info(&c.prompt()); @@ -1522,20 +1624,22 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "SCALELISTEDIT"); if self.tabs[i].scene.add_scale(arg, paper, drawing) { self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("Added annotation scale {arg}.").as_ref()); + self.command_line.push_output( + crate::tf!("Added annotation scale {arg}.").as_ref(), + ); } else { - self.command_line - .push_info(crate::tf!("Scale {arg} already exists.").as_ref()); + self.command_line.push_info( + crate::tf!("Scale {arg} already exists.").as_ref(), + ); } } - _ => self - .command_line - .push_error(crate::t!("SCALELISTEDIT ADD: use a ratio like 1:50.").as_ref()), + _ => self.command_line.push_error( + crate::t!("SCALELISTEDIT ADD: use a ratio like 1:50.").as_ref(), + ), }, - None => self - .command_line - .push_error(crate::t!("SCALELISTEDIT ADD: use a ratio like 1:50.").as_ref()), + None => self.command_line.push_error( + crate::t!("SCALELISTEDIT ADD: use a ratio like 1:50.").as_ref(), + ), }, "DELETE" | "REMOVE" => { let current = self.tabs[i] @@ -1545,20 +1649,25 @@ impl OpenCADStudio { .current_annotation_scale .clone(); if arg.is_empty() { - self.command_line.push_info(crate::t!("Usage: SCALELISTEDIT DELETE ").as_ref()); + self.command_line.push_info( + crate::t!("Usage: SCALELISTEDIT DELETE ").as_ref(), + ); } else if arg.eq_ignore_ascii_case(¤t) { - self.command_line.push_error(crate::tf!( - "Cannot delete the current annotation scale ({arg})." - ).as_ref()); + self.command_line.push_error( + crate::tf!("Cannot delete the current annotation scale ({arg}).") + .as_ref(), + ); } else { self.push_undo_snapshot(i, "SCALELISTEDIT"); if self.tabs[i].scene.remove_scale(arg) { self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("Removed annotation scale {arg}.").as_ref()); + self.command_line.push_output( + crate::tf!("Removed annotation scale {arg}.").as_ref(), + ); } else { - self.command_line - .push_info(crate::tf!("No annotation scale named {arg}.").as_ref()); + self.command_line.push_info( + crate::tf!("No annotation scale named {arg}.").as_ref(), + ); } } } @@ -1570,15 +1679,17 @@ impl OpenCADStudio { .map(|(n, _, _)| n) .collect(); if names.is_empty() { - self.command_line.push_info(crate::t!("No annotation scales defined.").as_ref()); - } else { self.command_line - .push_output(crate::tf!("Annotation scales: {}", names.join(", ")).as_ref()); + .push_info(crate::t!("No annotation scales defined.").as_ref()); + } else { + self.command_line.push_output( + crate::tf!("Annotation scales: {}", names.join(", ")).as_ref(), + ); } } - _ => self - .command_line - .push_info(crate::t!("Usage: SCALELISTEDIT [ADD 1:50 | DELETE 1:50]").as_ref()), + _ => self.command_line.push_info( + crate::t!("Usage: SCALELISTEDIT [ADD 1:50 | DELETE 1:50]").as_ref(), + ), } } @@ -1624,8 +1735,10 @@ impl OpenCADStudio { .collect(); if candidates.is_empty() { self.command_line.push_info( - crate::t!("ANNORESET: no selected object has alternate scale representations.") - .as_ref(), + crate::t!( + "ANNORESET: no selected object has alternate scale representations." + ) + .as_ref(), ); return Some(Task::none()); } @@ -1666,9 +1779,8 @@ impl OpenCADStudio { cmd if cmd.starts_with("DATALINK ") => { let path = cmd.trim_start_matches("DATALINK").trim(); if path.is_empty() { - self.command_line.push_info( - crate::t!("Usage: DATALINK ").as_ref(), - ); + self.command_line + .push_info(crate::t!("Usage: DATALINK ").as_ref()); return Some(Task::none()); } match std::fs::read_to_string(path) { @@ -1695,29 +1807,32 @@ impl OpenCADStudio { } let doc = &self.tabs[i].scene.document; let current_style = doc.header.current_table_style_name.clone(); - table.table_style_handle = doc.objects.iter().find_map(|(handle, object)| { - match object { - codec::objects::ObjectType::TableStyle(style) - if style.name.eq_ignore_ascii_case(¤t_style) => - { - Some(*handle) - } - _ => None, - } - }); + table.table_style_handle = + doc.objects + .iter() + .find_map(|(handle, object)| match object { + codec::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(¤t_style) => + { + Some(*handle) + } + _ => None, + }); let link_path = std::fs::canonicalize(path) .map(|value| value.to_string_lossy().into_owned()) .unwrap_or_else(|_| path.to_string()); - let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::new( - table, &link_path, - ); + let command = + crate::modules::annotate::data_link::DataLinkPlaceCommand::new( + table, &link_path, + ); self.command_line .push_info(&crate::command::CadCommand::prompt(&command)); self.tabs[i].active_cmd = Some(Box::new(command)); } Err(e) => { - self.command_line - .push_error(crate::tf!("DATALINK: cannot read \"{path}\": {e}").as_ref()); + self.command_line.push_error( + crate::tf!("DATALINK: cannot read \"{path}\": {e}").as_ref(), + ); } } } @@ -1784,15 +1899,17 @@ impl OpenCADStudio { &self.tabs[i].scene.document, *link_handle, ) - .and_then(|path| std::fs::write(path, csv).map_err(|error| error.to_string())) - { + .and_then(|path| { + std::fs::write(path, csv).map_err(|error| error.to_string()) + }) { Ok(()) => written += 1, Err(error) => errors.push(error), } } if written > 0 { self.command_line.push_output( - crate::tf!("DATALINKUPDATE: wrote {} linked source(s).", written).as_ref(), + crate::tf!("DATALINKUPDATE: wrote {} linked source(s).", written) + .as_ref(), ); } for error in errors { @@ -1813,8 +1930,9 @@ impl OpenCADStudio { .filter(|(_, _, rows)| !rows.is_empty()) .collect(); if updates.is_empty() { - self.command_line - .push_error(crate::t!("DATALINKUPDATE: linked sources could not be read.").as_ref()); + self.command_line.push_error( + crate::t!("DATALINKUPDATE: linked sources could not be read.").as_ref(), + ); return Some(Task::none()); } self.push_undo_snapshot(i, "DATALINKUPDATE"); @@ -1834,7 +1952,11 @@ impl OpenCADStudio { table.remove_row(table.row_count() - 1); } while table.column_count() < columns { - let width = table.columns.last().map(|column| column.width).unwrap_or(2.0); + let width = table + .columns + .last() + .map(|column| column.width) + .unwrap_or(2.0); table.add_column(width); } while table.column_count() > columns { @@ -1864,8 +1986,11 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.refresh_properties(); self.command_line.push_output( - crate::tf!("DATALINKUPDATE: updated {} linked table(s).", changed_handles.len()) - .as_ref(), + crate::tf!( + "DATALINKUPDATE: updated {} linked table(s).", + changed_handles.len() + ) + .as_ref(), ); } @@ -1874,8 +1999,10 @@ impl OpenCADStudio { // (northing easting elevation) → Point at (easting, northing, elev). "LANDXMLIMPORT" => { use crate::command::ValuePromptCommand; - let c = - ValuePromptCommand::new("LANDXMLIMPORT", "LANDXMLIMPORT path to the .xml file:"); + let c = ValuePromptCommand::new( + "LANDXMLIMPORT", + "LANDXMLIMPORT path to the .xml file:", + ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -1883,7 +2010,10 @@ impl OpenCADStudio { let path = cmd.trim_start_matches("LANDXMLIMPORT").trim(); if path.is_empty() { self.command_line.push_info( - crate::t!("Usage: LANDXMLIMPORT (imports CgPoint survey points)").as_ref(), + crate::t!( + "Usage: LANDXMLIMPORT (imports CgPoint survey points)" + ) + .as_ref(), ); return Some(Task::none()); } @@ -1891,8 +2021,10 @@ impl OpenCADStudio { Ok(xml) => { let pts = parse_landxml_cgpoints(&xml); if pts.is_empty() { - self.command_line - .push_info(crate::t!("LANDXMLIMPORT: no survey points found.").as_ref()); + self.command_line.push_info( + crate::t!("LANDXMLIMPORT: no survey points found.") + .as_ref(), + ); return Some(Task::none()); } self.push_undo_snapshot(i, "LANDXMLIMPORT"); @@ -1904,14 +2036,17 @@ impl OpenCADStudio { .add_entity_clone(codec::EntityType::Point(p)); } self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( + self.command_line.push_output( + crate::tf!( "LANDXMLIMPORT: imported {} survey point(s). Use ZOOM EXTENTS to view.", pts.len() - ).as_ref()); + ) + .as_ref(), + ); } - Err(e) => self - .command_line - .push_error(crate::tf!("LANDXMLIMPORT: cannot read \"{path}\": {e}").as_ref()), + Err(e) => self.command_line.push_error( + crate::tf!("LANDXMLIMPORT: cannot read \"{path}\": {e}").as_ref(), + ), } } @@ -2046,8 +2181,14 @@ mod tests { app.tabs[i].scene.refresh_pending_any(), "REDRAW must leave a pending force request" ); - assert_eq!(app.tabs[i].scene.geometry_epoch, geom_before, "REDRAW must not regen"); - assert_eq!(app.tabs[i].scene.block_epoch, block_before, "REDRAW must not regen blocks"); + assert_eq!( + app.tabs[i].scene.geometry_epoch, geom_before, + "REDRAW must not regen" + ); + assert_eq!( + app.tabs[i].scene.block_epoch, block_before, + "REDRAW must not regen blocks" + ); } #[test] @@ -2057,7 +2198,10 @@ mod tests { let _ = full.run_command_line("REDRAW"); let _ = short.run_command_line("R"); let i = full.active_tab; - assert!(short.tabs[i].scene.refresh_pending_any(), "'R' must trigger REDRAW"); + assert!( + short.tabs[i].scene.refresh_pending_any(), + "'R' must trigger REDRAW" + ); assert_eq!( short.tabs[i].scene.refresh_pending_any(), full.tabs[i].scene.refresh_pending_any(), @@ -2070,7 +2214,10 @@ mod tests { let mut app = fresh_app(); let i = app.active_tab; let _ = app.run_command_line("REDRAWALL"); - assert!(app.tabs[i].scene.refresh_pending_any(), "REDRAWALL must leave a force request"); + assert!( + app.tabs[i].scene.refresh_pending_any(), + "REDRAWALL must leave a force request" + ); } #[test] @@ -2081,11 +2228,23 @@ mod tests { let block_before = app.tabs[i].scene.block_epoch; app.tabs[i].dirty = false; let _ = app.run_command_line("REGEN"); - assert_ne!(app.tabs[i].scene.geometry_epoch, geom_before, "REGEN must regenerate geometry"); - assert_ne!(app.tabs[i].scene.block_epoch, block_before, "REGEN must regenerate block epoch"); - assert!(!app.tabs[i].dirty, "REGEN must NOT mark the document as modified (no DB change)"); + assert_ne!( + app.tabs[i].scene.geometry_epoch, geom_before, + "REGEN must regenerate geometry" + ); + assert_ne!( + app.tabs[i].scene.block_epoch, block_before, + "REGEN must regenerate block epoch" + ); + assert!( + !app.tabs[i].dirty, + "REGEN must NOT mark the document as modified (no DB change)" + ); let _ = app.run_command_line("REGENALL"); - assert!(!app.tabs[i].dirty, "REGENALL must not dirty the document either"); + assert!( + !app.tabs[i].dirty, + "REGENALL must not dirty the document either" + ); } #[test] diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 72f9a5358..0dc6605db 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -438,9 +438,7 @@ impl OpenCADStudio { .document .entities() .filter_map(|entity| match entity { - codec::EntityType::Face3D(face) => { - Some((face.common.handle, face.clone())) - } + codec::EntityType::Face3D(face) => Some((face.common.handle, face.clone())), _ => None, }) .collect(); @@ -1030,9 +1028,8 @@ impl OpenCADStudio { false, true, ); - self.command_line.push_output( - format!("{} constraint bar(s) reset.", count).as_str(), - ); + self.command_line + .push_output(format!("{} constraint bar(s) reset.", count).as_str()); } } @@ -1145,20 +1142,15 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(sel)); } else { let scope = self.tabs[i].current_parametric_scope(); - let inferred = self.tabs[i] - .scene - .inferred_parametric_constraints( - scope, - &handles, - &self.auto_constrain_settings, - ); + let inferred = self.tabs[i].scene.inferred_parametric_constraints( + scope, + &handles, + &self.auto_constrain_settings, + ); if inferred.is_empty() { self.command_line.push_output( - format!( - "0 constraint(s) applied to {} object(s).", - handles.len() - ) - .as_str(), + format!("0 constraint(s) applied to {} object(s).", handles.len()) + .as_str(), ); self.tabs[i].scene.deselect_all(); self.refresh_selected_grips(); @@ -1281,8 +1273,7 @@ impl OpenCADStudio { use crate::scene::named_parameters::DrivingValue; use crate::scene::parametric_constraints::{ dynamic_dimension_text, next_angular_parameter_name, - next_dimensional_parameter_name, next_radial_parameter_name, - ConstraintKind, + next_dimensional_parameter_name, next_radial_parameter_name, ConstraintKind, }; let scope = self.tabs[i].current_parametric_scope(); @@ -1322,7 +1313,11 @@ impl OpenCADStudio { }; if self.tabs[i] .scene - .validate_parametric_constraint(kind, &refs, Some(&DrivingValue::Literal(measured))) + .validate_parametric_constraint( + kind, + &refs, + Some(&DrivingValue::Literal(measured)), + ) .is_err() { refused += 1; @@ -1451,10 +1446,12 @@ impl OpenCADStudio { self.tabs[i].scene.deselect_all(); self.tabs[i].dirty = true; self.refresh_properties(); - self.command_line - .push_output(crate::tf!("{converted} associative dimensions converted").as_ref()); self.command_line.push_output( - crate::tf!("{refused} associative dimension(s) could not be converted").as_ref(), + crate::tf!("{converted} associative dimensions converted").as_ref(), + ); + self.command_line.push_output( + crate::tf!("{refused} associative dimension(s) could not be converted") + .as_ref(), ); if let Some(pd) = pending { self.commit_undo_delta(i, pd); @@ -1472,7 +1469,11 @@ impl OpenCADStudio { let (kind, axis, label) = if vertical { (ConstraintKind::Vertical, "Vertical", "Vertical constraint") } else { - (ConstraintKind::Horizontal, "Horizontal", "Horizontal constraint") + ( + ConstraintKind::Horizontal, + "Horizontal", + "Horizontal constraint", + ) }; let new_command = || { if vertical { @@ -1491,13 +1492,14 @@ impl OpenCADStudio { .push_error(&format!("{axis}: select exactly one compatible object.")); } else { let handle = handles[0]; - let reference = self.tabs[i] - .scene - .document - .get_entity(handle) - .and_then(|entity| { - HorizontalConstraintCommand::preselected_reference(entity, handle) - }); + let reference = + self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| { + HorizontalConstraintCommand::preselected_reference(entity, handle) + }); if let Some(reference) = reference { let plane = self.tabs[i].ucs_xform().working_plane(); let direction = if vertical { plane.y } else { plane.x }; @@ -1532,13 +1534,14 @@ impl OpenCADStudio { // the reference's own point-or-object prompt. let handles = self.tabs[i].scene.selected_handles_in_order(); if let [handle] = handles.as_slice() { - let reference = self.tabs[i] - .scene - .document - .get_entity(*handle) - .and_then(|entity| { - FixConstraintCommand::preselected_reference(entity, *handle) - }); + let reference = + self.tabs[i] + .scene + .document + .get_entity(*handle) + .and_then(|entity| { + FixConstraintCommand::preselected_reference(entity, *handle) + }); if let Some(reference) = reference { return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Fixed, @@ -1718,13 +1721,15 @@ impl OpenCADStudio { }) .collect::>(); if picks.len() == 2 && picks[0].reference != picks[1].reference { - return Some(self.apply_cmd_result(CmdResult::AddPerpendicularConstraint { - first: picks[0].reference, - second: picks[1].reference, - first_fixed: picks[0].fixed_reference, - second_start: picks[1].start_reference, - label: "Perpendicular constraint", - })); + return Some(self.apply_cmd_result( + CmdResult::AddPerpendicularConstraint { + first: picks[0].reference, + second: picks[1].reference, + first_fixed: picks[0].fixed_reference, + second_start: picks[1].start_reference, + label: "Perpendicular constraint", + }, + )); } self.tabs[i].scene.deselect_all(); self.command_line.push_error( @@ -1860,21 +1865,21 @@ impl OpenCADStudio { self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } else { - use crate::command::{ - CmdResult, SymmetricConstraintSelection, - }; + use crate::command::{CmdResult, SymmetricConstraintSelection}; use crate::modules::parametric::SymmetricConstraintCommand; - let refs = (handles.len() == 3).then(|| { - let first = self.tabs[i].scene.document.get_entity(handles[0])?; - let second = self.tabs[i].scene.document.get_entity(handles[1])?; - let axis = self.tabs[i].scene.document.get_entity(handles[2])?; - SymmetricConstraintCommand::preselected_refs( - (first, handles[0]), - (second, handles[1]), - (axis, handles[2]), - ) - }).flatten(); + let refs = (handles.len() == 3) + .then(|| { + let first = self.tabs[i].scene.document.get_entity(handles[0])?; + let second = self.tabs[i].scene.document.get_entity(handles[1])?; + let axis = self.tabs[i].scene.document.get_entity(handles[2])?; + SymmetricConstraintCommand::preselected_refs( + (first, handles[0]), + (second, handles[1]), + (axis, handles[2]), + ) + }) + .flatten(); if let Some([first, second, axis]) = refs { return Some(self.apply_cmd_result(CmdResult::AddSymmetricConstraint { selection: SymmetricConstraintSelection::Objects(first, second), @@ -1962,10 +1967,8 @@ impl OpenCADStudio { // asked for this command. // The reference asks for the circle itself, whatever is // selected, so a pick-first set is left alone. - let name = next_radial_parameter_name( - self.tabs[i].scene.named_parameters(), - diameter, - ); + let name = + next_radial_parameter_name(self.tabs[i].scene.named_parameters(), diameter); let command = DimConstraintCommand::new( if diameter { DimConstraintAxis::Diameter @@ -2537,10 +2540,7 @@ impl OpenCADStudio { .filter(|handle| { matches!( scene.document.get_entity(*handle), - Some( - codec::EntityType::Solid3D(_) - | codec::EntityType::Surface(_) - ) + Some(codec::EntityType::Solid3D(_) | codec::EntityType::Surface(_)) ) }) .collect::>(); diff --git a/src/app/commands/field.rs b/src/app/commands/field.rs index 5c3bcee64..2b1e464c8 100644 --- a/src/app/commands/field.rs +++ b/src/app/commands/field.rs @@ -3,10 +3,12 @@ use crate::app::{Message, OpenCADStudio}; use crate::command::CadCommand; -use crate::modules::annotate::field_cmd::{FieldObjectPickCommand, FieldPlaceCommand, FieldTablePickCommand}; +use crate::modules::annotate::field_cmd::{ + FieldObjectPickCommand, FieldPlaceCommand, FieldTablePickCommand, +}; use crate::ui::window::field_dialog::{ - fields_of, nav_props, object_properties, FieldDialogMsg, FieldDialogState, FieldTarget, DATE_FORMATS, - NAMED_TYPES, + fields_of, nav_props, object_properties, FieldDialogMsg, FieldDialogState, FieldTarget, + DATE_FORMATS, NAMED_TYPES, }; use iced::Task; @@ -18,7 +20,10 @@ impl OpenCADStudio { Some(Task::none()) } // FIELDEVAL — the events that update fields, kept in the drawing. - cmd if cmd == "FIELDEVAL" || cmd.starts_with("FIELDEVAL ") || cmd.starts_with("SETVAR FIELDEVAL") => { + cmd if cmd == "FIELDEVAL" + || cmd.starts_with("FIELDEVAL ") + || cmd.starts_with("SETVAR FIELDEVAL") => + { let value = cmd .trim_start_matches("SETVAR ") .trim_start_matches("FIELDEVAL") @@ -44,8 +49,9 @@ impl OpenCADStudio { } _ => { let (min, max) = (0, 31); - self.command_line - .push_error(&crate::tf!("Requires an integer between {min} and {max}.")); + self.command_line.push_error(&crate::tf!( + "Requires an integer between {min} and {max}." + )); ask(self); } } @@ -58,9 +64,14 @@ impl OpenCADStudio { .and_then(|h| u64::from_str_radix(h, 16).ok()) .map(codec::Handle::new); if let Some(state) = self.field_dialog.as_mut() { - if let Some(entity) = handle.and_then(|h| self.tabs[i].scene.document.get_entity(h)) { + if let Some(entity) = + handle.and_then(|h| self.tabs[i].scene.document.get_entity(h)) + { let props = object_properties(entity); - state.object = Some((entity.common().handle, crate::t!(crate::entities::names::ui_name(entity)).into_owned())); + state.object = Some(( + entity.common().handle, + crate::t!(crate::entities::names::ui_name(entity)).into_owned(), + )); state.object_prop = props.first().copied(); state.object_props = props; } @@ -100,7 +111,11 @@ impl OpenCADStudio { let function = *parts.first()?; let cell = |at: &[&str]| -> Option<(codec::Handle, usize, usize)> { let handle = codec::Handle::new(u64::from_str_radix(at.first()?, 16).ok()?); - let point = glam::DVec3::new(at.get(1)?.parse().ok()?, at.get(2)?.parse().ok()?, at.get(3)?.parse().ok()?); + let point = glam::DVec3::new( + at.get(1)?.parse().ok()?, + at.get(2)?.parse().ok()?, + at.get(3)?.parse().ok()?, + ); let document = &self.tabs[i].scene.document; let codec::EntityType::Table(table) = document.get_entity(handle)? else { return None; @@ -111,7 +126,11 @@ impl OpenCADStudio { let name = |row: usize, column: usize| format!("{}{}", column_letters(column), row + 1); let (table, row, column) = cell(parts.get(1..5)?)?; if function == "Cell" { - return Some(format!("Table(%<\\_ObjId {:X}>%).{}", table.value(), name(row, column))); + return Some(format!( + "Table(%<\\_ObjId {:X}>%).{}", + table.value(), + name(row, column) + )); } let (second_table, row2, column2) = cell(parts.get(5..9)?)?; if second_table != table { @@ -139,7 +158,13 @@ impl OpenCADStudio { .collect(); if let Some(db) = self.sheet_set.db() { let custom = codec::sheet_set::custom_properties(db.sheet_set()); - let names = |flag: i32| custom.iter().filter(|(_, _, f)| f & flag != 0).map(|(n, _, _)| n.clone()).collect(); + let names = |flag: i32| { + custom + .iter() + .filter(|(_, _, f)| f & flag != 0) + .map(|(n, _, _)| n.clone()) + .collect() + }; state.ss_sheet_custom = names(codec::sheet_set::CUSTOM_SHEET_PROP); state.ss_set_custom = names(codec::sheet_set::CUSTOM_SHEET_SET_PROP); state.ss_set_name = db.name().to_string(); @@ -153,8 +178,13 @@ impl OpenCADStudio { } fn field_value(&self, code: &str, objects: &[codec::Handle]) -> String { - crate::entities::field::evaluate(&self.tabs[self.active_tab].scene.document, code, objects, None) - .unwrap_or_else(|| "----".to_string()) + crate::entities::field::evaluate( + &self.tabs[self.active_tab].scene.document, + code, + objects, + None, + ) + .unwrap_or_else(|| "----".to_string()) } fn refresh_field_preview(&mut self) { @@ -165,7 +195,8 @@ impl OpenCADStudio { let preview = if state.complete() { if state.name == "BlockPlaceholder" { // A placeholder shows its property name until a block reference resolves it. - let (label, _, _) = codec::fields::BLOCK_PLACEHOLDER_PROPERTIES[state.placeholder_property]; + let (label, _, _) = + codec::fields::BLOCK_PLACEHOLDER_PROPERTIES[state.placeholder_property]; text_case(label, state.text_case) } else { self.field_value(&code, &objects) @@ -191,16 +222,32 @@ impl OpenCADStudio { .filter(|b| !b.name.starts_with('*')) .map(|b| (b.name.clone(), b.handle)) .collect(), - "Dimstyle" => document.dim_styles.iter().map(|s| (s.name.clone(), s.handle)).collect(), - "Layer" => document.layers.iter().map(|l| (l.name.clone(), l.handle)).collect(), - "Linetype" => document.line_types.iter().map(|l| (l.name.clone(), l.handle)).collect(), + "Dimstyle" => document + .dim_styles + .iter() + .map(|s| (s.name.clone(), s.handle)) + .collect(), + "Layer" => document + .layers + .iter() + .map(|l| (l.name.clone(), l.handle)) + .collect(), + "Linetype" => document + .line_types + .iter() + .map(|l| (l.name.clone(), l.handle)) + .collect(), "Textstyle" => document .text_styles .iter() .filter(|s| !s.name.is_empty()) .map(|s| (s.name.clone(), s.handle)) .collect(), - "View" => document.views.iter().map(|v| (v.name.clone(), v.handle)).collect(), + "View" => document + .views + .iter() + .map(|v| (v.name.clone(), v.handle)) + .collect(), _ => Vec::new(), }; names.sort_by_key(|(n, _)| n.to_lowercase()); @@ -378,8 +425,9 @@ impl OpenCADStudio { match state.target { FieldTarget::NewText => { self.close_active_modal(); - let defaults = - crate::scene::creation_style::current_text_defaults(&self.tabs[i].scene.document); + let defaults = crate::scene::creation_style::current_text_defaults( + &self.tabs[i].scene.document, + ); let command = FieldPlaceCommand::new( value, field.expect("set above"), @@ -416,8 +464,12 @@ impl OpenCADStudio { let target = self.field_dialog.take().map(|s| s.target); self.close_active_modal(); self.active_modal = match target { - Some(FieldTarget::AttdefDefault) if self.attdef_dialog.is_some() => Some(crate::app::ModalKind::AttDef), - Some(FieldTarget::AttdefEdit) if self.attdef_edit.is_some() => Some(crate::app::ModalKind::AttDefEdit), + Some(FieldTarget::AttdefDefault) if self.attdef_dialog.is_some() => { + Some(crate::app::ModalKind::AttDef) + } + Some(FieldTarget::AttdefEdit) if self.attdef_edit.is_some() => { + Some(crate::app::ModalKind::AttDefEdit) + } _ => None, }; } @@ -445,14 +497,20 @@ fn text_case(value: &str, case: usize) -> String { 3 => { let lower = value.to_lowercase(); let mut chars = lower.chars(); - chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default() + chars + .next() + .map(|c| c.to_uppercase().chain(chars).collect()) + .unwrap_or_default() } 4 => value .split(' ') .map(|word| { let lower = word.to_lowercase(); let mut chars = lower.chars(); - chars.next().map(|c| c.to_uppercase().chain(chars).collect::()).unwrap_or_default() + chars + .next() + .map(|c| c.to_uppercase().chain(chars).collect::()) + .unwrap_or_default() }) .collect::>() .join(" "), diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 4b395f182..7b922bec0 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -28,8 +28,9 @@ impl OpenCADStudio { // Tabs without a path (never saved) are skipped with a note. "SAVEALL" => { if self.read_only { - self.command_line - .push_error(crate::t!("Read-only session (--read-only): saving is disabled.").as_ref()); + self.command_line.push_error( + crate::t!("Read-only session (--read-only): saving is disabled.").as_ref(), + ); return Some(Task::none()); } #[cfg(not(target_arch = "wasm32"))] @@ -41,47 +42,47 @@ impl OpenCADStudio { continue; } if let Some(path) = self.tabs[t].current_path.clone() { - if self - .active_save_jobs - .contains_key(&self.tabs[t].id) - { - self.command_line.push_info(crate::tf!( - "SAVEALL: {} already has a save running", - path.display() - ).as_ref()); + if self.active_save_jobs.contains_key(&self.tabs[t].id) { + self.command_line.push_info( + crate::tf!( + "SAVEALL: {} already has a save running", + path.display() + ) + .as_ref(), + ); skipped += 1; continue; } - match self.save_tab_synchronously_protected( - t, - path.clone(), - false, - ) { + match self.save_tab_synchronously_protected(t, path.clone(), false) { Ok(()) => { saved += 1; } - Err(e) => self.command_line.push_error(crate::tf!( - "SAVEALL: {} failed: {e}", - path.display() - ).as_ref()), + Err(e) => self.command_line.push_error( + crate::tf!("SAVEALL: {} failed: {e}", path.display()).as_ref(), + ), } } else { skipped += 1; } } - self.command_line.push_output(crate::tf!( - "SAVEALL: saved {saved} drawing(s){}.", - if skipped > 0 { - crate::tf!("; {skipped} need SAVEAS (no file path yet)").into_owned() - } else { - String::new() - } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "SAVEALL: saved {saved} drawing(s){}.", + if skipped > 0 { + crate::tf!("; {skipped} need SAVEAS (no file path yet)") + .into_owned() + } else { + String::new() + } + ) + .as_ref(), + ); } #[cfg(target_arch = "wasm32")] { - self.command_line - .push_info(crate::t!("SAVEALL: save each tab individually in the web build.").as_ref()); + self.command_line.push_info( + crate::t!("SAVEALL: save each tab individually in the web build.").as_ref(), + ); } return Some(Task::none()); } @@ -105,7 +106,8 @@ impl OpenCADStudio { // without undoing any work done since. "OOPS" => { if self.oops_cache.is_empty() { - self.command_line.push_info(crate::t!("OOPS: nothing to restore.").as_ref()); + self.command_line + .push_info(crate::t!("OOPS: nothing to restore.").as_ref()); } else { let restored = std::mem::take(&mut self.oops_cache); let pending = self.begin_undo(i, "OOPS", restored.len(), true); @@ -202,7 +204,11 @@ impl OpenCADStudio { } for d in deps { let dp = PathBuf::from(&d); - let resolved = if dp.is_absolute() { dp } else { parent.join(&dp) }; + let resolved = if dp.is_absolute() { + dp + } else { + parent.join(&dp) + }; if resolved.exists() { if let Some(fname) = resolved.file_name() { if std::fs::copy(&resolved, folder.join(fname)).is_ok() { @@ -211,18 +217,23 @@ impl OpenCADStudio { } } } - self.command_line.push_output(crate::tf!( - "{cmd}: packaged {copied} file(s) into {}", - folder.display() - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "{cmd}: packaged {copied} file(s) into {}", + folder.display() + ) + .as_ref(), + ); } Err(e) => self .command_line .push_error(crate::tf!("{cmd}: cannot create folder ({e}).").as_ref()), } } else { - self.command_line - .push_error(crate::t!("ARCHIVE: save the drawing first (it has no file path yet).").as_ref()); + self.command_line.push_error( + crate::t!("ARCHIVE: save the drawing first (it has no file path yet).") + .as_ref(), + ); } } @@ -241,11 +252,14 @@ impl OpenCADStudio { "PERF" => { self.perf_hud = !self.perf_hud; crate::perf::set_ui_enabled(self.perf_hud); - self.command_line.push_info(crate::t!(if self.perf_hud { - "PERF panel on — tracing render and interaction costs" - } else { - "PERF panel off" - }).as_ref()); + self.command_line.push_info( + crate::t!(if self.perf_hud { + "PERF panel on — tracing render and interaction costs" + } else { + "PERF panel off" + }) + .as_ref(), + ); return Some(Task::none()); } @@ -277,14 +291,16 @@ impl OpenCADStudio { self.model_space.mode = crate::app::config::ModelSpaceMode::MatchTheme; self.sync_model_space_theme(true); self.persist_settings_if_changed(); - self.command_line - .push_output(crate::t!("Model space background set to Match Theme.").as_ref()); + self.command_line.push_output( + crate::t!("Model space background set to Match Theme.").as_ref(), + ); } else if first_arg == "CLASSIC" { self.model_space.mode = crate::app::config::ModelSpaceMode::ClassicDark; self.sync_model_space_theme(true); self.persist_settings_if_changed(); - self.command_line - .push_output(crate::t!("Model space background set to Classic CAD Dark.").as_ref()); + self.command_line.push_output( + crate::t!("Model space background set to Classic CAD Dark.").as_ref(), + ); } else if first_arg == "DEFAULT" || first_arg == "RESET" { if is_paper { self.model_space.custom_paper_bg = None; @@ -300,31 +316,48 @@ impl OpenCADStudio { .push_output(crate::t!("Background reset to default.").as_ref()); } else if first_arg == "DESK" { let sub_args = &args[1..]; - let sub_first = sub_args.first().map(|s| s.to_uppercase()).unwrap_or_default(); + let sub_first = sub_args + .first() + .map(|s| s.to_uppercase()) + .unwrap_or_default(); if sub_first == "DEFAULT" || sub_first == "RESET" { self.model_space.custom_desk_bg = None; self.sync_model_space_theme(false); self.persist_settings_if_changed(); - self.command_line - .push_output(crate::t!("Paper desk surround background reset to default.").as_ref()); + self.command_line.push_output( + crate::t!("Paper desk surround background reset to default.").as_ref(), + ); } else if let Some(rgba) = parse_background_color(sub_args) { let [r, g, b, _] = rgba; - let rgb = [(r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8]; + let rgb = [ + (r * 255.0).round() as u8, + (g * 255.0).round() as u8, + (b * 255.0).round() as u8, + ]; self.model_space.custom_desk_bg = Some(rgb); self.sync_model_space_theme(false); self.persist_settings_if_changed(); - self.command_line.push_output(crate::tf!( - "Desk surround background: rgb({}, {}, {})", - rgb[0], - rgb[1], - rgb[2] - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "Desk surround background: rgb({}, {}, {})", + rgb[0], + rgb[1], + rgb[2] + ) + .as_ref(), + ); } else { - self.command_line.push_info(crate::t!("Usage: BACKGROUND DESK | DEFAULT").as_ref()); + self.command_line.push_info( + crate::t!("Usage: BACKGROUND DESK | DEFAULT").as_ref(), + ); } } else if let Some(rgba) = parse_background_color(&args) { let [r, g, b, _] = rgba; - let rgb = [(r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8]; + let rgb = [ + (r * 255.0).round() as u8, + (g * 255.0).round() as u8, + (b * 255.0).round() as u8, + ]; if is_paper { self.model_space.custom_paper_bg = Some(rgb); self.paper_bg_input = crate::app::config::rgb_to_hex(rgb); @@ -335,12 +368,9 @@ impl OpenCADStudio { } self.sync_model_space_theme(true); self.persist_settings_if_changed(); - self.command_line.push_output(crate::tf!( - "Background: rgb({}, {}, {})", - rgb[0], - rgb[1], - rgb[2] - ).as_ref()); + self.command_line.push_output( + crate::tf!("Background: rgb({}, {}, {})", rgb[0], rgb[1], rgb[2]).as_ref(), + ); } else { self.command_line.push_info( crate::t!("Usage: BACKGROUND (0–255) | THEME|CLASSIC|DEFAULT|DESK|BLACK|DARKGRAY|GRAY|LIGHTGRAY|WHITE").as_ref(), @@ -399,15 +429,19 @@ impl OpenCADStudio { .filter(|l| !l.starts_with('#') && !l.starts_with(';')) .map(|l| Task::done(Message::ScriptLine(l.to_string()))) .collect(); - self.command_line.push_output(crate::tf!( - "SCRIPT: running {} command(s) from {clean_path}.", - cmds.len() - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "SCRIPT: running {} command(s) from {clean_path}.", + cmds.len() + ) + .as_ref(), + ); return Some(Task::batch(cmds)); } Err(e) => { - self.command_line - .push_error(crate::tf!("SCRIPT: cannot read {clean_path}: {e}").as_ref()); + self.command_line.push_error( + crate::tf!("SCRIPT: cannot read {clean_path}: {e}").as_ref(), + ); } } } @@ -482,17 +516,30 @@ mod tests { // Feed standard multi-line CAD script sequence let _ = app.update(Message::ScriptLine("LINE".into())); - assert!(app.tabs[i].active_cmd.is_some(), "LINE command should be active"); + assert!( + app.tabs[i].active_cmd.is_some(), + "LINE command should be active" + ); let _ = app.update(Message::ScriptLine("0,0".into())); - assert!(app.tabs[i].active_cmd.is_some(), "LINE command should stay active after first point"); + assert!( + app.tabs[i].active_cmd.is_some(), + "LINE command should stay active after first point" + ); let _ = app.update(Message::ScriptLine("10,10".into())); let _ = app.update(Message::ScriptLine("".into())); // Enter to finish let after_count = app.tabs[i].scene.document.entities().count(); - assert_eq!(after_count, before_count + 1, "Line entity should have been created"); - assert!(app.tabs[i].active_cmd.is_none(), "LINE command should be finished"); + assert_eq!( + after_count, + before_count + 1, + "Line entity should have been created" + ); + assert!( + app.tabs[i].active_cmd.is_none(), + "LINE command should be finished" + ); } #[test] @@ -504,8 +551,15 @@ mod tests { let _ = app.update(Message::ScriptLine("CIRCLE 5,5 2".into())); let after_count = app.tabs[i].scene.document.entities().count(); - assert_eq!(after_count, before_count + 1, "Circle entity should have been created"); - assert!(app.tabs[i].active_cmd.is_none(), "CIRCLE command should be finished"); + assert_eq!( + after_count, + before_count + 1, + "Circle entity should have been created" + ); + assert!( + app.tabs[i].active_cmd.is_none(), + "CIRCLE command should be finished" + ); } #[test] @@ -517,7 +571,11 @@ mod tests { let _ = app.update(Message::ScriptLine("_CIRCLE 10,10 3".into())); let after_count = app.tabs[i].scene.document.entities().count(); - assert_eq!(after_count, before_count + 1, "_CIRCLE should resolve to CIRCLE and create entity"); + assert_eq!( + after_count, + before_count + 1, + "_CIRCLE should resolve to CIRCLE and create entity" + ); } #[test] @@ -548,7 +606,11 @@ CIRCLE 50,50 15 ZOOM EXTENTS "#; - for line in SCRIPT.lines().map(str::trim).filter(|l| !l.starts_with('#') && !l.starts_with(';')) { + for line in SCRIPT + .lines() + .map(str::trim) + .filter(|l| !l.starts_with('#') && !l.starts_with(';')) + { let _ = app.update(Message::ScriptLine(line.to_string())); } // 4 square edges + 2 diagonal lines + 2 circles = 8 entities @@ -574,7 +636,10 @@ ZOOM EXTENTS // 2. Blank line when idle repeats LINE let _ = app.update(Message::ScriptLine("".into())); - assert!(app.tabs[i].active_cmd.is_some(), "Blank line should repeat LINE"); + assert!( + app.tabs[i].active_cmd.is_some(), + "Blank line should repeat LINE" + ); let _ = app.update(Message::ScriptLine("20,20".into())); let _ = app.update(Message::ScriptLine("30,30".into())); let _ = app.update(Message::ScriptLine("".into())); // finishes second LINE @@ -588,7 +653,10 @@ ZOOM EXTENTS // Blank line repeats CIRCLE let _ = app.update(Message::ScriptLine("".into())); - assert!(app.tabs[i].active_cmd.is_some(), "Blank line should repeat CIRCLE"); + assert!( + app.tabs[i].active_cmd.is_some(), + "Blank line should repeat CIRCLE" + ); let _ = app.update(Message::ScriptLine("15,15".into())); let _ = app.update(Message::ScriptLine("3".into())); assert_eq!(app.tabs[i].scene.document.entities().count(), 4); diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index dda3f01b1..8158bbe1c 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -4,12 +4,15 @@ impl OpenCADStudio { /// Align every selected object or complete group by one edge or center. fn align_selected_bounds(&mut self, i: usize, command: &str) { use crate::command::EntityTransform; - use kernel::space::BoundsAlignment; use glam::DVec3; + use kernel::space::BoundsAlignment; let (alignment, label) = match command { "ALIGNLEFT" => (BoundsAlignment::Left, "Align Left"), - "ALIGNHCENTER" => (BoundsAlignment::HorizontalCenter, "Align Horizontal Centers"), + "ALIGNHCENTER" => ( + BoundsAlignment::HorizontalCenter, + "Align Horizontal Centers", + ), "ALIGNRIGHT" => (BoundsAlignment::Right, "Align Right"), "ALIGNTOP" => (BoundsAlignment::Top, "Align Top"), "ALIGNVCENTER" => (BoundsAlignment::VerticalCenter, "Align Vertical Centers"), @@ -62,19 +65,17 @@ impl OpenCADStudio { .expect("validated at least two finite bounds"); let pending = self.begin_undo(i, label, handles.len(), true); for ((unit, _), [dx, dy]) in bounds.iter().zip(offsets) { - self.tabs[i].scene.transform_entities( - unit, - &EntityTransform::Translate(DVec3::new(dx, dy, 0.0)), - ); + self.tabs[i] + .scene + .transform_entities(unit, &EntityTransform::Translate(DVec3::new(dx, dy, 0.0))); } self.tabs[i].dirty = true; self.refresh_properties(); if let Some(pending) = pending { self.commit_undo_delta(i, pending); } - self.command_line.push_output( - crate::tf!("{command}: aligned {} object(s).", bounds.len()).as_ref(), - ); + self.command_line + .push_output(crate::tf!("{command}: aligned {} object(s).", bounds.len()).as_ref()); } pub(super) fn dispatch_inquiry(&mut self, cmd: &str, i: usize) -> Option> { @@ -94,7 +95,8 @@ impl OpenCADStudio { "DESELECT" | "DESELALL" => { self.tabs[i].scene.deselect_all(); - self.command_line.push_output(crate::t!("Deselected.").as_ref()); + self.command_line + .push_output(crate::t!("Deselected.").as_ref()); self.refresh_properties(); } @@ -129,14 +131,21 @@ impl OpenCADStudio { || cmd.starts_with("QUICKCALC ") || cmd.starts_with("QC ") => { - let expr = cmd.splitn(2, char::is_whitespace).nth(1).unwrap_or("").trim(); + let expr = cmd + .splitn(2, char::is_whitespace) + .nth(1) + .unwrap_or("") + .trim(); if expr.is_empty() { - self.command_line - .push_info(crate::t!("Usage: CAL e.g. CAL (2+3)*4").as_ref()); + self.command_line.push_info( + crate::t!("Usage: CAL e.g. CAL (2+3)*4").as_ref(), + ); } else { match arith_eval(expr) { Ok(v) => self.command_line.push_output(crate::tf!("= {v}").as_ref()), - Err(e) => self.command_line.push_error(crate::tf!("CAL: {e}").as_ref()), + Err(e) => self + .command_line + .push_error(crate::tf!("CAL: {e}").as_ref()), } } } @@ -145,8 +154,9 @@ impl OpenCADStudio { "LIST" => { let selected: Vec<_> = self.tabs[i].scene.selected_entities(); if selected.is_empty() { - self.command_line - .push_error(crate::t!("LIST: no entities selected. Select entities first.").as_ref()); + self.command_line.push_error( + crate::t!("LIST: no entities selected. Select entities first.").as_ref(), + ); } else { for (handle, _) in &selected { if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) { @@ -165,18 +175,21 @@ impl OpenCADStudio { }; // Entity-specific details let details = entity_list_details(entity); - self.command_line.push_output(crate::tf!( - "{type_name} Handle:{:X} Layer:{} Color:{} LT:{}{}", - handle.value(), - common.layer, - color_str, - linetype, - if details.is_empty() { - String::new() - } else { - format!("\n {details}") - } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "{type_name} Handle:{:X} Layer:{} Color:{} LT:{}{}", + handle.value(), + common.layer, + color_str, + linetype, + if details.is_empty() { + String::new() + } else { + format!("\n {details}") + } + ) + .as_ref(), + ); } } } @@ -190,8 +203,10 @@ impl OpenCADStudio { let scene = &self.tabs[i].scene; let selected = scene.selected_entities(); if selected.is_empty() { - self.command_line - .push_error(crate::t!("SELHANDLES: no entities selected. Select entities first.").as_ref()); + self.command_line.push_error( + crate::t!("SELHANDLES: no entities selected. Select entities first.") + .as_ref(), + ); } else { use std::collections::BTreeMap; let space = if scene.current_layout == "Model" { @@ -212,18 +227,23 @@ impl OpenCADStudio { } } handles.sort_unstable(); - let types: Vec = - type_counts.iter().map(|(t, n)| format!("{t}×{n}")).collect(); + let types: Vec = type_counts + .iter() + .map(|(t, n)| format!("{t}×{n}")) + .collect(); let list: Vec = handles.iter().map(|h| format!("{:X}", h)).collect(); let mut msg = crate::tf!( "SELHANDLES: {} selected in {}\n Types: {}", handles.len(), space, types.join(", ") - ).into_owned(); + ) + .into_owned(); if !block_counts.is_empty() { - let blocks: Vec = - block_counts.iter().map(|(b, n)| format!("{b}×{n}")).collect(); + let blocks: Vec = block_counts + .iter() + .map(|(b, n)| format!("{b}×{n}")) + .collect(); msg.push_str(&crate::tf!("\n Blocks: {}", blocks.join(", ")).into_owned()); } msg.push_str(&crate::tf!("\n Handles: {}", list.join(",")).into_owned()); @@ -257,9 +277,12 @@ impl OpenCADStudio { let details = entity_list_details(entity); format!( "{type_name} {}:{:X} {}:{} {}:{} LT:{}{}", - crate::t!("Handle"), common.handle.value(), - crate::t!("Layer"), common.layer, - crate::t!("Color"), color_str, + crate::t!("Handle"), + common.handle.value(), + crate::t!("Layer"), + common.layer, + crate::t!("Color"), + color_str, linetype, if details.is_empty() { String::new() @@ -296,7 +319,9 @@ impl OpenCADStudio { } let mut cmd = JoinCommand::new(); if let Some(handle) = selected.first() { - if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() { cmd = cmd.with_source(*handle, entity); } + if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() { + cmd = cmd.with_source(*handle, entity); + } } self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); @@ -325,27 +350,26 @@ impl OpenCADStudio { .filter_map(|e| { let h = e.common().handle.value(); match e { - codec::EntityType::LwPolyline(_) - | codec::EntityType::Polyline2D(_) => Some(( - h, - PeditTarget { - is_poly: true, - convertible: false, - mesh_size: None, - mesh_closed: None, - }, - )), - codec::EntityType::Line(_) | codec::EntityType::Arc(_) => { + codec::EntityType::LwPolyline(_) | codec::EntityType::Polyline2D(_) => { Some(( h, PeditTarget { - is_poly: false, - convertible: true, + is_poly: true, + convertible: false, mesh_size: None, mesh_closed: None, }, )) } + codec::EntityType::Line(_) | codec::EntityType::Arc(_) => Some(( + h, + PeditTarget { + is_poly: false, + convertible: true, + mesh_size: None, + mesh_closed: None, + }, + )), codec::EntityType::PolygonMesh(mesh) => Some(( h, PeditTarget { @@ -380,9 +404,7 @@ impl OpenCADStudio { } "MLEDIT" => { - use crate::modules::draw::modify::mledit::{ - MlineEditCommand, MlineEditTarget, - }; + use crate::modules::draw::modify::mledit::{MlineEditCommand, MlineEditTarget}; let document = &self.tabs[i].scene.document; let targets = document .entities() @@ -409,13 +431,16 @@ impl OpenCADStudio { "SPLINEDIT" => { use crate::modules::draw::modify::splinedit::SplineditCommand; - let mut cmd_obj = SplineditCommand::new().with_delete_source(self.delete_objects != 0); + let mut cmd_obj = + SplineditCommand::new().with_delete_source(self.delete_objects != 0); let selected: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); if let [handle] = selected.as_slice() { if let Some(entity @ codec::EntityType::Spline(_)) = self.tabs[i].scene.document.get_entity(*handle).cloned() { - if self.reject_locked_edit(i, *handle) { return Some(Task::none()); } + if self.reject_locked_edit(i, *handle) { + return Some(Task::none()); + } cmd_obj.inject_picked_entity(entity); cmd_obj.on_entity_pick(*handle, glam::DVec3::ZERO); } @@ -437,15 +462,16 @@ impl OpenCADStudio { use crate::modules::draw::modify::refedit::RefEditPickCommand; // If a session is already active, tell the user. if self.tabs[i].refedit_session.is_some() { - self.command_line - .push_error(crate::t!("REFEDIT: a session is already active. Use REFCLOSE first.").as_ref()); + self.command_line.push_error( + crate::t!("REFEDIT: a session is already active. Use REFCLOSE first.") + .as_ref(), + ); } else { // Check if a single INSERT is already selected. let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); if selected.len() == 1 { - if let Some(codec::EntityType::Insert(_)) = - selected.first().map(|(_, e)| e) + if let Some(codec::EntityType::Insert(_)) = selected.first().map(|(_, e)| e) { let handle = selected[0].0; // Skip pick phase — jump straight to begin. @@ -463,15 +489,15 @@ impl OpenCADStudio { "BEDIT" => { use crate::modules::draw::modify::block_edit::BlockEditPickCommand; if self.tabs[i].refedit_session.is_some() { - self.command_line - .push_error(crate::t!("BEDIT: finish the active REFEDIT (REFCLOSE) first.").as_ref()); + self.command_line.push_error( + crate::t!("BEDIT: finish the active REFEDIT (REFCLOSE) first.").as_ref(), + ); } else { // Jump straight to begin when a single INSERT is preselected. let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); if selected.len() == 1 { - if let Some(codec::EntityType::Insert(_)) = - selected.first().map(|(_, e)| e) + if let Some(codec::EntityType::Insert(_)) = selected.first().map(|(_, e)| e) { let handle = selected[0].0; let _ = @@ -498,31 +524,28 @@ impl OpenCADStudio { let block_name = match self.tabs[i].scene.document.get_entity(insert_handle) { Some(codec::EntityType::Insert(ins)) => ins.block_name.clone(), _ => { - self.command_line - .push_error(crate::t!("BEDIT: selected object is not a block reference.").as_ref()); + self.command_line.push_error( + crate::t!("BEDIT: selected object is not a block reference.").as_ref(), + ); return Some(Task::none()); } }; // Resolve the block record; reject external references (xrefs). - let (br_handle, is_xref) = match self.tabs[i] - .scene - .document - .block_records - .get(&block_name) - { - Some(br) => (br.handle, br.flags.is_xref), - None => { - self.command_line.push_error(crate::tf!( - "BEDIT: block \"{}\" not found.", - block_name - ).as_ref()); - return Some(Task::none()); - } - }; + let (br_handle, is_xref) = + match self.tabs[i].scene.document.block_records.get(&block_name) { + Some(br) => (br.handle, br.flags.is_xref), + None => { + self.command_line.push_error( + crate::tf!("BEDIT: block \"{}\" not found.", block_name).as_ref(), + ); + return Some(Task::none()); + } + }; if is_xref { - self.command_line - .push_error(crate::t!("BEDIT: cannot edit an external reference (xref).").as_ref()); + self.command_line.push_error( + crate::t!("BEDIT: cannot edit an external reference (xref).").as_ref(), + ); return Some(Task::none()); } if self.tabs[i] @@ -531,9 +554,7 @@ impl OpenCADStudio { .any(|session| session.br_handle == br_handle) { self.tabs[i].active_cmd = None; - return Some(Task::done(Message::BlockEditSwitch( - block_name.clone(), - ))); + return Some(Task::done(Message::BlockEditSwitch(block_name.clone()))); } // Snapshot the complete block definition so Discard can restore @@ -554,9 +575,7 @@ impl OpenCADStudio { .scene .block_definition_dependent_handles(br_handle) .into_iter() - .filter_map(|handle| { - self.tabs[i].scene.document.get_entity_arc(handle) - }) + .filter_map(|handle| self.tabs[i].scene.document.get_entity_arc(handle)) .collect(); let reference_attributes: Vec<_> = self.tabs[i] .scene @@ -567,10 +586,7 @@ impl OpenCADStudio { if reference.block_name.eq_ignore_ascii_case(&block_name) && !reference.attributes.is_empty() => { - Some(( - reference.common.handle, - reference.attributes.clone(), - )) + Some((reference.common.handle, reference.attributes.clone())) } _ => None, }) @@ -649,10 +665,13 @@ impl OpenCADStudio { self.refresh_properties(); self.tabs[i].active_cmd = None; self.tabs[i].dirty = true; - self.command_line.push_info(crate::tf!( - "BEDIT: Editing block \"{}\". Use Save Block or Discard to finish.", - block_name - ).as_ref()); + self.command_line.push_info( + crate::tf!( + "BEDIT: Editing block \"{}\". Use Save Block or Discard to finish.", + block_name + ) + .as_ref(), + ); } "BEDIT_SAVE" => { @@ -666,8 +685,9 @@ impl OpenCADStudio { return Some(Task::none()); } Some(_) => { - self.command_line - .push_error(crate::t!("BEDIT_SAVE: invalid block editor state.").as_ref()); + self.command_line.push_error( + crate::t!("BEDIT_SAVE: invalid block editor state.").as_ref(), + ); return Some(Task::none()); } }; @@ -679,10 +699,13 @@ impl OpenCADStudio { &session.return_camera, ); self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "BEDIT: Block \"{}\" saved. All references updated.", - session.block_name - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "BEDIT: Block \"{}\" saved. All references updated.", + session.block_name + ) + .as_ref(), + ); } "BEDIT_DISCARD" => { @@ -691,13 +714,15 @@ impl OpenCADStudio { self.tabs[i].block_edits.remove(index) } None => { - self.command_line - .push_error(crate::t!("BEDIT_DISCARD: no block editor is open.").as_ref()); + self.command_line.push_error( + crate::t!("BEDIT_DISCARD: no block editor is open.").as_ref(), + ); return Some(Task::none()); } Some(_) => { - self.command_line - .push_error(crate::t!("BEDIT_DISCARD: invalid block editor state.").as_ref()); + self.command_line.push_error( + crate::t!("BEDIT_DISCARD: invalid block editor state.").as_ref(), + ); return Some(Task::none()); } }; @@ -755,10 +780,9 @@ impl OpenCADStudio { &return_camera, ); self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "BEDIT: Block \"{}\" edit discarded.", - block_name - ).as_ref()); + self.command_line.push_output( + crate::tf!("BEDIT: Block \"{}\" edit discarded.", block_name).as_ref(), + ); } cmd if cmd.starts_with("REFEDIT_BEGIN:") => { @@ -777,8 +801,9 @@ impl OpenCADStudio { let insert = match self.tabs[i].scene.document.get_entity(insert_handle) { Some(codec::EntityType::Insert(ins)) => ins.clone(), _ => { - self.command_line - .push_error(crate::t!("REFEDIT: selected object is not an INSERT.").as_ref()); + self.command_line.push_error( + crate::t!("REFEDIT: selected object is not an INSERT.").as_ref(), + ); return Some(Task::none()); } }; @@ -814,10 +839,10 @@ impl OpenCADStudio { { Some(br) => br.handle, None => { - self.command_line.push_error(crate::tf!( - "REFEDIT: block \"{}\" not found.", - insert.block_name - ).as_ref()); + self.command_line.push_error( + crate::tf!("REFEDIT: block \"{}\" not found.", insert.block_name) + .as_ref(), + ); return Some(Task::none()); } }; @@ -845,7 +870,8 @@ impl OpenCADStudio { }; if block_entities.is_empty() { - self.command_line.push_error(crate::t!("REFEDIT: block is empty.").as_ref()); + self.command_line + .push_error(crate::t!("REFEDIT: block is empty.").as_ref()); return Some(Task::none()); } @@ -856,8 +882,7 @@ impl OpenCADStudio { forward, inverse, // Everything allocated from here on is part of the session. - handle_watermark: self - .tabs[i] + handle_watermark: self.tabs[i] .scene .document .next_handle() @@ -999,10 +1024,13 @@ impl OpenCADStudio { } self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "REFCLOSE: Block \"{}\" saved. All references updated.", - session.block_name - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "REFCLOSE: Block \"{}\" saved. All references updated.", + session.block_name + ) + .as_ref(), + ); // End the edit fade before rebuilding, so the restored geometry // recolours bright. (#136) self.tabs[i].scene.set_refedit_keep(None); @@ -1077,7 +1105,8 @@ impl OpenCADStudio { "MEASURE" => { use crate::modules::draw::inquiry::divide::MeasureCommand; - let cmd = MeasureCommand::new().with_blocks(self.tabs[i].scene.custom_block_names()); + let cmd = + MeasureCommand::new().with_blocks(self.tabs[i].scene.custom_block_names()); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } @@ -1108,21 +1137,26 @@ impl OpenCADStudio { "MASSPROP" => { let selected = self.tabs[i].scene.selected_entities(); if selected.is_empty() { - self.command_line - .push_error(crate::t!("MASSPROP: no entities selected. Select entities first.").as_ref()); + self.command_line.push_error( + crate::t!("MASSPROP: no entities selected. Select entities first.") + .as_ref(), + ); } else { for (handle, _) in &selected { if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) { use crate::entities::traits::EntityTypeOps; if let Some(props) = entity.mass_props() { - self.command_line.push_output(crate::tf!( - "{} Area={:.4} Perimeter={:.4} Centroid=({:.4},{:.4})", - crate::entities::names::dxf_name(entity), - props.area, - props.perimeter, - props.cx, - props.cy, - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "{} Area={:.4} Perimeter={:.4} Centroid=({:.4},{:.4})", + crate::entities::names::dxf_name(entity), + props.area, + props.perimeter, + props.cx, + props.cy, + ) + .as_ref(), + ); } } } @@ -1132,10 +1166,10 @@ impl OpenCADStudio { // ── FLATTEN — move selected (or all) entities to Z=0 ───────────── "FLATTEN" => { let scene = &self.tabs[i].scene; - let handles = - crate::modules::draw::modify::flatten::collect_flatten_handles(scene); + let handles = crate::modules::draw::modify::flatten::collect_flatten_handles(scene); if handles.is_empty() { - self.command_line.push_error(crate::t!("FLATTEN: no entities.").as_ref()); + self.command_line + .push_error(crate::t!("FLATTEN: no entities.").as_ref()); } else { let candidate_count = handles.len(); let updates = crate::modules::draw::modify::flatten::plan_flatten( @@ -1159,11 +1193,14 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.refresh_properties(); } - self.command_line.push_output(crate::tf!( - "FLATTEN: {} entity(ies) moved to Z=0; {} unchanged or unsupported.", - moved, - candidate_count.saturating_sub(moved) - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "FLATTEN: {} entity(ies) moved to Z=0; {} unchanged or unsupported.", + moved, + candidate_count.saturating_sub(moved) + ) + .as_ref(), + ); } } @@ -1200,8 +1237,9 @@ impl OpenCADStudio { .collect(); if prop.is_empty() { - self.command_line - .push_info(crate::t!("Usage: QSELECT TYPE|LAYER|COLOR|LINETYPE ").as_ref()); + self.command_line.push_info( + crate::t!("Usage: QSELECT TYPE|LAYER|COLOR|LINETYPE ").as_ref(), + ); } else if matched.is_empty() { self.command_line .push_output(crate::t!("QSELECT: no matching entities.").as_ref()); @@ -1210,8 +1248,9 @@ impl OpenCADStudio { for h in &matched { self.tabs[i].scene.select_entity(*h, false); } - self.command_line - .push_output(crate::tf!("QSELECT: {} entity(ies) selected.", matched.len()).as_ref()); + self.command_line.push_output( + crate::tf!("QSELECT: {} entity(ies) selected.", matched.len()).as_ref(), + ); self.refresh_properties(); } } @@ -1242,15 +1281,16 @@ impl OpenCADStudio { // Split at " REPLACE " keyword (case-insensitive). ASCII case // mapping keeps byte offsets valid for slicing `rest`. - let (search, replacement) = if let Some(pos) = rest.to_ascii_uppercase().find(" REPLACE ") - { - (&rest[..pos], Some(rest[pos + 9..].trim())) - } else { - (rest, None) - }; + let (search, replacement) = + if let Some(pos) = rest.to_ascii_uppercase().find(" REPLACE ") { + (&rest[..pos], Some(rest[pos + 9..].trim())) + } else { + (rest, None) + }; if search.is_empty() { - self.command_line.push_error(crate::t!("FIND: specify search text.").as_ref()); + self.command_line + .push_error(crate::t!("FIND: specify search text.").as_ref()); } else { let search_lc = search.to_lowercase(); let mut count = 0usize; @@ -1280,8 +1320,9 @@ impl OpenCADStudio { .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if targets.is_empty() { - self.command_line - .push_output(crate::tf!("FIND: \"{}\" not found.", search).as_ref()); + self.command_line.push_output( + crate::tf!("FIND: \"{}\" not found.", search).as_ref(), + ); } else { self.push_undo_snapshot(i, "FIND/REPLACE"); for h in &targets { @@ -1293,34 +1334,38 @@ impl OpenCADStudio { } } self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "FIND/REPLACE: replaced {} occurrence(s) of \"{}\" → \"{}\".", - count, search, rep - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "FIND/REPLACE: replaced {} occurrence(s) of \"{}\" → \"{}\".", + count, + search, + rep + ) + .as_ref(), + ); self.refresh_properties(); } } else { // List mode if handles.is_empty() { - self.command_line - .push_output(crate::tf!("FIND: \"{}\" not found.", search).as_ref()); + self.command_line.push_output( + crate::tf!("FIND: \"{}\" not found.", search).as_ref(), + ); } else { for h in &handles { if let Some(e) = self.tabs[i].scene.document.get_entity(*h) { use crate::entities::traits::EntityTypeOps; let txt = e.text_content().unwrap_or_default(); - self.command_line.push_output(crate::tf!( - " Handle {:X}: \"{}\"", - h.value(), - txt - ).as_ref()); + self.command_line.push_output( + crate::tf!(" Handle {:X}: \"{}\"", h.value(), txt) + .as_ref(), + ); } } - self.command_line.push_output(crate::tf!( - "FIND: {} match(es) for \"{}\".", - handles.len(), - search - ).as_ref()); + self.command_line.push_output( + crate::tf!("FIND: {} match(es) for \"{}\".", handles.len(), search) + .as_ref(), + ); } } } @@ -1418,15 +1463,26 @@ impl OpenCADStudio { }, ) }); - let Some((verb, kind, layer, color, transparency, linetype, lt_scale, lw, template_dimstyle)) = info + let Some(( + verb, + kind, + layer, + color, + transparency, + linetype, + lt_scale, + lw, + template_dimstyle, + )) = info else { self.command_line .push_error(crate::t!("ADDSELECTED: selected object not found.").as_ref()); return Task::none(); }; let Some(verb) = verb else { - self.command_line - .push_error(crate::tf!("ADDSELECTED: creating a new {kind} is not supported.").as_ref()); + self.command_line.push_error( + crate::tf!("ADDSELECTED: creating a new {kind} is not supported.").as_ref(), + ); return Task::none(); }; @@ -1439,15 +1495,34 @@ impl OpenCADStudio { // template's properties for the new object without permanently changing // CLAYER / CECOLOR / CELTYPE / CELWEIGHT (issue #239). let restore = crate::app::AddSelectedRestore { - layer_name: self.tabs[i].scene.document.header.current_layer_name.clone(), + layer_name: self.tabs[i] + .scene + .document + .header + .current_layer_name + .clone(), layer_handle: self.tabs[i].scene.document.header.current_layer_handle, color: self.tabs[i].scene.document.header.current_entity_color, transparency: self.tabs[i].scene.document.current_entity_transparency(), - linetype_name: self.tabs[i].scene.document.header.current_linetype_name.clone(), + linetype_name: self.tabs[i] + .scene + .document + .header + .current_linetype_name + .clone(), linetype_handle: self.tabs[i].scene.document.header.current_linetype_handle, line_weight: self.tabs[i].scene.document.header.current_line_weight, - lt_scale: self.tabs[i].scene.document.header.current_entity_linetype_scale, - dimstyle_name: self.tabs[i].scene.document.header.current_dimstyle_name.clone(), + lt_scale: self.tabs[i] + .scene + .document + .header + .current_entity_linetype_scale, + dimstyle_name: self.tabs[i] + .scene + .document + .header + .current_dimstyle_name + .clone(), dimstyle_handle: self.tabs[i].scene.document.header.current_dimstyle_handle, tab_active_layer: self.tabs[i].active_layer.clone(), tab_layers_current: self.tabs[i].layers.current_layer.clone(), @@ -1457,9 +1532,14 @@ impl OpenCADStudio { ribbon_lineweight: self.ribbon.active_lineweight, }; self.add_selected_restore = Some(restore); - if !self.tabs[i].scene.document.set_current_entity_transparency(transparency) { + if !self.tabs[i] + .scene + .document + .set_current_entity_transparency(transparency) + { self.add_selected_restore = None; - self.command_line.push_error("ADDSELECTED: template transparency cannot be adopted."); + self.command_line + .push_error("ADDSELECTED: template transparency cannot be adopted."); return Task::none(); } @@ -1517,9 +1597,9 @@ impl OpenCADStudio { self.ribbon.active_lineweight = lw; self.refresh_properties(); - self.command_line.push_output(crate::tf!( - "Add Selected: drawing a new {kind} on layer \"{layer}\"." - ).as_ref()); + self.command_line.push_output( + crate::tf!("Add Selected: drawing a new {kind} on layer \"{layer}\".").as_ref(), + ); // Launch the matching draw command (installs its interactive step). self.dispatch_command(verb) } @@ -1544,8 +1624,13 @@ impl OpenCADStudio { h.current_dimstyle_name = r.dimstyle_name; h.current_dimstyle_handle = r.dimstyle_handle; } - if !self.tabs[i].scene.document.set_current_entity_transparency(r.transparency) { - self.command_line.push_error("ADDSELECTED: current transparency could not be restored."); + if !self.tabs[i] + .scene + .document + .set_current_entity_transparency(r.transparency) + { + self.command_line + .push_error("ADDSELECTED: current transparency could not be restored."); } self.tabs[i].active_layer = r.tab_active_layer; self.tabs[i].layers.current_layer = r.tab_layers_current; @@ -1613,7 +1698,8 @@ fn entity_list_details(entity: &codec::EntityType) -> String { + (l.end.y - l.start.y).powi(2) + (l.end.z - l.start.z).powi(2)) .sqrt() - ).into_owned(), + ) + .into_owned(), codec::EntityType::Circle(c) => crate::tf!( "center ({:.4},{:.4},{:.4}) r={:.4} area={:.4}", c.center.x, @@ -1621,7 +1707,8 @@ fn entity_list_details(entity: &codec::EntityType) -> String { c.center.z, c.radius, PI * c.radius * c.radius - ).into_owned(), + ) + .into_owned(), codec::EntityType::Arc(a) => crate::tf!( "center ({:.4},{:.4},{:.4}) r={:.4} start={:.2}° end={:.2}°", a.center.x, @@ -1630,24 +1717,31 @@ fn entity_list_details(entity: &codec::EntityType) -> String { a.radius, a.start_angle.to_degrees(), a.end_angle.to_degrees() - ).into_owned(), + ) + .into_owned(), codec::EntityType::LwPolyline(p) => crate::tf!( "{} vertices closed={} elevation={:.4}", p.vertices.len(), p.is_closed, p.elevation - ).into_owned(), + ) + .into_owned(), codec::EntityType::Text(t) => crate::tf!( "\"{}\" h={:.4} at ({:.4},{:.4})", - t.value, t.height, t.insertion_point.x, t.insertion_point.y - ).into_owned(), + t.value, + t.height, + t.insertion_point.x, + t.insertion_point.y + ) + .into_owned(), codec::EntityType::MText(t) => crate::tf!( "\"{}\" h={:.4} at ({:.4},{:.4})", t.value.chars().take(40).collect::(), t.height, t.insertion_point.x, t.insertion_point.y - ).into_owned(), + ) + .into_owned(), codec::EntityType::Insert(ins) => crate::tf!( "block=\"{}\" at ({:.4},{:.4},{:.4}) scale=({:.4},{:.4},{:.4}) rot={:.2}°", ins.block_name, @@ -1658,25 +1752,27 @@ fn entity_list_details(entity: &codec::EntityType) -> String { ins.y_scale(), ins.z_scale(), ins.rotation.to_degrees() - ).into_owned(), + ) + .into_owned(), codec::EntityType::Spline(s) => crate::tf!( "{} ctrl pts degree={} closed={}", s.control_points.len(), s.degree, s.flags.closed - ).into_owned(), + ) + .into_owned(), codec::EntityType::Ellipse(e) => crate::tf!( "center ({:.4},{:.4}) major_len={:.4} ratio={:.4}", e.center.x, e.center.y, e.major_axis_length(), e.minor_axis_ratio - ).into_owned(), + ) + .into_owned(), _ => String::new(), } } - // ── CAL — arithmetic expression evaluator ────────────────────────────────── // A small recursive-descent parser for `+ - * /`, parentheses, unary signs and // decimal numbers. Self-contained (no external dependency). @@ -1806,11 +1902,11 @@ impl ArithParser { } } let s: String = self.chars[start..self.pos].iter().collect(); - s.parse::().map_err(|_| crate::tf!("bad number '{s}'").into_owned()) + s.parse::() + .map_err(|_| crate::tf!("bad number '{s}'").into_owned()) } } - #[cfg(test)] mod align_selected_bounds_tests { use super::*; @@ -1864,21 +1960,47 @@ mod align_selected_bounds_tests { let _ = app.automation_op(r#"{"op":"new"}"#); let left = add_line(&mut app, 0.0, 0.0, 1.0, 1.0); // leftmost & topmost already let right = add_line(&mut app, 5.0, -3.0, 8.0, -2.0); // further right and lower - app.tabs[app.active_tab].scene.select_entities(&[left, right]); + app.tabs[app.active_tab] + .scene + .select_entities(&[left, right]); let i = app.active_tab; app.dispatch_inquiry("ALIGNLEFT", i); - assert_eq!(line_start_x(&app, left), 0.0, "the already-leftmost object must not move"); - assert_eq!(line_start_x(&app, right), 0.0, "the other object's left edge should meet it"); + assert_eq!( + line_start_x(&app, left), + 0.0, + "the already-leftmost object must not move" + ); + assert_eq!( + line_start_x(&app, right), + 0.0, + "the other object's left edge should meet it" + ); app.dispatch_inquiry("ALIGNTOP", i); - assert_eq!(line_max_y(&app, left), 1.0, "the already-topmost object must not move"); - assert_eq!(line_max_y(&app, right), 1.0, "the other object's top edge should meet it"); + assert_eq!( + line_max_y(&app, left), + 1.0, + "the already-topmost object must not move" + ); + assert_eq!( + line_max_y(&app, right), + 1.0, + "the other object's top edge should meet it" + ); app.undo_steps(1); // undo ALIGNTOP - assert_eq!(line_max_y(&app, right), -2.0, "undo should restore the pre-ALIGNTOP position"); + assert_eq!( + line_max_y(&app, right), + -2.0, + "undo should restore the pre-ALIGNTOP position" + ); app.undo_steps(1); // undo ALIGNLEFT - assert_eq!(line_start_x(&app, right), 5.0, "undo should restore the original geometry"); + assert_eq!( + line_start_x(&app, right), + 5.0, + "undo should restore the original geometry" + ); } /// A complete, fully-selected group (`Scene::selected_object_units`) @@ -1891,8 +2013,12 @@ mod align_selected_bounds_tests { let a = add_line(&mut app, 5.0, 0.0, 6.0, 1.0); let b = add_line(&mut app, 7.0, 0.0, 8.0, 1.0); let other = add_line(&mut app, 0.0, 0.0, 1.0, 1.0); - app.tabs[app.active_tab].scene.create_group("pair".to_string(), vec![a, b]); - app.tabs[app.active_tab].scene.select_entities(&[a, b, other]); + app.tabs[app.active_tab] + .scene + .create_group("pair".to_string(), vec![a, b]); + app.tabs[app.active_tab] + .scene + .select_entities(&[a, b, other]); let i = app.active_tab; app.dispatch_inquiry("ALIGNLEFT", i); @@ -1901,7 +2027,11 @@ mod align_selected_bounds_tests { // transform — must shift by exactly the same amount, not collapse // onto `a`. assert_eq!(line_start_x(&app, a), 0.0); - assert_eq!(line_start_x(&app, b), 2.0, "b must keep its offset from a, not collapse onto it"); + assert_eq!( + line_start_x(&app, b), + 2.0, + "b must keep its offset from a, not collapse onto it" + ); } } @@ -1914,7 +2044,11 @@ mod find_replace_command_tests { /// taken from a case-mapped copy. #[test] fn find_replace_accepts_search_text_whose_case_mapping_changes_length() { - for cmd in ["FIND ı REPLACE x", "FINDALL fi REPLACE fi", "FIND ʼn replace n"] { + for cmd in [ + "FIND ı REPLACE x", + "FINDALL fi REPLACE fi", + "FIND ʼn replace n", + ] { let mut app = OpenCADStudio::new_for_test(); let i = app.active_tab; let _ = app.dispatch_inquiry(cmd, i); @@ -1942,18 +2076,37 @@ mod find_replace_command_tests { assert!(app.tabs[i].active_cmd.is_some()); // First point - let _ = app.tabs[i].active_cmd.as_mut().unwrap().on_point(glam::DVec3::new(0.0, 0.0, 0.0)); + let _ = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(glam::DVec3::new(0.0, 0.0, 0.0)); // Second point - let res = app.tabs[i].active_cmd.as_mut().unwrap().on_point(glam::DVec3::new(10.0, 0.0, 0.0)); + let res = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(glam::DVec3::new(10.0, 0.0, 0.0)); match res { crate::command::CmdResult::Measurement(msg) => { - assert!(msg.contains("Distance = 10"), "Expected 'Distance = 10', got: {msg}"); - assert!(msg.contains("Delta X = 10"), "Expected 'Delta X = 10', got: {msg}"); - assert!(msg.contains("Delta Y = 0"), "Expected 'Delta Y = 0', got: {msg}"); - assert!(msg.contains("Delta Z = 0"), "Expected 'Delta Z = 0', got: {msg}"); + assert!( + msg.contains("Distance = 10"), + "Expected 'Distance = 10', got: {msg}" + ); + assert!( + msg.contains("Delta X = 10"), + "Expected 'Delta X = 10', got: {msg}" + ); + assert!( + msg.contains("Delta Y = 0"), + "Expected 'Delta Y = 0', got: {msg}" + ); + assert!( + msg.contains("Delta Z = 0"), + "Expected 'Delta Z = 0', got: {msg}" + ); } _ => panic!("Expected CmdResult::Measurement"), } } } - diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index a54dec828..f3621dcb7 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -41,10 +41,12 @@ impl OpenCADStudio { "NEW" | "N" => { let name = parts.get(1).map(|s| s.trim()).unwrap_or("").to_string(); if name.is_empty() { - self.command_line.push_error(crate::t!("Usage: LAYER NEW ").as_ref()); - } else if self.tabs[i].scene.document.layers.contains(&name) { self.command_line - .push_error(crate::tf!("LAYER: '{}' already exists.", name).as_ref()); + .push_error(crate::t!("Usage: LAYER NEW ").as_ref()); + } else if self.tabs[i].scene.document.layers.contains(&name) { + self.command_line.push_error( + crate::tf!("LAYER: '{}' already exists.", name).as_ref(), + ); } else { let mut layer = Layer::new(&name); // Allocate a unique handle so the layer survives a @@ -66,7 +68,8 @@ impl OpenCADStudio { } self.push_undo_snapshot(i, "LAYER ON"); self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("LAYER: layers turned on.").as_ref()); + self.command_line + .push_output(crate::t!("LAYER: layers turned on.").as_ref()); } "OFF" => { for name in &parts[1..] { @@ -76,7 +79,8 @@ impl OpenCADStudio { } self.push_undo_snapshot(i, "LAYER OFF"); self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("LAYER: layers turned off.").as_ref()); + self.command_line + .push_output(crate::t!("LAYER: layers turned off.").as_ref()); } "FREEZE" | "FR" => { for name in &parts[1..] { @@ -86,7 +90,8 @@ impl OpenCADStudio { } self.push_undo_snapshot(i, "LAYER FREEZE"); self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("LAYER: layers frozen.").as_ref()); + self.command_line + .push_output(crate::t!("LAYER: layers frozen.").as_ref()); } "THAW" | "TH" => { for name in &parts[1..] { @@ -96,7 +101,8 @@ impl OpenCADStudio { } self.push_undo_snapshot(i, "LAYER THAW"); self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("LAYER: layers thawed.").as_ref()); + self.command_line + .push_output(crate::t!("LAYER: layers thawed.").as_ref()); } "LOCK" | "LO" => { for name in &parts[1..] { @@ -107,7 +113,8 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "LAYER LOCK"); self.tabs[i].dirty = true; self.refresh_properties(); - self.command_line.push_output(crate::t!("LAYER: layers locked.").as_ref()); + self.command_line + .push_output(crate::t!("LAYER: layers locked.").as_ref()); } "UNLOCK" | "UL" => { for name in &parts[1..] { @@ -118,7 +125,8 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "LAYER UNLOCK"); self.tabs[i].dirty = true; self.refresh_properties(); - self.command_line.push_output(crate::t!("LAYER: layers unlocked.").as_ref()); + self.command_line + .push_output(crate::t!("LAYER: layers unlocked.").as_ref()); } "COLOR" | "C" => { // LAYER COLOR @@ -136,30 +144,31 @@ impl OpenCADStudio { // this layer. The dependency index maps block // children back to their top-level INSERTs, so // unrelated entities stay warm. - self.tabs[i] - .scene - .invalidate_layer_dependencies(std::slice::from_ref( - &layer_name, - )); - self.command_line.push_output(crate::tf!( - "LAYER: '{}' color set to ACI {}.", - layer_name, idx - ).as_ref()); + self.tabs[i].scene.invalidate_layer_dependencies( + std::slice::from_ref(&layer_name), + ); + self.command_line.push_output( + crate::tf!("LAYER: '{}' color set to ACI {}.", layer_name, idx) + .as_ref(), + ); } else { - self.command_line - .push_error(crate::tf!("LAYER: '{}' not found.", layer_name).as_ref()); + self.command_line.push_error( + crate::tf!("LAYER: '{}' not found.", layer_name).as_ref(), + ); } } else { - self.command_line - .push_error(crate::t!("Usage: LAYER COLOR ").as_ref()); + self.command_line.push_error( + crate::t!("Usage: LAYER COLOR ").as_ref(), + ); } } "SET" | "S" | "CURRENT" => { let name = parts.get(1).map(|s| s.trim()).unwrap_or("").to_string(); if self.tabs[i].scene.document.layers.contains(&name) { self.tabs[i].layers.current_layer = name.clone(); - self.command_line - .push_output(crate::tf!("LAYER: current layer set to '{}'.", name).as_ref()); + self.command_line.push_output( + crate::tf!("LAYER: current layer set to '{}'.", name).as_ref(), + ); } else { self.command_line .push_error(crate::tf!("LAYER: '{}' not found.", name).as_ref()); @@ -206,22 +215,29 @@ impl OpenCADStudio { .map(|u| u.name.clone()) .collect(); if names.is_empty() { - self.command_line.push_output(crate::tf!( - "Active UCS: {} | No named UCSs defined.", - active_name - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "Active UCS: {} | No named UCSs defined.", + active_name + ) + .as_ref(), + ); } else { - self.command_line.push_output(crate::tf!( - "Active UCS: {} | Named: {}", - active_name, - names.join(", ") - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "Active UCS: {} | Named: {}", + active_name, + names.join(", ") + ) + .as_ref(), + ); } } "SAVE" | "S" => { let name = parts.get(2).map(|s| s.trim()).unwrap_or("").to_string(); if name.is_empty() { - self.command_line.push_error(crate::t!("Usage: UCS SAVE ").as_ref()); + self.command_line + .push_error(crate::t!("Usage: UCS SAVE ").as_ref()); } else { // Save the current active UCS under this name. let mut ucs = match &self.tabs[i].active_ucs { @@ -239,14 +255,8 @@ impl OpenCADStudio { .get(&name) .map(|existing| existing.handle) .filter(|handle| !handle.is_null()) - .unwrap_or_else(|| { - self.tabs[i].scene.document.allocate_handle() - }); - self.tabs[i] - .scene - .document - .ucss - .add_or_replace(ucs.clone()); + .unwrap_or_else(|| self.tabs[i].scene.document.allocate_handle()); + self.tabs[i].scene.document.ucss.add_or_replace(ucs.clone()); self.tabs[i].scene.bump_ucs_epoch(); self.tabs[i].active_ucs = Some(ucs); active_changed = true; @@ -258,9 +268,9 @@ impl OpenCADStudio { "DELETE" | "DEL" | "D" => { let name = parts.get(2).map(|s| s.trim()).unwrap_or("").to_string(); if name.is_empty() { - self.command_line.push_error(crate::t!("Usage: UCS DELETE ").as_ref()); - } else if let Some(removed) = - self.tabs[i].scene.document.ucss.remove(&name) + self.command_line + .push_error(crate::t!("Usage: UCS DELETE ").as_ref()); + } else if let Some(removed) = self.tabs[i].scene.document.ucss.remove(&name) { self.tabs[i].scene.bump_ucs_epoch(); let removed_handle = removed.handle; @@ -271,10 +281,11 @@ impl OpenCADStudio { } } } - let active_matches = self.tabs[i].active_ucs.as_ref().is_some_and(|ucs| { - (!removed_handle.is_null() && ucs.handle == removed_handle) - || ucs.name.eq_ignore_ascii_case(&name) - }); + let active_matches = + self.tabs[i].active_ucs.as_ref().is_some_and(|ucs| { + (!removed_handle.is_null() && ucs.handle == removed_handle) + || ucs.name.eq_ignore_ascii_case(&name) + }); if active_matches { if let Some(active) = self.tabs[i].active_ucs.as_mut() { active.name = "*ACTIVE*".to_string(); @@ -295,8 +306,9 @@ impl OpenCADStudio { "W" | "WORLD" => { self.tabs[i].active_ucs = None; active_changed = true; - self.command_line - .push_output(crate::t!("UCS reset to World Coordinate System.").as_ref()); + self.command_line.push_output( + crate::t!("UCS reset to World Coordinate System.").as_ref(), + ); } "VIEW" | "V" => { let rotation = self.tabs[i].scene.active_camera_rotation(); @@ -346,16 +358,23 @@ impl OpenCADStudio { } Some((handle, pick)) => { self.tabs[i].scene.restore_solid_models(&[handle]); - let built = self.tabs[i] - .scene - .solid_models - .get(&handle) - .and_then(|body| { - use crate::scene::model::solid_model; - let face = - solid_model::nearest_planar_face(body, pick.to_array())?; - solid_model::planar_face_ucs(body, face, pick.to_array()) - }); + let built = + self.tabs[i] + .scene + .solid_models + .get(&handle) + .and_then(|body| { + use crate::scene::model::solid_model; + let face = solid_model::nearest_planar_face( + body, + pick.to_array(), + )?; + solid_model::planar_face_ucs( + body, + face, + pick.to_array(), + ) + }); match built { Some(ucs) => { self.tabs[i].active_ucs = Some(ucs); @@ -395,7 +414,9 @@ impl OpenCADStudio { .scene .document .get_entity(handle) - .and_then(crate::scene::model::sweep_model::extrusion_profile_of) + .and_then( + crate::scene::model::sweep_model::extrusion_profile_of, + ) .and_then(|(profile, _)| { // The profile plane already carries the // entity's own in-plane axes, so adopt @@ -407,8 +428,7 @@ impl OpenCADStudio { let plane = profile.plane; let x = glam::DVec3::from_array(plane.x_axis); let y = glam::DVec3::from_array(plane.y_axis); - if x.length_squared() < 1e-12 - || y.length_squared() < 1e-12 + if x.length_squared() < 1e-12 || y.length_squared() < 1e-12 { return None; } @@ -418,10 +438,8 @@ impl OpenCADStudio { plane.origin[1], plane.origin[2], ); - ucs.x_axis = - Vector3::new(x.x, x.y, x.z); - ucs.y_axis = - Vector3::new(y.x, y.y, y.z); + ucs.x_axis = Vector3::new(x.x, x.y, x.z); + ucs.y_axis = Vector3::new(y.x, y.y, y.z); Some(ucs) }); match built { @@ -502,12 +520,18 @@ impl OpenCADStudio { wcs_origin.z as f64, ); active_changed = true; - self.command_line.push_output(crate::tf!( - "UCS origin set to ({:.4}, {:.4}, {:.4}).", - wcs_origin.x, wcs_origin.y, wcs_origin.z - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "UCS origin set to ({:.4}, {:.4}, {:.4}).", + wcs_origin.x, + wcs_origin.y, + wcs_origin.z + ) + .as_ref(), + ); } else { - self.command_line.push_error(crate::t!("Usage: UCS ORIGIN x,y,z").as_ref()); + self.command_line + .push_error(crate::t!("Usage: UCS ORIGIN x,y,z").as_ref()); } } // UCS Z angle — rotate active UCS around its Z axis by degrees @@ -517,9 +541,7 @@ impl OpenCADStudio { let rad = angle_deg.to_radians(); let current = self.tabs[i].active_ucs.as_ref(); let origin = current - .map(|u| { - glam::DVec3::new(u.origin.x, u.origin.y, u.origin.z) - }) + .map(|u| glam::DVec3::new(u.origin.x, u.origin.y, u.origin.z)) .unwrap_or(glam::DVec3::ZERO); let mut new_ucs = ucs_rotated_z(origin, rad); // If already had axes, compose rotation on top @@ -545,10 +567,12 @@ impl OpenCADStudio { } self.tabs[i].active_ucs = Some(new_ucs); active_changed = true; - self.command_line - .push_output(crate::tf!("UCS rotated {:.2}° around Z.", angle_deg).as_ref()); + self.command_line.push_output( + crate::tf!("UCS rotated {:.2}° around Z.", angle_deg).as_ref(), + ); } else { - self.command_line.push_error(crate::t!("Usage: UCS Z ").as_ref()); + self.command_line + .push_error(crate::t!("Usage: UCS Z ").as_ref()); } } // UCS X angle — rotate around current UCS X axis @@ -573,10 +597,12 @@ impl OpenCADStudio { let ny = rot * old_y; ucs.y_axis = Vector3::new(ny.x as f64, ny.y as f64, ny.z as f64); active_changed = true; - self.command_line - .push_output(crate::tf!("UCS rotated {:.2}° around X.", angle_deg).as_ref()); + self.command_line.push_output( + crate::tf!("UCS rotated {:.2}° around X.", angle_deg).as_ref(), + ); } else { - self.command_line.push_error(crate::t!("Usage: UCS X ").as_ref()); + self.command_line + .push_error(crate::t!("Usage: UCS X ").as_ref()); } } // UCS Y angle — rotate around current UCS Y axis @@ -601,10 +627,12 @@ impl OpenCADStudio { let nx = rot * old_x; ucs.x_axis = Vector3::new(nx.x as f64, nx.y as f64, nx.z as f64); active_changed = true; - self.command_line - .push_output(crate::tf!("UCS rotated {:.2}° around Y.", angle_deg).as_ref()); + self.command_line.push_output( + crate::tf!("UCS rotated {:.2}° around Y.", angle_deg).as_ref(), + ); } else { - self.command_line.push_error(crate::t!("Usage: UCS Y ").as_ref()); + self.command_line + .push_error(crate::t!("Usage: UCS Y ").as_ref()); } } _ => { @@ -641,8 +669,7 @@ impl OpenCADStudio { cmd if cmd == "CREATESKETCH" || cmd.starts_with("CREATESKETCH ") => { if self.tabs[i].sketch_session.is_some() { self.command_line.push_error( - crate::t!("A sketch is already open — FINISHSKETCH closes it.") - .as_ref(), + crate::t!("A sketch is already open — FINISHSKETCH closes it.").as_ref(), ); return Some(Task::none()); } @@ -661,10 +688,8 @@ impl OpenCADStudio { self.commit_active_ucs_change(i, "CREATESKETCH"); } else if !arg.is_empty() { self.command_line.push_error( - crate::t!( - "Usage: CREATESKETCH [XY|XZ|YZ]; bare uses the current UCS." - ) - .as_ref(), + crate::t!("Usage: CREATESKETCH [XY|XZ|YZ]; bare uses the current UCS.") + .as_ref(), ); return Some(Task::none()); } @@ -712,16 +737,19 @@ impl OpenCADStudio { "" | "CURRENT" | "C" => { let r = self.tabs[i].scene.viewcube_ucs_mat(); self.plan_snap(i, r); - self.command_line.push_output(crate::t!("PLAN: current UCS plan view.").as_ref()); + self.command_line + .push_output(crate::t!("PLAN: current UCS plan view.").as_ref()); } "WORLD" | "W" => { self.plan_snap(i, glam::Mat4::IDENTITY); - self.command_line.push_output(crate::t!("PLAN: world plan view.").as_ref()); + self.command_line + .push_output(crate::t!("PLAN: world plan view.").as_ref()); } "UCS" | "U" => { let name = parts.get(2).map(|s| s.trim()).unwrap_or(""); if name.is_empty() { - self.command_line.push_info(crate::t!("Usage: PLAN UCS ").as_ref()); + self.command_line + .push_info(crate::t!("Usage: PLAN UCS ").as_ref()); } else { let ucs = self.tabs[i] .scene @@ -732,13 +760,12 @@ impl OpenCADStudio { .cloned(); match ucs { Some(u) => { - let r = crate::app::helpers::UcsXform::from_ucs(&u) - .rotation_mat(); + let r = + crate::app::helpers::UcsXform::from_ucs(&u).rotation_mat(); self.plan_snap(i, r); - self.command_line.push_output(crate::tf!( - "PLAN: plan view of UCS '{}'.", - u.name - ).as_ref()); + self.command_line.push_output( + crate::tf!("PLAN: plan view of UCS '{}'.", u.name).as_ref(), + ); } None => self .command_line @@ -746,9 +773,9 @@ impl OpenCADStudio { } } } - _ => self - .command_line - .push_info(crate::t!("Usage: PLAN [CURRENT / UCS / WORLD]").as_ref()), + _ => self.command_line.push_info( + crate::t!("Usage: PLAN [CURRENT / UCS / WORLD]").as_ref(), + ), } } @@ -780,16 +807,19 @@ impl OpenCADStudio { .map(|v| v.name.clone()) .collect(); if views.is_empty() { - self.command_line.push_output(crate::t!("No named views saved.").as_ref()); - } else { self.command_line - .push_output(crate::tf!("Named views: {}", views.join(", ")).as_ref()); + .push_output(crate::t!("No named views saved.").as_ref()); + } else { + self.command_line.push_output( + crate::tf!("Named views: {}", views.join(", ")).as_ref(), + ); } } "SAVE" | "S" => { let name = parts.get(2).map(|s| s.trim()).unwrap_or("").to_string(); if name.is_empty() { - self.command_line.push_error(crate::t!("Usage: VIEW SAVE ").as_ref()); + self.command_line + .push_error(crate::t!("Usage: VIEW SAVE ").as_ref()); } else { let new_view = self.tabs[i].scene.current_as_named_view(&name); self.tabs[i].scene.document.views.add_or_replace(new_view); @@ -799,31 +829,51 @@ impl OpenCADStudio { } "WINDOW" | "W" => { // A plan view of the window between two corners. - let args: Vec<&str> = parts.get(2).map(|s| s.split_whitespace().collect()).unwrap_or_default(); + let args: Vec<&str> = parts + .get(2) + .map(|s| s.split_whitespace().collect()) + .unwrap_or_default(); let corner = |s: &str| { let mut c = s.split(','); - Some((c.next()?.trim().parse::().ok()?, c.next()?.trim().parse::().ok()?)) + Some(( + c.next()?.trim().parse::().ok()?, + c.next()?.trim().parse::().ok()?, + )) }; - match (args.first(), args.get(1).and_then(|s| corner(s)), args.get(2).and_then(|s| corner(s))) { + match ( + args.first(), + args.get(1).and_then(|s| corner(s)), + args.get(2).and_then(|s| corner(s)), + ) { (Some(name), Some(a), Some(b)) if a.0 != b.0 && a.1 != b.1 => { use codec::types::Vector3; let mut view = self.tabs[i].scene.current_as_named_view(name); - view.target = Vector3 { x: (a.0 + b.0) / 2.0, y: (a.1 + b.1) / 2.0, z: 0.0 }; - view.direction = Vector3 { x: 0.0, y: 0.0, z: 1.0 }; + view.target = Vector3 { + x: (a.0 + b.0) / 2.0, + y: (a.1 + b.1) / 2.0, + z: 0.0, + }; + view.direction = Vector3 { + x: 0.0, + y: 0.0, + z: 1.0, + }; view.width = (b.0 - a.0).abs(); view.height = (b.1 - a.1).abs(); self.tabs[i].scene.document.views.add_or_replace(view); - self.command_line.push_output(crate::tf!("View '{}' saved.", name).as_ref()); + self.command_line + .push_output(crate::tf!("View '{}' saved.", name).as_ref()); } - _ => self - .command_line - .push_error(crate::t!("Usage: VIEW WINDOW ").as_ref()), + _ => self.command_line.push_error( + crate::t!("Usage: VIEW WINDOW ").as_ref(), + ), } } "DELETE" | "DEL" | "D" => { let name = parts.get(2).map(|s| s.trim()).unwrap_or("").to_string(); if name.is_empty() { - self.command_line.push_error(crate::t!("Usage: VIEW DELETE ").as_ref()); + self.command_line + .push_error(crate::t!("Usage: VIEW DELETE ").as_ref()); } else { if self.tabs[i].scene.document.views.remove(&name).is_some() { self.command_line @@ -837,13 +887,15 @@ impl OpenCADStudio { "RESTORE" | "R" => { let name = parts.get(2).map(|s| s.trim()).unwrap_or("").to_string(); if name.is_empty() { - self.command_line.push_error(crate::t!("Usage: VIEW RESTORE ").as_ref()); + self.command_line + .push_error(crate::t!("Usage: VIEW RESTORE ").as_ref()); } else { let found = self.tabs[i].scene.document.views.get(&name).cloned(); if let Some(v) = found { self.tabs[i].scene.restore_named_view(&v); - self.command_line - .push_output(crate::tf!("View '{}' restored.", v.name).as_ref()); + self.command_line.push_output( + crate::tf!("View '{}' restored.", v.name).as_ref(), + ); } else { self.command_line .push_error(crate::tf!("View '{}' not found.", name).as_ref()); @@ -932,36 +984,42 @@ impl OpenCADStudio { .map(|s| { crate::tf!( "{} (h_margin:{:.2} v_margin:{:.2})", - s.name, s.horizontal_margin, s.vertical_margin - ).into_owned() + s.name, + s.horizontal_margin, + s.vertical_margin + ) + .into_owned() }) .collect(); if styles.is_empty() { - self.command_line.push_output(crate::t!("No table styles.").as_ref()); - } else { self.command_line - .push_output(crate::tf!("TableStyles:\n {}", styles.join("\n ")).as_ref()); + .push_output(crate::t!("No table styles.").as_ref()); + } else { + self.command_line.push_output( + crate::tf!("TableStyles:\n {}", styles.join("\n ")).as_ref(), + ); } } "NEW" | "N" => { let name = parts.get(1).copied().unwrap_or("").to_string(); if name.is_empty() { - self.command_line.push_error(crate::t!("Usage: TABLESTYLE NEW ").as_ref()); + self.command_line + .push_error(crate::t!("Usage: TABLESTYLE NEW ").as_ref()); } else { let doc = &self.tabs[i].scene.document; let exists = doc.objects.values().any(|o| { matches!(o, ObjectType::TableStyle(s) if s.name.eq_ignore_ascii_case(&name)) }); if exists { - self.command_line - .push_error(crate::tf!("TABLESTYLE: '{}' already exists.", name).as_ref()); + self.command_line.push_error( + crate::tf!("TABLESTYLE: '{}' already exists.", name).as_ref(), + ); } else { self.push_undo_snapshot(i, "TABLESTYLE NEW"); let mut style = TableStyle::standard(); style.name = name.clone(); - let nh = codec::Handle::new( - self.tabs[i].scene.document.next_handle(), - ); + let nh = + codec::Handle::new(self.tabs[i].scene.document.next_handle()); style.handle = nh; self.tabs[i] .scene @@ -969,8 +1027,9 @@ impl OpenCADStudio { .objects .insert(nh, ObjectType::TableStyle(style)); self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("TABLESTYLE: '{}' created.", name).as_ref()); + self.command_line.push_output( + crate::tf!("TABLESTYLE: '{}' created.", name).as_ref(), + ); } } } @@ -1016,31 +1075,34 @@ impl OpenCADStudio { }) .collect(); if styles.is_empty() { - self.command_line.push_output(crate::t!("No multiline styles.").as_ref()); - } else { self.command_line - .push_output(crate::tf!("MLineStyles:\n {}", styles.join("\n ")).as_ref()); + .push_output(crate::t!("No multiline styles.").as_ref()); + } else { + self.command_line.push_output( + crate::tf!("MLineStyles:\n {}", styles.join("\n ")).as_ref(), + ); } } "NEW" | "N" => { let name = parts.get(1).copied().unwrap_or("").to_string(); if name.is_empty() { - self.command_line.push_error(crate::t!("Usage: MLSTYLE NEW ").as_ref()); + self.command_line + .push_error(crate::t!("Usage: MLSTYLE NEW ").as_ref()); } else { let doc = &self.tabs[i].scene.document; let exists = doc.objects.values().any(|o| { matches!(o, ObjectType::MLineStyle(s) if s.name.eq_ignore_ascii_case(&name)) }); if exists { - self.command_line - .push_error(crate::tf!("MLSTYLE: '{}' already exists.", name).as_ref()); + self.command_line.push_error( + crate::tf!("MLSTYLE: '{}' already exists.", name).as_ref(), + ); } else { self.push_undo_snapshot(i, "MLSTYLE NEW"); let mut style = MLineStyle::standard(); style.name = name.clone(); - let nh = codec::Handle::new( - self.tabs[i].scene.document.next_handle(), - ); + let nh = + codec::Handle::new(self.tabs[i].scene.document.next_handle()); style.handle = nh; self.tabs[i] .scene @@ -1048,15 +1110,17 @@ impl OpenCADStudio { .objects .insert(nh, ObjectType::MLineStyle(style)); self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("MLSTYLE: '{}' created.", name).as_ref()); + self.command_line.push_output( + crate::tf!("MLSTYLE: '{}' created.", name).as_ref(), + ); } } } "SET" | "S" => { let name = parts.get(1).copied().unwrap_or("").to_string(); if name.is_empty() { - self.command_line.push_error(crate::t!("Usage: MLSTYLE SET ").as_ref()); + self.command_line + .push_error(crate::t!("Usage: MLSTYLE SET ").as_ref()); } else { let doc = &self.tabs[i].scene.document; let exists = doc.objects.values().any(|o| { @@ -1066,21 +1130,23 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "MLSTYLE SET"); self.tabs[i].scene.document.header.multiline_style = name.clone(); self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "MLSTYLE: current style set to '{}'.", - name - ).as_ref()); + self.command_line.push_output( + crate::tf!("MLSTYLE: current style set to '{}'.", name) + .as_ref(), + ); } else { - self.command_line - .push_error(crate::tf!("MLSTYLE: '{}' not found.", name).as_ref()); + self.command_line.push_error( + crate::tf!("MLSTYLE: '{}' not found.", name).as_ref(), + ); } } } "DEL" | "DELETE" => { let name = parts.get(1).copied().unwrap_or("").to_string(); if name.is_empty() || name.eq_ignore_ascii_case("Standard") { - self.command_line - .push_error(crate::t!("Cannot delete the Standard style.").as_ref()); + self.command_line.push_error( + crate::t!("Cannot delete the Standard style.").as_ref(), + ); } else { let doc = &self.tabs[i].scene.document; let handle = doc.objects.iter().find_map(|(&h, o)| { @@ -1098,17 +1164,21 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "MLSTYLE DEL"); self.tabs[i].scene.document.objects.remove(&h); self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("MLSTYLE: '{}' deleted.", name).as_ref()); + self.command_line.push_output( + crate::tf!("MLSTYLE: '{}' deleted.", name).as_ref(), + ); } else { - self.command_line - .push_error(crate::tf!("MLSTYLE: '{}' not found.", name).as_ref()); + self.command_line.push_error( + crate::tf!("MLSTYLE: '{}' not found.", name).as_ref(), + ); } } } _ => { - self.command_line - .push_error(crate::t!("Usage: MLSTYLE [LIST|NEW |SET |DEL ]").as_ref()); + self.command_line.push_error( + crate::t!("Usage: MLSTYLE [LIST|NEW |SET |DEL ]") + .as_ref(), + ); } } } @@ -1133,22 +1203,29 @@ impl OpenCADStudio { .document .dim_styles .iter() - .map(|s| crate::tf!("{}(txt:{:.2} asz:{:.2})", s.name, s.dimtxt, s.dimasz).into_owned()) + .map(|s| { + crate::tf!("{}(txt:{:.2} asz:{:.2})", s.name, s.dimtxt, s.dimasz) + .into_owned() + }) .collect(); if styles.is_empty() { - self.command_line.push_output(crate::t!("No dim styles defined.").as_ref()); - } else { self.command_line - .push_output(crate::tf!("DimStyles: {}", styles.join(", ")).as_ref()); + .push_output(crate::t!("No dim styles defined.").as_ref()); + } else { + self.command_line.push_output( + crate::tf!("DimStyles: {}", styles.join(", ")).as_ref(), + ); } } "NEW" | "N" => { let name = parts.get(1).map(|s| s.trim()).unwrap_or("").to_string(); if name.is_empty() { - self.command_line.push_error(crate::t!("Usage: DIMSTYLE NEW ").as_ref()); - } else if self.tabs[i].scene.document.dim_styles.contains(&name) { self.command_line - .push_error(crate::tf!("DIMSTYLE: '{}' already exists.", name).as_ref()); + .push_error(crate::t!("Usage: DIMSTYLE NEW ").as_ref()); + } else if self.tabs[i].scene.document.dim_styles.contains(&name) { + self.command_line.push_error( + crate::tf!("DIMSTYLE: '{}' already exists.", name).as_ref(), + ); } else { let undo = self.begin_dim_style_undo( i, @@ -1226,24 +1303,31 @@ impl OpenCADStudio { } } self.tabs[i].dirty = true; - self.tabs[i].scene + self.tabs[i] + .scene .invalidate_dim_style_dependencies(&style_name); self.commit_dim_style_undo(i, undo); - self.command_line.push_output(crate::tf!( - "DIMSTYLE: '{style_name}'.{prop} = {val:.3}" - ).as_ref()); + self.command_line.push_output( + crate::tf!("DIMSTYLE: '{style_name}'.{prop} = {val:.3}") + .as_ref(), + ); } else { - self.command_line - .push_error(crate::tf!("DIMSTYLE: '{}' not found.", style_name).as_ref()); + self.command_line.push_error( + crate::tf!("DIMSTYLE: '{}' not found.", style_name).as_ref(), + ); } } else { - self.command_line - .push_error(crate::t!("Usage: DIMSTYLE SET ").as_ref()); + self.command_line.push_error( + crate::t!("Usage: DIMSTYLE SET ").as_ref(), + ); } } _ => { self.command_line.push_info( - crate::t!("Usage: DIMSTYLE LIST | NEW | SET ").as_ref(), + crate::t!( + "Usage: DIMSTYLE LIST | NEW | SET " + ) + .as_ref(), ); } } @@ -1267,10 +1351,15 @@ impl OpenCADStudio { .values() .filter_map(|o| { if let ObjectType::MultiLeaderStyle(s) = o { - Some(crate::tf!( - "{}(txt:{:.2} asz:{:.2})", - s.name, s.text_height, s.arrowhead_size - ).into_owned()) + Some( + crate::tf!( + "{}(txt:{:.2} asz:{:.2})", + s.name, + s.text_height, + s.arrowhead_size + ) + .into_owned(), + ) } else { None } @@ -1278,13 +1367,17 @@ impl OpenCADStudio { .collect(); let current = &self.tabs[i].active_mleader_style; if styles.is_empty() { - self.command_line - .push_output(crate::tf!("MLeader styles: (none) active: {current}").as_ref()); + self.command_line.push_output( + crate::tf!("MLeader styles: (none) active: {current}").as_ref(), + ); } else { - self.command_line.push_output(crate::tf!( - "MLeader styles: {} active: {current}", - styles.join(", ") - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "MLeader styles: {} active: {current}", + styles.join(", ") + ) + .as_ref(), + ); } } "NEW" | "N" => { @@ -1297,10 +1390,9 @@ impl OpenCADStudio { |o| matches!(o, ObjectType::MultiLeaderStyle(s) if s.name == name), ); if already_exists { - self.command_line.push_error(crate::tf!( - "MLEADERSTYLE: '{}' already exists.", - name - ).as_ref()); + self.command_line.push_error( + crate::tf!("MLEADERSTYLE: '{}' already exists.", name).as_ref(), + ); } else { let handle = self.tabs[i].scene.document.allocate_handle(); let mut style = MultiLeaderStyle::new(&name); @@ -1312,8 +1404,9 @@ impl OpenCADStudio { .insert(handle, ObjectType::MultiLeaderStyle(style)); self.push_undo_snapshot(i, "MLEADERSTYLE NEW"); self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("MLEADERSTYLE: '{}' created.", name).as_ref()); + self.command_line.push_output( + crate::tf!("MLEADERSTYLE: '{}' created.", name).as_ref(), + ); } } } @@ -1363,38 +1456,46 @@ impl OpenCADStudio { } self.push_undo_snapshot(i, "MLEADERSTYLE SET"); self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "MLEADERSTYLE: '{style_name}'.{prop} = {val:.3}" - ).as_ref()); + self.command_line.push_output( + crate::tf!("MLEADERSTYLE: '{style_name}'.{prop} = {val:.3}") + .as_ref(), + ); } else { - self.command_line.push_error(crate::tf!( - "MLEADERSTYLE: '{}' not found.", - style_name - ).as_ref()); + self.command_line.push_error( + crate::tf!("MLEADERSTYLE: '{}' not found.", style_name) + .as_ref(), + ); } } else { - self.command_line - .push_error(crate::t!("Usage: MLEADERSTYLE SET ").as_ref()); + self.command_line.push_error( + crate::t!("Usage: MLEADERSTYLE SET ") + .as_ref(), + ); } } "CURRENT" | "C" | "ACTIVE" => { let name = parts.get(1).map(|s| s.trim()).unwrap_or("").to_string(); if name.is_empty() { - self.command_line.push_output(crate::tf!( - "Current MLeader style: {}", - self.tabs[i].active_mleader_style - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "Current MLeader style: {}", + self.tabs[i].active_mleader_style + ) + .as_ref(), + ); } else { let exists = name == "Standard" || self.tabs[i].scene.document.objects.values() .any(|o| matches!(o, ObjectType::MultiLeaderStyle(s) if s.name == name)); if exists { self.tabs[i].active_mleader_style = name.clone(); - self.command_line.push_output(crate::tf!( - "MLEADERSTYLE: current style set to '{name}'." - ).as_ref()); + self.command_line.push_output( + crate::tf!("MLEADERSTYLE: current style set to '{name}'.") + .as_ref(), + ); } else { - self.command_line - .push_error(crate::tf!("MLEADERSTYLE: '{}' not found.", name).as_ref()); + self.command_line.push_error( + crate::tf!("MLEADERSTYLE: '{}' not found.", name).as_ref(), + ); } } } @@ -1436,25 +1537,30 @@ impl OpenCADStudio { s.font_file, s.width_factor, s.oblique_angle.to_degrees() - ).into_owned() + ) + .into_owned() }) .collect(); if styles.is_empty() { - self.command_line.push_output(crate::t!("No text styles defined.").as_ref()); - } else { self.command_line - .push_output(crate::tf!("Text styles: {}", styles.join(" | ")).as_ref()); + .push_output(crate::t!("No text styles defined.").as_ref()); + } else { + self.command_line.push_output( + crate::tf!("Text styles: {}", styles.join(" | ")).as_ref(), + ); } } "SET" | "S" => { // STYLE SET — set active text style (for future text commands) let name = parts.get(1).map(|s| s.trim()).unwrap_or(""); if self.tabs[i].scene.document.text_styles.get(name).is_some() { - self.command_line - .push_output(crate::tf!("{prefix}: active style set to '{name}'.").as_ref()); + self.command_line.push_output( + crate::tf!("{prefix}: active style set to '{name}'.").as_ref(), + ); } else { - self.command_line - .push_error(crate::tf!("{prefix}: style '{name}' not found.").as_ref()); + self.command_line.push_error( + crate::tf!("{prefix}: style '{name}' not found.").as_ref(), + ); } } "NEW" | "N" => { @@ -1463,8 +1569,9 @@ impl OpenCADStudio { self.command_line .push_error(crate::tf!("Usage: {prefix} NEW ").as_ref()); } else if self.tabs[i].scene.document.text_styles.contains(&name) { - self.command_line - .push_error(crate::tf!("{prefix}: style '{name}' already exists.").as_ref()); + self.command_line.push_error( + crate::tf!("{prefix}: style '{name}' already exists.").as_ref(), + ); } else { let undo = self.begin_text_style_undo( i, @@ -1475,8 +1582,9 @@ impl OpenCADStudio { let _ = self.tabs[i].scene.document.text_styles.add(style); self.tabs[i].dirty = true; self.commit_text_style_undo(i, undo); - self.command_line - .push_output(crate::tf!("{prefix}: style '{name}' created.").as_ref()); + self.command_line.push_output( + crate::tf!("{prefix}: style '{name}' created.").as_ref(), + ); } } "FONT" | "F" => { @@ -1484,8 +1592,9 @@ impl OpenCADStudio { let style_name = parts.get(1).map(|s| s.trim()).unwrap_or("").to_string(); let font = parts.get(2).map(|s| s.trim()).unwrap_or("").to_string(); if style_name.is_empty() || font.is_empty() { - self.command_line - .push_error(crate::tf!("Usage: {prefix} FONT