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

commit3300ad3f312e3efb15f063155f5d37388da303ed
parent3cff3efdaf
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 15:41
feat(params): the params plate is back, fitted to the rows

The HUD's plate runs from its top to as far under the last row as the
first row stands under the top, so it is padded alike above and below
and follows the node shown, rather than the HUD's viewport-tall rect.
It is on by default again; the HUD claims the plate and no more, so the
scene below it stays the viewport's.

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

 CLAUDE.md     | 30 ++++++++++++++++---------
 src/app.rs    | 47 ++++++++++++++++++++++-----------------
 src/main.rs   | 71 ++++++++++++++++++++++++++++++++++++++++-------------------
 src/render.rs | 43 +++++++++++++++++++++++++++---------
 4 files changed, 127 insertions(+), 64 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e5c53fc..25b0b15 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2663,13 +2663,20 @@ 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 params plate fits its rows, and is optional (since 2026-10-06)
+
+The params HUD's plate is FITTED to its rows: from the HUD's top to as far
+under the last row as the first row stands under the top, so it is padded
+alike above and below, and it grows and shrinks with the node shown — not
+the HUD's rect, which runs the viewport's height. No rows (nothing
+selected), no plate. It can go, as the network's can, leaving the controls
+directly on the scene: `params_plate` on `State` and `ViewportSettings`
+(state.kdl and the project's display block; ON by default and when absent
+— it was off for an afternoon, so a state.kdl from then says `false`), the
+`toggle_params_plate` command (**Parameters Plate**, a switch in the
+palette, unbound) and `Action::ToggleParamsPlate`. The render arm draws it
+with `render::append_plate_at` — `append_widget_plate_radii` at a given
+rect, since that one draws at the widget's own.
 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
@@ -2677,16 +2684,17 @@ 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
+**The HUD is its ROWS, plate or not.** `State::params_claim` is the
+band from the HUD's top to just under its last row — the fitted plate
+with the plate on, `PARAMS_CLAIM_PAD` under the last row without it, the
+whole rect 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
+rows. `the_params_plate_fits_its_rows` and
 `a_point_number_under_a_plate_is_not_drawn` are the tests.
 
 ### The params pane is a HUD on the scene (since 2026-10-06)
diff --git a/src/app.rs b/src/app.rs
index 0727b1f..3dd55f4 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2000,10 +2000,10 @@ 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)]
+    /// Whether the params HUD draws its plate — one FITTED to its rows,
+    /// not to the HUD's rect. On by default; off, the rows stand on the
+    /// scene. Either way the HUD claims only the band its rows cover.
+    #[serde(default = "default_params_plate")]
     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
@@ -2225,6 +2225,10 @@ fn default_network_plate() -> bool {
     true
 }
 
+fn default_params_plate() -> bool {
+    true
+}
+
 impl Default for ViewportSettings {
     fn default() -> Self {
         Self {
@@ -2235,7 +2239,7 @@ impl Default for ViewportSettings {
             show_grid_enabled: true,
             show_origin_enabled: true,
             network_plate: true,
-            params_plate: false,
+            params_plate: true,
             node_wire_style: String::new(),
             show_point_markers: false,
             show_point_numbers: false,
@@ -3979,24 +3983,27 @@ impl State {
             })
     }
 
-    /// 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.
+    /// The part of the params HUD that is the HUD's: the band from its top
+    /// to just under its last row — the ROWS are the HUD, and the scene
+    /// below them is the viewport's, to orbit, scroll and number, as the
+    /// network's overlay claims only its nodes. With the plate on this is
+    /// the PLATE, fitted to the rows: as far under the last row as the
+    /// first row stands under the top, so it is padded alike above and
+    /// below; without it, `PARAMS_CLAIM_PAD` under. Rows overflowing the
+    /// HUD fill it, and a HUD with no rows (nothing selected) claims
+    /// nothing and draws no plate.
     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 || self.pane_is_stubbed(PARAM_IDX) {
+        if w <= 0.0 || h <= 0.0 || self.pane_is_stubbed(PARAM_IDX) {
             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)
+        let rows: Vec<(f32, f32, f32, f32)> = self.param_row_rects().into_iter().filter(|r| r.3 > 0.0).collect();
+        let Some(first) = rows.iter().map(|r| r.1).reduce(f32::min) else {
+            return (x, y, w, 0.0);
+        };
+        let last = rows.iter().map(|r| r.1 + r.3).fold(y, f32::max);
+        let pad = if self.params_plate { (first - y).max(PARAMS_CLAIM_PAD) } else { PARAMS_CLAIM_PAD };
+        (x, y, w, (last + pad).min(y + h) - y)
     }
 
     /// Whether (px, py) is the params pane's: inside `params_claim` where
@@ -10101,7 +10108,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             Action::ToggleParamsPlate => {
                 self.params_plate = !self.params_plate;
                 self.update_status_text(if self.params_plate {
-                    "Parameters plate on."
+                    "Parameters plate on, fitted to the rows."
                 } else {
                     "Parameters plate off — the controls stand on the scene."
                 });
diff --git a/src/main.rs b/src/main.rs
index 60938d7..27de283 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -423,6 +423,11 @@ mod tests {
 
         // The params HUD, with its plate on, off a row and where no plate
         // covers it: its menu alone, which detaches and does not collapse.
+        // A node selected, so the HUD has rows and a plate fitted to them.
+        let mut redraw = false;
+        let slot = geo(&state.fs_root).children.iter().position(|c| c.node_type == "sphere").unwrap();
+        state.apply_action(crate::app::McpAction::Select { slot }, &mut redraw).unwrap();
+        state.apply_layout();
         state.params_plate = true;
         let (x, y, w, h) = state.slots.get_dyn(PARAM_IDX).rect();
         let free = (0..(h as i32))
@@ -2050,26 +2055,29 @@ mod tests {
         assert!(!all.is_empty());
         assert!(all.iter().all(|(_, x, y, ..)| !state.under_a_plate(*x, *y + 6.0)), "none stands under a plate");
 
-        // Put the params plate over the middle of the scene: the numbers
-        // there go, the rest stay.
+        // Put the HUD, with a node's rows on its plate, over the middle of
+        // the scene: the numbers under the plate go, the rest stay.
+        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.params_plate = true;
-        state.positions[crate::slots::PARAM_IDX] = (700.0, 350.0, 200.0, 200.0);
+        state.positions[crate::slots::PARAM_IDX] = (800.0, 200.0, 200.0, 690.0);
+        state.slots.get_dyn_mut(crate::slots::PARAM_IDX).set_rect(800.0, 200.0, 200.0, 690.0);
+        let plate = state.params_claim();
+        assert!(plate.1 == 200.0 && plate.3 > 100.0 && plate.3 < 690.0, "the plate fits the rows: {plate:?}");
+        let in_plate = |x: f32, y: f32| x >= 800.0 && x < 1000.0 && y + 6.0 >= plate.1 && y + 6.0 < plate.1 + plate.3;
         let fewer = state.point_number_labels();
         assert!(fewer.len() < all.len(), "{} of {} are left", fewer.len(), all.len());
         assert!(!fewer.is_empty());
-        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)));
+        assert!(fewer.iter().all(|(_, x, y, ..)| !in_plate(*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.
+        // Without its plate the HUD hides only what its rows stand on: the
+        // band it claims is narrower, and more numbers show.
         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"
-        );
+        assert!(state.point_number_labels().len() >= fewer.len());
+        state.params_plate = true;
         state.positions[crate::slots::PARAM_IDX] = (px, py, pw, ph);
+        state.slots.get_dyn_mut(crate::slots::PARAM_IDX).set_rect(px, py, pw, ph);
         assert_eq!(state.point_number_labels().len(), all.len());
     }
 
@@ -2153,14 +2161,24 @@ mod tests {
         assert_eq!(area(&uncovered(r, &[(200.0, 0.0, 10.0, 10.0)])), 10000.0, "a cover elsewhere");
     }
 
-    /// 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.
+    /// The params HUD's plate is fitted to its rows — padded under the last
+    /// as the first is under the top — and on by default; without it the
+    /// rows stand on the scene. Either way the HUD claims only the band its
+    /// rows cover: a press or the wheel under it is the viewport's.
     #[test]
-    fn a_plateless_params_pane_is_its_rows() {
+    fn the_params_plate_fits_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));
+        assert!(state.params_plate, "the plate is on by default");
+        assert_eq!(state.command_toggle_state("toggle_params_plate"), Some(true));
+        // A HUD with no rows draws no plate and claims nothing.
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        if state.param_row_rects().iter().all(|r| r.3 <= 0.0) {
+            assert_eq!(state.params_claim().3, 0.0);
+        }
+        assert!(state.run_command("toggle_params_plate"));
+        assert!(!state.params_plate);
         state.resize(1600.0, 900.0, 1.0);
         state.rebuild_positions();
         state.apply_layout();
@@ -2184,12 +2202,19 @@ mod tests {
         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.
+        // With the plate: it runs as far under the last row as the first
+        // row stands under the HUD's top, and no further.
         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));
+        let (_, cy, _, ch) = state.params_claim();
+        let first = rows.iter().filter(|r| r.3 > 0.0).map(|r| r.1).fold(f32::INFINITY, f32::min);
+        assert_eq!(cy, py);
+        assert!((cy + ch - (last + (first - py))).abs() < 0.01, "padded alike: {} vs {}", cy + ch, last + first - py);
+        assert!(state.params_claims(px + pw * 0.5, last + 2.0), "the plate's bottom margin is the HUD's");
+        assert!(!state.params_claims(under.0, under.1), "under the plate is the scene's");
+        assert!(state.cursor_in_viewport());
+        assert!(!state.on_param_resize_edge(px, under.1));
+        assert!(state.on_param_resize_edge(px, row_y));
     }
 
     /// A scene rebuild leaves the numbers dimmed as they were: the 2D frame
diff --git a/src/render.rs b/src/render.rs
index f7594b8..830ad7e 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -86,6 +86,8 @@ impl State {
             crate::slots::CONTENT2_IDX | crate::slots::BREADCRUMB2_IDX => {
                 self.positions[crate::slots::NETWORK_PANEL2_IDX]
             }
+            // The plate fits the rows; without one, the HUD's rect.
+            PARAM_IDX if self.params_plate => self.params_claim(),
             PARAM_IDX => self.positions[PARAM_IDX],
             SPREADSHEET_IDX => self.positions[SPREADSHEET_IDX],
             _ => return None,
@@ -671,19 +673,21 @@ impl State {
             // Track and thumb are pills — half-width radius on the DE corner
             // family (squircle when corner_shape > 2), like the nodes.
             //
-            // 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 {
+            // The plate (`params_plate`, on by default) is FITTED to the
+            // rows (`params_claim`), not the HUD's rect, which runs the
+            // viewport's height. Without it the rows stand on the scene,
+            // and there is no idle copy of the bar, which only ever showed
+            // faintly through the frost — the bar is seen when a scroll
+            // raises it.
+            let fitted = self.params_claim();
+            if self.params_plate && fitted.2 > 0.0 && fitted.3 > 0.0 {
                 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 (fx, fy, fw, fh) = fitted;
+                append_plate_at(w, pc, rect(fx, fy, fw, fh), self.plate_focus_tint(idx), self.pane_plate_radii(fx, fy, fw, fh));
             }
 
             let (px, py, pw, ph) = self.positions[PARAM_IDX];
@@ -886,11 +890,11 @@ impl State {
                 (0.0, 0.0, self.width, self.height)
             }
             PARAM_MENUBAR_IDX => {
-                // No plate, no edge to ring.
+                // No plate, no edge to ring; the plate is fitted to the rows.
                 if !self.show_parameters || relief || !self.params_plate {
                     return;
                 }
-                self.positions[PARAM_IDX]
+                self.params_claim()
             }
             SPREADSHEET_MENUBAR_IDX => {
                 if !self.show_spreadsheet || relief {
@@ -1818,3 +1822,22 @@ pub(crate) fn uncovered(r: Rect, covers: &[(f32, f32, f32, f32)]) -> Vec<Rect> {
     pieces.retain(|p| p.width > 0.5 && p.height > 0.5);
     pieces
 }
+
+/// `append_widget_plate_radii` at `r` rather than the widget's own rect:
+/// the params HUD's plate is fitted to its rows, and the HUD's rect runs the
+/// viewport's height. The same material, relief and border as every pane.
+fn append_plate_at(w: &dyn WidgetHost, pc: &mut PaintCtx, r: Rect, tint: Option<[f32; 3]>, radii: (f32, f32, f32, f32)) {
+    let tint = tint.unwrap_or([1.0, 1.0, 1.0]);
+    let fill = cce_ui::scene::material::Material::from_fill(w.color());
+    if let Some(thickness) = w.plate_bevel() {
+        pc.bevel_tinted(r, radii, &fill, thickness, tint);
+    } else if let Some((border_color, thickness)) = w.solid_border() {
+        if cce_ui::layout::control_relief() {
+            pc.bevel_tinted(r, radii, &fill, cce_ui::colors::plate_bevel_width(), tint);
+        } else {
+            pc.border(r, radii, w.color(), border_color, thickness);
+        }
+    } else {
+        pc.border(r, radii, w.color(), [0.0; 4], 0.0);
+    }
+}