diff --git a/assets/icons/ui/pin.svg b/assets/icons/ui/pin.svg
new file mode 100644
index 000000000..709f2a3c1
--- /dev/null
+++ b/assets/icons/ui/pin.svg
@@ -0,0 +1 @@
+
diff --git a/src/app/config.rs b/src/app/config.rs
index e83bda40e..1c764d0bf 100644
--- a/src/app/config.rs
+++ b/src/app/config.rs
@@ -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 {
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 6ea74c4a9..4b13eb7d2 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -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,
+ properties_dock_preview: Option,
/// Read-only editor buffer backing the command-line history dropdown, so
/// the log can be drag-selected across lines and copied (issue #232).
/// Rebuilt from the history each time the dropdown is opened.
@@ -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,
diff --git a/src/app/update/file.rs b/src/app/update/file.rs
index 4e1d8b810..c386d96d2 100644
--- a/src/app/update/file.rs
+++ b/src/app/update/file.rs
@@ -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;
diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs
index 5e0832102..9f617b668 100644
--- a/src/app/update/mod.rs
+++ b/src/app/update/mod.rs
@@ -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 },
diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs
index 9ee4d73d6..bcd20a22e 100644
--- a/src/app/view/mod.rs
+++ b/src/app/view/mod.rs
@@ -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> = 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 for VBarLabel {
@@ -2216,7 +2286,11 @@ impl canvas::Program 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 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 {
diff --git a/src/ui/icons.rs b/src/ui/icons.rs
index 2566b1830..1bf048164 100644
--- a/src/ui/icons.rs
+++ b/src/ui/icons.rs
@@ -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");
diff --git a/src/ui/properties.rs b/src/ui/properties.rs
index 56a1622ea..f07ea46cb 100644
--- a/src/ui/properties.rs
+++ b/src/ui/properties.rs
@@ -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()
}