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:
parent
dce555f5bc
commit
10a1d89f19
7 changed files with 426 additions and 126 deletions
|
|
@ -461,8 +461,8 @@ impl OpenCADStudio {
|
||||||
self.show_block_palette ^= true;
|
self.show_block_palette ^= true;
|
||||||
if self.show_block_palette {
|
if self.show_block_palette {
|
||||||
// Always open expanded so the panel is immediately usable;
|
// Always open expanded so the panel is immediately usable;
|
||||||
// the user can still collapse it via the title-bar button.
|
// the user can still collapse it via the pin (Auto) button.
|
||||||
self.block_palette_expanded = true;
|
self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette);
|
||||||
self.refresh_block_palette();
|
self.refresh_block_palette();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -708,7 +708,7 @@ mod tests {
|
||||||
let _ = app.run_command_line("BLOCKPALETTE");
|
let _ = app.run_command_line("BLOCKPALETTE");
|
||||||
assert!(app.show_block_palette);
|
assert!(app.show_block_palette);
|
||||||
assert!(
|
assert!(
|
||||||
app.block_palette_expanded,
|
app.dock_expanded == Some(crate::ui::dock::PanelId::BlockPalette),
|
||||||
"palette must open expanded, not as the collapsed bar"
|
"palette must open expanded, not as the collapsed bar"
|
||||||
);
|
);
|
||||||
let _ = app.run_command_line("BLOCKSPALETTE");
|
let _ = app.run_command_line("BLOCKSPALETTE");
|
||||||
|
|
|
||||||
|
|
@ -593,10 +593,19 @@ pub(super) struct OpenCADStudio {
|
||||||
pub(crate) show_block_palette: bool,
|
pub(crate) show_block_palette: bool,
|
||||||
/// General edge-stack dock layout for the side panels.
|
/// General edge-stack dock layout for the side panels.
|
||||||
pub(crate) dock: crate::ui::dock::DockState,
|
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).
|
/// Docked Insert Block panel state (search, preview size, cached thumbnails).
|
||||||
pub(crate) block_palette: crate::ui::window::block_palette::BlockPalette,
|
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).
|
/// Whether the document file tabs are shown at the top (FILETAB).
|
||||||
show_file_tabs: bool,
|
show_file_tabs: bool,
|
||||||
/// Whether the layout/paper-space tabs are shown at the bottom (LAYOUTTAB).
|
/// 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),
|
PlotDlg(crate::ui::window::plot::PlotDlgMsg),
|
||||||
/// An edit inside the docked Insert Block panel.
|
/// An edit inside the docked Insert Block panel.
|
||||||
BlockPalette(crate::ui::window::block_palette::BlockPaletteMsg),
|
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.
|
/// Open the paper-layout batch output dialog.
|
||||||
PrintAllOpen,
|
PrintAllOpen,
|
||||||
/// Toggle one paper layout in the batch.
|
/// Toggle one paper layout in the batch.
|
||||||
|
|
@ -3160,8 +3172,12 @@ impl OpenCADStudio {
|
||||||
show_properties: true,
|
show_properties: true,
|
||||||
show_block_palette: false,
|
show_block_palette: false,
|
||||||
block_palette: Default::default(),
|
block_palette: Default::default(),
|
||||||
block_palette_expanded: false,
|
|
||||||
dock: Default::default(),
|
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_file_tabs: true,
|
||||||
show_layout_tabs: true,
|
show_layout_tabs: true,
|
||||||
last_point: None,
|
last_point: None,
|
||||||
|
|
|
||||||
|
|
@ -517,18 +517,6 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||||
self.refresh_block_palette();
|
self.refresh_block_palette();
|
||||||
iced::Task::none()
|
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(
|
BlockPaletteMsg::PickFile => iced::Task::perform(
|
||||||
async {
|
async {
|
||||||
let handle = rfd::AsyncFileDialog::new()
|
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.
|
/// Start placing `name` through the INSERT command, skipping the name prompt.
|
||||||
fn start_block_placement(&mut self, name: &str) {
|
fn start_block_placement(&mut self, name: &str) {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
|
|
@ -931,18 +1030,52 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blockpalette_collapse_and_close_toggle_state() {
|
fn blockpalette_pin_toggles_autocollapse_and_close_hides() {
|
||||||
let mut app = fresh();
|
let mut app = fresh();
|
||||||
app.show_block_palette = true;
|
app.show_block_palette = true;
|
||||||
app.block_palette_expanded = true;
|
let id = crate::ui::dock::PanelId::BlockPalette;
|
||||||
let _ = app.on_block_palette(BlockPaletteMsg::ToggleBar);
|
let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
|
||||||
assert!(!app.block_palette_expanded, "collapse hides the panel body");
|
assert!(app.dock.auto_collapse(id), "pin enables auto-collapse");
|
||||||
let _ = app.on_block_palette(BlockPaletteMsg::ToggleBar);
|
let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
|
||||||
assert!(app.block_palette_expanded, "bar click re-expands the panel");
|
assert!(!app.dock.auto_collapse(id), "second pin disables auto-collapse");
|
||||||
let _ = app.on_block_palette(BlockPaletteMsg::Close);
|
let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id));
|
||||||
assert!(!app.show_block_palette, "close dismisses the sidebar");
|
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]
|
#[test]
|
||||||
fn block_name_from_file_avoids_collisions() {
|
fn block_name_from_file_avoids_collisions() {
|
||||||
let mut app = fresh();
|
let mut app = fresh();
|
||||||
|
|
|
||||||
|
|
@ -6336,6 +6336,7 @@ impl OpenCADStudio {
|
||||||
Message::PlotDialogOpen => self.on_plot_dialog_open(),
|
Message::PlotDialogOpen => self.on_plot_dialog_open(),
|
||||||
Message::PlotDlg(m) => self.on_plot_dlg(m),
|
Message::PlotDlg(m) => self.on_plot_dlg(m),
|
||||||
Message::BlockPalette(m) => self.on_block_palette(m),
|
Message::BlockPalette(m) => self.on_block_palette(m),
|
||||||
|
Message::Dock(m) => self.on_dock(m),
|
||||||
Message::PrintAllOpen => self.on_print_all_open(),
|
Message::PrintAllOpen => self.on_print_all_open(),
|
||||||
Message::PrintAllToggle(name) => {
|
Message::PrintAllToggle(name) => {
|
||||||
if let Some((_, selected)) = self
|
if let Some((_, selected)) = self
|
||||||
|
|
|
||||||
|
|
@ -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::pick::grip::{grips_to_screen, grips_to_screen_paper, grips_to_screen_rte};
|
||||||
use crate::scene::view::viewport_pane::ViewportPane;
|
use crate::scene::view::viewport_pane::ViewportPane;
|
||||||
use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX};
|
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::DensitySwap;
|
||||||
use crate::ui::wrap_bar::WrapFlow;
|
use crate::ui::wrap_bar::WrapFlow;
|
||||||
use iced::widget::{
|
use iced::widget::{
|
||||||
|
|
@ -1497,32 +1496,54 @@ impl OpenCADStudio {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drawing viewports keep the command line as a bottom-centre overlay so
|
// The Block Palette docks through the general edge-stack dock (side,
|
||||||
// the input stays close to the cursor. The Start page gives it a real
|
// width, auto-collapse are persisted on `self.dock`). When auto-collapsing
|
||||||
// layout row instead: its panels and action buttons must end above the
|
// and not hovered it reduces to a rail; otherwise it renders full-height
|
||||||
// command line rather than rendering behind it (#546).
|
// with themed pin/close chrome and a grabbable, resizable header.
|
||||||
// The block palette docks on the right, mirroring the Properties panel on the
|
let block_palette_el: Option<Element<'_, Message>> = if !tab.is_start
|
||||||
// left. The collapse button reduces it to a vertical bar whose width equals
|
&& self.show_block_palette
|
||||||
// the title height, with the title rotated 90° (see `collapse_bar`).
|
&& !self.clean_screen
|
||||||
let block_palette_el: Element<'_, Message> = if tab.is_start {
|
{
|
||||||
Space::new().into()
|
let id = crate::ui::dock::PanelId::BlockPalette;
|
||||||
} else if self.show_block_palette && !self.clean_screen {
|
let side = self
|
||||||
if self.block_palette_expanded {
|
.dock
|
||||||
crate::ui::window::block_palette::view(&self.block_palette)
|
.location(id)
|
||||||
} else {
|
.map(|(s, _)| s)
|
||||||
// Collapsed bar width matches the title bar height (icon button
|
.unwrap_or(crate::app::config::DockSide::Right);
|
||||||
// 30px + 5px top/bottom padding) so expand/collapse is seamless.
|
let width = self.dock.width(id, self.win_size.0);
|
||||||
collapse_bar(
|
let auto_collapse = self.dock.auto_collapse(id);
|
||||||
|
let expanded = self.dock_expanded == Some(id);
|
||||||
|
if auto_collapse && !expanded {
|
||||||
|
Some(collapse_bar(
|
||||||
"Block Palette",
|
"Block Palette",
|
||||||
crate::app::config::DockSide::Right,
|
side,
|
||||||
Message::BlockPalette(BlockPaletteMsg::ToggleBar),
|
Message::Dock(crate::ui::dock::DockMsg::Hover(id)),
|
||||||
Message::Noop,
|
Message::Dock(crate::ui::dock::DockMsg::Hover(id)),
|
||||||
40.0,
|
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 {
|
} 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
|
// Command-line sits as a bottom-centre overlay on top of the
|
||||||
// viewport stack rather than as a separate row in the main
|
// viewport stack rather than as a separate row in the main
|
||||||
|
|
@ -1541,19 +1562,46 @@ impl OpenCADStudio {
|
||||||
&& !tab.dyn_fields.is_empty())
|
&& !tab.dyn_fields.is_empty())
|
||||||
|| self.mtext_editor.as_ref().is_some_and(|e| e.show_preview)
|
|| self.mtext_editor.as_ref().is_some_and(|e| e.show_preview)
|
||||||
|| self.text_inline.is_some();
|
|| self.text_inline.is_some();
|
||||||
let workspace: Element<'_, Message> = match (properties_el, self.properties_side) {
|
let properties_side = self.properties_side;
|
||||||
(Some(properties), crate::app::config::DockSide::Left) => {
|
// Combine the Properties panel and Block Palette into one row with the
|
||||||
row![properties, viewport_stack].width(Fill).height(Fill).into()
|
// 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 mut parts = left_items;
|
||||||
let workspace: Element<'_, Message> = if self.properties_dragging {
|
parts.push(viewport_stack.into());
|
||||||
let preview_side = self.properties_dock_preview.unwrap_or(self.properties_side);
|
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())
|
let preview = container(Space::new())
|
||||||
.width(Length::Fixed(properties_width))
|
.width(Length::Fixed(preview_width))
|
||||||
.height(Fill)
|
.height(Fill)
|
||||||
.style(|theme: &Theme| {
|
.style(|theme: &Theme| {
|
||||||
let palette = theme.palette();
|
let palette = theme.palette();
|
||||||
|
|
@ -1580,24 +1628,31 @@ impl OpenCADStudio {
|
||||||
} else {
|
} else {
|
||||||
workspace
|
workspace
|
||||||
};
|
};
|
||||||
let workspace: Element<'_, Message> = if self.properties_dragging
|
let workspace: Element<'_, Message> = if any_dragging || any_resizing {
|
||||||
|| self.properties_resizing
|
if self.properties_dragging || self.properties_resizing {
|
||||||
{
|
mouse_area(workspace)
|
||||||
mouse_area(workspace)
|
.on_move(Message::PropertiesDragMove)
|
||||||
.on_move(Message::PropertiesDragMove)
|
.on_release(Message::PropertiesDragRelease)
|
||||||
.on_release(Message::PropertiesDragRelease)
|
.interaction(if self.properties_resizing {
|
||||||
.interaction(if self.properties_resizing {
|
iced::mouse::Interaction::ResizingHorizontally
|
||||||
iced::mouse::Interaction::ResizingHorizontally
|
} else {
|
||||||
} else {
|
iced::mouse::Interaction::Grabbing
|
||||||
iced::mouse::Interaction::Grabbing
|
})
|
||||||
})
|
.into()
|
||||||
.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 {
|
} else {
|
||||||
workspace
|
workspace
|
||||||
};
|
};
|
||||||
let workspace = row![workspace, block_palette_el]
|
|
||||||
.width(Fill)
|
|
||||||
.height(Fill);
|
|
||||||
let command_line = self.command_line.view(
|
let command_line = self.command_line.view(
|
||||||
allow_autocomplete,
|
allow_autocomplete,
|
||||||
dyn_capturing,
|
dyn_capturing,
|
||||||
|
|
@ -2594,6 +2649,27 @@ fn properties_divider() -> Element<'static, Message> {
|
||||||
.into()
|
.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;
|
const START_ACTION_RADIUS: f32 = 6.0;
|
||||||
|
|
||||||
fn start_action_shape(mut style: button::Style) -> button::Style {
|
fn start_action_shape(mut style: button::Style) -> button::Style {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,30 @@ use crate::app::config::DockSide;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
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.
|
/// The dockable panels the application knows about. New palettes add a variant.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
|
@ -180,6 +204,23 @@ impl DockState {
|
||||||
.filter(|id| Some(**id) != expanded)
|
.filter(|id| Some(**id) != expanded)
|
||||||
.count()
|
.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.
|
/// Smallest docked width a panel may be dragged or sized to.
|
||||||
|
|
@ -304,4 +345,20 @@ mod tests {
|
||||||
0
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,19 +4,12 @@
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::modules::IconKind;
|
use crate::modules::IconKind;
|
||||||
use crate::scene::model::wire_model::WireModel;
|
use crate::scene::model::wire_model::WireModel;
|
||||||
|
use crate::ui::dock::{DockMsg, PanelId};
|
||||||
use iced::widget::canvas::{Frame, Path, Program, Stroke};
|
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};
|
use iced::{Background, Border, Color, Element, Fill, Length, Theme};
|
||||||
|
|
||||||
const PANEL_W: f32 = 260.0;
|
|
||||||
const TOOL_H: f32 = 22.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.
|
/// 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.
|
/// Fits the narrowest card (Small = 3 per row) at size 11 without wrapping.
|
||||||
const MAX_LABEL_CHARS: usize = 12;
|
const MAX_LABEL_CHARS: usize = 12;
|
||||||
|
|
@ -32,8 +25,6 @@ pub enum BlockPaletteMsg {
|
||||||
PickFile,
|
PickFile,
|
||||||
Insert(String),
|
Insert(String),
|
||||||
Refresh,
|
Refresh,
|
||||||
ToggleBar,
|
|
||||||
Close,
|
|
||||||
FilePicked(Result<std::path::PathBuf, String>),
|
FilePicked(Result<std::path::PathBuf, String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,7 +182,7 @@ impl<'a> Program<Message> for BlockPreviewCanvas<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the docked panel element from the palette state.
|
/// 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 query = palette.search.trim().to_lowercase();
|
||||||
let filtered: Vec<&BlockEntry> = palette
|
let filtered: Vec<&BlockEntry> = palette
|
||||||
.blocks
|
.blocks
|
||||||
|
|
@ -199,34 +190,54 @@ pub fn view(palette: &BlockPalette) -> Element<'_, Message> {
|
||||||
.filter(|block| query.is_empty() || block.name.to_lowercase().contains(&query))
|
.filter(|block| query.is_empty() || block.name.to_lowercase().contains(&query))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// ── Title bar (title, collapse, close) ──────────────────────────────────
|
// ── Dock chrome (title, pin, close) — matches the Properties panel ────
|
||||||
let title_bar = container(
|
let pin_icon = if auto_collapse {
|
||||||
row![
|
crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0)
|
||||||
text("Block Palette").size(12).color(TEXT),
|
} else {
|
||||||
iced::widget::Space::new().width(Fill),
|
crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0)
|
||||||
icon_button(
|
};
|
||||||
IconKind::Svg(include_bytes!("../../../assets/icons/ui/tri_right.svg")),
|
let pin = button(pin_icon)
|
||||||
BlockPaletteMsg::ToggleBar,
|
.on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::BlockPalette)))
|
||||||
),
|
.style(move |theme: &Theme, status| {
|
||||||
icon_button(
|
let mut style = button::subtle(theme, status);
|
||||||
IconKind::Svg(include_bytes!("../../../assets/icons/ui/close.svg")),
|
if auto_collapse {
|
||||||
BlockPaletteMsg::Close,
|
let palette = theme.palette();
|
||||||
),
|
style.background = Some(Background::Color(palette.primary.weak.color));
|
||||||
]
|
style.text_color = palette.primary.weak.text;
|
||||||
.spacing(4)
|
style.border.color = palette.primary.base.color;
|
||||||
.align_y(iced::Center),
|
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 {
|
.on_press(Message::Dock(DockMsg::DockGrab(PanelId::BlockPalette)))
|
||||||
background: Some(Background::Color(CARD_BG)),
|
.interaction(iced::mouse::Interaction::Grab);
|
||||||
border: Border {
|
|
||||||
color: PANEL_BORDER,
|
|
||||||
width: 1.0,
|
|
||||||
radius: 0.0.into(),
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.width(Fill)
|
|
||||||
.padding([5, 6]);
|
|
||||||
|
|
||||||
let search_input = text_input("Search blocks…", &palette.search)
|
let search_input = text_input("Search blocks…", &palette.search)
|
||||||
.on_input(|v| Message::BlockPalette(BlockPaletteMsg::Search(v)))
|
.on_input(|v| Message::BlockPalette(BlockPaletteMsg::Search(v)))
|
||||||
|
|
@ -253,7 +264,7 @@ pub fn view(palette: &BlockPalette) -> Element<'_, Message> {
|
||||||
} else {
|
} else {
|
||||||
"No matches"
|
"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_x(Fill)
|
||||||
.center_y(Fill)
|
.center_y(Fill)
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
|
|
@ -282,12 +293,12 @@ pub fn view(palette: &BlockPalette) -> Element<'_, Message> {
|
||||||
};
|
};
|
||||||
|
|
||||||
container(column![title_bar, header, body].spacing(6).padding(6))
|
container(column![title_bar, header, body].spacing(6).padding(6))
|
||||||
.width(Length::Fixed(PANEL_W))
|
.width(Length::Fixed(width))
|
||||||
.height(Fill)
|
.height(Fill)
|
||||||
.style(|_: &Theme| container::Style {
|
.style(|theme: &Theme| container::Style {
|
||||||
background: Some(Background::Color(PANEL_BG)),
|
background: Some(Background::Color(theme.palette().background.base.color)),
|
||||||
border: Border {
|
border: Border {
|
||||||
color: PANEL_BORDER,
|
color: theme.palette().background.neutral.color,
|
||||||
width: 1.0,
|
width: 1.0,
|
||||||
radius: 0.0.into(),
|
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).
|
// handles overflow, and this clips anything that still won't fit).
|
||||||
let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS))
|
let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS))
|
||||||
.size(11)
|
.size(11)
|
||||||
.color(TEXT)
|
.color(Color::WHITE)
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
.height(Length::Fixed(LABEL_LINE_H))
|
.height(Length::Fixed(LABEL_LINE_H))
|
||||||
.center();
|
.center();
|
||||||
|
|
@ -314,23 +325,27 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'
|
||||||
button(content)
|
button(content)
|
||||||
.on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone())))
|
.on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone())))
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
.style(move |_: &Theme, status| {
|
.style(move |theme: &Theme, status| {
|
||||||
let bg = if is_placing {
|
let palette = theme.palette();
|
||||||
CARD_ACTIVE
|
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 {
|
} else {
|
||||||
match status {
|
match status {
|
||||||
button::Status::Hovered | button::Status::Pressed => CARD_HOVER,
|
button::Status::Hovered | button::Status::Pressed => (strong, neutral),
|
||||||
_ => CARD_BG,
|
_ => (base, neutral),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
button::Style {
|
button::Style {
|
||||||
background: Some(Background::Color(bg)),
|
background: Some(Background::Color(bg)),
|
||||||
border: Border {
|
border: Border {
|
||||||
color: PANEL_BORDER,
|
color: border,
|
||||||
width: 1.0,
|
width: 1.0,
|
||||||
radius: 3.0.into(),
|
radius: 3.0.into(),
|
||||||
},
|
},
|
||||||
text_color: TEXT,
|
text_color: Color::WHITE,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -349,9 +364,11 @@ fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message>
|
||||||
.on_press(Message::BlockPalette(msg))
|
.on_press(Message::BlockPalette(msg))
|
||||||
.width(Length::Fixed(TOOL_H + 8.0))
|
.width(Length::Fixed(TOOL_H + 8.0))
|
||||||
.height(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 {
|
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,
|
_ => Color::TRANSPARENT,
|
||||||
})),
|
})),
|
||||||
border: Border {
|
border: Border {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue