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

commit9e6c4875a50b91e823b6b6df79e2d4dc37e6c558
parentff896deb69
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 14:44
feat(params): the params pane can drop its plate, and does by default

With params_plate off the controls stand directly on the scene: no plate,
no idle scrollbar copy, no flat focus ring. The pane then claims only the
band its rows cover (params_claim), so a press, the wheel and the point
numbers under the rows are the viewport's. Parameters Plate
(toggle_params_plate) brings the plate back; the setting rides state.kdl
and the project's display block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md       | 26 ++++++++++++++++++++
 src/app.rs      | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++----
 src/command.rs  |  1 +
 src/dialog.rs   |  1 +
 src/main.rs     | 55 +++++++++++++++++++++++++++++++++++++++++--
 src/render.rs   | 22 +++++++++++------
 src/shortcut.rs |  2 ++
 7 files changed, 167 insertions(+), 13 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index abdb1eb..131328a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2663,6 +2663,32 @@ network's — it ORBITS THE CAMERA instead (see below), which is what makes the
 overlay feel like a scene with a graph on it rather than a graph with a
 picture behind it. Deselecting is on Escape.
 
+### The params plate is optional, and off by default (since 2026-10-06)
+
+The params pane's plate can go too, as the network's can, and by default
+it has: the controls stand directly on the scene. `params_plate` on
+`State` and `ViewportSettings` (state.kdl and the project's display
+block; absent reads OFF), the `toggle_params_plate` command (**Parameters
+Plate**, a switch in the palette, unbound) and `Action::ToggleParamsPlate`.
+The PARAM_IDX render arm skips the plate and the scrollbar's idle copy —
+which only ever showed faintly through the frost; the bar is seen when a
+scroll raises it — and `append_context_border` draws no flat ring around a
+pane with no edge. The wells and labels are ParametersBg's own and are
+unchanged; with no enclosing plate the wells shade as overlays, which was
+checked in a shadow session and reads cleanly over the grid.
+
+**Without its plate the pane is its ROWS.** `State::params_claim` is the
+band from the pane's top to `PARAMS_CLAIM_PAD` under its last row (the
+whole rect with the plate on, or when the rows fill it), and
+`params_claims` adds an open dropdown, which grows past the rows. It is
+what `over_floating_pane_at` reads for the pane, so `cursor_in_viewport`,
+`under_a_plate` (a point number under the empty part draws) and the
+network overlay's claim follow it; the press cascade's `hits_widget` and
+the wheel loop ask it too, so a press or a scroll under the rows orbits
+and zooms the scene, and `on_param_resize_edge` runs only as far as the
+rows. `a_plateless_params_pane_is_its_rows` and
+`a_point_number_under_a_plate_is_not_drawn` are the tests.
+
 ### Deselecting has to stick
 
 The selection IS whatever sits in the grid cursor's cell — that is what
diff --git a/src/app.rs b/src/app.rs
index eaa94b8..289fdfa 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -582,6 +582,10 @@ pub struct NodeDragGroup {
 /// separator. Add Node leads because right-clicking empty space USED to open
 /// the add-node palette outright, and that is still the common reason to come
 /// here.
+/// How far under its last row a plateless params pane still claims the
+/// pointer: a press just below a row is the row's, not the scene's.
+pub const PARAMS_CLAIM_PAD: f32 = 8.0;
+
 pub const NETWORK_MENU_COMMANDS: &[Option<&'static str>] = &[
     Some("add_node"),
     None,
@@ -1988,6 +1992,11 @@ pub struct ViewportSettings {
     /// the project, but whether its surface is drawn is how you like to work.
     #[serde(default = "default_network_plate")]
     pub network_plate: bool,
+    /// Whether the params pane draws its plate. Off (the default, since
+    /// 2026-10-06): the rows stand on the scene, and the pane claims only
+    /// the band its rows cover, so the scene below them is the viewport's.
+    #[serde(default)]
+    pub params_plate: bool,
     /// How the network's node wires run (`cce_ui::widget::display::WireStyle`
     /// by name). Empty follows `style.surface.graph.node.wire_style` in
     /// config.kdl, which is every file from before the row.
@@ -2218,6 +2227,7 @@ impl Default for ViewportSettings {
             show_grid_enabled: true,
             show_origin_enabled: true,
             network_plate: true,
+            params_plate: false,
             node_wire_style: String::new(),
             show_point_markers: false,
             show_point_numbers: false,
@@ -2994,6 +3004,11 @@ pub struct State {
     /// it. The pane is still there: it keeps its rect, its focus, its corner
     /// menus and its clip; only the surface under it stops being drawn.
     pub network_plate: bool,
+    /// Whether the params pane draws its PLATE. With it off the rows stand
+    /// directly over the scene, and the pane claims only what its rows
+    /// cover (`params_claim`), so the scene under the rest of its rect
+    /// orbits, takes the wheel and shows its point numbers.
+    pub params_plate: bool,
     pub show_viewport: bool,
     pub show_parameters: bool,
     pub show_spreadsheet: bool,
@@ -3473,6 +3488,7 @@ impl State {
                 grid_thickness: self.grid_thickness,
                 grid_color: self.viewport().grid_color,
                 network_plate: self.network_plate,
+                params_plate: self.params_plate,
                 node_wire_style: self.slots.content.inner().chosen_wire_style().map(|w| w.name().to_string()).unwrap_or_default(),
                 show_point_markers: self.show_point_markers,
                 show_point_numbers: self.show_point_numbers,
@@ -3554,6 +3570,7 @@ impl State {
         self.origin_size = v.origin_size;
         self.grid_thickness = v.grid_thickness;
         self.network_plate = v.network_plate;
+        self.params_plate = v.params_plate;
         self.set_node_wire_style(cce_ui::widget::display::WireStyle::parse(&v.node_wire_style));
         self.circular_network_pane = self.is_detached_network || v.circular_pane;
         self.show_point_markers = v.show_point_markers;
@@ -3939,10 +3956,38 @@ impl State {
     /// which, when the network spans the whole window, is the only thing
     /// keeping a node drawn under the params pane from stealing its clicks.
     pub fn over_floating_pane_at(&self, px: f32, py: f32) -> bool {
-        [PARAM_IDX, SPREADSHEET_IDX, PLAYBAR_IDX].iter().any(|&idx| {
-            let (x, y, w, h) = self.positions[idx];
-            w > 0.0 && h > 0.0 && px >= x && px < x + w && py >= y && py < y + h
-        })
+        self.params_claims(px, py)
+            || [SPREADSHEET_IDX, PLAYBAR_IDX].iter().any(|&idx| {
+                let (x, y, w, h) = self.positions[idx];
+                w > 0.0 && h > 0.0 && px >= x && px < x + w && py >= y && py < y + h
+            })
+    }
+
+    /// The part of the params pane that is the pane's: its whole rect with
+    /// the plate on. With the plate off the ROWS are the pane — the band
+    /// from its top to just under its last row — and the scene below them
+    /// is the viewport's, to orbit, scroll and number, as the network's
+    /// overlay claims only its nodes. A pane overflowing its rows claims
+    /// all of it, the rows filling it.
+    pub fn params_claim(&self) -> (f32, f32, f32, f32) {
+        let (x, y, w, h) = self.positions[PARAM_IDX];
+        if self.params_plate || w <= 0.0 || h <= 0.0 {
+            return (x, y, w, h);
+        }
+        let last = self
+            .param_row_rects()
+            .iter()
+            .filter(|r| r.3 > 0.0)
+            .map(|r| r.1 + r.3)
+            .fold(y, f32::max);
+        (x, y, w, (last + PARAMS_CLAIM_PAD).min(y + h) - y)
+    }
+
+    /// Whether (px, py) is the params pane's: inside `params_claim`, or on
+    /// a dropdown it has open, which grows past its rows.
+    pub fn params_claims(&self, px: f32, py: f32) -> bool {
+        let inside = |(x, y, w, h): (f32, f32, f32, f32)| w > 0.0 && h > 0.0 && px >= x && px < x + w && py >= y && py < y + h;
+        inside(self.params_claim()) || self.slots.param.popover_rect().is_some_and(inside)
     }
 
     /// Whether (px, py) is under a plate drawn over the scene: a floating
@@ -4206,6 +4251,8 @@ impl State {
         let param_x = self.width - gap - param_w;
         let param_y = HEADER_H + gap;
         let param_h = (self.height - HEADER_H - STATUS_H - 2.0 * gap).max(100.0);
+        // Without its plate the pane's edge runs as far as its rows do.
+        let param_h = if self.params_plate { param_h } else { self.params_claim().3.min(param_h) };
         let margin = 8.0_f32;
         cx >= param_x - margin && cx <= param_x + margin && cy >= param_y - margin && cy <= param_y + param_h + margin
     }
@@ -8241,6 +8288,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             active_camera,
             show_network: true,
             network_plate: settings.viewport.network_plate,
+            params_plate: settings.viewport.params_plate,
             show_viewport: true,
             show_parameters: true,
             show_spreadsheet: false,
@@ -9965,6 +10013,15 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 // viewport toggle uses, rather than a save call of its own.
                 settings_changed = true;
             }
+            Action::ToggleParamsPlate => {
+                self.params_plate = !self.params_plate;
+                self.update_status_text(if self.params_plate {
+                    "Parameters plate on."
+                } else {
+                    "Parameters plate off — the controls stand on the scene."
+                });
+                settings_changed = true;
+            }
             Action::Deselect => {
                 self.deselect_node();
             }
@@ -10540,6 +10597,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         if i == VIEWPORT_IDX {
                             continue;
                         }
+                        // Under its rows a plateless params pane is scene.
+                        if i == PARAM_IDX && !self.params_claims(self.cursor_x, self.cursor_y) {
+                            continue;
+                        }
                         let root = self.slots.get_dyn_mut(i).base().id();
                         if self.ui_context.propagate_event(&wheel_ev, root) {
                             handled = true;
@@ -10978,6 +11039,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         } else {
                             false
                         }
+                    } else if i == PARAM_IDX && !state.params_plate {
+                        // Without its plate the pane is its rows: a press
+                        // under them is the scene's.
+                        state.params_claims(x, y) && state.slots.get_dyn(i).hit_test(x, y, &state.ui_context)
                     } else if state.network_overlay() && i == CONTENT_IDX {
                         // The graph's RECT spans the window in overlay mode,
                         // so the widget's own hit test would claim every press
diff --git a/src/command.rs b/src/command.rs
index 178e554..973652c 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -192,6 +192,7 @@ pub const COMMANDS: &[Command] = &[
     Command { id: "zoom_out", label: "Zoom Out", context: Context::Network, run: Run::Menu("Zoom Out"), default_chord: None },
     Command { id: "reset_zoom", label: "Reset Zoom", context: Context::Network, run: Run::Menu("Reset Zoom"), default_chord: None },
     Command { id: "toggle_network_plate", label: "Network Plate", context: Context::Network, run: Run::Key(Action::ToggleNetworkPlate), default_chord: Some("Shift+p") },
+    Command { id: "toggle_params_plate", label: "Parameters Plate", context: Context::Parameters, run: Run::Key(Action::ToggleParamsPlate), default_chord: None },
     Command { id: "toggle_circular_pane", label: "Circular Pane", context: Context::Network, run: Run::Key(Action::ToggleCircularPane), default_chord: Some("Ctrl+d") },
     Command { id: "detach_circular_window", label: "Detach Circular Window", context: Context::Network, run: Run::Key(Action::DetachCircularWindow), default_chord: None },
 
diff --git a/src/dialog.rs b/src/dialog.rs
index df8b07c..4c3210e 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -2290,6 +2290,7 @@ impl State {
             "toggle_ray_traced_preview" => self.viewport().rt_mode,
             "toggle_square_viewport" => self.square_viewport,
             "toggle_network_plate" => self.network_plate,
+            "toggle_params_plate" => self.params_plate,
             "toggle_circular_pane" => self.circular_network_pane,
             "detach_circular_window" => self.detached_circular_network,
             "toggle_spreadsheet" => self.show_spreadsheet,
diff --git a/src/main.rs b/src/main.rs
index bb2d011..dc8f5f6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2023,15 +2023,66 @@ mod tests {
 
         // Put the params plate over the middle of the scene: the numbers
         // there go, the rest stay.
+        state.params_plate = true;
         state.positions[crate::slots::PARAM_IDX] = (700.0, 350.0, 200.0, 200.0);
         let fewer = state.point_number_labels();
         assert!(fewer.len() < all.len(), "{} of {} are left", fewer.len(), all.len());
         assert!(!fewer.is_empty());
-        assert!(fewer.iter().all(|(_, x, y, ..)| !(*x >= 700.0 && *x < 900.0 && *y + 6.0 >= 350.0 && *y + 6.0 < 550.0)));
+        let in_pane = |x: f32, y: f32| x >= 700.0 && x < 900.0 && y + 6.0 >= 350.0 && y + 6.0 < 550.0;
+        assert!(fewer.iter().all(|(_, x, y, ..)| !in_pane(*x, *y)));
+
+        // Without its plate the pane is its rows, which stand elsewhere:
+        // the numbers in the rest of its rect are the scene's, and drawn.
+        state.params_plate = false;
+        let (_, cy, _, ch) = state.params_claim();
+        assert!(ch <= crate::app::PARAMS_CLAIM_PAD && cy == 350.0, "no row stands in the moved rect");
+        assert!(
+            state.point_number_labels().iter().any(|(_, x, y, ..)| in_pane(*x, *y)),
+            "a plateless pane hides no number under its empty space"
+        );
         state.positions[crate::slots::PARAM_IDX] = (px, py, pw, ph);
         assert_eq!(state.point_number_labels().len(), all.len());
     }
 
+    /// The params pane can drop its plate, and does by default: the rows
+    /// stand on the scene, and the pane claims only the band they cover —
+    /// a press or the wheel under the last row is the viewport's.
+    #[test]
+    fn a_plateless_params_pane_is_its_rows() {
+        let mut state = State::new(false);
+        assert!(!state.params_plate, "the plate is off by default");
+        assert_eq!(state.command_toggle_state("toggle_params_plate"), Some(false));
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        let mut redraw = false;
+        let slot = geo(&state.fs_root).children.iter().position(|c| c.node_type == "sphere").unwrap();
+        state.apply_action(McpAction::Select { slot }, &mut redraw).unwrap();
+        state.apply_layout();
+
+        let (px, py, pw, ph) = state.positions[crate::slots::PARAM_IDX];
+        let rows = state.param_row_rects();
+        let last = rows.iter().filter(|r| r.3 > 0.0).map(|r| r.1 + r.3).fold(py, f32::max);
+        assert!(last + crate::app::PARAMS_CLAIM_PAD < py + ph, "the sphere's rows leave room under them");
+        let (row_x, row_y) = (px + pw * 0.5, rows[0].1 + rows[0].3 * 0.5);
+        let under = (px + pw * 0.5, (last + py + ph) * 0.5);
+
+        // A row is the pane's; the space under the rows is the scene's.
+        assert!(state.params_claims(row_x, row_y));
+        assert!(!state.params_claims(under.0, under.1));
+        state.cursor_x = under.0;
+        state.cursor_y = under.1;
+        assert!(state.cursor_in_viewport(), "under the rows is the viewport");
+        assert!(!state.on_param_resize_edge(px, under.1), "and the pane's edge runs only as far as its rows");
+
+        // With the plate the whole rect is the pane again.
+        assert!(state.run_command("toggle_params_plate"));
+        assert!(state.params_plate);
+        assert!(state.params_claims(under.0, under.1));
+        assert!(!state.cursor_in_viewport());
+        assert!(state.on_param_resize_edge(px, under.1));
+    }
+
     /// A scene rebuild leaves the numbers dimmed as they were: the 2D frame
     /// is painted before the stage pass, so a rebuild that cleared the
     /// dimming drew one frame of every number at full strength, and a
@@ -15844,7 +15895,7 @@ mod tests {
             "toggle_grid", "toggle_origin", "toggle_wireframe",
             "toggle_wire_single_color", "toggle_ray_traced_preview",
             "toggle_circular_pane", "toggle_camera_pivot", "toggle_square_viewport",
-            "toggle_network_plate",
+            "toggle_network_plate", "toggle_params_plate",
         ] {
             assert!(crate::command::by_id(id).is_some(), "the toggle '{id}' has no command");
             assert!(state.command_toggle_state(id).is_some(), "the toggle '{id}' draws no switch");
diff --git a/src/render.rs b/src/render.rs
index dc50509..5fac112 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -667,14 +667,21 @@ impl State {
             };
             // Track and thumb are pills — half-width radius on the DE corner
             // family (squircle when corner_shape > 2), like the nodes.
-            if let Some((quads, _)) = &param_scrollbar {
-                for &(qx, qy, qw, qh, qc) in quads {
-                    pc.rounded_rect(rect(qx, qy, qw, qh), qw.min(qh) * 0.5, (true, true, true, true), qc);
+            //
+            // Without its plate (`params_plate` off, the default) the rows
+            // stand on the scene: no plate, and no idle copy of the bar,
+            // which only ever showed faintly through the frost — the bar
+            // is seen when a scroll raises it.
+            if self.params_plate {
+                if let Some((quads, _)) = &param_scrollbar {
+                    for &(qx, qy, qw, qh, qc) in quads {
+                        pc.rounded_rect(rect(qx, qy, qw, qh), qw.min(qh) * 0.5, (true, true, true, true), qc);
+                    }
                 }
-            }
 
-            let (wx, wy, ww2, wh2) = w.rect();
-            append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(wx, wy, ww2, wh2));
+                let (wx, wy, ww2, wh2) = w.rect();
+                append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(wx, wy, ww2, wh2));
+            }
 
             let (px, py, pw, ph) = self.positions[PARAM_IDX];
             let view = rect(px, py + 4.0, pw, (ph - 8.0).max(0.0));
@@ -865,7 +872,8 @@ impl State {
                 (0.0, 0.0, self.width, self.height)
             }
             PARAM_MENUBAR_IDX => {
-                if !self.show_parameters || relief {
+                // No plate, no edge to ring.
+                if !self.show_parameters || relief || !self.params_plate {
                     return;
                 }
                 self.positions[PARAM_IDX]
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 439bbf8..588529f 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -79,6 +79,8 @@ pub enum Action {
     LayoutNodes,
     /// Draw the network pane's plate, or let the graph overlay the scene.
     ToggleNetworkPlate,
+    /// Draw the params pane's plate, or stand its controls on the scene.
+    ToggleParamsPlate,
     /// Clear the node selection.
     Deselect,
     /// Turn the camera square to the image the viewport shows and fit it.