tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ 92dd0e5bfb2ca4456006da99437f4100e156d856 widgets/nodegraph/src/lib.rs Rust · 718 líneas · 21.3 KB Historia Quién tocó qué crudo

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · Sergio ·

1//! `llimphi-widget-nodegraph` — lienzo de nodos con pins y cables
2//! Bezier sobre Llimphi.
3//!
4//! Modelo declarativo de un grafo dirigido: cada frame, el caller pasa
5//! la lista actual de [`NodeSpec`]s + [`Wire`]s y el widget pinta:
6//!
7//! - el lienzo (fondo lleno);
8//! - cada nodo como un rect con título arriba y pins a los lados
9//!   (entradas a la izquierda, salidas a la derecha);
10//! - los cables entre `(node_a, output_pin_a)` y `(node_b, input_pin_b)`
11//!   como Bezier cúbicas con tangentes horizontales (mismo look que
12//!   `pluma-editor-llimphi::multilienzo_editor::carril_editor`).
13//!
14//! El widget no mantiene estado: el caller acumula posición de nodos +
15//! cables en su `Model` y le pasa handlers para los dos eventos
16//! interactivos:
17//!
18//! - **mover un nodo** — `on_drag_node(node_id, phase, dx, dy)` se
19//!   invoca al arrastrar la title bar de un nodo. El handler suma el
20//!   delta a la posición persistida.
21//! - **conectar dos pins** — al arrastrar desde un pin de salida y
22//!   soltar sobre un pin de entrada, `on_connect(from_node, from_out,
23//!   to_node, to_in)` se invoca para que el caller materialice el
24//!   `Wire` en su modelo.
25//!
26//! Reusable por: pluma (visualizador DAG), nakui (fórmulas yupay),
27//! tullpu (ajustes no destructivos), dominium (sistemas), takiy
28//! (cadena de audio), pluma-notebook (kernel-DAG visual).
29
30#![forbid(unsafe_code)]
31
32use std::sync::Arc;
33
34use llimphi_ui::llimphi_layout::taffy::{
35    prelude::{length, percent, Dimension, Position, Size, Style},
36    Rect,
37};
38use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Stroke};
39use llimphi_ui::llimphi_raster::peniko::Color;
40use llimphi_ui::llimphi_text::Alignment;
41use llimphi_ui::{DragPhase, View};
42
43/// Identificador opaco de un nodo. El caller asigna estos valores; el
44/// widget los pasa de vuelta sin interpretarlos.
45pub type NodeId = u32;
46/// Índice del pin dentro de la lista `inputs` o `outputs` del nodo.
47pub type PinIdx = u16;
48
49/// Especificación de un nodo del grafo. El caller construye uno por
50/// nodo en cada `view`. Las posiciones son en pixels relativas al rect
51/// del lienzo.
52#[derive(Debug, Clone)]
53pub struct NodeSpec {
54    pub id: NodeId,
55    pub label: String,
56    /// Esquina superior-izquierda del nodo, en coordenadas del lienzo.
57    pub x: f32,
58    pub y: f32,
59    /// Labels de los pins de entrada. Cantidad = altura mínima del nodo.
60    pub inputs: Vec<String>,
61    /// Labels de los pins de salida.
62    pub outputs: Vec<String>,
63}
64
65/// Cable entre el pin de salida de un nodo y el pin de entrada de otro.
66/// El widget no valida ciclos ni direcciones — esa política vive en el
67/// caller.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct Wire {
70    pub from_node: NodeId,
71    pub from_output: PinIdx,
72    pub to_node: NodeId,
73    pub to_input: PinIdx,
74}
75
76/// Tinte opcional de un nodo resaltado. Cada campo `Some` sobrescribe
77/// el color correspondiente de la paleta global para *ese* nodo; los
78/// `None` heredan la paleta. Sirve para que el caller marque un subgrafo
79/// (p.ej. el cono afectado por un morfismo) sin tocar el resto.
80#[derive(Debug, Clone, Copy, Default)]
81pub struct NodeTint {
82    pub bg_node: Option<Color>,
83    pub bg_title: Option<Color>,
84    pub fg_title: Option<Color>,
85}
86
87/// Paleta del lienzo. Hereda del [`llimphi_theme::Theme`] semántico.
88#[derive(Debug, Clone, Copy)]
89pub struct NodegraphPalette {
90    pub bg_canvas: Color,
91    pub bg_node: Color,
92    pub bg_title: Color,
93    pub fg_title: Color,
94    pub fg_pin_label: Color,
95    pub pin_input: Color,
96    pub pin_output: Color,
97    pub pin_drop_hover: Color,
98    pub wire: Color,
99    pub border: Color,
100}
101
102impl Default for NodegraphPalette {
103    fn default() -> Self {
104        Self::from_theme(&llimphi_theme::Theme::dark())
105    }
106}
107
108impl NodegraphPalette {
109    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
110        Self {
111            bg_canvas: t.bg_app,
112            bg_node: t.bg_panel,
113            bg_title: t.bg_panel_alt,
114            fg_title: t.fg_text,
115            fg_pin_label: t.fg_muted,
116            pin_input: t.accent,
117            pin_output: t.accent,
118            pin_drop_hover: t.bg_selected,
119            wire: t.accent,
120            border: t.border,
121        }
122    }
123}
124
125/// Geometría del nodo y de los pins.
126#[derive(Debug, Clone, Copy)]
127pub struct NodegraphMetrics {
128    pub node_width: f32,
129    pub title_height: f32,
130    pub pin_row_height: f32,
131    pub pin_radius: f32,
132    pub pin_label_size: f32,
133    pub title_text_size: f32,
134    pub wire_stroke: f32,
135    pub node_radius: f64,
136}
137
138impl Default for NodegraphMetrics {
139    fn default() -> Self {
140        Self {
141            node_width: 160.0,
142            title_height: 22.0,
143            pin_row_height: 18.0,
144            pin_radius: 5.0,
145            pin_label_size: 10.0,
146            title_text_size: 11.0,
147            wire_stroke: 1.6,
148            node_radius: 4.0,
149        }
150    }
151}
152
153impl NodegraphMetrics {
154    /// Alto total del rect que ocupa un nodo con `n_in` entradas y
155    /// `n_out` salidas. El cuerpo crece con el lado que tenga más pins.
156    pub fn node_height(&self, n_in: usize, n_out: usize) -> f32 {
157        let rows = n_in.max(n_out).max(1) as f32;
158        self.title_height + rows * self.pin_row_height + 6.0
159    }
160
161    /// Centro Y absoluto de un pin de entrada del nodo cuyo top-left es
162    /// `(_x, node_y)`. Sirve también para outputs (misma alineación).
163    pub fn input_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 {
164        node_y
165            + self.title_height
166            + 3.0
167            + (pin as f32 + 0.5) * self.pin_row_height
168    }
169
170    pub fn output_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 {
171        self.input_pin_y(node_y, pin)
172    }
173}
174
175type DragNodeFn<Msg> =
176    Arc<dyn Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync>;
177type ConnectFn<Msg> = Arc<
178    dyn Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync,
179>;
180
181/// Codifica `(node_id, pin_idx)` en el `u64` que viaja como payload del
182/// drag de un pin. 32 bits superiores = node_id, 16 bits inferiores =
183/// pin_idx.
184#[inline]
185fn encode_payload(node: NodeId, pin: PinIdx) -> u64 {
186    ((node as u64) << 32) | (pin as u64)
187}
188
189#[inline]
190fn decode_payload(payload: u64) -> (NodeId, PinIdx) {
191    let node = (payload >> 32) as NodeId;
192    let pin = (payload & 0xFFFF) as PinIdx;
193    (node, pin)
194}
195
196/// Construye el lienzo de nodos. `on_drag_node` se invoca con el delta
197/// del cursor cuando el usuario arrastra la title bar de un nodo (las
198/// fases `Move` y `End` se reenvían tal cual). `on_connect` se invoca
199/// cuando el usuario suelta un cable iniciado en un pin de salida
200/// sobre un pin de entrada de otro nodo.
201pub fn nodegraph_view<Msg, FDrag, FConnect>(
202    nodes: &[NodeSpec],
203    wires: &[Wire],
204    palette: &NodegraphPalette,
205    metrics: &NodegraphMetrics,
206    on_drag_node: FDrag,
207    on_connect: FConnect,
208) -> View<Msg>
209where
210    Msg: Clone + Send + Sync + 'static,
211    FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
212    FConnect:
213        Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
214{
215    nodegraph_view_ex::<Msg, FDrag, FConnect, fn(NodeId) -> Option<Msg>>(
216        nodes,
217        wires,
218        palette,
219        metrics,
220        on_drag_node,
221        on_connect,
222        None,
223    )
224}
225
226/// Variante extendida con un handler opcional de click derecho sobre
227/// la title bar de cada nodo. Permite a la app montar acciones por-nodo
228/// (estilo "ejecutar desde aquí" en un notebook reactivo, o "duplicar
229/// este nodo" en un editor de cadena de audio) sin esperar a que el
230/// widget tenga un menú contextual propio.
231///
232/// `on_right_click_node` se evalúa una vez por nodo al construir la
233/// vista — si devuelve `Some(msg)`, el runtime emite ese `Msg` al hacer
234/// right-click sobre la title bar; `None` deja al nodo sin acción
235/// contextual.
236pub fn nodegraph_view_ex<Msg, FDrag, FConnect, FRight>(
237    nodes: &[NodeSpec],
238    wires: &[Wire],
239    palette: &NodegraphPalette,
240    metrics: &NodegraphMetrics,
241    on_drag_node: FDrag,
242    on_connect: FConnect,
243    on_right_click_node: Option<FRight>,
244) -> View<Msg>
245where
246    Msg: Clone + Send + Sync + 'static,
247    FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
248    FConnect:
249        Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
250    FRight: Fn(NodeId) -> Option<Msg>,
251{
252    nodegraph_view_styled(
253        nodes,
254        wires,
255        palette,
256        metrics,
257        on_drag_node,
258        on_connect,
259        on_right_click_node,
260        None,
261        None,
262    )
263}
264
265/// Variante con realce: además de los handlers, acepta dos closures de
266/// estilo evaluados en construcción —`node_tint(id)` tiñe nodos puntuales
267/// y `wire_tint(&Wire)` recolorea cables— para que el caller marque un
268/// subgrafo (cono afectado, ruta crítica, celda con error…) sin tocar la
269/// paleta global. Ambos `None` = render idéntico a [`nodegraph_view`].
270#[allow(clippy::too_many_arguments)]
271pub fn nodegraph_view_styled<Msg, FDrag, FConnect, FRight>(
272    nodes: &[NodeSpec],
273    wires: &[Wire],
274    palette: &NodegraphPalette,
275    metrics: &NodegraphMetrics,
276    on_drag_node: FDrag,
277    on_connect: FConnect,
278    on_right_click_node: Option<FRight>,
279    node_tint: Option<&dyn Fn(NodeId) -> Option<NodeTint>>,
280    wire_tint: Option<&dyn Fn(&Wire) -> Option<Color>>,
281) -> View<Msg>
282where
283    Msg: Clone + Send + Sync + 'static,
284    FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
285    FConnect:
286        Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
287    FRight: Fn(NodeId) -> Option<Msg>,
288{
289    let on_drag: DragNodeFn<Msg> = Arc::new(on_drag_node);
290    let on_connect: ConnectFn<Msg> = Arc::new(on_connect);
291
292    let painted = precompute_wires(nodes, wires, metrics, palette.wire, wire_tint);
293    let stroke_px = metrics.wire_stroke;
294
295    let mut children: Vec<View<Msg>> = Vec::with_capacity(nodes.len() + 1);
296
297    // Capa 0 — cables (van detrás de los nodos).
298    children.push(wires_layer(painted, stroke_px));
299
300    // Capa 1..N — nodos.
301    for node in nodes {
302        let right_click_msg = on_right_click_node
303            .as_ref()
304            .and_then(|f| f(node.id));
305        let tint = node_tint.and_then(|f| f(node.id));
306        children.push(node_view(
307            node,
308            palette,
309            metrics,
310            &on_drag,
311            &on_connect,
312            right_click_msg,
313            tint,
314        ));
315    }
316
317    View::new(Style {
318        size: Size {
319            width: percent(1.0_f32),
320            height: percent(1.0_f32),
321        },
322        ..Default::default()
323    })
324    .fill(palette.bg_canvas)
325    .clip(true)
326    .children(children)
327}
328
329#[derive(Debug, Clone, Copy)]
330struct WirePainted {
331    x1: f32,
332    y1: f32,
333    x2: f32,
334    y2: f32,
335    color: Color,
336}
337
338fn precompute_wires(
339    nodes: &[NodeSpec],
340    wires: &[Wire],
341    metrics: &NodegraphMetrics,
342    default_color: Color,
343    wire_tint: Option<&dyn Fn(&Wire) -> Option<Color>>,
344) -> Vec<WirePainted> {
345    let mut out = Vec::with_capacity(wires.len());
346    for w in wires {
347        let from = nodes.iter().find(|n| n.id == w.from_node);
348        let to = nodes.iter().find(|n| n.id == w.to_node);
349        if let (Some(a), Some(b)) = (from, to) {
350            let x1 = a.x + metrics.node_width;
351            let y1 = metrics.output_pin_y(a.y, w.from_output);
352            let x2 = b.x;
353            let y2 = metrics.input_pin_y(b.y, w.to_input);
354            let color = wire_tint.and_then(|f| f(w)).unwrap_or(default_color);
355            out.push(WirePainted {
356                x1,
357                y1,
358                x2,
359                y2,
360                color,
361            });
362        }
363    }
364    out
365}
366
367fn wires_layer<Msg>(wires: Vec<WirePainted>, stroke_px: f32) -> View<Msg>
368where
369    Msg: Clone + 'static,
370{
371    let nodo = View::new(Style {
372        position: Position::Absolute,
373        inset: Rect {
374            left: length(0.0_f32),
375            top: length(0.0_f32),
376            right: length(0.0_f32),
377            bottom: length(0.0_f32),
378        },
379        size: Size {
380            width: percent(1.0_f32),
381            height: percent(1.0_f32),
382        },
383        ..Default::default()
384    });
385    if wires.is_empty() {
386        return nodo;
387    }
388    nodo.paint_with(move |scene, _ts, rect| {
389        let stroke = Stroke::new(stroke_px as f64);
390        for w in &wires {
391            // Bezier cúbica con tangentes horizontales — mismo patrón
392            // que las hebras de pluma-editor-llimphi.
393            let dx = ((w.x2 - w.x1).abs().max(40.0) * 0.5) as f64;
394            let x1 = (rect.x + w.x1) as f64;
395            let y1 = (rect.y + w.y1) as f64;
396            let x2 = (rect.x + w.x2) as f64;
397            let y2 = (rect.y + w.y2) as f64;
398            let mut path = BezPath::new();
399            path.move_to((x1, y1));
400            path.curve_to((x1 + dx, y1), (x2 - dx, y2), (x2, y2));
401            scene.stroke(&stroke, Affine::IDENTITY, w.color, None, &path);
402        }
403    })
404}
405
406fn node_view<Msg>(
407    node: &NodeSpec,
408    palette: &NodegraphPalette,
409    metrics: &NodegraphMetrics,
410    on_drag: &DragNodeFn<Msg>,
411    on_connect: &ConnectFn<Msg>,
412    on_right_click_msg: Option<Msg>,
413    tint: Option<NodeTint>,
414) -> View<Msg>
415where
416    Msg: Clone + Send + Sync + 'static,
417{
418    let n_in = node.inputs.len();
419    let n_out = node.outputs.len();
420    let height = metrics.node_height(n_in, n_out);
421
422    // Colores efectivos: el tinte sobrescribe la paleta por-campo.
423    let tint = tint.unwrap_or_default();
424    let bg_node = tint.bg_node.unwrap_or(palette.bg_node);
425    let bg_title = tint.bg_title.unwrap_or(palette.bg_title);
426    let fg_title = tint.fg_title.unwrap_or(palette.fg_title);
427
428    let node_id = node.id;
429    let drag = on_drag.clone();
430    let mut title_bar = View::new(Style {
431        size: Size {
432            width: percent(1.0_f32),
433            height: length(metrics.title_height),
434        },
435        flex_shrink: 0.0,
436        padding: Rect {
437            left: length(8.0_f32),
438            right: length(8.0_f32),
439            top: length(0.0_f32),
440            bottom: length(0.0_f32),
441        },
442        ..Default::default()
443    })
444    .fill(bg_title)
445    .text_aligned(
446        node.label.clone(),
447        metrics.title_text_size,
448        fg_title,
449        Alignment::Start,
450    )
451    .draggable(move |phase, dx, dy| (drag)(node_id, phase, dx, dy));
452
453    if let Some(msg) = on_right_click_msg {
454        title_bar = title_bar.on_right_click(msg);
455    }
456
457    let mut pin_layer_children: Vec<View<Msg>> = Vec::with_capacity(n_in + n_out);
458    for (i, label) in node.inputs.iter().enumerate() {
459        pin_layer_children.push(pin_view(
460            node_id,
461            i as PinIdx,
462            PinKind::Input,
463            label,
464            palette,
465            metrics,
466            on_connect.clone(),
467        ));
468    }
469    for (i, label) in node.outputs.iter().enumerate() {
470        pin_layer_children.push(pin_view(
471            node_id,
472            i as PinIdx,
473            PinKind::Output,
474            label,
475            palette,
476            metrics,
477            on_connect.clone(),
478        ));
479    }
480    let pin_layer = View::new(Style {
481        position: Position::Absolute,
482        inset: Rect {
483            left: length(0.0_f32),
484            top: length(metrics.title_height),
485            right: length(0.0_f32),
486            bottom: length(0.0_f32),
487        },
488        size: Size {
489            width: percent(1.0_f32),
490            height: Dimension::auto(),
491        },
492        ..Default::default()
493    })
494    .children(pin_layer_children);
495
496    View::new(Style {
497        position: Position::Absolute,
498        inset: Rect {
499            left: length(node.x),
500            top: length(node.y),
501            right: length(0.0_f32),
502            bottom: length(0.0_f32),
503        },
504        size: Size {
505            width: length(metrics.node_width),
506            height: length(height),
507        },
508        ..Default::default()
509    })
510    .fill(bg_node)
511    .radius(metrics.node_radius)
512    .children(vec![title_bar, pin_layer])
513}
514
515#[derive(Debug, Clone, Copy)]
516enum PinKind {
517    Input,
518    Output,
519}
520
521fn pin_view<Msg>(
522    node_id: NodeId,
523    pin_idx: PinIdx,
524    kind: PinKind,
525    label: &str,
526    palette: &NodegraphPalette,
527    metrics: &NodegraphMetrics,
528    on_connect: ConnectFn<Msg>,
529) -> View<Msg>
530where
531    Msg: Clone + Send + Sync + 'static,
532{
533    let y_top = pin_idx as f32 * metrics.pin_row_height;
534    let row_h = metrics.pin_row_height;
535    let r = metrics.pin_radius;
536    let diam = r * 2.0;
537
538    let (pin_left, pin_right, dot_color, label_align) = match kind {
539        PinKind::Input => (
540            Some(length(-r)),
541            None,
542            palette.pin_input,
543            Alignment::Start,
544        ),
545        PinKind::Output => (
546            None,
547            Some(length(-r)),
548            palette.pin_output,
549            Alignment::End,
550        ),
551    };
552
553    let mut dot = View::new(Style {
554        position: Position::Absolute,
555        inset: Rect {
556            left: pin_left.unwrap_or_else(|| length(0.0_f32)),
557            top: length((row_h - diam) * 0.5),
558            right: pin_right.unwrap_or_else(|| length(0.0_f32)),
559            bottom: length(0.0_f32),
560        },
561        size: Size {
562            width: length(diam),
563            height: length(diam),
564        },
565        ..Default::default()
566    })
567    .fill(dot_color)
568    .radius(r as f64);
569
570    match kind {
571        PinKind::Output => {
572            dot = dot
573                .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None)
574                .drag_payload(encode_payload(node_id, pin_idx));
575        }
576        PinKind::Input => {
577            let to_node = node_id;
578            let to_pin = pin_idx;
579            let cb = on_connect.clone();
580            dot = dot
581                .on_drop(move |payload: u64| {
582                    let (from_node, from_pin) = decode_payload(payload);
583                    (cb)(from_node, from_pin, to_node, to_pin)
584                })
585                .drop_hover_fill(palette.pin_drop_hover);
586        }
587    }
588
589    let label_view = View::new(Style {
590        position: Position::Absolute,
591        inset: Rect {
592            left: length(diam + 4.0),
593            top: length(0.0),
594            right: length(diam + 4.0),
595            bottom: length(0.0),
596        },
597        size: Size {
598            width: Dimension::auto(),
599            height: length(row_h),
600        },
601        ..Default::default()
602    })
603    .text_aligned(
604        label.to_string(),
605        metrics.pin_label_size,
606        palette.fg_pin_label,
607        label_align,
608    );
609
610    View::new(Style {
611        position: Position::Absolute,
612        inset: Rect {
613            left: length(0.0),
614            top: length(y_top),
615            right: length(0.0),
616            bottom: length(0.0_f32),
617        },
618        size: Size {
619            width: percent(1.0_f32),
620            height: length(row_h),
621        },
622        ..Default::default()
623    })
624    .children(vec![label_view, dot])
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn payload_roundtrip() {
633        for (n, p) in [
634            (0u32, 0u16),
635            (1, 0),
636            (0, 1),
637            (42, 7),
638            (u32::MAX, u16::MAX),
639            (123_456, 65_535),
640        ] {
641            let enc = encode_payload(n, p);
642            let (n2, p2) = decode_payload(enc);
643            assert_eq!((n, p), (n2, p2), "payload {enc} → ({n2}, {p2})");
644        }
645    }
646
647    #[test]
648    fn metrics_node_height_grows_with_max_side() {
649        let m = NodegraphMetrics::default();
650        assert_eq!(m.node_height(3, 1), m.node_height(1, 3));
651        let min = m.title_height + m.pin_row_height + 6.0;
652        assert_eq!(m.node_height(0, 0), min);
653    }
654
655    #[test]
656    fn pin_y_progression() {
657        let m = NodegraphMetrics::default();
658        let y0 = m.input_pin_y(100.0, 0);
659        let y1 = m.input_pin_y(100.0, 1);
660        let y2 = m.input_pin_y(100.0, 2);
661        assert!(y1 - y0 > 0.0, "pins crecen hacia abajo");
662        assert!((y2 - y1) - (y1 - y0) < 1e-3, "espaciado uniforme");
663    }
664
665    #[test]
666    fn precompute_skips_dangling_wires() {
667        let nodes = vec![NodeSpec {
668            id: 1,
669            label: "solo".into(),
670            x: 0.0,
671            y: 0.0,
672            inputs: vec!["in".into()],
673            outputs: vec!["out".into()],
674        }];
675        let wires = vec![Wire {
676            from_node: 99,
677            from_output: 0,
678            to_node: 1,
679            to_input: 0,
680        }];
681        let m = NodegraphMetrics::default();
682        let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None);
683        assert!(pre.is_empty());
684    }
685
686    #[test]
687    fn precompute_resolves_existing_wires() {
688        let nodes = vec![
689            NodeSpec {
690                id: 1,
691                label: "a".into(),
692                x: 0.0,
693                y: 0.0,
694                inputs: vec![],
695                outputs: vec!["out".into()],
696            },
697            NodeSpec {
698                id: 2,
699                label: "b".into(),
700                x: 200.0,
701                y: 50.0,
702                inputs: vec!["in".into()],
703                outputs: vec![],
704            },
705        ];
706        let wires = vec![Wire {
707            from_node: 1,
708            from_output: 0,
709            to_node: 2,
710            to_input: 0,
711        }];
712        let m = NodegraphMetrics::default();
713        let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None);
714        assert_eq!(pre.len(), 1);
715        assert!((pre[0].x1 - m.node_width).abs() < 1e-3);
716        assert!((pre[0].x2 - 200.0).abs() < 1e-3);
717    }
718}