graphic design tool
git clone https://git.lucas.co/cce-designer.git
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);
+ }
}