From 09e4792a0a861edeb2242bfe5c56417f62790433 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:24:33 +0000 Subject: [PATCH] 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(