graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: dragging a plate's corner dot repositions it — dock swap
The dot drag now MOVES plates instead of resizing them (sizing stays on the
pane edge hotspots). The floating layout becomes a dock model: Left, Right
and Bottom slots own the geometry (column widths, strip height and tucks),
and panes are assigned to docks — so swapping panes preserves the layout's
shape. Dragging a dot highlights the dock region under the cursor (lower
band = Bottom, halves = Left/Right) and release snaps the plate there,
swapping occupants. A clean click still opens the menu; stubbed panes and
the playbar stay click-only.
The derivations that read pane identity (the bottom strip's flush edges and
tuck conditions read show_network/show_parameters) now read dock OCCUPANCY,
so they hold whichever pane sits in each dock. Full Width / Between Panes
keep working on the bottom strip through the same generalization.
CCE_DOCK_DEBUG traces arm/start/motion/drop. Live-verified with paced
pointer injection: drag start over Left, motion retargeting to Bottom with
the overlay following, drop swapping network into the strip and the
spreadsheet into the left column — screenshot shows the network's cell grid
living in the bottom strip. (Back-to-back injected motions can race the
release — human motion streams cannot; the earlier "failed" repros were
injection pacing, confirmed by the trace.)
src/app.rs | 212 ++++++++++++++++++++++++++++++++++++++++------------------
src/main.rs | 48 +++++++++++++
src/render.rs | 17 +++++
3 files changed, 213 insertions(+), 64 deletions(-)
diff --git a/src/app.rs b/src/app.rs
index 9b06980..eb08006 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -589,6 +589,16 @@ pub struct ResizeDirection {
/// now ONLY ever names a widget drag (a slot whose `Input` drag hooks are driving:
/// panel move, graph node drag, param slider, spreadsheet scroll). Exactly one of
/// `app_drag`/`drag_widget` is armed per press.
+/// The floating layout's three dock slots. Dimensions belong to the DOCK
+/// (left/right column widths, bottom strip height and tucks), panes are
+/// assigned to docks — so swapping panes preserves the geometry.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Dock {
+ Left,
+ Right,
+ Bottom,
+}
+
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum AppDrag {
NetworkResize { dir: ResizeDirection, start_rect: (f32, f32, f32, f32), start_mouse: (f32, f32) },
@@ -600,19 +610,12 @@ pub enum AppDrag {
SpreadsheetResizeLeft { start_inset: f32, start_mouse_x: f32 },
/// The spreadsheet's right edge, symmetrically, tucking under the parameter pane.
SpreadsheetResizeRight { start_inset: f32, start_mouse_x: f32 },
- /// Dragging a plate's corner dot: continuous adjustment of that pane's
- /// free layout parameters (the panes are edge-anchored, so "moving" one
- /// means driving what the layout lets vary — network width, params width,
- /// spreadsheet height + right tuck). Armed from a press on the dot once
- /// motion exceeds the click threshold; a clean click still opens the menu.
- CornerLayout {
- idx: usize,
- start_mouse: (f32, f32),
- start_fw: f32,
- start_param_w: f32,
- start_ss_h: f32,
- start_inset_r: f32,
- },
+ /// Dragging a plate's corner dot repositions the plate: the dock region
+ /// under the cursor highlights, and release snaps the plate there,
+ /// swapping with whatever pane held that dock. Armed from a press on the
+ /// dot once motion exceeds the click threshold; a clean click still opens
+ /// the menu. (Sizing stays on the pane edge hotspots.)
+ DockDrag { idx: usize },
}
#[repr(C)]
@@ -722,8 +725,12 @@ pub struct State {
/// `plate_corner::PLATE_SLOTS` entries are ever set.
pub collapsed_panes: [bool; WIDGET_COUNT],
/// A press on a plate corner dot, not yet resolved into click-opens-menu
- /// or drag-adjusts-layout: `(slot, press_x, press_y)`.
+ /// or drag-repositions-plate: `(slot, press_x, press_y)`.
pub corner_press: Option<(usize, f32, f32)>,
+ /// Dock occupancy, indexed Left/Right/Bottom. Swapped by dot drags.
+ pub dock_panes: [usize; 3],
+ /// The dock a live DockDrag would drop into — the render pass highlights it.
+ pub dock_drag_target: Option<Dock>,
pub drag_widget: Option<usize>,
/// Where the pointer pressed when `drag_widget` armed — the drag
@@ -1188,6 +1195,75 @@ impl State {
/// flush position into the neighbor's span — the pane tucks UNDER the neighbor,
/// whose bottom the layout raises to the spreadsheet's top — so the height clamp
/// keeps 100px of shortened neighbor above.
+ pub fn pane_in_dock(&self, dock: Dock) -> usize {
+ self.dock_panes[dock as usize]
+ }
+
+ pub fn dock_of_pane(&self, slot: usize) -> Option<Dock> {
+ [Dock::Left, Dock::Right, Dock::Bottom]
+ .into_iter()
+ .find(|&d| self.dock_panes[d as usize] == slot)
+ }
+
+ /// Is the pane occupying a slot currently shown (its View toggle)?
+ pub fn pane_shown(&self, slot: usize) -> bool {
+ match slot {
+ NETWORK_PANEL_IDX => self.show_network,
+ PARAM_IDX => self.show_parameters,
+ SPREADSHEET_IDX => self.show_spreadsheet,
+ PLAYBAR_IDX => self.show_playbar,
+ _ => false,
+ }
+ }
+
+ fn dock_shown(&self, dock: Dock) -> bool {
+ self.pane_shown(self.pane_in_dock(dock))
+ }
+
+ /// The dock a cursor position drops into: the lower band is the bottom
+ /// strip, the rest splits into left and right halves.
+ pub fn dock_region_at(&self, cx: f32, cy: f32) -> Dock {
+ if cy > self.height * 0.62 {
+ Dock::Bottom
+ } else if cx < self.width * 0.5 {
+ Dock::Left
+ } else {
+ Dock::Right
+ }
+ }
+
+ /// A dock's current rect from the DOCK-owned dimensions — independent of
+ /// whether its pane is shown, so the drag overlay can highlight it.
+ pub fn dock_rect(&self, dock: Dock) -> (f32, f32, f32, f32) {
+ let gap = 18.0_f32;
+ let pb_off = if self.show_playbar { PLAYBAR_H + gap } else { 0.0 };
+ match dock {
+ Dock::Left => {
+ let (fx, fy, fw, fh) = self.floating_network_layout;
+ (fx.max(gap), fy.max(gap), fw, fh)
+ }
+ Dock::Right => {
+ let param_w = self.floating_param_width.clamp(150.0, (self.width - 2.0 * gap).max(150.0));
+ let h = (self.height - STATUS_H - pb_off - 2.0 * gap).max(100.0);
+ (self.width - gap - param_w, gap, param_w, h)
+ }
+ Dock::Bottom => self.floating_spreadsheet_rect(),
+ }
+ }
+
+ /// Move a plate to a dock, swapping with the pane that held it. The
+ /// dock-owned dimensions stay put, so the geometry survives the swap.
+ pub fn move_pane_to_dock(&mut self, slot: usize, dock: Dock) {
+ let Some(from) = self.dock_of_pane(slot) else { return };
+ if from == dock {
+ return;
+ }
+ self.dock_panes.swap(from as usize, dock as usize);
+ self.rebuild_positions();
+ self.apply_layout();
+ self.read_panel_offsets();
+ }
+
pub fn floating_spreadsheet_rect(&self) -> (f32, f32, f32, f32) {
let gap = 18.0_f32;
let fx = gap;
@@ -1195,8 +1271,8 @@ impl State {
fw = fw.clamp(150.0, (self.width - 2.0 * gap).max(150.0));
let param_w = self.floating_param_width.clamp(150.0, (self.width - 2.0 * gap).max(150.0));
let param_x = self.width - gap - param_w;
- let flush_left = if self.show_network { fx + fw + gap } else { gap };
- let flush_right = if self.show_parameters { param_x - gap } else { self.width - gap };
+ let flush_left = if self.dock_shown(Dock::Left) { fx + fw + gap } else { gap };
+ let flush_right = if self.dock_shown(Dock::Right) { param_x - gap } else { self.width - gap };
let ss_x = (flush_left - self.floating_spreadsheet_inset_left.max(0.0)).max(gap);
let ss_end = (flush_right + self.floating_spreadsheet_inset_right.max(0.0)).min(self.width - gap);
let ss_w = (ss_end - ss_x).max(150.0);
@@ -1215,11 +1291,11 @@ impl State {
/// Whether the spreadsheet is tucked under the network / parameter pane
/// (side inset active while both panes are shown).
pub fn spreadsheet_tucks_left(&self) -> bool {
- self.show_spreadsheet && self.show_network && self.floating_spreadsheet_inset_left > 0.5
+ self.dock_shown(Dock::Bottom) && self.dock_shown(Dock::Left) && self.floating_spreadsheet_inset_left > 0.5
}
pub fn spreadsheet_tucks_right(&self) -> bool {
- self.show_spreadsheet && self.show_parameters && self.floating_spreadsheet_inset_right > 0.5
+ self.dock_shown(Dock::Bottom) && self.dock_shown(Dock::Right) && self.floating_spreadsheet_inset_right > 0.5
}
/// The spreadsheet pane's edge-resize hotspot at (cx, cy): top resizes the
@@ -1256,7 +1332,7 @@ impl State {
return Some(match drag {
AppDrag::NetworkResize { dir, .. } => dir_cursor(dir),
AppDrag::ParamResize { .. } => CursorIcon::EwResize,
- AppDrag::CornerLayout { .. } => CursorIcon::Move,
+ AppDrag::DockDrag { .. } => CursorIcon::Grabbing,
AppDrag::SpreadsheetResize { .. } => CursorIcon::NsResize,
AppDrag::SpreadsheetResizeLeft { .. } | AppDrag::SpreadsheetResizeRight { .. } => {
CursorIcon::EwResize
@@ -2786,6 +2862,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
plate_menu_actions: Vec::new(),
collapsed_panes: [false; WIDGET_COUNT],
corner_press: None,
+ dock_panes: [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX],
+ dock_drag_target: None,
drag_widget: None,
drag_press_cursor: None,
focused_widget: None,
@@ -3465,12 +3543,28 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
let vp_y = 0.0;
let vp_h = if viewport_visible { body_h } else { 0.0 };
- let (px, py, pw, ph) = if self.show_network {
- (fx, fy, fw, fh)
- } else {
- (0.0, 0.0, 0.0, 0.0)
+ // Dock model: the three rects computed above belong to the
+ // DOCKS (left column, right column, bottom strip); which pane
+ // wears which rect is the dock assignment, swapped by dragging
+ // a plate's corner dot. A hidden pane zeroes its rect wherever
+ // it is docked.
+ let dock_rects = [
+ (fx, fy, fw, fh),
+ (param_x, param_y, param_w, param_h),
+ (ss_x, ss_y, ss_w, ss_h),
+ ];
+ let rect_for = |slot: usize, state: &Self| -> (f32, f32, f32, f32) {
+ if !state.pane_shown(slot) {
+ return (0.0, 0.0, 0.0, 0.0);
+ }
+ match state.dock_of_pane(slot) {
+ Some(d) => dock_rects[d as usize],
+ None => (0.0, 0.0, 0.0, 0.0),
+ }
};
+ let (px, py, pw, ph) = rect_for(NETWORK_PANEL_IDX, self);
+
if let Some(menubar) = self.slots.left_menubar.as_any_mut().downcast_mut::<cce_ui::widget::MenuBar>() {
menubar.set_curved_circle(None);
}
@@ -3487,7 +3581,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
}
self.positions[SPLITTER1_IDX] = (0.0, 0.0, 0.0, 0.0);
self.positions[SPLITTER2_IDX] = (0.0, 0.0, 0.0, 0.0);
- let p_rect = if self.show_parameters { (param_x, param_y, param_w, param_h) } else { (0.0, 0.0, 0.0, 0.0) };
+ let p_rect = rect_for(PARAM_IDX, self);
self.positions[PARAM_IDX] = p_rect;
self.slots.param.set_rect(p_rect.0, p_rect.1, p_rect.2, p_rect.3);
@@ -3497,7 +3591,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
self.positions[LEFT_MENUBAR_IDX] = (0.0, 0.0, 0.0, 0.0);
self.positions[SPREADSHEET_MENUBAR_IDX] = (0.0, 0.0, 0.0, 0.0);
- self.positions[SPREADSHEET_IDX] = if spreadsheet_visible { (ss_x, ss_y, ss_w, ss_h) } else { (0.0, 0.0, 0.0, 0.0) };
+ self.positions[SPREADSHEET_IDX] = rect_for(SPREADSHEET_IDX, self);
self.slots.spreadsheet.set_rect(
self.positions[SPREADSHEET_IDX].0,
self.positions[SPREADSHEET_IDX].1,
@@ -4089,14 +4183,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
if moved > 4.0 {
self.corner_press = None;
if !self.pane_is_stubbed(idx) && idx != PLAYBAR_IDX {
- self.app_drag = Some(AppDrag::CornerLayout {
- idx,
- start_mouse: (px, py),
- start_fw: self.floating_network_layout.2,
- start_param_w: self.floating_param_width,
- start_ss_h: self.floating_spreadsheet_height,
- start_inset_r: self.floating_spreadsheet_inset_right,
- });
+ self.app_drag = Some(AppDrag::DockDrag { idx });
+ self.dock_drag_target = Some(self.dock_region_at(self.cursor_x, self.cursor_y));
+ if std::env::var("CCE_DOCK_DEBUG").is_ok() {
+ eprintln!("[dock] drag start slot={idx} target={:?}", self.dock_drag_target);
+ }
}
}
}
@@ -4178,36 +4269,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
self.sync_grid_settings();
changed = true;
}
- AppDrag::CornerLayout { idx, start_mouse, start_fw, start_param_w, start_ss_h, start_inset_r } => {
- let dx = self.cursor_x - start_mouse.0;
- let dy = self.cursor_y - start_mouse.1;
- let gap = 18.0_f32;
- match idx {
- NETWORK_PANEL_IDX => {
- // Dot rides the pane's top-right: width follows it.
- let max_w = (self.width - 2.0 * gap).max(150.0);
- self.floating_network_layout.2 = (start_fw + dx).clamp(150.0, max_w);
- }
- PARAM_IDX => {
- // Right-anchored: the LEFT edge follows the cursor.
- let max_w = (self.width - 2.0 * gap).max(150.0);
- self.floating_param_width = (start_param_w - dx).clamp(150.0, max_w);
- }
- SPREADSHEET_IDX => {
- // Corner drag: vertical is height, horizontal is the
- // right tuck under the params pane.
- self.floating_spreadsheet_height = (start_ss_h - dy).max(100.0);
- let param_x = self.width - gap - self.floating_param_width;
- let flush_right = param_x - gap;
- let max_inset = (self.width - gap - flush_right).max(0.0);
- self.floating_spreadsheet_inset_right = (start_inset_r + dx).clamp(0.0, max_inset);
- }
- _ => {}
+ AppDrag::DockDrag { idx } => {
+ let _ = idx;
+ let target = self.dock_region_at(self.cursor_x, self.cursor_y);
+ if std::env::var("CCE_DOCK_DEBUG").is_ok() {
+ eprintln!("[dock] motion ({:.0},{:.0}) -> {target:?}", self.cursor_x, self.cursor_y);
+ }
+ if self.dock_drag_target != Some(target) {
+ self.dock_drag_target = Some(target);
+ changed = true;
}
- self.rebuild_positions();
- self.apply_layout();
- self.sync_grid_settings();
- changed = true;
}
AppDrag::SpreadsheetResizeRight { start_inset, start_mouse_x } => {
// The right edge tucks under the parameter pane, symmetrically.
@@ -4383,6 +4454,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
// press never reaches the pane underneath.
if *button == MouseButton::Left {
if let Some(idx) = self.plate_corner_at(self.cursor_x, self.cursor_y) {
+ if std::env::var("CCE_DOCK_DEBUG").is_ok() {
+ eprintln!("[dock] armed corner press slot={idx} at ({:.0},{:.0})", self.cursor_x, self.cursor_y);
+ }
self.corner_press = Some((idx, self.cursor_x, self.cursor_y));
return true;
}
@@ -4724,8 +4798,18 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
AppDrag::SpreadsheetResize { .. }
| AppDrag::SpreadsheetResizeLeft { .. }
| AppDrag::SpreadsheetResizeRight { .. } => SPREADSHEET_IDX,
- AppDrag::CornerLayout { idx, .. } => idx,
+ AppDrag::DockDrag { idx } => idx,
};
+ // Dock drop: snap the dragged plate into the
+ // highlighted region, swapping occupants.
+ if let AppDrag::DockDrag { idx } = drag {
+ if let Some(target) = self.dock_drag_target.take() {
+ if std::env::var("CCE_DOCK_DEBUG").is_ok() {
+ eprintln!("[dock] drop slot={idx} into {target:?} (was {:?})", self.dock_of_pane(idx));
+ }
+ self.move_pane_to_dock(idx, target);
+ }
+ }
{
let ptr = self.slots.get_dyn_mut(idx) as *mut (dyn WidgetHost + 'static);
unsafe { (*ptr).handle_event(&cce_ui::widget::Event::DragEnd, &mut self.ui_context); }
@@ -4737,7 +4821,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
drag,
AppDrag::NetworkResize { .. }
| AppDrag::SpreadsheetResizeLeft { .. }
- | AppDrag::CornerLayout { idx: NETWORK_PANEL_IDX, .. }
+ | AppDrag::DockDrag { .. }
) {
self.read_panel_offsets();
}
diff --git a/src/main.rs b/src/main.rs
index 30ea2cc..c08c04e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -235,6 +235,54 @@ mod tests {
assert!(parent.slots.get_dyn(VIEWPORT_IDX).visible(), "the rest of the parent survived");
}
+ /// Dock swap: dragging a plate's dot to another region swaps occupants,
+ /// and the dock-owned dimensions stay put — the network lands in the
+ /// bottom strip's rect, the spreadsheet in the left column's.
+ #[test]
+ fn test_dock_swap_repositions_plates() {
+ use crate::app::Dock;
+ use crate::slots::{NETWORK_PANEL_IDX, SPREADSHEET_IDX};
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+ state.show_spreadsheet = true;
+ state.rebuild_positions();
+ state.apply_layout();
+
+ let net_before = state.slots.get_dyn(NETWORK_PANEL_IDX).rect();
+ let ss_before = state.slots.get_dyn(SPREADSHEET_IDX).rect();
+ assert!(net_before.2 > 0.0 && ss_before.2 > 0.0);
+ assert!(net_before.1 < ss_before.1, "network starts above the bottom strip");
+
+ state.move_pane_to_dock(NETWORK_PANEL_IDX, Dock::Bottom);
+ assert_eq!(state.dock_of_pane(NETWORK_PANEL_IDX), Some(Dock::Bottom));
+ assert_eq!(state.dock_of_pane(SPREADSHEET_IDX), Some(Dock::Left));
+
+ let net_after = state.slots.get_dyn(NETWORK_PANEL_IDX).rect();
+ let ss_after = state.slots.get_dyn(SPREADSHEET_IDX).rect();
+ // The network now wears (approximately) the strip geometry and the
+ // spreadsheet the left column's; exact equality is not required
+ // because the strip derivation reads dock occupancy, but the vertical
+ // order must have inverted and both must remain visible.
+ assert!(net_after.1 > ss_after.1, "network did not move below the spreadsheet");
+ assert!(net_after.2 > 0.0 && ss_after.2 > 0.0, "a pane vanished in the swap");
+
+ // Dropping it back restores the original arrangement.
+ state.move_pane_to_dock(NETWORK_PANEL_IDX, Dock::Left);
+ assert_eq!(state.dock_of_pane(SPREADSHEET_IDX), Some(Dock::Bottom));
+ }
+
+ /// The drop-region mapping: lower band is the bottom dock, the rest
+ /// splits into halves.
+ #[test]
+ fn test_dock_region_mapping() {
+ use crate::app::Dock;
+ let mut state = State::new(false);
+ state.resize(1000.0, 1000.0, 1.0);
+ assert_eq!(state.dock_region_at(100.0, 100.0), Dock::Left);
+ assert_eq!(state.dock_region_at(900.0, 100.0), Dock::Right);
+ assert_eq!(state.dock_region_at(500.0, 900.0), Dock::Bottom);
+ }
+
/// Full width tucks the spreadsheet under BOTH neighbors (their bottoms
/// rise via the tuck interlock); between-panes clears both tucks.
#[test]
diff --git a/src/render.rs b/src/render.rs
index f81fc3a..7a1325e 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -172,6 +172,7 @@ impl State {
self.append_context_border(&mut pc);
self.append_frame_text(&mut pc);
self.append_popovers(&mut pc);
+ self.append_dock_drag_overlay(&mut pc);
self.append_plate_corners(&mut pc);
// The node right-click context menu floats above everything (drawn last).
@@ -719,6 +720,22 @@ impl State {
/// open context menu: a solid dot in the plate's border color — one color,
/// like the graph's port dots and geometry toggles — growing slightly on
/// hover (and while its menu is open) instead of changing tint.
+ /// The dock-drop highlight while a plate is being dragged by its dot: the
+ /// region the release would snap it into, tinted and outlined.
+ fn append_dock_drag_overlay(&self, pc: &mut PaintCtx) {
+ let (Some(crate::app::AppDrag::DockDrag { .. }), Some(target)) = (self.app_drag, self.dock_drag_target) else {
+ return;
+ };
+ let (x, y, w, h) = self.dock_rect(target);
+ if w <= 0.0 || h <= 0.0 {
+ return;
+ }
+ let hl = colors::highlight_primary_color();
+ let r = cce_ui::layout::plate_corner_radius();
+ pc.rounded_rect(rect(x, y, w, h), r, (true, true, true, true), [hl[0], hl[1], hl[2], 0.12]);
+ pc.border(rect(x, y, w, h), (r, r, r, r), [0.0; 4], [hl[0], hl[1], hl[2], 0.8], 2.0);
+ }
+
fn append_plate_corners(&self, pc: &mut PaintCtx) {
let hovered = self.plate_corner_at(self.cursor_x, self.cursor_y);
for idx in crate::plate_corner::PLATE_SLOTS {