forked from 1b-agent/opencadstudio-upstream
Dock: one group per edge at a time, pallets stacked within a group
A group is now a set of pallets shown together, stacked top to bottom, and an edge shows one group at a time. The icon strip lists every group (grip bar over its pallets' icons, the shown group on a grey background); clicking any icon of a group shows that whole group. - Dropping a pallet on the top / bottom half of a pallet in the shown group stacks it above / below; dropping on the strip's icons places it in that group at that spot, and on a grip or below the last group starts a new group (which is then shown). - Splitters set the heights of the pallets within a group; double-click makes them equal again. - Each group keeps its own width; the divider sizes only the group it belongs to, and switching groups switches width. - A group whose pallets all auto-hide shows only while hovered. - Layouts saved by older builds load with their stacked pallets as one group. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
This commit is contained in:
4 files changed
+821
-915
No files matched your search
+3
-2
@@ -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)>,
|
||||
|
||||
+345
-378
@@ -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<crate::ui::dock::PanelId> {
|
||||
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<crate::ui::dock::PanelId> {
|
||||
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<usize> {
|
||||
(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<usize> {
|
||||
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<usize> {
|
||||
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<f32> = visible
|
||||
.iter()
|
||||
.map(|gi| self.dock.groups(side)[*gi].weight)
|
||||
let group = &self.dock.groups(side)[gi];
|
||||
let open: Vec<usize> = (0..group.panels.len())
|
||||
.filter(|i| self.dock_panel_visible(group.panels[*i]))
|
||||
.collect();
|
||||
visible
|
||||
.into_iter()
|
||||
let weights: Vec<f32> = 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();
|
||||
|
||||
+105
-164
@@ -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<Element<'a, Message>> {
|
||||
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<Element<'_, Message>> = 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<usize> = 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<Element<'_, Message>> = 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<Element<'_, Message>> = 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<Element<'_, Message>> = 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<usize> = (0..group.panels.len())
|
||||
.filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i]))
|
||||
.collect();
|
||||
let weights: Vec<f32> = 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<f32> = 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::<f32>() / others.len() as f32
|
||||
});
|
||||
let weights: Vec<f32> = 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()
|
||||
}
|
||||
|
||||
+368
-371
@@ -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<PanelId>,
|
||||
/// 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<PanelId>,
|
||||
/// Relative height of each pallet, parallel to `panels`.
|
||||
pub weights: Vec<f32>,
|
||||
}
|
||||
|
||||
/// 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<PanelId>) -> 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::<f32>() / 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<PanelId>,
|
||||
Stack {
|
||||
panels: Vec<PanelId>,
|
||||
#[serde(default)]
|
||||
active: Option<PanelId>,
|
||||
#[serde(default = "default_weight")]
|
||||
weight: f32,
|
||||
weights: Vec<f32>,
|
||||
},
|
||||
Tabs { tabs: Vec<PanelId> },
|
||||
}
|
||||
|
||||
fn default_weight() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl From<GroupRepr> 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<Vec<DockGroup>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let reprs = Vec::<GroupRepr>::deserialize(de)?;
|
||||
let mut groups = Vec::new();
|
||||
let mut legacy: Vec<PanelId> = 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<DockGroup>,
|
||||
/// 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<DockGroup>,
|
||||
/// The group each edge shows (left, right).
|
||||
pub shown: (usize, usize),
|
||||
/// Floating panels, back → front.
|
||||
pub floating: Vec<FloatPanel>,
|
||||
/// 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<PanelId, DockPanel>,
|
||||
}
|
||||
|
||||
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<bool> = 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::<f32>() / 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<usize>,
|
||||
) -> 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<usize>,
|
||||
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::<f32>() - 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!(
|
||||
|
||||
Reference in new issue
Block a user