git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitce2147b2053a55b64619b53ded316e5437587cb9
parent5978b59914
authorLucas Galante <[email protected]>
date2026-09-22 12:24
Graph: the node body has a size of its own, off the pitch

`style.surface.graph.node.width` / `node.height` (defaults 150 x 75, the
cell the old grid gave a node) size the body; the pitch only places it.
A denser grid moves nodes closer and does not shrink them. The
derive-from-pitch rule of the previous commit is gone; `set_node_size`
joins the controller trait so hosts scale the body with their zoom as
they scale the pitch.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/layout.rs                      | 45 +++++++++++++++++++++++++++++++++-----
 src/widget/container/content_bg.rs |  2 ++
 src/widget/display/graph.rs        | 44 ++++++++++++++-----------------------
 src/widget/mod.rs                  |  6 +++--
 4 files changed, 62 insertions(+), 35 deletions(-)

diff --git a/src/layout.rs b/src/layout.rs
index 7c1a1f7..4d325d7 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -325,6 +325,8 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
                 "style.surface.graph.spacing_x" => "graph_spacing_x",
                 "style.surface.graph.spacing_y" => "graph_spacing_y",
                 "style.surface.graph.line_width" => "graph_line_width",
+                "style.surface.graph.node.width" => "graph_node_width",
+                "style.surface.graph.node.height" => "graph_node_height",
                 "style.surface.graph.grid_snap" => "graph_grid_snap",
                 "style.surface.graph.blur" => "graph_blur",
                 "style.surface.graph.font" => "graph_font",
@@ -3450,12 +3452,12 @@ pub fn set_tree_corner_radius(radius: f32) {
 
 /// The graph grid's pitch along x: the distance from the centre of one
 /// vertical grid line to the centre of the next. It is the grid's ONE size
-/// per axis — nodes are centred on the lattice intersections, and the node
-/// body's size follows from the pitch (`Graph::node_size_for_pitch`). It
-/// replaced a cell size plus a gap (`spacing_*` was the cell, `gap_col_w` /
-/// `gap_row_h` the gap, and a step was the two added up); the defaults are
-/// what those two used to add up to, so a config that set neither draws the
-/// same lattice it did.
+/// per axis — nodes are centred on the lattice intersections. The node body
+/// has a size of its own (`graph_node_width` / `graph_node_height`), so a
+/// denser grid does not shrink the nodes. The pitch replaced a cell size
+/// plus a gap (`spacing_*` was the cell, `gap_col_w` / `gap_row_h` the gap,
+/// and a step was the two added up); the defaults are what those two used
+/// to add up to, so a config that set neither draws the same lattice it did.
 pub fn graph_spacing_x() -> f32 {
     lazy_init_style_registry();
     get_style_registry().read().unwrap().get_float("graph_spacing_x").unwrap_or(187.5)
@@ -3497,6 +3499,35 @@ pub fn set_graph_line_width(width: f32) {
     }
 }
 
+/// The node body's width at 100% zoom (`style.surface.graph.node.width`),
+/// independent of the grid pitch: a node is a thing of its own size sitting
+/// on a crossing, and the grid is a reference under it. The default is the
+/// cell the old grid gave a node.
+pub fn graph_node_width() -> f32 {
+    lazy_init_style_registry();
+    get_style_registry().read().unwrap().get_float("graph_node_width").unwrap_or(150.0)
+}
+
+pub fn set_graph_node_width(width: f32) {
+    lazy_init_style_registry();
+    if let Ok(mut registry) = get_style_registry().write() {
+        registry.set_float("graph_node_width", width);
+    }
+}
+
+/// The node body's height at 100% zoom — see [`graph_node_width`].
+pub fn graph_node_height() -> f32 {
+    lazy_init_style_registry();
+    get_style_registry().read().unwrap().get_float("graph_node_height").unwrap_or(75.0)
+}
+
+pub fn set_graph_node_height(height: f32) {
+    lazy_init_style_registry();
+    if let Ok(mut registry) = get_style_registry().write() {
+        registry.set_float("graph_node_height", height);
+    }
+}
+
 pub fn graph_grid_snap() -> bool {
     lazy_init_style_registry();
     get_style_registry().read().unwrap().get_float("graph_grid_snap").unwrap_or(0.0) != 0.0
@@ -6928,6 +6959,8 @@ mod tests {
         assert_eq!(graph_spacing_x(), stored("graph_spacing_x").unwrap_or(187.5));
         assert_eq!(graph_spacing_y(), stored("graph_spacing_y").unwrap_or(112.5));
         assert_eq!(graph_line_width(), stored("graph_line_width").unwrap_or(1.0));
+        assert_eq!(graph_node_width(), stored("graph_node_width").unwrap_or(150.0));
+        assert_eq!(graph_node_height(), stored("graph_node_height").unwrap_or(75.0));
         assert_eq!(graph_grid_snap(), stored("graph_grid_snap").unwrap_or(0.0) != 0.0);
         assert_eq!(graph_blur(), stored("graph_blur").unwrap_or(0.0));
         assert_eq!(graph_node_corner_radius(), stored("graph_node_corner_radius").unwrap_or(4.0));
diff --git a/src/widget/container/content_bg.rs b/src/widget/container/content_bg.rs
index b55d443..bc700ec 100644
--- a/src/widget/container/content_bg.rs
+++ b/src/widget/container/content_bg.rs
@@ -267,6 +267,8 @@ impl GraphController for ContentBg {
         self.grid_size_x = (px - self.skipped_col_w).max(0.0);
         self.grid_size_y = (py - self.skipped_row_h).max(0.0);
     }
+    /// No nodes here; the cell-and-gap gradient has no body to size.
+    fn set_node_size(&mut self, _w: f32, _h: f32) {}
     fn set_grid_sizes(&mut self, gx: f32, gy: f32) { self.grid_size_x = gx; self.grid_size_y = gy; }
     fn set_skipped_sizes(&mut self, row_h: f32, col_w: f32) { self.skipped_row_h = row_h; self.skipped_col_w = col_w; }
     fn set_grid_origin(&mut self, ox: f32, oy: f32) { self.grid_origin_x = ox; self.grid_origin_y = oy; }
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index 8852310..d933ada 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -13,8 +13,8 @@
 //! The grid is a LATTICE OF LINES with one size per axis — the pitch, from the centre of
 //! one line to the centre of the next — and a node is centred on the intersection its
 //! `position` names: node (c, r) sits on `grid_origin + (c * pitch_x, r * pitch_y)`. The
-//! node body's size follows from the pitch ([`Graph::node_size_for_pitch`]). It used to be
-//! a grid of CELLS — a cell size that was also the node size, plus a gap between cells,
+//! node body has a size of its own (`graph_node_width` / `graph_node_height`, scaled with
+//! the zoom), independent of the pitch. It used to be a grid of CELLS — a cell size that was also the node size, plus a gap between cells,
 //! with a node filling its cell — and the cell-and-gap setters survive as a description of
 //! the same lattice for hosts that still speak it (a cell plus its gap is a pitch).
 
@@ -68,8 +68,8 @@ pub struct Graph {
     /// axis. The grid's one size.
     pitch_x: f32,
     pitch_y: f32,
-    /// The node body's size — derived from the pitch by `set_grid_pitch`,
-    /// or set outright by `set_node_size` and the cell-model setters.
+    /// The node body's size — its own (`graph_node_width` / `_height` at
+    /// 100%), independent of the pitch; the cell-model setters set it too.
     node_w: f32,
     node_h: f32,
     /// The lattice intersection node (0, 0) is centred on, window-absolute.
@@ -134,7 +134,8 @@ impl Graph {
 
         let pitch_x = crate::layout::graph_spacing_x();
         let pitch_y = crate::layout::graph_spacing_y();
-        let (node_w, node_h) = Self::node_size_for_pitch(pitch_x, pitch_y);
+        let node_w = crate::layout::graph_node_width();
+        let node_h = crate::layout::graph_node_height();
         let grid_snap_enabled = crate::layout::graph_grid_snap();
 
         let cell_col = crate::color::graph_cell_color();
@@ -185,20 +186,6 @@ impl Graph {
     pub fn set_node_opacity(&mut self, opacity: f32) {
         self.node_opacity = opacity;
     }
-    /// The node body's width as a share of the x pitch, and its height as a
-    /// share of the y pitch. A fifth of the pitch is left between neighbours
-    /// in a row; a third between rows, because that is where the ports float
-    /// and the wires run. They are the proportions the cell grid had (a 150
-    /// cell in a 187.5 step, a 75 cell in a 112.5 step), so nothing moved.
-    pub const NODE_W_OF_PITCH: f32 = 0.8;
-    pub const NODE_H_OF_PITCH: f32 = 2.0 / 3.0;
-
-    /// The node body's size for a grid pitch — the ONE rule, so a host's
-    /// cursor and the widget's nodes cannot disagree about it.
-    pub fn node_size_for_pitch(pitch_x: f32, pitch_y: f32) -> (f32, f32) {
-        (pitch_x * Self::NODE_W_OF_PITCH, pitch_y * Self::NODE_H_OF_PITCH)
-    }
-
     /// The pitch: centre of one grid line to the centre of the next, per axis.
     pub fn grid_pitch(&self) -> (f32, f32) {
         (self.pitch_x, self.pitch_y)
@@ -207,8 +194,8 @@ impl Graph {
     pub fn node_size(&self) -> (f32, f32) {
         (self.node_w, self.node_h)
     }
-    /// Set the node body's size outright, off the pitch's rule — what the
-    /// cell-model setters do, since there the cell IS the node.
+    /// Set the node body's size — what the cell-model setters do too, since
+    /// there the cell IS the node.
     pub fn set_node_size(&mut self, w: f32, h: f32) {
         self.node_w = w;
         self.node_h = h;
@@ -1289,9 +1276,8 @@ impl GraphController for Graph {
     fn set_grid_pitch(&mut self, px: f32, py: f32) {
         self.pitch_x = px;
         self.pitch_y = py;
-        let (w, h) = Self::node_size_for_pitch(px, py);
-        self.set_node_size(w, h);
     }
+    fn set_node_size(&mut self, w: f32, h: f32) { Graph::set_node_size(self, w, h) }
     /// Cell model: the cell is the node body, and the gap it had stays, so
     /// the two setters commute (a cell plus its gap is a pitch).
     fn set_grid_sizes(&mut self, gx: f32, gy: f32) {
@@ -1337,6 +1323,7 @@ mod tests {
         let mut g = Graph::new();
         WidgetHost::set_rect(&mut g, 0.0, 0.0, 800.0, 600.0);
         g.set_grid_pitch(100.0, 60.0);
+        g.set_node_size(80.0, 40.0);
         g.set_grid_origin(140.0, 120.0);
         g.set_grid_snap_enabled(true);
         let node = |id: &str, name: &str, col: f32, row: f32| GraphNode {
@@ -1427,6 +1414,7 @@ mod tests {
         let mut g = Graph::new();
         WidgetHost::set_rect(&mut g, 0.0, 0.0, 800.0, 600.0);
         g.set_grid_pitch(100.0, 60.0);
+        g.set_node_size(80.0, 40.0);
         g.set_grid_origin(140.0, 120.0);
         g.set_grid_snap_enabled(true);
         let node = |id: &str, name: &str, col: f32, row: f32, params: Vec<(String, String, String)>| GraphNode {
@@ -1476,14 +1464,16 @@ mod tests {
 
     /// The grid has one size per axis, the pitch, and a node is CENTRED on
     /// the intersection its position names — its body straddles the lines
-    /// rather than filling a cell between them. The body's size follows
-    /// from the pitch by one rule the hosts read too.
+    /// rather than filling a cell between them. The body's size is its own:
+    /// changing the pitch moves nodes apart without resizing them.
     #[test]
     fn nodes_are_centred_on_lattice_intersections() {
-        let g = two_nodes();
+        let mut g = two_nodes();
         assert_eq!(g.grid_pitch(), (100.0, 60.0));
-        assert_eq!(g.node_size(), Graph::node_size_for_pitch(100.0, 60.0));
         assert_eq!(g.node_size(), (80.0, 40.0));
+        g.set_grid_pitch(200.0, 90.0);
+        assert_eq!(g.node_size(), (80.0, 40.0), "the pitch does not size the node");
+        g.set_grid_pitch(100.0, 60.0);
         // Node a is on the (140, 120) intersection: its 80 x 40 body is
         // centred there.
         let (x, y, w, h) = g.node_rect(0).unwrap();
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index b43b5a1..eda59f8 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -624,9 +624,11 @@ pub trait GraphController {
     fn set_grid_snap(&mut self, gx: f32, gy: f32);
     /// The grid's ONE size per axis: the pitch, from the centre of one grid
     /// line to the centre of the next. Nodes are centred on the lattice
-    /// intersections, and the node body's size follows from the pitch
-    /// (`Graph::node_size_for_pitch`).
+    /// intersections. Leaves the node size alone.
     fn set_grid_pitch(&mut self, px: f32, py: f32);
+    /// The node body's size, independent of the pitch — hosts scale it with
+    /// their zoom as they scale the pitch.
+    fn set_node_size(&mut self, w: f32, h: f32);
     /// The older cell-and-gap description of the same lattice — a cell plus
     /// its gap is a pitch, and the node body is the cell. Kept for hosts
     /// that still speak it (cce-files, cce-graph); new code sets the pitch.