GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Graph: one grid pitch per axis; nodes centred on the lattice crossings
The network grid had two sizes per axis — a cell (also the node body) and
a gap between cells, with a node filling its cell. It now has ONE: the
pitch, from the centre of one grid line to the centre of the next
(`style.surface.graph.spacing_x` / `spacing_y`, defaulting to what cell
plus gap used to add up to). A node's position names a lattice
intersection and its body is centred on it; the body's size follows from
the pitch by one rule, `Graph::node_size_for_pitch` (four fifths of the x
pitch, two thirds of the y — the old proportions), which hosts read too.
The grid paints as lines (gap colour, network opacity, the new
`style.surface.graph.line_width`, each centred on its coordinate so the
width moves nothing) instead of rounded cells with grout; the origin axes
are the two lines through the (0, 0) crossing. Snapping, the drop-target
rect and the drop itself share one nearest-crossing rule.
`set_grid_pitch` is the new setter; `set_grid_sizes` / `set_skipped_sizes`
survive as the cell-and-gap description of the same lattice (a cell plus
its gap is a pitch, the cell is the node body, and they commute), so
cce-files and cce-graph build and place exactly as before.
`graph_gap_col_w` / `graph_gap_row_h` are gone.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
src/layout.rs | 48 +++--
src/widget/container/content_bg.rs | 6 +
src/widget/display/graph.rs | 369 +++++++++++++++++++++++--------------
src/widget/mod.rs | 15 +-
4 files changed, 273 insertions(+), 165 deletions(-)
diff --git a/src/layout.rs b/src/layout.rs
index 297a129..7c1a1f7 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -324,8 +324,7 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
"style.surface.graph.node.opacity" => "graph_node_opacity",
"style.surface.graph.spacing_x" => "graph_spacing_x",
"style.surface.graph.spacing_y" => "graph_spacing_y",
- "style.surface.graph.gap_col_w" => "graph_gap_col_w",
- "style.surface.graph.gap_row_h" => "graph_gap_row_h",
+ "style.surface.graph.line_width" => "graph_line_width",
"style.surface.graph.grid_snap" => "graph_grid_snap",
"style.surface.graph.blur" => "graph_blur",
"style.surface.graph.font" => "graph_font",
@@ -3449,9 +3448,17 @@ 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.
pub fn graph_spacing_x() -> f32 {
lazy_init_style_registry();
- get_style_registry().read().unwrap().get_float("graph_spacing_x").unwrap_or(150.0)
+ get_style_registry().read().unwrap().get_float("graph_spacing_x").unwrap_or(187.5)
}
pub fn set_graph_spacing_x(spacing: f32) {
@@ -3461,9 +3468,10 @@ pub fn set_graph_spacing_x(spacing: f32) {
}
}
+/// The graph grid's pitch along y — see [`graph_spacing_x`].
pub fn graph_spacing_y() -> f32 {
lazy_init_style_registry();
- get_style_registry().read().unwrap().get_float("graph_spacing_y").unwrap_or(75.0)
+ get_style_registry().read().unwrap().get_float("graph_spacing_y").unwrap_or(112.5)
}
pub fn set_graph_spacing_y(spacing: f32) {
@@ -3473,30 +3481,19 @@ pub fn set_graph_spacing_y(spacing: f32) {
}
}
-/// The gap between grid cells, the companion to `graph_spacing_*` (which is the cell
-/// itself). One node slot to the next is the two added up. Defaults match `ContentBg`'s
-/// own, i.e. a quarter of the cell.
-pub fn graph_gap_col_w() -> f32 {
+/// The drawn width of a graph grid line, in logical px. The pitch is
+/// measured centre to centre, so this changes how heavy the lattice looks
+/// and nothing about where anything sits. Not scaled by zoom — a lattice is
+/// a reference, not a thing in the scene.
+pub fn graph_line_width() -> f32 {
lazy_init_style_registry();
- get_style_registry().read().unwrap().get_float("graph_gap_col_w").unwrap_or(37.5)
+ get_style_registry().read().unwrap().get_float("graph_line_width").unwrap_or(1.0)
}
-pub fn set_graph_gap_col_w(gap: f32) {
+pub fn set_graph_line_width(width: f32) {
lazy_init_style_registry();
if let Ok(mut registry) = get_style_registry().write() {
- registry.set_float("graph_gap_col_w", gap);
- }
-}
-
-pub fn graph_gap_row_h() -> f32 {
- lazy_init_style_registry();
- get_style_registry().read().unwrap().get_float("graph_gap_row_h").unwrap_or(37.5)
-}
-
-pub fn set_graph_gap_row_h(gap: f32) {
- lazy_init_style_registry();
- if let Ok(mut registry) = get_style_registry().write() {
- registry.set_float("graph_gap_row_h", gap);
+ registry.set_float("graph_line_width", width);
}
}
@@ -6928,8 +6925,9 @@ mod tests {
reg.get_float(key)
}
- assert_eq!(graph_spacing_x(), stored("graph_spacing_x").unwrap_or(150.0));
- assert_eq!(graph_spacing_y(), stored("graph_spacing_y").unwrap_or(75.0));
+ 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_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 65d0ece..b55d443 100644
--- a/src/widget/container/content_bg.rs
+++ b/src/widget/container/content_bg.rs
@@ -261,6 +261,12 @@ impl GraphController for ContentBg {
fn set_grid_snap_enabled(&mut self, _enabled: bool) {}
fn take_node_geom_toggle(&mut self) -> Option<(usize, bool)> { None }
fn set_grid_snap(&mut self, _gx: f32, _gy: f32) {}
+ /// This background keeps its cell-and-gap gradient; a pitch lands as a
+ /// cell of pitch-minus-gap, so the bands still add up to the pitch.
+ fn set_grid_pitch(&mut self, px: f32, py: f32) {
+ self.grid_size_x = (px - self.skipped_col_w).max(0.0);
+ self.grid_size_y = (py - self.skipped_row_h).max(0.0);
+ }
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 f0b6431..8852310 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -9,6 +9,14 @@
//! `all_quads` emptied by the adapter so no host draws it twice. Node-name text is clipped to
//! the widget rect via [`Paint::text_bounds`]. All grid geometry is in absolute screen space
//! (hosts pan by moving `grid_origin`); the widget rect only culls and clips.
+//!
+//! 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,
+//! 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).
use crate::colors;
use crate::scene::layout::Rect;
@@ -56,15 +64,20 @@ const WIDGET_CORNERS: (bool, bool, bool, bool) = (false, false, true, true);
pub struct Graph {
show_network_grid: bool,
- grid_size_x: f32,
- grid_size_y: f32,
+ /// The pitch: centre of one grid line to the centre of the next, per
+ /// 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.
+ node_w: f32,
+ node_h: f32,
+ /// The lattice intersection node (0, 0) is centred on, window-absolute.
grid_origin_x: f32,
grid_origin_y: f32,
/// Smooth-scroll driver behind the pan origin: notches glide, a trackpad
/// flick coasts across the unbounded canvas.
pan_motion: crate::widget::ScrollMotion,
- skipped_row_h: f32,
- skipped_col_w: f32,
nodes: Vec<GraphNode>,
selected_idx: Option<usize>,
selected_id: Option<String>,
@@ -119,8 +132,9 @@ impl Graph {
pub fn new() -> Adapted<Graph> {
crate::layout::lazy_init_style_registry();
- let grid_size_x = crate::layout::graph_spacing_x();
- let grid_size_y = crate::layout::graph_spacing_y();
+ 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 grid_snap_enabled = crate::layout::graph_grid_snap();
let cell_col = crate::color::graph_cell_color();
@@ -128,13 +142,13 @@ impl Graph {
Adapted::new(Graph {
show_network_grid: false,
- grid_size_x,
- grid_size_y,
+ pitch_x,
+ pitch_y,
+ node_w,
+ node_h,
grid_origin_x: 0.0,
grid_origin_y: 0.0,
pan_motion: crate::widget::ScrollMotion::new(),
- skipped_row_h: grid_size_y / 2.0,
- skipped_col_w: grid_size_x / 2.0,
nodes: Vec::new(),
selected_idx: None,
selected_id: None,
@@ -171,11 +185,42 @@ 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)
+ }
+ /// The node body's size at the current zoom.
+ 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.
+ pub fn set_node_size(&mut self, w: f32, h: f32) {
+ self.node_w = w;
+ self.node_h = h;
+ }
+ /// The cell-model view of the lattice: the node body (its "cell").
pub fn grid_sizes(&self) -> (f32, f32) {
- (self.grid_size_x, self.grid_size_y)
+ (self.node_w, self.node_h)
}
+ /// The cell-model view of the lattice: what a pitch has beyond the node
+ /// body, as (row gap, column gap) — the order `set_skipped_sizes` takes.
pub fn skipped_sizes(&self) -> (f32, f32) {
- (self.skipped_row_h, self.skipped_col_w)
+ (self.pitch_y - self.node_h, self.pitch_x - self.node_w)
}
pub fn grid_origin(&self) -> (f32, f32) {
(self.grid_origin_x, self.grid_origin_y)
@@ -190,44 +235,50 @@ impl Graph {
self.gap_color = color;
}
+ /// The top-left corner of a node body centred on lattice cell (col, row).
+ fn cell_origin(&self, col: f32, row: f32) -> (f32, f32) {
+ (
+ self.grid_origin_x + col * self.pitch_x - self.node_w * 0.5,
+ self.grid_origin_y + row * self.pitch_y - self.node_h * 0.5,
+ )
+ }
+
+ /// The lattice cell whose intersection is nearest the CENTRE of a node
+ /// body whose top-left is (nx, ny) — the one snapping rule, shared by the
+ /// drag preview, the drop-target highlight and the drop itself. None on a
+ /// degenerate pitch.
+ fn nearest_cell(&self, nx: f32, ny: f32) -> Option<(f32, f32)> {
+ if self.pitch_x <= 0.0 || self.pitch_y <= 0.0 {
+ return None;
+ }
+ let c = ((nx + self.node_w * 0.5 - self.grid_origin_x) / self.pitch_x).round();
+ let r = ((ny + self.node_h * 0.5 - self.grid_origin_y) / self.pitch_y).round();
+ Some((c, r))
+ }
+
pub fn node_rect(&self, idx: usize) -> Option<(f32, f32, f32, f32)> {
let node = self.nodes.get(idx)?;
+ let at_cell = self.cell_origin(node.position.0, node.position.1);
let (nx, ny) = if self.dragging_idx == Some(idx) {
- self.drag_node_pos.unwrap_or((
- node.position.0 * (self.grid_size_x + self.skipped_col_w) + self.grid_origin_x,
- node.position.1 * (self.grid_size_y + self.skipped_row_h) + self.grid_origin_y,
- ))
+ self.drag_node_pos.unwrap_or(at_cell)
} else {
- (
- node.position.0 * (self.grid_size_x + self.skipped_col_w) + self.grid_origin_x,
- node.position.1 * (self.grid_size_y + self.skipped_row_h) + self.grid_origin_y,
- )
+ at_cell
};
- Some((nx, ny, self.grid_size_x, self.grid_size_y))
+ Some((nx, ny, self.node_w, self.node_h))
}
- /// The grid cell an in-flight node drag will deposit on, as its pixel
- /// rect — hosts highlight it as the drop target. Runs the SAME
- /// resolution as `commit_drag` (round to the nearest cell, then
- /// `find_empty_cell` walks off occupied ones), so the highlight never
- /// lies about where the node actually lands. None outside a node drag.
+ /// The rect the in-flight node drag will deposit its body on — hosts
+ /// highlight it as the drop target. Runs the SAME resolution as
+ /// `commit_drag` (nearest intersection, then `find_empty_cell` walks off
+ /// occupied ones), so the highlight never lies about where the node
+ /// actually lands. None outside a node drag.
pub fn drop_target_cell_rect(&self) -> Option<(f32, f32, f32, f32)> {
let idx = self.dragging_idx?;
let (nx, ny) = self.drag_node_pos?;
- let step_x = self.grid_size_x + self.skipped_col_w;
- let step_y = self.grid_size_y + self.skipped_row_h;
- if step_x <= 0.0 || step_y <= 0.0 {
- return None;
- }
- let c = ((nx - self.grid_origin_x) / step_x).round();
- let r = ((ny - self.grid_origin_y) / step_y).round();
+ let (c, r) = self.nearest_cell(nx, ny)?;
let (c, r) = self.find_empty_cell(c, r, Some(idx));
- Some((
- self.grid_origin_x + c * step_x,
- self.grid_origin_y + r * step_y,
- self.grid_size_x,
- self.grid_size_y,
- ))
+ let (x, y) = self.cell_origin(c, r);
+ Some((x, y, self.node_w, self.node_h))
}
pub fn is_node_rect(&self, qx: f32, qy: f32, qw: f32, qh: f32) -> bool {
@@ -312,31 +363,31 @@ impl Graph {
(x, y)
}
+ /// Scale the lattice and the node bodies together; the limits are on the
+ /// node width, as they always were.
+ fn scale_by(&mut self, factor: f32) {
+ self.pitch_x *= factor;
+ self.pitch_y *= factor;
+ self.node_w *= factor;
+ self.node_h *= factor;
+ }
+
pub fn zoom_in(&mut self) {
- if self.grid_size_x < 400.0 {
- self.grid_size_x *= 1.1;
- self.grid_size_y *= 1.1;
- self.skipped_row_h *= 1.1;
- self.skipped_col_w *= 1.1;
+ if self.node_w < 400.0 {
+ self.scale_by(1.1);
}
}
pub fn zoom_out(&mut self) {
- if self.grid_size_x > 40.0 {
- self.grid_size_x /= 1.1;
- self.grid_size_y /= 1.1;
- self.skipped_row_h /= 1.1;
- self.skipped_col_w /= 1.1;
+ if self.node_w > 40.0 {
+ self.scale_by(1.0 / 1.1);
}
}
pub fn zoom_by_factor(&mut self, factor: f32) {
- let new_grid_x = self.grid_size_x * factor;
- if new_grid_x >= 40.0 && new_grid_x <= 400.0 {
- self.grid_size_x = new_grid_x;
- self.grid_size_y *= factor;
- self.skipped_row_h *= factor;
- self.skipped_col_w *= factor;
+ let new_w = self.node_w * factor;
+ if new_w >= 40.0 && new_w <= 400.0 {
+ self.scale_by(factor);
}
}
@@ -360,44 +411,37 @@ impl Graph {
m.fill(PlateRole::Nested)
}
- /// The grid cells' rounded-corner radius at the current zoom: the desktop
- /// grid's corner PROPORTION — the DE root plate radius per 512 virtual
- /// units of cell, span-widened (cce-grid draws exactly this on its
- /// 512vu cells) — applied to this grid's cell size, so the pane's cells
- /// read as miniatures of the desktop's. Clamped to a quarter sweep;
- /// 0 when the grid is off or degenerate.
+ /// The corner radius of anything node-shaped at the current zoom — the
+ /// hosts' empty-cell cursor and drop-target highlight read it. Clamped to
+ /// a quarter sweep of the node body; 0 when the body is degenerate.
pub fn cell_corner_radius(&self) -> f32 {
- // Pure cell GEOMETRY — no display gating: hosts read this for
- // anything cell-shaped (the empty-cell cursor, drop-target
- // highlights), which exist whether or not the cell tiles render.
- // Gating on show_network_grid/uniform_background silently squared
- // those consumers whenever the tiles were hidden.
- if self.grid_size_x <= 0.0 || self.grid_size_y <= 0.0 {
+ // Pure GEOMETRY — no display gating: the cursor and the highlight
+ // exist whether or not the lattice is drawn. Gating on
+ // show_network_grid/uniform_background silently squared those
+ // consumers whenever the grid was hidden.
+ if self.node_w <= 0.0 || self.node_h <= 0.0 {
return 0.0;
}
- let cell = self.grid_size_x.min(self.grid_size_y);
- // The NODE radius, not a scaled-down plate radius: nodes, grid cells
- // and the hosts' cell cursors (the designer's empty-cell cursor reads
- // this) share one corner language. The old derivation
- // (plate_corner_radius * cell/512 * span) gave cells a different,
- // grid-size-dependent rounding that never matched the node bodies
- // sitting on them.
- crate::layout::graph_node_corner_radius().min(cell / 2.0)
+ let body = self.node_w.min(self.node_h);
+ // The NODE radius, so the cursor sitting on a node's cell traces the
+ // same silhouette the node does.
+ crate::layout::graph_node_corner_radius().min(body / 2.0)
}
/// The grid lines, flat, over whatever the graph is painted on — the
- /// pane plate: the cells are the plate showing through (no fill of their
- /// own), and everything outside them is grout in the gap colour at the
- /// network opacity — ONE [`Prim::Grout`] draw, so the cells' rounded
- /// corners (the node corner radius, the DE's shared corner language) are
- /// exact, notch at every crossing included. Plus the origin axes as 2px
- /// lines down the rail centrelines beside row and column 0. Gated on the
- /// grid's visibility only: `uniform_background` describes the widget's
- /// own background fill and the designer hard-codes it true, which is how
- /// its grid went undrawn until 2026-09-20. (Flat per-cell strips came
- /// between: seamless, but square-cornered — no quad can paint the notch.)
+ /// pane plate. A lattice of lines one pitch apart in the gap colour at
+ /// the network opacity, each centred on its coordinate (the pitch is
+ /// measured centre to centre, and `graph_line_width` only thickens
+ /// them), so the intersections are exactly where the node centres go.
+ /// Plus the origin axes: the two lines through the (0, 0) intersection,
+ /// 2px, in the axis colour. Gated on the grid's visibility only:
+ /// `uniform_background` describes the widget's own background fill and
+ /// the designer hard-codes it true, which is how its grid went undrawn
+ /// until 2026-09-20. (Rounded cells with grout between them came before
+ /// the lattice; a node then FILLED a cell rather than sitting on a
+ /// crossing.)
pub fn paint_grid(&self, rect: Rect, pc: &mut PaintCtx) {
- if self.grid_size_x <= 0.0 || self.grid_size_y <= 0.0 {
+ if self.pitch_x <= 0.0 || self.pitch_y <= 0.0 {
return;
}
let (min_x, min_y) = (rect.x, rect.y);
@@ -412,32 +456,35 @@ impl Graph {
}
};
- let step_x = self.grid_size_x + self.skipped_col_w;
- let step_y = self.grid_size_y + self.skipped_row_h;
- if self.show_network_grid && step_x >= 4.0 && step_y >= 4.0 {
- let gap_rgba = [self.gap_color[0], self.gap_color[1], self.gap_color[2], self.network_opacity];
- pc.grout(
- rect,
- (step_x, step_y),
- (self.grid_origin_x + self.grid_size_x * 0.5, self.grid_origin_y + self.grid_size_y * 0.5),
- (self.grid_size_x, self.grid_size_y),
- self.cell_corner_radius(),
- gap_rgba,
- );
+ let line = crate::layout::graph_line_width().max(0.0);
+ if self.show_network_grid && line > 0.0 && self.pitch_x >= 4.0 && self.pitch_y >= 4.0 {
+ let color = [self.gap_color[0], self.gap_color[1], self.gap_color[2], self.network_opacity];
+ // The line indices that can cross the rect, one past each edge so
+ // a line's own width never pops at the boundary.
+ let c0 = ((min_x - self.grid_origin_x) / self.pitch_x).floor() as i32 - 1;
+ let c1 = ((max_x - self.grid_origin_x) / self.pitch_x).ceil() as i32 + 1;
+ for c in c0..=c1 {
+ let x = self.grid_origin_x + c as f32 * self.pitch_x;
+ clipped(x - line / 2.0, rect.y, line, rect.height, color, pc);
+ }
+ let r0 = ((min_y - self.grid_origin_y) / self.pitch_y).floor() as i32 - 1;
+ let r1 = ((max_y - self.grid_origin_y) / self.pitch_y).ceil() as i32 + 1;
+ for r in r0..=r1 {
+ let y = self.grid_origin_y + r as f32 * self.pitch_y;
+ clipped(rect.x, y - line / 2.0, rect.width, line, color, pc);
+ }
}
- // Origin axes, in the gaps beside row/column 0.
+ // Origin axes: the lattice lines through the (0, 0) intersection.
let axis = [0.0, 0.0, 0.0, self.network_opacity];
let thickness = 2.0;
- let y_center = self.grid_origin_y + self.grid_size_y + self.skipped_row_h / 2.0;
- clipped(rect.x, y_center - thickness / 2.0, rect.width, thickness, axis, pc);
- let x_center = self.grid_origin_x - self.skipped_col_w / 2.0;
- clipped(x_center - thickness / 2.0, rect.y, thickness, rect.height, axis, pc);
+ clipped(rect.x, self.grid_origin_y - thickness / 2.0, rect.width, thickness, axis, pc);
+ clipped(self.grid_origin_x - thickness / 2.0, rect.y, thickness, rect.height, axis, pc);
}
- /// The wires / connection preview / grid cells / axes / node bodies / toggles as plain
- /// quads — the legacy `extra_quads` body, against `rect` instead of a stored rect —
- /// with grid cells tagged by their surviving rounded corners (see [`TaggedQuad`]).
+ /// The wires / connection preview / node bodies / toggles as plain quads — the legacy
+ /// `extra_quads` body, against `rect` instead of a stored rect. The [`TaggedQuad`] cell
+ /// tag is always `None` now: the lattice is `paint_grid`'s, and it has no cells.
pub fn geometry_quads_tagged(&self, rect: Rect) -> Vec<TaggedQuad> {
let mut quads = Vec::new();
let min_x = rect.x;
@@ -457,7 +504,7 @@ impl Graph {
};
// A three-segment orthogonal wire from (start_x, start_y) down/up to (end_x, end_y).
- let scale_f = self.grid_size_x / 80.0;
+ let scale_f = self.node_w / 80.0;
let wire_thickness = (3.0 * scale_f).clamp(1.0, 15.0);
let push_wire = |start_x: f32, start_y: f32, end_x: f32, end_y: f32, color: [f32; 4], q: &mut Vec<TaggedQuad>| {
let mid_y = start_y + (end_y - start_y) / 2.0;
@@ -923,21 +970,10 @@ impl Input for Graph {
let nx = px - self.drag_ox;
let ny = py - self.drag_oy;
- let snap_x = if self.grid_snap_enabled { self.grid_size_x + self.skipped_col_w } else { 0.0 };
- let snap_y = if self.grid_snap_enabled { self.grid_size_y + self.skipped_row_h } else { 0.0 };
-
- let nx = if snap_x > 0.0 {
- let relative = nx - self.grid_origin_x;
- (relative / snap_x).round() * snap_x + self.grid_origin_x
- } else {
- nx
- };
-
- let ny = if snap_y > 0.0 {
- let relative = ny - self.grid_origin_y;
- (relative / snap_y).round() * snap_y + self.grid_origin_y
- } else {
- ny
+ // Snapping centres the body on the nearest intersection.
+ let (nx, ny) = match self.nearest_cell(nx, ny) {
+ Some((c, r)) if self.grid_snap_enabled => self.cell_origin(c, r),
+ _ => (nx, ny),
};
self.drag_node_pos = Some((nx, ny));
@@ -1113,7 +1149,7 @@ impl Graph {
/// thickness — `push_wire`'s exact shape, unclipped.
fn wire_segment_rects(&self, src_idx: usize, dest_idx: usize) -> Option<[(f32, f32, f32, f32); 3]> {
let ((start_x, start_y), (end_x, end_y)) = self.wire_endpoints(src_idx, dest_idx)?;
- let scale_f = self.grid_size_x / 80.0;
+ let scale_f = self.node_w / 80.0;
let t = (3.0 * scale_f).clamp(1.0, 15.0);
let mid_y = start_y + (end_y - start_y) / 2.0;
let v1 = (start_x - t / 2.0, start_y.min(mid_y), t, (start_y - mid_y).abs());
@@ -1156,16 +1192,16 @@ impl Graph {
None
}
- /// Drop the in-flight node drag onto the nearest free grid cell (legacy `drag_end`),
+ /// Drop the in-flight node drag onto the nearest free intersection (legacy `drag_end`),
/// resolving a held splice target into `pending_splice` for the host.
fn commit_drag(&mut self) {
let target = self.splice_target.take();
if let Some((nx, ny)) = self.drag_node_pos.take() {
- let c = ((nx - self.grid_origin_x) / (self.grid_size_x + self.skipped_col_w)).round();
- let r = ((ny - self.grid_origin_y) / (self.grid_size_y + self.skipped_row_h)).round();
if let Some(idx) = self.dragging_idx.take() {
- let (nx, ny) = self.find_empty_cell(c, r, Some(idx));
- self.nodes[idx].position = (nx, ny);
+ if let Some((c, r)) = self.nearest_cell(nx, ny) {
+ let (c, r) = self.find_empty_cell(c, r, Some(idx));
+ self.nodes[idx].position = (c, r);
+ }
if let Some((src_id, dest_id)) = target {
let src_name = self.nodes.iter().find(|n| n.id == src_id).map(|n| n.name.clone());
let dest_ok = self.nodes.iter().any(|n| n.id == dest_id);
@@ -1249,9 +1285,25 @@ impl GraphController for Graph {
fn clear_double_clicked_node(&mut self) { self.double_clicked_id = None; }
fn set_grid_snap_enabled(&mut self, enabled: bool) { self.grid_snap_enabled = enabled; }
fn take_node_geom_toggle(&mut self) -> Option<(usize, bool)> { self.node_geom_toggled.take() }
- fn set_grid_snap(&mut self, gx: f32, gy: f32) { self.grid_size_x = gx; self.grid_size_y = gy; }
- 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_snap(&mut self, gx: f32, gy: f32) { self.set_grid_sizes(gx, gy) }
+ 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);
+ }
+ /// 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) {
+ let (gap_row, gap_col) = self.skipped_sizes();
+ self.set_node_size(gx, gy);
+ self.pitch_x = gx + gap_col;
+ self.pitch_y = gy + gap_row;
+ }
+ fn set_skipped_sizes(&mut self, row_h: f32, col_w: f32) {
+ self.pitch_x = self.node_w + col_w;
+ self.pitch_y = self.node_h + row_h;
+ }
fn set_grid_origin(&mut self, ox: f32, oy: f32) { self.grid_origin_x = ox; self.grid_origin_y = oy; }
fn grid_origin(&self) -> (f32, f32) { (self.grid_origin_x, self.grid_origin_y) }
fn set_show_network_grid(&mut self, show: bool) { self.show_network_grid = show; }
@@ -1278,12 +1330,14 @@ mod tests {
use crate::context::UiContext;
use crate::widget::WidgetHost;
+ /// A 100 x 60 lattice whose (0, 0) intersection is at (140, 120), so node
+ /// a's 80 x 40 body is the rect (100, 100, 80, 40) and node b's, one cell
+ /// down-right, is (200, 160, 80, 40).
fn two_nodes() -> Adapted<Graph> {
let mut g = Graph::new();
WidgetHost::set_rect(&mut g, 0.0, 0.0, 800.0, 600.0);
- g.set_grid_sizes(80.0, 40.0);
- g.set_skipped_sizes(20.0, 20.0);
- g.set_grid_origin(100.0, 100.0);
+ g.set_grid_pitch(100.0, 60.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 {
id: id.into(),
@@ -1350,7 +1404,7 @@ mod tests {
assert_eq!(g.selected_node(), Some(0));
assert!(g.is_dragging() && g.draggable());
- // Drag one grid step right (step_x = 100): snap puts the node at column 1, but cell
+ // Drag one pitch right (pitch_x = 100): snap puts the node at column 1, and cell
// (1, 0) is free so it lands there.
g.drag_begin(110.0, 120.0);
assert!(g.drag_update(210.0, 120.0));
@@ -1372,9 +1426,8 @@ mod tests {
let mut ctx = UiContext::new();
let mut g = Graph::new();
WidgetHost::set_rect(&mut g, 0.0, 0.0, 800.0, 600.0);
- g.set_grid_sizes(80.0, 40.0);
- g.set_skipped_sizes(20.0, 20.0);
- g.set_grid_origin(100.0, 100.0);
+ g.set_grid_pitch(100.0, 60.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 {
id: id.into(),
@@ -1421,6 +1474,46 @@ mod tests {
assert_eq!(GraphController::take_pending_splice(&mut *g), None);
}
+ /// 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.
+ #[test]
+ fn nodes_are_centred_on_lattice_intersections() {
+ let 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));
+ // 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();
+ assert_eq!((x + w / 2.0, y + h / 2.0), (140.0, 120.0));
+ assert_eq!((x, y, w, h), (100.0, 100.0, 80.0, 40.0));
+ // Node b, at (1, 1), is one pitch away along each axis.
+ let (x, y, w, h) = g.node_rect(1).unwrap();
+ assert_eq!((x + w / 2.0, y + h / 2.0), (240.0, 180.0));
+ }
+
+ /// The cell-and-gap setters describe the same lattice — a cell plus its
+ /// gap is a pitch, and the cell is the node body — in either order, so a
+ /// host still speaking them gets exactly the geometry it asked for.
+ #[test]
+ fn cell_and_gap_setters_describe_the_same_lattice() {
+ let mut g = Graph::new();
+ g.set_grid_sizes(140.0, 70.0);
+ g.set_skipped_sizes(35.0, 35.0);
+ assert_eq!(g.grid_pitch(), (175.0, 105.0));
+ assert_eq!(g.node_size(), (140.0, 70.0));
+ assert_eq!(g.grid_sizes(), (140.0, 70.0));
+ assert_eq!(g.skipped_sizes(), (35.0, 35.0));
+
+ let mut h = Graph::new();
+ h.set_skipped_sizes(35.0, 35.0);
+ h.set_grid_sizes(140.0, 70.0);
+ assert_eq!(h.grid_pitch(), (175.0, 105.0));
+ assert_eq!(h.node_size(), (140.0, 70.0));
+ }
+
#[test]
fn port_click_starts_and_completes_a_connection() {
let mut ctx = UiContext::new();
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 514a9c7..b43b5a1 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -622,8 +622,18 @@ pub trait GraphController {
fn set_grid_snap_enabled(&mut self, enabled: bool);
fn take_node_geom_toggle(&mut self) -> Option<(usize, bool)>;
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`).
+ fn set_grid_pitch(&mut self, px: f32, py: 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.
fn set_grid_sizes(&mut self, gx: f32, gy: f32);
+ /// The gap half of the cell-and-gap description; see [`set_grid_sizes`].
fn set_skipped_sizes(&mut self, row_h: f32, col_w: f32);
+ /// The lattice intersection node (0, 0) is centred on, window-absolute.
fn set_grid_origin(&mut self, ox: f32, oy: f32);
fn grid_origin(&self) -> (f32, f32);
fn set_show_network_grid(&mut self, show: bool);
@@ -640,7 +650,8 @@ pub trait GraphController {
fn is_node_rect(&self, qx: f32, qy: f32, qw: f32, qh: f32) -> bool;
/// The topmost node whose body contains (px, py), window-absolute coords.
fn node_at(&self, px: f32, py: f32) -> Option<usize>;
- /// The grid cells' superellipse corner radius at the current zoom (0 = square).
+ /// The corner radius of anything node-shaped on the grid at the current
+ /// zoom — the cursor, the drop-target highlight (0 = square).
fn cell_corner_radius(&self) -> f32;
/// The flat-geometry emission with grid cells tagged by their surviving
/// rounded corners — for hosts that draw the graph's quads themselves
@@ -650,7 +661,7 @@ pub trait GraphController {
/// hosts that draw the graph's quads themselves, called at the point in
/// their walk where the grid goes (under the wires and nodes).
fn paint_grid(&self, rect: crate::scene::layout::Rect, pc: &mut crate::scene::paint::PaintCtx);
- /// The pixel rect of the cell an in-flight node drag will deposit on
+ /// The pixel rect the in-flight node drag will deposit its body on
/// (`commit_drag`'s resolution), for hosts' drop-target highlight.
/// None outside a node drag.
fn drop_target_cell_rect(&self) -> Option<(f32, f32, f32, f32)>;