From 7448472291613a4a56b4112253a0259696198aed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:25:34 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt --- src/app/dock/mod.rs | 12 + src/app/dock/tests.rs | 570 ++++++++++++++++++ src/app/dock/update.rs | 633 +++++++++++++++++++ src/app/dock/view.rs | 1045 ++++++++++++++++++++++++++++++++ src/app/mod.rs | 1 + src/app/update/dialog.rs | 1233 -------------------------------------- src/app/view/mod.rs | 1041 +------------------------------- src/ui/dock.rs | 37 -- 8 files changed, 2266 insertions(+), 2306 deletions(-) create mode 100644 src/app/dock/mod.rs create mode 100644 src/app/dock/tests.rs create mode 100644 src/app/dock/update.rs create mode 100644 src/app/dock/view.rs diff --git a/src/app/dock/mod.rs b/src/app/dock/mod.rs new file mode 100644 index 000000000..153c2de95 --- /dev/null +++ b/src/app/dock/mod.rs @@ -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; diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs new file mode 100644 index 000000000..3cac5f413 --- /dev/null +++ b/src/app/dock/tests.rs @@ -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 { + 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 { + 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( + ::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); +} diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs new file mode 100644 index 000000000..5478b03e8 --- /dev/null +++ b/src/app/dock/update.rs @@ -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 { + 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) { + 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 { + 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 { + 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 { + (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 { + 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, 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 = (0..group.panels.len()) + .filter(|i| self.dock_panel_visible(group.panels[*i])) + .collect(); + let weights: Vec = 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 } + } +} diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs new file mode 100644 index 000000000..55972f488 --- /dev/null +++ b/src/app/dock/view.rs @@ -0,0 +1,1045 @@ +//! Dock drawing: the edges (icon strip and shown group), floating pallets, +//! the + menu and the drag preview. + +use crate::app::document::DocumentTab; +use crate::app::{Message, OpenCADStudio}; +use crate::t; +use crate::ui::dock::DOCK_DIVIDER_W; +use iced::widget::{button, canvas, column, container, mouse_area, row, stack, text, Space}; +use iced::{Background, Border, Color, Element, Fill, Length, Theme}; + +impl OpenCADStudio { + /// Everything the dock draws over the workspace (the row of edges and + /// viewport): floating pallets, an open + menu, the drag preview, and + /// the pointer capture that feeds drags. + pub(in crate::app) fn dock_overlay<'a>( + &'a self, + workspace: Element<'a, Message>, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + // Floating panels sit over the workspace, back to front. + let mut layers: Vec> = vec![workspace]; + for f in &self.dock.floating { + // Once a drag is under way only its preview shows the panel. + let dragged = self + .dock_drag + .and_then(|d| d.movement()) + .is_some_and(|(panel, _, target)| panel == f.id && target.is_some()); + if self.dock_panel_visible(f.id) && !dragged { + layers.push(self.floating_panel(*f, tab)); + } + } + // An open pallet menu hangs beside its edge's + button, over a + // catcher that closes it on any click elsewhere. + if let Some(side) = self.dock_edge_menu { + if !self.dock_visible_groups(side).is_empty() { + // Checked = open and docked on this edge. + let shown: Vec<_> = crate::ui::dock::PanelId::ALL + .iter() + .map(|p| { + let here = self.dock.location(*p).map(|(s, _)| s) == Some(side); + (*p, here && self.dock_panel_visible(*p)) + }) + .collect(); + let (_, plus_top) = self.dock_strip_layout(side); + let strip = crate::ui::dock::DOCK_STRIP_W; + let x = match side { + crate::app::config::DockSide::Left => strip + 4.0, + crate::app::config::DockSide::Right => { + self.dock_workspace_size().0 - strip - DOCK_MENU_W - 4.0 + } + }; + layers.push( + mouse_area(Space::new().width(Fill).height(Fill)) + .on_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) + .on_right_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) + .into(), + ); + layers.push(place_at(dock_edge_menu(side, &shown), x.max(0.0), plus_top)); + } + } + // While a panel is dragged, preview where it lands. + if let Some((id, _, Some(target))) = self.dock_drag.and_then(|d| d.movement()) { + layers.push(self.dock_drop_preview(id, target)); + } + // 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(); + use crate::ui::dock::DockDrag; + let capturing = self.dock_drag.is_some() + || self.xref_col_drag.is_some() + || self.xref_split_drag; + 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(match self.dock_drag { + Some(DockDrag::FloatSize { from_left: true, .. }) => { + iced::mouse::Interaction::ResizingDiagonallyUp + } + Some(DockDrag::FloatSize { from_left: false, .. }) => { + iced::mouse::Interaction::ResizingDiagonallyDown + } + Some(DockDrag::Split { .. }) => iced::mouse::Interaction::ResizingVertically, + Some(DockDrag::Move { .. }) => iced::mouse::Interaction::Grabbing, + // Widths, the Layer Manager column and the xref drags. + _ => iced::mouse::Interaction::ResizingHorizontally, + }); + } + capture.into() + } + + /// Build one edge: the vertical icon strip at the very edge plus the + /// edge's shown group beside it. The strip lists every group as a grip + /// bar over its pallets' icons, with a + button below; clicking an icon + /// shows its group. The shown group's pallets stack top to bottom, each + /// at its weighted share of the height, with a draggable splitter between + /// neighbours. A group whose pallets all auto-hide shows only while one + /// of its icons (or the group itself) is hovered; leaving the edge hides + /// it again. `None` when nothing on the edge is open. + pub(in crate::app) fn build_edge_stack<'a>( + &'a self, + side: crate::app::config::DockSide, + tab: &'a DocumentTab, + ) -> Option> { + let gi = self.dock_shown_group(side)?; + let strip = self.dock_icon_strip(side); + let column_el = self.dock_edge_expanded(side).then(|| { + let width = self.dock_group_width(side, gi); + let group = &self.dock.groups(side)[gi]; + let mut layers: Vec> = Vec::new(); + let mut prev: Option = None; + for (pos, id) in group.panels.iter().enumerate() { + if !self.dock_panel_visible(*id) { + continue; + } + if let Some(upper) = prev { + layers.push(dock_splitter(side, gi, upper, pos, width)); + } + layers.push( + container(self.expanded_panel(*id, side, width, tab)) + .height(Length::FillPortion(crate::ui::dock::portion( + group.weights[pos], + ))) + .into(), + ); + prev = Some(pos); + } + column(layers).height(Fill).into() + }); + + let mut children: Vec> = Vec::new(); + match side { + crate::app::config::DockSide::Left => { + children.push(strip); + children.extend(column_el); + } + crate::app::config::DockSide::Right => { + children.extend(column_el); + children.push(strip); + } + } + Some( + mouse_area(row(children).height(Fill)) + .on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit)) + .into(), + ) + } + + /// An edge's vertical icon strip: per group its open pallets' icons in a + /// row ended by a hairline divider, then a + button opening the pallet + /// menu. The band along a group's window-side edge is its grip: it + /// carries the blue bar of the shown group, lights up while hovered (or + /// while it is a drop target) and drags the whole group; an icon shows its + /// group on click and drags just that pallet. Heights follow + /// `dock::strip_layout`, which drop targeting uses too. + fn dock_icon_strip(&self, side: crate::app::config::DockSide) -> Element<'_, Message> { + use crate::app::config::DockSide; + use crate::ui::dock::{ + DockMsg, DropTarget, DOCK_STRIP_W, STRIP_CELL_H, STRIP_DIVIDER_H, STRIP_GRIP_W, + STRIP_PAD, STRIP_PLUS_GAP, + }; + let shown = self.dock_shown_group(side); + let movement = self.dock_drag.and_then(|d| d.movement()); + let move_target = movement.and_then(|(_, _, target)| target); + let dragging = move_target.is_some(); + let target_group = match move_target { + Some(DropTarget::Join { side: s, group, .. }) if s == side => Some(group), + _ => None, + }; + let tip_side = match side { + DockSide::Left => iced::widget::tooltip::Position::Right, + DockSide::Right => iced::widget::tooltip::Position::Left, + }; + let mut items: Vec> = Vec::new(); + for gi in self.dock_visible_groups(side) { + let is_shown = shown == Some(gi); + let lit = target_group == Some(gi) + || (!dragging && self.dock_grip_hover == Some((side, gi))); + let group_dragged = + dragging && movement.is_some_and(|(_, group, _)| group == Some((side, gi))); + let panels = self.dock_group_visible(side, gi); + let row_h = 2.0 * STRIP_PAD + panels.len() as f32 * STRIP_CELL_H; + + let mut icons: Vec> = vec![Space::new().height(STRIP_PAD).into()]; + for id in panels { + let faded = group_dragged + || (dragging + && movement.is_some_and(|(panel, group, _)| group.is_none() && panel == id)); + let glyph = if faded { + crate::ui::icons::themed_disabled(id.icon(), 20.0) + } else if is_shown { + crate::ui::icons::themed(id.icon(), 20.0) + } else { + crate::ui::icons::themed_secondary(id.icon(), 20.0) + }; + let hovered = !dragging && self.dock_icon_hover == Some(id); + let cell = container(glyph) + .center_x(Length::Fixed(STRIP_CELL_H)) + .center_y(Length::Fixed(STRIP_CELL_H - 2.0)) + .style(move |theme: &Theme| container::Style { + background: hovered + .then(|| Background::Color(theme.palette().background.weak.color)), + border: Border { + radius: 5.0.into(), + ..Default::default() + }, + ..Default::default() + }); + let cell = mouse_area( + container(cell) + .center_x(Length::Fixed(DOCK_STRIP_W)) + .center_y(Length::Fixed(STRIP_CELL_H)), + ) + .on_press(Message::Dock(DockMsg::IconPress(id))) + .on_enter(Message::Dock(DockMsg::Hover(Some(id)))) + .on_exit(Message::Dock(DockMsg::Hover(None))) + .interaction(iced::mouse::Interaction::Pointer); + icons.push(cell.into()); + } + + // 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_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) + } else if is_shown { + (Some(palette.primary.base.color), false) + } else { + (None, false) + }; + container::Style { + background: color.map(Background::Color), + border: Border { + radius: match side { + DockSide::Left => iced::border::Radius::default().right(3.0), + DockSide::Right => iced::border::Radius::default().left(3.0), + }, + ..Default::default() + }, + shadow: if glow { + iced::Shadow { + color: palette.primary.base.color.scale_alpha(0.55), + offset: iced::Vector::new(0.0, 0.0), + blur_radius: 8.0, + } + } else { + iced::Shadow::default() + }, + ..Default::default() + } + }); + let band = mouse_area( + container(bar) + .width(Length::Fixed(STRIP_GRIP_W)) + .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, + }), + ) + .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) + .on_enter(Message::Dock(DockMsg::GripHover(Some((side, gi))))) + .on_exit(Message::Dock(DockMsg::GripHover(None))) + .interaction(iced::mouse::Interaction::Grab); + let band = iced::widget::tooltip( + band, + text(t!("Drag to move group")).size(10), + tip_side, + ) + .gap(4); + let band = container(band) + .width(Fill) + .height(Length::Fixed(row_h)) + .align_x(match side { + DockSide::Left => iced::alignment::Horizontal::Left, + DockSide::Right => iced::alignment::Horizontal::Right, + }); + items.push( + container(stack![column(icons), band]) + .width(Length::Fixed(DOCK_STRIP_W)) + .height(Length::Fixed(row_h)) + .style(move |theme: &Theme| container::Style { + background: lit.then(|| { + Background::Color(theme.palette().primary.weak.color.scale_alpha(0.12)) + }), + ..Default::default() + }) + .into(), + ); + items.push( + container(Space::new()) + .width(Fill) + .height(Length::Fixed(STRIP_DIVIDER_H)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.neutral.color, + )), + ..Default::default() + }) + .into(), + ); + } + items.push(Space::new().height(STRIP_PLUS_GAP).into()); + let plus = button(crate::ui::icons::themed_secondary(crate::ui::icons::PLUS, 14.0)) + .on_press(Message::Dock(DockMsg::EdgeMenu(Some(side)))) + .style(|theme: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => { + theme.palette().background.weak.color + } + _ => theme.palette().background.weakest.color, + })), + border: Border { + radius: 14.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .width(Length::Fixed(28.0)) + .height(Length::Fixed(28.0)) + .padding(7); + items.push(container(plus).center_x(Length::Fixed(DOCK_STRIP_W)).into()); + container(column(items)) + .width(Length::Fixed(DOCK_STRIP_W)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .into() + } + + /// The content of panel `id` at `width`. A `floating` panel leaves its + /// title to the floating frame's vertical title bar. + pub(in crate::app) fn panel_body<'a>( + &'a self, + id: crate::ui::dock::PanelId, + width: f32, + floating: bool, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + let chrome = crate::ui::dock::Chrome { + auto_collapse: self.dock.auto_hides(id), + floating, + title_hovered: self.dock_title_hover == Some(id), + }; + match id { + crate::ui::dock::PanelId::Properties => tab.properties.view(width, chrome), + crate::ui::dock::PanelId::BlockPalette => { + crate::ui::window::block_palette::view(&self.block_palette, width, chrome) + } + crate::ui::dock::PanelId::ExternalReferences => self.xref_manager.view( + width, + chrome, + tab.xref_missing, + &tab.scene.document, + ), + crate::ui::dock::PanelId::Browser => crate::ui::window::browser::view( + &tab.scene.document, + tab.sketch_session.as_ref().map(|session| session.name.as_str()), + width, + chrome, + ), + crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, chrome), + crate::ui::dock::PanelId::Count => crate::ui::window::count_palette::view( + &self.count_palette, + tab.count.as_ref(), + &tab.scene.document, + tab.id, + tab.scene.geometry_epoch, + width, + chrome, + ), + crate::ui::dock::PanelId::PointCloudManager => crate::ui::window::pc_manager::view( + &self.pc_manager, + &tab.scene.document, + &tab.scene.selected_handles_in_order(), + width, + chrome, + ), + crate::ui::dock::PanelId::SheetSetManager => { + crate::ui::window::sheet_set::view(&self.sheet_set, width, chrome) + } + crate::ui::dock::PanelId::Layers => { + tab.layers.view_panel(self.layer_name_col_w, width, chrome) + } + } + } + + /// A pallet of the shown group: its body plus a grabbable divider + /// against the viewport (sizing the whole group). Hovering is handled by + /// the enclosing edge region (see `build_edge_stack`), so the body stays + /// fully interactive without fighting the region's hover tracking. + fn expanded_panel<'a>( + &'a self, + id: crate::ui::dock::PanelId, + side: crate::app::config::DockSide, + width: f32, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + let panel = self.panel_body(id, width, false, tab); + let divider = dock_divider(id); + match side { + crate::app::config::DockSide::Left => row![panel, divider].height(Fill).into(), + crate::app::config::DockSide::Right => row![divider, panel].height(Fill).into(), + } + } + + /// A floating panel at its saved place: a vertical title bar on the side + /// facing the nearer workspace edge, the panel beside it, and a corner + /// grip to resize it. With its pin on, the panel hides down to the title + /// bar until hovered. + fn floating_panel<'a>( + &'a self, + f: crate::ui::dock::FloatPanel, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + use crate::ui::dock::DockMsg; + let (ww, wh) = self.dock_workspace_size(); + // 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_drag { + Some(crate::ui::dock::DockDrag::FloatSize { panel, from_left }) if panel == 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_peek != 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(grip_icon) + .center_x(Length::Fixed(14.0)) + .center_y(Length::Fixed(14.0)), + ) + .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(if grip_left { + iced::alignment::Horizontal::Left + } else { + iced::alignment::Horizontal::Right + }) + .align_y(iced::alignment::Vertical::Bottom), + ] + .width(Length::Fixed(f.w)) + .height(Fill); + if bar_left { + row![bar, body].into() + } else { + row![body, bar].into() + } + }; + let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; + let framed = container(content) + .height(Length::Fixed(f.h)) + .style(|theme: &Theme| container::Style { + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 0.0.into(), + }, + shadow: iced::Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.35), + offset: iced::Vector::new(0.0, 4.0), + blur_radius: 14.0, + }, + ..Default::default() + }); + // The idle cursor marks the panel as opaque to the pointer, so clicks + // on its empty areas don't fall through to the drawing below. + let panel = mouse_area(framed) + .on_press(Message::Dock(DockMsg::FloatRaise(f.id))) + .on_enter(Message::Dock(DockMsg::Hover(Some(f.id)))) + .on_exit(Message::Dock(DockMsg::HoverExit)) + .interaction(iced::mouse::Interaction::Idle); + // A hidden panel keeps its title bar where it sits when shown. + let x = if hidden && !bar_left { f.x + f.w } else { f.x }; + // Keep the panel on screen when the window shrank since it was placed. + let x = x.min(ww - total_w).max(0.0); + let y = f.y.min(wh - f.h).max(0.0); + place_at(panel.into(), x, y) + } + + /// The live drop preview for dragged panel `id`: a tint over the target + /// edge plus a panel-sized ghost where it lands — stacked into a group + /// (sized as that group will be), as a new group of its own (full + /// height), or as a floating window. + fn dock_drop_preview( + &self, + id: crate::ui::dock::PanelId, + target: crate::ui::dock::DropTarget, + ) -> Element<'_, Message> { + use crate::app::config::DockSide; + use crate::ui::dock::DropTarget; + let (ww, avail) = self.dock_workspace_size(); + let strip = crate::ui::dock::DOCK_STRIP_W; + let tint = |side: DockSide, w: f32| -> Element<'_, Message> { + let band = container(Space::new()) + .width(Length::Fixed(w + strip)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().primary.weak.color.scale_alpha(0.35), + )), + ..Default::default() + }); + container(band) + .width(Fill) + .height(Fill) + .align_x(match side { + DockSide::Left => iced::alignment::Horizontal::Left, + DockSide::Right => iced::alignment::Horizontal::Right, + }) + .into() + }; + let mut layers: Vec> = Vec::new(); + let docked = match target { + DropTarget::Edge { side, index } => { + let mut after = self.dock.clone(); + after.dock(id, side, index); + Some((side, after)) + } + DropTarget::Join { side, group, index } => { + let mut after = self.dock.clone(); + after.join_group(id, side, group, index); + Some((side, after)) + } + DropTarget::Float { x, y } => { + let (w, h) = self.dock_float_size(id); + layers.push(place_at( + dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), + x, + y, + )); + None + } + }; + // A whole group shows only the strip feedback and its ghost; the + // column keeps showing what is there. + let group_drag = self.dock_drag.and_then(|d| d.movement()).and_then(|(_, g, _)| g); + let docked = docked.map(|(side, after)| (side, group_drag.is_none().then_some(after))); + let marks_side = docked.as_ref().map(|(side, _)| *side); + if let Some((side, Some(after))) = docked { + // Lay the group out as it will be after the drop. + if let Some((_, gi)) = after.location(id) { + let group = &after.groups(side)[gi]; + let open: Vec = (0..group.panels.len()) + .filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i])) + .collect(); + let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); + let spans = crate::ui::dock::slot_spans(&weights, avail); + let at = open + .iter() + .position(|i| group.panels[*i] == id) + .expect("dropped panel"); + let (top, bottom) = spans[at]; + let w = group + .panels + .iter() + .map(|p| after.width(*p, self.win_size.0)) + .fold(0.0, f32::max); + let x = match side { + DockSide::Left => strip, + DockSide::Right => (ww - w - strip).max(0.0), + }; + layers.push(tint(side, w)); + layers.push(place_at(dock_ghost(id, w, bottom - top), x, top)); + } + } + // On top: the strip's drop marks and a ghost of what is dragged + // following the pointer. + if let Some(side) = marks_side { + layers.extend(self.dock_strip_drop_marks(side, target)); + if let Some(p) = self.dock_drag_last { + let icons = match group_drag { + Some((s, g)) => self.dock_group_visible(s, g), + None => vec![id], + }; + layers.push(place_at(dock_icon_ghost(&icons), p.x - 16.0, p.y - 15.0)); + } + } + iced::widget::Stack::with_children(layers) + .width(Fill) + .height(Fill) + .into() + } + + /// The icon strip's drop marks for `target` on `side`: a short line with + /// end caps between a group's icons (join that group there), or a + /// full-width line on a divider with a "+ New group" badge. + fn dock_strip_drop_marks( + &self, + side: crate::app::config::DockSide, + target: crate::ui::dock::DropTarget, + ) -> Vec> { + use crate::app::config::DockSide; + use crate::ui::dock::{DropTarget, DOCK_STRIP_W, STRIP_CELL_H}; + let (ww, _) = self.dock_workspace_size(); + let strip_x = match side { + DockSide::Left => 0.0, + DockSide::Right => ww - DOCK_STRIP_W, + }; + let (layout, _) = self.dock_strip_layout(side); + let mut marks: Vec> = Vec::new(); + match target { + DropTarget::Join { group, index, .. } => { + let Some(g) = layout.iter().find(|g| g.group == group) else { + return marks; + }; + // Icons above the insertion point, counted as the strip shows + // them (the dragged icon still sits in its old place). + let dragged = self.dock_drag.and_then(|d| d.movement()).map(|(p, _, _)| p); + let above = self.dock.groups(side)[group].panels[..index] + .iter() + .filter(|p| self.dock_panel_visible(**p) || Some(**p) == dragged) + .count(); + let y = g.icons_top + above as f32 * STRIP_CELL_H; + marks.push(place_at(dock_insert_line(), strip_x + 7.0, y - 3.5)); + } + DropTarget::Edge { index, .. } => { + let y = layout + .iter() + .find(|g| g.group >= index) + .map(|g| g.top) + .or_else(|| layout.last().map(|g| g.bottom())) + .unwrap_or(0.0); + marks.push(place_at( + container(Space::new()) + .width(Length::Fixed(DOCK_STRIP_W)) + .height(Length::Fixed(4.0)) + .style(dock_drop_line_style) + .into(), + strip_x, + (y - 2.0).max(0.0), + )); + const BADGE_W: f32 = 96.0; + let badge = container(text(format!("+ {}", t!("New group"))).size(10)) + .width(Length::Fixed(BADGE_W)) + .center_x(Length::Fixed(BADGE_W)) + .padding([2, 6]) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().primary.base.color)), + text_color: Some(theme.palette().primary.base.text), + border: Border { + radius: 9.0.into(), + ..Default::default() + }, + ..Default::default() + }); + let badge_x = match side { + DockSide::Left => DOCK_STRIP_W + 6.0, + DockSide::Right => ww - DOCK_STRIP_W - 6.0 - BADGE_W, + }; + marks.push(place_at(badge.into(), badge_x, (y - 9.0).max(0.0))); + } + DropTarget::Float { .. } => {} + } + marks + } +} + +/// The glowing accent used by the strip's drop lines. +fn dock_drop_line_style(theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(theme.palette().primary.base.color)), + border: Border { + radius: 2.0.into(), + ..Default::default() + }, + shadow: iced::Shadow { + color: theme.palette().primary.base.color.scale_alpha(0.8), + offset: iced::Vector::new(0.0, 0.0), + blur_radius: 6.0, + }, + ..Default::default() + } +} + +/// The short insertion line with end caps shown between a group's icons. +fn dock_insert_line() -> Element<'static, Message> { + let cap = || { + container(Space::new()) + .width(Length::Fixed(3.0)) + .height(Length::Fixed(7.0)) + .style(dock_drop_line_style) + }; + row![ + cap(), + container(Space::new()) + .width(Length::Fixed(20.0)) + .height(Length::Fixed(3.0)) + .style(dock_drop_line_style), + cap(), + ] + .align_y(iced::Center) + .height(Length::Fixed(7.0)) + .into() +} + +/// The ghost following the pointer while pallets are dragged: their icons +/// in an accent-framed box. +fn dock_icon_ghost(icons: &[crate::ui::dock::PanelId]) -> Element<'static, Message> { + let glyphs: Vec> = icons + .iter() + .map(|id| { + container(crate::ui::icons::themed(id.icon(), 18.0)) + .center_x(Length::Fixed(32.0)) + .center_y(Length::Fixed(30.0)) + .into() + }) + .collect(); + container(column(glyphs)) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.primary.base.color.scale_alpha(0.22))), + border: Border { + color: palette.primary.base.color, + width: 1.5, + radius: 6.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +/// Canvas that draws a label rotated 90° (for a collapsed panel's bar). +struct VBarLabel { + text: String, + clockwise: bool, + /// Start the text at the reading-start end of the bar instead of + /// centring it. + from_start: bool, +} + +impl canvas::Program for VBarLabel { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced::Renderer, + theme: &Theme, + bounds: iced::Rectangle, + _cursor: iced::advanced::mouse::Cursor, + ) -> Vec { + 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(if self.clockwise { + std::f32::consts::FRAC_PI_2 + } else { + -std::f32::consts::FRAC_PI_2 + })); + // After the rotation the text runs along local +x, so the + // reading start sits at -height/2. + let (position, align_x) = if self.from_start { + ( + iced::Point::new(-bounds.height / 2.0, 0.0), + iced::advanced::text::Alignment::Left, + ) + } else { + (iced::Point::ORIGIN, iced::advanced::text::Alignment::Center) + }; + frame.fill_text(canvas::Text { + content: self.text.clone(), + position, + color: theme.palette().background.base.text.scale_alpha(0.72), + size: iced::Pixels(13.0), + align_x, + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + }); + vec![frame.into_geometry()] + } +} + +/// Width of a floating panel's vertical title bar. +const DOCK_FLOAT_BAR_W: f32 = 28.0; + +/// A floating panel's vertical title bar: close then the hide (auto-collapse) +/// pin at the top, the panel's icon at the bottom with its title reading +/// upward above it. Pressing the bar's free area drags the panel. +fn floating_title_bar( + id: crate::ui::dock::PanelId, + auto_collapse: bool, + on_left: bool, +) -> Element<'static, Message> { + use crate::ui::dock::DockMsg; + let tip = if on_left { + iced::widget::tooltip::Position::Left + } else { + iced::widget::tooltip::Position::Right + }; + 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, + from_start: true, + }) + .width(Fill) + .height(Fill); + let bar = column![ + close, + pin, + container(title).width(Fill).height(Fill).padding([6, 0]), + container(crate::ui::icons::themed(id.icon(), 16.0)).center_x(Fill), + ] + .spacing(2) + .padding([4, 0]) + .align_x(iced::Center) + .width(Length::Fixed(DOCK_FLOAT_BAR_W)) + .height(Fill); + mouse_area(container(bar).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + })) + .on_press(Message::Dock(DockMsg::DockGrab(id))) + // Double-click docks it against the edge its title bar faces. + .on_double_click(Message::Dock(DockMsg::DockTo( + id, + if on_left { + crate::app::config::DockSide::Left + } else { + crate::app::config::DockSide::Right + }, + ))) + .interaction(iced::mouse::Interaction::Grab) + .into() +} + +/// Grabbable separator for a docked panel managed by the general dock. Same +/// visual as the previous per-panel divider but emits generic dock messages. +fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { + let line = container(Space::new()) + .width(Length::Fixed(DOCK_DIVIDER_W)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }); + let grab = Message::Dock(crate::ui::dock::DockMsg::ResizeGrab(id)); + let reset = Message::Dock(crate::ui::dock::DockMsg::WidthReset(id)); + mouse_area(line) + .on_press(grab) + .on_double_click(reset) + .interaction(iced::mouse::Interaction::ResizingHorizontally) + .into() +} + + +/// Width of the icon strip's pallet menu. +const DOCK_MENU_W: f32 = 230.0; + +/// The pallet menu of an edge's + button, styled like the right-click menu: +/// one row per pallet with its icon, its name and a check mark when it is +/// open on this edge. `shown` holds each pallet with that state. +fn dock_edge_menu( + side: crate::app::config::DockSide, + shown: &[(crate::ui::dock::PanelId, bool)], +) -> Element<'static, Message> { + use crate::ui::dock::DockMsg; + let rows: Vec> = shown + .iter() + .map(|(id, checked)| { + let id = *id; + let check: Element<'static, Message> = if *checked { + crate::ui::icons::themed(crate::ui::icons::CHECK, 12.0) + } else { + Space::new().width(12).height(12).into() + }; + button( + row![ + crate::ui::icons::themed(id.icon(), 16.0), + text(id.title()).size(12).width(Fill), + check, + ] + .spacing(8) + .align_y(iced::Center), + ) + .on_press(Message::Dock(DockMsg::EdgeMenuToggle(side, id))) + .style(|theme: &Theme, status| { + let palette = theme.palette(); + button::Style { + background: matches!(status, button::Status::Hovered | button::Status::Pressed) + .then(|| Background::Color(palette.background.weak.color)), + text_color: palette.background.base.text, + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + } + }) + .padding([5, 8]) + .width(Fill) + .into() + }) + .collect(); + container(column(rows).spacing(1).padding(4)) + .width(Length::Fixed(DOCK_MENU_W)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + shadow: iced::Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.35), + offset: iced::Vector::new(0.0, 4.0), + blur_radius: 12.0, + }, + ..Default::default() + }) + .into() +} + +/// Draggable bar between two stacked pallets of group `gi`; double-click +/// gives every pallet of the group the same height again. It spans exactly +/// the pallets' width (panel plus its divider): a `Fill` width would stretch +/// the whole edge column across the workspace and squeeze the drawing view. +fn dock_splitter( + side: crate::app::config::DockSide, + gi: usize, + upper: usize, + lower: usize, + col_w: f32, +) -> Element<'static, Message> { + let line = container(Space::new()) + .width(Length::Fixed(col_w + DOCK_DIVIDER_W)) + .height(Length::Fixed(5.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }); + mouse_area(line) + .on_press(Message::Dock(crate::ui::dock::DockMsg::SplitGrab(side, gi, upper, lower))) + .on_double_click(Message::Dock(crate::ui::dock::DockMsg::SplitReset(side, gi))) + .interaction(iced::mouse::Interaction::ResizingVertically) + .into() +} + +/// The blue drop ghost: a `w`×`h` outline of where pallet `id` lands, with +/// its name as the header. +fn dock_ghost(id: crate::ui::dock::PanelId, w: f32, h: f32) -> Element<'static, Message> { + let header = container( + text(id.title()) + .size(12) + .wrapping(iced::widget::text::Wrapping::None) + .ellipsis(iced::advanced::text::Ellipsis::End), + ) + .width(Fill) + .padding([5, 8]) + .clip(true) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.primary.base.color)), + text_color: Some(palette.primary.base.text), + ..Default::default() + } + }); + container(column![header, Space::new()]) + .width(Length::Fixed(w.max(1.0))) + .height(Length::Fixed(h.max(1.0))) + .clip(true) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color( + palette.background.base.color.scale_alpha(0.85), + )), + border: Border { + color: palette.primary.base.color, + width: 2.0, + radius: 0.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +/// Position `content` with its top-left corner at workspace point (`x`, `y`). +fn place_at(content: Element<'_, Message>, x: f32, y: f32) -> Element<'_, Message> { + container(content) + .width(Fill) + .height(Fill) + .align_x(iced::alignment::Horizontal::Left) + .align_y(iced::alignment::Vertical::Top) + .padding(iced::Padding { + top: y.max(0.0), + right: 0.0, + bottom: 0.0, + left: x.max(0.0), + }) + .into() +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 56c0e8628..706dde860 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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; diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 0ccc36442..9386406fa 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -20,9 +20,6 @@ use codec::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; -/// Pointer travel (px) before pressing a panel's title bar or tab becomes a -/// drag, so a plain click never moves the panel. -const DOCK_DRAG_THRESHOLD: f32 = 5.0; impl OpenCADStudio { pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task { @@ -541,629 +538,6 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven Ok(name) } - /// Dock chrome interaction (grab / pin / resize / hover / move) applied to - /// whichever panel the message names. - pub(super) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task { - 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) { - 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 { - 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 { - 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 { - (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 { - 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, 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 = (0..group.panels.len()) - .filter(|i| self.dock_panel_visible(group.panels[*i])) - .collect(); - let weights: Vec = 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 } - } - /// Rebuild the Reference Manager's entry list from the active drawing. /// Uses the tab's session sets (unloaded + stat baseline) so CLI and /// palette agree; writes fresh load-time mtimes back into the tab's stat @@ -2032,613 +1406,6 @@ mod tests { ); } - #[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 { - 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 { - 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 blockpalette_dropped_on_the_top_half_of_a_pallet_stacks_above_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()); - } - - #[test] - fn dock_drop_on_the_bottom_half_stacks_below() { - use crate::app::config::DockSide; - 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(150.0, 700.0), - ); - assert_eq!( - target, - Some(DropTarget::Join { - side: DockSide::Left, - group: 0, - index: 1 - }) - ); - 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_along_the_strip_reorders_its_group() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, DropTarget, PanelId, STRIP_CELL_H}; - let mut app = dock_app(); - app.dock.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1); - let (layout, _) = app.dock_strip_layout(DockSide::Left); - let top = layout[0].icons_top; - // Drag the Properties icon onto the lower half of the Blocks icon: - // below it, still in the group (the divider under it would start a - // new group). - let _ = app.on_dock(DockMsg::IconPress(PanelId::Properties)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, top + 10.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new( - 18.0, - top + 2.0 * STRIP_CELL_H - 10.0, - ))); - assert_eq!( - drag_target(&app), - Some(DropTarget::Join { - side: DockSide::Left, - group: 0, - index: 2 - }) - ); - let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!( - app.dock.left[0].panels, - vec![PanelId::BlockPalette, PanelId::Properties] - ); - } - - #[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_auto_hide_edge_shows_the_hovered_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.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)); - } - - #[test] - fn dock_hover_does_not_switch_groups_without_auto_hide() { - 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)); - let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Browser))); - assert_eq!(app.dock_shown_group(DockSide::Left), Some(0)); - 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( - ::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::Bounded; - 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); - } - #[test] fn block_name_from_file_avoids_collisions() { let mut app = fresh(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 0b1daa9f9..6914a82e2 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -9,11 +9,11 @@ use crate::t; use crate::ui::wrap_bar::DensitySwap; use crate::ui::wrap_bar::WrapFlow; use iced::widget::{ - button, canvas, column, container, mouse_area, pane_grid, responsive, row, scrollable, shader, + button, column, container, mouse_area, pane_grid, responsive, row, scrollable, shader, stack, text, Row, Space, }; use iced::window; -use iced::{keyboard, Background, Border, Color, Element, Fill, Length, Subscription, Task, Theme}; +use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme}; use iced_aw::ContextMenu; use std::sync::Arc; @@ -1876,11 +1876,8 @@ bg={bg_ms:.1}ms n={view_count}" viewport_stack = viewport_stack.push(tab.graph.view(sections)); } - // Docked side panels (Properties, block palette, future palettes) live - // in an ordered vertical stack of slots on the left/right edge of the - // drawing view; a slot may hold several panels as tabs. Auto-collapsing - // (pinned) slots that aren't being hovered reduce to a rail; the rest - // share the column height by their adjustable weights. + // Docked pallets: each edge's icon strip and shown group (see + // `app::dock`). let left_edge = self.build_edge_stack(crate::app::config::DockSide::Left, tab); let right_edge = self.build_edge_stack(crate::app::config::DockSide::Right, tab); @@ -1927,82 +1924,7 @@ bg={bg_ms:.1}ms n={view_count}" } let workspace: Element<'_, Message> = row(parts).width(Fill).height(Fill).into(); - // Floating panels sit over the workspace, back to front. - let mut layers: Vec> = vec![workspace]; - for f in &self.dock.floating { - // Once a drag is under way only its preview shows the panel. - let dragged = self - .dock_drag - .and_then(|d| d.movement()) - .is_some_and(|(panel, _, target)| panel == f.id && target.is_some()); - if self.dock_panel_visible(f.id) && !dragged { - layers.push(self.floating_panel(*f, tab)); - } - } - // An open pallet menu hangs beside its edge's + button, over a - // catcher that closes it on any click elsewhere. - if let Some(side) = self.dock_edge_menu { - if !self.dock_visible_groups(side).is_empty() { - // Checked = open and docked on this edge. - let shown: Vec<_> = crate::ui::dock::PanelId::ALL - .iter() - .map(|p| { - let here = self.dock.location(*p).map(|(s, _)| s) == Some(side); - (*p, here && self.dock_panel_visible(*p)) - }) - .collect(); - let (_, plus_top) = self.dock_strip_layout(side); - let strip = crate::ui::dock::DOCK_STRIP_W; - let x = match side { - crate::app::config::DockSide::Left => strip + 4.0, - crate::app::config::DockSide::Right => { - self.dock_workspace_size().0 - strip - DOCK_MENU_W - 4.0 - } - }; - layers.push( - mouse_area(Space::new().width(Fill).height(Fill)) - .on_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) - .on_right_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) - .into(), - ); - layers.push(place_at(dock_edge_menu(side, &shown), x.max(0.0), plus_top)); - } - } - // While a panel is dragged, preview where it lands. - if let Some((id, _, Some(target))) = self.dock_drag.and_then(|d| d.movement()) { - layers.push(self.dock_drop_preview(id, target)); - } - // 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(); - use crate::ui::dock::DockDrag; - let capturing = self.dock_drag.is_some() - || self.xref_col_drag.is_some() - || self.xref_split_drag; - 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(match self.dock_drag { - Some(DockDrag::FloatSize { from_left: true, .. }) => { - iced::mouse::Interaction::ResizingDiagonallyUp - } - Some(DockDrag::FloatSize { from_left: false, .. }) => { - iced::mouse::Interaction::ResizingDiagonallyDown - } - Some(DockDrag::Split { .. }) => iced::mouse::Interaction::ResizingVertically, - Some(DockDrag::Move { .. }) => iced::mouse::Interaction::Grabbing, - // Widths, the Layer Manager column and the xref drags. - _ => iced::mouse::Interaction::ResizingHorizontally, - }); - } - let workspace: Element<'_, Message> = capture.into(); + let workspace = self.dock_overlay(workspace, tab); let command_line = self.command_line.view( allow_autocomplete, dyn_capturing, @@ -2770,681 +2692,6 @@ impl OpenCADStudio { pub(super) fn unfocus_widgets(&self) -> Task { iced::advanced::widget::operate(iced::advanced::widget::operation::focusable::unfocus()) } - - /// Build one edge: the vertical icon strip at the very edge plus the - /// edge's shown group beside it. The strip lists every group as a grip - /// bar over its pallets' icons, with a + button below; clicking an icon - /// shows its group. The shown group's pallets stack top to bottom, each - /// at its weighted share of the height, with a draggable splitter between - /// neighbours. A group whose pallets all auto-hide shows only while one - /// of its icons (or the group itself) is hovered; leaving the edge hides - /// it again. `None` when nothing on the edge is open. - fn build_edge_stack<'a>( - &'a self, - side: crate::app::config::DockSide, - tab: &'a DocumentTab, - ) -> Option> { - let gi = self.dock_shown_group(side)?; - let strip = self.dock_icon_strip(side); - let column_el = self.dock_edge_expanded(side).then(|| { - let width = self.dock_group_width(side, gi); - let group = &self.dock.groups(side)[gi]; - let mut layers: Vec> = Vec::new(); - let mut prev: Option = None; - for (pos, id) in group.panels.iter().enumerate() { - if !self.dock_panel_visible(*id) { - continue; - } - if let Some(upper) = prev { - layers.push(dock_splitter(side, gi, upper, pos, width)); - } - layers.push( - container(self.expanded_panel(*id, side, width, tab)) - .height(Length::FillPortion(crate::ui::dock::portion( - group.weights[pos], - ))) - .into(), - ); - prev = Some(pos); - } - column(layers).height(Fill).into() - }); - - let mut children: Vec> = Vec::new(); - match side { - crate::app::config::DockSide::Left => { - children.push(strip); - children.extend(column_el); - } - crate::app::config::DockSide::Right => { - children.extend(column_el); - children.push(strip); - } - } - Some( - mouse_area(row(children).height(Fill)) - .on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit)) - .into(), - ) - } - - /// An edge's vertical icon strip: per group its open pallets' icons in a - /// row ended by a hairline divider, then a + button opening the pallet - /// menu. The band along a group's window-side edge is its grip: it - /// carries the blue bar of the shown group, lights up while hovered (or - /// while it is a drop target) and drags the whole group; an icon shows its - /// group on click and drags just that pallet. Heights follow - /// `dock::strip_layout`, which drop targeting uses too. - fn dock_icon_strip(&self, side: crate::app::config::DockSide) -> Element<'_, Message> { - use crate::app::config::DockSide; - use crate::ui::dock::{ - DockMsg, DropTarget, DOCK_STRIP_W, STRIP_CELL_H, STRIP_DIVIDER_H, STRIP_GRIP_W, - STRIP_PAD, STRIP_PLUS_GAP, - }; - let shown = self.dock_shown_group(side); - let movement = self.dock_drag.and_then(|d| d.movement()); - let move_target = movement.and_then(|(_, _, target)| target); - let dragging = move_target.is_some(); - let target_group = match move_target { - Some(DropTarget::Join { side: s, group, .. }) if s == side => Some(group), - _ => None, - }; - let tip_side = match side { - DockSide::Left => iced::widget::tooltip::Position::Right, - DockSide::Right => iced::widget::tooltip::Position::Left, - }; - let mut items: Vec> = Vec::new(); - for gi in self.dock_visible_groups(side) { - let is_shown = shown == Some(gi); - let lit = target_group == Some(gi) - || (!dragging && self.dock_grip_hover == Some((side, gi))); - let group_dragged = - dragging && movement.is_some_and(|(_, group, _)| group == Some((side, gi))); - let panels = self.dock_group_visible(side, gi); - let row_h = 2.0 * STRIP_PAD + panels.len() as f32 * STRIP_CELL_H; - - let mut icons: Vec> = vec![Space::new().height(STRIP_PAD).into()]; - for id in panels { - let faded = group_dragged - || (dragging - && movement.is_some_and(|(panel, group, _)| group.is_none() && panel == id)); - let glyph = if faded { - crate::ui::icons::themed_disabled(id.icon(), 20.0) - } else if is_shown { - crate::ui::icons::themed(id.icon(), 20.0) - } else { - crate::ui::icons::themed_secondary(id.icon(), 20.0) - }; - let hovered = !dragging && self.dock_icon_hover == Some(id); - let cell = container(glyph) - .center_x(Length::Fixed(STRIP_CELL_H)) - .center_y(Length::Fixed(STRIP_CELL_H - 2.0)) - .style(move |theme: &Theme| container::Style { - background: hovered - .then(|| Background::Color(theme.palette().background.weak.color)), - border: Border { - radius: 5.0.into(), - ..Default::default() - }, - ..Default::default() - }); - let cell = mouse_area( - container(cell) - .center_x(Length::Fixed(DOCK_STRIP_W)) - .center_y(Length::Fixed(STRIP_CELL_H)), - ) - .on_press(Message::Dock(DockMsg::IconPress(id))) - .on_enter(Message::Dock(DockMsg::Hover(Some(id)))) - .on_exit(Message::Dock(DockMsg::Hover(None))) - .interaction(iced::mouse::Interaction::Pointer); - icons.push( - iced::widget::tooltip(cell, text(id.title()).size(10), tip_side) - .gap(4) - .into(), - ); - } - - // 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_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) - } else if is_shown { - (Some(palette.primary.base.color), false) - } else { - (None, false) - }; - container::Style { - background: color.map(Background::Color), - border: Border { - radius: match side { - DockSide::Left => iced::border::Radius::default().right(3.0), - DockSide::Right => iced::border::Radius::default().left(3.0), - }, - ..Default::default() - }, - shadow: if glow { - iced::Shadow { - color: palette.primary.base.color.scale_alpha(0.55), - offset: iced::Vector::new(0.0, 0.0), - blur_radius: 8.0, - } - } else { - iced::Shadow::default() - }, - ..Default::default() - } - }); - let band = mouse_area( - container(bar) - .width(Length::Fixed(STRIP_GRIP_W)) - .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, - }), - ) - .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) - .on_enter(Message::Dock(DockMsg::GripHover(Some((side, gi))))) - .on_exit(Message::Dock(DockMsg::GripHover(None))) - .interaction(iced::mouse::Interaction::Grab); - let band = iced::widget::tooltip( - band, - text(t!("Drag to move group")).size(10), - tip_side, - ) - .gap(4); - let band = container(band) - .width(Fill) - .height(Length::Fixed(row_h)) - .align_x(match side { - DockSide::Left => iced::alignment::Horizontal::Left, - DockSide::Right => iced::alignment::Horizontal::Right, - }); - items.push( - container(stack![column(icons), band]) - .width(Length::Fixed(DOCK_STRIP_W)) - .height(Length::Fixed(row_h)) - .style(move |theme: &Theme| container::Style { - background: lit.then(|| { - Background::Color(theme.palette().primary.weak.color.scale_alpha(0.12)) - }), - ..Default::default() - }) - .into(), - ); - items.push( - container(Space::new()) - .width(Fill) - .height(Length::Fixed(STRIP_DIVIDER_H)) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color, - )), - ..Default::default() - }) - .into(), - ); - } - items.push(Space::new().height(STRIP_PLUS_GAP).into()); - let plus = button(crate::ui::icons::themed_secondary(crate::ui::icons::PLUS, 14.0)) - .on_press(Message::Dock(DockMsg::EdgeMenu(Some(side)))) - .style(|theme: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => { - theme.palette().background.weak.color - } - _ => theme.palette().background.weakest.color, - })), - border: Border { - radius: 14.0.into(), - ..Default::default() - }, - ..Default::default() - }) - .width(Length::Fixed(28.0)) - .height(Length::Fixed(28.0)) - .padding(7); - items.push(container(plus).center_x(Length::Fixed(DOCK_STRIP_W)).into()); - container(column(items)) - .width(Length::Fixed(DOCK_STRIP_W)) - .height(Fill) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - border: Border { - color: theme.palette().background.neutral.color, - width: 1.0, - radius: 0.0.into(), - }, - ..Default::default() - }) - .into() - } - - /// The content of panel `id` at `width`. A `floating` panel leaves its - /// title to the floating frame's vertical title bar. - fn panel_body<'a>( - &'a self, - id: crate::ui::dock::PanelId, - width: f32, - floating: bool, - tab: &'a DocumentTab, - ) -> Element<'a, Message> { - let chrome = crate::ui::dock::Chrome { - auto_collapse: self.dock.auto_hides(id), - floating, - title_hovered: self.dock_title_hover == Some(id), - }; - match id { - crate::ui::dock::PanelId::Properties => tab.properties.view(width, chrome), - crate::ui::dock::PanelId::BlockPalette => { - crate::ui::window::block_palette::view(&self.block_palette, width, chrome) - } - crate::ui::dock::PanelId::ExternalReferences => self.xref_manager.view( - width, - chrome, - tab.xref_missing, - &tab.scene.document, - ), - crate::ui::dock::PanelId::Browser => crate::ui::window::browser::view( - &tab.scene.document, - tab.sketch_session.as_ref().map(|session| session.name.as_str()), - width, - chrome, - ), - crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, chrome), - crate::ui::dock::PanelId::Count => crate::ui::window::count_palette::view( - &self.count_palette, - tab.count.as_ref(), - &tab.scene.document, - tab.id, - tab.scene.geometry_epoch, - width, - chrome, - ), - crate::ui::dock::PanelId::PointCloudManager => crate::ui::window::pc_manager::view( - &self.pc_manager, - &tab.scene.document, - &tab.scene.selected_handles_in_order(), - width, - chrome, - ), - crate::ui::dock::PanelId::SheetSetManager => { - crate::ui::window::sheet_set::view(&self.sheet_set, width, chrome) - } - crate::ui::dock::PanelId::Layers => { - tab.layers.view_panel(self.layer_name_col_w, width, chrome) - } - } - } - - /// A pallet of the shown group: its body plus a grabbable divider - /// against the viewport (sizing the whole group). Hovering is handled by - /// the enclosing edge region (see `build_edge_stack`), so the body stays - /// fully interactive without fighting the region's hover tracking. - fn expanded_panel<'a>( - &'a self, - id: crate::ui::dock::PanelId, - side: crate::app::config::DockSide, - width: f32, - tab: &'a DocumentTab, - ) -> Element<'a, Message> { - let panel = self.panel_body(id, width, false, tab); - let divider = dock_divider(id); - match side { - crate::app::config::DockSide::Left => row![panel, divider].height(Fill).into(), - crate::app::config::DockSide::Right => row![divider, panel].height(Fill).into(), - } - } - - /// A floating panel at its saved place: a vertical title bar on the side - /// facing the nearer workspace edge, the panel beside it, and a corner - /// grip to resize it. With its pin on, the panel hides down to the title - /// bar until hovered. - fn floating_panel<'a>( - &'a self, - f: crate::ui::dock::FloatPanel, - tab: &'a DocumentTab, - ) -> Element<'a, Message> { - use crate::ui::dock::DockMsg; - let (ww, wh) = self.dock_workspace_size(); - // 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_drag { - Some(crate::ui::dock::DockDrag::FloatSize { panel, from_left }) if panel == 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_peek != 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(grip_icon) - .center_x(Length::Fixed(14.0)) - .center_y(Length::Fixed(14.0)), - ) - .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(if grip_left { - iced::alignment::Horizontal::Left - } else { - iced::alignment::Horizontal::Right - }) - .align_y(iced::alignment::Vertical::Bottom), - ] - .width(Length::Fixed(f.w)) - .height(Fill); - if bar_left { - row![bar, body].into() - } else { - row![body, bar].into() - } - }; - let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W }; - let framed = container(content) - .height(Length::Fixed(f.h)) - .style(|theme: &Theme| container::Style { - border: Border { - color: theme.palette().background.strong.color, - width: 1.0, - radius: 0.0.into(), - }, - shadow: iced::Shadow { - color: Color::from_rgba(0.0, 0.0, 0.0, 0.35), - offset: iced::Vector::new(0.0, 4.0), - blur_radius: 14.0, - }, - ..Default::default() - }); - // The idle cursor marks the panel as opaque to the pointer, so clicks - // on its empty areas don't fall through to the drawing below. - let panel = mouse_area(framed) - .on_press(Message::Dock(DockMsg::FloatRaise(f.id))) - .on_enter(Message::Dock(DockMsg::Hover(Some(f.id)))) - .on_exit(Message::Dock(DockMsg::HoverExit)) - .interaction(iced::mouse::Interaction::Idle); - // A hidden panel keeps its title bar where it sits when shown. - let x = if hidden && !bar_left { f.x + f.w } else { f.x }; - // Keep the panel on screen when the window shrank since it was placed. - let x = x.min(ww - total_w).max(0.0); - let y = f.y.min(wh - f.h).max(0.0); - place_at(panel.into(), x, y) - } - - /// The live drop preview for dragged panel `id`: a tint over the target - /// edge plus a panel-sized ghost where it lands — stacked into a group - /// (sized as that group will be), as a new group of its own (full - /// height), or as a floating window. - fn dock_drop_preview( - &self, - id: crate::ui::dock::PanelId, - target: crate::ui::dock::DropTarget, - ) -> Element<'_, Message> { - use crate::app::config::DockSide; - use crate::ui::dock::DropTarget; - let (ww, avail) = self.dock_workspace_size(); - let strip = crate::ui::dock::DOCK_STRIP_W; - let tint = |side: DockSide, w: f32| -> Element<'_, Message> { - let band = container(Space::new()) - .width(Length::Fixed(w + strip)) - .height(Fill) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().primary.weak.color.scale_alpha(0.35), - )), - ..Default::default() - }); - container(band) - .width(Fill) - .height(Fill) - .align_x(match side { - DockSide::Left => iced::alignment::Horizontal::Left, - DockSide::Right => iced::alignment::Horizontal::Right, - }) - .into() - }; - let mut layers: Vec> = Vec::new(); - let docked = match target { - DropTarget::Edge { side, index } => { - let mut after = self.dock.clone(); - after.dock(id, side, index); - Some((side, after)) - } - DropTarget::Join { side, group, index } => { - let mut after = self.dock.clone(); - after.join_group(id, side, group, index); - Some((side, after)) - } - DropTarget::Float { x, y } => { - let (w, h) = self.dock_float_size(id); - layers.push(place_at( - dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), - x, - y, - )); - None - } - }; - // A whole group shows only the strip feedback and its ghost; the - // column keeps showing what is there. - let group_drag = self.dock_drag.and_then(|d| d.movement()).and_then(|(_, g, _)| g); - let docked = docked.map(|(side, after)| (side, group_drag.is_none().then_some(after))); - let marks_side = docked.as_ref().map(|(side, _)| *side); - if let Some((side, Some(after))) = docked { - // Lay the group out as it will be after the drop. - if let Some((_, gi)) = after.location(id) { - let group = &after.groups(side)[gi]; - let open: Vec = (0..group.panels.len()) - .filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i])) - .collect(); - let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); - let spans = crate::ui::dock::slot_spans(&weights, avail); - let at = open - .iter() - .position(|i| group.panels[*i] == id) - .expect("dropped panel"); - let (top, bottom) = spans[at]; - let w = group - .panels - .iter() - .map(|p| after.width(*p, self.win_size.0)) - .fold(0.0, f32::max); - let x = match side { - DockSide::Left => strip, - DockSide::Right => (ww - w - strip).max(0.0), - }; - layers.push(tint(side, w)); - layers.push(place_at(dock_ghost(id, w, bottom - top), x, top)); - } - } - // On top: the strip's drop marks and a ghost of what is dragged - // following the pointer. - if let Some(side) = marks_side { - layers.extend(self.dock_strip_drop_marks(side, target)); - if let Some(p) = self.dock_drag_last { - let icons = match group_drag { - Some((s, g)) => self.dock_group_visible(s, g), - None => vec![id], - }; - layers.push(place_at(dock_icon_ghost(&icons), p.x - 16.0, p.y - 15.0)); - } - } - iced::widget::Stack::with_children(layers) - .width(Fill) - .height(Fill) - .into() - } - - /// The icon strip's drop marks for `target` on `side`: a short line with - /// end caps between a group's icons (join that group there), or a - /// full-width line on a divider with a "+ New group" badge. - fn dock_strip_drop_marks( - &self, - side: crate::app::config::DockSide, - target: crate::ui::dock::DropTarget, - ) -> Vec> { - use crate::app::config::DockSide; - use crate::ui::dock::{DropTarget, DOCK_STRIP_W, STRIP_CELL_H}; - let (ww, _) = self.dock_workspace_size(); - let strip_x = match side { - DockSide::Left => 0.0, - DockSide::Right => ww - DOCK_STRIP_W, - }; - let (layout, _) = self.dock_strip_layout(side); - let mut marks: Vec> = Vec::new(); - match target { - DropTarget::Join { group, index, .. } => { - let Some(g) = layout.iter().find(|g| g.group == group) else { - return marks; - }; - // Icons above the insertion point, counted as the strip shows - // them (the dragged icon still sits in its old place). - let dragged = self.dock_drag.and_then(|d| d.movement()).map(|(p, _, _)| p); - let above = self.dock.groups(side)[group].panels[..index] - .iter() - .filter(|p| self.dock_panel_visible(**p) || Some(**p) == dragged) - .count(); - let y = g.icons_top + above as f32 * STRIP_CELL_H; - marks.push(place_at(dock_insert_line(), strip_x + 7.0, y - 3.5)); - } - DropTarget::Edge { index, .. } => { - let y = layout - .iter() - .find(|g| g.group >= index) - .map(|g| g.top) - .or_else(|| layout.last().map(|g| g.bottom())) - .unwrap_or(0.0); - marks.push(place_at( - container(Space::new()) - .width(Length::Fixed(DOCK_STRIP_W)) - .height(Length::Fixed(4.0)) - .style(dock_drop_line_style) - .into(), - strip_x, - (y - 2.0).max(0.0), - )); - const BADGE_W: f32 = 96.0; - let badge = container(text(format!("+ {}", t!("New group"))).size(10)) - .width(Length::Fixed(BADGE_W)) - .center_x(Length::Fixed(BADGE_W)) - .padding([2, 6]) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().primary.base.color)), - text_color: Some(theme.palette().primary.base.text), - border: Border { - radius: 9.0.into(), - ..Default::default() - }, - ..Default::default() - }); - let badge_x = match side { - DockSide::Left => DOCK_STRIP_W + 6.0, - DockSide::Right => ww - DOCK_STRIP_W - 6.0 - BADGE_W, - }; - marks.push(place_at(badge.into(), badge_x, (y - 9.0).max(0.0))); - } - DropTarget::Float { .. } => {} - } - marks - } -} - -/// The glowing accent used by the strip's drop lines. -fn dock_drop_line_style(theme: &Theme) -> container::Style { - container::Style { - background: Some(Background::Color(theme.palette().primary.base.color)), - border: Border { - radius: 2.0.into(), - ..Default::default() - }, - shadow: iced::Shadow { - color: theme.palette().primary.base.color.scale_alpha(0.8), - offset: iced::Vector::new(0.0, 0.0), - blur_radius: 6.0, - }, - ..Default::default() - } -} - -/// The short insertion line with end caps shown between a group's icons. -fn dock_insert_line() -> Element<'static, Message> { - let cap = || { - container(Space::new()) - .width(Length::Fixed(3.0)) - .height(Length::Fixed(7.0)) - .style(dock_drop_line_style) - }; - row![ - cap(), - container(Space::new()) - .width(Length::Fixed(20.0)) - .height(Length::Fixed(3.0)) - .style(dock_drop_line_style), - cap(), - ] - .align_y(iced::Center) - .height(Length::Fixed(7.0)) - .into() -} - -/// The ghost following the pointer while pallets are dragged: their icons -/// in an accent-framed box. -fn dock_icon_ghost(icons: &[crate::ui::dock::PanelId]) -> Element<'static, Message> { - let glyphs: Vec> = icons - .iter() - .map(|id| { - container(crate::ui::icons::themed(id.icon(), 18.0)) - .center_x(Length::Fixed(32.0)) - .center_y(Length::Fixed(30.0)) - .into() - }) - .collect(); - container(column(glyphs)) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.primary.base.color.scale_alpha(0.22))), - border: Border { - color: palette.primary.base.color, - width: 1.5, - radius: 6.0.into(), - }, - ..Default::default() - } - }) - .into() } // ── Document tab bar ─────────────────────────────────────────────────────── @@ -3773,284 +3020,6 @@ fn pane_mouse_area<'a>(idx: usize) -> Element<'a, Message> { .into() } -/// Canvas that draws a label rotated 90° (for a collapsed panel's bar). -struct VBarLabel { - text: String, - clockwise: bool, - /// Start the text at the reading-start end of the bar instead of - /// centring it. - from_start: bool, -} - -impl canvas::Program for VBarLabel { - type State = (); - - fn draw( - &self, - _state: &(), - renderer: &iced::Renderer, - theme: &Theme, - bounds: iced::Rectangle, - _cursor: iced::advanced::mouse::Cursor, - ) -> Vec { - 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(if self.clockwise { - std::f32::consts::FRAC_PI_2 - } else { - -std::f32::consts::FRAC_PI_2 - })); - // After the rotation the text runs along local +x, so the - // reading start sits at -height/2. - let (position, align_x) = if self.from_start { - ( - iced::Point::new(-bounds.height / 2.0, 0.0), - iced::advanced::text::Alignment::Left, - ) - } else { - (iced::Point::ORIGIN, iced::advanced::text::Alignment::Center) - }; - frame.fill_text(canvas::Text { - content: self.text.clone(), - position, - color: theme.palette().background.base.text.scale_alpha(0.72), - size: iced::Pixels(13.0), - align_x, - align_y: iced::alignment::Vertical::Center, - shaping: iced::advanced::text::Shaping::Advanced, - ..Default::default() - }); - }); - vec![frame.into_geometry()] - } -} - -/// Width of a floating panel's vertical title bar. -const DOCK_FLOAT_BAR_W: f32 = 28.0; - -/// A floating panel's vertical title bar: close then the hide (auto-collapse) -/// pin at the top, the panel's icon at the bottom with its title reading -/// upward above it. Pressing the bar's free area drags the panel. -fn floating_title_bar( - id: crate::ui::dock::PanelId, - auto_collapse: bool, - on_left: bool, -) -> Element<'static, Message> { - use crate::ui::dock::DockMsg; - let tip = if on_left { - iced::widget::tooltip::Position::Left - } else { - iced::widget::tooltip::Position::Right - }; - 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, - from_start: true, - }) - .width(Fill) - .height(Fill); - let bar = column![ - close, - pin, - container(title).width(Fill).height(Fill).padding([6, 0]), - container(crate::ui::icons::themed(id.icon(), 16.0)).center_x(Fill), - ] - .spacing(2) - .padding([4, 0]) - .align_x(iced::Center) - .width(Length::Fixed(DOCK_FLOAT_BAR_W)) - .height(Fill); - mouse_area(container(bar).style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.weak.color)), - ..Default::default() - })) - .on_press(Message::Dock(DockMsg::DockGrab(id))) - // Double-click docks it against the edge its title bar faces. - .on_double_click(Message::Dock(DockMsg::DockTo( - id, - if on_left { - crate::app::config::DockSide::Left - } else { - crate::app::config::DockSide::Right - }, - ))) - .interaction(iced::mouse::Interaction::Grab) - .into() -} - -/// Grabbable separator for a docked panel managed by the general dock. Same -/// visual as the previous per-panel divider but emits generic dock messages. -fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { - let line = container(Space::new()) - .width(Length::Fixed(DOCK_DIVIDER_W)) - .height(Fill) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.neutral.color)), - ..Default::default() - }); - let grab = Message::Dock(crate::ui::dock::DockMsg::ResizeGrab(id)); - let reset = Message::Dock(crate::ui::dock::DockMsg::WidthReset(id)); - mouse_area(line) - .on_press(grab) - .on_double_click(reset) - .interaction(iced::mouse::Interaction::ResizingHorizontally) - .into() -} - -use crate::ui::dock::DOCK_DIVIDER_W; - -/// Width of the icon strip's pallet menu. -const DOCK_MENU_W: f32 = 230.0; - -/// The pallet menu of an edge's + button, styled like the right-click menu: -/// one row per pallet with its icon, its name and a check mark when it is -/// open on this edge. `shown` holds each pallet with that state. -fn dock_edge_menu( - side: crate::app::config::DockSide, - shown: &[(crate::ui::dock::PanelId, bool)], -) -> Element<'static, Message> { - use crate::ui::dock::DockMsg; - let rows: Vec> = shown - .iter() - .map(|(id, checked)| { - let id = *id; - let check: Element<'static, Message> = if *checked { - crate::ui::icons::themed(crate::ui::icons::CHECK, 12.0) - } else { - Space::new().width(12).height(12).into() - }; - button( - row![ - crate::ui::icons::themed(id.icon(), 16.0), - text(id.title()).size(12).width(Fill), - check, - ] - .spacing(8) - .align_y(iced::Center), - ) - .on_press(Message::Dock(DockMsg::EdgeMenuToggle(side, id))) - .style(|theme: &Theme, status| { - let palette = theme.palette(); - button::Style { - background: matches!(status, button::Status::Hovered | button::Status::Pressed) - .then(|| Background::Color(palette.background.weak.color)), - text_color: palette.background.base.text, - border: Border { - radius: 3.0.into(), - ..Default::default() - }, - ..Default::default() - } - }) - .padding([5, 8]) - .width(Fill) - .into() - }) - .collect(); - container(column(rows).spacing(1).padding(4)) - .width(Length::Fixed(DOCK_MENU_W)) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - border: Border { - color: theme.palette().background.neutral.color, - width: 1.0, - radius: 4.0.into(), - }, - shadow: iced::Shadow { - color: Color::from_rgba(0.0, 0.0, 0.0, 0.35), - offset: iced::Vector::new(0.0, 4.0), - blur_radius: 12.0, - }, - ..Default::default() - }) - .into() -} - -/// Draggable bar between two stacked pallets of group `gi`; double-click -/// gives every pallet of the group the same height again. It spans exactly -/// the pallets' width (panel plus its divider): a `Fill` width would stretch -/// the whole edge column across the workspace and squeeze the drawing view. -fn dock_splitter( - side: crate::app::config::DockSide, - gi: usize, - upper: usize, - lower: usize, - col_w: f32, -) -> Element<'static, Message> { - let line = container(Space::new()) - .width(Length::Fixed(col_w + DOCK_DIVIDER_W)) - .height(Length::Fixed(5.0)) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.neutral.color)), - ..Default::default() - }); - mouse_area(line) - .on_press(Message::Dock(crate::ui::dock::DockMsg::SplitGrab(side, gi, upper, lower))) - .on_double_click(Message::Dock(crate::ui::dock::DockMsg::SplitReset(side, gi))) - .interaction(iced::mouse::Interaction::ResizingVertically) - .into() -} - -/// The blue drop ghost: a `w`×`h` outline of where pallet `id` lands, with -/// its name as the header. -fn dock_ghost(id: crate::ui::dock::PanelId, w: f32, h: f32) -> Element<'static, Message> { - let header = container( - text(id.title()) - .size(12) - .wrapping(iced::widget::text::Wrapping::None) - .ellipsis(iced::advanced::text::Ellipsis::End), - ) - .width(Fill) - .padding([5, 8]) - .clip(true) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.primary.base.color)), - text_color: Some(palette.primary.base.text), - ..Default::default() - } - }); - container(column![header, Space::new()]) - .width(Length::Fixed(w.max(1.0))) - .height(Length::Fixed(h.max(1.0))) - .clip(true) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color( - palette.background.base.color.scale_alpha(0.85), - )), - border: Border { - color: palette.primary.base.color, - width: 2.0, - radius: 0.0.into(), - }, - ..Default::default() - } - }) - .into() -} - -/// Position `content` with its top-left corner at workspace point (`x`, `y`). -fn place_at(content: Element<'_, Message>, x: f32, y: f32) -> Element<'_, Message> { - container(content) - .width(Fill) - .height(Fill) - .align_x(iced::alignment::Horizontal::Left) - .align_y(iced::alignment::Vertical::Top) - .padding(iced::Padding { - top: y.max(0.0), - right: 0.0, - bottom: 0.0, - left: x.max(0.0), - }) - .into() -} - const START_ACTION_RADIUS: f32 = 6.0; fn start_action_shape(mut style: button::Style) -> button::Style { diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 480c2c8dc..58ece0e82 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -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()); - } }