From 6c00b8bc91b6c978c9dabf3238bb9cb6266c8eb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:03:15 +0000 Subject: [PATCH] Dock: fix double-click on title bars, centre the strip bar, close + menu - Double-clicking a docked or floating title bar did nothing. iced keeps a widget's state (including the first click of a double-click) only while the widget tree keeps its shape, and pressing a title bar starts a possible drag that wrapped the workspace in an extra pointer-capture layer, rebuilding the title bar and forgetting the first click. The workspace is now always inside the same stack and mouse area; the capture handlers are only attached while dragging. A test drives the real view through iced's UserInterface (keeping its cache between events, as the runtime does) and fails without this fix. - The shown group's edge bar was shorter than its row (the tooltip around the edge band sized it to its content), so it looked off centre. Band and bar now have explicit heights. - Picking a pallet in the + menu closes the menu. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/update/dialog.rs | 70 ++++++++++++++++++++++++++++++++++++++++ src/app/view/mod.rs | 51 +++++++++++++++-------------- 2 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index d394215e4..f799a40b1 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -649,6 +649,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } let task = self.dock_open_panel(id); self.dock.show_group_of(id); + // Adding a pallet is the end of the menu's job. + self.dock_edge_menu = None; self.save_config(); task } @@ -2438,6 +2440,8 @@ mod tests { assert_eq!(app.dock.left.len(), 2); assert_eq!(app.dock.left[1].panels, vec![PanelId::Browser]); assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); + assert_eq!(app.dock_edge_menu, None, "the menu closes after adding"); + let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left))); // Unchecking hides it but keeps its place. let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser)); assert!(!app.show_browser); @@ -2489,6 +2493,72 @@ mod tests { assert!(app.dock.float_rect(PanelId::Layers).is_some()); } + /// Drive the real view through iced's `UserInterface`: point at the + /// first text `label`, feed `events` one at a time, and between events + /// hand the published messages to the app and rebuild the view while + /// keeping the widget-tree cache, as the runtime does. + fn drive_view(app: &mut OpenCADStudio, label: &str, events: &[iced::Event]) { + use iced_test::core::{mouse, shell, widget, window, Size}; + use iced_test::runtime::user_interface::{Cache, UserInterface}; + use iced_test::selector::Bounded; + use iced_test::Selector; + let mut renderer = iced_test::futures::futures::executor::block_on( + ::new( + iced_test::core::renderer::Settings::default(), + None, + ), + ) + .expect("headless renderer"); + let size = Size::new(1600.0, 900.0); + let mut cache = Cache::default(); + let mut cursor = mouse::Cursor::Unavailable; + { + let mut ui = UserInterface::build(app.view_main(), size, cache, &mut renderer); + let mut find = Selector::find(label); + ui.operate(&renderer, &mut widget::operation::black_box(&mut find)); + if let widget::operation::Outcome::Some(Some(target)) = widget::Operation::finish(&find) { + if let Some(bounds) = target.visible_bounds() { + cursor = mouse::Cursor::Available(bounds.center()); + } + } + cache = ui.into_cache(); + } + assert!(matches!(cursor, mouse::Cursor::Available(_)), "{label} not found"); + for event in events { + let mut messages = Vec::new(); + { + let mut ui = UserInterface::build(app.view_main(), size, cache, &mut renderer); + let _ = ui.update( + &window::Headless, + &shell::Waker::noop(), + std::slice::from_ref(event), + cursor, + &mut renderer, + &mut messages, + ); + cache = ui.into_cache(); + } + for message in messages { + let _ = app.update(message); + } + } + } + + #[test] + fn docked_title_bar_double_click_floats_the_pallet_in_the_real_view() { + use crate::ui::dock::PanelId; + let mut app = dock_app(); + let press = iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)); + let release = + iced::Event::Mouse(iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left)); + // The block palette's title text sits in its docked title bar. + drive_view(&mut app, "Blocks", &[press.clone(), release.clone(), press, release]); + assert!( + app.dock.float_rect(PanelId::BlockPalette).is_some(), + "a double-click on a docked title bar floats the pallet" + ); + } + #[test] fn dock_double_clicks_float_and_dock_panels() { use crate::app::config::DockSide; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index eb069b62d..160c1683e 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1969,14 +1969,14 @@ bg={bg_ms:.1}ms n={view_count}" if let (Some(id), Some(target)) = (self.dock_dragging, self.dock_drag_target) { layers.push(self.dock_drop_preview(id, target)); } - let workspace: Element<'_, Message> = if layers.len() == 1 { - layers.pop().expect("workspace layer") - } else { - iced::widget::Stack::with_children(layers) - .width(Fill) - .height(Fill) - .into() - }; + // Always a stack (and, below, always a mouse area), even with nothing + // on top: iced keeps a widget's state only while the tree keeps its + // shape, and a title bar's double-click needs its first click to + // survive the drag that pressing the bar starts. + let workspace: Element<'_, Message> = iced::widget::Stack::with_children(layers) + .width(Fill) + .height(Fill) + .into(); let any_dragging = self.dock_dragging.is_some(); let any_splitting = self.dock_split_drag.is_some(); let any_resizing = self.dock_resizing.is_some() @@ -1984,8 +1984,10 @@ bg={bg_ms:.1}ms n={view_count}" || self.layer_col_dragging || self.xref_col_drag.is_some() || self.xref_split_drag; - let workspace: Element<'_, Message> = if any_dragging || any_resizing || any_splitting { - mouse_area(workspace) + let capturing = any_dragging || any_resizing || any_splitting; + let mut capture = mouse_area(workspace); + if capturing { + capture = capture .on_move(move |p| Message::Dock(crate::ui::dock::DockMsg::DragMove(p))) .on_release(Message::Dock(crate::ui::dock::DockMsg::DragRelease)) .interaction(if let Some((_, from_left)) = self.dock_float_resizing { @@ -2000,11 +2002,9 @@ bg={bg_ms:.1}ms n={view_count}" iced::mouse::Interaction::ResizingVertically } else { iced::mouse::Interaction::Grabbing - }) - .into() - } else { - workspace - }; + }); + } + let workspace: Element<'_, Message> = capture.into(); let command_line = self.command_line.view( allow_autocomplete, dyn_capturing, @@ -2905,8 +2905,16 @@ impl OpenCADStudio { } // The group grip: a band on the window side holding the edge bar. + // Explicit heights: the tooltip around the band sizes it to its + // content, so a `Fill` bar would stop short of the row's bottom. let bar_w = if lit { 5.0 } else { 3.0 }; - let bar = container(Space::new().width(bar_w).height(Fill)).style(move |theme: &Theme| { + let bar_inset = 5.0; + let bar = container( + Space::new() + .width(bar_w) + .height(Length::Fixed(row_h - 2.0 * bar_inset)), + ) + .style(move |theme: &Theme| { let palette = theme.palette(); let (color, glow) = if lit { (Some(palette.primary.weak.color), true) @@ -2939,13 +2947,8 @@ impl OpenCADStudio { let band = mouse_area( container(bar) .width(Length::Fixed(STRIP_GRIP_W)) - .height(Fill) - .padding(iced::Padding { - top: 5.0, - bottom: 5.0, - left: 0.0, - right: 0.0, - }) + .height(Length::Fixed(row_h)) + .align_y(iced::alignment::Vertical::Center) .align_x(match side { DockSide::Left => iced::alignment::Horizontal::Left, DockSide::Right => iced::alignment::Horizontal::Right, @@ -2963,7 +2966,7 @@ impl OpenCADStudio { .gap(4); let band = container(band) .width(Fill) - .height(Fill) + .height(Length::Fixed(row_h)) .align_x(match side { DockSide::Left => iced::alignment::Horizontal::Left, DockSide::Right => iced::alignment::Horizontal::Right,