Dock: move app-side dock code into app::dock, trim tests, drop icon tooltip

- The dock's state handling and drawing move out of update/dialog.rs and
  view/mod.rs into src/app/dock/ (update.rs, view.rs, tests.rs). The
  overlay view_main stacked over the workspace (floating pallets, + menu,
  drag preview, pointer capture) becomes one `dock_overlay` call. Code is
  moved, not changed.
- Strip icons no longer show the pallet name on hover.
- Tests trimmed where they overlapped: the top / bottom half drops are
  one test, the two hover tests are one, and the in-group icon reorder,
  raise-float, empty floating title bar and slot-span tests are dropped
  (each is covered by another test).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
This commit is contained in:
Claude committed 2026-10-09 20:25:34 +00:00
1 parent 4db24b3bbd
commit 7448472291
8 files changed
+2266 -2306

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
//! The app side of docking: pallets docked on the drawing view's edges (one
//! group shown per edge, listed in an icon strip) or floating over it.
//!
//! The persisted layout and its pure geometry live in [`crate::ui::dock`];
//! this module holds the transient state handling (`update`) and drawing
//! (`view`).
mod update;
mod view;
#[cfg(test)]
mod tests;
+570
View File
@@ -0,0 +1,570 @@
//! Dock behaviour through the app: drags, hover, menus, and the real view.
use crate::app::{Message, OpenCADStudio};
fn fresh() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app
}
#[test]
fn blockpalette_pin_toggles_autocollapse_and_close_hides() {
let mut app = fresh();
app.show_block_palette = true;
let id = crate::ui::dock::PanelId::BlockPalette;
let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
assert!(app.dock.auto_hides(id), "pin enables auto-hide");
let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
assert!(!app.dock.auto_hides(id), "second pin disables auto-hide");
let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id));
assert!(!app.show_block_palette, "close dismisses the sidebar");
}
/// A hermetic app with a 1600×900 workspace and the default dock layout
/// (Properties left, block palette right), both shown.
fn dock_app() -> OpenCADStudio {
let mut app = fresh();
// Tests load the user's persisted config; reset the dock to a known
// state so this stays hermetic.
app.dock = Default::default();
app.dock.ensure_settings();
app.show_properties = true;
app.show_block_palette = true;
let i = app.active_tab;
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0);
app.win_size = (1600.0, 900.0).into();
app
}
/// Where the current dock drag would land, if it has started.
fn drag_target(app: &OpenCADStudio) -> Option<crate::ui::dock::DropTarget> {
app.dock_drag.and_then(|d| d.movement()).and_then(|(_, _, target)| target)
}
/// Press `id`'s title bar at `from`, drag to `to`, and return the target
/// shown just before release.
fn drag_panel(
app: &mut OpenCADStudio,
id: crate::ui::dock::PanelId,
from: iced::Point,
to: iced::Point,
) -> Option<crate::ui::dock::DropTarget> {
use crate::ui::dock::DockMsg;
let _ = app.on_dock(DockMsg::DockGrab(id));
let _ = app.on_dock(DockMsg::DragMove(from));
let _ = app.on_dock(DockMsg::DragMove(to));
let target = drag_target(app);
let _ = app.on_dock(DockMsg::DragRelease);
target
}
#[test]
fn dropping_on_a_pallets_top_or_bottom_half_stacks_above_or_below_it() {
use crate::app::config::DockSide;
use crate::ui::dock::{DropTarget, PanelId};
let mut app = dock_app();
let id = PanelId::BlockPalette;
assert_eq!(app.dock.location(id), Some((DockSide::Right, 0)));
let target = drag_panel(
&mut app,
id,
iced::Point::new(1500.0, 10.0),
iced::Point::new(150.0, 300.0),
);
assert_eq!(
target,
Some(DropTarget::Join {
side: DockSide::Left,
group: 0,
index: 0
})
);
assert_eq!(
app.dock.left[0].panels,
vec![PanelId::BlockPalette, PanelId::Properties]
);
assert!(app.dock.right.is_empty());
// The bottom half of a pallet stacks below it.
let target = drag_panel(
&mut app,
id,
iced::Point::new(150.0, 100.0),
iced::Point::new(150.0, 800.0),
);
assert_eq!(
target,
Some(DropTarget::Join {
side: DockSide::Left,
group: 0,
index: 2
})
);
assert_eq!(
app.dock.left[0].panels,
vec![PanelId::Properties, PanelId::BlockPalette]
);
}
#[test]
fn dock_dragging_a_lone_pallet_over_its_own_group_is_a_no_op() {
use crate::app::config::DockSide;
use crate::ui::dock::{DropTarget, PanelId};
let mut app = dock_app();
let before = app.dock.clone();
// The right edge's only group holds just the block palette; drag it
// around over itself.
let target = drag_panel(
&mut app,
PanelId::BlockPalette,
iced::Point::new(1500.0, 10.0),
iced::Point::new(1450.0, 600.0),
);
assert_eq!(
target,
Some(DropTarget::Join {
side: DockSide::Right,
group: 0,
index: 1
})
);
assert_eq!(app.dock, before);
}
#[test]
fn dock_click_without_drag_changes_nothing() {
let mut app = dock_app();
let before = app.dock.clone();
let id = crate::ui::dock::PanelId::BlockPalette;
let p = iced::Point::new(1500.0, 10.0);
let target = drag_panel(&mut app, id, p, iced::Point::new(1502.0, 11.0));
assert_eq!(target, None, "movement under the threshold is a click");
assert_eq!(app.dock, before);
}
#[test]
fn dock_drop_below_the_strip_groups_starts_a_new_shown_group() {
use crate::app::config::DockSide;
use crate::ui::dock::{DropTarget, PanelId};
let mut app = dock_app();
let (_, plus_top) = app.dock_strip_layout(DockSide::Left);
let target = drag_panel(
&mut app,
PanelId::BlockPalette,
iced::Point::new(1500.0, 10.0),
iced::Point::new(18.0, plus_top + 10.0),
);
assert_eq!(
target,
Some(DropTarget::Edge {
side: DockSide::Left,
index: 1
})
);
assert_eq!(app.dock.left.len(), 2);
assert_eq!(app.dock.left[1].panels, vec![PanelId::BlockPalette]);
// The new group is the one shown: the column holds only the palette.
assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]);
}
#[test]
fn dock_icon_press_switches_the_shown_group() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, DockMsg, PanelId};
let mut app = dock_app();
app.show_browser = true;
app.dock.left = vec![
DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]),
DockGroup::single(PanelId::Browser),
];
app.dock.right.clear();
// Group 0 shows both stacked pallets.
assert_eq!(app.dock_slot_spans(DockSide::Left).len(), 2);
let _ = app.on_dock(DockMsg::IconPress(PanelId::Browser));
let _ = app.on_dock(DockMsg::DragRelease);
assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]);
// Each group keeps its own width.
app.dock.set_width(PanelId::Browser, 400.0);
assert_eq!(app.dock_column_width(DockSide::Left), 400.0);
let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette));
let _ = app.on_dock(DockMsg::DragRelease);
assert_eq!(app.dock_column_width(DockSide::Left), 260.0);
}
#[test]
fn dock_drop_over_the_viewport_floats_the_panel() {
use crate::ui::dock::{DropTarget, PanelId};
let mut app = dock_app();
let target = drag_panel(
&mut app,
PanelId::BlockPalette,
iced::Point::new(1500.0, 10.0),
iced::Point::new(800.0, 400.0),
);
assert!(matches!(target, Some(DropTarget::Float { .. })));
assert!(app.dock.right.is_empty());
let f = app.dock.float_rect(PanelId::BlockPalette).expect("floating");
assert!(f.x > 600.0 && f.x < 800.0 && f.y > 350.0 && f.y < 400.0);
// Dragging a floating panel keeps the grab point under the pointer.
let target = drag_panel(
&mut app,
PanelId::BlockPalette,
iced::Point::new(f.x + 20.0, f.y + 10.0),
iced::Point::new(f.x + 120.0, f.y + 60.0),
);
assert_eq!(target, Some(DropTarget::Float { x: f.x + 100.0, y: f.y + 50.0 }));
// Opening a floating panel must not re-dock it.
assert!(app.dock.is_placed(PanelId::BlockPalette));
}
#[test]
fn dock_dragging_an_icon_into_another_group_lands_between_its_icons() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, DockMsg, DropTarget, PanelId, STRIP_CELL_H};
let mut app = dock_app();
app.show_browser = true;
app.dock.left = vec![
DockGroup::stack(vec![PanelId::Properties, PanelId::Browser]),
DockGroup::single(PanelId::BlockPalette),
];
app.dock.right.clear();
let (layout, _) = app.dock_strip_layout(DockSide::Left);
// Drag the Blocks icon (group 1) to between Properties and Browser.
let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(20.0, layout[1].icons_top + 10.0)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(
20.0,
layout[0].icons_top + STRIP_CELL_H + 2.0,
)));
assert_eq!(
drag_target(&app),
Some(DropTarget::Join {
side: DockSide::Left,
group: 0,
index: 1
})
);
let _ = app.on_dock(DockMsg::DragRelease);
assert_eq!(app.dock.left.len(), 1, "the emptied group goes away");
assert_eq!(
app.dock.left[0].panels,
vec![PanelId::Properties, PanelId::BlockPalette, PanelId::Browser]
);
}
#[test]
fn dock_grip_drag_moves_a_whole_group() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, DockMsg, PanelId};
let mut app = dock_app();
app.show_browser = true;
app.dock.left = vec![
DockGroup::stack(vec![PanelId::Properties, PanelId::Browser]),
DockGroup::single(PanelId::BlockPalette),
];
app.dock.right.clear();
let (layout, plus_top) = app.dock_strip_layout(DockSide::Left);
let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 0));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, layout[0].top + 2.0)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0)));
let _ = app.on_dock(DockMsg::DragRelease);
assert_eq!(app.dock.left[0].panels, vec![PanelId::BlockPalette]);
assert_eq!(
app.dock.left[1].panels,
vec![PanelId::Properties, PanelId::Browser]
);
// A group dropped over the viewport does not float or split up.
let before = app.dock.clone();
let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 1));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, 300.0)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0)));
assert_eq!(drag_target(&app), None);
let _ = app.on_dock(DockMsg::DragRelease);
assert_eq!(app.dock, before);
}
#[test]
fn dock_closed_pallets_take_no_room_and_empty_groups_hide() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, PanelId};
let mut app = dock_app();
app.dock.left = vec![
DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties]),
DockGroup::single(PanelId::Browser),
];
app.show_block_palette = false;
app.show_browser = false;
// Only Properties is open: one listed group, full height.
assert_eq!(app.dock_visible_groups(DockSide::Left), vec![0]);
assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(1, 0.0, 900.0)]);
// A shown group with nothing open falls back to one that has.
app.dock.show_group(DockSide::Left, 1);
assert_eq!(app.dock_shown_group(DockSide::Left), Some(0));
}
#[test]
fn dock_splitter_drag_changes_pallet_heights() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, DockMsg, PanelId};
let mut app = dock_app();
app.dock.left = vec![DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties])];
app.dock.right.clear();
let _ = app.on_dock(DockMsg::SplitGrab(DockSide::Left, 0, 0, 1));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 450.0)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 675.0)));
let _ = app.on_dock(DockMsg::DragRelease);
// 225 px of a 900 px edge holding 2 weight units = 0.5 units.
let w = &app.dock.left[0].weights;
assert!((w[0] - 1.5).abs() < 1e-4 && (w[1] - 0.5).abs() < 1e-4);
let _ = app.on_dock(DockMsg::SplitReset(DockSide::Left, 0));
assert_eq!(app.dock.left[0].weights, vec![1.0, 1.0]);
}
#[test]
fn dock_hover_reveals_groups_only_on_an_auto_hiding_edge() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, DockMsg, PanelId};
let mut app = dock_app();
app.show_browser = true;
app.dock.left.push(DockGroup::single(PanelId::Browser));
// The pin on any docked pallet turns auto-hide on for its edge.
let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties));
assert!(app.dock.edge_auto_hide(DockSide::Left));
assert!(!app.dock.edge_auto_hide(DockSide::Right));
assert!(!app.dock_edge_expanded(DockSide::Left));
// Hovering an icon flies its group out.
let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Browser)));
assert!(app.dock_edge_expanded(DockSide::Left));
assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
let _ = app.on_dock(DockMsg::HoverExit);
assert!(!app.dock_edge_expanded(DockSide::Left));
// Without auto-hide, hovering does not switch groups (clicks do).
let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties));
let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Properties)));
assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
assert!(app.dock_edge_expanded(DockSide::Left));
}
#[test]
fn dock_float_resize_grows_the_panel() {
use crate::ui::dock::{DockMsg, FloatPanel, PanelId};
let mut app = dock_app();
app.dock.float(FloatPanel {
id: PanelId::BlockPalette,
x: 100.0,
y: 100.0,
w: 260.0,
h: 300.0,
});
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]
fn dock_edge_menu_adds_and_hides_pallets() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockMsg, PanelId};
let mut app = dock_app();
app.show_browser = false;
let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left)));
// Checking a closed pallet opens it as a new, shown group here.
let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser));
assert!(app.show_browser);
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);
assert_eq!(app.dock.location(PanelId::Browser), Some((DockSide::Left, 1)));
// Opening it again from the same edge reuses that place.
let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser));
assert_eq!(app.dock.left.len(), 2);
// A pallet open on the other edge is not checked here: picking it
// moves it over instead of hiding it.
let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::BlockPalette));
assert!(app.show_block_palette);
assert!(app.dock.right.is_empty());
assert_eq!(app.dock.location(PanelId::BlockPalette), Some((DockSide::Left, 2)));
}
#[test]
fn dock_divider_resizes_only_its_group() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, DockMsg, PanelId};
let mut app = dock_app();
app.dock.left = vec![
DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]),
DockGroup::single(PanelId::Count),
];
app.dock.right.clear();
let _ = app.on_dock(DockMsg::ResizeGrab(PanelId::Properties));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(300.0, 100.0)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(330.0, 100.0)));
let _ = app.on_dock(DockMsg::DragRelease);
// The group was 260 wide (its widest pallet); it is now 290, while
// the other group and the pallets' own widths are untouched.
assert_eq!(app.dock_group_width(DockSide::Left, 0), 290.0);
assert_eq!(app.dock_group_width(DockSide::Left, 1), 280.0);
assert_eq!(app.dock.settings(PanelId::Properties).width, 250.0);
}
#[test]
fn layers_command_opens_a_floating_pallet_and_toggles_it_closed() {
use crate::ui::dock::PanelId;
let mut app = dock_app();
let _ = app.update(Message::ToggleLayers);
assert!(app.show_layers);
assert!(app.active_modal.is_none(), "no modal backdrop");
let f = app.dock.float_rect(PanelId::Layers).expect("floats on first use");
assert!(f.x > 0.0 && f.y >= 0.0);
let _ = app.update(Message::ToggleLayers);
assert!(!app.show_layers);
// Its place is kept for next time.
assert!(app.dock.float_rect(PanelId::Layers).is_some());
}
/// A headless renderer for driving the real view.
fn headless_renderer() -> iced::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")
}
/// Size the real view is laid out at.
const VIEW_SIZE: iced::Size = iced::Size::new(1600.0, 900.0);
/// Centre of the first text `label` in the real view.
fn find_in_view(app: &OpenCADStudio, label: &str) -> iced::Point {
use iced_test::core::widget;
use iced_test::runtime::user_interface::{Cache, UserInterface};
use iced_test::Selector;
let mut renderer = headless_renderer();
let mut ui = UserInterface::build(app.view_main(), VIEW_SIZE, Cache::default(), &mut renderer);
let mut find = Selector::find(label);
ui.operate(&renderer, &mut widget::operation::black_box(&mut find));
match widget::Operation::finish(&find) {
widget::operation::Outcome::Some(Some(target)) => target
.visible_bounds()
.expect("visible")
.center(),
_ => panic!("{label} not found"),
}
}
/// Drive the real view through iced's `UserInterface` with the pointer at
/// `at`: 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, at: iced::Point, events: &[iced::Event]) {
use iced_test::core::{mouse, shell, window};
use iced_test::runtime::user_interface::{Cache, UserInterface};
let mut renderer = headless_renderer();
let mut cache = Cache::default();
let cursor = mouse::Cursor::Available(at);
for event in events {
let mut messages = Vec::new();
{
let mut ui = UserInterface::build(app.view_main(), VIEW_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.
let title = find_in_view(&app, "Blocks");
drive_view(&mut app, title, &[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 hovering_a_strip_icon_reveals_an_auto_hiding_edge_in_the_real_view() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockMsg, PanelId, STRIP_CELL_H, STRIP_PAD};
let mut app = dock_app();
// Mirror the layout onto the left so the block palette's title marks
// where the workspace starts, then auto-hide the right edge.
let title = find_in_view(&app, "Blocks");
let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::BlockPalette));
assert!(!app.dock_edge_expanded(DockSide::Right));
// The title bar text sits ~13 px below the workspace top; the strip's
// first icon is centred STRIP_PAD + half a cell below it.
let workspace_top = title.y - 13.0;
let icon = iced::Point::new(
VIEW_SIZE.width - crate::ui::dock::DOCK_STRIP_W / 2.0,
workspace_top + STRIP_PAD + STRIP_CELL_H / 2.0,
);
drive_view(
&mut app,
icon,
&[iced::Event::Mouse(iced::mouse::Event::CursorMoved { position: icon })],
);
assert!(
app.dock_edge_expanded(DockSide::Right),
"hovering the icon reveals the auto-hiding edge"
);
}
#[test]
fn dock_double_clicks_float_and_dock_panels() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockMsg, PanelId};
let mut app = dock_app();
let _ = app.on_dock(DockMsg::FloatOut(PanelId::Properties));
assert!(app.dock.left.is_empty());
let f = app.dock.float_rect(PanelId::Properties).expect("floating");
assert!(f.x > 0.0, "floats clear of the left edge");
let _ = app.on_dock(DockMsg::DockTo(PanelId::Properties, DockSide::Right));
assert!(app.dock.floating.is_empty());
assert_eq!(app.dock.location(PanelId::Properties), Some((DockSide::Right, 1)));
}
#[test]
fn blockpalette_width_reset() {
use crate::app::config::DockSide;
let mut app = dock_app();
let id = crate::ui::dock::PanelId::BlockPalette;
app.dock.set_group_width(DockSide::Right, 0, 500.0);
let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id));
assert_eq!(app.dock.group_width(DockSide::Right, 0), 260.0);
}
+633
View File
@@ -0,0 +1,633 @@
//! Dock state changes: drags, hover, open / close, and the queries the view
//! and drop targeting share.
use crate::app::{Message, OpenCADStudio};
/// Pointer travel (px) before pressing a title bar or icon becomes a drag,
/// so a plain click never moves anything.
const DOCK_DRAG_THRESHOLD: f32 = 5.0;
impl OpenCADStudio {
/// Dock chrome interaction (grab / pin / resize / hover / move) applied to
/// whichever panel the message names.
pub(crate) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task<Message> {
use crate::app::config::DockSide;
use crate::ui::dock::{DockDrag, DockMsg, DropTarget, FloatPanel};
match m {
DockMsg::DockGrab(id) => self.dock_begin(DockDrag::moving(id, None), Some(id)),
DockMsg::IconPress(id) => {
// An icon press shows the icon's group and may also start
// dragging the pallet; a plain click never passes the drag
// threshold.
if self.dock.show_group_of(id) {
self.save_config();
}
self.dock_begin(DockDrag::moving(id, None), Some(id));
}
DockMsg::GroupGrab(side, gi) => {
if let Some(&id) = self.dock_group_visible(side, gi).first() {
self.dock_begin(DockDrag::moving(id, Some((side, gi))), Some(id));
}
}
DockMsg::ResizeGrab(id) => self.dock_begin(DockDrag::Width(id), Some(id)),
DockMsg::SplitGrab(side, group, upper, lower) => self.dock_begin(
DockDrag::Split {
side,
group,
upper,
lower,
},
None,
),
DockMsg::FloatResizeGrab(panel, from_left) => {
self.dock.raise_float(panel);
self.dock_begin(DockDrag::FloatSize { panel, from_left }, None);
}
DockMsg::SplitReset(side, gi) => {
self.dock.reset_splits(side, gi);
self.save_config();
}
DockMsg::WidthReset(id) => {
if let Some((side, gi)) = self.dock.location(id) {
self.dock.reset_group_width(side, gi);
} else {
self.dock.reset_width(id);
}
self.save_config();
}
DockMsg::TitleHover(id) => self.dock_title_hover = id,
DockMsg::GripHover(grip) => self.dock_grip_hover = grip,
DockMsg::FloatRaise(id) => self.dock.raise_float(id),
DockMsg::FloatOut(id) => {
// Float beside the edge it was docked on, near the top.
let (ww, _) = self.dock_workspace_size();
let (w, h) = self.dock_float_size(id);
let strip = crate::ui::dock::DOCK_STRIP_W;
let x = match self.dock.location(id) {
Some((DockSide::Right, _)) => {
ww - strip - self.dock_column_width(DockSide::Right) - w - 60.0
}
_ => strip + self.dock_column_width(DockSide::Left) + 40.0,
};
self.dock_drag = None;
if self.dock.float(FloatPanel {
id,
x: x.max(0.0),
y: 40.0,
w,
h,
}) {
self.save_config();
}
}
DockMsg::DockTo(id, side) => {
self.dock_drag = None;
if self.dock.dock(id, side, usize::MAX) {
self.save_config();
}
}
DockMsg::EdgeMenu(side) => self.dock_edge_menu = side,
DockMsg::EdgeMenuToggle(side, id) => {
// Checked = open and docked on this edge: unchecking hides it.
// Anything else (closed, floating, on the other edge) moves
// here and opens, in its own group here if it has one, else
// as a new group; adding a pallet ends the menu's job.
let here = self.dock.location(id).map(|(s, _)| s) == Some(side);
if !here {
self.dock.dock(id, side, usize::MAX);
} else if self.dock_panel_visible(id) {
let task = self.dock_set_open(id, false);
self.save_config();
return task;
}
self.dock_edge_menu = None;
let task = self.dock_set_open(id, true);
self.save_config();
return task;
}
DockMsg::AutoCollapseToggle(id) => {
// Docked: auto-hide for the whole edge; floating: this pallet.
if self.dock.toggle_auto_hide(id) {
self.dock_peek = None;
}
self.save_config();
}
DockMsg::Close(id) => return self.dock_set_open(id, false),
DockMsg::Hover(id) => {
self.dock_icon_hover = id;
// Only an auto-hiding edge (or floating pallet) reacts to
// hover: it reveals the hovered pallet's group. Otherwise
// groups switch on click. Ignored mid-drag, when the pointer
// is over the preview rather than the strip.
if let Some(id) = id {
if self.dock_drag.is_none() && self.dock.auto_hides(id) {
self.dock_reveal(id);
}
}
}
DockMsg::HoverExit => {
if self.dock_drag.is_none()
&& self.dock_peek.is_some_and(|id| self.dock.auto_hides(id))
{
self.dock_peek = None;
}
}
DockMsg::DragMove(point) => self.dock_drag_move(point),
DockMsg::DragRelease => {
let mut changed = false;
match self.dock_drag.take() {
Some(DockDrag::Move {
group: Some((side, gi)),
target: Some(DropTarget::Edge { side: to, index }),
..
}) => changed = self.dock.move_group(side, gi, to, index),
Some(DockDrag::Move {
panel,
group: None,
target: Some(target),
..
}) => {
changed = match target {
DropTarget::Edge { side, index } => self.dock.dock(panel, side, index),
DropTarget::Join { side, group, index } => {
self.dock.join_group(panel, side, group, index)
}
DropTarget::Float { x, y } => {
let (w, h) = self.dock_float_size(panel);
self.dock.float(FloatPanel {
id: panel,
x,
y,
w,
h,
})
}
};
}
Some(DockDrag::Move { .. } | DockDrag::LayerColumn) | None => {}
Some(DockDrag::Width(_) | DockDrag::Split { .. } | DockDrag::FloatSize { .. }) => {
changed = true;
}
}
if changed {
self.save_config();
}
self.dock_drag_last = None;
self.xref_col_drag = None;
self.xref_col_last = None;
self.xref_split_drag = false;
}
}
iced::Task::none()
}
/// Pointer motion while something in the dock is dragged.
fn dock_drag_move(&mut self, point: iced::Point) {
// NOTE: xref column drags intentionally do NOT ride this path: its
// points live in workspace space while the header tracker reports
// header-local points, and mixing the two produced a one-time jump
// plus a stuck drag. Column moves arrive via Message::XrefColMove
// only.
use crate::app::config::DockSide;
use crate::ui::dock::{DockDrag, DropTarget};
let last = self.dock_drag_last.replace(point);
let Some(drag) = self.dock_drag else {
return;
};
match drag {
DockDrag::Move {
panel,
group,
origin,
target,
grab,
} => {
let origin = origin.unwrap_or(point);
let moved = (point.x - origin.x).hypot(point.y - origin.y);
let started = target.is_some() || moved >= DOCK_DRAG_THRESHOLD;
// Hold a floating panel where it was grabbed; a docked one by
// its title bar.
let grab = if target.is_none() && started {
match self.dock.float_rect(panel) {
Some(f) => iced::Vector::new(origin.x - f.x, origin.y - f.y),
None => iced::Vector::new(
(self.dock.width(panel, self.win_size.0) * 0.5).min(120.0),
14.0,
),
}
} else {
grab
};
// Store the grab first: the drop target reads it.
self.dock_drag = Some(DockDrag::Move {
panel,
group,
origin: Some(origin),
target,
grab,
});
if !started {
return;
}
let found = self.dock_drop_target(point);
// A whole group only moves between group positions: it
// cannot join another group or float.
let target = match (group, found) {
(Some(_), DropTarget::Edge { .. }) | (None, _) => Some(found),
(Some(_), _) => None,
};
self.dock_drag = Some(DockDrag::Move {
panel,
group,
origin: Some(origin),
target,
grab,
});
}
DockDrag::Split {
side,
group,
upper,
lower,
} => {
let Some(last) = last else { return };
let (_, avail) = self.dock_workspace_size();
let total: f32 = self
.dock_slot_spans(side)
.iter()
.map(|(i, _, _)| self.dock.groups(side)[group].weights[*i])
.sum();
if avail > 0.0 && total > 0.0 {
let per_px = total / avail;
self.dock.shift_split(
side,
group,
upper,
lower,
(point.y - last.y) * per_px,
crate::ui::dock::GROUP_MIN_H * per_px,
);
}
}
DockDrag::FloatSize { panel, from_left } => {
let (Some(last), Some(f)) = (last, self.dock.float_rect(panel)) else {
return;
};
let dx = point.x - last.x;
self.dock.resize_float(
panel,
if from_left { f.w - dx } else { f.w + dx },
f.h + point.y - last.y,
from_left,
);
}
DockDrag::LayerColumn => {
let Some(last) = last else { return };
self.layer_name_col_w =
(self.layer_name_col_w + point.x - last.x).clamp(60.0, 640.0);
}
DockDrag::Width(id) => {
let Some(last) = last else { return };
let dx = point.x - last.x;
match self.dock.location(id) {
// The divider sizes the panel's group.
Some((side, gi)) => {
let delta = if side == DockSide::Left { dx } else { -dx };
let cur = self.dock_group_width(side, gi) + delta;
self.dock.set_group_width(side, gi, cur);
}
None => {
let cur = self.dock.settings(id).width + dx;
self.dock.set_width(id, cur);
}
}
}
}
}
/// Start `drag`, peeking `peek` (a pallet being dragged or resized stays
/// shown on an auto-hiding edge).
fn dock_begin(&mut self, drag: crate::ui::dock::DockDrag, peek: Option<crate::ui::dock::PanelId>) {
if let crate::ui::dock::DockDrag::Move { panel, .. } = drag {
self.dock.raise_float(panel);
}
self.dock_drag = Some(drag);
self.dock_drag_last = None;
self.dock_edge_menu = None;
if peek.is_some() {
self.dock_peek = peek;
}
}
/// Bring `id` into view: show its group on its edge and, where it
/// auto-hides, keep it revealed until the pointer leaves.
pub(crate) fn dock_reveal(&mut self, id: crate::ui::dock::PanelId) {
self.dock.show_group_of(id);
self.dock_peek = Some(id);
}
/// Open or close pallet `id`, running its usual open path (refreshes,
/// ribbon highlight) or close path. An opened pallet is revealed where
/// the layout has it.
pub(crate) fn dock_set_open(
&mut self,
id: crate::ui::dock::PanelId,
open: bool,
) -> iced::Task<Message> {
use crate::ui::dock::PanelId;
if open {
let task = if self.dock_panel_visible(id) {
iced::Task::none()
} else {
match id {
PanelId::Properties => {
self.show_properties = true;
self.ribbon.set_properties(true);
iced::Task::none()
}
PanelId::BlockPalette => {
self.open_blocks_palette(None);
iced::Task::none()
}
PanelId::ExternalReferences => {
self.show_external_references = true;
self.refresh_xref_manager();
iced::Task::none()
}
PanelId::Browser => {
self.show_browser = true;
iced::Task::none()
}
PanelId::NodeGraph => self.on_graph(crate::ui::node_graph::GraphMsg::Toggle),
PanelId::PointCloudManager => {
self.pc_manager.show = true;
iced::Task::none()
}
PanelId::Count => {
self.set_count_palette(true);
iced::Task::none()
}
PanelId::SheetSetManager => {
self.show_sheet_set_manager(true);
iced::Task::none()
}
PanelId::Layers => {
self.sync_ribbon_layers();
self.show_layers = true;
iced::Task::none()
}
}
};
self.dock_reveal(id);
return task;
}
match id {
PanelId::BlockPalette => {
self.show_block_palette = false;
self.block_palette.placing = None;
}
PanelId::ExternalReferences => self.show_external_references = false,
PanelId::Browser => self.show_browser = false,
PanelId::NodeGraph => self.show_node_graph = false,
PanelId::PointCloudManager => self.pc_manager.show = false,
PanelId::Count => {
self.count_palette.show = false;
self.ribbon.set_count_palette(false);
}
PanelId::SheetSetManager => {
self.sheet_set.show = false;
self.ribbon.set_sheet_set(false);
}
PanelId::Properties => {
self.show_properties = false;
self.ribbon.set_properties(false);
}
PanelId::Layers => {
self.show_layers = false;
self.ribbon.deactivate_tool_if("LAYERS");
}
}
if self.dock_peek == Some(id) {
self.dock_peek = None;
}
if self
.dock_drag
.is_some_and(|d| d.movement().is_some_and(|(p, _, _)| p == id) || d == crate::ui::dock::DockDrag::Width(id))
{
self.dock_drag = None;
}
iced::Task::none()
}
/// Whether `id` is currently rendered (not closed, not on the start screen /
/// clean-screen viewport). Mirrors the visibility filter the edge column
/// renderer applies before building each side's stack.
pub(crate) fn dock_panel_visible(&self, id: crate::ui::dock::PanelId) -> bool {
use crate::ui::dock::PanelId;
if self.tabs[self.active_tab].is_start || self.clean_screen {
return false;
}
match id {
PanelId::Properties => self.show_properties,
PanelId::BlockPalette => self.show_block_palette,
PanelId::ExternalReferences => self.show_external_references,
PanelId::Browser => self.show_browser,
PanelId::NodeGraph => self.show_node_graph,
PanelId::PointCloudManager => self.pc_manager.show,
PanelId::Count => self.count_palette.show,
PanelId::SheetSetManager => self.sheet_set.show,
PanelId::Layers => self.show_layers,
}
}
/// Size of the workspace the dock lays out in: window width by viewport
/// height.
pub(crate) fn dock_workspace_size(&self) -> (f32, f32) {
(
self.win_size.0,
self.tabs[self.active_tab].scene.selection.borrow().view.vp_size.1,
)
}
/// The open pallets of group `gi` on `side`, top → bottom. Closed
/// pallets keep their place but take no space, so a group with none open
/// is not drawn at all.
pub(crate) fn dock_group_visible(
&self,
side: crate::app::config::DockSide,
gi: usize,
) -> Vec<crate::ui::dock::PanelId> {
self.dock.groups(side)[gi]
.panels
.iter()
.copied()
.filter(|id| self.dock_panel_visible(*id))
.collect()
}
/// Indices of the groups on `side` with an open pallet (listed in the
/// icon strip).
pub(crate) fn dock_visible_groups(&self, side: crate::app::config::DockSide) -> Vec<usize> {
(0..self.dock.groups(side).len())
.filter(|gi| !self.dock_group_visible(side, *gi).is_empty())
.collect()
}
/// The group `side` draws: the one it shows, or the first with an open
/// pallet when that one has none.
pub(crate) fn dock_shown_group(&self, side: crate::app::config::DockSide) -> Option<usize> {
let visible = self.dock_visible_groups(side);
let shown = self.dock.shown(side);
if visible.contains(&shown) {
Some(shown)
} else {
visible.first().copied()
}
}
/// Whether `side` draws its shown group beside the icon strip: always,
/// unless the edge auto-hides and none of the group's icons is hovered.
pub(crate) fn dock_edge_expanded(&self, side: crate::app::config::DockSide) -> bool {
let Some(gi) = self.dock_shown_group(side) else {
return false;
};
!self.dock.edge_auto_hide(side)
|| self
.dock_peek
.is_some_and(|e| self.dock_group_visible(side, gi).contains(&e))
}
/// On-screen width of group `gi` on `side`.
pub(crate) fn dock_group_width(&self, side: crate::app::config::DockSide, gi: usize) -> f32 {
self.dock.group_width_px(side, gi, self.win_size.0)
}
/// Width of the pallet column beside `side`'s icon strip: the shown
/// group's width, or nothing when it is hidden.
pub(crate) fn dock_column_width(&self, side: crate::app::config::DockSide) -> f32 {
match self.dock_shown_group(side) {
Some(gi) if self.dock_edge_expanded(side) => self.dock_group_width(side, gi),
_ => 0.0,
}
}
/// The icon strip of `side`: each listed group's place, and the top of
/// the + button.
pub(crate) fn dock_strip_layout(
&self,
side: crate::app::config::DockSide,
) -> (Vec<crate::ui::dock::StripGroup>, f32) {
let groups: Vec<(usize, usize)> = self
.dock_visible_groups(side)
.into_iter()
.map(|gi| (gi, self.dock_group_visible(side, gi).len()))
.collect();
crate::ui::dock::strip_layout(&groups)
}
/// The open pallets of `side`'s shown group, each as (position in the
/// group, top, bottom) in the workspace.
pub(crate) fn dock_slot_spans(
&self,
side: crate::app::config::DockSide,
) -> Vec<(usize, f32, f32)> {
let Some(gi) = self.dock_shown_group(side) else {
return Vec::new();
};
let (_, avail) = self.dock_workspace_size();
let group = &self.dock.groups(side)[gi];
let open: Vec<usize> = (0..group.panels.len())
.filter(|i| self.dock_panel_visible(group.panels[*i]))
.collect();
let weights: Vec<f32> = open.iter().map(|i| group.weights[*i]).collect();
open.into_iter()
.zip(crate::ui::dock::slot_spans(&weights, avail))
.map(|(i, (top, bottom))| (i, top, bottom))
.collect()
}
/// Size a panel takes when floated: its current floating size, or its
/// docked width by a comfortable share of the workspace height.
pub(crate) fn dock_float_size(&self, id: crate::ui::dock::PanelId) -> (f32, f32) {
if let Some(f) = self.dock.float_rect(id) {
return (f.w, f.h);
}
let (_, avail) = self.dock_workspace_size();
(
self.dock.width(id, self.win_size.0),
(avail * 0.6).clamp(crate::ui::dock::FLOAT_MIN_H, 520.0),
)
}
/// Where a dragged pallet lands if released at workspace point `p`:
/// on an edge's icon strip it joins a group between its icons or starts a
/// new group; over the shown group's pallets it stacks above / below the
/// one under the pointer; on an empty edge it starts a group; anywhere
/// else it floats.
pub(crate) fn dock_drop_target(&self, p: iced::Point) -> crate::ui::dock::DropTarget {
use crate::app::config::DockSide;
use crate::ui::dock::{DropTarget, StripHit, DOCK_EDGE_ZONE, DOCK_STRIP_W};
let (ww, wh) = self.dock_workspace_size();
for side in [DockSide::Left, DockSide::Right] {
let listed = !self.dock_visible_groups(side).is_empty();
let from_edge = match side {
DockSide::Left => p.x,
DockSide::Right => ww - p.x,
};
if !listed {
if from_edge <= DOCK_EDGE_ZONE {
return DropTarget::Edge {
side,
index: self.dock.groups(side).len(),
};
}
continue;
}
// Over the icon strip.
if from_edge <= DOCK_STRIP_W {
let (layout, _) = self.dock_strip_layout(side);
return match crate::ui::dock::strip_hit(p.y, &layout) {
StripHit::NewGroup(at) => DropTarget::Edge {
side,
index: at.unwrap_or(self.dock.groups(side).len()),
},
StripHit::Tab { group, index } => {
// Map the gap among open pallets to a position among
// all of the group's pallets.
let open = self.dock_group_visible(side, group);
let all = &self.dock.groups(side)[group].panels;
let index = open
.get(index)
.and_then(|t| all.iter().position(|a| a == t))
.unwrap_or(all.len());
DropTarget::Join { side, group, index }
}
};
}
// Over the shown group's pallets: above or below the one there.
if from_edge <= DOCK_STRIP_W + self.dock_column_width(side) {
let gi = self.dock_shown_group(side).expect("listed edge");
let spans = self.dock_slot_spans(side);
if let Some(&(pos, top, bottom)) = spans
.iter()
.find(|(_, _, bottom)| p.y < *bottom)
.or(spans.last())
{
let index = if p.y < (top + bottom) * 0.5 { pos } else { pos + 1 };
return DropTarget::Join {
side,
group: gi,
index,
};
}
}
}
let grab = match self.dock_drag {
Some(crate::ui::dock::DockDrag::Move { grab, .. }) => grab,
_ => iced::Vector::new(0.0, 0.0),
};
let x = (p.x - grab.x).clamp(0.0, (ww - 60.0).max(0.0));
let y = (p.y - grab.y).clamp(0.0, (wh - 30.0).max(0.0));
DropTarget::Float { x, y }
}
}
+1045
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -16,6 +16,7 @@ pub(crate) mod dim_viewport;
mod viewport_dimension_tests;
#[cfg(test)]
mod dimension_preview_tests;
mod dock;
mod document;
mod drafting_settings;
pub(crate) mod expr_eval;
File diff suppressed because it is too large. Load diff
+5 -1036
View File
File diff suppressed because it is too large. Load diff
-37
View File
@@ -1412,22 +1412,6 @@ mod tests {
assert_eq!((f.x, f.w), (DOCK_MIN_W - 350.0, 350.0));
}
#[test]
fn raise_float_moves_it_to_the_front() {
let mut state = DockState::default();
for id in [PanelId::Count, PanelId::Browser] {
state.float(FloatPanel {
id,
x: 0.0,
y: 0.0,
w: 300.0,
h: 300.0,
});
}
state.raise_float(PanelId::Count);
assert_eq!(state.floating.last().map(|f| f.id), Some(PanelId::Count));
}
#[test]
fn every_panel_has_its_own_icon() {
let ids = PanelId::ALL;
@@ -1439,25 +1423,4 @@ mod tests {
}
}
#[test]
fn floating_title_bar_replaces_the_horizontal_one() {
let floating = Chrome {
floating: true,
..Default::default()
};
let bar: Element<'_, Message> = title_bar(PanelId::Count, "Count".into(), floating);
assert_eq!(
bar.as_widget().size(),
iced::Size::new(Length::Fixed(0.0), Length::Fixed(0.0))
);
}
#[test]
fn slot_spans_follow_weights() {
assert_eq!(
slot_spans(&[1.0, 3.0], 400.0),
vec![(0.0, 100.0), (100.0, 400.0)]
);
assert!(slot_spans(&[], 400.0).is_empty());
}
}