diff --git a/src/app/mod.rs b/src/app/mod.rs index 899d0d2c8..464074156 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -870,8 +870,11 @@ pub(super) struct OpenCADStudio { pub(crate) dock_float_resizing: Option<(crate::ui::dock::PanelId, bool)>, /// Docked panel whose title bar the pointer is over (shows pin / close). pub(crate) dock_title_hover: Option, - /// Slot (side, index) whose pallet menu is open. - pub(crate) dock_tab_menu: Option<(crate::app::config::DockSide, usize)>, + /// Edge whose icon-strip pallet menu (+) is open. + pub(crate) dock_edge_menu: Option, + /// Group being dragged by its strip grip (side, slot index). While set, + /// `dock_dragging` holds the group's shown panel for the preview. + pub(crate) dock_dragging_group: Option<(crate::app::config::DockSide, usize)>, /// Reference-table column currently being width-resized (column index), /// with the last pointer position. Mirrors the Layers Name-column drag. pub(crate) xref_col_drag: Option, @@ -4312,7 +4315,8 @@ impl OpenCADStudio { dock_split_drag: None, dock_float_resizing: None, dock_title_hover: None, - dock_tab_menu: None, + dock_edge_menu: None, + dock_dragging_group: None, xref_col_drag: None, xref_col_last: None, xref_split_drag: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 2c3117af3..83463273d 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -624,33 +624,33 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } iced::Task::none() } - DockMsg::TabMenu(slot) => { - self.dock_tab_menu = slot; + DockMsg::EdgeMenu(side) => { + self.dock_edge_menu = side; iced::Task::none() } - DockMsg::TabMenuToggle(side, gi, id) => { - if gi >= self.dock.groups(side).len() { - self.dock_tab_menu = None; - return iced::Task::none(); - } - if self.dock_group_visible_tabs(side, gi).contains(&id) { - // Unchecking hides the pallet; it keeps its tab, so it - // comes back here when opened again. + DockMsg::EdgeMenuToggle(side, id) => { + if self.dock_panel_visible(id) { let task = self.on_dock(DockMsg::Close(id)); - if self.dock_group_shown(side, gi).is_none() { - self.dock_tab_menu = None; - } self.save_config(); return task; } - self.dock.add_tab(id, side, gi, None); - // Joining may have removed an earlier slot on this side. - self.dock_tab_menu = self.dock.location(id); + // A pallet opened from an edge's menu lands on that edge: in + // its own group there if it has one, else as a new group. + if self.dock.location(id).map(|(s, _)| s) != Some(side) { + self.dock.dock(id, side, usize::MAX); + } let task = self.dock_open_panel(id); self.dock.select_tab(id); self.save_config(); task } + DockMsg::GroupGrab(side, gi) => { + if let Some(id) = self.dock_group_shown(side, gi) { + self.dock_begin_drag(id); + self.dock_dragging_group = Some((side, gi)); + } + iced::Task::none() + } DockMsg::FloatRaise(id) => { self.dock.raise_float(id); iced::Task::none() @@ -754,7 +754,13 @@ 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 { - self.dock_drag_target = Some(self.dock_drop_target(id, point)); + let target = self.dock_drop_target(id, point); + // A whole group only moves between slots: 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 { if let Some(last) = self.dock_drag_last { @@ -797,12 +803,22 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven Some((DockSide::Left, _)) => dx, _ => -dx, }; - // Every tab of the slot shares one 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); + // 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); + } + } } } if self.dock_dragging.is_some() @@ -819,7 +835,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven let mut changed = self.dock_resizing.is_some() || self.dock_split_drag.is_some() || self.dock_float_resizing.is_some(); - if let (Some(id), Some(target)) = (self.dock_dragging, self.dock_drag_target) { + if let (Some((side, gi)), Some(DropTarget::Edge { side: to, index })) = + (self.dock_dragging_group, self.dock_drag_target) + { + changed |= self.dock.move_group(side, gi, to, index); + } 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 } => { @@ -839,6 +859,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.save_config(); } self.dock_dragging = None; + self.dock_dragging_group = None; self.dock_resizing = None; self.dock_split_drag = None; self.dock_float_resizing = None; @@ -892,6 +913,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven fn dock_begin_drag(&mut self, id: crate::ui::dock::PanelId) { self.dock.raise_float(id); self.dock_dragging = Some(id); + self.dock_dragging_group = None; + self.dock_edge_menu = None; self.dock_resizing = None; self.dock_split_drag = None; self.dock_float_resizing = None; @@ -1000,24 +1023,18 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .fold(0.0, f32::max) } - /// Left x of the expanded slots on `side` (past the auto-hide rail, if - /// the edge has one) in workspace pixels. - pub(crate) fn dock_column_x0(&self, side: crate::app::config::DockSide) -> f32 { - use crate::app::config::DockSide; - let has_rail = self.dock_visible_groups(side).into_iter().any(|gi| { - self.dock_group_shown(side, gi) - .is_some_and(|id| self.dock.auto_collapse(id)) - }); - let rail = if has_rail { crate::ui::dock::DOCK_RAIL_W } else { 0.0 }; - match side { - DockSide::Left => rail, - DockSide::Right => { - self.dock_workspace_size().0 - - rail - - self.dock_column_width(side) - - crate::ui::dock::DOCK_DIVIDER_W - } - } + /// The icon strip of `side`: each shown 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_tabs(side, gi).len())) + .collect(); + crate::ui::dock::strip_layout(&groups) } /// Visible slots on `side` with their vertical span in the workspace. @@ -1063,7 +1080,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven use crate::ui::dock::{slot_zone, DropTarget, SlotZone, DOCK_EDGE_ZONE}; let (ww, wh) = self.dock_workspace_size(); for side in [DockSide::Left, DockSide::Right] { - let zone = self.dock_column_width(side).max(DOCK_EDGE_ZONE); + 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, @@ -1071,6 +1089,37 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven if !inside { 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 { + 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 { + 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 + .get(index) + .and_then(|t| all.iter().position(|a| a == t)) + .unwrap_or(all.len()); + DropTarget::Tab { + side, + group, + index: Some(index), + } + } + }; + } let spans = self.dock_slot_spans(side); let Some(&(gi, top, bottom)) = spans .iter() @@ -1082,33 +1131,6 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven index: self.dock.groups(side).len(), }; }; - // Over a tab strip: drop between its tabs (reorders a member). - let tabs = self.dock_group_visible_tabs(side, gi); - if tabs.len() > 1 - && self.dock_expanded_groups(side).contains(&gi) - && p.y < top + crate::ui::dock::DOCK_TAB_H - { - let x0 = self.dock_column_x0(side) - + if side == DockSide::Right { crate::ui::dock::DOCK_DIVIDER_W } else { 0.0 } - + crate::ui::dock::DOCK_TAB_INSET; - let visible_at = crate::ui::dock::tab_insert_index( - p.x, - x0, - crate::ui::dock::DOCK_TAB_CELL_W, - tabs.len(), - ); - // Map the gap among visible tabs to a position in all tabs. - let all = &self.dock.groups(side)[gi].tabs; - let index = tabs - .get(visible_at) - .and_then(|t| all.iter().position(|a| a == t)) - .unwrap_or(all.len()); - return DropTarget::Tab { - side, - group: gi, - index: Some(index), - }; - } return match slot_zone(p.y, top, bottom) { SlotZone::Above => DropTarget::Edge { side, index: gi }, SlotZone::Below => DropTarget::Edge { side, index: gi + 1 }, @@ -2107,17 +2129,19 @@ mod tests { } #[test] - fn dock_dragging_a_tab_along_the_strip_reorders_it() { + fn dock_dragging_an_icon_along_the_strip_reorders_it() { use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, DropTarget, PanelId}; + 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(); - // Icon tabs: Properties | Blocks from the left. Drag the Properties - // tab past the Blocks tab. + // 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(10.0, 12.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(78.0, 12.0))); + 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 { @@ -2131,30 +2155,101 @@ mod tests { app.dock.left[0].tabs, vec![PanelId::BlockPalette, PanelId::Properties] ); - assert_eq!(app.dock.left[0].active, PanelId::Properties); } #[test] - fn dock_tab_menu_adds_and_hides_pallets() { + 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]); + } + + #[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.dock.left = vec![ + DockGroup { + tabs: vec![PanelId::Properties, PanelId::Browser], + active: PanelId::Properties, + weight: 1.0, + }, + DockGroup::single(PanelId::BlockPalette, 1.0), + ]; + 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))); + 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); + 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::TabMenu(Some((DockSide::Left, 0)))); - // Checking a closed pallet opens it as a tab of this slot. - let _ = app.on_dock(DockMsg::TabMenuToggle(DockSide::Left, 0, PanelId::Browser)); + 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[0].tabs, vec![PanelId::Properties, PanelId::Browser]); - assert_eq!(app.dock.left[0].active, PanelId::Browser); - // Checking a pallet docked elsewhere moves it here. - let _ = app.on_dock(DockMsg::TabMenuToggle(DockSide::Left, 0, PanelId::BlockPalette)); - assert!(app.dock.right.is_empty()); - assert_eq!(app.dock.left[0].tabs.len(), 3); - // Unchecking hides it but keeps its tab. - let _ = app.on_dock(DockMsg::TabMenuToggle(DockSide::Left, 0, PanelId::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!(app.dock.left[0].tabs.contains(&PanelId::Browser)); - assert_eq!(app.dock_tab_menu, Some((DockSide::Left, 0))); + 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] diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 1f4451134..08b181f37 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1936,39 +1936,29 @@ bg={bg_ms:.1}ms n={view_count}" layers.push(self.floating_panel(*f, tab)); } } - // An open pallet menu hangs under its slot's menu button, over a + // An open pallet menu hangs beside its edge's + button, over a // catcher that closes it on any click elsewhere. - if let Some((side, gi)) = self.dock_tab_menu { - let span = self - .dock_slot_spans(side) - .into_iter() - .find(|(g, _, _)| *g == gi); - if let Some((_, top, _)) = span { - let tabs = self.dock_group_visible_tabs(side, gi); + if let Some(side) = self.dock_edge_menu { + if !self.dock_visible_groups(side).is_empty() { let shown: Vec<_> = crate::ui::dock::PanelId::ALL .iter() - .map(|p| (*p, tabs.contains(p))) + .map(|p| (*p, self.dock_panel_visible(*p))) .collect(); - let right_pad = 2.0; - let button_right = self.dock_column_x0(side) - + if side == crate::app::config::DockSide::Right { - DOCK_DIVIDER_W - } else { - 0.0 + 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 } - + self.dock_column_width(side) - - right_pad; + }; layers.push( mouse_area(Space::new().width(Fill).height(Fill)) - .on_press(Message::Dock(crate::ui::dock::DockMsg::TabMenu(None))) - .on_right_press(Message::Dock(crate::ui::dock::DockMsg::TabMenu(None))) + .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_tab_menu(side, gi, &shown), - (button_right - DOCK_MENU_W).max(0.0), - top + crate::ui::dock::DOCK_TAB_H, - )); + layers.push(place_at(dock_edge_menu(side, &shown), x.max(0.0), plus_top)); } } // While a panel is dragged, preview where it lands. @@ -2779,56 +2769,32 @@ impl OpenCADStudio { iced::advanced::widget::operate(iced::advanced::widget::operation::focusable::unfocus()) } - /// Build one edge: a narrow tab strip at the very edge plus the expanded - /// slots sliding out beside it. Auto-collapsing (pinned) slots are always - /// visible as equal-height tabs in the strip (via `Fill` distribution); - /// hovering a tab raises its slot beside the strip, so switching between - /// stacked panels is a direct tab-to-tab hover with no width-jumping - /// collapse dance. Unpinned slots are always expanded and split the column - /// height by their weights, with a draggable splitter between neighbours. - /// The whole edge is one hover region: leaving it collapses any - /// auto-collapsing slot, but moving between the tabs and the expanded slot - /// does not. `None` when nothing on the edge is shown. + /// 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. fn build_edge_stack<'a>( &'a self, side: crate::app::config::DockSide, tab: &'a DocumentTab, ) -> Option> { - let visible = self.dock_visible_groups(side); - if visible.is_empty() { + if self.dock_visible_groups(side).is_empty() { return None; } - let shown = |gi: usize| self.dock_group_shown(side, gi).expect("visible group"); - let pinned: Vec = visible - .iter() - .copied() - .filter(|gi| self.dock.auto_collapse(shown(*gi))) - .collect(); let expanded = self.dock_expanded_groups(side); - // Each expanded slot renders at its shown panel's saved width; the - // column is as wide as the widest one currently showing. let col_w = expanded .iter() .map(|gi| self.dock_group_width(side, *gi)) .fold(0.0, f32::max); - let tab_strip = (!pinned.is_empty()).then(|| { - let tabs: Vec> = pinned - .iter() - .map(|gi| { - let tabs = self.dock_group_visible_tabs(side, *gi); - self.rail_slice( - shown(*gi), - &tabs, - side, - self.dock_dragging.is_some_and(|d| tabs.contains(&d)), - ) - }) - .collect(); - column(tabs).width(DOCK_RAIL_W).height(Fill).into() - }); + let strip = self.dock_icon_strip(side); let expanded_stack = (!expanded.is_empty()).then(|| { - // Expanded slots stack one below the other, each taking its + // Expanded groups stack one below the other, each taking its // weighted share of the column height — never overlapping. let mut layers: Vec> = Vec::new(); for (n, gi) in expanded.iter().enumerate() { @@ -2848,20 +2814,12 @@ impl OpenCADStudio { let mut children: Vec> = Vec::new(); match side { crate::app::config::DockSide::Left => { - if let Some(t) = tab_strip { - children.push(t); - } - if let Some(e) = expanded_stack { - children.push(e); - } + children.push(strip); + children.extend(expanded_stack); } crate::app::config::DockSide::Right => { - if let Some(e) = expanded_stack { - children.push(e); - } - if let Some(t) = tab_strip { - children.push(t); - } + children.extend(expanded_stack); + children.push(strip); } } Some( @@ -2871,52 +2829,98 @@ impl OpenCADStudio { ) } - /// One narrow tab in the edge strip, naming every panel of its slot. - /// Equal `Fill` heights across the tabs give each 1/N of the strip; - /// hovering or clicking it raises its slot. - fn rail_slice( - &self, - id: crate::ui::dock::PanelId, - tabs: &[crate::ui::dock::PanelId], - side: crate::app::config::DockSide, - is_dragging: bool, - ) -> Element<'_, Message> { - let label = canvas(VBarLabel { - text: tabs - .iter() - .map(|t| t.title()) - .collect::>() - .join(" · "), - clockwise: side == crate::app::config::DockSide::Left, - from_start: false, - }) - .width(Fill) - .height(Fill); - // Hovering raises the slot but leaves the rail looking as it is. - let bg = move |theme: &Theme| { - let palette = theme.palette(); - if is_dragging { - palette.primary.weak.color.scale_alpha(0.55) - } else { - palette.background.base.color + /// 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. + 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 mut items: Vec> = Vec::new(); + for gi in self.dock_visible_groups(side) { + items.push(Space::new().height(STRIP_GAP).into()); + let grip = container( + container(Space::new()) + .width(Length::Fixed(18.0)) + .height(Length::Fixed(3.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.strong.color, + )), + border: Border { + radius: 1.5.into(), + ..Default::default() + }, + ..Default::default() + }), + ) + .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 cell = mouse_area( + container(icon) + .center_x(Length::Fixed(DOCK_STRIP_W)) + .center_y(Length::Fixed(STRIP_CELL_H)), + ) + .on_press(Message::Dock(DockMsg::SelectTab(id))) + .on_enter(Message::Dock(DockMsg::Hover(id))) + .interaction(iced::mouse::Interaction::Pointer); + items.push( + iced::widget::tooltip( + cell, + text(id.title()).size(10), + match side { + crate::app::config::DockSide::Left => { + iced::widget::tooltip::Position::Right + } + crate::app::config::DockSide::Right => { + iced::widget::tooltip::Position::Left + } + }, + ) + .gap(4) + .into(), + ); } - }; - let tab = container(label) - .width(Length::Fixed(DOCK_RAIL_W)) + } + items.push(Space::new().height(STRIP_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(button::subtle) + .padding([5, 8]); + items.push(container(plus).center_x(Length::Fixed(DOCK_STRIP_W)).into()); + container(column(items)) + .width(Length::Fixed(DOCK_STRIP_W)) .height(Fill) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(bg(theme))), + .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() - }); - mouse_area(tab) - .interaction(iced::mouse::Interaction::Pointer) - .on_press(Message::Dock(crate::ui::dock::DockMsg::DockGrab(id))) - .on_enter(Message::Dock(crate::ui::dock::DockMsg::Hover(id))) + }) .into() } @@ -2977,8 +2981,8 @@ impl OpenCADStudio { } } - /// An expanded slot: its tab strip (when it holds more than one shown - /// panel), the shown panel, and a grabbable divider against the viewport. + /// 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. @@ -2989,16 +2993,8 @@ impl OpenCADStudio { width: f32, tab: &'a DocumentTab, ) -> Element<'a, Message> { - let tabs = self.dock_group_visible_tabs(side, gi); let id = self.dock_group_shown(side, gi).expect("visible group"); - let body = self.panel_body(id, width, false, tab); - let panel: Element<'_, Message> = if tabs.len() > 1 { - column![dock_tab_strip(&tabs, id, width, side, gi), body] - .height(Fill) - .into() - } else { - body - }; + 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(), @@ -3113,8 +3109,11 @@ impl OpenCADStudio { 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 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); + let w = edge_w(side) + strip; let band = container(Space::new()) .width(Length::Fixed(w)) .height(Fill) @@ -3134,8 +3133,8 @@ impl OpenCADStudio { .into() }; let edge_x = |side: DockSide, w: f32| match side { - DockSide::Left => 0.0, - DockSide::Right => (ww - w).max(0.0), + DockSide::Left => strip, + DockSide::Right => (ww - w - strip).max(0.0), }; let mut layers: Vec> = Vec::new(); match target { @@ -3689,84 +3688,16 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { .into() } -use crate::ui::dock::{DOCK_DIVIDER_W, DOCK_RAIL_W}; +use crate::ui::dock::DOCK_DIVIDER_W; -/// The tab row above a docked slot holding several pallets: one icon tab per -/// shown pallet, from the left, and a menu button on the right that lists -/// every pallet (drawn by `dock_tab_menu`). Pressing a tab shows its pallet; -/// dragging it reorders the tabs or pulls the pallet out. -fn dock_tab_strip( - tabs: &[crate::ui::dock::PanelId], - active: crate::ui::dock::PanelId, - width: f32, - side: crate::app::config::DockSide, - gi: usize, -) -> Element<'static, Message> { - use crate::ui::dock::{DockMsg, DOCK_TAB_CELL_W, DOCK_TAB_INSET}; - const TAB_GAP: f32 = 2.0; - let mut cells: Vec> = tabs - .iter() - .map(|id| { - let id = *id; - let is_active = id == active; - let cell = container(crate::ui::icons::themed(id.icon(), 18.0)) - .center_x(Length::Fixed(DOCK_TAB_CELL_W - TAB_GAP)) - .center_y(Fill) - .style(move |theme: &Theme| container::Style { - // Only the shown tab gets a background. - background: is_active - .then(|| Background::Color(theme.palette().background.weak.color)), - border: Border { - radius: iced::border::Radius::default().top(4.0), - ..Default::default() - }, - ..Default::default() - }); - let cell = mouse_area(cell) - .on_press(Message::Dock(DockMsg::SelectTab(id))) - .interaction(iced::mouse::Interaction::Pointer); - iced::widget::tooltip( - cell, - text(id.title()).size(10), - iced::widget::tooltip::Position::Bottom, - ) - .gap(4) - .into() - }) - .collect(); - cells.push(Space::new().width(Fill).into()); - cells.push( - button(crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 14.0)) - .on_press(Message::Dock(DockMsg::TabMenu(Some((side, gi))))) - .style(button::subtle) - .padding([3, 5]) - .into(), - ); - container(row(cells).spacing(TAB_GAP).height(Fill).align_y(iced::Center)) - .width(Length::Fixed(width)) - .height(Length::Fixed(crate::ui::dock::DOCK_TAB_H)) - .padding(iced::Padding { - top: 3.0, - right: 2.0, - bottom: 0.0, - left: DOCK_TAB_INSET, - }) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.strong.color)), - ..Default::default() - }) - .into() -} - -/// Width of the tab row's pallet menu. +/// Width of the icon strip's pallet menu. const DOCK_MENU_W: f32 = 230.0; -/// The pallet menu of slot (`side`, `gi`), styled like the right-click menu: -/// one row per pallet with its icon, its name and a check mark when it shows -/// in this slot. `shown` holds each pallet with that state. -fn dock_tab_menu( +/// 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 +/// shown. `shown` holds each pallet with that state. +fn dock_edge_menu( side: crate::app::config::DockSide, - gi: usize, shown: &[(crate::ui::dock::PanelId, bool)], ) -> Element<'static, Message> { use crate::ui::dock::DockMsg; @@ -3788,7 +3719,7 @@ fn dock_tab_menu( .spacing(8) .align_y(iced::Center), ) - .on_press(Message::Dock(DockMsg::TabMenuToggle(side, gi, id))) + .on_press(Message::Dock(DockMsg::EdgeMenuToggle(side, id))) .style(|theme: &Theme, status| { let palette = theme.palette(); button::Style { diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 3ebe13176..63e88b967 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -43,12 +43,14 @@ pub enum DockMsg { FloatResizeGrab(PanelId, bool), /// The pointer entered (`Some`) or left (`None`) a docked title bar. TitleHover(Option), - /// Open (`Some`) the pallet menu of a slot (side, slot index), or close - /// it (`None`). - TabMenu(Option<(DockSide, usize)>), - /// Pallet menu pick: show `panel` as a tab of the slot, or hide it when - /// it already shows there. - TabMenuToggle(DockSide, usize, PanelId), + /// Open (`Some`) the pallet menu of an edge's icon strip (its + button), + /// or close it (`None`). + EdgeMenu(Option), + /// Pallet menu pick: hide `panel` when it shows, else open it docked on + /// the edge (as a new group unless it already has a group there). + EdgeMenuToggle(DockSide, PanelId), + /// Begin dragging a whole group (slot) by its grip in the icon strip. + GroupGrab(DockSide, usize), /// Double-click on a docked title bar: float the panel. FloatOut(PanelId), /// Double-click on a floating title bar: dock the panel on that side. @@ -503,6 +505,28 @@ impl DockState { } } + /// Move slot `group` on `side` to insertion `index` on `to` (counted + /// before the move). 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; + } + let mut index = index; + if side == to { + if index == group || index == group + 1 { + return false; + } + if group < index { + index -= 1; + } + } + let g = self.groups_mut(side).remove(group); + let dest = self.groups_mut(to); + let index = index.min(dest.len()); + dest.insert(index, g); + true + } + /// Float `id` at `rect`, on top of the other floating panels. pub fn float(&mut self, rect: FloatPanel) -> bool { let before = self.clone(); @@ -641,25 +665,79 @@ pub const DOCK_MIN_W: f32 = 200.0; pub const DOCK_MAX_W: f32 = 600.0; /// Width of the band along an empty edge that docks a dragged panel there. pub const DOCK_EDGE_ZONE: f32 = 48.0; -/// Width of a collapsed (auto-collapsing) slot's tab in the edge strip. -pub const DOCK_RAIL_W: f32 = 28.0; /// Width of the grabbable divider between a docked slot and the viewport. pub const DOCK_DIVIDER_W: f32 = 5.0; -/// Height of a docked slot's tab strip. -pub const DOCK_TAB_H: f32 = 28.0; +/// Width of an edge's vertical icon strip (the groups' tabs). +pub const DOCK_STRIP_W: f32 = 36.0; +/// Height of one pallet icon in the strip. +pub const STRIP_CELL_H: f32 = 32.0; +/// Height of the grip bar above each group's icons. +pub const STRIP_GRIP_H: f32 = 10.0; +/// Space above the first group and between groups in the strip. +pub const STRIP_GAP: f32 = 6.0; -/// Pitch of one icon tab in a tab strip (tab plus the gap after it). -pub const DOCK_TAB_CELL_W: f32 = 34.0; -/// Left inset of the first tab in a tab strip. -pub const DOCK_TAB_INSET: f32 = 4.0; +/// Where one group sits in an edge's icon strip, top to bottom. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StripGroup { + /// Slot index on the edge. + pub group: usize, + /// Top of the group's grip bar. + pub grip_top: f32, + /// Top of the group's first icon. + pub icons_top: f32, + /// Number of icons (visible tabs). + pub icons: usize, +} -/// Insertion position (0..=`count`) for a tab dropped at `x` on a strip of -/// `count` left-aligned tabs of pitch `cell` starting at `x0`. -pub fn tab_insert_index(x: f32, x0: f32, cell: f32, count: usize) -> usize { - if count == 0 || cell <= 0.0 { - return 0; +/// Lay out the icon strip for `groups` = (slot index, visible tab count), +/// returning each group's place and the top of the + button below them. +pub fn strip_layout(groups: &[(usize, usize)]) -> (Vec, f32) { + let mut y = STRIP_GAP; + let placed = groups + .iter() + .map(|&(group, icons)| { + let grip_top = y; + let icons_top = grip_top + STRIP_GRIP_H; + y = icons_top + icons as f32 * STRIP_CELL_H + STRIP_GAP; + StripGroup { + group, + grip_top, + icons_top, + icons, + } + }) + .collect(); + (placed, y) +} + +/// What a dragged pallet released at height `y` over the icon strip joins. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StripHit { + /// A new group before slot `group`; `None` = after the last group. + NewGroup(Option), + /// Group `group`, at position `index` among its visible icons. + Tab { group: usize, index: usize }, +} + +/// Classify a pointer at strip height `y`: over a group's grip (or the gap +/// above it) starts a new group there, over its icons joins it between the +/// nearest icons, below every group starts a new last group. +pub fn strip_hit(y: f32, layout: &[StripGroup]) -> StripHit { + for g in layout { + if y < g.icons_top { + return StripHit::NewGroup(Some(g.group)); + } + let bottom = g.icons_top + g.icons as f32 * STRIP_CELL_H + STRIP_GAP * 0.5; + if y < bottom { + let index = (((y - g.icons_top) / STRIP_CELL_H).round().max(0.0) as usize) + .min(g.icons); + return StripHit::Tab { + group: g.group, + index, + }; + } } - (((x - x0) / cell).round().max(0.0) as usize).min(count) + StripHit::NewGroup(None) } // ── Shared panel chrome ───────────────────────────────────────────────── @@ -978,12 +1056,40 @@ mod tests { } #[test] - fn tab_insert_index_rounds_to_the_nearest_gap() { - assert_eq!(tab_insert_index(0.0, 0.0, 30.0, 3), 0); - assert_eq!(tab_insert_index(40.0, 0.0, 30.0, 3), 1); - assert_eq!(tab_insert_index(85.0, 0.0, 30.0, 3), 3); - assert_eq!(tab_insert_index(250.0, 0.0, 30.0, 3), 3); - assert_eq!(tab_insert_index(-20.0, 0.0, 30.0, 3), 0); + fn strip_hit_maps_grips_icons_and_the_end() { + // Two groups: three icons, then one. + let (layout, plus_top) = strip_layout(&[(0, 3), (2, 1)]); + let g0 = layout[0]; + let g1 = layout[1]; + assert_eq!(g0.grip_top, STRIP_GAP); + assert_eq!(g1.icons_top, g1.grip_top + STRIP_GRIP_H); + assert_eq!(plus_top, g1.icons_top + STRIP_CELL_H + STRIP_GAP); + // On the first grip: a new group before slot 0. + assert_eq!(strip_hit(g0.grip_top + 2.0, &layout), StripHit::NewGroup(Some(0))); + // Between the first and second icon of group 0. + assert_eq!( + strip_hit(g0.icons_top + STRIP_CELL_H + 4.0, &layout), + StripHit::Tab { group: 0, index: 1 } + ); + // On the second group's grip: a new group before slot 2. + assert_eq!(strip_hit(g1.grip_top + 1.0, &layout), StripHit::NewGroup(Some(2))); + // Below everything: a new last group. + 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]