graphic design tool
git clone https://git.lucas.co/cce-designer.git
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, _)) = ¶m_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, _)) = ¶m_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.