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() } }