From 7bfb6177455b42cc6a48fee1eea65a4e6d50fa07 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 11 Aug 2026 23:09:52 +0100 Subject: [PATCH] feat(dock): stacked edge rendering and drag-to-reorder preview --- src/app/config.rs | 24 +- src/app/mod.rs | 39 --- src/app/update/dialog.rs | 20 +- src/app/update/file.rs | 12 - src/app/update/mod.rs | 109 --------- src/app/view/mod.rs | 505 ++++++++++++++++++++++++--------------- src/ui/dock.rs | 58 ----- src/ui/properties.rs | 7 +- 8 files changed, 326 insertions(+), 448 deletions(-) diff --git a/src/app/config.rs b/src/app/config.rs index d9debc1a1..dab59c952 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -27,11 +27,8 @@ pub struct AppConfig { pub start: StartConfig, /// Which status-bar pills the user has hidden. pub statusbar: StatusBarConfig, - /// Dock position, width and auto-collapse behavior of the Properties panel. - pub properties: PropertiesDockConfig, /// General edge-stack dock layout (which panels are docked, side, order, - /// width and auto-collapse). Newer dock state; the Properties dock is - /// folded into this when its panel is migrated. + /// width and auto-collapse) for the Properties panel and block palette. pub dock: crate::ui::dock::DockState, /// Add a newly selected annotation scale to existing annotative objects. pub annotation_auto_scale: i8, @@ -52,7 +49,6 @@ impl Default for AppConfig { recent: RecentConfig::default(), start: StartConfig::default(), statusbar: StatusBarConfig::default(), - properties: PropertiesDockConfig::default(), dock: crate::ui::dock::DockState::default(), annotation_auto_scale: -4, ribbon: RibbonConfig::default(), @@ -82,24 +78,6 @@ pub enum DockSide { Right, } -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PropertiesDockConfig { - pub side: DockSide, - pub width: f32, - pub auto_collapse: bool, -} - -impl Default for PropertiesDockConfig { - fn default() -> Self { - Self { - side: DockSide::Left, - width: 250.0, - auto_collapse: false, - } - } -} - #[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct UiThemeConfig { diff --git a/src/app/mod.rs b/src/app/mod.rs index 04d362327..a4d83243f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -340,19 +340,6 @@ pub(super) struct OpenCADStudio { /// Widest natural single-row width of the Start-page action buttons, /// measured by `WrapFlow` so side lists collapse before those buttons wrap. start_action_w: std::sync::Arc, - /// When the window is too narrow the properties panel collapses to a - /// vertical bar; this is the user's toggle to expand it back out. - props_expanded: bool, - /// Persisted dock width and side of the Properties panel. - properties_width: f32, - properties_side: config::DockSide, - properties_auto_collapse: bool, - /// Transient hover/drag state for the docked Properties panel. - properties_hovered: bool, - properties_dragging: bool, - properties_resizing: bool, - properties_drag_last: Option, - properties_dock_preview: Option, /// Read-only editor buffer backing the command-line history dropdown, so /// the log can be drag-selected across lines and copied (issue #232). /// Rebuilt from the history each time the dropdown is opened. @@ -1771,23 +1758,6 @@ pub enum Message { OpenUrl(String), /// Select which section a narrow (tabbed) Start page shows. StartSectionSelect(StartSection), - /// Expand/collapse the properties panel when it has shrunk to a bar. - TogglePropertiesBar, - /// Close the docked Properties panel; the ribbon command can reopen it. - PropertiesClose, - /// Pin/unpin the docked Properties panel. - PropertiesAutoCollapseToggle, - /// Hover state drives expansion while auto-collapse is enabled. - PropertiesHover(bool), - /// Begin dragging the Properties title bar to the opposite dock edge. - PropertiesDockGrab, - /// Begin dragging the Properties/viewport divider. - PropertiesResizeGrab, - /// Reset the dock width to its default value. - PropertiesWidthReset, - /// Full-workspace pointer tracking shared by dock and resize drags. - PropertiesDragMove(Point), - PropertiesDragRelease, /// Scroll the status-bar layout-tab strip horizontally by `delta` px /// (negative = left). Driven by the ‹ › arrows next to the tabs. ScrollLayoutTabs(f32), @@ -3072,15 +3042,6 @@ impl OpenCADStudio { props_asym_scale: std::collections::HashSet::new(), start_section: StartSection::default(), start_action_w: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)), - props_expanded: false, - properties_width: 250.0, - properties_side: config::DockSide::Left, - properties_auto_collapse: false, - properties_hovered: false, - properties_dragging: false, - properties_resizing: false, - properties_drag_last: None, - properties_dock_preview: None, history_content: iced::widget::text_editor::Content::new(), command_history_resizing: false, command_history_drag_last: None, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 9e4e99bc1..fb87dbfa5 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -599,7 +599,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.show_block_palette = false; self.block_palette.placing = None; } - PanelId::Properties => self.show_properties = false, + PanelId::Properties => { + self.show_properties = false; + self.ribbon.set_properties(false); + } } if self.dock_expanded == Some(id) { self.dock_expanded = None; @@ -613,13 +616,20 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven iced::Task::none() } DockMsg::Hover(id) => { - self.dock_expanded = Some(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. + if self.dock_dragging.is_none() && self.dock_resizing.is_none() { + self.dock_expanded = Some(id); + } iced::Task::none() } DockMsg::HoverExit => { - if let Some(id) = self.dock_expanded { - if self.dock.auto_collapse(id) { - self.dock_expanded = None; + if self.dock_dragging.is_none() && self.dock_resizing.is_none() { + if let Some(id) = self.dock_expanded { + if self.dock.auto_collapse(id) { + self.dock_expanded = None; + } } } iced::Task::none() diff --git a/src/app/update/file.rs b/src/app/update/file.rs index fb42fe1df..eec6232dd 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -553,11 +553,6 @@ impl OpenCADStudio { section: self.start_section, }, statusbar: self.statusbar_config.clone(), - properties: crate::app::config::PropertiesDockConfig { - side: self.properties_side, - width: self.properties_width, - auto_collapse: self.properties_auto_collapse, - }, dock: { let mut dock = self.dock.clone(); dock.ensure_settings(); @@ -600,13 +595,6 @@ impl OpenCADStudio { // (`refresh_recent_thumbs`) — never here on the boot path. self.start_section = cfg.start.section; self.statusbar_config = cfg.statusbar; - self.properties_side = cfg.properties.side; - self.properties_width = if cfg.properties.width.is_finite() { - cfg.properties.width.clamp(220.0, 600.0) - } else { - 250.0 - }; - self.properties_auto_collapse = cfg.properties.auto_collapse; let mut dock = cfg.dock; dock.ensure_settings(); self.dock = dock; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index bdf861e8a..f87004527 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -377,115 +377,6 @@ impl OpenCADStudio { Task::none() } - Message::TogglePropertiesBar => { - self.props_expanded = !self.props_expanded; - Task::none() - } - - Message::PropertiesClose => { - self.show_properties = false; - self.properties_hovered = false; - self.properties_dragging = false; - self.properties_resizing = false; - self.properties_drag_last = None; - self.properties_dock_preview = None; - self.ribbon.set_properties(false); - Task::none() - } - - Message::PropertiesAutoCollapseToggle => { - self.properties_auto_collapse ^= true; - // Keep the panel open while the pointer is still over the - // button; leaving the panel performs the first collapse. - self.properties_hovered = self.properties_auto_collapse; - self.save_config(); - Task::none() - } - - Message::PropertiesHover(hovered) => { - if self.properties_auto_collapse - && !self.properties_dragging - && !self.properties_resizing - { - self.properties_hovered = hovered; - } - Task::none() - } - - Message::PropertiesDockGrab => { - self.properties_dragging = true; - self.properties_resizing = false; - self.properties_drag_last = None; - self.properties_dock_preview = Some(self.properties_side); - self.properties_hovered = true; - Task::none() - } - - Message::PropertiesResizeGrab => { - self.properties_resizing = true; - self.properties_dragging = false; - self.properties_drag_last = None; - self.properties_dock_preview = None; - self.properties_hovered = true; - Task::none() - } - - Message::PropertiesWidthReset => { - self.properties_width = 250.0; - self.save_config(); - Task::none() - } - - Message::PropertiesDragMove(point) => { - if self.properties_dragging { - self.properties_dock_preview = Some(if point.x < self.win_size.0 * 0.5 { - crate::app::config::DockSide::Left - } else { - crate::app::config::DockSide::Right - }); - } else if self.properties_resizing { - if let Some(last) = self.properties_drag_last { - let dx = point.x - last.x; - let delta = match self.properties_side { - crate::app::config::DockSide::Left => dx, - crate::app::config::DockSide::Right => -dx, - }; - let max_width = (self.win_size.0 * 0.45).clamp(220.0, 600.0); - self.properties_width = - (self.properties_width + delta).clamp(220.0, max_width); - } - } - if self.properties_dragging || self.properties_resizing { - self.properties_drag_last = Some(point); - } - Task::none() - } - - Message::PropertiesDragRelease => { - let changed = if self.properties_dragging { - if let Some(side) = self.properties_dock_preview { - let changed = side != self.properties_side; - self.properties_side = side; - changed - } else { - false - } - } else { - self.properties_resizing - }; - self.properties_dragging = false; - self.properties_resizing = false; - self.properties_drag_last = None; - self.properties_dock_preview = None; - if self.properties_auto_collapse { - self.properties_hovered = false; - } - if changed { - self.save_config(); - } - Task::none() - } - Message::ScrollLayoutTabs(dx) => iced::widget::operation::scroll_by( iced::advanced::widget::Id::new(crate::ui::statusbar::LAYOUT_TABS_SCROLL_ID), iced::widget::scrollable::AbsoluteOffset { x: dx, y: 0.0 }, diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index a388e9612..cc375fd51 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1458,92 +1458,36 @@ impl OpenCADStudio { } } - // Docked Properties panel. It keeps its pixel width when moved between - // edges; auto-collapse swaps the full panel for a hoverable rail. - let show_properties = !tab.is_start && self.show_properties && !self.clean_screen; - let properties_width = self - .properties_width - .min((self.win_size.0 * 0.45).clamp(220.0, 600.0)); - let properties_el: Option> = show_properties.then(|| { - let narrow_collapsed = self.win_size.0 < 1000.0 - && !self.props_expanded - && !self.properties_hovered; - let auto_collapsed = self.properties_auto_collapse - && !self.properties_hovered - && !self.properties_dragging - && !self.properties_resizing; - if narrow_collapsed || auto_collapsed { - collapse_bar( - "Properties", - self.properties_side, - Message::TogglePropertiesBar, - Message::PropertiesHover(true), - 26.0, - ) - } else { - let panel = tab - .properties - .view(properties_width, self.properties_auto_collapse); - let divider = properties_divider(); - let group: Element<'_, Message> = match self.properties_side { - crate::app::config::DockSide::Left => row![panel, divider].into(), - crate::app::config::DockSide::Right => row![divider, panel].into(), - }; - mouse_area(group) - .on_enter(Message::PropertiesHover(true)) - .on_exit(Message::PropertiesHover(false)) - .into() + // 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; } - }); - - // The Block Palette docks through the general edge-stack dock (side, - // width, auto-collapse are persisted on `self.dock`). When auto-collapsing - // and not hovered it reduces to a rail; otherwise it renders full-height - // with themed pin/close chrome and a grabbable, resizable header. - let block_palette_el: Option> = if !tab.is_start - && self.show_block_palette - && !self.clean_screen - { - let id = crate::ui::dock::PanelId::BlockPalette; - let side = self - .dock - .location(id) - .map(|(s, _)| s) - .unwrap_or(crate::app::config::DockSide::Right); - let width = self.dock.width(id, self.win_size.0); - let auto_collapse = self.dock.auto_collapse(id); - let expanded = self.dock_expanded == Some(id); - if auto_collapse && !expanded { - Some(collapse_bar( - "Block Palette", - side, - Message::Dock(crate::ui::dock::DockMsg::Hover(id)), - Message::Dock(crate::ui::dock::DockMsg::Hover(id)), - 26.0, - )) - } else { - let panel = - crate::ui::window::block_palette::view(&self.block_palette, width, auto_collapse); - let divider = dock_divider(id); - let group: Element<'_, Message> = match side { - crate::app::config::DockSide::Left => row![panel, divider].into(), - crate::app::config::DockSide::Right => row![divider, panel].into(), - }; - Some( - mouse_area(group) - .on_enter(Message::Dock(crate::ui::dock::DockMsg::Hover(id))) - .on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit)) - .into(), - ) + match id { + crate::ui::dock::PanelId::Properties => self.show_properties, + crate::ui::dock::PanelId::BlockPalette => self.show_block_palette, } - } else { - None }; - let bp_side = self - .dock - .location(crate::ui::dock::PanelId::BlockPalette) - .map(|(s, _)| s) - .unwrap_or(crate::app::config::DockSide::Right); + 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); // Command-line sits as a bottom-centre overlay on top of the // viewport stack rather than as a separate row in the main @@ -1562,52 +1506,93 @@ impl OpenCADStudio { && !tab.dyn_fields.is_empty()) || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) || self.text_inline.is_some(); - let properties_side = self.properties_side; - // Combine the Properties panel and Block Palette into one row with the - // viewport, each placed on its own dock side. Ordering is stable so two - // panels sharing an edge never swap unpredictably. - let mut left_items: Vec> = Vec::new(); - let mut right_items: Vec> = Vec::new(); - if let Some(p) = properties_el { - match properties_side { - crate::app::config::DockSide::Left => left_items.push(p), - crate::app::config::DockSide::Right => right_items.push(p), - } + // The workspace row is: left edge stack, viewport, right edge stack. + let mut parts: Vec> = Vec::new(); + if let Some(e) = left_edge { + parts.push(e); } - if let Some(p) = block_palette_el { - match bp_side { - crate::app::config::DockSide::Left => left_items.push(p), - crate::app::config::DockSide::Right => right_items.push(p), - } - } - let mut parts = left_items; parts.push(viewport_stack.into()); - parts.extend(right_items); + if let Some(e) = right_edge { + parts.push(e); + } let workspace: Element<'_, Message> = row(parts).width(Fill).height(Fill).into(); - let any_dragging = self.properties_dragging || self.dock_dragging.is_some(); - let any_resizing = self.properties_resizing || self.dock_resizing.is_some(); + let any_dragging = self.dock_dragging.is_some(); + let any_resizing = self.dock_resizing.is_some(); let workspace = if any_dragging { - let (preview_side, preview_width) = if let Some(id) = self.dock_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), - ); - (side, self.dock.width(id, self.win_size.0)) - } else { - let side = self.properties_dock_preview.unwrap_or(self.properties_side); - (side, properties_width) + 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 panel (the dragged panel included). + let preview_width = { + let mut widths: Vec = match side { + crate::app::config::DockSide::Left => self.dock.left.clone(), + crate::app::config::DockSide::Right => self.dock.right.clone(), + } + .into_iter() + .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) }; - let preview = container(Space::new()) + // 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.72), + 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 = stack size after the + // drop), with a header naming which panel is being moved. + let was_here = self.dock.location(id).map(|(s, _)| s) == Some(side); + let final_count = if was_here { + std::cmp::max(self.dock.len(side), 1) + } else { + self.dock.len(side) + 1 + }; + let slot_h = self.win_size.1 / 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, @@ -1617,39 +1602,74 @@ impl OpenCADStudio { ..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 preview_side { - crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left, - crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right, + .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() } else { workspace }; let workspace: Element<'_, Message> = if any_dragging || any_resizing { - if self.properties_dragging || self.properties_resizing { - mouse_area(workspace) - .on_move(Message::PropertiesDragMove) - .on_release(Message::PropertiesDragRelease) - .interaction(if self.properties_resizing { - iced::mouse::Interaction::ResizingHorizontally - } else { - iced::mouse::Interaction::Grabbing - }) - .into() - } else { - 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_resizing.is_some() { - iced::mouse::Interaction::ResizingHorizontally - } else { - iced::mouse::Interaction::Grabbing - }) - .into() - } + 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 { + iced::mouse::Interaction::ResizingHorizontally + } else { + iced::mouse::Interaction::Grabbing + }) + .into() } else { workspace }; @@ -2231,6 +2251,148 @@ impl OpenCADStudio { iced::advanced::widget::operation::focusable::unfocus(), ) } + + /// 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. + 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); + } + } + // Each expanded panel renders at its own 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)) + .fold(0.0, f32::max); + + let tab_strip = (!pinned.is_empty()).then(|| { + let tabs: Vec> = pinned + .iter() + .map(|id| self.rail_slice(*id, side)) + .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(); + column(layers).height(Fill).into() + }); + + 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); + } + } + crate::app::config::DockSide::Right => { + if let Some(e) = expanded_stack { + children.push(e); + } + if let Some(t) = tab_strip { + children.push(t); + } + } + } + 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. + fn rail_slice( + &self, + id: crate::ui::dock::PanelId, + side: crate::app::config::DockSide, + ) -> Element<'_, Message> { + let label = canvas(VBarLabel { + text: id.title().to_string(), + clockwise: side == crate::app::config::DockSide::Left, + }) + .width(Fill) + .height(Fill); + mouse_area( + container(label) + .width(Length::Fixed(DOCK_RAIL_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() + }), + ) + .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() + } + + /// 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>( + &'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 { + 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) + } + }; + 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(), + } + } } // ── Document tab bar ─────────────────────────────────────────────────────── @@ -2591,66 +2753,11 @@ impl canvas::Program for VBarLabel { } } -/// A collapsed panel rendered as a tall narrow bar with its name written along -/// it. It can be clicked on narrow windows or expanded by hover when auto-hide -/// is enabled. -pub(super) fn collapse_bar<'a>( - name: &str, - side: crate::app::config::DockSide, - on_press: Message, - on_enter: Message, - width: f32, -) -> Element<'a, Message> { - let label = canvas(VBarLabel { - text: name.to_string(), - clockwise: side == crate::app::config::DockSide::Left, - }) - .width(Fill) - .height(Fill); - - mouse_area( - container(label) - .width(iced::Length::Fixed(width)) - .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() - }), - ) - .interaction(iced::mouse::Interaction::Pointer) - .on_press(on_press) - .on_enter(on_enter) - .into() -} - -/// Grabbable separator between the docked panel and drawing view. The visible -/// line is wider than a single pixel so it remains discoverable in every theme. -fn properties_divider() -> Element<'static, Message> { - let line = container(Space::new()) - .width(Length::Fixed(5.0)) - .height(Fill) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color, - )), - ..Default::default() - }); - mouse_area(line) - .on_press(Message::PropertiesResizeGrab) - .on_double_click(Message::PropertiesWidthReset) - .interaction(iced::mouse::Interaction::ResizingHorizontally) - .into() -} +/// Width of a collapsed (auto-collapsing) panel's tab in the edge strip. +const DOCK_RAIL_W: f32 = 28.0; /// Grabbable separator for a docked panel managed by the general dock. Same -/// visual as `properties_divider` but emits generic dock messages for `id`. +/// 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)) diff --git a/src/ui/dock.rs b/src/ui/dock.rs index f8a51f7d6..1649e6a3a 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -196,15 +196,6 @@ impl DockState { true } - /// True when the panel is in a non-empty reduced state on `side`, i.e. the - /// number of reduced panels that share the side's column height. - pub fn reduced_count(&self, side: DockSide, expanded: Option) -> usize { - self.stack(side) - .iter() - .filter(|id| Some(**id) != expanded) - .count() - } - /// Number of panels currently docked on `side`. pub fn len(&self, side: DockSide) -> usize { self.stack(side).len() @@ -228,49 +219,10 @@ 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; -/// Height allocated to one reduced panel sharing an edge with `count` others. -pub fn slice_height(count: usize, avail: f32) -> f32 { - if count == 0 { - 0.0 - } else { - avail / count as f32 - } -} - -/// Index (top → bottom) of the reduced slice whose bounds contain screen-local -/// `y` measured from the top of the edge column. Returns `None` for a y outside -/// the column or when there are no reduced panels. Even when an expanded panel -/// overlays the column, this computed index lets a reduced sibling be raised. -pub fn slice_index_at_y(y: f32, count: usize, avail: f32) -> Option { - if count == 0 || avail <= 0.0 || y < 0.0 { - return None; - } - let h = avail / count as f32; - let i = (y / h) as usize; - (i < count).then_some(i) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn slice_height_divides_equally() { - assert_eq!(slice_height(3, 300.0), 100.0); - assert_eq!(slice_height(1, 300.0), 300.0); - assert_eq!(slice_height(0, 300.0), 0.0); - } - - #[test] - fn slice_index_maps_y_to_panel() { - assert_eq!(slice_index_at_y(50.0, 3, 300.0), Some(0)); - assert_eq!(slice_index_at_y(110.0, 3, 300.0), Some(1)); - assert_eq!(slice_index_at_y(299.0, 3, 300.0), Some(2)); - assert_eq!(slice_index_at_y(300.0, 3, 300.0), None); - assert_eq!(slice_index_at_y(-1.0, 3, 300.0), None); - assert_eq!(slice_index_at_y(50.0, 0, 300.0), None); - } - #[test] fn default_docks_each_known_panel_on_an_edge() { let state = DockState::default(); @@ -336,16 +288,6 @@ mod tests { assert_eq!(state.width(PanelId::BlockPalette, 800.0), DOCK_MAX_W.min(360.0)); } - #[test] - fn reduced_count_excludes_expanded() { - let state = DockState::default(); // right: [BlockPalette] - assert_eq!(state.reduced_count(DockSide::Right, None), 1); - assert_eq!( - state.reduced_count(DockSide::Right, Some(PanelId::BlockPalette)), - 0 - ); - } - #[test] fn drop_index_maps_between_zero_and_total() { assert_eq!(drop_index(0.0, 3, 300.0), 0); diff --git a/src/ui/properties.rs b/src/ui/properties.rs index edfbfef3e..631676fee 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -369,6 +369,7 @@ impl PropertiesPanel { } pub fn view(&self, width: f32, auto_collapse: bool) -> Element<'_, Message> { + use crate::ui::dock::{DockMsg, PanelId}; // ── Header ────────────────────────────────────────────────────────── let pin_icon = if auto_collapse { crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) @@ -376,7 +377,7 @@ impl PropertiesPanel { crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) }; let pin = button(pin_icon) - .on_press(Message::PropertiesAutoCollapseToggle) + .on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::Properties))) .style(move |theme: &Theme, status| { let mut style = button::subtle(theme, status); if auto_collapse { @@ -395,7 +396,7 @@ impl PropertiesPanel { crate::ui::icons::CLOSE, 12.0, )) - .on_press(Message::PropertiesClose) + .on_press(Message::Dock(DockMsg::Close(PanelId::Properties))) .style(button::subtle) .padding([3, 5]); let close = tooltip( @@ -425,7 +426,7 @@ impl PropertiesPanel { .width(Length::Fill) .padding([3, 6]), ) - .on_press(Message::PropertiesDockGrab) + .on_press(Message::Dock(DockMsg::DockGrab(PanelId::Properties))) .interaction(iced::mouse::Interaction::Grab); // ── Title bar (entity type / "No Selection") ─────────────────────