Dock: reorder tabs by dragging, one width per tab group, plain rail hover

- Dragging a tab along its slot's tab strip moves it to the gap under the
  pointer; the drop preview shows the new tab order. A panel dragged from
  elsewhere onto a tab strip lands at that position too. A drop in the
  middle of the slot still appends a newcomer and leaves a member where
  it is.
- All tabs of a slot share one width: a joining panel takes the slot's
  width, resizing or resetting one tab sizes them all, and slots sized
  apart by older configs render at their widest tab. Switching tabs no
  longer changes the column width.
- Hovering an auto-hide rail raises its slot without turning the rail
  blue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
This commit is contained in:
Claude committed 2026-10-09 10:58:37 +00:00
1 parent 1de5597216
commit 2b2581eb46
3 files changed
+253 -42

No files matched your search

+121 -9
View File
@@ -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));
+16 -21
View File
@@ -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<Message> 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.
+116 -12
View File
@@ -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<usize>,
) -> 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<usize>,
},
/// 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,