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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
This commit is contained in:
Claude committed 2026-10-09 20:03:15 +00:00
1 parent 96b4479f58
commit 6c00b8bc91
2 files changed
+97 -24

No files matched your search

+70
View File
@@ -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(
<iced::Renderer as iced_test::core::renderer::Headless>::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;
+27 -24
View File
@@ -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,