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() }