From 10a1d89f1908509bb391ff1f7bc08012e047065d Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 11 Aug 2026 06:54:00 +0100 Subject: [PATCH] feat(dock): migrate block palette onto general edge-stack dock Pin/close/resize/drag-between-edges/auto-collapse hover, themed and persisted, mirroring the properties panel chrome. --- src/app/commands/blocks.rs | 6 +- src/app/mod.rs | 22 ++++- src/app/update/dialog.rs | 171 +++++++++++++++++++++++++++++---- src/app/update/mod.rs | 1 + src/app/view/mod.rs | 170 +++++++++++++++++++++++--------- src/ui/dock.rs | 57 +++++++++++ src/ui/window/block_palette.rs | 125 +++++++++++++----------- 7 files changed, 426 insertions(+), 126 deletions(-) diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 65499e4a0..1888df2e9 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -461,8 +461,8 @@ impl OpenCADStudio { self.show_block_palette ^= true; if self.show_block_palette { // Always open expanded so the panel is immediately usable; - // the user can still collapse it via the title-bar button. - self.block_palette_expanded = true; + // the user can still collapse it via the pin (Auto) button. + self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette); self.refresh_block_palette(); } } @@ -708,7 +708,7 @@ mod tests { let _ = app.run_command_line("BLOCKPALETTE"); assert!(app.show_block_palette); assert!( - app.block_palette_expanded, + app.dock_expanded == Some(crate::ui::dock::PanelId::BlockPalette), "palette must open expanded, not as the collapsed bar" ); let _ = app.run_command_line("BLOCKSPALETTE"); diff --git a/src/app/mod.rs b/src/app/mod.rs index d70323b92..04d362327 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -593,10 +593,19 @@ pub(super) struct OpenCADStudio { pub(crate) show_block_palette: bool, /// 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. + 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)>, /// Docked Insert Block panel state (search, preview size, cached thumbnails). pub(crate) block_palette: crate::ui::window::block_palette::BlockPalette, - /// Whether the narrow-window block-palette bar is expanded. - block_palette_expanded: bool, /// Whether the document file tabs are shown at the top (FILETAB). show_file_tabs: bool, /// Whether the layout/paper-space tabs are shown at the bottom (LAYOUTTAB). @@ -2720,6 +2729,9 @@ pub enum Message { PlotDlg(crate::ui::window::plot::PlotDlgMsg), /// An edit inside the docked Insert Block panel. BlockPalette(crate::ui::window::block_palette::BlockPaletteMsg), + /// A dock chrome interaction (grab/resize/pin/hover/dock move) on a side + /// panel. + Dock(crate::ui::dock::DockMsg), /// Open the paper-layout batch output dialog. PrintAllOpen, /// Toggle one paper layout in the batch. @@ -3160,8 +3172,12 @@ impl OpenCADStudio { show_properties: true, show_block_palette: false, block_palette: Default::default(), - block_palette_expanded: false, dock: Default::default(), + dock_expanded: None, + dock_dragging: None, + dock_resizing: None, + dock_drag_last: None, + dock_drag_target: None, show_file_tabs: true, show_layout_tabs: true, last_point: None, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 93473f6ba..9e4e99bc1 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -517,18 +517,6 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.refresh_block_palette(); iced::Task::none() } - BlockPaletteMsg::ToggleBar => { - // Collapse bar (mirrors the Properties panel). - self.block_palette_expanded ^= true; - if self.block_palette_expanded { - self.refresh_block_palette(); - } - iced::Task::none() - } - BlockPaletteMsg::Close => { - self.show_block_palette = false; - iced::Task::none() - } BlockPaletteMsg::PickFile => iced::Task::perform( async { let handle = rfd::AsyncFileDialog::new() @@ -571,6 +559,117 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } } + /// 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::{DockMsg, 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); + iced::Task::none() + } + 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::WidthReset(id) => { + self.dock.reset_width(id); + self.save_config(); + iced::Task::none() + } + DockMsg::AutoCollapseToggle(id) => { + let on = !self.dock.auto_collapse(id); + self.dock.set_auto_collapse(id, on); + self.dock_expanded = if on { None } else { Some(id) }; + self.save_config(); + iced::Task::none() + } + DockMsg::Close(id) => { + match id { + PanelId::BlockPalette => { + self.show_block_palette = false; + self.block_palette.placing = None; + } + PanelId::Properties => self.show_properties = false, + } + 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::Hover(id) => { + 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; + } + } + iced::Task::none() + } + DockMsg::DragMove(point) => { + if self.dock_dragging.is_some() { + 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.len(side), 1), + self.win_size.1, + ); + self.dock_drag_target = Some((side, index)); + } 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, + }; + let cur = self.dock.settings(id).width + delta; + self.dock.set_width(id, cur); + } + } + if self.dock_dragging.is_some() || self.dock_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(); + } + } + } + self.dock_dragging = None; + self.dock_resizing = None; + self.dock_drag_last = None; + self.dock_drag_target = None; + iced::Task::none() + } + } + } + /// Start placing `name` through the INSERT command, skipping the name prompt. fn start_block_placement(&mut self, name: &str) { let i = self.active_tab; @@ -931,18 +1030,52 @@ mod tests { } #[test] - fn blockpalette_collapse_and_close_toggle_state() { + fn blockpalette_pin_toggles_autocollapse_and_close_hides() { let mut app = fresh(); app.show_block_palette = true; - app.block_palette_expanded = true; - let _ = app.on_block_palette(BlockPaletteMsg::ToggleBar); - assert!(!app.block_palette_expanded, "collapse hides the panel body"); - let _ = app.on_block_palette(BlockPaletteMsg::ToggleBar); - assert!(app.block_palette_expanded, "bar click re-expands the panel"); - let _ = app.on_block_palette(BlockPaletteMsg::Close); + 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"); + let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); + assert!(!app.dock.auto_collapse(id), "second pin disables auto-collapse"); + let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id)); assert!(!app.show_block_palette, "close dismisses the sidebar"); } + #[test] + fn blockpalette_dock_moves_to_other_side_and_persists() { + 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(); + let id = crate::ui::dock::PanelId::BlockPalette; + assert_eq!( + app.dock.location(id), + Some((crate::app::config::DockSide::Right, 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!( + app.dock_drag_target, + Some((crate::app::config::DockSide::Left, 0)) + ); + let _ = app.on_dock(crate::ui::dock::DockMsg::DragRelease); + assert_eq!( + app.dock.location(id), + Some((crate::app::config::DockSide::Left, 0)) + ); + } + + #[test] + fn blockpalette_width_reset() { + let mut app = fresh(); + let id = crate::ui::dock::PanelId::BlockPalette; + app.dock.set_width(id, 500.0); + let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id)); + assert_eq!(app.dock.settings(id).width, 260.0); + } + #[test] fn block_name_from_file_avoids_collisions() { let mut app = fresh(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 395e50939..bdf861e8a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6336,6 +6336,7 @@ impl OpenCADStudio { Message::PlotDialogOpen => self.on_plot_dialog_open(), Message::PlotDlg(m) => self.on_plot_dlg(m), Message::BlockPalette(m) => self.on_block_palette(m), + Message::Dock(m) => self.on_dock(m), Message::PrintAllOpen => self.on_print_all_open(), Message::PrintAllToggle(name) => { if let Some((_, selected)) = self diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index d4ed02dd3..a388e9612 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -5,7 +5,6 @@ use super::{ArrowKey, Message, OpenCADStudio}; use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper, grips_to_screen_rte}; use crate::scene::view::viewport_pane::ViewportPane; use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX}; -use crate::ui::window::block_palette::BlockPaletteMsg; use crate::ui::wrap_bar::DensitySwap; use crate::ui::wrap_bar::WrapFlow; use iced::widget::{ @@ -1497,32 +1496,54 @@ impl OpenCADStudio { } }); - // Drawing viewports keep the command line as a bottom-centre overlay so - // the input stays close to the cursor. The Start page gives it a real - // layout row instead: its panels and action buttons must end above the - // command line rather than rendering behind it (#546). - // The block palette docks on the right, mirroring the Properties panel on the - // left. The collapse button reduces it to a vertical bar whose width equals - // the title height, with the title rotated 90° (see `collapse_bar`). - let block_palette_el: Element<'_, Message> = if tab.is_start { - Space::new().into() - } else if self.show_block_palette && !self.clean_screen { - if self.block_palette_expanded { - crate::ui::window::block_palette::view(&self.block_palette) - } else { - // Collapsed bar width matches the title bar height (icon button - // 30px + 5px top/bottom padding) so expand/collapse is seamless. - collapse_bar( + // 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", - crate::app::config::DockSide::Right, - Message::BlockPalette(BlockPaletteMsg::ToggleBar), - Message::Noop, - 40.0, + 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(), ) } } else { - Space::new().into() + None }; + let bp_side = self + .dock + .location(crate::ui::dock::PanelId::BlockPalette) + .map(|(s, _)| s) + .unwrap_or(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 @@ -1541,19 +1562,46 @@ 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 workspace: Element<'_, Message> = match (properties_el, self.properties_side) { - (Some(properties), crate::app::config::DockSide::Left) => { - row![properties, viewport_stack].width(Fill).height(Fill).into() + 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), } - (Some(properties), crate::app::config::DockSide::Right) => { - row![viewport_stack, properties].width(Fill).height(Fill).into() + } + 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), } - (None, _) => container(viewport_stack).width(Fill).height(Fill).into(), - }; - let workspace: Element<'_, Message> = if self.properties_dragging { - let preview_side = self.properties_dock_preview.unwrap_or(self.properties_side); + } + let mut parts = left_items; + parts.push(viewport_stack.into()); + parts.extend(right_items); + 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 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 preview = container(Space::new()) - .width(Length::Fixed(properties_width)) + .width(Length::Fixed(preview_width)) .height(Fill) .style(|theme: &Theme| { let palette = theme.palette(); @@ -1580,24 +1628,31 @@ impl OpenCADStudio { } else { workspace }; - let workspace: Element<'_, Message> = 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() + 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() + } } else { workspace }; - let workspace = row![workspace, block_palette_el] - .width(Fill) - .height(Fill); let command_line = self.command_line.view( allow_autocomplete, dyn_capturing, @@ -2594,6 +2649,27 @@ fn properties_divider() -> Element<'static, Message> { .into() } +/// Grabbable separator for a docked panel managed by the general dock. Same +/// visual as `properties_divider` but emits generic dock messages for `id`. +fn dock_divider(id: crate::ui::dock::PanelId) -> 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() + }); + 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() +} + 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 8453dd81e..f8a51f7d6 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -11,6 +11,30 @@ use crate::app::config::DockSide; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +/// Dock chrome interactions. These are panel-agnostic so any dockable panel +/// (Properties, the block palette, future palettes) shares one code path. +#[derive(Debug, Clone)] +pub enum DockMsg { + /// Begin dragging `panel` to another side / position. + DockGrab(PanelId), + /// Begin resizing `panel`'s width. + ResizeGrab(PanelId), + /// Reset `panel`'s width to its default. + WidthReset(PanelId), + /// Toggle `panel`'s auto-collapse (pin) behavior. + AutoCollapseToggle(PanelId), + /// Close / hide `panel`. + Close(PanelId), + /// The pointer is over `panel`, raising it to full height. + Hover(PanelId), + /// Pointer moved while a panel is dragging or resizing. + DragMove(iced::Point), + /// Pointer released after a drag / resize. + DragRelease, + /// The pointer left the edge column; collapse any auto-collapsing panel. + HoverExit, +} + /// The dockable panels the application knows about. New palettes add a variant. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -180,6 +204,23 @@ impl DockState { .filter(|id| Some(**id) != expanded) .count() } + + /// Number of panels currently docked on `side`. + pub fn len(&self, side: DockSide) -> usize { + self.stack(side).len() + } +} + +/// Insertion index (0..=total) for dropping a panel whose pointer is at +/// screen-local `y` within an edge column of `avail` height holding `total` +/// slots. Used to compute the live drag target index while reordering. +pub fn drop_index(y: f32, total: usize, avail: f32) -> usize { + if total == 0 || avail <= 0.0 { + return 0; + } + let h = avail / total as f32; + let idx = (y / h).round() as usize; + idx.min(total) } /// Smallest docked width a panel may be dragged or sized to. @@ -304,4 +345,20 @@ mod tests { 0 ); } + + #[test] + fn drop_index_maps_between_zero_and_total() { + assert_eq!(drop_index(0.0, 3, 300.0), 0); + assert_eq!(drop_index(100.0, 3, 300.0), 1); + assert_eq!(drop_index(299.0, 3, 300.0), 3); + assert_eq!(drop_index(300.0, 3, 300.0), 3); + assert_eq!(drop_index(50.0, 0, 300.0), 0); + } + + #[test] + fn len_reports_stack_size() { + let state = DockState::default(); + assert_eq!(state.len(DockSide::Left), 1); + assert_eq!(state.len(DockSide::Right), 1); + } } \ No newline at end of file diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index ccd0a4cff..6ecf88717 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -4,19 +4,12 @@ use crate::app::Message; use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; +use crate::ui::dock::{DockMsg, PanelId}; use iced::widget::canvas::{Frame, Path, Program, Stroke}; -use iced::widget::{button, canvas, column, container, row, scrollable, svg, text, text_input}; +use iced::widget::{button, canvas, column, container, mouse_area, row, scrollable, svg, text, text_input, tooltip}; use iced::{Background, Border, Color, Element, Fill, Length, Theme}; -const PANEL_W: f32 = 260.0; const TOOL_H: f32 = 22.0; -const PANEL_BG: Color = Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0 }; -const PANEL_BORDER: Color = Color { r: 0.22, g: 0.22, b: 0.24, a: 1.0 }; -const CARD_BG: Color = Color { r: 0.16, g: 0.16, b: 0.18, a: 1.0 }; -const CARD_HOVER: Color = Color { r: 0.20, g: 0.20, b: 0.22, a: 1.0 }; -const CARD_ACTIVE: Color = Color { r: 0.20, g: 0.40, b: 0.70, a: 1.0 }; -const TEXT: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 }; -const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }; /// Character budget for a block card's label so all rows stay one line. /// Fits the narrowest card (Small = 3 per row) at size 11 without wrapping. const MAX_LABEL_CHARS: usize = 12; @@ -32,8 +25,6 @@ pub enum BlockPaletteMsg { PickFile, Insert(String), Refresh, - ToggleBar, - Close, FilePicked(Result), } @@ -191,7 +182,7 @@ impl<'a> Program for BlockPreviewCanvas<'a> { } /// Build the docked panel element from the palette state. -pub fn view(palette: &BlockPalette) -> Element<'_, Message> { +pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<'_, Message> { let query = palette.search.trim().to_lowercase(); let filtered: Vec<&BlockEntry> = palette .blocks @@ -199,34 +190,54 @@ pub fn view(palette: &BlockPalette) -> Element<'_, Message> { .filter(|block| query.is_empty() || block.name.to_lowercase().contains(&query)) .collect(); - // ── Title bar (title, collapse, close) ────────────────────────────────── - let title_bar = container( - row![ - text("Block Palette").size(12).color(TEXT), - iced::widget::Space::new().width(Fill), - icon_button( - IconKind::Svg(include_bytes!("../../../assets/icons/ui/tri_right.svg")), - BlockPaletteMsg::ToggleBar, - ), - icon_button( - IconKind::Svg(include_bytes!("../../../assets/icons/ui/close.svg")), - BlockPaletteMsg::Close, - ), - ] - .spacing(4) - .align_y(iced::Center), + // ── Dock chrome (title, pin, close) — matches the Properties panel ──── + 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(PanelId::BlockPalette))) + .style(move |theme: &Theme, status| { + let mut style = button::subtle(theme, status); + if auto_collapse { + let palette = theme.palette(); + style.background = Some(Background::Color(palette.primary.weak.color)); + style.text_color = palette.primary.weak.text; + style.border.color = palette.primary.base.color; + style.border.width = 1.0; + } + style + }) + .padding([3, 5]); + let pin = tooltip(pin, text("Auto").size(10), tooltip::Position::Bottom).gap(4); + + let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0)) + .on_press(Message::Dock(DockMsg::Close(PanelId::BlockPalette))) + .style(button::subtle) + .padding([3, 5]); + let close = tooltip(close, text("Close").size(10), tooltip::Position::Bottom).gap(4); + + let title_bar = mouse_area( + container( + row![ + text("Block Palette").size(12), + iced::widget::Space::new().width(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(Fill) + .padding([3, 6]), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(CARD_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 0.0.into(), - }, - ..Default::default() - }) - .width(Fill) - .padding([5, 6]); + .on_press(Message::Dock(DockMsg::DockGrab(PanelId::BlockPalette))) + .interaction(iced::mouse::Interaction::Grab); let search_input = text_input("Search blocks…", &palette.search) .on_input(|v| Message::BlockPalette(BlockPaletteMsg::Search(v))) @@ -253,7 +264,7 @@ pub fn view(palette: &BlockPalette) -> Element<'_, Message> { } else { "No matches" }; - container(text(msg).size(12).color(DIM)) + container(text(msg).size(12).color(iced::Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 })) .center_x(Fill) .center_y(Fill) .width(Fill) @@ -282,12 +293,12 @@ pub fn view(palette: &BlockPalette) -> Element<'_, Message> { }; container(column![title_bar, header, body].spacing(6).padding(6)) - .width(Length::Fixed(PANEL_W)) + .width(Length::Fixed(width)) .height(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), border: Border { - color: PANEL_BORDER, + color: theme.palette().background.neutral.color, width: 1.0, radius: 0.0.into(), }, @@ -306,7 +317,7 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<' // handles overflow, and this clips anything that still won't fit). let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS)) .size(11) - .color(TEXT) + .color(Color::WHITE) .width(Fill) .height(Length::Fixed(LABEL_LINE_H)) .center(); @@ -314,23 +325,27 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<' button(content) .on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone()))) .width(Fill) - .style(move |_: &Theme, status| { - let bg = if is_placing { - CARD_ACTIVE + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let neutral = palette.background.neutral.color; + let base = palette.background.base.color; + let strong = palette.background.strong.color; + let (bg, border) = if is_placing { + (palette.primary.base.color, palette.primary.base.color) } else { match status { - button::Status::Hovered | button::Status::Pressed => CARD_HOVER, - _ => CARD_BG, + button::Status::Hovered | button::Status::Pressed => (strong, neutral), + _ => (base, neutral), } }; button::Style { background: Some(Background::Color(bg)), border: Border { - color: PANEL_BORDER, + color: border, width: 1.0, radius: 3.0.into(), }, - text_color: TEXT, + text_color: Color::WHITE, ..Default::default() } }) @@ -349,9 +364,11 @@ fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> .on_press(Message::BlockPalette(msg)) .width(Length::Fixed(TOOL_H + 8.0)) .height(Length::Fixed(TOOL_H + 8.0)) - .style(|_: &Theme, status| button::Style { + .style(|theme: &Theme, status| button::Style { background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => CARD_HOVER, + button::Status::Hovered | button::Status::Pressed => { + theme.palette().background.strong.color + } _ => Color::TRANSPARENT, })), border: Border {