forked from 1b-agent/opencadstudio-upstream
Dock: floating panels, tab groups and adjustable slot heights
Panels can now float over the workspace, share a docked slot as tabs, and split an edge at heights the user picks. - Dropping a panel away from the edges floats it at the pointer. Floating panels move by their title bar, resize from a corner grip, and come to the front when clicked. - Dropping in the top or bottom quarter of a docked slot docks the panel above or below it, as before. Dropping in the middle adds the panel as a tab of that slot. Pressing a tab shows that panel; dragging a tab pulls the panel out again. - While dragging, a blue ghost previews the result: the new slot, the slot with its future tab row, or the floating window. - A splitter between stacked slots sets their heights; double-click it to make the slots equal again. Heights persist. - A press only becomes a drag after 5 px of movement, so clicking a title bar or tab never moves the panel. The persisted layout now stores each edge as tab groups with a height weight, plus a list of floating panels. Configs with the old bare-panel-id stacks still load as single-panel slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
This commit is contained in:
11 files changed
+1452
-417
No files matched your search
@@ -911,10 +911,9 @@ impl OpenCADStudio {
|
||||
if self.show_external_references {
|
||||
// Always open expanded so the panel is immediately usable;
|
||||
// dock it on the right if the user closed it from the layout.
|
||||
if self
|
||||
if !self
|
||||
.dock
|
||||
.location(crate::ui::dock::PanelId::ExternalReferences)
|
||||
.is_none()
|
||||
.is_placed(crate::ui::dock::PanelId::ExternalReferences)
|
||||
{
|
||||
self.dock.dock(
|
||||
crate::ui::dock::PanelId::ExternalReferences,
|
||||
|
||||
@@ -236,7 +236,7 @@ impl OpenCADStudio {
|
||||
self.count_palette.show = open;
|
||||
self.ribbon.set_count_palette(open);
|
||||
if open {
|
||||
if self.dock.location(id).is_none() {
|
||||
if !self.dock.is_placed(id) {
|
||||
self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX);
|
||||
}
|
||||
self.dock_expanded = Some(id);
|
||||
|
||||
@@ -679,7 +679,7 @@ impl OpenCADStudio {
|
||||
// rearrange themselves on upgrade for a panel they have
|
||||
// not asked for.
|
||||
let id = crate::ui::dock::PanelId::Browser;
|
||||
if self.dock.location(id).is_none() {
|
||||
if !self.dock.is_placed(id) {
|
||||
// dock() clamps an out-of-range index to the end of
|
||||
// the edge, so this appends below whatever is there.
|
||||
self.dock
|
||||
|
||||
@@ -374,7 +374,7 @@ impl OpenCADStudio {
|
||||
"POINTCLOUDMANAGER" => {
|
||||
let id = crate::ui::dock::PanelId::PointCloudManager;
|
||||
self.pc_manager.show = true;
|
||||
if self.dock.location(id).is_none() {
|
||||
if !self.dock.is_placed(id) {
|
||||
self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX);
|
||||
}
|
||||
self.dock_expanded = Some(id);
|
||||
|
||||
@@ -577,7 +577,7 @@ impl OpenCADStudio {
|
||||
self.sheet_set.show = on;
|
||||
self.ribbon.set_sheet_set(on);
|
||||
if on {
|
||||
if self.dock.location(id).is_none() {
|
||||
if !self.dock.is_placed(id) {
|
||||
self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX);
|
||||
}
|
||||
self.dock_expanded = Some(id);
|
||||
|
||||
+17
-2
@@ -852,8 +852,19 @@ pub(super) struct OpenCADStudio {
|
||||
pub(crate) dock_resizing: Option<crate::ui::dock::PanelId>,
|
||||
/// Last pointer position during a drag / resize.
|
||||
pub(crate) dock_drag_last: Option<iced::Point>,
|
||||
/// Live drag target (side + index), shown as a highlight while dragging.
|
||||
pub(crate) dock_drag_target: Option<(crate::app::config::DockSide, usize)>,
|
||||
/// Live drop target, shown as a preview while dragging. `None` until the
|
||||
/// pointer has moved far enough to count as a drag, so a click on a title
|
||||
/// bar or tab never moves the panel.
|
||||
pub(crate) dock_drag_target: Option<crate::ui::dock::DropTarget>,
|
||||
/// First pointer position of the current panel drag.
|
||||
pub(crate) dock_drag_origin: Option<iced::Point>,
|
||||
/// 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)>,
|
||||
/// Floating panel being resized from its corner grip.
|
||||
pub(crate) dock_float_resizing: Option<crate::ui::dock::PanelId>,
|
||||
/// Reference-table column currently being width-resized (column index),
|
||||
/// with the last pointer position. Mirrors the Layers Name-column drag.
|
||||
pub(crate) xref_col_drag: Option<usize>,
|
||||
@@ -4289,6 +4300,10 @@ impl OpenCADStudio {
|
||||
dock_resizing: None,
|
||||
dock_drag_last: None,
|
||||
dock_drag_target: None,
|
||||
dock_drag_origin: None,
|
||||
dock_grab_offset: iced::Vector::new(0.0, 0.0),
|
||||
dock_split_drag: None,
|
||||
dock_float_resizing: None,
|
||||
xref_col_drag: None,
|
||||
xref_col_last: None,
|
||||
xref_split_drag: false,
|
||||
|
||||
@@ -173,7 +173,7 @@ impl OpenCADStudio {
|
||||
// The node library docks on the right on first use; after
|
||||
// that it stays wherever the user moved it.
|
||||
let id = crate::ui::dock::PanelId::NodeGraph;
|
||||
if self.dock.location(id).is_none() {
|
||||
if !self.dock.is_placed(id) {
|
||||
self.dock
|
||||
.dock(id, crate::app::config::DockSide::Right, usize::MAX);
|
||||
}
|
||||
|
||||
+458
-118
@@ -20,6 +20,9 @@ use codec::{EntityType as AcadEntityType, Handle};
|
||||
use iced::time::Instant;
|
||||
use iced::{mouse, Point, Task};
|
||||
|
||||
/// Pointer travel (px) before pressing a panel's title bar or tab becomes a
|
||||
/// drag, so a plain click never moves the panel.
|
||||
const DOCK_DRAG_THRESHOLD: f32 = 5.0;
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task<Message> {
|
||||
@@ -542,14 +545,19 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
/// whichever panel the message names.
|
||||
pub(super) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task<Message> {
|
||||
use crate::app::config::DockSide;
|
||||
use crate::ui::dock::{DockMsg, PanelId};
|
||||
use crate::ui::dock::{DockMsg, DropTarget, FloatPanel, PanelId};
|
||||
match m {
|
||||
DockMsg::DockGrab(id) => {
|
||||
self.dock_dragging = Some(id);
|
||||
self.dock_resizing = None;
|
||||
self.dock_drag_last = None;
|
||||
self.dock_drag_target = self.dock.location(id);
|
||||
self.dock_expanded = Some(id);
|
||||
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) {
|
||||
self.save_config();
|
||||
}
|
||||
self.dock_begin_drag(id);
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::ResizeGrab(id) => {
|
||||
@@ -560,6 +568,30 @@ 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));
|
||||
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);
|
||||
self.save_config();
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::FloatResizeGrab(id) => {
|
||||
self.dock.raise_float(id);
|
||||
self.dock_float_resizing = Some(id);
|
||||
self.dock_dragging = None;
|
||||
self.dock_drag_last = None;
|
||||
self.dock_drag_target = None;
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::FloatRaise(id) => {
|
||||
self.dock.raise_float(id);
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::WidthReset(id) => {
|
||||
self.dock.reset_width(id);
|
||||
self.save_config();
|
||||
@@ -639,19 +671,50 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
// tracker reports header-local points, and mixing the two
|
||||
// produced a one-time jump plus a stuck drag. Column moves
|
||||
// arrive via Message::XrefColMove only.
|
||||
if self.dock_dragging.is_some() {
|
||||
let avail = self.tabs[self.active_tab].scene.selection.borrow().view.vp_size.1;
|
||||
let side = if point.x < self.win_size.0 * 0.5 {
|
||||
DockSide::Left
|
||||
} else {
|
||||
DockSide::Right
|
||||
};
|
||||
let index = crate::ui::dock::drop_index(
|
||||
point.y,
|
||||
std::cmp::max(self.dock_visible_len(side), 1),
|
||||
avail,
|
||||
);
|
||||
self.dock_drag_target = Some((side, index));
|
||||
if let Some(id) = self.dock_dragging {
|
||||
let origin = *self.dock_drag_origin.get_or_insert(point);
|
||||
let moved = (point.x - origin.x).hypot(point.y - origin.y);
|
||||
if self.dock_drag_target.is_none() && moved >= DOCK_DRAG_THRESHOLD {
|
||||
// Hold a floating panel where it was grabbed; a docked
|
||||
// one by its title bar.
|
||||
self.dock_grab_offset = match self.dock.float_rect(id) {
|
||||
Some(f) => iced::Vector::new(origin.x - f.x, origin.y - f.y),
|
||||
None => iced::Vector::new(
|
||||
(self.dock.width(id, self.win_size.0) * 0.5).min(120.0),
|
||||
14.0,
|
||||
),
|
||||
};
|
||||
}
|
||||
if self.dock_drag_target.is_some() || moved >= DOCK_DRAG_THRESHOLD {
|
||||
self.dock_drag_target = Some(self.dock_drop_target(id, point));
|
||||
}
|
||||
} else if let Some((side, 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)
|
||||
.iter()
|
||||
.map(|gi| self.dock.groups(side)[*gi].weight)
|
||||
.sum();
|
||||
if avail > 0.0 && total > 0.0 {
|
||||
let per_px = total / avail;
|
||||
self.dock.shift_split(
|
||||
side,
|
||||
upper,
|
||||
lower,
|
||||
(point.y - last.y) * per_px,
|
||||
crate::ui::dock::GROUP_MIN_H * per_px,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if let Some(id) = self.dock_float_resizing {
|
||||
if let (Some(last), Some(f)) = (self.dock_drag_last, self.dock.float_rect(id)) {
|
||||
self.dock.resize_float(
|
||||
id,
|
||||
f.w + point.x - last.x,
|
||||
f.h + point.y - last.y,
|
||||
);
|
||||
}
|
||||
} else if let Some(id) = self.dock_resizing {
|
||||
if let Some(last) = self.dock_drag_last {
|
||||
let dx = point.x - last.x;
|
||||
@@ -663,22 +726,42 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
self.dock.set_width(id, cur);
|
||||
}
|
||||
}
|
||||
if self.dock_dragging.is_some() || self.dock_resizing.is_some() {
|
||||
if self.dock_dragging.is_some()
|
||||
|| self.dock_resizing.is_some()
|
||||
|| self.dock_split_drag.is_some()
|
||||
|| self.dock_float_resizing.is_some()
|
||||
{
|
||||
self.dock_drag_last = Some(point);
|
||||
}
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::DragRelease => {
|
||||
if let Some(id) = self.dock_dragging {
|
||||
if let Some((side, index)) = self.dock_drag_target {
|
||||
if self.dock.dock(id, side, index) {
|
||||
self.save_config();
|
||||
let mut changed = self.dock_resizing.is_some()
|
||||
|| self.dock_split_drag.is_some()
|
||||
|| self.dock_float_resizing.is_some();
|
||||
if let (Some(id), Some(target)) = (self.dock_dragging, self.dock_drag_target) {
|
||||
let moved = match target {
|
||||
DropTarget::Edge { side, index } => self.dock.dock(id, side, index),
|
||||
DropTarget::Tab { side, group } => self.dock.add_tab(id, side, group),
|
||||
DropTarget::Float { x, y } => {
|
||||
let (w, h) = self.dock_float_size(id);
|
||||
self.dock.float(FloatPanel { id, x, y, w, h })
|
||||
}
|
||||
};
|
||||
if moved {
|
||||
self.dock_expanded = Some(id);
|
||||
}
|
||||
changed |= moved;
|
||||
}
|
||||
if changed {
|
||||
self.save_config();
|
||||
}
|
||||
self.dock_dragging = None;
|
||||
self.dock_resizing = None;
|
||||
self.dock_split_drag = None;
|
||||
self.dock_float_resizing = None;
|
||||
self.dock_drag_last = None;
|
||||
self.dock_drag_origin = None;
|
||||
self.dock_drag_target = None;
|
||||
self.xref_col_drag = None;
|
||||
self.xref_col_last = None;
|
||||
@@ -688,6 +771,20 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a (not yet confirmed) drag of `id`: it only moves once the
|
||||
/// pointer travels past `DOCK_DRAG_THRESHOLD`.
|
||||
fn dock_begin_drag(&mut self, id: crate::ui::dock::PanelId) {
|
||||
self.dock.raise_float(id);
|
||||
self.dock_dragging = Some(id);
|
||||
self.dock_resizing = None;
|
||||
self.dock_split_drag = None;
|
||||
self.dock_float_resizing = None;
|
||||
self.dock_drag_last = None;
|
||||
self.dock_drag_origin = None;
|
||||
self.dock_drag_target = None;
|
||||
self.dock_expanded = Some(id);
|
||||
}
|
||||
|
||||
/// Whether `id` is currently rendered (not closed, not on the start screen /
|
||||
/// clean-screen viewport). Mirrors the visibility filter the edge column
|
||||
/// renderer applies before building each side's stack.
|
||||
@@ -708,17 +805,150 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of panels currently rendered on `side`. Closed panels keep their
|
||||
/// stack slot but take no vertical space, so drag preview geometry must
|
||||
/// size slots against only the panels that are actually visible.
|
||||
pub(crate) fn dock_visible_len(&self, side: crate::app::config::DockSide) -> usize {
|
||||
let ids: &[crate::ui::dock::PanelId] = match side {
|
||||
crate::app::config::DockSide::Left => &self.dock.left,
|
||||
crate::app::config::DockSide::Right => &self.dock.right,
|
||||
};
|
||||
ids.iter()
|
||||
.filter(|id| self.dock_panel_visible(**id))
|
||||
.count()
|
||||
/// Size of the workspace the dock lays out in: window width by viewport
|
||||
/// height.
|
||||
pub(crate) fn dock_workspace_size(&self) -> (f32, f32) {
|
||||
(
|
||||
self.win_size.0,
|
||||
self.tabs[self.active_tab].scene.selection.borrow().view.vp_size.1,
|
||||
)
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&self,
|
||||
side: crate::app::config::DockSide,
|
||||
gi: usize,
|
||||
) -> Vec<crate::ui::dock::PanelId> {
|
||||
self.dock.groups(side)[gi]
|
||||
.tabs
|
||||
.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).
|
||||
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())
|
||||
.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()
|
||||
}
|
||||
|
||||
/// Width of the edge column on `side`: its widest shown panel.
|
||||
pub(crate) fn dock_column_width(&self, side: crate::app::config::DockSide) -> f32 {
|
||||
self.dock_visible_groups(side)
|
||||
.into_iter()
|
||||
.filter_map(|gi| self.dock_group_shown(side, gi))
|
||||
.map(|id| self.dock.width(id, self.win_size.0))
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Visible slots on `side` with their vertical span in the workspace.
|
||||
pub(crate) fn dock_slot_spans(
|
||||
&self,
|
||||
side: crate::app::config::DockSide,
|
||||
) -> Vec<(usize, f32, f32)> {
|
||||
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)
|
||||
.collect();
|
||||
visible
|
||||
.into_iter()
|
||||
.zip(crate::ui::dock::slot_spans(&weights, avail))
|
||||
.map(|(gi, (top, bottom))| (gi, top, bottom))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Size a panel takes when floated: its current floating size, or its
|
||||
/// docked width by a comfortable share of the workspace height.
|
||||
pub(crate) fn dock_float_size(&self, id: crate::ui::dock::PanelId) -> (f32, f32) {
|
||||
if let Some(f) = self.dock.float_rect(id) {
|
||||
return (f.w, f.h);
|
||||
}
|
||||
let (_, avail) = self.dock_workspace_size();
|
||||
(
|
||||
self.dock.width(id, self.win_size.0),
|
||||
(avail * 0.6).clamp(crate::ui::dock::FLOAT_MIN_H, 520.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
use crate::app::config::DockSide;
|
||||
use crate::ui::dock::{slot_zone, DropTarget, SlotZone, DOCK_EDGE_ZONE};
|
||||
let (ww, wh) = self.dock_workspace_size();
|
||||
for side in [DockSide::Left, DockSide::Right] {
|
||||
let zone = self.dock_column_width(side).max(DOCK_EDGE_ZONE);
|
||||
let inside = match side {
|
||||
DockSide::Left => p.x <= zone,
|
||||
DockSide::Right => p.x >= ww - zone,
|
||||
};
|
||||
if !inside {
|
||||
continue;
|
||||
}
|
||||
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 }
|
||||
}
|
||||
SlotZone::Middle => DropTarget::Tab { side, group: gi },
|
||||
};
|
||||
}
|
||||
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));
|
||||
DropTarget::Float { x, y }
|
||||
}
|
||||
|
||||
/// Rebuild the Reference Manager's entry list from the active drawing.
|
||||
@@ -1602,112 +1832,222 @@ mod tests {
|
||||
assert!(!app.show_block_palette, "close dismisses the sidebar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockpalette_dock_moves_to_other_side_and_persists() {
|
||||
/// A hermetic app with a 1600×900 workspace and the default dock layout
|
||||
/// (Properties left, block palette right), both shown.
|
||||
fn dock_app() -> OpenCADStudio {
|
||||
let mut app = fresh();
|
||||
// Tests load the user's persisted config; reset the dock to a known
|
||||
// state so this stays hermetic.
|
||||
app.dock = Default::default();
|
||||
app.dock.ensure_settings();
|
||||
app.show_properties = true;
|
||||
app.show_block_palette = true;
|
||||
let i = app.active_tab;
|
||||
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0);
|
||||
app.win_size = (1600.0, 900.0).into();
|
||||
app
|
||||
}
|
||||
|
||||
/// Press `id`'s title bar at `from`, drag to `to`, and return the target
|
||||
/// shown just before release.
|
||||
fn drag_panel(
|
||||
app: &mut OpenCADStudio,
|
||||
id: crate::ui::dock::PanelId,
|
||||
from: iced::Point,
|
||||
to: iced::Point,
|
||||
) -> Option<crate::ui::dock::DropTarget> {
|
||||
use crate::ui::dock::DockMsg;
|
||||
let _ = app.on_dock(DockMsg::DockGrab(id));
|
||||
let _ = app.on_dock(DockMsg::DragMove(from));
|
||||
let _ = app.on_dock(DockMsg::DragMove(to));
|
||||
let target = app.dock_drag_target;
|
||||
let _ = app.on_dock(DockMsg::DragRelease);
|
||||
target
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockpalette_dock_moves_to_other_side_and_persists() {
|
||||
use crate::app::config::DockSide;
|
||||
use crate::ui::dock::DropTarget;
|
||||
let mut app = dock_app();
|
||||
let id = crate::ui::dock::PanelId::BlockPalette;
|
||||
assert_eq!(
|
||||
app.dock.location(id),
|
||||
Some((crate::app::config::DockSide::Right, 0))
|
||||
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),
|
||||
);
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
|
||||
app.win_size = (1600.0, 900.0).into();
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 100.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_drag_target,
|
||||
Some((crate::app::config::DockSide::Left, 0))
|
||||
app.dock.location(crate::ui::dock::PanelId::Properties),
|
||||
Some((DockSide::Left, 1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_click_without_drag_changes_nothing() {
|
||||
let mut app = dock_app();
|
||||
let before = app.dock.clone();
|
||||
let id = crate::ui::dock::PanelId::BlockPalette;
|
||||
let p = iced::Point::new(1500.0, 10.0);
|
||||
let target = drag_panel(&mut app, id, p, iced::Point::new(1502.0, 11.0));
|
||||
assert_eq!(target, None, "movement under the threshold is a click");
|
||||
assert_eq!(app.dock, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_drop_in_slot_middle_adds_a_tab() {
|
||||
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, 450.0),
|
||||
);
|
||||
assert_eq!(target, Some(DropTarget::Tab { side: DockSide::Left, group: 0 }));
|
||||
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.location(id),
|
||||
Some((crate::app::config::DockSide::Left, 0))
|
||||
);
|
||||
assert_eq!(app.dock.left[0].active, PanelId::Properties);
|
||||
assert_eq!(app.dock.left[0].tabs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_visible_len_counts_only_rendered_panels() {
|
||||
let mut app = fresh();
|
||||
app.dock = Default::default();
|
||||
app.dock.left = vec![
|
||||
crate::ui::dock::PanelId::BlockPalette,
|
||||
crate::ui::dock::PanelId::Properties,
|
||||
];
|
||||
// Left: block palette hidden, properties shown -> 1 visible panel.
|
||||
app.show_block_palette = false;
|
||||
app.show_properties = true;
|
||||
assert_eq!(
|
||||
app.dock_visible_len(crate::app::config::DockSide::Left),
|
||||
1
|
||||
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),
|
||||
);
|
||||
// Reveal the block palette -> both count.
|
||||
assert_eq!(target, Some(DropTarget::Edge { side: DockSide::Left, index: 1 }));
|
||||
assert_eq!(app.dock.location(PanelId::BlockPalette), Some((DockSide::Left, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_drop_over_the_viewport_floats_the_panel() {
|
||||
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(800.0, 400.0),
|
||||
);
|
||||
assert!(matches!(target, Some(DropTarget::Float { .. })));
|
||||
assert!(app.dock.right.is_empty());
|
||||
let f = app.dock.float_rect(PanelId::BlockPalette).expect("floating");
|
||||
assert!(f.x > 600.0 && f.x < 800.0 && f.y > 350.0 && f.y < 400.0);
|
||||
// Dragging a floating panel keeps the grab point under the pointer.
|
||||
let target = drag_panel(
|
||||
&mut app,
|
||||
PanelId::BlockPalette,
|
||||
iced::Point::new(f.x + 20.0, f.y + 10.0),
|
||||
iced::Point::new(f.x + 120.0, f.y + 60.0),
|
||||
);
|
||||
assert_eq!(target, Some(DropTarget::Float { x: f.x + 100.0, y: f.y + 50.0 }));
|
||||
// Opening a floating panel must not re-dock it.
|
||||
assert!(app.dock.is_placed(PanelId::BlockPalette));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_drop_on_own_lone_slot_is_a_no_op() {
|
||||
use crate::app::config::DockSide;
|
||||
use crate::ui::dock::{DropTarget, PanelId};
|
||||
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),
|
||||
);
|
||||
assert_eq!(target, Some(DropTarget::Edge { side: DockSide::Left, index: 0 }));
|
||||
assert_eq!(app.dock, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_visible_groups_skip_closed_panels() {
|
||||
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),
|
||||
];
|
||||
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.
|
||||
assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(1, 0.0, 900.0)]);
|
||||
app.show_block_palette = true;
|
||||
assert_eq!(
|
||||
app.dock_visible_len(crate::app::config::DockSide::Left),
|
||||
2
|
||||
);
|
||||
// A hidden (closed) panel counts for nothing even when stacked.
|
||||
app.show_block_palette = false;
|
||||
assert_eq!(
|
||||
app.dock_visible_len(crate::app::config::DockSide::Left),
|
||||
1
|
||||
);
|
||||
assert_eq!(app.dock_visible_groups(DockSide::Left), vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_drag_target_ignores_hidden_panels_on_the_side() {
|
||||
// Reproduce the persisted layout that exposed a half-height ghost: two
|
||||
// panels live in the left stack but the block palette is hidden
|
||||
// (show_block_palette=false), so only Properties renders. Drag geometry
|
||||
// must count only panels that are actually visible.
|
||||
let mut app = fresh();
|
||||
app.dock = Default::default();
|
||||
app.dock.left = vec![
|
||||
crate::ui::dock::PanelId::BlockPalette,
|
||||
crate::ui::dock::PanelId::Properties,
|
||||
];
|
||||
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);
|
||||
assert_eq!(app.dock_group_shown(DockSide::Left, 0), Some(PanelId::BlockPalette));
|
||||
app.show_block_palette = false;
|
||||
app.show_properties = true;
|
||||
let i = app.active_tab;
|
||||
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0);
|
||||
app.win_size = (1600.0, 900.0).into();
|
||||
let id = crate::ui::dock::PanelId::Properties;
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
|
||||
// Pointer near the bottom of the left edge: one visible panel means a
|
||||
// single slot, so every y maps to index 0 (no top/bottom split).
|
||||
let _ =
|
||||
app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0)));
|
||||
assert_eq!(
|
||||
app.dock_drag_target,
|
||||
Some((crate::app::config::DockSide::Left, 0))
|
||||
);
|
||||
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_drag_target_counts_all_visible_panels() {
|
||||
let mut app = fresh();
|
||||
app.dock = Default::default();
|
||||
fn dock_splitter_drag_changes_slot_heights() {
|
||||
use crate::app::config::DockSide;
|
||||
use crate::ui::dock::{DockGroup, DockMsg, PanelId};
|
||||
let mut app = dock_app();
|
||||
app.dock.left = vec![
|
||||
crate::ui::dock::PanelId::BlockPalette,
|
||||
crate::ui::dock::PanelId::Properties,
|
||||
DockGroup::single(PanelId::BlockPalette, 1.0),
|
||||
DockGroup::single(PanelId::Properties, 1.0),
|
||||
];
|
||||
// Both panels shown: two real slots on the left edge. A pointer near
|
||||
// the bottom maps to the append slot (index == 2).
|
||||
app.show_block_palette = true;
|
||||
app.show_properties = true;
|
||||
let i = app.active_tab;
|
||||
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0);
|
||||
app.win_size = (1600.0, 900.0).into();
|
||||
let id = crate::ui::dock::PanelId::Properties;
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
|
||||
let _ =
|
||||
app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0)));
|
||||
assert_eq!(
|
||||
app.dock_drag_target,
|
||||
Some((crate::app::config::DockSide::Left, 2))
|
||||
);
|
||||
app.dock.right.clear();
|
||||
let _ = app.on_dock(DockMsg::SplitGrab(DockSide::Left, 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_float_resize_grows_the_panel() {
|
||||
use crate::ui::dock::{DockMsg, FloatPanel, PanelId};
|
||||
let mut app = dock_app();
|
||||
app.dock.float(FloatPanel {
|
||||
id: PanelId::BlockPalette,
|
||||
x: 100.0,
|
||||
y: 100.0,
|
||||
w: 260.0,
|
||||
h: 300.0,
|
||||
});
|
||||
let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette));
|
||||
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(360.0, 400.0)));
|
||||
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(400.0, 450.0)));
|
||||
let _ = app.on_dock(DockMsg::DragRelease);
|
||||
let f = app.dock.float_rect(PanelId::BlockPalette).unwrap();
|
||||
assert_eq!((f.w, f.h), (300.0, 350.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2568,7 +2568,7 @@ impl OpenCADStudio {
|
||||
if self.show_external_references {
|
||||
use crate::app::config::DockSide;
|
||||
use crate::ui::dock::PanelId;
|
||||
if self.dock.location(PanelId::ExternalReferences).is_none() {
|
||||
if !self.dock.is_placed(PanelId::ExternalReferences) {
|
||||
self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX);
|
||||
}
|
||||
self.dock_expanded = Some(PanelId::ExternalReferences);
|
||||
|
||||
+464
-225
@@ -1877,40 +1877,12 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
}
|
||||
|
||||
// Docked side panels (Properties, block palette, future palettes) live
|
||||
// in an ordered vertical stack on the left/right edge of the drawing
|
||||
// view. Auto-collapsing (pinned) panels that aren't being hovered
|
||||
// reduce to a rail sharing 1/N of the edge column's height; the
|
||||
// hovered or unpinned panel expands to the full column height on top.
|
||||
let visible_panel = |id: crate::ui::dock::PanelId| -> bool {
|
||||
if tab.is_start || self.clean_screen {
|
||||
return false;
|
||||
}
|
||||
match id {
|
||||
crate::ui::dock::PanelId::Properties => self.show_properties,
|
||||
crate::ui::dock::PanelId::BlockPalette => self.show_block_palette,
|
||||
crate::ui::dock::PanelId::ExternalReferences => self.show_external_references,
|
||||
crate::ui::dock::PanelId::Browser => self.show_browser,
|
||||
crate::ui::dock::PanelId::NodeGraph => self.show_node_graph,
|
||||
crate::ui::dock::PanelId::PointCloudManager => self.pc_manager.show,
|
||||
crate::ui::dock::PanelId::Count => self.count_palette.show,
|
||||
crate::ui::dock::PanelId::SheetSetManager => self.sheet_set.show,
|
||||
}
|
||||
};
|
||||
let edge_stack = |side: crate::app::config::DockSide| -> Option<Element<'_, Message>> {
|
||||
let ids: Vec<crate::ui::dock::PanelId> = match side {
|
||||
crate::app::config::DockSide::Left => self.dock.left.clone(),
|
||||
crate::app::config::DockSide::Right => self.dock.right.clone(),
|
||||
}
|
||||
.into_iter()
|
||||
.filter(|id| visible_panel(*id))
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(self.build_edge_stack(side, &ids, tab))
|
||||
};
|
||||
let left_edge = edge_stack(crate::app::config::DockSide::Left);
|
||||
let right_edge = edge_stack(crate::app::config::DockSide::Right);
|
||||
// in an ordered vertical stack of slots on the left/right edge of the
|
||||
// drawing view; a slot may hold several panels as tabs. Auto-collapsing
|
||||
// (pinned) slots that aren't being hovered reduce to a rail; the rest
|
||||
// share the column height by their adjustable weights.
|
||||
let left_edge = self.build_edge_stack(crate::app::config::DockSide::Left, tab);
|
||||
let right_edge = self.build_edge_stack(crate::app::config::DockSide::Right, tab);
|
||||
|
||||
// Command-line sits as a bottom-centre overlay on top of the
|
||||
// viewport stack rather than as a separate row in the main
|
||||
@@ -1955,151 +1927,41 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
}
|
||||
let workspace: Element<'_, Message> = row(parts).width(Fill).height(Fill).into();
|
||||
|
||||
let any_dragging = self.dock_dragging.is_some();
|
||||
let any_resizing =
|
||||
self.dock_resizing.is_some() || self.xref_col_drag.is_some() || self.xref_split_drag;
|
||||
let workspace = if any_dragging {
|
||||
let id = self.dock_dragging.expect("guarded by any_dragging");
|
||||
let side = self.dock_drag_target.map(|(s, _)| s).unwrap_or(
|
||||
self.dock
|
||||
.location(id)
|
||||
.map(|(s, _)| s)
|
||||
.unwrap_or(crate::app::config::DockSide::Right),
|
||||
);
|
||||
// Dropping onto an edge joins that edge's stack, whose column is as
|
||||
// wide as its widest currently-shown panel (the dragged panel
|
||||
// included). Hidden (closed) panels don't render, so they can't
|
||||
// widen the column being previewed.
|
||||
let preview_width = {
|
||||
let mut widths: Vec<f32> = match side {
|
||||
crate::app::config::DockSide::Left => &self.dock.left,
|
||||
crate::app::config::DockSide::Right => &self.dock.right,
|
||||
}
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&pid| self.dock_panel_visible(pid))
|
||||
.map(|pid| self.dock.width(pid, self.win_size.0))
|
||||
.collect();
|
||||
widths.push(self.dock.width(id, self.win_size.0));
|
||||
widths.into_iter().fold(0.0, f32::max)
|
||||
};
|
||||
// Faint tint over the whole edge column so the target edge reads
|
||||
// even before the panel-sized ghost snaps to a slot.
|
||||
let edge_tint = container(Space::new())
|
||||
.width(Length::Fixed(preview_width))
|
||||
.height(Fill)
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(
|
||||
palette.primary.weak.color.scale_alpha(0.35),
|
||||
)),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
// The ghost is the dragged panel at its real size: its own saved
|
||||
// width and its 1/N share of the edge (N = number of panels shown
|
||||
// on the edge after the drop), with a header naming which panel is
|
||||
// being moved. Only visibly-docked panels share the column height:
|
||||
// a hidden (closed) panel keeps its stack slot but no screen space,
|
||||
// so it must not split the preview.
|
||||
let was_here = self.dock.location(id).map(|(s, _)| s) == Some(side);
|
||||
let visible = self.dock_visible_len(side);
|
||||
let final_count = if was_here {
|
||||
std::cmp::max(visible, 1)
|
||||
} else {
|
||||
visible + 1
|
||||
};
|
||||
let edge_h = tab.scene.selection.borrow().view.vp_size.1;
|
||||
let slot_h = edge_h / final_count as f32;
|
||||
let index = self.dock_drag_target.map(|(_, i)| i).unwrap_or(0);
|
||||
let slot = index.min(final_count.saturating_sub(1));
|
||||
let ghost_top = slot as f32 * slot_h;
|
||||
let ghost_header = container(text(id.title()).size(12))
|
||||
.width(Fill)
|
||||
.padding(iced::Padding {
|
||||
top: 5.0,
|
||||
right: 8.0,
|
||||
bottom: 5.0,
|
||||
left: 8.0,
|
||||
})
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(palette.primary.base.color)),
|
||||
text_color: Some(palette.primary.base.text),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
let ghost_panel = container(column![ghost_header, Space::new()])
|
||||
.width(Length::Fixed(self.dock.width(id, self.win_size.0)))
|
||||
.height(Length::Fixed(slot_h))
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(palette.background.base.color)),
|
||||
border: Border {
|
||||
color: palette.primary.base.color,
|
||||
width: 2.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
// Position the ghost at its drop slot, flush against the edge.
|
||||
let ghost = container(ghost_panel)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.align_x(match side {
|
||||
crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left,
|
||||
crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right,
|
||||
})
|
||||
.align_y(iced::alignment::Vertical::Top)
|
||||
.padding(iced::Padding {
|
||||
top: ghost_top,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0.0,
|
||||
});
|
||||
// Thin line across the column at the drop slot's top boundary.
|
||||
let drop_line = container(
|
||||
container(Space::new())
|
||||
.width(Fill)
|
||||
.height(Length::Fixed(3.0))
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().primary.base.color)),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.align_y(iced::alignment::Vertical::Top)
|
||||
.padding(iced::Padding {
|
||||
top: ghost_top,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0.0,
|
||||
});
|
||||
let preview = iced::widget::stack![edge_tint, ghost, drop_line]
|
||||
.width(Length::Fixed(preview_width))
|
||||
.height(Fill);
|
||||
let preview = container(preview)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.align_x(match side {
|
||||
crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left,
|
||||
crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right,
|
||||
});
|
||||
stack![workspace, preview].width(Fill).height(Fill).into()
|
||||
// Floating panels sit over the workspace, back to front.
|
||||
let mut layers: Vec<Element<'_, Message>> = vec![workspace];
|
||||
for f in &self.dock.floating {
|
||||
if self.dock_panel_visible(f.id) {
|
||||
layers.push(self.floating_panel(*f, tab));
|
||||
}
|
||||
}
|
||||
// While a panel is dragged, preview where it lands.
|
||||
if let (Some(id), Some(target)) = (self.dock_dragging, self.dock_drag_target) {
|
||||
layers.push(self.dock_drop_preview(id, target));
|
||||
}
|
||||
let workspace: Element<'_, Message> = if layers.len() == 1 {
|
||||
layers.pop().expect("workspace layer")
|
||||
} else {
|
||||
workspace
|
||||
iced::widget::Stack::with_children(layers)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
};
|
||||
let workspace: Element<'_, Message> = if any_dragging || any_resizing {
|
||||
let any_dragging = self.dock_dragging.is_some();
|
||||
let any_splitting = self.dock_split_drag.is_some();
|
||||
let any_resizing = self.dock_resizing.is_some()
|
||||
|| self.dock_float_resizing.is_some()
|
||||
|| self.xref_col_drag.is_some()
|
||||
|| self.xref_split_drag;
|
||||
let workspace: Element<'_, Message> = if any_dragging || any_resizing || any_splitting {
|
||||
mouse_area(workspace)
|
||||
.on_move(move |p| Message::Dock(crate::ui::dock::DockMsg::DragMove(p)))
|
||||
.on_release(Message::Dock(crate::ui::dock::DockMsg::DragRelease))
|
||||
.interaction(if any_resizing {
|
||||
.interaction(if self.dock_float_resizing.is_some() {
|
||||
iced::mouse::Interaction::ResizingDiagonallyDown
|
||||
} else if any_resizing {
|
||||
iced::mouse::Interaction::ResizingHorizontally
|
||||
} else if any_splitting {
|
||||
iced::mouse::Interaction::ResizingVertically
|
||||
} else {
|
||||
iced::mouse::Interaction::Grabbing
|
||||
})
|
||||
@@ -2876,65 +2738,69 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
/// Build one edge: a narrow tab strip at the very edge plus the expanded
|
||||
/// panels sliding out beside it. Auto-collapsing (pinned) panels are always
|
||||
/// visible as equal-height 1/N tabs in the strip (via `Fill` distribution);
|
||||
/// hovering a tab raises its panel to full column height beside the strip,
|
||||
/// so switching between stacked panels is a direct tab-to-tab hover with no
|
||||
/// width-jumping collapse dance. Unpinned panels are always expanded. The
|
||||
/// whole edge is one hover region: leaving it collapses any auto-collapsing
|
||||
/// panel, but moving between the tabs and the expanded panel does not.
|
||||
/// slots sliding out beside it. Auto-collapsing (pinned) slots are always
|
||||
/// visible as equal-height tabs in the strip (via `Fill` distribution);
|
||||
/// hovering a tab raises its slot beside the strip, so switching between
|
||||
/// stacked panels is a direct tab-to-tab hover with no width-jumping
|
||||
/// collapse dance. Unpinned slots are always expanded and split the column
|
||||
/// height by their weights, with a draggable splitter between neighbours.
|
||||
/// The whole edge is one hover region: leaving it collapses any
|
||||
/// auto-collapsing slot, but moving between the tabs and the expanded slot
|
||||
/// does not. `None` when nothing on the edge is shown.
|
||||
fn build_edge_stack<'a>(
|
||||
&'a self,
|
||||
side: crate::app::config::DockSide,
|
||||
ids: &[crate::ui::dock::PanelId],
|
||||
tab: &'a DocumentTab,
|
||||
) -> Element<'a, Message> {
|
||||
let pinned: Vec<crate::ui::dock::PanelId> = ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| self.dock.auto_collapse(*id))
|
||||
.collect();
|
||||
// Expanded = unpinned panels (always open) in stack order, then the
|
||||
// hovered auto-collapsing one last so it floats on top.
|
||||
let mut expanded: Vec<crate::ui::dock::PanelId> = ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !self.dock.auto_collapse(*id))
|
||||
.collect();
|
||||
if let Some(id) = self.dock_expanded {
|
||||
if ids.contains(&id) && self.dock.auto_collapse(id) {
|
||||
expanded.push(id);
|
||||
}
|
||||
) -> Option<Element<'a, Message>> {
|
||||
let visible = self.dock_visible_groups(side);
|
||||
if visible.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Each expanded panel renders at its own saved width; the column is as
|
||||
// wide as the widest one currently showing.
|
||||
let shown = |gi: usize| self.dock_group_shown(side, gi).expect("visible group");
|
||||
let pinned: Vec<usize> = visible
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|gi| self.dock.auto_collapse(shown(*gi)))
|
||||
.collect();
|
||||
let expanded = self.dock_expanded_groups(side);
|
||||
// Each expanded slot renders at its shown panel's saved width; the
|
||||
// column is as wide as the widest one currently showing.
|
||||
let col_w = expanded
|
||||
.iter()
|
||||
.map(|id| self.dock.width(*id, self.win_size.0))
|
||||
.map(|gi| self.dock.width(shown(*gi), self.win_size.0))
|
||||
.fold(0.0, f32::max);
|
||||
|
||||
let tab_strip = (!pinned.is_empty()).then(|| {
|
||||
let tabs: Vec<Element<'_, Message>> = pinned
|
||||
.iter()
|
||||
.map(|id| {
|
||||
.map(|gi| {
|
||||
let tabs = self.dock_group_visible_tabs(side, *gi);
|
||||
self.rail_slice(
|
||||
*id,
|
||||
shown(*gi),
|
||||
&tabs,
|
||||
side,
|
||||
self.dock_expanded == Some(*id),
|
||||
self.dock_dragging == Some(*id),
|
||||
self.dock_expanded.is_some_and(|e| tabs.contains(&e)),
|
||||
self.dock_dragging.is_some_and(|d| tabs.contains(&d)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
column(tabs).width(DOCK_RAIL_W).height(Fill).into()
|
||||
});
|
||||
let expanded_stack = (!expanded.is_empty()).then(|| {
|
||||
// Multiple simultaneously-expanded panels (e.g. two unpinned panels
|
||||
// on the same edge) stack one below the other, each taking 1/N of
|
||||
// the column height via equal `Fill` shares — never overlapping.
|
||||
let layers: Vec<Element<'_, Message>> = expanded
|
||||
.iter()
|
||||
.map(|id| self.expanded_panel(*id, side, col_w, tab))
|
||||
.collect();
|
||||
// Expanded slots stack one below the other, each taking its
|
||||
// weighted share of the column height — never overlapping.
|
||||
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));
|
||||
}
|
||||
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)))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
column(layers).height(Fill).into()
|
||||
});
|
||||
|
||||
@@ -2957,22 +2823,30 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
mouse_area(row(children).height(Fill))
|
||||
.on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit))
|
||||
.into()
|
||||
Some(
|
||||
mouse_area(row(children).height(Fill))
|
||||
.on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit))
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// One narrow tab in the edge strip. Equal `Fill` heights across the tabs
|
||||
/// give each 1/N of the strip; hovering or clicking it raises its panel.
|
||||
/// One narrow tab in the edge strip, naming every panel of its slot.
|
||||
/// Equal `Fill` heights across the tabs give each 1/N of the strip;
|
||||
/// hovering or clicking it raises its slot.
|
||||
fn rail_slice(
|
||||
&self,
|
||||
id: crate::ui::dock::PanelId,
|
||||
tabs: &[crate::ui::dock::PanelId],
|
||||
side: crate::app::config::DockSide,
|
||||
is_active: bool,
|
||||
is_dragging: bool,
|
||||
) -> Element<'_, Message> {
|
||||
let label = canvas(VBarLabel {
|
||||
text: id.title().to_string(),
|
||||
text: tabs
|
||||
.iter()
|
||||
.map(|t| t.title())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · "),
|
||||
clockwise: side == crate::app::config::DockSide::Left,
|
||||
})
|
||||
.width(Fill)
|
||||
@@ -3010,19 +2884,15 @@ impl OpenCADStudio {
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A panel expanded to the full edge column: the panel body plus 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_panel<'a>(
|
||||
/// The content of panel `id` at `width`.
|
||||
fn panel_body<'a>(
|
||||
&'a self,
|
||||
id: crate::ui::dock::PanelId,
|
||||
side: crate::app::config::DockSide,
|
||||
width: f32,
|
||||
tab: &'a DocumentTab,
|
||||
) -> Element<'a, Message> {
|
||||
let auto_collapse = self.dock.auto_collapse(id);
|
||||
let panel: Element<'_, Message> = match id {
|
||||
match id {
|
||||
crate::ui::dock::PanelId::Properties => tab.properties.view(width, auto_collapse),
|
||||
crate::ui::dock::PanelId::BlockPalette => {
|
||||
crate::ui::window::block_palette::view(&self.block_palette, width, auto_collapse)
|
||||
@@ -3059,6 +2929,30 @@ impl OpenCADStudio {
|
||||
crate::ui::dock::PanelId::SheetSetManager => {
|
||||
crate::ui::window::sheet_set::view(&self.sheet_set, width, auto_collapse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An expanded slot: its tab strip (when it holds more than one shown
|
||||
/// panel), the shown panel, 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 self,
|
||||
side: crate::app::config::DockSide,
|
||||
gi: usize,
|
||||
width: f32,
|
||||
tab: &'a DocumentTab,
|
||||
) -> Element<'a, Message> {
|
||||
let tabs = self.dock_group_visible_tabs(side, gi);
|
||||
let id = self.dock_group_shown(side, gi).expect("visible group");
|
||||
let body = self.panel_body(id, width, tab);
|
||||
let panel: Element<'_, Message> = if tabs.len() > 1 {
|
||||
column![dock_tab_strip(&tabs, id, width), body]
|
||||
.height(Fill)
|
||||
.into()
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let divider = dock_divider(id);
|
||||
match side {
|
||||
@@ -3066,6 +2960,181 @@ impl OpenCADStudio {
|
||||
crate::app::config::DockSide::Right => row![divider, panel].height(Fill).into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A floating panel at its saved place, with a corner grip to resize it.
|
||||
fn floating_panel<'a>(
|
||||
&'a self,
|
||||
f: crate::ui::dock::FloatPanel,
|
||||
tab: &'a DocumentTab,
|
||||
) -> Element<'a, Message> {
|
||||
use crate::ui::dock::DockMsg;
|
||||
let (ww, wh) = self.dock_workspace_size();
|
||||
let grip = mouse_area(
|
||||
container(crate::ui::icons::themed_secondary(crate::ui::icons::RESIZE, 12.0))
|
||||
.width(Length::Fixed(14.0))
|
||||
.height(Length::Fixed(14.0))
|
||||
.center_x(Length::Fixed(14.0))
|
||||
.center_y(Length::Fixed(14.0)),
|
||||
)
|
||||
.on_press(Message::Dock(DockMsg::FloatResizeGrab(f.id)))
|
||||
.interaction(iced::mouse::Interaction::ResizingDiagonallyDown);
|
||||
let body = stack![
|
||||
self.panel_body(f.id, f.w, tab),
|
||||
container(grip)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.align_x(iced::alignment::Horizontal::Right)
|
||||
.align_y(iced::alignment::Vertical::Bottom),
|
||||
]
|
||||
.width(Length::Fixed(f.w))
|
||||
.height(Length::Fixed(f.h));
|
||||
let framed = container(body).style(|theme: &Theme| container::Style {
|
||||
border: Border {
|
||||
color: theme.palette().background.strong.color,
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
shadow: iced::Shadow {
|
||||
color: Color::from_rgba(0.0, 0.0, 0.0, 0.35),
|
||||
offset: iced::Vector::new(0.0, 4.0),
|
||||
blur_radius: 14.0,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
// The idle cursor marks the panel as opaque to the pointer, so clicks
|
||||
// on its empty areas don't fall through to the drawing below.
|
||||
let panel = mouse_area(framed)
|
||||
.on_press(Message::Dock(DockMsg::FloatRaise(f.id)))
|
||||
.interaction(iced::mouse::Interaction::Idle);
|
||||
// Keep the panel on screen when the window shrank since it was placed.
|
||||
let x = f.x.min(ww - f.w).max(0.0);
|
||||
let y = f.y.min(wh - f.h).max(0.0);
|
||||
place_at(panel.into(), x, y)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn dock_drop_preview(
|
||||
&self,
|
||||
id: crate::ui::dock::PanelId,
|
||||
target: crate::ui::dock::DropTarget,
|
||||
) -> 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 tint = |side: DockSide| -> Element<'_, Message> {
|
||||
let w = edge_w(side);
|
||||
let band = container(Space::new())
|
||||
.width(Length::Fixed(w))
|
||||
.height(Fill)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(
|
||||
theme.palette().primary.weak.color.scale_alpha(0.35),
|
||||
)),
|
||||
..Default::default()
|
||||
});
|
||||
container(band)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.align_x(match side {
|
||||
DockSide::Left => iced::alignment::Horizontal::Left,
|
||||
DockSide::Right => iced::alignment::Horizontal::Right,
|
||||
})
|
||||
.into()
|
||||
};
|
||||
let edge_x = |side: DockSide, w: f32| match side {
|
||||
DockSide::Left => 0.0,
|
||||
DockSide::Right => (ww - w).max(0.0),
|
||||
};
|
||||
let mut layers: Vec<Element<'_, Message>> = Vec::new();
|
||||
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,
|
||||
));
|
||||
}
|
||||
DropTarget::Tab { side, group } => {
|
||||
let (top, bottom) = self
|
||||
.dock_slot_spans(side)
|
||||
.into_iter()
|
||||
.find(|(gi, _, _)| *gi == group)
|
||||
.map(|(_, t, b)| (t, b))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let mut tabs = self.dock_group_visible_tabs(side, group);
|
||||
tabs.retain(|t| *t != id);
|
||||
tabs.push(id);
|
||||
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::Float { x, y } => {
|
||||
let (w, h) = self.dock_float_size(id);
|
||||
layers.push(place_at(dock_ghost(&[id], id, w, h), x, y));
|
||||
}
|
||||
}
|
||||
iced::widget::Stack::with_children(layers)
|
||||
.width(Fill)
|
||||
.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 ───────────────────────────────────────────────────────
|
||||
@@ -3456,6 +3525,176 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Height of a docked slot's tab strip.
|
||||
const DOCK_TAB_H: f32 = 24.0;
|
||||
|
||||
/// The tab row above a slot holding several panels. Pressing a tab shows its
|
||||
/// panel; dragging it pulls the panel out of the slot.
|
||||
fn dock_tab_strip(
|
||||
tabs: &[crate::ui::dock::PanelId],
|
||||
active: crate::ui::dock::PanelId,
|
||||
width: f32,
|
||||
) -> Element<'static, Message> {
|
||||
let cells: Vec<Element<'static, Message>> = tabs
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let id = *id;
|
||||
let is_active = id == active;
|
||||
let label = text(id.title())
|
||||
.size(11)
|
||||
.wrapping(iced::widget::text::Wrapping::None)
|
||||
.ellipsis(iced::advanced::text::Ellipsis::End);
|
||||
let cell = container(label)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding([0, 8])
|
||||
.center_y(Fill)
|
||||
.clip(true)
|
||||
.style(move |theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(if is_active {
|
||||
palette.background.base.color
|
||||
} else {
|
||||
palette.background.weak.color
|
||||
})),
|
||||
text_color: Some(if is_active {
|
||||
palette.background.base.text
|
||||
} else {
|
||||
palette.background.weak.text.scale_alpha(0.75)
|
||||
}),
|
||||
border: Border {
|
||||
color: if is_active {
|
||||
palette.primary.base.color
|
||||
} else {
|
||||
palette.background.neutral.color
|
||||
},
|
||||
width: if is_active { 1.0 } else { 0.5 },
|
||||
radius: iced::border::Radius::default().top(4.0),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
mouse_area(cell)
|
||||
.on_press(Message::Dock(crate::ui::dock::DockMsg::SelectTab(id)))
|
||||
.interaction(iced::mouse::Interaction::Pointer)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
container(row(cells).spacing(2).height(Fill))
|
||||
.width(Length::Fixed(width))
|
||||
.height(Length::Fixed(DOCK_TAB_H))
|
||||
.padding(iced::Padding {
|
||||
top: 3.0,
|
||||
right: 4.0,
|
||||
bottom: 0.0,
|
||||
left: 4.0,
|
||||
})
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().background.strong.color)),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Draggable bar between two expanded slots on an edge; double-click gives
|
||||
/// every slot on the edge the same height again.
|
||||
fn dock_splitter(
|
||||
side: crate::app::config::DockSide,
|
||||
upper: usize,
|
||||
lower: usize,
|
||||
) -> Element<'static, Message> {
|
||||
let line = container(Space::new())
|
||||
.width(Fill)
|
||||
.height(Length::Fixed(5.0))
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().background.neutral.color)),
|
||||
..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)))
|
||||
.interaction(iced::mouse::Interaction::ResizingVertically)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The blue drop ghost: a `w`×`h` panel outline whose header shows the tabs
|
||||
/// the slot will hold, with the dragged panel's tab highlighted.
|
||||
fn dock_ghost(
|
||||
tabs: &[crate::ui::dock::PanelId],
|
||||
dragged: crate::ui::dock::PanelId,
|
||||
w: f32,
|
||||
h: f32,
|
||||
) -> Element<'static, Message> {
|
||||
let cells: Vec<Element<'static, Message>> = tabs
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let is_dragged = *id == dragged;
|
||||
container(
|
||||
text(id.title())
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::None)
|
||||
.ellipsis(iced::advanced::text::Ellipsis::End),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding([5, 8])
|
||||
.clip(true)
|
||||
.style(move |theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(if is_dragged {
|
||||
palette.primary.base.color
|
||||
} else {
|
||||
palette.primary.weak.color
|
||||
})),
|
||||
text_color: Some(if is_dragged {
|
||||
palette.primary.base.text
|
||||
} else {
|
||||
palette.primary.weak.text
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
container(column![row(cells).spacing(2), Space::new()])
|
||||
.width(Length::Fixed(w.max(1.0)))
|
||||
.height(Length::Fixed(h.max(1.0)))
|
||||
.clip(true)
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(
|
||||
palette.background.base.color.scale_alpha(0.85),
|
||||
)),
|
||||
border: Border {
|
||||
color: palette.primary.base.color,
|
||||
width: 2.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Position `content` with its top-left corner at workspace point (`x`, `y`).
|
||||
fn place_at(content: Element<'_, Message>, x: f32, y: f32) -> Element<'_, Message> {
|
||||
container(content)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.align_x(iced::alignment::Horizontal::Left)
|
||||
.align_y(iced::alignment::Vertical::Top)
|
||||
.padding(iced::Padding {
|
||||
top: y.max(0.0),
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
left: x.max(0.0),
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
const START_ACTION_RADIUS: f32 = 6.0;
|
||||
|
||||
fn start_action_shape(mut style: button::Style) -> button::Style {
|
||||
|
||||
+505
-63
@@ -1,11 +1,12 @@
|
||||
//! General edge-stack docking for side panels.
|
||||
//!
|
||||
//! Any number of dockable panels (Properties, the block palette, future
|
||||
//! palettes) live in an ordered vertical stack on the left or right edge of
|
||||
//! the drawing view. This module owns the persisted layout (which panels are
|
||||
//! docked, on which side, in what vertical order, at what width, and whether
|
||||
//! each auto-collapses) plus the pure geometry used to render and hover an
|
||||
//! edge's stacked panels.
|
||||
//! palettes) live in an ordered vertical stack of slots on the left or right
|
||||
//! edge of the drawing view, or float over it. A slot holds one panel or
|
||||
//! several sharing it as tabs, and owns an adjustable share of the edge
|
||||
//! height. This module owns the persisted layout (which panels are docked or
|
||||
//! floating, where, at what size, and whether each auto-collapses) plus the
|
||||
//! pure geometry used to render and hover an edge's stacked slots.
|
||||
|
||||
use crate::app::config::DockSide;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -31,6 +32,16 @@ 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),
|
||||
/// Begin resizing floating `panel` from its corner grip.
|
||||
FloatResizeGrab(PanelId),
|
||||
/// Bring floating `panel` to the front.
|
||||
FloatRaise(PanelId),
|
||||
/// The pointer left the edge column; collapse any auto-collapsing panel.
|
||||
HoverExit,
|
||||
}
|
||||
@@ -119,16 +130,111 @@ impl DockPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole dock layout: two ordered per-side stacks 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.
|
||||
/// 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")]
|
||||
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,
|
||||
}
|
||||
|
||||
/// On-disk forms of a [`DockGroup`]: configs written before tab groups stored
|
||||
/// each slot as a bare panel id.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum GroupRepr {
|
||||
Single(PanelId),
|
||||
Group {
|
||||
tabs: Vec<PanelId>,
|
||||
#[serde(default)]
|
||||
active: Option<PanelId>,
|
||||
#[serde(default = "default_weight")]
|
||||
weight: f32,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_weight() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl From<GroupRepr> for DockGroup {
|
||||
fn from(repr: GroupRepr) -> Self {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DockGroup {
|
||||
pub fn single(id: PanelId, weight: f32) -> Self {
|
||||
Self {
|
||||
tabs: vec![id],
|
||||
active: id,
|
||||
weight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel floating over the workspace, in workspace-local pixels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FloatPanel {
|
||||
pub id: PanelId,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
/// Smallest floating panel height.
|
||||
pub const FLOAT_MIN_H: f32 = 160.0;
|
||||
/// Smallest on-screen height of a docked slot 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
|
||||
/// 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 {
|
||||
/// Panels anchored to the left edge, top → bottom.
|
||||
pub left: Vec<PanelId>,
|
||||
/// Panels anchored to the right edge, top → bottom.
|
||||
pub right: Vec<PanelId>,
|
||||
/// Slots anchored to the left edge, top → bottom.
|
||||
pub left: Vec<DockGroup>,
|
||||
/// Slots anchored to the right edge, top → bottom.
|
||||
pub right: Vec<DockGroup>,
|
||||
/// Floating panels, back → front.
|
||||
pub floating: Vec<FloatPanel>,
|
||||
/// Per-panel width / auto-collapse settings, keyed by `PanelId`.
|
||||
pub panels: BTreeMap<PanelId, DockPanel>,
|
||||
}
|
||||
@@ -136,23 +242,24 @@ pub struct DockState {
|
||||
impl Default for DockState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
left: vec![PanelId::Properties],
|
||||
right: vec![PanelId::BlockPalette],
|
||||
left: vec![DockGroup::single(PanelId::Properties, 1.0)],
|
||||
right: vec![DockGroup::single(PanelId::BlockPalette, 1.0)],
|
||||
floating: Vec::new(),
|
||||
panels: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DockState {
|
||||
/// Edge-collection of the panels ordered for rendering on `side`.
|
||||
fn stack(&self, side: DockSide) -> &[PanelId] {
|
||||
/// The slots stacked on `side`, top → bottom.
|
||||
pub fn groups(&self, side: DockSide) -> &[DockGroup] {
|
||||
match side {
|
||||
DockSide::Left => &self.left,
|
||||
DockSide::Right => &self.right,
|
||||
}
|
||||
}
|
||||
|
||||
fn stack_mut(&mut self, side: DockSide) -> &mut Vec<PanelId> {
|
||||
fn groups_mut(&mut self, side: DockSide) -> &mut Vec<DockGroup> {
|
||||
match side {
|
||||
DockSide::Left => &mut self.left,
|
||||
DockSide::Right => &mut self.right,
|
||||
@@ -161,7 +268,8 @@ impl DockState {
|
||||
|
||||
/// 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.
|
||||
/// written by an older version (or edited by hand): a panel placed twice
|
||||
/// keeps only its first placement.
|
||||
pub fn ensure_settings(&mut self) {
|
||||
for id in [
|
||||
PanelId::Properties,
|
||||
@@ -175,18 +283,43 @@ impl DockState {
|
||||
] {
|
||||
self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
groups.retain(|g| !g.tabs.is_empty());
|
||||
}
|
||||
self.floating.retain(|f| seen.insert(f.id));
|
||||
}
|
||||
|
||||
/// Where (if anywhere) a panel is currently docked.
|
||||
/// Where (if anywhere) a panel is docked: its side and slot index.
|
||||
pub fn location(&self, id: PanelId) -> Option<(DockSide, usize)> {
|
||||
for side in [DockSide::Left, DockSide::Right] {
|
||||
if let Some(i) = self.stack(side).iter().position(|p| *p == id) {
|
||||
if let Some(i) = self.groups(side).iter().position(|g| g.tabs.contains(&id)) {
|
||||
return Some((side, i));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The floating placement of `id`, if it floats.
|
||||
pub fn float_rect(&self, id: PanelId) -> Option<FloatPanel> {
|
||||
self.floating.iter().find(|f| f.id == id).copied()
|
||||
}
|
||||
|
||||
/// Whether `id` has a place in the layout (docked or floating). A panel
|
||||
/// without one is docked on the right when it is opened.
|
||||
pub fn is_placed(&self, id: PanelId) -> bool {
|
||||
self.location(id).is_some() || self.float_rect(id).is_some()
|
||||
}
|
||||
|
||||
pub fn settings(&self, id: PanelId) -> DockPanel {
|
||||
self.panels
|
||||
.get(&id)
|
||||
@@ -222,43 +355,203 @@ impl DockState {
|
||||
entry.auto_collapse = on;
|
||||
}
|
||||
|
||||
/// Dock `id` to `side` at `index` (clamped), removing it from any other
|
||||
/// stack first. Returns whether the layout actually changed.
|
||||
pub fn dock(&mut self, id: PanelId, side: DockSide, index: usize) -> bool {
|
||||
if let Some((old_side, old_i)) = self.location(id) {
|
||||
if old_side == side && old_i == index {
|
||||
return false;
|
||||
}
|
||||
self.stack_mut(old_side).remove(old_i);
|
||||
/// 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));
|
||||
}
|
||||
let stack = self.stack_mut(side);
|
||||
let index = index.min(stack.len());
|
||||
stack.insert(index, id);
|
||||
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` (index counted before the
|
||||
/// move) and show it. Returns whether the layout changed.
|
||||
pub fn add_tab(&mut self, id: PanelId, side: DockSide, group: usize) -> bool {
|
||||
if group >= self.groups(side).len() {
|
||||
return false;
|
||||
}
|
||||
if self.groups(side)[group].tabs.contains(&id) {
|
||||
return self.select_tab(id);
|
||||
}
|
||||
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];
|
||||
g.tabs.push(id);
|
||||
g.active = id;
|
||||
true
|
||||
}
|
||||
|
||||
/// Float `id` at `rect`, on top of the other floating panels.
|
||||
pub fn float(&mut self, rect: FloatPanel) -> bool {
|
||||
let before = self.clone();
|
||||
self.detach(rect.id);
|
||||
self.floating.push(rect);
|
||||
*self != before
|
||||
}
|
||||
|
||||
/// Bring a floating panel to the front.
|
||||
pub fn raise_float(&mut self, id: PanelId) {
|
||||
if let Some(i) = self.floating.iter().position(|f| f.id == id) {
|
||||
let f = self.floating.remove(i);
|
||||
self.floating.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize a floating panel, keeping it at least a usable size.
|
||||
pub fn resize_float(&mut self, id: PanelId, w: f32, h: f32) {
|
||||
if let Some(f) = self.floating.iter_mut().find(|f| f.id == id) {
|
||||
f.w = w.clamp(DOCK_MIN_W, id.max_width());
|
||||
f.h = h.max(FLOAT_MIN_H);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
pub fn shift_split(
|
||||
&mut self,
|
||||
side: DockSide,
|
||||
upper: usize,
|
||||
lower: usize,
|
||||
delta: f32,
|
||||
min_weight: f32,
|
||||
) {
|
||||
let groups = self.groups_mut(side);
|
||||
if upper >= groups.len() || lower >= groups.len() || upper == lower {
|
||||
return;
|
||||
}
|
||||
let total = groups[upper].weight + groups[lower].weight;
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insertion index (0..=total) for dropping a panel whose pointer is at
|
||||
/// screen-local `y` within an edge column of `avail` height holding `total`
|
||||
/// slots. Used to compute the live drag target index while reordering.
|
||||
pub fn drop_index(y: f32, total: usize, avail: f32) -> usize {
|
||||
if total == 0 || avail <= 0.0 {
|
||||
return 0;
|
||||
/// 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).
|
||||
Edge { side: DockSide, index: usize },
|
||||
/// A new tab in slot `group` on `side`.
|
||||
Tab { side: DockSide, group: 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 quarters split the slot (dock above / below), the middle 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.25).min(80.0);
|
||||
if y < top + band {
|
||||
SlotZone::Above
|
||||
} else if y > bottom - band {
|
||||
SlotZone::Below
|
||||
} else {
|
||||
SlotZone::Middle
|
||||
}
|
||||
let h = avail / total as f32;
|
||||
let idx = (y / h).round() as usize;
|
||||
// A single full-height panel must not offer a top/bottom split: any drop
|
||||
// lands in the one available slot.
|
||||
if total <= 1 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
let total: f32 = weights.iter().sum();
|
||||
if total <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
idx.min(total)
|
||||
let mut y = 0.0;
|
||||
weights
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let top = y;
|
||||
y += avail * w / total;
|
||||
(top, y)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Integer share for iced's `FillPortion` from a slot weight.
|
||||
pub fn portion(weight: f32) -> u16 {
|
||||
(weight * 1000.0).round().clamp(1.0, u16::MAX as f32) as u16
|
||||
}
|
||||
|
||||
/// Smallest docked width a panel may be dragged or sized to.
|
||||
pub const DOCK_MIN_W: f32 = 200.0;
|
||||
/// Largest docked width a panel may be dragged or sized to.
|
||||
pub const DOCK_MAX_W: f32 = 600.0;
|
||||
/// Width of the band along an empty edge that docks a dragged panel there.
|
||||
pub const DOCK_EDGE_ZONE: f32 = 48.0;
|
||||
|
||||
// ── Shared panel chrome ─────────────────────────────────────────────────
|
||||
|
||||
@@ -460,29 +753,178 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_index_maps_between_zero_and_total() {
|
||||
assert_eq!(drop_index(0.0, 3, 300.0), 0);
|
||||
assert_eq!(drop_index(100.0, 3, 300.0), 1);
|
||||
assert_eq!(drop_index(299.0, 3, 300.0), 3);
|
||||
assert_eq!(drop_index(300.0, 3, 300.0), 3);
|
||||
assert_eq!(drop_index(50.0, 0, 300.0), 0);
|
||||
fn legacy_config_with_bare_panel_ids_loads_as_single_slots() {
|
||||
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!(state.floating.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_index_single_slot_is_always_zero() {
|
||||
// One panel sharing the full edge height: any pointer y lands in the
|
||||
// single slot, so the insertion index must be 0 (no top/bottom split).
|
||||
assert_eq!(drop_index(5.0, 1, 900.0), 0);
|
||||
assert_eq!(drop_index(450.0, 1, 900.0), 0);
|
||||
assert_eq!(drop_index(895.0, 1, 900.0), 0);
|
||||
fn layout_round_trips_through_serde() {
|
||||
let mut state = DockState::default();
|
||||
state.add_tab(PanelId::Browser, DockSide::Left, 0);
|
||||
state.float(FloatPanel {
|
||||
id: PanelId::Count,
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
w: 300.0,
|
||||
h: 400.0,
|
||||
});
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let back: DockState = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_index_multi_slot_maps_position() {
|
||||
// Two panels on an edge produce insertion positions 0..=total: pointer
|
||||
// near the top lands before the first slot (0); pointer near the bottom
|
||||
// of the last slot lands after the last (== total, append).
|
||||
assert_eq!(drop_index(10.0, 2, 900.0), 0);
|
||||
assert_eq!(drop_index(890.0, 2, 900.0), 2);
|
||||
fn add_tab_joins_a_slot_and_shows_the_new_tab() {
|
||||
let mut state = DockState::default();
|
||||
assert!(state.add_tab(PanelId::BlockPalette, DockSide::Left, 0));
|
||||
assert!(state.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 docking_a_tab_out_keeps_the_rest_of_the_group() {
|
||||
let mut state = DockState::default();
|
||||
state.add_tab(PanelId::BlockPalette, DockSide::Left, 0);
|
||||
// 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));
|
||||
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();
|
||||
let rect = FloatPanel {
|
||||
id: PanelId::Properties,
|
||||
x: 100.0,
|
||||
y: 50.0,
|
||||
w: 250.0,
|
||||
h: 300.0,
|
||||
};
|
||||
assert!(state.float(rect));
|
||||
assert!(state.left.is_empty());
|
||||
assert_eq!(state.location(PanelId::Properties), None);
|
||||
assert_eq!(state.float_rect(PanelId::Properties), Some(rect));
|
||||
assert!(state.is_placed(PanelId::Properties));
|
||||
assert!(state.dock(PanelId::Properties, DockSide::Right, 0));
|
||||
assert!(state.floating.is_empty());
|
||||
assert_eq!(state.location(PanelId::Properties), Some((DockSide::Right, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_resize_respects_minimums() {
|
||||
let mut state = DockState::default();
|
||||
state.float(FloatPanel {
|
||||
id: PanelId::Count,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: 300.0,
|
||||
h: 300.0,
|
||||
});
|
||||
state.resize_float(PanelId::Count, 10.0, 10.0);
|
||||
let f = state.float_rect(PanelId::Count).unwrap();
|
||||
assert_eq!((f.w, f.h), (DOCK_MIN_W, FLOAT_MIN_H));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_float_moves_it_to_the_front() {
|
||||
let mut state = DockState::default();
|
||||
for id in [PanelId::Count, PanelId::Browser] {
|
||||
state.float(FloatPanel {
|
||||
id,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: 300.0,
|
||||
h: 300.0,
|
||||
});
|
||||
}
|
||||
state.raise_float(PanelId::Count);
|
||||
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 slot_zone_splits_quarters() {
|
||||
assert_eq!(slot_zone(10.0, 0.0, 400.0), SlotZone::Above);
|
||||
assert_eq!(slot_zone(200.0, 0.0, 400.0), SlotZone::Middle);
|
||||
assert_eq!(slot_zone(390.0, 0.0, 400.0), SlotZone::Below);
|
||||
// Tall slots cap the split bands so the tab zone stays generous.
|
||||
assert_eq!(slot_zone(100.0, 0.0, 1000.0), SlotZone::Middle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_spans_follow_weights() {
|
||||
assert_eq!(
|
||||
slot_spans(&[1.0, 3.0], 400.0),
|
||||
vec![(0.0, 100.0), (100.0, 400.0)]
|
||||
);
|
||||
assert!(slot_spans(&[], 400.0).is_empty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user