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

commite120ef9d46bf49fe27bd5cee9815f67b773c99b7
parentbc2268af38
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 12:48
feat: a graph's wires are strokes in a style, and wire_color / wire_size are read

Graph draws its wires in one of four WireStyles - orthogonal (as before),
rounded, bezier and straight - chosen by style.surface.graph.node.wire_style
or by the host (set_wire_style). The colour and width are wire_color and
wire_size from the same block, which were parsed and never read: the wires
were a hard-coded cyan 3 px.

The wires leave geometry_quads_tagged for paint_wires (also on
GraphController), since only one style is axis-aligned; Paint::paint calls
it after the grid. The draw pass and the splice hit test read one path, so a
drop lands on the wire as drawn in any style.

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

 CLAUDE.md                          |  31 +++
 src/layout.rs                      |   9 +
 src/widget/container/content_bg.rs |   2 +
 src/widget/display/graph.rs        | 428 ++++++++++++++++++++++++++++++-------
 src/widget/display/mod.rs          |   2 +-
 src/widget/mod.rs                  |   5 +
 6 files changed, 400 insertions(+), 77 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a4e366e..71c99f1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -753,6 +753,37 @@ cce-system-interface) to confirm behavior, not just the test suite.
   `detect_metric` — the display's logical px per mm from its `wl_output` geometry).
 - `units.rs` — lengths with units and the display metric; see the Units section below.
 
+## A graph's wires are strokes in a style (since 2026-09-30)
+
+`Graph` draws its wires in one of four `WireStyle`s: **orthogonal** (down,
+across at half the height, down — what every wire was), **rounded** (the
+same with the two bends rounded, the radius at most half a node's height),
+**bezier** (a cubic that leaves the output and reaches the input heading
+down, so a wire back up the graph loops) and **straight**. The style is
+`style.surface.graph.node.wire_style` unless the host sets one
+(`Graph::set_wire_style`, `None` to follow the config again). Colour and
+width are `wire_color` and `wire_size` (px at 100%, scaled with the node
+body) in the same block — both parsed since long before and READ BY
+NOTHING until this change, when the wires were a hard-coded cyan 3 px.
+
+- **The wires are not in `geometry_quads_tagged` any more.** They are
+  `Graph::paint_wires` (also on `GraphController`), `Prim::Vector`s and
+  `Prim::Arc`s, since only one style is axis-aligned. `Paint::paint` calls
+  it after the grid; a host drawing the quads itself (the designer) calls
+  it between `paint_grid` and the bodies. The legacy `extra_quads` view has
+  no wires.
+- **One path, drawn and hit**: `wire_path` derives each style's pieces, and
+  the splice hit test (`splice_wire_at`) walks the same pieces against the
+  dragged ghost, so a drop lands on the wire as drawn in any style.
+- **The orthogonal joins do not overlap** — the across run is widened by
+  half a thickness to fill the corners and the down runs stop at its edge —
+  so a translucent wire is one alpha throughout. A bezier is flat-capped
+  pieces about 6 px of control net apiece, fine enough that no notch shows
+  at the joins.
+
+`every_wire_style_runs_from_port_to_port` and the splice test, run in all
+four styles, are the tests.
+
 ## Units — logical px inside, real lengths at the edges
 
 The toolkit's working unit is and stays the **logical pixel**: every layout
diff --git a/src/layout.rs b/src/layout.rs
index 0ccbaf3..2cd165a 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -368,6 +368,7 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
                 "style.surface.graph.node.wire_color" => "graph_wire_color",
                 "style.surface.graph.node.wire_highlight_color" => "graph_wire_highlight_color",
                 "style.surface.graph.node.wire_size" => "graph_wire_size",
+                "style.surface.graph.node.wire_style" => "graph_wire_style",
                 "style.surface.graph.node.wire_activation_radius" => "graph_wire_activation_radius",
                 "style.surface.graph.node.connector_color" => "graph_connector_color",
                 "style.surface.graph.node.connector_highlight_color" => "graph_connector_highlight_color",
@@ -3610,6 +3611,14 @@ pub fn set_graph_node_delete(key: String) {
     }
 }
 
+/// `style.surface.graph.node.wire_style`: how a wire runs, by
+/// `WireStyle::name` — `orthogonal`, `rounded`, `bezier` or `straight`.
+/// `None` when the config does not say.
+pub fn graph_wire_style() -> Option<String> {
+    lazy_init_style_registry();
+    get_style_registry().read().unwrap().get_string("graph_wire_style")
+}
+
 pub fn graph_wire_size() -> f32 {
     lazy_init_style_registry();
     get_style_registry().read().unwrap().get_float("graph_wire_size").unwrap_or(6.0)
diff --git a/src/widget/container/content_bg.rs b/src/widget/container/content_bg.rs
index bc700ec..42846d0 100644
--- a/src/widget/container/content_bg.rs
+++ b/src/widget/container/content_bg.rs
@@ -280,6 +280,8 @@ impl GraphController for ContentBg {
             pc.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
         }
     }
+    /// No nodes, so no wires.
+    fn paint_wires(&self, _rect: Rect, _pc: &mut PaintCtx) {}
     fn take_pending_connection(&mut self) -> Option<(String, String)> { None }
     fn cancel_connecting(&mut self) {}
     fn is_node_rect(&self, _qx: f32, _qy: f32, _qw: f32, _qh: f32) -> bool { false }
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index 861b574..90de728 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -10,6 +10,12 @@
 //! 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 WIRES are not quads: they are strokes in a [`WireStyle`] — orthogonal, rounded,
+//! bezier or straight — painted by [`Graph::paint_wires`], which `paint` calls after the
+//! grid and a host drawing the quads itself calls in the same place. Their colour, width
+//! and style are `wire_color`, `wire_size` and `wire_style` under
+//! `style.surface.graph.node`; until 2026-09-30 the first two were parsed and never read.
+//!
 //! 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
@@ -41,6 +47,185 @@ fn default_outputs() -> usize { 1 }
 /// and `None` for everything else (wires, gaps, axes, nodes, toggles).
 pub type TaggedQuad = (f32, f32, f32, f32, [f32; 4], Option<(bool, bool, bool, bool)>);
 
+/// How a wire runs from an output port (the bottom of its node) to an input
+/// port (the top of the next): `style.surface.graph.node.wire_style` in
+/// config.kdl, by [`WireStyle::name`], unless a host sets one of its own
+/// ([`Graph::set_wire_style`]). Every style is drawn and hit-tested from the
+/// one path [`wire_path`] derives, so a splice drop lands on the wire you see.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum WireStyle {
+    /// Down, across at half the height, down: three straight runs meeting
+    /// square. What every wire was before there was a choice.
+    #[default]
+    Orthogonal,
+    /// The same three runs with their two bends rounded off.
+    Rounded,
+    /// One cubic curve that leaves the output heading down and arrives at
+    /// the input heading down, so a wire running back up the graph loops.
+    Bezier,
+    /// One straight line, port to port.
+    Straight,
+}
+
+impl WireStyle {
+    pub const ALL: [WireStyle; 4] = [WireStyle::Orthogonal, WireStyle::Rounded, WireStyle::Bezier, WireStyle::Straight];
+
+    /// The config spelling.
+    pub fn name(self) -> &'static str {
+        match self {
+            WireStyle::Orthogonal => "orthogonal",
+            WireStyle::Rounded => "rounded",
+            WireStyle::Bezier => "bezier",
+            WireStyle::Straight => "straight",
+        }
+    }
+
+    /// The spelling a menu shows.
+    pub fn label(self) -> &'static str {
+        match self {
+            WireStyle::Orthogonal => "Orthogonal",
+            WireStyle::Rounded => "Rounded",
+            WireStyle::Bezier => "Bezier",
+            WireStyle::Straight => "Straight",
+        }
+    }
+
+    /// Either spelling, any case.
+    pub fn parse(s: &str) -> Option<WireStyle> {
+        let s = s.trim();
+        WireStyle::ALL.into_iter().find(|w| w.name().eq_ignore_ascii_case(s))
+    }
+
+    /// The configured style; orthogonal when the key is absent or names
+    /// no style.
+    pub fn configured() -> WireStyle {
+        crate::layout::graph_wire_style().as_deref().and_then(WireStyle::parse).unwrap_or_default()
+    }
+}
+
+/// One piece of a wire's path, in window px: a straight run, or an arc about
+/// a centre at a CENTRELINE radius from one angle to another (radians,
+/// screen space, so y runs down).
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum WireSeg {
+    Line((f32, f32), (f32, f32)),
+    Arc { c: (f32, f32), r: f32, a0: f32, a1: f32 },
+}
+
+/// The path of a wire from `start` to `end` drawn `t` px thick. `bend` is
+/// the largest radius a Rounded bend takes and the least a Bezier lead
+/// runs straight down before curving — both scale with the node, so the
+/// shape keeps its proportions under zoom.
+fn wire_path(style: WireStyle, start: (f32, f32), end: (f32, f32), t: f32, bend: f32) -> Vec<WireSeg> {
+    let ((sx, sy), (ex, ey)) = (start, end);
+    let my = sy + (ey - sy) / 2.0;
+    let orthogonal = || {
+        // Square joins with no overlap, so a translucent wire is one alpha
+        // throughout: the across run is widened by half a thickness at each
+        // end to fill the corners, and the down runs stop at its edge.
+        let s = if ey >= sy { 1.0 } else { -1.0 };
+        let mut out = Vec::with_capacity(3);
+        let v1_end = my - s * t / 2.0;
+        if s * (v1_end - sy) > 0.0 {
+            out.push(WireSeg::Line((sx, sy), (sx, v1_end)));
+        }
+        out.push(WireSeg::Line((sx.min(ex) - t / 2.0, my), (sx.max(ex) + t / 2.0, my)));
+        let v2_start = my + s * t / 2.0;
+        if s * (ey - v2_start) > 0.0 {
+            out.push(WireSeg::Line((ex, v2_start), (ex, ey)));
+        }
+        out
+    };
+    match style {
+        WireStyle::Straight => vec![WireSeg::Line(start, end)],
+        WireStyle::Orthogonal => orthogonal(),
+        WireStyle::Rounded => {
+            let (dx, dy) = (ex - sx, ey - sy);
+            if dx.abs() < 0.5 {
+                return vec![WireSeg::Line(start, end)];
+            }
+            let r = bend.min(dx.abs() / 2.0).min(dy.abs() / 2.0);
+            // A bend tighter than half the stroke has no inside edge.
+            if r < t / 2.0 {
+                return orthogonal();
+            }
+            // Bend at (bx, by) turning from heading u1 to heading u2.
+            let bend_at = |bx: f32, by: f32, u1: (f32, f32), u2: (f32, f32)| {
+                let t1 = (bx - u1.0 * r, by - u1.1 * r);
+                let t2 = (bx + u2.0 * r, by + u2.1 * r);
+                let c = (t1.0 + u2.0 * r, t1.1 + u2.1 * r);
+                let a0 = (t1.1 - c.1).atan2(t1.0 - c.0);
+                let a1 = (t2.1 - c.1).atan2(t2.0 - c.0);
+                let mut sweep = a1 - a0;
+                if sweep > std::f32::consts::PI {
+                    sweep -= std::f32::consts::TAU;
+                } else if sweep < -std::f32::consts::PI {
+                    sweep += std::f32::consts::TAU;
+                }
+                (t1, t2, WireSeg::Arc { c, r, a0, a1: a0 + sweep })
+            };
+            let down = (0.0, dy.signum());
+            let across = (dx.signum(), 0.0);
+            let (p1a, p1b, arc1) = bend_at(sx, my, down, across);
+            let (p2a, p2b, arc2) = bend_at(ex, my, across, down);
+            vec![
+                WireSeg::Line(start, p1a),
+                arc1,
+                WireSeg::Line(p1b, p2a),
+                arc2,
+                WireSeg::Line(p2b, end),
+            ]
+        }
+        WireStyle::Bezier => {
+            let lead = ((ey - sy).abs() * 0.5).max(bend);
+            let (c1, c2) = ((sx, sy + lead), (ex, ey - lead));
+            // Fine enough that the flat-capped pieces meet without a visible
+            // notch: about one piece per 6 px of the control net.
+            let net = lead * 2.0 + ((c2.0 - c1.0).powi(2) + (c2.1 - c1.1).powi(2)).sqrt();
+            let n = ((net / 6.0).ceil() as usize).clamp(8, 96);
+            let at = |u: f32| {
+                let v = 1.0 - u;
+                let (a, b, c, d) = (v * v * v, 3.0 * v * v * u, 3.0 * v * u * u, u * u * u);
+                (a * sx + b * c1.0 + c * c2.0 + d * ex, a * sy + b * c1.1 + c * c2.1 + d * ey)
+            };
+            let mut prev = start;
+            (1..=n)
+                .map(|i| {
+                    let p = at(i as f32 / n as f32);
+                    let seg = WireSeg::Line(prev, p);
+                    prev = p;
+                    seg
+                })
+                .collect()
+        }
+    }
+}
+
+/// Whether the segment a→b passes through the rect (x1, y1)-(x2, y2):
+/// Liang–Barsky clipping of the segment to the rect.
+fn segment_meets_rect(a: (f32, f32), b: (f32, f32), x1: f32, y1: f32, x2: f32, y2: f32) -> bool {
+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);
+    let (mut lo, mut hi) = (0.0f32, 1.0f32);
+    for (p, q) in [(-dx, a.0 - x1), (dx, x2 - a.0), (-dy, a.1 - y1), (dy, y2 - a.1)] {
+        if p == 0.0 {
+            if q < 0.0 {
+                return false;
+            }
+        } else {
+            let r = q / p;
+            if p < 0.0 {
+                lo = lo.max(r);
+            } else {
+                hi = hi.min(r);
+            }
+            if lo > hi {
+                return false;
+            }
+        }
+    }
+    true
+}
+
 #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
 pub struct GraphNode {
     #[serde(default)]
@@ -124,6 +309,8 @@ pub struct Graph {
     /// node id). The host rewires: dragged.Input = upstream name,
     /// downstream.Input = dragged's name.
     pending_splice: Option<(String, String, String)>,
+    /// The host's choice of wire style; `None` follows the config.
+    wire_style: Option<WireStyle>,
 }
 
 impl Graph {
@@ -169,6 +356,7 @@ impl Graph {
             hovered_port: None,
             splice_target: None,
             pending_splice: None,
+            wire_style: None,
         })
     }
 
@@ -455,73 +643,93 @@ impl Graph {
         clipped(self.grid_origin_x - thickness / 2.0, rect.y, thickness, rect.height, axis, pc);
     }
 
-    /// 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;
-        let min_y = rect.y;
-        let max_x = rect.x + rect.width;
-        let max_y = rect.y + rect.height;
-        let push_clipped = |qx: f32, qy: f32, qw: f32, qh: f32, qc: [f32; 4], q: &mut Vec<TaggedQuad>| {
-            let rx1 = qx.max(min_x);
-            let ry1 = qy.max(min_y);
-            let rx2 = (qx + qw).min(max_x);
-            let ry2 = (qy + qh).min(max_y);
-            let rw = rx2 - rx1;
-            let rh = ry2 - ry1;
-            if rw > 0.0 && rh > 0.0 {
-                q.push((rx1, ry1, rw, rh, qc, None));
-            }
-        };
+    /// The wire style in effect: the host's, else the config's.
+    pub fn wire_style(&self) -> WireStyle {
+        self.wire_style.unwrap_or_else(WireStyle::configured)
+    }
 
-        // A three-segment orthogonal wire from (start_x, start_y) down/up to (end_x, end_y).
-        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;
+    /// The host's own choice of wire style, if it made one.
+    pub fn chosen_wire_style(&self) -> Option<WireStyle> {
+        self.wire_style
+    }
 
-            let v1_min_y = start_y.min(mid_y);
-            let v1_max_y = start_y.max(mid_y);
-            push_clipped(start_x - wire_thickness / 2.0, v1_min_y, wire_thickness, v1_max_y - v1_min_y, color, q);
+    /// Choose the wire style for this graph; `None` goes back to the
+    /// config's `wire_style`.
+    pub fn set_wire_style(&mut self, style: Option<WireStyle>) {
+        self.wire_style = style;
+    }
 
-            let h_min_x = start_x.min(end_x);
-            let h_max_x = start_x.max(end_x);
-            push_clipped(h_min_x, mid_y - wire_thickness / 2.0, h_max_x - h_min_x, wire_thickness, color, q);
+    /// A wire's stroke: `graph_wire_size` px at 100%, scaled with the node
+    /// body as the zoom scales it.
+    fn wire_thickness(&self) -> f32 {
+        let base = crate::layout::graph_node_width();
+        let zoom = if base > 0.0 { self.node_w / base } else { 1.0 };
+        (crate::layout::graph_wire_size() * zoom).clamp(1.0, (self.node_h * 0.5).max(1.0))
+    }
 
-            let v2_min_y = mid_y.min(end_y);
-            let v2_max_y = mid_y.max(end_y);
-            push_clipped(end_x - wire_thickness / 2.0, v2_min_y, wire_thickness, v2_max_y - v2_min_y, color, q);
-        };
+    /// What a Rounded bend's radius and a Bezier's straight lead are made of.
+    fn wire_bend(&self) -> f32 {
+        self.node_h * 0.5
+    }
+
+    /// The wire from `src_idx`'s output to `dest_idx`'s input, as drawn.
+    fn wire_segments(&self, src_idx: usize, dest_idx: usize) -> Option<Vec<WireSeg>> {
+        let (start, end) = self.wire_endpoints(src_idx, dest_idx)?;
+        Some(wire_path(self.wire_style(), start, end, self.wire_thickness(), self.wire_bend()))
+    }
 
-        // Connection wires: each node with an "input" parameter draws a wire
-        // from that source node's first output port to its own first input
-        // port (pairs and endpoints from the shared helpers the splice hit
-        // test also reads). The wire an in-flight node drag would splice
-        // into draws in the highlight color — the drop affordance.
-        let wire_color = [0.0, 0.75, 1.0, 0.7 * self.node_opacity]; // Vibrant cyan glow
+    /// The wires, and the one being dragged out of a port, in the style in
+    /// effect ([`Self::wire_style`]), `wire_color` at the node opacity and
+    /// `wire_size` thick; the wire an in-flight node drag would splice into
+    /// is in `wire_highlight_color` — the drop affordance. Clipped to `rect`.
+    /// Hosts that draw the graph's quads themselves call it between
+    /// [`Self::paint_grid`] and the node bodies.
+    pub fn paint_wires(&self, rect: Rect, pc: &mut PaintCtx) {
+        let t = self.wire_thickness();
+        let stroke = |segs: &[WireSeg], color: [f32; 4], pc: &mut PaintCtx| {
+            for seg in segs {
+                match *seg {
+                    WireSeg::Line(a, b) => pc.vector(a.0, a.1, b.0, b.1, t, color, crate::scene::paint::Cap::Flat),
+                    // The arc's radius is its OUTER edge; the path's is the
+                    // centreline.
+                    WireSeg::Arc { c, r, a0, a1 } => pc.arc(c.0, c.1, r + t / 2.0, t, a0, a1, color),
+                }
+            }
+        };
+        let wc = crate::color::graph_wire_color();
+        let wire_color = [wc[0], wc[1], wc[2], wc[3] * self.node_opacity];
         let hl = crate::color::graph_wire_highlight_color();
         let splice_color = [hl[0], hl[1], hl[2], hl[3] * self.node_opacity];
-        for (src_idx, i) in self.wire_pairs() {
-            if let Some(((start_x, start_y), (end_x, end_y))) = self.wire_endpoints(src_idx, i) {
+        pc.clip(rect, |pc| {
+            for (src_idx, i) in self.wire_pairs() {
+                let Some(segs) = self.wire_segments(src_idx, i) else { continue };
                 let is_splice_target = self
                     .splice_target
                     .as_ref()
-                    .map(|(s, d)| self.nodes[src_idx].id == *s && self.nodes[i].id == *d)
-                    .unwrap_or(false);
-                let color = if is_splice_target { splice_color } else { wire_color };
-                push_wire(start_x, start_y, end_x, end_y, color, &mut quads);
+                    .is_some_and(|(s, d)| self.nodes[src_idx].id == *s && self.nodes[i].id == *d);
+                stroke(&segs, if is_splice_target { splice_color } else { wire_color }, pc);
             }
-        }
-
-        // Connection preview while dragging one out
-        if let Some((node_idx, port_type, port_idx)) = self.connecting_from {
-            if let Some((start_x, start_y)) = self.port_center(node_idx, port_type, port_idx) {
-                let preview_color = [1.0, 0.6, 0.0, 0.8]; // Golden orange preview
-                push_wire(start_x, start_y, self.current_mouse_pos.0, self.current_mouse_pos.1, preview_color, &mut quads);
+            // The connection being dragged out of a port.
+            if let Some((node_idx, port_type, port_idx)) = self.connecting_from {
+                if let Some(start) = self.port_center(node_idx, port_type, port_idx) {
+                    let segs = wire_path(self.wire_style(), start, self.current_mouse_pos, t, self.wire_bend());
+                    stroke(&segs, [1.0, 0.6, 0.0, 0.8], pc); // Golden orange preview
+                }
             }
-        }
+        });
+    }
+
+    /// The 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;
+        let min_y = rect.y;
+        let max_x = rect.x + rect.width;
+        let max_y = rect.y + rect.height;
+        // The wires and the connection preview are `paint_wires`' — strokes,
+        // not quads, since only one of the styles is axis-aligned.
 
         // The grid lines and the origin axes are `paint_grid`'s — hosts that
         // draw these quads themselves call it at the same point in their walk.
@@ -745,12 +953,13 @@ impl Paint for Graph {
     }
 
     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
-        // The background is the first entry; the grid lines go over it
-        // before the wires and nodes.
+        // The background is the first entry; the grid lines and then the
+        // wires go over it before the nodes.
         for (i, (qx, qy, qw, qh, r, c, corners)) in self.rounded_geometry(rect).into_iter().enumerate() {
             ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, r, corners, c);
             if i == 0 {
                 self.paint_grid(rect, ctx);
+                self.paint_wires(rect, ctx);
             }
         }
         for (cx, cy, r, c) in self.port_circles(rect) {
@@ -1131,21 +1340,8 @@ impl Graph {
         Some((start, end))
     }
 
-    /// The wire's three segments as axis-aligned rects at the drawn
-    /// 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.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());
-        let h = (start_x.min(end_x), mid_y - t / 2.0, (end_x - start_x).abs(), t);
-        let v2 = (end_x - t / 2.0, mid_y.min(end_y), t, (end_y - mid_y).abs());
-        Some([v1, h, v2])
-    }
-
     /// The wire the dragged node's ghost at (nx, ny) would splice into —
-    /// the first pair (draw order) whose segment run touches the ghost rect,
+    /// the first pair (draw order) whose path, as drawn, touches the ghost rect,
     /// inflated by the wire activation radius so a near miss still takes.
     /// The dragged node's own wires never count (dropping a node on a wire
     /// it is already an end of is a move, not a rewire), and a node with no
@@ -1160,16 +1356,26 @@ impl Graph {
             return None;
         }
         let (_, _, nw, nh) = self.node_rect(idx)?;
-        let pad = crate::layout::graph_wire_activation_radius().max(0.0);
+        // Half the stroke on top of the activation radius, so the wire's
+        // edge counts and not only its centreline.
+        let pad = crate::layout::graph_wire_activation_radius().max(0.0) + self.wire_thickness() / 2.0;
         let (gx1, gy1) = (nx - pad, ny - pad);
         let (gx2, gy2) = (nx + nw + pad, ny + nh + pad);
         for (src, dest) in self.wire_pairs() {
             if src == idx || dest == idx {
                 continue;
             }
-            let Some(segs) = self.wire_segment_rects(src, dest) else { continue };
-            let hit = segs.iter().any(|&(x, y, w, h)| {
-                x < gx2 && x + w > gx1 && y < gy2 && y + h > gy1
+            let Some(segs) = self.wire_segments(src, dest) else { continue };
+            let hit = segs.iter().any(|seg| match *seg {
+                WireSeg::Line(a, b) => segment_meets_rect(a, b, gx1, gy1, gx2, gy2),
+                // An arc, as the chords of its eighths.
+                WireSeg::Arc { c, r, a0, a1 } => (0..8).any(|k| {
+                    let at = |u: f32| {
+                        let a = a0 + (a1 - a0) * u;
+                        (c.0 + r * a.cos(), c.1 + r * a.sin())
+                    };
+                    segment_meets_rect(at(k as f32 / 8.0), at((k + 1) as f32 / 8.0), gx1, gy1, gx2, gy2)
+                }),
             });
             if hit {
                 return Some((src, dest));
@@ -1207,6 +1413,9 @@ impl GraphController for Graph {
     fn paint_grid(&self, rect: Rect, pc: &mut PaintCtx) {
         Graph::paint_grid(self, rect, pc)
     }
+    fn paint_wires(&self, rect: Rect, pc: &mut PaintCtx) {
+        Graph::paint_wires(self, rect, pc)
+    }
     fn set_nodes(&mut self, nodes: &[GraphNode]) {
         // Hover carries a node INDEX, so remap it by id across the rebuild
         // instead of clearing — hosts (the designer) re-sync nodes on EVERY
@@ -1447,8 +1656,15 @@ mod tests {
     /// reports nothing, and the handshake is take-once.
     #[test]
     fn node_dropped_on_a_wire_reports_a_splice() {
+        for style in WireStyle::ALL {
+            node_dropped_on_a_wire_reports_a_splice_in(style);
+        }
+    }
+
+    fn node_dropped_on_a_wire_reports_a_splice_in(style: WireStyle) {
         let mut ctx = UiContext::new();
         let mut g = Graph::new();
+        g.set_wire_style(Some(style));
         WidgetHost::set_rect(&mut g, 0.0, 0.0, 800.0, 600.0);
         g.set_grid_pitch(100.0, 60.0);
         g.set_node_size(80.0, 40.0);
@@ -1485,7 +1701,7 @@ mod tests {
         assert_eq!(
             splice,
             Some(("c".to_string(), "alpha".to_string(), "b".to_string())),
-            "drop on the wire must report (dragged, upstream name, downstream id)"
+            "{style:?}: drop on the wire must report (dragged, upstream name, downstream id)"
         );
         assert_eq!(GraphController::take_pending_splice(&mut *g), None, "take-once");
 
@@ -1499,6 +1715,66 @@ mod tests {
         assert_eq!(GraphController::take_pending_splice(&mut *g), None);
     }
 
+    /// Where a piece of a wire begins and ends.
+    fn seg_ends(seg: &WireSeg) -> ((f32, f32), (f32, f32)) {
+        match *seg {
+            WireSeg::Line(a, b) => (a, b),
+            WireSeg::Arc { c, r, a0, a1 } => (
+                (c.0 + r * a0.cos(), c.1 + r * a0.sin()),
+                (c.0 + r * a1.cos(), c.1 + r * a1.sin()),
+            ),
+        }
+    }
+
+    fn near(a: (f32, f32), b: (f32, f32)) -> bool {
+        (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01
+    }
+
+    /// Every style but the orthogonal one is one unbroken run from the
+    /// output port to the input port, down the graph and back up it: each
+    /// piece begins where the last ended. The orthogonal one is three runs
+    /// meeting square, which touch without overlapping so a translucent wire
+    /// is one alpha throughout — the across run filling the corners.
+    #[test]
+    fn every_wire_style_runs_from_port_to_port() {
+        let t = 6.0;
+        let cases = [((100.0, 100.0), (300.0, 260.0)), ((300.0, 260.0), (100.0, 100.0)), ((100.0, 100.0), (100.0, 300.0))];
+        for (start, end) in cases {
+            for style in [WireStyle::Rounded, WireStyle::Bezier, WireStyle::Straight] {
+                let segs = wire_path(style, start, end, t, 20.0);
+                assert!(near(seg_ends(&segs[0]).0, start), "{style:?} {start:?}->{end:?} starts at the output");
+                assert!(near(seg_ends(segs.last().unwrap()).1, end), "{style:?} {start:?}->{end:?} ends at the input");
+                for w in segs.windows(2) {
+                    assert!(near(seg_ends(&w[0]).1, seg_ends(&w[1]).0), "{style:?} {start:?}->{end:?} is unbroken");
+                }
+            }
+            let segs = wire_path(WireStyle::Rounded, start, end, t, 20.0);
+            if start.0 != end.0 {
+                assert_eq!(segs.iter().filter(|s| matches!(s, WireSeg::Arc { .. })).count(), 2, "two rounded bends");
+            }
+        }
+
+        let segs = wire_path(WireStyle::Orthogonal, (100.0, 100.0), (300.0, 260.0), t, 20.0);
+        assert_eq!(
+            segs,
+            vec![
+                WireSeg::Line((100.0, 100.0), (100.0, 177.0)),
+                WireSeg::Line((97.0, 180.0), (303.0, 180.0)),
+                WireSeg::Line((300.0, 183.0), (300.0, 260.0)),
+            ]
+        );
+    }
+
+    #[test]
+    fn a_wire_style_is_named_either_way_in_any_case() {
+        for style in WireStyle::ALL {
+            assert_eq!(WireStyle::parse(style.name()), Some(style));
+            assert_eq!(WireStyle::parse(style.label()), Some(style));
+            assert_eq!(WireStyle::parse(&style.name().to_uppercase()), Some(style));
+        }
+        assert_eq!(WireStyle::parse("wiggly"), 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 is its own:
diff --git a/src/widget/display/mod.rs b/src/widget/display/mod.rs
index 3a85632..ae2021f 100644
--- a/src/widget/display/mod.rs
+++ b/src/widget/display/mod.rs
@@ -28,7 +28,7 @@ pub use self::splitter::Splitter;
 pub use self::list_item::{TextItem, InteractiveListItem};
 pub use self::separator::Separator;
 pub use self::serialize::serialize_widgets;
-pub use self::graph::{GraphNode, Graph, TaggedQuad};
+pub use self::graph::{GraphNode, Graph, TaggedQuad, WireStyle};
 pub use self::usage_bar::UsageBar;
 pub use self::info_box::InfoBox;
 pub use self::status_dot::{DotStatus, StatusDot};
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 9c9235f..54d8423 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -701,6 +701,11 @@ 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 wires and the connection being dragged out, stroked in the wire
+    /// style in effect — for hosts that draw the graph's quads themselves,
+    /// called after [`paint_grid`](Self::paint_grid) and under the nodes.
+    /// The quads carry no wires.
+    fn paint_wires(&self, rect: crate::scene::layout::Rect, pc: &mut crate::scene::paint::PaintCtx);
     /// 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.