graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(params): the plate collapses into a small circle with no rows
With nothing to show (no node selected, or a node without parameters)
the params plate eases into a 36 px circle in the HUD's top right
corner, and grows back into the plate fitted to the rows when they
return, the rows revealed as it grows. The circle is a true one:
plate_shaped with the corner exponent eased to 2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 17 +++++++++++--
src/app.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
src/main.rs | 63 +++++++++++++++++++++++++++++++++++++++++++---
src/render.rs | 47 +++++++++++++++++++++++++++-------
4 files changed, 191 insertions(+), 17 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 78d2150..0f3d504 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2706,8 +2706,21 @@ picture behind it. Deselecting is on Escape.
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
+the HUD's rect, which runs the viewport's height. **With no rows to show
+(nothing selected, a node without parameters) it collapses into a small
+circle** (`PARAMS_DOT_D`, 36 px) in the HUD's top right corner, which the
+HUD claims, and grows back out of it when rows return. `params_plate_target`
+is where it is heading, `[x, y, w, h, round]` (round 1 the circle);
+`State::params_plate_shown` eases toward it each tick
+(`animate_params_plate`, exponential like the drop glow);
+`params_plate_drawn` is the frame's, its corners rounding toward half the
+side. Settled on the rows it is the bevelled plate every pane wears; the
+circle and the way between are `PaintCtx::plate_shaped` with the corner
+exponent eased toward 2 — circular arcs, since the DE's squircle at full
+radius is a rounded square and not a circle — and the rolled edge toward a
+dot's, cce-browser's bar-from-its-corner-control morph. The rows are
+clipped to the plate as drawn, so they are revealed as it grows. A circle
+has no edge to resize. `the_params_plate_collapses_to_a_circle_with_no_rows`. 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
diff --git a/src/app.rs b/src/app.rs
index 785bc8f..2c0229a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -595,6 +595,10 @@ pub const PARAMS_CLAIM_PAD: f32 = 8.0;
/// The narrowest the params HUD may be dragged.
pub const PARAMS_HUD_MIN_W: f32 = 150.0;
+/// The params plate's size collapsed: a circle this wide at the HUD's top
+/// right corner, while there are no rows to show.
+pub const PARAMS_DOT_D: f32 = 36.0;
+
pub const NETWORK_MENU_COMMANDS: &[Option<&'static str>] = &[
Some("add_node"),
None,
@@ -3193,6 +3197,11 @@ pub struct State {
/// fading). None once fully faded. Advanced in [`State::tick_frame`],
/// drawn by the CONTENT branch in render.rs.
pub drop_glow: Option<DropGlow>,
+ /// The params plate as drawn: `[x, y, w, h, round]`, eased each tick
+ /// toward `params_plate_target` — `round` 0 is the plate fitted to the
+ /// rows, 1 the small circle it collapses to with no rows to show. `None`
+ /// with no plate, and before the first tick (the target is drawn then).
+ pub params_plate_shown: Option<[f32; 5]>,
pub network_opacity: f32,
/// Node-domain opacity (style.surface.graph.node.opacity) — independent of
/// the pane's network_opacity; fades node bodies/wires/ports and node text.
@@ -4122,13 +4131,74 @@ impl State {
}
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);
+ // No rows: the plate is the small circle it collapses to, and
+ // with no plate there is nothing at all.
+ return if self.params_plate { self.params_dot_rect() } else { (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)
}
+ /// The circle the params plate collapses to while there are no rows to
+ /// show: `PARAMS_DOT_D` wide, in the HUD's top right corner.
+ pub fn params_dot_rect(&self) -> (f32, f32, f32, f32) {
+ let (x, y, w, _) = self.positions[PARAM_IDX];
+ let d = PARAMS_DOT_D.min(w.max(0.0));
+ (x + w - d, y, d, d)
+ }
+
+ /// Whether the params HUD has rows to show.
+ pub fn params_have_rows(&self) -> bool {
+ self.param_row_rects().iter().any(|r| r.3 > 0.0)
+ }
+
+ /// Where the params plate is heading: `[x, y, w, h, round]`, the plate
+ /// fitted to the rows (`round` 0) or, with none, the circle (`round`
+ /// 1). `None` with the plate off or the HUD not shown.
+ pub fn params_plate_target(&self) -> Option<[f32; 5]> {
+ let (_, _, w, h) = self.positions[PARAM_IDX];
+ if !self.params_plate || w <= 0.0 || h <= 0.0 || self.pane_is_stubbed(PARAM_IDX) || !self.slots.param.visible() {
+ return None;
+ }
+ let (cx, cy, cw, ch) = self.params_claim();
+ let round = if self.params_have_rows() { 0.0 } else { 1.0 };
+ (cw > 0.0 && ch > 0.0).then_some([cx, cy, cw, ch, round])
+ }
+
+ /// Ease the drawn plate toward its target, as the drop glow eases:
+ /// exponential, so frame-rate independent. True while it moves.
+ pub fn animate_params_plate(&mut self, dt: f32) -> bool {
+ let Some(target) = self.params_plate_target() else {
+ return self.params_plate_shown.take().is_some();
+ };
+ let Some(shown) = self.params_plate_shown.as_mut() else {
+ self.params_plate_shown = Some(target);
+ return true;
+ };
+ let k = 1.0 - (-16.0 * dt.max(1e-4)).exp();
+ let mut moving = false;
+ for (s, t) in shown.iter_mut().zip(target) {
+ *s += (t - *s) * k;
+ if (t - *s).abs() > 0.3 {
+ moving = true;
+ }
+ }
+ if !moving {
+ *shown = target;
+ }
+ moving
+ }
+
+ /// The params plate as it is drawn this frame, and its corner radii.
+ pub fn params_plate_drawn(&self) -> Option<([f32; 5], f32)> {
+ let shown = self.params_plate_shown.or_else(|| self.params_plate_target())?;
+ let [_, _, w, h, round] = shown;
+ let plate_r = cce_ui::layout::plate_corner_radius();
+ let r = plate_r + ((w.min(h) * 0.5) - plate_r) * round.clamp(0.0, 1.0);
+ Some((shown, r.max(0.0)))
+ }
+
/// Whether (px, py) is the params pane's: inside `params_claim` where
/// no plate covers it, or on a dropdown it has open, which grows past
/// its rows and is drawn over everything.
@@ -4396,7 +4466,8 @@ impl State {
/// the edge as far down as the HUD claims (its rows, without its
/// plate), and nowhere a plate covers it.
pub fn on_param_resize_edge(&self, cx: f32, cy: f32) -> bool {
- if !self.show_parameters || self.pane_is_stubbed(PARAM_IDX) {
+ // Collapsed to its circle, the HUD has no edge to drag.
+ if !self.show_parameters || self.pane_is_stubbed(PARAM_IDX) || !self.params_have_rows() {
return false;
}
let (x, y, w, h) = self.params_claim();
@@ -8544,6 +8615,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// the cell tint): the checkerboard grout is off by design; the
// drop-target glow (render.rs) carries the only cell highlight.
drop_glow: None,
+ params_plate_shown: None,
network_opacity: 0.95,
node_opacity: 1.0,
node_compression: None,
@@ -12407,6 +12479,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// target — the position GLIDES between cells, alpha fades in while a
// drag is in flight and out after it ends (lingering at the last
// cell). Exponential rates are frame-rate independent.
+ // The params plate eases between the plate fitted to its rows and
+ // the circle it collapses to with none.
+ let plate_animating = self.animate_params_plate(dt);
let mut glow_animating = false;
{
let target = self.graph().drop_target_cell_rect();
@@ -12711,7 +12786,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.read_panel_offsets();
}
- tick_changed || panned || reclaimed || glow_animating || frame_moved || config_changed || light_moved
+ tick_changed || panned || reclaimed || glow_animating || plate_animating || frame_moved || config_changed || light_moved
}
/// Flush CPU-staged mesh updates to the renderer's persistent meshes.
diff --git a/src/main.rs b/src/main.rs
index f88ebbb..4a9a8ec 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2170,15 +2170,19 @@ mod tests {
let mut state = State::new(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.
+ // A HUD with no rows collapses its plate to the small circle, and
+ // claims that; with the plate off it 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);
+ if !state.params_have_rows() {
+ assert_eq!(state.params_claim(), state.params_dot_rect());
}
assert!(state.run_command("toggle_params_plate"));
assert!(!state.params_plate);
+ if !state.params_have_rows() {
+ assert_eq!(state.params_claim().3, 0.0);
+ }
state.resize(1600.0, 900.0, 1.0);
state.rebuild_positions();
state.apply_layout();
@@ -16231,6 +16235,59 @@ mod tests {
/// selection with it, rigidly, and the region travels too. Dragging a node
/// OUTSIDE the selection is the ordinary one-node drag, and collapses the
/// selection onto what was grabbed.
+ /// The params plate collapses into a small circle in the HUD's top
+ /// right corner while there are no rows to show, and grows back into
+ /// the plate fitted to the rows — eased, a tick at a time.
+ #[test]
+ fn the_params_plate_collapses_to_a_circle_with_no_rows() {
+ let mut state = State::new(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();
+ assert!(state.params_have_rows());
+ let fitted = state.params_plate_target().expect("a plate");
+ assert_eq!(fitted[4], 0.0, "the plate fitted to the rows");
+ // Settle on it.
+ while state.animate_params_plate(1.0 / 60.0) {}
+ assert_eq!(state.params_plate_shown, Some(fitted));
+
+ // Nothing selected: the target is the circle at the HUD's top right.
+ state.deselect_node();
+ state.sync_parameters_pane();
+ state.apply_layout();
+ assert!(!state.params_have_rows(), "no node, no rows");
+ let (hx, hy, hw, _) = state.positions[crate::slots::PARAM_IDX];
+ let dot = state.params_plate_target().expect("a circle");
+ let d = crate::app::PARAMS_DOT_D;
+ assert_eq!(dot, [hx + hw - d, hy, d, d, 1.0]);
+ assert_eq!(state.params_claim(), (dot[0], dot[1], dot[2], dot[3]), "the circle is the HUD's");
+ assert!(!state.on_param_resize_edge(hx, hy + 10.0), "a circle has no edge to drag");
+ // Eased: one tick goes part of the way, and it arrives.
+ assert!(state.animate_params_plate(1.0 / 60.0));
+ let mid = state.params_plate_shown.unwrap();
+ assert!(mid[2] < fitted[2] && mid[2] > dot[2], "on its way: {mid:?}");
+ let (_, r) = state.params_plate_drawn().unwrap();
+ assert!(r < mid[2].min(mid[3]) * 0.5 + 0.01);
+ let mut ticks = 0;
+ while state.animate_params_plate(1.0 / 60.0) {
+ ticks += 1;
+ assert!(ticks < 120, "it settles");
+ }
+ assert_eq!(state.params_plate_shown, Some(dot));
+ let (_, r) = state.params_plate_drawn().unwrap();
+ assert_eq!(r, d * 0.5, "a circle: corners half its side");
+
+ // The plate off: no plate at all.
+ state.params_plate = false;
+ assert!(state.params_plate_target().is_none());
+ state.animate_params_plate(1.0 / 60.0);
+ assert!(state.params_plate_shown.is_none());
+ }
+
/// A config.kdl edit to the grid's spacing shows at once, at the zoom
/// in hand: the reload re-applies the configured geometry scaled as the
/// live one was. It used to be read at startup and only zoomed after.
diff --git a/src/render.rs b/src/render.rs
index 830ad7e..81311a2 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -86,8 +86,12 @@ 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(),
+ // The plate as drawn — fitted to the rows, the circle, or on
+ // its way between them — so the rows are revealed as it grows.
+ PARAM_IDX if self.params_plate => match self.params_plate_drawn() {
+ Some(([x, y, w, h, _], r)) => return (w > 0.0 && h > 0.0).then_some((rect(x, y, w, h), r)),
+ None => return None,
+ },
PARAM_IDX => self.positions[PARAM_IDX],
SPREADSHEET_IDX => self.positions[SPREADSHEET_IDX],
_ => return None,
@@ -679,15 +683,40 @@ impl State {
// 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, _)) = ¶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);
+ //
+ // With no rows to show it collapses into a small circle in the
+ // HUD's top right corner, and grows back out of it: the plate is
+ // drawn as `params_plate_drawn` has it, eased each tick, its
+ // corners rounding from the pane's toward half its side.
+ if let Some(([fx, fy, fw, fh, round], r)) = self.params_plate_drawn() {
+ if round < 0.5 {
+ 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 (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));
+ if round <= 0.0 {
+ // Settled on the rows: the plate every pane wears.
+ append_plate_at(w, pc, rect(fx, fy, fw, fh), self.plate_focus_tint(idx), self.pane_plate_radii(fx, fy, fw, fh));
+ } else {
+ // The circle, or on its way: the DE's corner exponent
+ // eased toward 2 — circular arcs, so a plate whose
+ // corners reach half its side IS a circle and not the
+ // squircle the rest of the DE wears — and the rolled
+ // edge eased toward a dot's, as cce-browser's bar
+ // unfolds from its corner control.
+ let cs = cce_ui::layout::corner_shape();
+ let shape = cs + (2.0 - cs) * round;
+ let depth = cce_ui::colors::plate_bevel_width() + (3.0 - cce_ui::colors::plate_bevel_width()) * round;
+ pc.plate_shaped(
+ rect(fx, fy, fw, fh),
+ (r, r, r, r),
+ &cce_ui::scene::material::Material::from_fill(w.color()),
+ depth.max(0.0),
+ Some(shape),
+ );
+ }
}
let (px, py, pw, ph) = self.positions[PARAM_IDX];