diff --git a/src/app/mod.rs b/src/app/mod.rs index 464074156..a8279c317 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -863,8 +863,9 @@ pub(super) struct OpenCADStudio { /// Pointer offset from a dragged panel's top-left corner, so a floated /// panel lands where it is held rather than jumping to the pointer. pub(crate) dock_grab_offset: iced::Vector, - /// Splitter being dragged between two slots on a side (upper, lower). - pub(crate) dock_split_drag: Option<(crate::app::config::DockSide, usize, usize)>, + /// Splitter being dragged between two pallets of a group (side, group, + /// upper, lower). + pub(crate) dock_split_drag: Option<(crate::app::config::DockSide, usize, usize, usize)>, /// Floating panel being resized from its corner grip, and whether from /// the bottom-left corner. pub(crate) dock_float_resizing: Option<(crate::ui::dock::PanelId, bool)>, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 83463273d..0dd6c1fb9 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -551,10 +551,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.dock_begin_drag(id); iced::Task::none() } - DockMsg::SelectTab(id) => { - // A tab press shows the tab and may also start pulling it out - // of the group; a plain click never passes the drag threshold. - if self.dock.select_tab(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_drag(id); @@ -568,15 +569,15 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.dock_expanded = Some(id); iced::Task::none() } - DockMsg::SplitGrab(side, upper, lower) => { - self.dock_split_drag = Some((side, upper, lower)); + DockMsg::SplitGrab(side, gi, upper, lower) => { + self.dock_split_drag = Some((side, gi, upper, lower)); self.dock_dragging = None; self.dock_drag_last = None; self.dock_drag_target = None; iced::Task::none() } - DockMsg::SplitReset(side) => { - self.dock.reset_splits(side); + DockMsg::SplitReset(side, gi) => { + self.dock.reset_splits(side, gi); self.save_config(); iced::Task::none() } @@ -598,9 +599,12 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven let (w, h) = self.dock_float_size(id); let x = match self.dock.location(id) { Some((DockSide::Right, _)) => { - ww - self.dock_column_width(DockSide::Right) - w - 60.0 + ww - crate::ui::dock::DOCK_STRIP_W + - self.dock_column_width(DockSide::Right) + - w + - 60.0 } - _ => self.dock_column_width(DockSide::Left) + 40.0, + _ => crate::ui::dock::DOCK_STRIP_W + self.dock_column_width(DockSide::Left) + 40.0, }; self.dock_dragging = None; self.dock_drag_target = None; @@ -640,12 +644,12 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.dock.dock(id, side, usize::MAX); } let task = self.dock_open_panel(id); - self.dock.select_tab(id); + self.dock.show_group_of(id); self.save_config(); task } DockMsg::GroupGrab(side, gi) => { - if let Some(id) = self.dock_group_shown(side, gi) { + if let Some(&id) = self.dock_group_visible(side, gi).first() { self.dock_begin_drag(id); self.dock_dragging_group = Some((side, gi)); } @@ -720,6 +724,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven // panel being dragged or repoint the resize target. if self.dock_dragging.is_none() && self.dock_resizing.is_none() { self.dock_expanded = Some(id); + // Hovering an auto-hiding group's icon flies it out. + if self.dock.auto_collapse(id) { + self.dock.show_group_of(id); + } } iced::Task::none() } @@ -754,26 +762,27 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven }; } if self.dock_drag_target.is_some() || moved >= DOCK_DRAG_THRESHOLD { - let target = self.dock_drop_target(id, point); - // A whole group only moves between slots: it cannot - // join another group or float. + let target = self.dock_drop_target(point); + // A whole group only moves between group positions: + // it cannot join another group or float. self.dock_drag_target = match (self.dock_dragging_group, target) { (Some(_), DropTarget::Edge { .. }) | (None, _) => Some(target), (Some(_), _) => None, }; } - } else if let Some((side, upper, lower)) = self.dock_split_drag { + } else if let Some((side, gi, upper, lower)) = self.dock_split_drag { if let Some(last) = self.dock_drag_last { let (_, avail) = self.dock_workspace_size(); let total: f32 = self - .dock_expanded_groups(side) + .dock_slot_spans(side) .iter() - .map(|gi| self.dock.groups(side)[*gi].weight) + .map(|(i, _, _)| self.dock.groups(side)[gi].weights[*i]) .sum(); if avail > 0.0 && total > 0.0 { let per_px = total / avail; self.dock.shift_split( side, + gi, upper, lower, (point.y - last.y) * per_px, @@ -803,22 +812,13 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven Some((DockSide::Left, _)) => dx, _ => -dx, }; - // The divider sizes the whole edge column: every - // shown group on the edge takes the new width (and - // keeps it if it moves elsewhere later). - match self.dock.location(id) { - Some((side, _)) => { - let cur = self.dock_column_width(side) + delta; - for gi in self.dock_visible_groups(side) { - let shown = self.dock.groups(side)[gi].active; - self.dock.set_group_width(shown, cur); - } - } - None => { - let cur = self.dock.settings(id).width + delta; - self.dock.set_group_width(id, cur); - } - } + // The divider sizes the panel's group; every group + // keeps its own width. + let cur = match self.dock.location(id) { + Some((side, gi)) => self.dock_group_width(side, gi), + None => self.dock.settings(id).width, + } + delta; + self.dock.set_group_width(id, cur); } } if self.dock_dragging.is_some() @@ -842,8 +842,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } else if let (Some(id), Some(target)) = (self.dock_dragging, self.dock_drag_target) { let moved = match target { DropTarget::Edge { side, index } => self.dock.dock(id, side, index), - DropTarget::Tab { side, group, index } => { - self.dock.add_tab(id, side, group, index) + DropTarget::Join { side, group, index } => { + self.dock.join_group(id, side, group, index) } DropTarget::Float { x, y } => { let (w, h) = self.dock_float_size(id); @@ -954,77 +954,74 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven ) } - /// Visible tabs of slot `gi` on `side`. Closed panels keep their tab but - /// take no space, so a slot with no visible tab is not rendered at all. - pub(crate) fn dock_group_visible_tabs( + /// 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] - .tabs + .panels .iter() .copied() .filter(|id| self.dock_panel_visible(*id)) .collect() } - /// The panel slot `gi` on `side` shows: its active tab, or the first - /// visible one when the active panel is closed. - pub(crate) fn dock_group_shown( - &self, - side: crate::app::config::DockSide, - gi: usize, - ) -> Option { - let active = self.dock.groups(side)[gi].active; - if self.dock_panel_visible(active) { - return Some(active); - } - self.dock_group_visible_tabs(side, gi).first().copied() - } - - /// Indices of the slots on `side` that render (hold a visible panel). + /// 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_shown(side, *gi).is_some()) + .filter(|gi| !self.dock_group_visible(side, *gi).is_empty()) .collect() } - /// Visible slots on `side` that are open in the edge column rather than - /// reduced to a rail tab: not auto-collapsing, or hovered. - pub(crate) fn dock_expanded_groups(&self, side: crate::app::config::DockSide) -> Vec { - self.dock_visible_groups(side) - .into_iter() - .filter(|gi| { - let shown = self.dock_group_shown(side, *gi).expect("visible group"); - !self.dock.auto_collapse(shown) - || self - .dock_expanded - .is_some_and(|e| self.dock_group_visible_tabs(side, *gi).contains(&e)) - }) - .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() + } } - /// Width of slot `gi` on `side`: all its tabs share the widest one's - /// width, so switching tabs never resizes the column. + /// Whether `side` draws its shown group beside the icon strip: always, + /// unless all its pallets auto-hide and none 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; + }; + let panels = self.dock_group_visible(side, gi); + panels.iter().any(|id| !self.dock.auto_collapse(*id)) + || self.dock_expanded.is_some_and(|e| panels.contains(&e)) + } + + /// Width of group `gi` on `side`: its pallets share the widest one's + /// width. pub(crate) fn dock_group_width(&self, side: crate::app::config::DockSide, gi: usize) -> f32 { self.dock.groups(side)[gi] - .tabs + .panels .iter() .map(|id| self.dock.width(*id, self.win_size.0)) .fold(0.0, f32::max) } - /// Width of the edge column on `side`: its widest shown slot. + /// 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 { - self.dock_visible_groups(side) - .into_iter() - .map(|gi| self.dock_group_width(side, gi)) - .fold(0.0, f32::max) + 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 shown group's place, and the top of the - /// + button. + /// 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, @@ -1032,26 +1029,29 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven let groups: Vec<(usize, usize)> = self .dock_visible_groups(side) .into_iter() - .map(|gi| (gi, self.dock_group_visible_tabs(side, gi).len())) + .map(|gi| (gi, self.dock_group_visible(side, gi).len())) .collect(); crate::ui::dock::strip_layout(&groups) } - /// Visible slots on `side` with their vertical span in the workspace. + /// 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 visible = self.dock_visible_groups(side); - let weights: Vec = visible - .iter() - .map(|gi| self.dock.groups(side)[*gi].weight) + let group = &self.dock.groups(side)[gi]; + let open: Vec = (0..group.panels.len()) + .filter(|i| self.dock_panel_visible(group.panels[*i])) .collect(); - visible - .into_iter() + let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); + open.into_iter() .zip(crate::ui::dock::slot_spans(&weights, avail)) - .map(|(gi, (top, bottom))| (gi, top, bottom)) + .map(|(i, (top, bottom))| (i, top, bottom)) .collect() } @@ -1068,82 +1068,68 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven ) } - /// Where dragged panel `id` lands if released at workspace point `p`: - /// near an edge it docks (above / below a slot, or as a tab in the middle - /// of one), anywhere else it floats. - pub(crate) fn dock_drop_target( - &self, - id: crate::ui::dock::PanelId, - p: iced::Point, - ) -> crate::ui::dock::DropTarget { + /// 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::{slot_zone, DropTarget, SlotZone, DOCK_EDGE_ZONE}; + 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 zone = (self.dock_column_width(side) + crate::ui::dock::DOCK_STRIP_W) - .max(DOCK_EDGE_ZONE); - let inside = match side { - DockSide::Left => p.x <= zone, - DockSide::Right => p.x >= ww - zone, + let listed = !self.dock_visible_groups(side).is_empty(); + let from_edge = match side { + DockSide::Left => p.x, + DockSide::Right => ww - p.x, }; - if !inside { + if !listed { + if from_edge <= DOCK_EDGE_ZONE { + return DropTarget::Edge { + side, + index: self.dock.groups(side).len(), + }; + } continue; } - // Over the icon strip: join a group between its icons, or start - // a new group at a grip / below the last group. - let on_strip = !self.dock_visible_groups(side).is_empty() - && match side { - DockSide::Left => p.x <= crate::ui::dock::DOCK_STRIP_W, - DockSide::Right => p.x >= ww - crate::ui::dock::DOCK_STRIP_W, - }; - if on_strip { + // 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) { - crate::ui::dock::StripHit::NewGroup(at) => DropTarget::Edge { + StripHit::NewGroup(at) => DropTarget::Edge { side, index: at.unwrap_or(self.dock.groups(side).len()), }, - crate::ui::dock::StripHit::Tab { group, index } => { - // Map the gap among visible icons to a position in - // all of the group's tabs. - let tabs = self.dock_group_visible_tabs(side, group); - let all = &self.dock.groups(side)[group].tabs; - let index = tabs + 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::Tab { - side, - group, - index: Some(index), - } + DropTarget::Join { side, group, index } } }; } - let spans = self.dock_slot_spans(side); - let Some(&(gi, top, bottom)) = spans - .iter() - .find(|(_, _, bottom)| p.y < *bottom) - .or(spans.last()) - else { - return DropTarget::Edge { - side, - index: self.dock.groups(side).len(), - }; - }; - return match slot_zone(p.y, top, bottom) { - SlotZone::Above => DropTarget::Edge { side, index: gi }, - SlotZone::Below => DropTarget::Edge { side, index: gi + 1 }, - // Tabbing a panel onto its own lone slot changes nothing. - SlotZone::Middle if self.dock_group_visible_tabs(side, gi) == [id] => { - DropTarget::Edge { side, index: gi } + // 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, + }; } - SlotZone::Middle => DropTarget::Tab { - side, - group: gi, - index: None, - }, - }; + } } let x = (p.x - self.dock_grab_offset.x).clamp(0.0, (ww - 60.0).max(0.0)); let y = (p.y - self.dock_grab_offset.y).clamp(0.0, (wh - 30.0).max(0.0)); @@ -2065,24 +2051,55 @@ mod tests { } #[test] - fn blockpalette_dock_moves_to_other_side_and_persists() { + fn blockpalette_dropped_on_the_top_half_of_a_pallet_stacks_above_it() { use crate::app::config::DockSide; - use crate::ui::dock::DropTarget; + use crate::ui::dock::{DropTarget, PanelId}; let mut app = dock_app(); - let id = crate::ui::dock::PanelId::BlockPalette; + let id = PanelId::BlockPalette; assert_eq!(app.dock.location(id), Some((DockSide::Right, 0))); - // Top quarter of the Properties slot: dock above it. let target = drag_panel( &mut app, id, iced::Point::new(1500.0, 10.0), - iced::Point::new(100.0, 40.0), + iced::Point::new(150.0, 300.0), ); - assert_eq!(target, Some(DropTarget::Edge { side: DockSide::Left, index: 0 })); - assert_eq!(app.dock.location(id), Some((DockSide::Left, 0))); assert_eq!( - app.dock.location(crate::ui::dock::PanelId::Properties), - Some((DockSide::Left, 1)) + 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] ); } @@ -2098,216 +2115,54 @@ mod tests { } #[test] - fn dock_drop_in_slot_middle_adds_a_tab() { + 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(100.0, 450.0), + iced::Point::new(18.0, plus_top + 10.0), ); assert_eq!( target, - Some(DropTarget::Tab { - side: DockSide::Left, - group: 0, - index: None - }) - ); - assert_eq!( - app.dock.left[0].tabs, - vec![PanelId::Properties, PanelId::BlockPalette] - ); - assert_eq!(app.dock.left[0].active, PanelId::BlockPalette); - // Clicking the Properties tab shows it again. - let _ = app.on_dock(crate::ui::dock::DockMsg::SelectTab(PanelId::Properties)); - let _ = app.on_dock(crate::ui::dock::DockMsg::DragRelease); - assert_eq!(app.dock.left[0].active, PanelId::Properties); - assert_eq!(app.dock.left[0].tabs.len(), 2); - } - - #[test] - fn dock_dragging_an_icon_along_the_strip_reorders_it() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, DropTarget, PanelId, STRIP_CELL_H}; - let mut app = dock_app(); - app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); - app.dock.right.clear(); - // Icons: Properties above Blocks. Drag the Properties icon below the - // Blocks icon. - let (layout, _) = app.dock_strip_layout(DockSide::Left); - let top = layout[0].icons_top; - let _ = app.on_dock(DockMsg::SelectTab(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))); - assert_eq!( - app.dock_drag_target, - Some(DropTarget::Tab { - side: DockSide::Left, - group: 0, - index: Some(2) - }) - ); - let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!( - app.dock.left[0].tabs, - vec![PanelId::BlockPalette, PanelId::Properties] - ); - } - - #[test] - fn dock_dropping_an_icon_below_the_strip_groups_starts_a_new_group() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, DropTarget, PanelId}; - let mut app = dock_app(); - app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); - let (layout, plus_top) = app.dock_strip_layout(DockSide::Left); - let _ = app.on_dock(DockMsg::SelectTab(PanelId::BlockPalette)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, layout[0].icons_top + 40.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0))); - assert_eq!( - app.dock_drag_target, Some(DropTarget::Edge { side: DockSide::Left, index: 1 }) ); - let _ = app.on_dock(DockMsg::DragRelease); assert_eq!(app.dock.left.len(), 2); - assert_eq!(app.dock.left[1].tabs, vec![PanelId::BlockPalette]); + 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_grip_drag_moves_a_whole_group() { + 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 { - tabs: vec![PanelId::Properties, PanelId::Browser], - active: PanelId::Properties, - weight: 1.0, - }, - DockGroup::single(PanelId::BlockPalette, 1.0), + DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]), + DockGroup::single(PanelId::Browser), ]; app.dock.right.clear(); - app.show_browser = true; - 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].grip_top + 2.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0))); + // 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.left[0].tabs, vec![PanelId::BlockPalette]); - assert_eq!( - app.dock.left[1].tabs, - 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!(app.dock_drag_target, None); + 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, before); - } - - #[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 group on this edge. - 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].tabs, vec![PanelId::Browser]); - // 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); - } - - #[test] - fn dock_divider_resizes_the_whole_column() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockGroup, DockMsg, PanelId}; - let mut app = dock_app(); - app.dock.left.push(DockGroup::single(PanelId::BlockPalette, 1.0)); - app.dock.right.clear(); - app.dock.set_width(PanelId::BlockPalette, 300.0); - let _ = app.on_dock(DockMsg::ResizeGrab(PanelId::Properties)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(330.0, 100.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(350.0, 100.0))); - let _ = app.on_dock(DockMsg::DragRelease); - // The column was 300 (the widest group); both groups now take 320. - assert_eq!(app.dock.settings(PanelId::Properties).width, 320.0); - assert_eq!(app.dock.settings(PanelId::BlockPalette).width, 320.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()); - } - - #[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 dock_tab_group_resize_applies_to_every_tab() { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, PanelId}; - let mut app = dock_app(); - app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); - let _ = app.on_dock(DockMsg::ResizeGrab(PanelId::BlockPalette)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(250.0, 100.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(300.0, 100.0))); - let _ = app.on_dock(DockMsg::DragRelease); - assert_eq!(app.dock.settings(PanelId::Properties).width, 300.0); - assert_eq!(app.dock.settings(PanelId::BlockPalette).width, 300.0); - } - - #[test] - fn dock_drop_in_bottom_quarter_docks_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(100.0, 880.0), - ); - assert_eq!(target, Some(DropTarget::Edge { side: DockSide::Left, index: 1 })); - assert_eq!(app.dock.location(PanelId::BlockPalette), Some((DockSide::Left, 1))); + assert_eq!(app.dock_column_width(DockSide::Left), 260.0); } #[test] @@ -2337,71 +2192,111 @@ mod tests { } #[test] - fn dock_drop_on_own_lone_slot_is_a_no_op() { + fn dock_dragging_an_icon_along_the_strip_reorders_its_group() { use crate::app::config::DockSide; - use crate::ui::dock::{DropTarget, PanelId}; + use crate::ui::dock::{DockMsg, DropTarget, PanelId, STRIP_CELL_H}; let mut app = dock_app(); - let before = app.dock.clone(); - let target = drag_panel( - &mut app, - PanelId::Properties, - iced::Point::new(100.0, 10.0), - iced::Point::new(100.0, 450.0), + 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 below the Blocks icon. + 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))); + assert_eq!( + app.dock_drag_target, + Some(DropTarget::Join { + side: DockSide::Left, + group: 0, + index: 2 + }) ); - assert_eq!(target, Some(DropTarget::Edge { side: DockSide::Left, index: 0 })); + let _ = app.on_dock(DockMsg::DragRelease); + assert_eq!( + app.dock.left[0].panels, + vec![PanelId::BlockPalette, PanelId::Properties] + ); + } + + #[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].grip_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!(app.dock_drag_target, None); + let _ = app.on_dock(DockMsg::DragRelease); assert_eq!(app.dock, before); } #[test] - fn dock_visible_groups_skip_closed_panels() { + 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::single(PanelId::BlockPalette, 1.0), - DockGroup::single(PanelId::Properties, 1.0), + DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties]), + DockGroup::single(PanelId::Browser), ]; app.show_block_palette = false; - assert_eq!(app.dock_visible_groups(DockSide::Left), vec![1]); - // The single visible slot spans the full height, so the drop geometry - // never splits around the hidden one. + 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)]); - app.show_block_palette = true; - assert_eq!(app.dock_visible_groups(DockSide::Left), vec![0, 1]); + // 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_group_shows_a_visible_tab_when_the_active_one_is_closed() { - use crate::app::config::DockSide; - use crate::ui::dock::PanelId; - let mut app = dock_app(); - app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); - assert_eq!(app.dock_group_shown(DockSide::Left, 0), Some(PanelId::BlockPalette)); - app.show_block_palette = false; - assert_eq!(app.dock_group_shown(DockSide::Left, 0), Some(PanelId::Properties)); - app.show_properties = false; - assert_eq!(app.dock_group_shown(DockSide::Left, 0), None); - } - - #[test] - fn dock_splitter_drag_changes_slot_heights() { + 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::single(PanelId::BlockPalette, 1.0), - DockGroup::single(PanelId::Properties, 1.0), - ]; + app.dock.left = vec![DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties])]; app.dock.right.clear(); - let _ = app.on_dock(DockMsg::SplitGrab(DockSide::Left, 0, 1)); + 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. - assert!((app.dock.left[0].weight - 1.5).abs() < 1e-4); - assert!((app.dock.left[1].weight - 0.5).abs() < 1e-4); - let _ = app.on_dock(DockMsg::SplitReset(DockSide::Left)); - assert_eq!(app.dock.left[0].weight, 1.0); + 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_hiding_group_shows_while_its_icon_is_hovered() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = dock_app(); + let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties)); + assert!(!app.dock_edge_expanded(DockSide::Left)); + let _ = app.on_dock(DockMsg::Hover(PanelId::Properties)); + assert!(app.dock_edge_expanded(DockSide::Left)); + let _ = app.on_dock(DockMsg::HoverExit); + assert!(!app.dock_edge_expanded(DockSide::Left)); } #[test] @@ -2430,6 +2325,78 @@ mod tests { 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)); + // 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); + } + + #[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); both pallets take 290. + assert_eq!(app.dock.settings(PanelId::Properties).width, 290.0); + assert_eq!(app.dock.settings(PanelId::BlockPalette).width, 290.0); + assert_eq!(app.dock.settings(PanelId::Count).width, 280.0); + assert_eq!(app.dock_group_width(DockSide::Left, 0), 290.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()); + } + + #[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() { let mut app = fresh(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 08b181f37..c316dccdb 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2770,43 +2770,40 @@ impl OpenCADStudio { } /// Build one edge: the vertical icon strip at the very edge plus the - /// expanded groups beside it. The strip lists every shown group's pallets - /// as icons under a grip bar, with a + button below them; clicking an - /// icon shows that pallet in its group. Unpinned groups are always - /// expanded and split the column height by their weights, with a - /// draggable splitter between neighbours. An auto-hiding (pinned) group - /// lives only in the strip until one of its icons is hovered. The whole - /// edge is one hover region: leaving it collapses any auto-hiding group. - /// `None` when nothing on the edge is shown. + /// 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> { - if self.dock_visible_groups(side).is_empty() { - return None; - } - let expanded = self.dock_expanded_groups(side); - let col_w = expanded - .iter() - .map(|gi| self.dock_group_width(side, *gi)) - .fold(0.0, f32::max); - + let gi = self.dock_shown_group(side)?; let strip = self.dock_icon_strip(side); - let expanded_stack = (!expanded.is_empty()).then(|| { - // Expanded groups stack one below the other, each taking its - // weighted share of the column height — never overlapping. + 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(); - for (n, gi) in expanded.iter().enumerate() { - if n > 0 { - layers.push(dock_splitter(side, expanded[n - 1], *gi, col_w)); + 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)); } - let weight = self.dock.groups(side)[*gi].weight; layers.push( - container(self.expanded_group(side, *gi, col_w, tab)) - .height(Length::FillPortion(crate::ui::dock::portion(weight))) + 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() }); @@ -2815,10 +2812,10 @@ impl OpenCADStudio { match side { crate::app::config::DockSide::Left => { children.push(strip); - children.extend(expanded_stack); + children.extend(column_el); } crate::app::config::DockSide::Right => { - children.extend(expanded_stack); + children.extend(column_el); children.push(strip); } } @@ -2829,12 +2826,13 @@ impl OpenCADStudio { ) } - /// An edge's vertical icon strip: per shown group a grip bar (drag it to - /// move the group) and its pallets' icons (the group's tabs; the shown one - /// on a grey background), then a + button opening the pallet menu. - /// Heights follow `dock::strip_layout`, which drop targeting uses too. + /// An edge's vertical icon strip: per group a grip bar (drag it to move + /// the group) over its open pallets' icons, the shown group framed on a + /// grey background, then a + button opening the pallet menu. Heights + /// follow `dock::strip_layout`, which drop targeting uses too. fn dock_icon_strip(&self, side: crate::app::config::DockSide) -> Element<'_, Message> { use crate::ui::dock::{DockMsg, DOCK_STRIP_W, STRIP_CELL_H, STRIP_GAP, STRIP_GRIP_H}; + let shown = self.dock_shown_group(side); let mut items: Vec> = Vec::new(); for gi in self.dock_visible_groups(side) { items.push(Space::new().height(STRIP_GAP).into()); @@ -2855,37 +2853,20 @@ impl OpenCADStudio { ) .center_x(Length::Fixed(DOCK_STRIP_W)) .center_y(Length::Fixed(STRIP_GRIP_H)); - items.push( - mouse_area(grip) - .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) - .interaction(iced::mouse::Interaction::Grab) - .into(), - ); - let shown = self.dock_group_shown(side, gi); - for id in self.dock_group_visible_tabs(side, gi) { - let is_active = Some(id) == shown; - let icon = container(crate::ui::icons::themed(id.icon(), 20.0)) - .center_x(Length::Fixed(STRIP_CELL_H - 4.0)) - .center_y(Length::Fixed(STRIP_CELL_H - 4.0)) - .style(move |theme: &Theme| container::Style { - // Only the shown pallet of the group gets a background. - background: is_active - .then(|| Background::Color(theme.palette().background.weak.color)), - border: Border { - radius: 4.0.into(), - ..Default::default() - }, - ..Default::default() - }); + let mut group_items: Vec> = vec![mouse_area(grip) + .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) + .interaction(iced::mouse::Interaction::Grab) + .into()]; + for id in self.dock_group_visible(side, gi) { let cell = mouse_area( - container(icon) + container(crate::ui::icons::themed(id.icon(), 20.0)) .center_x(Length::Fixed(DOCK_STRIP_W)) .center_y(Length::Fixed(STRIP_CELL_H)), ) - .on_press(Message::Dock(DockMsg::SelectTab(id))) + .on_press(Message::Dock(DockMsg::IconPress(id))) .on_enter(Message::Dock(DockMsg::Hover(id))) .interaction(iced::mouse::Interaction::Pointer); - items.push( + group_items.push( iced::widget::tooltip( cell, text(id.title()).size(10), @@ -2902,6 +2883,18 @@ impl OpenCADStudio { .into(), ); } + let is_shown = shown == Some(gi); + items.push( + container(column(group_items)) + .width(Length::Fixed(DOCK_STRIP_W)) + .style(move |theme: &Theme| container::Style { + // The shown group sits on the grey background. + background: is_shown + .then(|| Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .into(), + ); } items.push(Space::new().height(STRIP_GAP).into()); let plus = button(crate::ui::icons::themed_secondary(crate::ui::icons::PLUS, 14.0)) @@ -2981,19 +2974,17 @@ impl OpenCADStudio { } } - /// An expanded slot: its shown panel (the group's tabs live in the icon - /// strip) and a grabbable divider against the viewport. - /// 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_group<'a>( + /// 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, - gi: usize, width: f32, tab: &'a DocumentTab, ) -> Element<'a, Message> { - let id = self.dock_group_shown(side, gi).expect("visible group"); let panel = self.panel_body(id, width, false, tab); let divider = dock_divider(id); match side { @@ -3097,8 +3088,9 @@ impl OpenCADStudio { } /// The live drop preview for dragged panel `id`: a tint over the target - /// edge plus a panel-sized ghost where it lands — a new slot above or - /// below, a new tab over a whole slot, or a floating window. + /// 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, @@ -3106,16 +3098,11 @@ impl OpenCADStudio { ) -> Element<'_, Message> { use crate::app::config::DockSide; use crate::ui::dock::DropTarget; - let (ww, _) = self.dock_workspace_size(); - let id_w = self.dock.width(id, self.win_size.0); - let edge_w = |side: DockSide| self.dock_column_width(side).max(id_w); + let (ww, avail) = self.dock_workspace_size(); let strip = crate::ui::dock::DOCK_STRIP_W; - // The tint covers the edge's icon strip and column; the ghost sits in - // the column beside the strip. - let tint = |side: DockSide| -> Element<'_, Message> { - let w = edge_w(side) + strip; + let tint = |side: DockSide, w: f32| -> Element<'_, Message> { let band = container(Space::new()) - .width(Length::Fixed(w)) + .width(Length::Fixed(w + strip)) .height(Fill) .style(|theme: &Theme| container::Style { background: Some(Background::Color( @@ -3132,46 +3119,17 @@ impl OpenCADStudio { }) .into() }; - let edge_x = |side: DockSide, w: f32| match side { - DockSide::Left => strip, - DockSide::Right => (ww - w - strip).max(0.0), - }; let mut layers: Vec> = Vec::new(); - match target { + let docked = match target { DropTarget::Edge { side, index } => { - let (top, bottom) = self.dock_edge_preview_span(id, side, index); - let w = edge_w(side); - layers.push(tint(side)); - layers.push(place_at( - dock_ghost(&[id], id, w, bottom - top), - edge_x(side, w), - top, - )); + let mut after = self.dock.clone(); + after.dock(id, side, index); + Some((side, after)) } - DropTarget::Tab { side, group, index } => { - let (top, bottom) = self - .dock_slot_spans(side) - .into_iter() - .find(|(gi, _, _)| *gi == group) - .map(|(_, t, b)| (t, b)) - .unwrap_or((0.0, 0.0)); - // The slot's tabs as they will be after the drop. - let mut preview = self.dock.clone(); - preview.add_tab(id, side, group, index); - let tabs: Vec<_> = preview - .location(id) - .map(|(s, gi)| preview.groups(s)[gi].tabs.clone()) - .unwrap_or_default() - .into_iter() - .filter(|t| *t == id || self.dock_panel_visible(*t)) - .collect(); - let w = edge_w(side); - layers.push(tint(side)); - layers.push(place_at( - dock_ghost(&tabs, id, w, bottom - top), - edge_x(side, w), - top, - )); + 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); @@ -3180,6 +3138,34 @@ impl OpenCADStudio { x, y, )); + None + } + }; + if let Some((side, 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], id, w, bottom - top), x, top)); } } iced::widget::Stack::with_children(layers) @@ -3187,52 +3173,6 @@ impl OpenCADStudio { .height(Fill) .into() } - - /// Vertical span the dragged panel `id` would take as a new slot at - /// insertion `index` on `side`, after it leaves its current place. - fn dock_edge_preview_span( - &self, - id: crate::ui::dock::PanelId, - side: crate::app::config::DockSide, - index: usize, - ) -> (f32, f32) { - let (_, avail) = self.dock_workspace_size(); - let groups = self.dock.groups(side); - let lone_here = |gi: usize| self.dock_group_visible_tabs(side, gi) == [id]; - let mut own_weight = None; - // (weight, is_new) for each visible slot after the move. - let mut slots: Vec<(f32, bool)> = Vec::new(); - let mut inserted = false; - for gi in self.dock_visible_groups(side) { - if !inserted && gi >= index { - slots.push((0.0, true)); - inserted = true; - } - if lone_here(gi) { - own_weight = Some(groups[gi].weight); - continue; - } - slots.push((groups[gi].weight, false)); - } - if !inserted { - slots.push((0.0, true)); - } - let others: Vec = slots.iter().filter(|s| !s.1).map(|s| s.0).collect(); - let new_weight = own_weight.unwrap_or(if others.is_empty() { - 1.0 - } else { - others.iter().sum::() / others.len() as f32 - }); - let weights: Vec = slots - .iter() - .map(|(w, is_new)| if *is_new { new_weight } else { *w }) - .collect(); - let at = slots.iter().position(|s| s.1).expect("new slot"); - crate::ui::dock::slot_spans(&weights, avail) - .get(at) - .copied() - .unwrap_or((0.0, avail)) - } } // ── Document tab bar ─────────────────────────────────────────────────────── @@ -3757,12 +3697,13 @@ fn dock_edge_menu( .into() } -/// Draggable bar between two expanded slots on an edge; double-click gives -/// every slot on the edge the same height again. It spans exactly the slots' -/// width (panel plus its divider): a `Fill` width would stretch the whole -/// edge column across the workspace and squeeze the drawing view. +/// 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, @@ -3775,8 +3716,8 @@ fn dock_splitter( ..Default::default() }); mouse_area(line) - .on_press(Message::Dock(crate::ui::dock::DockMsg::SplitGrab(side, upper, lower))) - .on_double_click(Message::Dock(crate::ui::dock::DockMsg::SplitReset(side))) + .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() } diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 63e88b967..19e9e9678 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -32,12 +32,14 @@ pub enum DockMsg { DragMove(iced::Point), /// Pointer released after a drag / resize. DragRelease, - /// Show `panel`'s tab in its tab group. - SelectTab(PanelId), - /// Begin dragging the splitter between slots `upper` and `lower` on a side. - SplitGrab(DockSide, usize, usize), - /// Give every slot on a side the same height again. - SplitReset(DockSide), + /// A press on `panel`'s strip icon: show its group, and start a possible + /// drag of the pallet. + IconPress(PanelId), + /// Begin dragging the splitter between pallets `upper` and `lower` of a + /// group (side, group, upper, lower). + SplitGrab(DockSide, usize, usize, usize), + /// Give every pallet of a group the same height again (side, group). + SplitReset(DockSide, usize), /// Begin resizing floating `panel` from its corner grip; `true` for the /// bottom-left grip (a panel whose title bar is on the right). FloatResizeGrab(PanelId, bool), @@ -177,79 +179,103 @@ impl DockPanel { } } -/// One slot of an edge stack: one or more panels sharing the slot as tabs. -/// A single-panel group renders without a tab strip, exactly like the old -/// one-panel-per-slot stack. `weight` is the slot's share of the edge height -/// relative to the other slots on the same edge (equal weights = equal split). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(from = "GroupRepr")] +/// One group of an edge: pallets shown together, stacked top to bottom, each +/// with an adjustable share of the edge height. An edge shows one group at a +/// time; its icon strip lists every group so the user can switch. +#[derive(Debug, Clone, PartialEq, Serialize)] pub struct DockGroup { - /// Panels in tab order. - pub tabs: Vec, - /// The tab currently shown. Always one of `tabs`. - pub active: PanelId, - /// Relative share of the edge height. - pub weight: f32, + /// Pallets top → bottom. + pub panels: Vec, + /// Relative height of each pallet, parallel to `panels`. + pub weights: Vec, } -/// On-disk forms of a [`DockGroup`]: configs written before tab groups stored -/// each slot as a bare panel id. +impl DockGroup { + pub fn single(id: PanelId) -> Self { + Self { + panels: vec![id], + weights: vec![1.0], + } + } + + pub fn stack(panels: Vec) -> Self { + let weights = vec![1.0; panels.len()]; + Self { panels, weights } + } + + /// Keep one sane weight per pallet (configs edited by hand, or written + /// by an older build). + fn heal(&mut self) { + self.weights.resize(self.panels.len(), 1.0); + for w in &mut self.weights { + *w = if w.is_finite() && *w > 0.0 { + w.clamp(MIN_WEIGHT, MAX_WEIGHT) + } else { + 1.0 + }; + } + } + + fn insert(&mut self, index: usize, id: PanelId, weight: f32) { + let index = index.min(self.panels.len()); + self.panels.insert(index, id); + self.weights.insert(index, weight); + } + + fn remove(&mut self, id: PanelId) -> Option<(usize, f32)> { + let i = self.panels.iter().position(|p| *p == id)?; + self.panels.remove(i); + Some((i, self.weights.remove(i))) + } + + fn mean_weight(&self) -> f32 { + if self.weights.is_empty() { + 1.0 + } else { + self.weights.iter().sum::() / self.weights.len() as f32 + } + } +} + +/// On-disk forms of one entry of an edge list. Configs from before groups +/// stored each stacked pallet as a bare id; this branch's earlier builds +/// stored tab groups. #[derive(Deserialize)] #[serde(untagged)] enum GroupRepr { Single(PanelId), - Group { - tabs: Vec, + Stack { + panels: Vec, #[serde(default)] - active: Option, - #[serde(default = "default_weight")] - weight: f32, + weights: Vec, }, + Tabs { tabs: Vec }, } -fn default_weight() -> f32 { - 1.0 -} - -impl From for DockGroup { - fn from(repr: GroupRepr) -> Self { +/// Read an edge list. Bare ids were pallets stacked on the edge, all shown +/// at once, so they become one group to keep the old layout. +fn edge_from_reprs<'de, D>(de: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let reprs = Vec::::deserialize(de)?; + let mut groups = Vec::new(); + let mut legacy: Vec = Vec::new(); + for repr in reprs { match repr { - GroupRepr::Single(id) => DockGroup::single(id, 1.0), - GroupRepr::Group { - tabs, - active, - weight, - } => { - // A hand-edited or corrupt group still yields a usable slot. - let tabs = if tabs.is_empty() { - vec![PanelId::Properties] - } else { - tabs - }; - let active = active.filter(|a| tabs.contains(a)).unwrap_or(tabs[0]); - let weight = if weight.is_finite() && weight > 0.0 { - weight.clamp(MIN_WEIGHT, MAX_WEIGHT) - } else { - 1.0 - }; - DockGroup { - tabs, - active, - weight, - } - } + GroupRepr::Single(id) => legacy.push(id), + GroupRepr::Stack { panels, weights } => groups.push(DockGroup { panels, weights }), + GroupRepr::Tabs { tabs } => groups.push(DockGroup::stack(tabs)), } } -} - -impl DockGroup { - pub fn single(id: PanelId, weight: f32) -> Self { - Self { - tabs: vec![id], - active: id, - weight, - } + if !legacy.is_empty() { + groups.insert(0, DockGroup::stack(legacy)); } + for g in &mut groups { + g.heal(); + } + groups.retain(|g| !g.panels.is_empty()); + Ok(groups) } /// A panel floating over the workspace, in workspace-local pixels. @@ -264,33 +290,39 @@ pub struct FloatPanel { /// Smallest floating panel height. pub const FLOAT_MIN_H: f32 = 160.0; -/// Smallest on-screen height of a docked slot while dragging a splitter. +/// Smallest on-screen height of a stacked pallet while dragging a splitter. pub const GROUP_MIN_H: f32 = 80.0; const MIN_WEIGHT: f32 = 0.05; const MAX_WEIGHT: f32 = 20.0; -/// The whole dock layout: two ordered per-side stacks of tab groups, the +/// The whole dock layout: per edge its groups and which one shows, the /// floating panels, plus per-panel settings. Only the persisted layout lives /// here; transient drag/hover state is app state (see `update::mod`) so it is /// skipped by serialization. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct DockState { - /// Slots anchored to the left edge, top → bottom. + /// Groups on the left edge, in icon-strip order. + #[serde(deserialize_with = "edge_from_reprs")] pub left: Vec, - /// Slots anchored to the right edge, top → bottom. + /// Groups on the right edge, in icon-strip order. + #[serde(deserialize_with = "edge_from_reprs")] pub right: Vec, + /// The group each edge shows (left, right). + pub shown: (usize, usize), /// Floating panels, back → front. pub floating: Vec, - /// Per-panel width / auto-collapse settings, keyed by `PanelId`. + /// Per-panel width / auto-collapse settings, keyed by `PanelId`. A + /// group's pallets share one width, so each group keeps its own. pub panels: BTreeMap, } impl Default for DockState { fn default() -> Self { Self { - left: vec![DockGroup::single(PanelId::Properties, 1.0)], - right: vec![DockGroup::single(PanelId::BlockPalette, 1.0)], + left: vec![DockGroup::single(PanelId::Properties)], + right: vec![DockGroup::single(PanelId::BlockPalette)], + shown: (0, 0), floating: Vec::new(), panels: BTreeMap::new(), } @@ -298,7 +330,7 @@ impl Default for DockState { } impl DockState { - /// The slots stacked on `side`, top → bottom. + /// The groups on `side`, in icon-strip order. pub fn groups(&self, side: DockSide) -> &[DockGroup] { match side { DockSide::Left => &self.left, @@ -313,6 +345,34 @@ impl DockState { } } + /// The group `side` shows (it may hold no open pallet; see the app's + /// `dock_shown_group` for the one actually drawn). + pub fn shown(&self, side: DockSide) -> usize { + match side { + DockSide::Left => self.shown.0, + DockSide::Right => self.shown.1, + } + } + + /// Make group `gi` the one `side` shows. Returns whether it changed. + pub fn show_group(&mut self, side: DockSide, gi: usize) -> bool { + let slot = match side { + DockSide::Left => &mut self.shown.0, + DockSide::Right => &mut self.shown.1, + }; + let changed = *slot != gi; + *slot = gi; + changed + } + + /// Show the group holding `id`. Returns whether the shown group changed. + pub fn show_group_of(&mut self, id: PanelId) -> bool { + match self.location(id) { + Some((side, gi)) => self.show_group(side, gi), + None => false, + } + } + /// Guarantee every known `PanelId` has a settings entry, so rendering and /// resize never hit a missing configuration. Also a cheap heal for configs /// written by an older version (or edited by hand): a panel placed twice @@ -325,22 +385,25 @@ impl DockState { for side in [DockSide::Left, DockSide::Right] { let groups = self.groups_mut(side); for g in groups.iter_mut() { - g.tabs.retain(|id| seen.insert(*id)); - if !g.tabs.contains(&g.active) { - if let Some(first) = g.tabs.first() { - g.active = *first; - } - } + g.heal(); + let keep: Vec = g.panels.iter().map(|id| seen.insert(*id)).collect(); + let mut k = keep.iter(); + g.weights.retain(|_| *k.next().expect("parallel")); + let mut k = keep.iter(); + g.panels.retain(|_| *k.next().expect("parallel")); } - groups.retain(|g| !g.tabs.is_empty()); + groups.retain(|g| !g.panels.is_empty()); + let len = groups.len(); + let shown = self.shown(side).min(len.saturating_sub(1)); + self.show_group(side, shown); } self.floating.retain(|f| seen.insert(f.id)); } - /// Where (if anywhere) a panel is docked: its side and slot index. + /// Where (if anywhere) a panel is docked: its side and group index. pub fn location(&self, id: PanelId) -> Option<(DockSide, usize)> { for side in [DockSide::Left, DockSide::Right] { - if let Some(i) = self.groups(side).iter().position(|g| g.tabs.contains(&id)) { + if let Some(i) = self.groups(side).iter().position(|g| g.panels.contains(&id)) { return Some((side, i)); } } @@ -393,120 +456,99 @@ impl DockState { entry.auto_collapse = on; } - /// Take `id` out of the layout. Returns the docked slot it left and - /// whether that slot disappeared (it held no other tab). - fn detach(&mut self, id: PanelId) -> Option<(DockSide, usize, bool)> { - self.floating.retain(|f| f.id != id); - let (side, gi) = self.location(id)?; - let groups = self.groups_mut(side); - let g = &mut groups[gi]; - g.tabs.retain(|t| *t != id); - if g.tabs.is_empty() { - groups.remove(gi); - return Some((side, gi, true)); - } - if g.active == id { - g.active = g.tabs[0]; - } - Some((side, gi, false)) - } - - /// Dock `id` as its own slot on `side` at insertion `index` (0 = top, - /// `len` = bottom), counted in the stack as it is *before* the move. - /// Returns whether the layout actually changed. - pub fn dock(&mut self, id: PanelId, side: DockSide, index: usize) -> bool { - let before = self.clone(); - let weight = { - let groups = self.groups(side); - if groups.is_empty() { - 1.0 - } else { - groups.iter().map(|g| g.weight).sum::() / groups.len() as f32 - } - }; - let mut index = index; - let mut weight = weight; - if let Some((old_side, old_i, removed)) = self.detach(id) { - if old_side == side && removed { - // Moving within an edge keeps the slot's own height share. - weight = before.groups(side)[old_i].weight; - if old_i < index { - index -= 1; - } - } - } - let groups = self.groups_mut(side); - let index = index.min(groups.len()); - groups.insert(index, DockGroup::single(id, weight)); - *self != before - } - - /// Add `id` as a tab of slot `group` on `side` (slot index counted - /// before the move) and show it. `index` is the tab position, counted in - /// the slot's tabs before the move; `None` appends a newcomer and leaves - /// a tab already in the slot where it is. A joining panel takes on the - /// slot's width, so switching tabs never changes the column width. - /// Returns whether the layout changed. - pub fn add_tab( - &mut self, - id: PanelId, - side: DockSide, - group: usize, - index: Option, - ) -> bool { - if group >= self.groups(side).len() { - return false; - } - let before = self.clone(); - if let Some(pos) = self.groups(side)[group].tabs.iter().position(|t| *t == id) { - // Reordering within the slot. - let g = &mut self.groups_mut(side)[group]; - if let Some(index) = index { - g.tabs.remove(pos); - let index = if pos < index { index - 1 } else { index }; - g.tabs.insert(index.min(g.tabs.len()), id); - } - g.active = id; - return *self != before; - } - let width = self.group_width(side, group); - let mut group = group; - if let Some((old_side, old_i, removed)) = self.detach(id) { - if old_side == side && removed && old_i < group { - group -= 1; - } - } - let g = &mut self.groups_mut(side)[group]; - let index = index.unwrap_or(g.tabs.len()).min(g.tabs.len()); - g.tabs.insert(index, id); - g.active = id; - self.set_width(id, width); - true - } - - /// The saved width a slot's panels share: the widest of its tabs, so a - /// slot whose tabs were sized apart (older configs) renders at one width. + /// The saved width a group's pallets share: the widest of them, so a + /// group whose pallets were sized apart renders at one width. pub fn group_width(&self, side: DockSide, group: usize) -> f32 { self.groups(side)[group] - .tabs + .panels .iter() .map(|id| self.settings(*id).width) .fold(DOCK_MIN_W, f32::max) } - /// Set the width of `id` and of every other tab in its slot. + /// Set the width of `id` and of every other pallet in its group. pub fn set_group_width(&mut self, id: PanelId, width: f32) { - let tabs = match self.location(id) { - Some((side, gi)) => self.groups(side)[gi].tabs.clone(), + let panels = match self.location(id) { + Some((side, gi)) => self.groups(side)[gi].panels.clone(), None => vec![id], }; - for t in tabs { - self.set_width(t, width); + for p in panels { + self.set_width(p, width); } } - /// Move slot `group` on `side` to insertion `index` on `to` (counted - /// before the move). Returns whether the layout changed. + /// Take `id` out of the layout. Returns the group it left, its position + /// and weight there, and whether the group disappeared (it held nothing + /// else). + fn detach(&mut self, id: PanelId) -> Option<(DockSide, usize, usize, f32, bool)> { + self.floating.retain(|f| f.id != id); + let (side, gi) = self.location(id)?; + let (pos, weight) = self.groups_mut(side)[gi].remove(id).expect("located"); + if !self.groups(side)[gi].panels.is_empty() { + return Some((side, gi, pos, weight, false)); + } + self.groups_mut(side).remove(gi); + // Keep showing the same group, or its neighbour when it went away. + let shown = self.shown(side); + if shown > gi || (shown == gi && shown > 0 && shown >= self.groups(side).len()) { + self.show_group(side, shown - 1); + } + Some((side, gi, pos, weight, true)) + } + + /// Dock `id` as a new group of its own on `side` at insertion `index` + /// (0 = first, `len` = last), counted before the move, and show it. + /// Returns whether the layout changed. + pub fn dock(&mut self, id: PanelId, side: DockSide, index: usize) -> bool { + let before = self.clone(); + let mut index = index; + if let Some((old_side, old_gi, _, _, removed)) = self.detach(id) { + if old_side == side && removed && old_gi < index { + index -= 1; + } + } + let groups = self.groups_mut(side); + let index = index.min(groups.len()); + groups.insert(index, DockGroup::single(id)); + self.show_group(side, index); + *self != before + } + + /// Stack `id` into group `group` on `side` at position `index` (0 = top, + /// `len` = bottom), both counted before the move, and show the group. A + /// pallet already in the group moves within it. A joining pallet takes on + /// the group's width. Returns whether the layout changed. + pub fn join_group(&mut self, id: PanelId, side: DockSide, group: usize, index: usize) -> bool { + if group >= self.groups(side).len() { + return false; + } + let before = self.clone(); + let width = self.group_width(side, group); + let joining = !self.groups(side)[group].panels.contains(&id); + let mut group = group; + let mut index = index; + let mut weight = self.groups(side)[group].mean_weight(); + if let Some((old_side, old_gi, pos, w, removed)) = self.detach(id) { + if old_side == side && old_gi == group { + // Moving within the group keeps the pallet's height. + weight = w; + if pos < index { + index -= 1; + } + } else if old_side == side && removed && old_gi < group { + group -= 1; + } + } + self.groups_mut(side)[group].insert(index, id, weight); + if joining { + self.set_width(id, width); + } + self.show_group(side, group); + *self != before + } + + /// Move group `group` on `side` to insertion `index` on `to` (counted + /// before the move) and show it there. Returns whether the layout changed. pub fn move_group(&mut self, side: DockSide, group: usize, to: DockSide, index: usize) -> bool { if group >= self.groups(side).len() { return false; @@ -520,10 +562,17 @@ impl DockState { index -= 1; } } + let shown_here = self.shown(side); let g = self.groups_mut(side).remove(group); + if side != to && shown_here > group { + self.show_group(side, shown_here - 1); + } else if side != to && shown_here >= self.groups(side).len() && shown_here > 0 { + self.show_group(side, shown_here - 1); + } let dest = self.groups_mut(to); let index = index.min(dest.len()); dest.insert(index, g); + self.show_group(to, index); true } @@ -556,43 +605,36 @@ impl DockState { } } - /// Show `id` in its tab group. Returns whether the active tab changed. - pub fn select_tab(&mut self, id: PanelId) -> bool { - let Some((side, gi)) = self.location(id) else { - return false; - }; - let g = &mut self.groups_mut(side)[gi]; - let changed = g.active != id; - g.active = id; - changed - } - - /// Move the boundary between slots `upper` and `lower` on `side` by - /// `delta` weight units, keeping their combined share and each above - /// `min_weight`. + /// Move the boundary between pallets `upper` and `lower` of group + /// `group` on `side` by `delta` weight units, keeping their combined + /// share and each above `min_weight`. pub fn shift_split( &mut self, side: DockSide, + group: usize, upper: usize, lower: usize, delta: f32, min_weight: f32, ) { - let groups = self.groups_mut(side); - if upper >= groups.len() || lower >= groups.len() || upper == lower { + let Some(g) = self.groups_mut(side).get_mut(group) else { + return; + }; + let w = &mut g.weights; + if upper >= w.len() || lower >= w.len() || upper == lower { return; } - let total = groups[upper].weight + groups[lower].weight; + let total = w[upper] + w[lower]; let min = min_weight.clamp(MIN_WEIGHT, total * 0.5); - let up = (groups[upper].weight + delta).clamp(min, total - min); - groups[upper].weight = up; - groups[lower].weight = total - up; + let up = (w[upper] + delta).clamp(min, total - min); + w[upper] = up; + w[lower] = total - up; } - /// Give every slot on `side` the same height again. - pub fn reset_splits(&mut self, side: DockSide) { - for g in self.groups_mut(side) { - g.weight = 1.0; + /// Give every pallet of group `group` on `side` the same height again. + pub fn reset_splits(&mut self, side: DockSide, group: usize) { + if let Some(g) = self.groups_mut(side).get_mut(group) { + g.weights.iter_mut().for_each(|w| *w = 1.0); } } } @@ -600,42 +642,19 @@ impl DockState { /// Where a dragged panel lands on release. #[derive(Debug, Clone, Copy, PartialEq)] pub enum DropTarget { - /// A new slot on `side` at insertion `index` (0 = top, len = bottom). + /// A new group on `side` at insertion `index` (0 = first, len = last). Edge { side: DockSide, index: usize }, - /// A tab in slot `group` on `side`, at position `index` among the - /// slot's tabs (before the move), or appended / left in place for `None`. - Tab { + /// Stacked into group `group` on `side` at position `index` (counted + /// before the move; 0 = top). + Join { side: DockSide, group: usize, - index: Option, + index: usize, }, /// Floating with its top-left corner at (`x`, `y`). Float { x: f32, y: f32 }, } -/// Which part of a docked slot the pointer is over while dragging: the top -/// and bottom bands (35 % each) split the slot (dock above / below), the -/// middle 30 % joins it as a tab. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SlotZone { - Above, - Middle, - Below, -} - -/// Classify a pointer at `y` within a slot spanning `top..bottom`. -pub fn slot_zone(y: f32, top: f32, bottom: f32) -> SlotZone { - let h = (bottom - top).max(1.0); - let band = h * 0.35; - if y < top + band { - SlotZone::Above - } else if y > bottom - band { - SlotZone::Below - } else { - SlotZone::Middle - } -} - /// Split `avail` pixels of height between slots of `weights`, returning each /// slot's `(top, bottom)`. pub fn slot_spans(weights: &[f32], avail: f32) -> Vec<(f32, f32)> { @@ -983,21 +1002,26 @@ mod tests { } #[test] - fn legacy_config_with_bare_panel_ids_loads_as_single_slots() { + fn legacy_bare_ids_load_as_one_stacked_group() { + // Before groups, every id on an edge was shown stacked; keep that. let json = r#"{"left":["properties","block_palette"],"right":[]}"#; let state: DockState = serde_json::from_str(json).unwrap(); - assert_eq!(state.left.len(), 2); - assert_eq!(state.left[0], DockGroup::single(PanelId::Properties, 1.0)); - assert_eq!(state.location(PanelId::BlockPalette), Some((DockSide::Left, 1))); + assert_eq!(state.left.len(), 1); + assert_eq!( + state.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette] + ); + assert_eq!(state.left[0].weights, vec![1.0, 1.0]); assert!(state.floating.is_empty()); } #[test] fn layout_round_trips_through_serde() { let mut state = DockState::default(); - state.add_tab(PanelId::Browser, DockSide::Left, 0, None); + state.join_group(PanelId::Browser, DockSide::Left, 0, 1); + state.dock(PanelId::Count, DockSide::Left, 1); state.float(FloatPanel { - id: PanelId::Count, + id: PanelId::SheetSetManager, x: 10.0, y: 20.0, w: 300.0, @@ -1009,50 +1033,113 @@ mod tests { } #[test] - fn add_tab_joins_a_slot_and_shows_the_new_tab() { - let mut state = DockState::default(); - assert!(state.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None)); - assert!(state.right.is_empty(), "its old slot disappears"); - assert_eq!(state.left.len(), 1); - assert_eq!( - state.left[0].tabs, - vec![PanelId::Properties, PanelId::BlockPalette] - ); - assert_eq!(state.left[0].active, PanelId::BlockPalette); - assert!(state.select_tab(PanelId::Properties)); - assert_eq!(state.left[0].active, PanelId::Properties); - } - - #[test] - fn add_tab_reorders_within_a_slot() { - let mut state = DockState::default(); - state.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); - state.add_tab(PanelId::Browser, DockSide::Left, 0, None); - // Drag the first tab to the end. - assert!(state.add_tab(PanelId::Properties, DockSide::Left, 0, Some(3))); - assert_eq!( - state.left[0].tabs, - vec![PanelId::BlockPalette, PanelId::Browser, PanelId::Properties] - ); - // Drag the last tab to the front. - assert!(state.add_tab(PanelId::Properties, DockSide::Left, 0, Some(0))); - assert_eq!(state.left[0].tabs[0], PanelId::Properties); - // A middle drop (None) leaves a member where it is. - assert!(!state.add_tab(PanelId::Properties, DockSide::Left, 0, None)); - // A newcomer can land at a given position. - state.add_tab(PanelId::Count, DockSide::Left, 0, Some(1)); - assert_eq!(state.left[0].tabs[1], PanelId::Count); - } - - #[test] - fn tabs_share_the_slot_width() { + fn join_group_stacks_and_shows_the_group() { let mut state = DockState::default(); state.set_width(PanelId::Properties, 320.0); - state.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); + // Below Properties in the left group. + assert!(state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1)); + assert!(state.right.is_empty(), "its old group disappears"); + assert_eq!( + state.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette] + ); + assert_eq!(state.left[0].weights.len(), 2); + // It takes on the group's width. assert_eq!(state.settings(PanelId::BlockPalette).width, 320.0); - state.set_group_width(PanelId::BlockPalette, 400.0); - assert_eq!(state.settings(PanelId::Properties).width, 400.0); - assert_eq!(state.group_width(DockSide::Left, 0), 400.0); + // Moving it above Properties keeps it in the group. + assert!(state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 0)); + assert_eq!(state.left[0].panels[0], PanelId::BlockPalette); + // Dropping it where it already is changes nothing. + assert!(!state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1)); + } + + #[test] + fn join_group_index_counts_groups_before_the_move() { + let mut state = DockState::default(); + state.left = vec![ + DockGroup::single(PanelId::BlockPalette), + DockGroup::single(PanelId::Properties), + ]; + state.right.clear(); + // Group 1 (Properties) is group 0 once the palette's group is gone. + assert!(state.join_group(PanelId::BlockPalette, DockSide::Left, 1, 1)); + assert_eq!(state.left.len(), 1); + assert_eq!( + state.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette] + ); + assert_eq!(state.shown(DockSide::Left), 0); + } + + #[test] + fn dock_starts_a_new_group_and_shows_it() { + let mut state = DockState::default(); + state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1); + assert!(state.dock(PanelId::BlockPalette, DockSide::Left, 1)); + assert_eq!(state.left.len(), 2); + assert_eq!(state.left[0].panels, vec![PanelId::Properties]); + assert_eq!(state.left[1].panels, vec![PanelId::BlockPalette]); + assert_eq!(state.shown(DockSide::Left), 1); + assert!(state.show_group_of(PanelId::Properties)); + assert_eq!(state.shown(DockSide::Left), 0); + } + + #[test] + fn removing_the_shown_group_keeps_a_valid_shown_index() { + let mut state = DockState::default(); + state.dock(PanelId::Browser, DockSide::Left, 1); + assert_eq!(state.shown(DockSide::Left), 1); + // Floating Browser removes the last group; the edge shows group 0. + state.float(FloatPanel { + id: PanelId::Browser, + x: 0.0, + y: 0.0, + w: 250.0, + h: 300.0, + }); + assert_eq!(state.left.len(), 1); + assert_eq!(state.shown(DockSide::Left), 0); + } + + #[test] + fn move_group_reorders_and_crosses_edges() { + let mut state = DockState::default(); + state.left.push(DockGroup::single(PanelId::Browser)); + state.left.push(DockGroup::single(PanelId::Count)); + // Same spot (before itself or after itself) changes nothing. + assert!(!state.move_group(DockSide::Left, 1, DockSide::Left, 1)); + assert!(!state.move_group(DockSide::Left, 1, DockSide::Left, 2)); + assert!(state.move_group(DockSide::Left, 0, DockSide::Left, 3)); + assert_eq!(state.left[2].panels, vec![PanelId::Properties]); + assert_eq!(state.shown(DockSide::Left), 2, "the moved group shows"); + assert!(state.move_group(DockSide::Left, 2, DockSide::Right, 0)); + assert_eq!(state.right[0].panels, vec![PanelId::Properties]); + assert_eq!(state.left.len(), 2); + assert!(state.shown(DockSide::Left) < 2); + } + + #[test] + fn shift_split_keeps_combined_share_and_minimum() { + let mut state = DockState::default(); + state.left = vec![DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties])]; + state.shift_split(DockSide::Left, 0, 0, 1, 0.5, 0.1); + assert_eq!(state.left[0].weights, vec![1.5, 0.5]); + state.shift_split(DockSide::Left, 0, 0, 1, 10.0, 0.1); + assert!((state.left[0].weights[1] - 0.1).abs() < 1e-6); + assert!((state.left[0].weights.iter().sum::() - 2.0).abs() < 1e-6); + state.reset_splits(DockSide::Left, 0); + assert_eq!(state.left[0].weights, vec![1.0, 1.0]); + } + + #[test] + fn ensure_settings_drops_duplicates_and_clamps_the_shown_group() { + let mut state = DockState::default(); + state.right.push(DockGroup::single(PanelId::Properties)); + state.shown = (5, 7); + state.ensure_settings(); + assert_eq!(state.location(PanelId::Properties), Some((DockSide::Left, 0))); + assert_eq!(state.right.len(), 1); + assert_eq!(state.shown, (0, 0)); } #[test] @@ -1077,62 +1164,6 @@ mod tests { assert_eq!(strip_hit(plus_top + 20.0, &layout), StripHit::NewGroup(None)); } - #[test] - fn move_group_reorders_and_crosses_edges() { - let mut state = DockState::default(); - state.left.push(DockGroup::single(PanelId::Browser, 1.0)); - state.left.push(DockGroup::single(PanelId::Count, 1.0)); - // Same spot (before itself or after itself) changes nothing. - assert!(!state.move_group(DockSide::Left, 1, DockSide::Left, 1)); - assert!(!state.move_group(DockSide::Left, 1, DockSide::Left, 2)); - assert!(state.move_group(DockSide::Left, 0, DockSide::Left, 3)); - assert_eq!(state.left[2].tabs, vec![PanelId::Properties]); - assert!(state.move_group(DockSide::Left, 2, DockSide::Right, 0)); - assert_eq!(state.right[0].tabs, vec![PanelId::Properties]); - assert_eq!(state.left.len(), 2); - } - - #[test] - fn docking_a_tab_out_keeps_the_rest_of_the_group() { - let mut state = DockState::default(); - state.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); - // Pull the shown tab out below the group. - assert!(state.dock(PanelId::BlockPalette, DockSide::Left, 1)); - assert_eq!(state.left.len(), 2); - assert_eq!(state.left[0].tabs, vec![PanelId::Properties]); - assert_eq!(state.left[0].active, PanelId::Properties); - assert_eq!(state.location(PanelId::BlockPalette), Some((DockSide::Left, 1))); - } - - #[test] - fn add_tab_index_counts_slots_before_the_move() { - let mut state = DockState::default(); - state.left = vec![ - DockGroup::single(PanelId::BlockPalette, 1.0), - DockGroup::single(PanelId::Properties, 1.0), - ]; - state.right.clear(); - // Slot 1 (Properties) is slot 0 once the palette's slot is gone. - assert!(state.add_tab(PanelId::BlockPalette, DockSide::Left, 1, None)); - assert_eq!(state.left.len(), 1); - assert_eq!( - state.left[0].tabs, - vec![PanelId::Properties, PanelId::BlockPalette] - ); - } - - #[test] - fn moving_a_slot_down_keeps_its_height_share() { - let mut state = DockState::default(); - state.left = vec![ - DockGroup::single(PanelId::BlockPalette, 0.5), - DockGroup::single(PanelId::Properties, 1.5), - ]; - assert!(state.dock(PanelId::BlockPalette, DockSide::Left, 2)); - assert_eq!(state.left[0].tabs, vec![PanelId::Properties]); - assert_eq!(state.left[1], DockGroup::single(PanelId::BlockPalette, 0.5)); - } - #[test] fn float_detaches_and_dock_brings_it_back() { let mut state = DockState::default(); @@ -1188,31 +1219,6 @@ mod tests { assert_eq!(state.floating.last().map(|f| f.id), Some(PanelId::Count)); } - #[test] - fn shift_split_keeps_combined_share_and_minimum() { - let mut state = DockState::default(); - state.left = vec![ - DockGroup::single(PanelId::BlockPalette, 1.0), - DockGroup::single(PanelId::Properties, 1.0), - ]; - state.shift_split(DockSide::Left, 0, 1, 0.5, 0.1); - assert_eq!((state.left[0].weight, state.left[1].weight), (1.5, 0.5)); - state.shift_split(DockSide::Left, 0, 1, 10.0, 0.1); - assert!((state.left[1].weight - 0.1).abs() < 1e-6); - assert!((state.left[0].weight + state.left[1].weight - 2.0).abs() < 1e-6); - state.reset_splits(DockSide::Left); - assert_eq!((state.left[0].weight, state.left[1].weight), (1.0, 1.0)); - } - - #[test] - fn ensure_settings_drops_duplicate_placements() { - let mut state = DockState::default(); - state.right.push(DockGroup::single(PanelId::Properties, 1.0)); - state.ensure_settings(); - assert_eq!(state.location(PanelId::Properties), Some((DockSide::Left, 0))); - assert_eq!(state.right.len(), 1); - } - #[test] fn every_panel_has_its_own_icon() { let ids = PanelId::ALL; @@ -1237,15 +1243,6 @@ mod tests { ); } - #[test] - fn slot_zone_splits_into_stacking_and_tab_bands() { - assert_eq!(slot_zone(10.0, 0.0, 400.0), SlotZone::Above); - assert_eq!(slot_zone(130.0, 0.0, 400.0), SlotZone::Above); - assert_eq!(slot_zone(200.0, 0.0, 400.0), SlotZone::Middle); - assert_eq!(slot_zone(270.0, 0.0, 400.0), SlotZone::Below); - assert_eq!(slot_zone(390.0, 0.0, 400.0), SlotZone::Below); - } - #[test] fn slot_spans_follow_weights() { assert_eq!(