feat(ui): add dockable properties panel

Preserve panel width across left/right docking and persist resize, position, and auto-collapse preferences.

Refs #348
Refs #650
This commit is contained in:
Hakan Seven committed 2026-08-04 13:46:02 +03:00
1 parent e35348525a
commit 4be8d9735c
8 files changed
+355 -27

No files matched your search

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3 H16 L15 9 L18 12 V14 H6 V12 L9 9 Z"/><path d="M12 14 V21"/></svg>

After

Width:  |  Height:  |  Size: 235 B

+27
View File
@@ -27,6 +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,
/// Add a newly selected annotation scale to existing annotative objects.
pub annotation_auto_scale: i8,
/// Ribbon collapse density.
@@ -44,6 +46,7 @@ impl Default for AppConfig {
recent: RecentConfig::default(),
start: StartConfig::default(),
statusbar: StatusBarConfig::default(),
properties: PropertiesDockConfig::default(),
annotation_auto_scale: -4,
ribbon: RibbonConfig::default(),
plot: PlotDialogState::default(),
@@ -51,6 +54,30 @@ impl Default for AppConfig {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DockSide {
Left,
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 {
+33
View File
@@ -305,6 +305,16 @@ pub(super) struct OpenCADStudio {
/// 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<Point>,
properties_dock_preview: Option<config::DockSide>,
/// 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.
@@ -1620,6 +1630,21 @@ pub enum Message {
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),
@@ -2790,6 +2815,14 @@ impl OpenCADStudio {
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(),
status_bar: StatusBar::new(),
cursor_pos: Point::ORIGIN,
+12
View File
@@ -520,6 +520,11 @@ 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,
},
annotation_auto_scale: self.annotation_auto_scale,
ribbon: crate::app::config::RibbonConfig {
collapse: self.ribbon.collapse_mode(),
@@ -550,6 +555,13 @@ 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;
self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4);
self.ribbon.set_collapse_mode(cfg.ribbon.collapse);
self.plot_dialog = cfg.plot;
+104
View File
@@ -350,6 +350,110 @@ impl OpenCADStudio {
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 },
+124 -23
View File
@@ -12,7 +12,7 @@ use iced::widget::{
stack, text, Row, Space,
};
use iced::window;
use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme};
use iced::{keyboard, Background, Border, Color, Element, Fill, Length, Subscription, Task, Theme};
use iced_aw::ContextMenu;
use crate::t;
@@ -1342,26 +1342,42 @@ impl OpenCADStudio {
}
}
// The Start tab shows its own three-panel page (Recent · Welcome ·
// Supporters) in place of the viewport, so the left properties slot is
// empty there.
let properties_el: Element<'_, Message> = if tab.is_start {
Space::new().into()
} else if self.show_properties && !self.clean_screen {
// On a narrow window the panel collapses to a vertical "Properties"
// bar to free width for the viewport; clicking the bar expands it.
let narrow = self.win_size.0 < 1000.0;
let bar = || collapse_bar("Properties", Message::TogglePropertiesBar);
if narrow && !self.props_expanded {
bar()
} else if narrow {
row![bar(), tab.properties.view()].into()
// 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<Element<'_, Message>> = 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),
)
} else {
tab.properties.view()
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()
}
} else {
Space::new().into()
};
});
// 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
@@ -1378,7 +1394,60 @@ impl OpenCADStudio {
(self.dyn_input && tab.active_cmd.is_some() && !tab.dyn_fields.is_empty())
|| self.mtext_editor.as_ref().is_some_and(|e| e.show_preview)
|| self.text_inline.is_some();
let workspace = row![properties_el, viewport_stack].width(Fill).height(Fill);
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()
}
(Some(properties), crate::app::config::DockSide::Right) => {
row![viewport_stack, properties].width(Fill).height(Fill).into()
}
(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 preview = container(Space::new())
.width(Length::Fixed(properties_width))
.height(Fill)
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(
palette.primary.weak.color.scale_alpha(0.72),
)),
border: Border {
color: palette.primary.base.color,
width: 2.0,
radius: 0.0.into(),
},
..Default::default()
}
});
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,
});
stack![workspace, preview].width(Fill).height(Fill).into()
} 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()
} else {
workspace
};
let command_line = self.command_line.view(
allow_autocomplete,
dyn_capturing,
@@ -2200,6 +2269,7 @@ fn pane_mouse_area<'a>(idx: usize) -> Element<'a, Message> {
/// Canvas that draws a label rotated 90° (for a collapsed panel's bar).
struct VBarLabel {
text: String,
clockwise: bool,
}
impl canvas::Program<Message> for VBarLabel {
@@ -2216,7 +2286,11 @@ impl canvas::Program<Message> for VBarLabel {
let mut frame = canvas::Frame::new(renderer, bounds.size());
frame.with_save(|frame| {
frame.translate(iced::Vector::new(bounds.width / 2.0, bounds.height / 2.0));
frame.rotate(iced::Radians(std::f32::consts::FRAC_PI_2));
frame.rotate(iced::Radians(if self.clockwise {
std::f32::consts::FRAC_PI_2
} else {
-std::f32::consts::FRAC_PI_2
}));
frame.fill_text(canvas::Text {
content: self.text.clone(),
position: iced::Point::ORIGIN,
@@ -2233,10 +2307,17 @@ impl canvas::Program<Message> for VBarLabel {
}
/// A collapsed panel rendered as a tall narrow bar with its name written along
/// it, rotated 90°. Pressing it emits `on_press`.
pub(super) fn collapse_bar<'a>(name: &str, on_press: Message) -> Element<'a, Message> {
/// 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,
) -> Element<'a, Message> {
let label = canvas(VBarLabel {
text: name.to_string(),
clockwise: side == crate::app::config::DockSide::Left,
})
.width(Fill)
.height(Fill);
@@ -2259,9 +2340,29 @@ pub(super) fn collapse_bar<'a>(name: &str, on_press: Message) -> Element<'a, Mes
)
.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()
}
const START_ACTION_RADIUS: f32 = 6.0;
fn start_action_shape(mut style: button::Style) -> button::Style {
+1
View File
@@ -64,6 +64,7 @@ pub const COPY: &[u8] = include_bytes!("../../assets/icons/ui/copy.svg");
pub const MENU: &[u8] = include_bytes!("../../assets/icons/ui/menu.svg");
pub const MOVE: &[u8] = include_bytes!("../../assets/icons/ui/move.svg");
pub const RESIZE: &[u8] = include_bytes!("../../assets/icons/ui/resize.svg");
pub const PIN: &[u8] = include_bytes!("../../assets/icons/ui/pin.svg");
pub const SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg");
pub const SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg");
pub const GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg");
+53 -4
View File
@@ -15,6 +15,7 @@ use acadrust::types::{Color as AcadColor, LineWeight};
use acadrust::Handle;
use iced::widget::{
button, canvas, column, combo_box, container, mouse_area, row, scrollable, text, text_input,
tooltip, Space,
};
use iced::{
mouse, Background, Border, Color, Element, Length, Padding, Point, Rectangle, Size, Theme,
@@ -367,9 +368,54 @@ impl PropertiesPanel {
.unwrap_or_default()
}
pub fn view(&self) -> Element<'_, Message> {
pub fn view(&self, width: f32, auto_collapse: bool) -> Element<'_, Message> {
// ── Header ──────────────────────────────────────────────────────────
let header = container(text(t!("Properties")).size(12))
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::PropertiesAutoCollapseToggle)
.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(t!("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::PropertiesClose)
.style(button::subtle)
.padding([3, 5]);
let close = tooltip(
close,
text(t!("Close")).size(10),
tooltip::Position::Bottom,
)
.gap(4);
let header = mouse_area(
container(
row![
text(t!("Properties")).size(12),
Space::new().width(Length::Fill),
pin,
close,
]
.spacing(3)
.align_y(iced::Center),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.weak.color,
@@ -377,7 +423,10 @@ impl PropertiesPanel {
..Default::default()
})
.width(Length::Fill)
.padding([6, 10]);
.padding([3, 6]),
)
.on_press(Message::PropertiesDockGrab)
.interaction(iced::mouse::Interaction::Grab);
// ── Title bar (entity type / "No Selection") ─────────────────────
let title_content: Element<'_, Message> = if self.selection_groups.is_empty() {
@@ -446,7 +495,7 @@ impl PropertiesPanel {
..Default::default()
}
})
.width(250)
.width(Length::Fixed(width))
.height(Length::Fill)
.into()
}