diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 3aeb66db5..1a07ae657 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -598,6 +598,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } DockMsg::WidthReset(id) => { self.dock.reset_width(id); + self.dock.set_group_width(id, self.dock.settings(id).width); self.save_config(); iced::Task::none() } @@ -728,8 +729,12 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven Some((DockSide::Left, _)) => dx, _ => -dx, }; - let cur = self.dock.settings(id).width + delta; - self.dock.set_width(id, cur); + // 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); } } if self.dock_dragging.is_some() @@ -748,7 +753,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven 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 } => self.dock.add_tab(id, side, group), + DropTarget::Tab { side, group, index } => { + self.dock.add_tab(id, side, group, index) + } DropTarget::Float { x, y } => { let (w, h) = self.dock_float_size(id); self.dock.float(FloatPanel { id, x, y, w, h }) @@ -871,15 +878,44 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .collect() } - /// Width of the edge column on `side`: its widest shown panel. + /// Width of slot `gi` on `side`: all its tabs share the widest one's + /// width, so switching tabs never resizes the column. + pub(crate) fn dock_group_width(&self, side: crate::app::config::DockSide, gi: usize) -> f32 { + self.dock.groups(side)[gi] + .tabs + .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. pub(crate) fn dock_column_width(&self, side: crate::app::config::DockSide) -> f32 { self.dock_visible_groups(side) .into_iter() - .filter_map(|gi| self.dock_group_shown(side, gi)) - .map(|id| self.dock.width(id, self.win_size.0)) + .map(|gi| self.dock_group_width(side, gi)) .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. + 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 + } + } + } + /// Visible slots on `side` with their vertical span in the workspace. pub(crate) fn dock_slot_spans( &self, @@ -942,6 +978,29 @@ 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 && 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 }; + let visible_at = crate::ui::dock::tab_insert_index( + p.x, + x0, + self.dock_column_width(side), + 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 }, @@ -949,7 +1008,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven SlotZone::Middle if self.dock_group_visible_tabs(side, gi) == [id] => { DropTarget::Edge { side, index: gi } } - SlotZone::Middle => DropTarget::Tab { side, group: gi }, + 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)); @@ -1915,7 +1978,14 @@ mod tests { iced::Point::new(1500.0, 10.0), iced::Point::new(100.0, 450.0), ); - assert_eq!(target, Some(DropTarget::Tab { side: DockSide::Left, group: 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] @@ -1928,6 +1998,48 @@ mod tests { assert_eq!(app.dock.left[0].tabs.len(), 2); } + #[test] + fn dock_dragging_a_tab_along_the_strip_reorders_it() { + 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); + app.dock.right.clear(); + // Tabs: Properties | Blocks across the 250 px column. Drag the + // Properties tab past the end of the strip. + let _ = app.on_dock(DockMsg::SelectTab(PanelId::Properties)); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(60.0, 12.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(245.0, 12.0))); + 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] + ); + assert_eq!(app.dock.left[0].active, PanelId::Properties); + } + + #[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; @@ -2008,7 +2120,7 @@ mod tests { use crate::app::config::DockSide; use crate::ui::dock::PanelId; let mut app = dock_app(); - app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0); + 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)); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index d2cedd212..687f2ada3 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2773,7 +2773,7 @@ impl OpenCADStudio { // column is as wide as the widest one currently showing. let col_w = expanded .iter() - .map(|gi| self.dock.width(shown(*gi), self.win_size.0)) + .map(|gi| self.dock_group_width(side, *gi)) .fold(0.0, f32::max); let tab_strip = (!pinned.is_empty()).then(|| { @@ -2785,7 +2785,6 @@ impl OpenCADStudio { shown(*gi), &tabs, side, - self.dock_expanded.is_some_and(|e| tabs.contains(&e)), self.dock_dragging.is_some_and(|d| tabs.contains(&d)), ) }) @@ -2844,7 +2843,6 @@ impl OpenCADStudio { id: crate::ui::dock::PanelId, tabs: &[crate::ui::dock::PanelId], side: crate::app::config::DockSide, - is_active: bool, is_dragging: bool, ) -> Element<'_, Message> { let label = canvas(VBarLabel { @@ -2858,11 +2856,10 @@ impl OpenCADStudio { }) .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_active { - palette.primary.weak.color - } else if is_dragging { + if is_dragging { palette.primary.weak.color.scale_alpha(0.55) } else { palette.background.base.color @@ -2874,11 +2871,7 @@ impl OpenCADStudio { .style(move |theme: &Theme| container::Style { background: Some(Background::Color(bg(theme))), border: Border { - color: if is_active { - theme.palette().primary.base.color - } else { - theme.palette().background.neutral.color - }, + color: theme.palette().background.neutral.color, width: 1.0, radius: 0.0.into(), }, @@ -3117,16 +3110,23 @@ impl OpenCADStudio { top, )); } - DropTarget::Tab { side, group } => { + 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)); - let mut tabs = self.dock_group_visible_tabs(side, group); - tabs.retain(|t| *t != id); - tabs.push(id); + // 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( @@ -3576,10 +3576,6 @@ impl canvas::Program for VBarLabel { } } -/// Width of a collapsed (auto-collapsing) panel's tab in the edge strip. -const DOCK_RAIL_W: f32 = 28.0; -/// Width of the grabbable divider between a docked panel and the viewport. -const DOCK_DIVIDER_W: f32 = 5.0; /// Width of a floating panel's vertical title bar. const DOCK_FLOAT_BAR_W: f32 = 28.0; @@ -3645,8 +3641,7 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { .into() } -/// Height of a docked slot's tab strip. -const DOCK_TAB_H: f32 = 24.0; +use crate::ui::dock::{DOCK_DIVIDER_W, DOCK_RAIL_W, DOCK_TAB_H}; /// The tab row above a slot holding several panels. Pressing a tab shows its /// panel; dragging it pulls the panel out of the slot. diff --git a/src/ui/dock.rs b/src/ui/dock.rs index c891011e8..502d5fd68 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -420,15 +420,35 @@ impl DockState { *self != before } - /// Add `id` as a tab of slot `group` on `side` (index counted before the - /// move) and show it. Returns whether the layout changed. - pub fn add_tab(&mut self, id: PanelId, side: DockSide, group: usize) -> bool { + /// 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; } - if self.groups(side)[group].tabs.contains(&id) { - return self.select_tab(id); + 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 { @@ -436,11 +456,34 @@ impl DockState { } } let g = &mut self.groups_mut(side)[group]; - g.tabs.push(id); + 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. + pub fn group_width(&self, side: DockSide, group: usize) -> f32 { + self.groups(side)[group] + .tabs + .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. + 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(), + None => vec![id], + }; + for t in tabs { + self.set_width(t, width); + } + } + /// Float `id` at `rect`, on top of the other floating panels. pub fn float(&mut self, rect: FloatPanel) -> bool { let before = self.clone(); @@ -516,8 +559,13 @@ impl DockState { pub enum DropTarget { /// A new slot on `side` at insertion `index` (0 = top, len = bottom). Edge { side: DockSide, index: usize }, - /// A new tab in slot `group` on `side`. - Tab { side: DockSide, group: 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 { + side: DockSide, + group: usize, + index: Option, + }, /// Floating with its top-left corner at (`x`, `y`). Float { x: f32, y: f32 }, } @@ -574,6 +622,22 @@ 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 = 24.0; + +/// Insertion position (0..=`count`) for a tab dropped at `x` on a strip of +/// `count` equal tabs spanning `x0..x0 + width`. +pub fn tab_insert_index(x: f32, x0: f32, width: f32, count: usize) -> usize { + if count == 0 || width <= 0.0 { + return 0; + } + let cell = width / count as f32; + (((x - x0) / cell).round().max(0.0) as usize).min(count) +} // ── Shared panel chrome ───────────────────────────────────────────────── @@ -829,7 +893,7 @@ mod tests { #[test] fn layout_round_trips_through_serde() { let mut state = DockState::default(); - state.add_tab(PanelId::Browser, DockSide::Left, 0); + state.add_tab(PanelId::Browser, DockSide::Left, 0, None); state.float(FloatPanel { id: PanelId::Count, x: 10.0, @@ -845,7 +909,7 @@ 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)); + 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!( @@ -857,10 +921,50 @@ mod tests { 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() { + let mut state = DockState::default(); + state.set_width(PanelId::Properties, 320.0); + state.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); + 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); + } + + #[test] + fn tab_insert_index_rounds_to_the_nearest_gap() { + assert_eq!(tab_insert_index(0.0, 0.0, 300.0, 3), 0); + assert_eq!(tab_insert_index(140.0, 0.0, 300.0, 3), 1); + assert_eq!(tab_insert_index(290.0, 0.0, 300.0, 3), 3); + assert_eq!(tab_insert_index(-20.0, 0.0, 300.0, 3), 0); + } + #[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); + 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); @@ -878,7 +982,7 @@ mod tests { ]; 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)); + assert!(state.add_tab(PanelId::BlockPalette, DockSide::Left, 1, None)); assert_eq!(state.left.len(), 1); assert_eq!( state.left[0].tabs,