From 1de5597216d0707b580b0f11ef4fec994f41af0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:37:11 +0000 Subject: [PATCH] Dock: hover-only title buttons, tilted pin, mirrored float grip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docked title bars show the pin and close only while the pointer is over the bar; the bar keeps a fixed height so nothing jumps. - Auto-hide on is drawn as a 30° tilted pin (new pin_active.svg) instead of a coloured button background, on docked and floating bars. - A floating panel's resize grip sits in the bottom corner away from its title bar. With the bar on the right it is bottom-left (new mirrored resize_left.svg) and resizing from there keeps the right edge fixed. The bar stays on its side while resizing. - While a floating panel is dragged, only the drop preview shows it; the panel no longer stays drawn at its old place. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- assets/icons/ui/pin_active.svg | 1 + assets/icons/ui/resize_left.svg | 1 + src/app/mod.rs | 8 ++- src/app/update/dialog.rs | 23 +++++-- src/app/view/mod.rs | 92 ++++++++++++------------- src/ui/dock.rs | 117 ++++++++++++++++++++------------ src/ui/icons.rs | 4 ++ 7 files changed, 147 insertions(+), 99 deletions(-) create mode 100644 assets/icons/ui/pin_active.svg create mode 100644 assets/icons/ui/resize_left.svg diff --git a/assets/icons/ui/pin_active.svg b/assets/icons/ui/pin_active.svg new file mode 100644 index 000000000..3a291545c --- /dev/null +++ b/assets/icons/ui/pin_active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/ui/resize_left.svg b/assets/icons/ui/resize_left.svg new file mode 100644 index 000000000..bacde830e --- /dev/null +++ b/assets/icons/ui/resize_left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/mod.rs b/src/app/mod.rs index 8f79a88fd..48cb2fd26 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -863,8 +863,11 @@ pub(super) struct OpenCADStudio { pub(crate) dock_grab_offset: iced::Vector, /// Splitter being dragged between two slots on a side (upper, lower). pub(crate) dock_split_drag: Option<(crate::app::config::DockSide, usize, usize)>, - /// Floating panel being resized from its corner grip. - pub(crate) dock_float_resizing: Option, + /// Floating panel being resized from its corner grip, and whether from + /// the bottom-left corner. + pub(crate) dock_float_resizing: Option<(crate::ui::dock::PanelId, bool)>, + /// Docked panel whose title bar the pointer is over (shows pin / close). + pub(crate) dock_title_hover: Option, /// Reference-table column currently being width-resized (column index), /// with the last pointer position. Mirrors the Layers Name-column drag. pub(crate) xref_col_drag: Option, @@ -4304,6 +4307,7 @@ impl OpenCADStudio { dock_grab_offset: iced::Vector::new(0.0, 0.0), dock_split_drag: None, dock_float_resizing: None, + dock_title_hover: None, xref_col_drag: None, xref_col_last: None, xref_split_drag: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index b5d9b8578..3aeb66db5 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -580,14 +580,18 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.save_config(); iced::Task::none() } - DockMsg::FloatResizeGrab(id) => { + DockMsg::FloatResizeGrab(id, from_left) => { self.dock.raise_float(id); - self.dock_float_resizing = Some(id); + self.dock_float_resizing = Some((id, from_left)); self.dock_dragging = None; self.dock_drag_last = None; self.dock_drag_target = None; iced::Task::none() } + DockMsg::TitleHover(id) => { + self.dock_title_hover = id; + iced::Task::none() + } DockMsg::FloatRaise(id) => { self.dock.raise_float(id); iced::Task::none() @@ -707,12 +711,14 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven ); } } - } else if let Some(id) = self.dock_float_resizing { + } else if let Some((id, from_left)) = self.dock_float_resizing { if let (Some(last), Some(f)) = (self.dock_drag_last, self.dock.float_rect(id)) { + let dx = point.x - last.x; self.dock.resize_float( id, - f.w + point.x - last.x, + if from_left { f.w - dx } else { f.w + dx }, f.h + point.y - last.y, + from_left, ); } } else if let Some(id) = self.dock_resizing { @@ -2042,12 +2048,19 @@ mod tests { w: 260.0, h: 300.0, }); - let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette)); + let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, false)); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(360.0, 400.0))); let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(400.0, 450.0))); let _ = app.on_dock(DockMsg::DragRelease); let f = app.dock.float_rect(PanelId::BlockPalette).unwrap(); assert_eq!((f.w, f.h), (300.0, 350.0)); + // The bottom-left grip grows the panel leftward, keeping its right edge. + let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, true)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 450.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(60.0, 450.0))); + let _ = app.on_dock(DockMsg::DragRelease); + let g = app.dock.float_rect(PanelId::BlockPalette).unwrap(); + assert_eq!((g.x, g.w, g.h), (f.x - 40.0, 340.0, 350.0)); } #[test] diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 294311ca6..d2cedd212 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1930,7 +1930,9 @@ bg={bg_ms:.1}ms n={view_count}" // Floating panels sit over the workspace, back to front. let mut layers: Vec> = vec![workspace]; for f in &self.dock.floating { - if self.dock_panel_visible(f.id) { + // Once a drag is under way only its preview shows the panel. + let dragged = self.dock_dragging == Some(f.id) && self.dock_drag_target.is_some(); + if self.dock_panel_visible(f.id) && !dragged { layers.push(self.floating_panel(*f, tab)); } } @@ -1956,8 +1958,12 @@ bg={bg_ms:.1}ms n={view_count}" 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_float_resizing.is_some() { - iced::mouse::Interaction::ResizingDiagonallyDown + .interaction(if let Some((_, from_left)) = self.dock_float_resizing { + if from_left { + iced::mouse::Interaction::ResizingDiagonallyUp + } else { + iced::mouse::Interaction::ResizingDiagonallyDown + } } else if any_resizing { iced::mouse::Interaction::ResizingHorizontally } else if any_splitting { @@ -2897,6 +2903,7 @@ impl OpenCADStudio { let chrome = crate::ui::dock::Chrome { auto_collapse: self.dock.auto_collapse(id), floating, + title_hovered: self.dock_title_hover == Some(id), }; match id { crate::ui::dock::PanelId::Properties => tab.properties.view(width, chrome), @@ -2978,25 +2985,48 @@ impl OpenCADStudio { ) -> Element<'a, Message> { use crate::ui::dock::DockMsg; let (ww, wh) = self.dock_workspace_size(); - let bar_left = f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5; + // The bar faces the nearer workspace edge; while resizing, it stays + // opposite the grip being dragged so it cannot flip mid-drag. + let bar_left = match self.dock_float_resizing { + Some((id, from_left)) if id == f.id => !from_left, + _ => f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5, + }; let hidden = self.dock.auto_collapse(f.id) && self.dock_expanded != Some(f.id); let bar = floating_title_bar(f.id, self.dock.auto_collapse(f.id), bar_left); let content: Element<'_, Message> = if hidden { bar } else { + // The resize grip sits in the bottom corner away from the bar. + let grip_left = !bar_left; + let grip_icon = crate::ui::icons::themed_secondary( + if grip_left { + crate::ui::icons::RESIZE_LEFT + } else { + crate::ui::icons::RESIZE + }, + 12.0, + ); let grip = mouse_area( - container(crate::ui::icons::themed_secondary(crate::ui::icons::RESIZE, 12.0)) + container(grip_icon) .center_x(Length::Fixed(14.0)) .center_y(Length::Fixed(14.0)), ) - .on_press(Message::Dock(DockMsg::FloatResizeGrab(f.id))) - .interaction(iced::mouse::Interaction::ResizingDiagonallyDown); + .on_press(Message::Dock(DockMsg::FloatResizeGrab(f.id, grip_left))) + .interaction(if grip_left { + iced::mouse::Interaction::ResizingDiagonallyUp + } else { + iced::mouse::Interaction::ResizingDiagonallyDown + }); let body = stack![ self.panel_body(f.id, f.w, true, tab), container(grip) .width(Fill) .height(Fill) - .align_x(iced::alignment::Horizontal::Right) + .align_x(if grip_left { + iced::alignment::Horizontal::Left + } else { + iced::alignment::Horizontal::Right + }) .align_y(iced::alignment::Vertical::Bottom), ] .width(Length::Fixed(f.w)) @@ -3562,49 +3592,13 @@ fn floating_title_bar( on_left: bool, ) -> Element<'static, Message> { use crate::ui::dock::DockMsg; - let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0)) - .on_press(Message::Dock(DockMsg::Close(id))) - .style(button::subtle) - .padding([3, 5]); - let close = iced::widget::tooltip( - close, - text(t!("Close")).size(10), - if on_left { - iced::widget::tooltip::Position::Left - } else { - iced::widget::tooltip::Position::Right - }, - ) - .gap(4); - let pin_icon = if auto_collapse { - crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) + let tip = if on_left { + iced::widget::tooltip::Position::Left } else { - crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) + iced::widget::tooltip::Position::Right }; - let pin = button(pin_icon) - .on_press(Message::Dock(DockMsg::AutoCollapseToggle(id))) - .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 = iced::widget::tooltip( - pin, - text(t!("Auto-hide")).size(10), - if on_left { - iced::widget::tooltip::Position::Left - } else { - iced::widget::tooltip::Position::Right - }, - ) - .gap(4); + let close = crate::ui::dock::close_button(id, tip); + let pin = crate::ui::dock::pin_button(id, auto_collapse, tip); let title = canvas(VBarLabel { text: id.title().to_string(), clockwise: false, diff --git a/src/ui/dock.rs b/src/ui/dock.rs index d3cb445a8..c891011e8 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -38,8 +38,11 @@ pub enum DockMsg { SplitGrab(DockSide, usize, usize), /// Give every slot on a side the same height again. SplitReset(DockSide), - /// Begin resizing floating `panel` from its corner grip. - FloatResizeGrab(PanelId), + /// Begin resizing floating `panel` from its corner grip; `true` for the + /// bottom-left grip (a panel whose title bar is on the right). + FloatResizeGrab(PanelId, bool), + /// The pointer entered (`Some`) or left (`None`) a docked title bar. + TitleHover(Option), /// Bring floating `panel` to the front. FloatRaise(PanelId), /// The pointer left the edge column; collapse any auto-collapsing panel. @@ -454,10 +457,15 @@ impl DockState { } } - /// Resize a floating panel, keeping it at least a usable size. - pub fn resize_float(&mut self, id: PanelId, w: f32, h: f32) { + /// Resize a floating panel, keeping it at least a usable size. With + /// `keep_right` the right edge stays put (resizing from the left corner). + pub fn resize_float(&mut self, id: PanelId, w: f32, h: f32, keep_right: bool) { if let Some(f) = self.floating.iter_mut().find(|f| f.id == id) { - f.w = w.clamp(DOCK_MIN_W, id.max_width()); + let new_w = w.clamp(DOCK_MIN_W, id.max_width()); + if keep_right { + f.x += f.w - new_w; + } + f.w = new_w; f.h = h.max(FLOAT_MIN_H); } } @@ -582,6 +590,8 @@ pub struct Chrome { /// The panel floats; its title lives in the vertical side bar drawn by /// the floating frame instead of the horizontal title bar. pub floating: bool, + /// The pointer is over the title bar, which shows its pin and close. + pub title_hovered: bool, } /// The row every docked panel starts with: its title, the auto-collapse pin @@ -591,48 +601,65 @@ pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, if chrome.floating { return Space::new().width(0).height(0).into(); } - let auto_collapse = chrome.auto_collapse; - 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) + // The pin and close only show while the pointer is over the bar; the + // fixed height keeps the bar from jumping when they appear. + let mut bar = row![text(title).size(12), Space::new().width(Length::Fill)] + .spacing(3) + .height(Length::Fixed(TITLE_BUTTON_H)) + .align_y(iced::Center); + if chrome.title_hovered { + bar = bar + .push(pin_button(id, chrome.auto_collapse, tooltip::Position::Bottom)) + .push(close_button(id, tooltip::Position::Bottom)); + } + mouse_area( + container(bar) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .width(Length::Fill) + .padding([3, 6]), + ) + .on_press(Message::Dock(DockMsg::DockGrab(id))) + .on_enter(Message::Dock(DockMsg::TitleHover(Some(id)))) + .on_exit(Message::Dock(DockMsg::TitleHover(None))) + .interaction(iced::mouse::Interaction::Grab) + .into() +} + +/// Height of a title bar's button row. +const TITLE_BUTTON_H: f32 = 20.0; + +/// The auto-hide pin. While auto-hide is on the pin is drawn tilted instead +/// of on a coloured background. +pub fn pin_button<'a>(id: PanelId, auto_collapse: bool, tip: tooltip::Position) -> Element<'a, Message> { + let icon = crate::ui::icons::themed_secondary( + if auto_collapse { + crate::ui::icons::PIN_ACTIVE + } else { + crate::ui::icons::PIN + }, + 12.0, + ); + let pin = button(icon) .on_press(Message::Dock(DockMsg::AutoCollapseToggle(id))) - .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 - }) + .style(button::subtle) .padding([3, 5]); - let pin = tooltip(pin, text(crate::t!("Auto")).size(10), tooltip::Position::Bottom).gap(4); + tooltip(pin, text(crate::t!("Auto-hide")).size(10), tip) + .gap(4) + .into() +} + +/// The close button of a panel's title bar. +pub fn close_button<'a>(id: PanelId, tip: tooltip::Position) -> Element<'a, Message> { let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0)) .on_press(Message::Dock(DockMsg::Close(id))) .style(button::subtle) .padding([3, 5]); - let close = tooltip(close, text(crate::t!("Close")).size(10), tooltip::Position::Bottom).gap(4); - mouse_area( - container( - row![text(title).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)), - ..Default::default() - }) - .width(Length::Fill) - .padding([3, 6]), - ) - .on_press(Message::Dock(DockMsg::DockGrab(id))) - .interaction(iced::mouse::Interaction::Grab) - .into() + tooltip(close, text(crate::t!("Close")).size(10), tip) + .gap(4) + .into() } /// Side length of a panel toolbar button's icon. @@ -901,9 +928,13 @@ mod tests { w: 300.0, h: 300.0, }); - state.resize_float(PanelId::Count, 10.0, 10.0); + state.resize_float(PanelId::Count, 10.0, 10.0, false); let f = state.float_rect(PanelId::Count).unwrap(); assert_eq!((f.w, f.h), (DOCK_MIN_W, FLOAT_MIN_H)); + // From the left corner the right edge stays where it was. + state.resize_float(PanelId::Count, 350.0, 300.0, true); + let f = state.float_rect(PanelId::Count).unwrap(); + assert_eq!((f.x, f.w), (DOCK_MIN_W - 350.0, 350.0)); } #[test] @@ -970,8 +1001,8 @@ mod tests { #[test] fn floating_title_bar_replaces_the_horizontal_one() { let floating = Chrome { - auto_collapse: false, floating: true, + ..Default::default() }; let bar: Element<'_, Message> = title_bar(PanelId::Count, "Count".into(), floating); assert_eq!( diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 694b98a07..2330bd179 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -72,7 +72,11 @@ pub static COPY: &[u8] = include_bytes!("../../assets/icons/ui/copy.svg"); pub static MENU: &[u8] = include_bytes!("../../assets/icons/ui/menu.svg"); pub static MOVE: &[u8] = include_bytes!("../../assets/icons/ui/move.svg"); pub static RESIZE: &[u8] = include_bytes!("../../assets/icons/ui/resize.svg"); +/// `RESIZE` mirrored for a bottom-left corner grip. +pub static RESIZE_LEFT: &[u8] = include_bytes!("../../assets/icons/ui/resize_left.svg"); pub static PIN: &[u8] = include_bytes!("../../assets/icons/ui/pin.svg"); +/// `PIN` tilted 30°: the pin while auto-hide is on. +pub static PIN_ACTIVE: &[u8] = include_bytes!("../../assets/icons/ui/pin_active.svg"); pub static SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg"); pub static SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg"); pub static GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg");