git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitdb63fce2ecd7b26e135a2f44927be3ac60d6d60a
parentc82016b44f
authorLucas Galante <[email protected]>
date2026-09-24 08:36
fix: Frame All fits the node labels, not just the bodies

A node's name hangs off its right edge (Graph::node_labels: an 8 px gap
and a 14 px font, both scaled with the body), so framing the bodies alone
cut the right-hand column's labels off. `State::node_extent` repeats the
widget's rule with its own `TextLabel::estimate_width`, and the fit is
iterated because the font floor and width rounding make the extent
non-linear in the zoom.

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

 CLAUDE.md   |  12 +++++
 src/app.rs  | 161 +++++++++++++++++++++++++++++++-----------------------------
 src/main.rs |  71 +++++++++++++++++++++++++++
 3 files changed, 165 insertions(+), 79 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7eaab2f..bb95584 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -921,6 +921,18 @@ It scrolls the FAR cell into view (`keep_cell_in_view`, which
 is not moving, and following it would scroll the wrong end of the selection
 into view.
 
+**Frame All fits the name labels, not just the bodies.** A label hangs off
+its node's right edge (`Graph::node_labels`: an 8 px gap and a 14 px font,
+both scaled with the body against its 80 px baseline, the font clamped to
+6..48), so framing the bodies alone cut the right-hand column's names off.
+`State::node_extent` repeats that rule — the widget offers no query for it —
+using the widget's own `TextLabel::estimate_width`, the number it culls the
+label against, so the two cannot disagree. The fit is iterated rather than
+solved once, because the extent is not linear in the zoom: the font floor and
+the width's rounding mean a fit computed at 100% overstates what a small zoom
+saves. `frame_all_keeps_the_node_labels_inside_the_pane` is the check, and it
+fails on the body-only fit.
+
 Two chords moved to make room, both caught by `command::conflicts` rather than
 by hand: `edit_handles` from `Ctrl+H` to `Ctrl+Shift+H` (the ctrl+hjkl family
 owns those now), and `f` now frames the CURSOR where it used to frame
diff --git a/src/app.rs b/src/app.rs
index 8fa9922..08823a4 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -6239,93 +6239,96 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         true
     }
 
-    /// Fit every node in the current level into the network pane.
+    /// The extent a node takes up on the sheet at a grid geometry, relative
+    /// to the (0, 0) crossing — `(x_min, x_max, y_min, y_max)`. The body,
+    /// AND the name label the widget draws to its right: framing the bodies
+    /// alone left the labels of the right-hand column cut off, which is what
+    /// Frame All exists to avoid.
+    ///
+    /// The label's placement is `Graph::node_labels`'s rule, repeated here
+    /// because the widget offers no query for it: an 8 px gap and a 14 px
+    /// font, both scaled with the body against its 80 px baseline, the font
+    /// clamped to 6..48, the width the widget's own estimate — the number it
+    /// culls the label against, so the two cannot disagree about where a
+    /// label ends. The label is centred on the body and shorter than it at
+    /// every zoom Frame All can choose, but its own height is folded in
+    /// anyway rather than argued away.
+    fn node_extent(name: &str, (col, row): (f32, f32), g: &GridGeometry) -> (f32, f32, f32, f32) {
+        let x_min = col * g.pitch_x - g.node_w * 0.5;
+        let y_min = row * g.pitch_y - g.node_h * 0.5;
+        let scale_f = g.node_w / 80.0;
+        let font_size = (14.0 * scale_f).clamp(6.0, 48.0);
+        let label_w = TextLabel::estimate_width(name, font_size);
+        let cy = row * g.pitch_y;
+        (
+            x_min,
+            x_min + g.node_w + 8.0 * scale_f + label_w,
+            y_min.min(cy - font_size * 0.5),
+            (y_min + g.node_h).max(cy + font_size * 0.5),
+        )
+    }
+
+    /// The union of `node_extent` over the current level, or None when the
+    /// level is empty.
+    fn level_extent(&self, g: &GridGeometry) -> Option<(f32, f32, f32, f32)> {
+        self.current_dir().children.iter().fold(None, |acc, child| {
+            let (x0, x1, y0, y1) = Self::node_extent(&child.name, child.position, g);
+            Some(match acc {
+                None => (x0, x1, y0, y1),
+                Some((ax0, ax1, ay0, ay1)) => (ax0.min(x0), ax1.max(x1), ay0.min(y0), ay1.max(y1)),
+            })
+        })
+    }
+
+    /// Fit every node in the current level — body and name label — into the
+    /// network pane.
     ///
     /// Extracted verbatim from the `f` key's inline arm so the command
     /// registry and the key run the same code — the point of the registry
     /// being that there is one implementation behind every way of asking.
     pub(crate) fn frame_all_nodes(&mut self) {
-            let active_nodes = self.current_dir().children.len();
-            // The framing baseline is the configured geometry (100% zoom),
-            // scaled down until everything fits — Frame All never zooms in
-            // past 100%.
-            let base = configured_grid_geometry();
-            if active_nodes == 0 {
-                self.set_grid_geometry(base);
-                self.pan_x = 20.0;
-                self.pan_y = 20.0;
-            } else {
-                // Node bodies' bounds, relative to the (0, 0) intersection,
-                // at the baseline.
-                let (base_px, base_py, base_w, base_h) = (base.pitch_x, base.pitch_y, base.node_w, base.node_h);
-                let mut b_xmin = f32::MAX;
-                let mut b_xmax = f32::MIN;
-                let mut b_ymin = f32::MAX;
-                let mut b_ymax = f32::MIN;
-
-                for slot_idx in 0..active_nodes {
-                    let (col, row) = self.current_dir().children[slot_idx].position;
-                    let x_min = col * base_px - base_w * 0.5;
-                    let x_max = x_min + base_w;
-                    let y_min = row * base_py - base_h * 0.5;
-                    let y_max = y_min + base_h;
-
-                    if x_min < b_xmin { b_xmin = x_min; }
-                    if x_max > b_xmax { b_xmax = x_max; }
-                    if y_min < b_ymin { b_ymin = y_min; }
-                    if y_max > b_ymax { b_ymax = y_max; }
-                }
-
-                let w_base = b_xmax - b_xmin;
-                let h_base = b_ymax - b_ymin;
-
-                 let (_px, _py, pw, ph) = self.positions[CONTENT_IDX];
-                 let viewport_w = pw;
-                 let viewport_h = ph;
-
-                let padding = 40.0;
-                let padded_w = (viewport_w - 2.0 * padding).max(10.0);
-                let padded_h = (viewport_h - 2.0 * padding).max(10.0);
-
-                let fx = padded_w / w_base;
-                let fy = padded_h / h_base;
-                let mut f = fx.min(fy);
-
-                f = f.min(1.0).max(MIN_PITCH_X / base_px);
-
-                self.set_grid_geometry(base);
-                self.scale_grid_geometry(f);
-                let (node_w, node_h) = self.node_size();
-
-                let mut actual_xmin = f32::MAX;
-                let mut actual_xmax = f32::MIN;
-                let mut actual_ymin = f32::MAX;
-                let mut actual_ymax = f32::MIN;
-
-                for slot_idx in 0..active_nodes {
-                    let (col, row) = self.current_dir().children[slot_idx].position;
-                    let x_min = col * self.grid_pitch_x - node_w * 0.5;
-                    let x_max = x_min + node_w;
-                    let y_min = row * self.grid_pitch_y - node_h * 0.5;
-                    let y_max = y_min + node_h;
-
-                    if x_min < actual_xmin { actual_xmin = x_min; }
-                    if x_max > actual_xmax { actual_xmax = x_max; }
-                    if y_min < actual_ymin { actual_ymin = y_min; }
-                    if y_max > actual_ymax { actual_ymax = y_max; }
+        // The framing baseline is the configured geometry (100% zoom),
+        // scaled down until everything fits — Frame All never zooms in
+        // past 100%.
+        let base = configured_grid_geometry();
+        let at = |f: f32| GridGeometry {
+            pitch_x: base.pitch_x * f,
+            pitch_y: base.pitch_y * f,
+            node_w: base.node_w * f,
+            node_h: base.node_h * f,
+        };
+        let (_px, _py, pw, ph) = self.positions[CONTENT_IDX];
+        let padding = 40.0;
+        let padded_w = (pw - 2.0 * padding).max(10.0);
+        let padded_h = (ph - 2.0 * padding).max(10.0);
+
+        if self.level_extent(&base).is_none() {
+            self.set_grid_geometry(base);
+            self.pan_x = 20.0;
+            self.pan_y = 20.0;
+        } else {
+            // The extent is not linear in the zoom: a label's font stops
+            // shrinking at 6 px and its width is rounded up, so the fit
+            // computed at 100% overstates how much a small zoom saves. Each
+            // pass refits at the zoom the last one chose; the factor only
+            // ever falls, and a pass that changes nothing ends it.
+            let mut f = 1.0f32;
+            for _ in 0..4 {
+                let (x0, x1, y0, y1) = self.level_extent(&at(f)).unwrap();
+                let fit = (padded_w / (x1 - x0)).min(padded_h / (y1 - y0)).min(1.0);
+                if fit >= 1.0 {
+                    break;
                 }
-
-                let actual_w = actual_xmax - actual_xmin;
-                let actual_h = actual_ymax - actual_ymin;
-
-                self.pan_x = (viewport_w - actual_w) / 2.0 - actual_xmin;
-                self.pan_y = (viewport_h - actual_h) / 2.0 - actual_ymin;
+                f *= fit;
             }
+            f = f.max(MIN_PITCH_X / base.pitch_x);
+
+            self.set_grid_geometry(at(f));
+            let (x0, x1, y0, y1) = self.level_extent(&at(f)).unwrap();
+            self.pan_x = (pw - (x1 - x0)) / 2.0 - x0;
+            self.pan_y = (ph - (y1 - y0)) / 2.0 - y0;
+        }
 
-            self.sync_grid_settings();
-            self.rebuild_positions();
-            self.apply_layout();
-            self.update_panel_bounds();
         self.sync_grid_settings();
         self.rebuild_positions();
         self.apply_layout();
diff --git a/src/main.rs b/src/main.rs
index 4f4846c..b7f89da 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9878,4 +9878,75 @@ mod tests {
             }
         }
     }
+    /// Frame All fits the name labels, not just the bodies. A node's label
+    /// hangs off its right edge — at 100% zoom, `8 + estimate_width(name,
+    /// 14)` px past the body — so a level whose bodies fit the pane with a
+    /// long name on the right-hand node used to frame with that name cut
+    /// off. The pane's right edge is where the label has to end now.
+    #[test]
+    fn frame_all_keeps_the_node_labels_inside_the_pane() {
+        use cce_ui::widget::TextLabel;
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        state.focused_pane = crate::slots::LEFT_MENUBAR_IDX;
+        state.param_editor = crate::slots::CONTENT_IDX;
+        // Clear the bundled level so only these two nodes are framed.
+        let existing = state.current_dir().children.len();
+        for slot in (0..existing).rev() {
+            state.delete_node(slot);
+        }
+
+        // The widget's own label rule, spelled out here rather than read
+        // back off the app, since agreeing with the widget is the claim.
+        let label_right = |state: &State, slot: usize| {
+            let child = &state.current_dir().children[slot];
+            let (x, _y, w, _h) = state.cell_rect(child.position.0 as i32, child.position.1 as i32);
+            let scale_f = w / 80.0;
+            let font_size = (14.0 * scale_f).clamp(6.0, 48.0);
+            x + w + 8.0 * scale_f + TextLabel::estimate_width(&child.name, font_size)
+        };
+        let (px, _py, pw, _ph) = state.positions[crate::slots::CONTENT_IDX];
+        let padding = 40.0;
+
+        let mut redraw = false;
+        let long_name = "a_node_whose_name_runs_well_past_the_edge_of_its_own_body_and_then_some";
+        // Two nodes whose bodies span most of the pane at 100%: the bodies
+        // alone fit, the right-hand label does not.
+        let cols = ((pw - 2.0 * padding) / 140.0).floor() - 2.0;
+        for (name, x) in [("a", 0.0), (long_name, cols)] {
+            state
+                .apply_action(
+                    crate::app::McpAction::AddNode { template_name: "Plane".into(), name: Some(name.into()), x, y: 0.0 },
+                    &mut redraw,
+                )
+                .unwrap();
+        }
+        state.rebuild_positions();
+        state.apply_layout();
+        let long = state.current_dir().children.iter().position(|c| c.name == long_name).expect("long node");
+
+        state.frame_all_nodes();
+
+        let right = label_right(&state, long);
+        assert!(
+            right <= px + pw - padding + 0.5,
+            "the long label ends at {right:.1}, past the pane's padded right edge {:.1} (pitch {:.1})",
+            px + pw - padding,
+            state.grid_pitch_x
+        );
+        let (ax, _, _, _) = state.cell_rect(0, 0);
+        assert!(ax >= px + padding - 0.5, "the left-hand body starts at {ax:.1}, inside the padding");
+        // The fit is by the labels: the bodies alone would have fitted at
+        // 100%, so the zoom had to come down for the name.
+        assert!(state.grid_pitch_x < 140.0, "the zoom stayed at 100% ({}), so the label was not counted", state.grid_pitch_x);
+
+        // And a level whose labels already fit frames at 100% — the label
+        // rule must not shrink a level that has room.
+        state.apply_action(crate::app::McpAction::RenameNode { slot: long, new_name: "b".into() }, &mut redraw).unwrap();
+        state.frame_all_nodes();
+        assert!((state.grid_pitch_x - 140.0).abs() < 0.01, "short labels fit at 100%, got pitch {}", state.grid_pitch_x);
+        assert!(label_right(&state, long) <= px + pw - padding + 0.5);
+    }
 }