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

commitf3c970be244ad54121021a8223986ba553e09838
parent331541e4f1
authorLucas Galante <[email protected]>
date2026-09-24 14:37
fix(graph): a node against the pane's right edge keeps its name

A node's label hangs off its body's right edge, and the cull dropped it
whole when it began past the pane — so a node parked against the right
edge drew with no name at all, whatever the name's length (found on a
one-letter wrangle in a screenshot and misread as a short-name bug).
When the label does not fit on the right and fits on the left, it hangs
off the left edge instead, right-aligned to it. Frame All assumes the
right-hand placement, which stays safe: after framing every label fits
on the right and none flips.

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

 src/widget/display/graph.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 52 insertions(+), 1 deletion(-)

diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index d933ada..8213e80 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -689,15 +689,28 @@ impl Graph {
 
     /// Node-name labels beside each node, scaled with the grid, included only when they
     /// intersect the widget rect (legacy `text_labels`).
+    /// A node's name hangs off its body's RIGHT edge — an 8 px gap and a
+    /// 14 px font, both scaled with the body against its 80 px baseline —
+    /// unless it would not fit there and fits on the LEFT, where it hangs
+    /// off the left edge instead, right-aligned to it. A node parked against
+    /// the pane's right edge used to draw with no name at all: the label
+    /// began past the edge and the cull dropped it whole, whatever its
+    /// length. Frame All assumes the right-hand placement, which is safe —
+    /// after framing every label fits on the right and none flips.
     fn node_labels(&self, rect: Rect) -> Vec<TextLabel> {
         let mut labels = Vec::new();
         for (i, node) in self.nodes.iter().enumerate() {
             if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
                 let scale_f = nw / 80.0;
                 let font_size = (14.0 * scale_f).clamp(6.0, 48.0);
-                let lx = nx + nw + 8.0 * scale_f;
+                let gap = 8.0 * scale_f;
                 let ly = crate::layout::align_text_y(ny, nh, font_size, 0.0);
                 let text_w = TextLabel::estimate_width(&node.name, font_size);
+                let right = nx + nw + gap;
+                let left = nx - gap - text_w;
+                let fits_right = right + text_w <= rect.x + rect.width;
+                let fits_left = left >= rect.x;
+                let lx = if !fits_right && fits_left { left } else { right };
                 if lx + text_w >= rect.x && lx < rect.x + rect.width && ly + font_size >= rect.y && ly < rect.y + rect.height {
                     labels.push(TextLabel {
                         text: node.name.clone(),
@@ -1340,6 +1353,44 @@ mod tests {
         g
     }
 
+    /// A node against the pane's right edge keeps its name: the label flips
+    /// to the body's left when it would not fit on the right, whatever the
+    /// name's length, and a node with room keeps the right-hand placement.
+    #[test]
+    fn a_node_at_the_right_edge_keeps_its_label_on_the_left() {
+        let mut g = two_nodes();
+        let node = |name: &str, col: f32| GraphNode {
+            id: name.into(),
+            name: name.into(),
+            position: (col, 0.0),
+            parameters: Vec::new(),
+            geom_visible: true,
+            node_type: String::new(),
+            inputs: 1,
+            outputs: 1,
+        };
+        // With the origin at 160, column 6's body spans 720..800 — flush
+        // against the right edge of an 800 px pane, so a right-hand label
+        // would BEGIN past the edge, which is exactly the screenshot that
+        // found this. Column 2's spans 320..400: plenty of room.
+        g.set_grid_origin(160.0, 120.0);
+        g.set_nodes(&[node("w", 6.0), node("wrangle_with_a_long_name", 6.0), node("mid", 2.0)]);
+        let rect = Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
+        let labels = g.node_labels(rect);
+        assert_eq!(labels.len(), 3, "every node keeps a label: {:?}", labels.iter().map(|l| &l.text).collect::<Vec<_>>());
+        let (nx, _, nw, _) = g.node_rect(0).unwrap();
+        assert_eq!((nx, nx + nw), (720.0, 800.0));
+        for name in ["w", "wrangle_with_a_long_name"] {
+            let l = labels.iter().find(|l| l.text == name).unwrap();
+            let w = TextLabel::estimate_width(name, l.font_size);
+            assert!((l.x + w - (nx - 8.0)).abs() < 0.5, "{name} hangs off the left edge, right-aligned to it: x {} w {w}", l.x);
+            assert!(l.x >= rect.x, "{name} stays inside the pane");
+        }
+        let mid = labels.iter().find(|l| l.text == "mid").unwrap();
+        let (mx, _, mw, _) = g.node_rect(2).unwrap();
+        assert_eq!(mid.x, mx + mw + 8.0, "a node with room keeps the right-hand placement");
+    }
+
     /// A double-click's two presses always straddle a host node re-sync — the
     /// designer calls set_nodes on EVERY window event — so the detection state
     /// must survive set_nodes. It used to be wiped there wholesale, which made