git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitea870a6f1c0c3cffb0747841c1ab439385405106
parent675e6d867c
authorLucas Galante <[email protected]>
date2026-08-24 14:50
feat: adjust the pane layout by dragging the corner dots; spreadsheet Full Width

The corner dot is now a drag handle as well as a menu trigger: a press arms
click-vs-drag, a clean release still opens the menu, and motion past 4px
becomes an AppDrag::CornerLayout that drives the pane's FREE layout
parameters continuously — the panes are edge-anchored, so "moving" one means
driving what the layout lets vary. Network (dot on its top-right): width
follows the dot. Params (right-anchored): the left edge follows the cursor.
Spreadsheet: vertical is height, horizontal is the right tuck under the
params pane. Stubbed panes and the playbar stay click-only.

The spreadsheet's corner menu gains "Full Width" / "Between Panes": full
width maxes both tuck insets so the strip spans the window and BOTH
neighbors' bottoms rise to make room (the playbar treatment, via the
existing tuck interlock); between-panes clears the tucks.

Live-verified with pointer injection: clean dot click opens the menu with
the new items; Full Width spans the strip window-wide with both panes
shortened; dragging the network dot narrows the pane continuously with the
dot riding its edge; dragging the spreadsheet dot upward grows it with the
neighbors following.

 src/app.rs          | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++---
 src/main.rs         | 20 ++++++++++++
 src/plate_corner.rs | 32 +++++++++++++++++++
 3 files changed, 138 insertions(+), 4 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 6cd95f7..9b06980 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -600,6 +600,19 @@ 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,
+    },
 }
 
 #[repr(C)]
@@ -708,6 +721,9 @@ pub struct State {
     /// Panes shrunk to their title stub, indexed by slot. Only the
     /// `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)`.
+    pub corner_press: Option<(usize, f32, f32)>,
 
     pub drag_widget: Option<usize>,
     /// Where the pointer pressed when `drag_widget` armed — the drag
@@ -1240,6 +1256,7 @@ impl State {
             return Some(match drag {
                 AppDrag::NetworkResize { dir, .. } => dir_cursor(dir),
                 AppDrag::ParamResize { .. } => CursorIcon::EwResize,
+                AppDrag::CornerLayout { .. } => CursorIcon::Move,
                 AppDrag::SpreadsheetResize { .. } => CursorIcon::NsResize,
                 AppDrag::SpreadsheetResizeLeft { .. } | AppDrag::SpreadsheetResizeRight { .. } => {
                     CursorIcon::EwResize
@@ -2768,6 +2785,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             plate_menu_slot: None,
             plate_menu_actions: Vec::new(),
             collapsed_panes: [false; WIDGET_COUNT],
+            corner_press: None,
             drag_widget: None,
             drag_press_cursor: None,
             focused_widget: None,
@@ -4064,6 +4082,25 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     changed = true;
                 }
 
+                // An armed corner-dot press becomes a layout drag once it
+                // moves; stubbed (collapsed/detached) panes stay click-only.
+                if let Some((idx, px, py)) = self.corner_press {
+                    let moved = (self.cursor_x - px).abs().max((self.cursor_y - py).abs());
+                    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,
+                            });
+                        }
+                    }
+                }
+
                 if self.is_panning {
                     let dx = self.cursor_x - self.pan_start_cx;
                     let dy = self.cursor_y - self.pan_start_cy;
@@ -4141,6 +4178,37 @@ 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);
+                                    }
+                                    _ => {}
+                                }
+                                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.
                                 let dx = self.cursor_x - start_mouse_x;
@@ -4309,11 +4377,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                                 return true;
                             }
                         }
-                        // A press ON a corner control opens (or re-closes) its
-                        // menu and never reaches the pane underneath.
+                        // A press ON a corner control arms click-vs-drag: a
+                        // clean release opens the menu, motion past the
+                        // threshold becomes a layout drag. Either way the
+                        // press never reaches the pane underneath.
                         if *button == MouseButton::Left {
                             if let Some(idx) = self.plate_corner_at(self.cursor_x, self.cursor_y) {
-                                self.open_plate_menu(idx);
+                                self.corner_press = Some((idx, self.cursor_x, self.cursor_y));
                                 return true;
                             }
                         }
@@ -4654,6 +4724,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                                 AppDrag::SpreadsheetResize { .. }
                                 | AppDrag::SpreadsheetResizeLeft { .. }
                                 | AppDrag::SpreadsheetResizeRight { .. } => SPREADSHEET_IDX,
+                                AppDrag::CornerLayout { idx, .. } => idx,
                             };
                             {
                                 let ptr = self.slots.get_dyn_mut(idx) as *mut (dyn WidgetHost + 'static);
@@ -4662,11 +4733,22 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                             self.sync_layout();
                             // Both of these resized the network pane, so the node
                             // offsets need the same refresh.
-                            if matches!(drag, AppDrag::NetworkResize { .. } | AppDrag::SpreadsheetResizeLeft { .. }) {
+                            if matches!(
+                                drag,
+                                AppDrag::NetworkResize { .. }
+                                    | AppDrag::SpreadsheetResizeLeft { .. }
+                                    | AppDrag::CornerLayout { idx: NETWORK_PANEL_IDX, .. }
+                            ) {
                                 self.read_panel_offsets();
                             }
                             changed = true;
                         }
+                        // A corner-dot press that never became a drag is a
+                        // click: open that plate's menu now, on release.
+                        if let Some((idx, _, _)) = self.corner_press.take() {
+                            self.open_plate_menu(idx);
+                            changed = true;
+                        }
                         if self.drag_widget.is_some() {
                             let idx = self.drag_widget.unwrap();
                             if idx == PARAM_IDX {
diff --git a/src/main.rs b/src/main.rs
index 155f6c4..30ea2cc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -235,6 +235,26 @@ mod tests {
         assert!(parent.slots.get_dyn(VIEWPORT_IDX).visible(), "the rest of the parent survived");
     }
 
+    /// Full width tucks the spreadsheet under BOTH neighbors (their bottoms
+    /// rise via the tuck interlock); between-panes clears both tucks.
+    #[test]
+    fn test_spreadsheet_full_width_round_trip() {
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.show_spreadsheet = true;
+        state.rebuild_positions();
+        assert!(!state.spreadsheet_tucks_left() && !state.spreadsheet_tucks_right());
+
+        state.set_spreadsheet_full_width(true);
+        assert!(state.spreadsheet_tucks_left() && state.spreadsheet_tucks_right(),
+            "full width must tuck under both neighbors");
+        let (ss_x, _, ss_w, _) = state.floating_spreadsheet_rect();
+        assert!(ss_x <= 18.5 && ss_x + ss_w >= 1600.0 - 18.5, "not actually full width: x={ss_x} w={ss_w}");
+
+        state.set_spreadsheet_full_width(false);
+        assert!(!state.spreadsheet_tucks_left() && !state.spreadsheet_tucks_right());
+    }
+
     /// The way back. A detached pane's menu offers Reattach and nothing else,
     /// and reattaching restores the pane in full.
     #[test]
diff --git a/src/plate_corner.rs b/src/plate_corner.rs
index 7688f4a..7ad5fe3 100644
--- a/src/plate_corner.rs
+++ b/src/plate_corner.rs
@@ -41,6 +41,11 @@ pub enum PlateMenuAction {
     Detach,
     /// Take a detached pane back, closing the window that held it.
     Reattach,
+    /// Spreadsheet: span the full window width, tucking under both neighbors
+    /// (whose bottoms the layout raises to make room — the playbar treatment).
+    FullWidth,
+    /// Spreadsheet: back to the strip between the network and params panes.
+    BetweenPanes,
 }
 
 impl State {
@@ -141,6 +146,19 @@ impl State {
             actions.push(PlateMenuAction::Collapse);
         }
 
+        if idx == SPREADSHEET_IDX && !self.collapsed_panes[idx] {
+            // Layout spans: full-width is the playbar treatment; the neighbors'
+            // bottoms rise to make room via the tuck interlock.
+            if !(self.spreadsheet_tucks_left() && self.spreadsheet_tucks_right()) {
+                options.push("Full Width".to_string());
+                actions.push(PlateMenuAction::FullWidth);
+            }
+            if self.spreadsheet_tucks_left() || self.spreadsheet_tucks_right() {
+                options.push("Between Panes".to_string());
+                actions.push(PlateMenuAction::BetweenPanes);
+            }
+        }
+
         if self.plate_can_detach(idx) {
             options.push("Detach".to_string());
             actions.push(PlateMenuAction::Detach);
@@ -188,9 +206,23 @@ impl State {
             PlateMenuAction::Expand => self.set_pane_collapsed(idx, false),
             PlateMenuAction::Detach => self.detach_plate(idx),
             PlateMenuAction::Reattach => self.reattach_plate(idx),
+            PlateMenuAction::FullWidth => self.set_spreadsheet_full_width(true),
+            PlateMenuAction::BetweenPanes => self.set_spreadsheet_full_width(false),
         }
     }
 
+    /// Spreadsheet span: full width sets both tuck insets to their maxima
+    /// (the rect derivation clamps to the usable span), between-panes clears
+    /// them. The neighbors' heights follow through the existing tuck interlock.
+    pub fn set_spreadsheet_full_width(&mut self, full: bool) {
+        let v = if full { self.width.max(1.0) } else { 0.0 };
+        self.floating_spreadsheet_inset_left = v;
+        self.floating_spreadsheet_inset_right = v;
+        self.rebuild_positions();
+        self.apply_layout();
+        self.read_panel_offsets();
+    }
+
     pub fn set_pane_collapsed(&mut self, idx: usize, collapsed: bool) {
         if !PLATE_SLOTS.contains(&idx) || self.collapsed_panes[idx] == collapsed {
             return;