git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commit7c4696e1df1c9fa83f7f40f7782da7509fc73db1
parent0a3f9a7822
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 06:22
feat(network): the graph page draws its wires and ports

cce-ui's render_widget now hands a flat host a widget's strokes, arcs
and discs (cce-ui c9b073a). PageContent keeps them (strokes, via
RenderTarget::line / arc / circle), rebuild_layout turns them into
WidgetFx::Stroke and the paint pass draws them with PaintCtx::vector /
arc / circle, cut by their clip.

Three things it took:
- The nodes wear no geometry toggle (Graph::set_show_toggles, cce-ui
  80a2798): the disc appeared on every node once circles were drawn.
- Nodes stand from column and row 1, the lattice origin a pitch outside
  the pane, so its two heavy axes no longer lie over column 0's wires.
- A stroke's page-view clip limits it top and bottom only. Clipped across
  to its own one-pixel box, a vertical wire on a half pixel rounded away
  and only the horizontal runs drew.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md            |  2 +-
 src/main.rs          | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/pages/mod.rs     | 51 +++++++++++++++++++++++++++++++++++++++++++++++++--
 src/pages/network.rs | 41 +++++++++++++++++++++++++----------------
 4 files changed, 127 insertions(+), 19 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 66fcebe..3143901 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -47,7 +47,7 @@ There are no composite container widgets. Every widget is owned outright by the
 
 ### Three pages, one preview
 - **Browse** — the `List` widget (columnar, integrated search box) plus a `Breadcrumb`. The right pane is a `Preview` widget, split from the list by `SplitPane` — app-owned (`main.rs`), carrying the `SplitBox` two-child horizontal math verbatim plus the divider quad, its hover tint, and the proportion drag.
-- **Network** — a `Graph` view of the same directory (nodes = entries), also split against the preview. It uses the configured lattice and node body as the designer's network does (a node is CENTRED on a crossing, its name hangs off its right side), laid out to fit its pane by `NetworkState::lay_out`: the pitch across holds a body, the name's gap and the widest name (cut with an ellipsis past `NAME_MAX`), as many columns as fit, column 0 a margin inside the pane, re-laid out when the pane changes size. The graph is cut to its pane (`PageContent::clipped_to`) for when it is panned. Until 2026-10-06 it set the retired cell model (140 x 70 cells, origin 60, 60, seven columns), which the lattice turned into nodes left of the pane, past its right edge, and names lying across their neighbours. `the_graph_fits_its_pane_and_its_names_fit_their_columns` is the test. Its WIRES are not drawn: `render_widget` has no line primitive for a flat host.
+- **Network** — a `Graph` view of the same directory (nodes = entries), also split against the preview. It uses the configured lattice and node body as the designer's network does (a node is CENTRED on a crossing, its name hangs off its right side), laid out to fit its pane by `NetworkState::lay_out`: the pitch across holds a body, the name's gap and the widest name (cut with an ellipsis past `NAME_MAX`), as many columns as fit, column 0 a margin inside the pane, re-laid out when the pane changes size. The graph is cut to its pane (`PageContent::clipped_to`) for when it is panned. Until 2026-10-06 it set the retired cell model (140 x 70 cells, origin 60, 60, seven columns), which the lattice turned into nodes left of the pane, past its right edge, and names lying across their neighbours. `the_graph_fits_its_pane_and_its_names_fit_their_columns` is the test. Its wires and ports draw since 2026-10-06: `render_widget` hands a widget's strokes, arcs and discs to `RenderTarget::line` / `arc` / `circle` (cce-ui c9b073a), which `PageContent` keeps as `strokes` and `rebuild_layout` paints as `WidgetFx::Stroke`, cut by a clip as a glyph is. The nodes wear no geometry toggle (`Graph::set_show_toggles(false)`). Nodes stand from column and row 1, the lattice's (0, 0) a pitch outside the pane, so its heavy axes do not lie over column 0's wires. A stroke's page-view clip limits it top and bottom only: clipped to its own one-pixel box across, a vertical wire on a half pixel rounded away and only the horizontal runs drew.
 - **Space** — a GrandPerspective-style treemap of the whole subtree, also split against the preview.
 The active page is picked by `view_dropdown` next to the breadcrumb (there is no sidebar — it was removed; `has_sidebar` is hardcoded `false`). The dropdown's labels name the *visualization* ("List"/"Graph"/"Space") and are a separate list from `Page::label()` ("Browse"/"Network"/"Space") — but **its order must track `Page::ALL`**, because the selected index is indexed straight into it.
 
diff --git a/src/main.rs b/src/main.rs
index 540e4e8..79c360b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -266,6 +266,10 @@ enum WidgetFx {
     /// A cce-icons glyph tinted with the widget's `color` (`PaintCtx::icon`),
     /// cut to `clip` when it has one.
     Icon { name: String, clip: Option<cce_ui::scene::layout::Rect> },
+    /// A widget's stroke, arc or disc (a graph's wire or port) in the
+    /// widget's `color`, cut to `clip` when it has one; the rect is its
+    /// bounding box, for culling.
+    Stroke { shape: crate::pages::Stroke, clip: Option<cce_ui::scene::layout::Rect> },
     /// A line engraved from (ax, ay) to (bx, by) into the widget's rect, which
     /// is the HOST surface here rather than the mark's own bounds — the shading
     /// fades out across the host's rolled edge. The breadcrumb's slanted seams;
@@ -1082,6 +1086,41 @@ impl FilesystemApp {
                     fx: WidgetFx::Icon { name: name.clone(), clip },
                 });
             }
+            for (shape, color, bounds) in &pc_part.strokes {
+                // Cut by a clip, as the glyphs are: a wire is a line, not a
+                // fill to press flat.
+                let (sx, sy, sw, sh) = shape.bounds();
+                let mut clip = bounds.map(|[l, t, r, b]| cce_ui::scene::layout::Rect { x: l, y: t, width: r - l, height: b - t });
+                if is_page_content {
+                    // The page's view cuts a stroke top and bottom only. Its
+                    // own box is no clip across: a vertical wire is one pixel
+                    // wide on a half-pixel, and a scissor that tight rounds
+                    // it away (the graph page drew only its horizontal runs).
+                    let view = cce_ui::scene::layout::Rect { x: sx - 4.0, y: content_y, width: sw + 8.0, height: content_h };
+                    clip = Some(match clip {
+                        Some(c) => intersect_rect(c, view),
+                        None => view,
+                    });
+                }
+                if let Some(c) = clip {
+                    if c.width <= 0.0 || c.height <= 0.0
+                        || c.x >= sx + sw || c.x + c.width <= sx
+                        || c.y >= sy + sh || c.y + c.height <= sy
+                    {
+                        continue;
+                    }
+                }
+                widgets.push(AppWidget {
+                    x: sx,
+                    y: sy,
+                    w: sw,
+                    h: sh,
+                    color: *color,
+                    radius: 0.0,
+                    corners: (true, true, true, true),
+                    fx: WidgetFx::Stroke { shape: *shape, clip },
+                });
+            }
             for (rx, ry, rw, rh, rr, rd, kind) in &pc_part.reliefs {
                 let (mut wy, mut wh) = (*ry, *rh);
                 if is_page_content {
@@ -1839,6 +1878,19 @@ impl Application for FilesystemApp {
                         pc.pop_clip();
                     }
                 }
+                WidgetFx::Stroke { shape, clip } => {
+                    if let Some(c) = clip {
+                        pc.push_clip(c);
+                    }
+                    match shape {
+                        crate::pages::Stroke::Line { x1, y1, x2, y2, thickness, cap } => pc.vector(x1, y1, x2, y2, thickness, w.color, cap),
+                        crate::pages::Stroke::Arc { cx, cy, radius, thickness, start, end } => pc.arc(cx, cy, radius, thickness, start, end, w.color),
+                        crate::pages::Stroke::Disc { cx, cy, radius } => pc.circle(cx, cy, radius, w.color),
+                    }
+                    if clip.is_some() {
+                        pc.pop_clip();
+                    }
+                }
                 WidgetFx::Groove { ax, ay, bx, by, width, depth } => {
                     pc.groove((ax, ay), (bx, by), width, depth, rect)
                 }
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 6c88734..6e1c842 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -145,6 +145,31 @@ pub const RELIEF_RECESSED_FOCUS: u8 = 3;
 /// highlight — the ring a focused control plate wears (`ControlPlate::with_tint`).
 pub const RELIEF_INSET_FOCUS: u8 = 4;
 
+/// A stroked shape a widget draws: a graph's wire (a line, or an arc at a
+/// rounded bend) or its port (a disc). Coordinates as `PaintCtx` takes them;
+/// an arc's `radius` is its OUTER edge.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Stroke {
+    Line { x1: f32, y1: f32, x2: f32, y2: f32, thickness: f32, cap: cce_ui::scene::paint::Cap },
+    Arc { cx: f32, cy: f32, radius: f32, thickness: f32, start: f32, end: f32 },
+    Disc { cx: f32, cy: f32, radius: f32 },
+}
+
+impl Stroke {
+    /// The shape's bounding box `(x, y, w, h)` — what culls it.
+    pub fn bounds(&self) -> (f32, f32, f32, f32) {
+        match *self {
+            Stroke::Line { x1, y1, x2, y2, thickness, .. } => {
+                let h = thickness * 0.5;
+                (x1.min(x2) - h, y1.min(y2) - h, (x1 - x2).abs() + thickness, (y1 - y2).abs() + thickness)
+            }
+            Stroke::Arc { cx, cy, radius, .. } | Stroke::Disc { cx, cy, radius } => {
+                (cx - radius, cy - radius, 2.0 * radius, 2.0 * radius)
+            }
+        }
+    }
+}
+
 pub struct PageContent {
     pub rects: Vec<([f32; 4], f32, f32, f32, f32, f32, (bool, bool, bool, bool))>,
     pub texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
@@ -171,6 +196,10 @@ pub struct PageContent {
     /// order, after its images. The clip cuts a glyph scrolled half under an
     /// edge rather than squashing it, as text bounds cut a label.
     pub icons: Vec<(String, f32, f32, f32, f32, [f32; 4], Option<[f32; 4]>)>,
+    /// Strokes, arcs and discs — (shape, colour, clip `[l, t, r, b]`): a
+    /// widget's wires and ports as `render_widget` hands them over
+    /// (`RenderTarget::line` / `arc` / `circle`). The graph page's wires.
+    pub strokes: Vec<(Stroke, [f32; 4], Option<[f32; 4]>)>,
     /// Lit plates — (color, x, y, w, h, radius, depth). Unlike [`reliefs`], a plate
     /// owns its FILL as well as its edge: one primitive carrying a rounded face and
     /// the rolled, lit perimeter, shaded in a single lighting evaluation. That is
@@ -192,6 +221,7 @@ impl PageContent {
             grooves: Vec::new(),
             images: Vec::new(),
             icons: Vec::new(),
+            strokes: Vec::new(),
             plates: Vec::new(),
         }
     }
@@ -204,7 +234,7 @@ impl PageContent {
     /// exactly that reason. The destructuring below turns a new field into a
     /// compile error instead of a missing mark on screen.
     pub fn absorb(&mut self, other: PageContent) {
-        let PageContent { rects, texts, buttons, reliefs, grooves, images, icons, plates } = other;
+        let PageContent { rects, texts, buttons, reliefs, grooves, images, icons, strokes, plates } = other;
         self.rects.extend(rects);
         self.texts.extend(texts);
         self.buttons.extend(buttons);
@@ -212,6 +242,7 @@ impl PageContent {
         self.grooves.extend(grooves);
         self.images.extend(images);
         self.icons.extend(icons);
+        self.strokes.extend(strokes);
         self.plates.extend(plates);
     }
 
@@ -219,7 +250,8 @@ impl PageContent {
     /// fill wholly outside is dropped), and the bounds of every label and
     /// glyph narrowed to it. For a widget that paints past the rect it was
     /// given — the graph, panned — since a flat host has no clip stack.
-    /// Parts with no clip of their own (buttons, reliefs, grooves, images,
+    /// Strokes are cut by their clip as glyphs are. Parts with no clip of
+    /// their own (buttons, reliefs, grooves, images,
     /// plates) pass through as they are.
     pub fn clipped_to(mut self, clip: [f32; 4]) -> Self {
         let [l, t, r, b] = clip;
@@ -244,6 +276,9 @@ impl PageContent {
         for i in &mut self.icons {
             i.6 = narrow(i.6);
         }
+        for st in &mut self.strokes {
+            st.2 = narrow(st.2);
+        }
         self
     }
 
@@ -433,6 +468,18 @@ impl RenderTarget for PageContent {
         PageContent::icon(self, name, rect.x, rect.y, rect.width, rect.height, color);
     }
 
+    fn line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, thickness: f32, color: [f32; 4], cap: cce_ui::scene::paint::Cap) {
+        self.strokes.push((Stroke::Line { x1, y1, x2, y2, thickness, cap }, color, None));
+    }
+
+    fn arc(&mut self, cx: f32, cy: f32, radius: f32, thickness: f32, start: f32, end: f32, color: [f32; 4]) {
+        self.strokes.push((Stroke::Arc { cx, cy, radius, thickness, start, end }, color, None));
+    }
+
+    fn circle(&mut self, cx: f32, cy: f32, radius: f32, color: [f32; 4]) {
+        self.strokes.push((Stroke::Disc { cx, cy, radius }, color, None));
+    }
+
     fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
         self.rects.push((color, x, y, w, h, 0.0, (true, true, true, true)));
     }
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 62d33eb..c0a62df 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -33,6 +33,8 @@ impl Default for NetworkState {
         graph.set_show_network_grid(true);
         graph.set_grid_snap_enabled(true);
         graph.set_network_opacity(0.95);
+        // A directory has no geometry to show: no toggle disc on the nodes.
+        graph.inner_mut().set_show_toggles(false);
         let base_pitch = graph.inner().grid_pitch();
 
         let mut breadcrumb = Breadcrumb::new();
@@ -83,8 +85,8 @@ fn fit_name(name: &str, font_size: f32, max: f32) -> String {
 /// Where a directory's nodes go in a pane `(x, y, w, h)`: the pitch across
 /// (a body, its name's gap and the widest name the column holds, or the
 /// configured pitch if that is wider), how many columns fit, and the lattice
-/// origin that puts column 0 and row 0 a margin inside the pane — a node is
-/// centred on its crossing.
+/// origin that puts column 1 and row 1 — where the nodes begin — a margin
+/// inside the pane: a node is centred on its crossing.
 fn layout(pane: (f32, f32, f32, f32), node: (f32, f32), base_pitch: (f32, f32), names: &[String]) -> ((f32, f32), usize, (f32, f32)) {
     use cce_ui::widget::display::TextLabel;
     let (px, py, pw, _) = pane;
@@ -93,7 +95,10 @@ fn layout(pane: (f32, f32, f32, f32), node: (f32, f32), base_pitch: (f32, f32),
     let widest = names.iter().map(|n| TextLabel::estimate_width(n, font)).fold(0.0f32, f32::max);
     let pitch_x = (nw + gap + widest.min(NAME_MAX) + MARGIN).max(base_pitch.0);
     let cols = (((pw - 2.0 * MARGIN) - (nw + gap + widest.min(NAME_MAX))) / pitch_x).floor().max(0.0) as usize + 1;
-    let origin = (px + MARGIN + nw * 0.5, py + MARGIN + nh * 0.5);
+    // The lattice's (0, 0) crossing a pitch above and left of the first
+    // node, so its two heavy axes fall outside the pane: on the first
+    // column they lay over its wires, which run down the lattice lines.
+    let origin = (px + MARGIN + nw * 0.5 - pitch_x, py + MARGIN + nh * 0.5 - base_pitch.1);
     ((pitch_x, base_pitch.1), cols, origin)
 }
 
@@ -112,11 +117,13 @@ impl NetworkState {
         self.laid_out_for = pane;
     }
 
-    /// Fill the graph: the parent at row 0 and the directory at row 1 over
-    /// column `cols / 2`, then `entries` (shown as `names`) `cols` to a row.
+    /// Fill the graph: the parent at row 1 and the directory at row 2 over
+    /// the middle column, then `entries` (shown as `names`) `cols` to a row,
+    /// columns counted from 1.
     pub fn populate_graph(&mut self, current_dir: &Path, entries: &[DirEntry], cols: usize, names: &[String]) {
         let cols = cols.max(1);
-        let mid = ((cols - 1) / 2) as f32;
+        // Columns and rows from 1: the lattice's 0 lines are off the pane.
+        let mid = ((cols - 1) / 2) as f32 + 1.0;
         let mut nodes = Vec::new();
         let mut glyphs = Vec::new();
 
@@ -131,7 +138,7 @@ impl NetworkState {
             nodes.push(GraphNode {
                 id: String::new(),
                 name: name.clone(),
-                position: (mid, 0.0),
+                position: (mid, 1.0),
                 parameters: Vec::new(),
                 geom_visible: true,
                 node_type: String::new(),
@@ -159,7 +166,7 @@ impl NetworkState {
         nodes.push(GraphNode {
             id: String::new(),
             name: current_node_name.clone(),
-            position: (mid, 1.0),
+            position: (mid, 2.0),
             parameters: current_params,
             geom_visible: true,
             node_type: String::new(),
@@ -172,9 +179,9 @@ impl NetworkState {
             let node_name = names.get(idx).cloned().unwrap_or_else(|| entry.name.clone());
             glyphs.push(if entry.is_dir { "folder" } else { "file" });
 
-            // `cols` to a row, from row 2.
-            let col = (idx % cols) as f32;
-            let row = 2.0 + (idx / cols) as f32;
+            // `cols` to a row, from row 3.
+            let col = 1.0 + (idx % cols) as f32;
+            let row = 3.0 + (idx / cols) as f32;
 
             nodes.push(GraphNode {
                 id: String::new(),
@@ -273,10 +280,9 @@ pub fn view(state: &mut NetworkState, browse: &BrowseState, view_dropdown: &mut
     cce_ui::layout::render_widget(&mut graph_pc, &mut state.graph, cx, cy + 28.0, cw, ch - 28.0, ctx);
     pc.absorb(graph_pc.clipped_to([cx, cy + 28.0, cx + cw, cy + ch]));
 
-    // Each node's glyph on the left of its body, mirroring the geometry
-    // toggle the graph draws on the right (`Graph::toggle_rect`): the same
-    // size, the same inset, scaled with the zoom. Cut to the graph's rect, as
-    // its labels are.
+    // Each node's glyph on the left of its body, at the size and inset the
+    // graph's geometry toggle has on the right (hidden here): scaled with
+    // the zoom. Cut to the graph's rect, as its labels are.
     let canvas = [cx, cy + 28.0, cx + cw, cy + ch];
     let glyph_color = [0xcc as f32 / 255.0, 0xcc as f32 / 255.0, 0xd4 as f32 / 255.0, 1.0];
     for (idx, glyph) in state.node_glyphs.iter().enumerate() {
@@ -381,7 +387,10 @@ mod tests {
             assert!(label_w <= NAME_MAX + 0.01, "{:?} is not cut to a column", node.name);
         }
         assert!(nodes.iter().any(|n| n.name.ends_with('\u{2026}')), "the long name is cut with an ellipsis");
-        assert!(nodes.iter().skip(2).any(|n| n.position.0 > 0.0), "more than one column fits 600 px");
+        assert!(nodes.iter().skip(2).any(|n| n.position.0 > 1.0), "more than one column fits 600 px");
+        let (ox, oy) = g.grid_origin();
+        assert!(ox < pane.0 && oy < pane.1, "the lattice's heavy axes lie outside the pane");
+        assert!(g.toggle_rect(0).is_none(), "a directory's nodes wear no geometry toggle");
     }
 
     #[test]