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.
This commit is contained in:
Karim Jerbi committed 2026-08-11 23:13:05 +01:00
1 parent dce555f5bc
commit 10a1d89f19
7 files changed
+426 -126

No files matched your search

+3 -3
View File
@@ -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");
+19 -3
View File
@@ -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<crate::ui::dock::PanelId>,
/// Panel currently being dragged between sides / reordered.
pub(crate) dock_dragging: Option<crate::ui::dock::PanelId>,
/// Panel currently being width-resized.
pub(crate) dock_resizing: Option<crate::ui::dock::PanelId>,
/// Last pointer position during a drag / resize.
pub(crate) dock_drag_last: Option<iced::Point>,
/// 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,
+152 -19
View File
@@ -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<Message> {
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();
+1
View File
@@ -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
+123 -47
View File
@@ -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<Element<'_, Message>> = 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<Element<'_, Message>> = Vec::new();
let mut right_items: Vec<Element<'_, Message>> = 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 {