iced_nodegraph 0.5.0

High-performance node graph editor widget for Iced with SDF-based rendering
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
//! Built-in theme-driven default styles.
//!
//! Each `default_*_style` translates the iced [`Theme`] palette into one
//! complete, concrete style. It is both what the widget draws when no closure is
//! set, and the base a user closure overrides via struct-update - so a host
//! closure can always reach everything the default reaches.
//!
//! Every color comes from [`Roles`], the single theme-to-graph mapping, so the
//! same relationships hold in all 22 built-in themes: node surfaces are
//! elevations of the canvas, edges and pins are two rungs of one legibility
//! ladder, and the accents are reserved - `primary` for selection, `danger` for
//! cutting. Geometry (radii, widths, distances) is theme-independent and lives
//! here; the only branch on the palette is light-versus-dark shadow weight,
//! which is a genuine physical difference rather than a hue mapping.
//!
//! The per-element defaults take a status and express its feedback in full: a
//! selected node is not just a recolored border (see [`default_node_style`]), and
//! an edge marked for cutting takes the cutting tool's own color. The chrome
//! defaults ([`default_selection_box_style`], [`default_cutting_tool_style`],
//! [`default_minimap_style`]) have no status: what they draw is either on
//! screen or absent.
//!
//! ```rust,no_run
//! use iced::{widget::text, Color, Point};
//! use iced_nodegraph::{Indexed, Node, NodeStyle, default_node_style, node};
//!
//! # #[derive(Debug, Clone)]
//! # enum Message {}
//! # let (pos, body) = (Point::ORIGIN, text("body"));
//! let n: Node<'_, Indexed, Message, iced::Theme, iced::Renderer> = node(0, pos, body)
//!     .style(|theme, status| NodeStyle {
//!         fill_color: Color::WHITE.into(),      // user override wins
//!         ..default_node_style(theme, status)   // theme base + status fills the rest
//!     });
//! ```
//!
//! Geometry lives here too, as named constants rather than factors applied to
//! whatever a style happens to hold: a pin's drawn radius is
//! [`PinStyle::radius`](crate::PinStyle::radius) verbatim, and the well it opens
//! in the node body is its own field.

use iced_nodegraph_sdf::Pattern;
use iced_widget::core::{Color, Theme};

use super::roles::Roles;
use super::{
    AnchorStatus, AnchorStyle, CuttingToolStyle, EdgeCurve, EdgeStatus, EdgeStyle, GraphStyle,
    MinimapStyle, NodeStatus, NodeStyle, ParticleStyle, PinShape, PinStatus, PinStyle,
    SelectionBoxStyle, TilingBackground, ramp,
};

/// Corner radius of a node body, in world units.
const NODE_CORNER_RADIUS: f32 = 5.0;

/// How far a selected node's body is pulled toward the accent.
///
/// Enough to identify the node when its border is off-screen, small enough that
/// hosted content - colored by the application for the theme's background - stays
/// readable on it.
const SELECTION_TINT: f32 = 0.12;

/// Drawn radius of a pin indicator, in world units.
///
/// A mark you aim at, sized to be aimed at: at zoom 1 this is a 10 pixel dot,
/// comfortably inside [`PIN_CLICK_THRESHOLD`] so the visible pin is always
/// smaller than the area that accepts a click, never larger.
const PIN_RADIUS: f32 = 5.0;

/// Radius of the well a pin opens in the node body, in world units.
///
/// [`PIN_CLICK_THRESHOLD`], deliberately: the well is the body stepping aside
/// for a pin, and how far it steps aside should be how far the pin's hit area
/// reaches. That is a property of the interaction, not of how big the mark
/// happens to be drawn - scaling this off [`PIN_RADIUS`] would make restyling a
/// pin silently reshape the node and desync the two.
///
/// It also leaves room for the halo a valid drop target wears, which fills
/// exactly the gap between the two.
const PIN_CUTOUT_RADIUS: f32 = crate::node_graph::PIN_CLICK_THRESHOLD;

/// Complete theme-derived node style, with the selected look expressed in full
/// rather than as a border tweak.
///
/// A node is an OPAQUE card one elevation step above the canvas. Opacity is not a
/// styling knob here: a translucent body lets the grid and the edges running
/// behind it show through the content, which is the single loudest way to make a
/// node read as an overlay instead of an object.
///
/// A selected node reads as *brought forward*: its body is tinted toward the
/// accent, the border takes the accent and gains a translucent halo, and the drop
/// shadow deepens - reinforcing the z-promotion the widget already applies. All
/// of it is style-level (color bands and a stop chain), so switching selection
/// does not touch node geometry or the shape cache.
///
/// Override any of it by returning your own [`NodeStyle`] from the node's
/// `style` closure; the closure receives the [`NodeStatus`], so a host is not
/// limited to recoloring the border.
pub fn default_node_style(theme: &Theme, status: NodeStatus) -> NodeStyle {
    let roles = Roles::of(theme);

    // Shadow weight is genuinely light/dark dependent: a black shadow barely
    // registers against a dark canvas, so the dark variant leans on the border
    // for silhouette and keeps the shadow as a hint of depth, while the light
    // variant lets the shadow do the separating.
    let (shadow_alpha, shadow_distance) = if roles.is_dark {
        (0.38, 7.0)
    } else {
        (0.22, 9.0)
    };

    let base = NodeStyle {
        fill_color: roles.body.into(),
        corner_radius: NODE_CORNER_RADIUS,
        opacity: 1.0,
        border_color: roles.border.into(),
        border_pattern: Pattern::solid(1.0),
        border_outline_width: 0.0,
        border_outline_color: Color::TRANSPARENT.into(),
        shadow_color: Color::from_rgba(0.0, 0.0, 0.0, shadow_alpha),
        shadow_distance,
        shadow_offset: (0.0, 3.0),
    };

    match status {
        NodeStatus::Idle => base,
        NodeStatus::Selected => NodeStyle {
            fill_color: ramp::blend(roles.body, roles.accent, SELECTION_TINT).into(),
            border_color: roles.accent.into(),
            border_pattern: Pattern::solid(2.0),
            // An outward band on the silhouette, so the halo reads at any zoom
            // without moving the outline.
            border_outline_width: 3.0,
            border_outline_color: Color {
                a: 0.28,
                ..roles.accent
            }
            .into(),
            shadow_distance: shadow_distance * 1.6,
            ..base
        },
    }
}

/// Complete theme-derived pin style, with the valid-target state expressed as a
/// color change plus a halo.
///
/// An idle pin is a MARK: it takes the wire's ladder one rung brighter rather
/// than the selection accent, so "this node is selected" and "this is a
/// connection point" never resolve to the same color, and so a theme whose
/// `primary` collides with its background still has visible pins. A filled dot
/// needs no border, exactly as iced's slider handle carries none.
///
/// A valid drop target is the one moment a pin earns an accent, and it gets its
/// own: `success` reads as "this connection would be accepted", leaving
/// `primary` to selection and `danger` to cutting. The halo is a translucent
/// ring drawn outside the indicator, filling the pin's cutout exactly.
///
/// The feedback is a still image, not motion, and every field it touches is a
/// color band. Both pin states resolve to the same indicator recipe and the same
/// node silhouette, so a drag repaints what the SDF renderer already has resident
/// rather than making it rebuild a shape per frame.
pub fn default_pin_style(theme: &Theme, status: PinStatus) -> PinStyle {
    let roles = Roles::of(theme);

    let base = PinStyle {
        color: roles.terminal.into(),
        radius: PIN_RADIUS,
        shape: PinShape::Circle,
        cutout_radius: PIN_CUTOUT_RADIUS,
        border_color: Color::TRANSPARENT.into(),
        border_width: 0.0,
    };

    match status {
        PinStatus::Idle => base,
        PinStatus::ValidTarget => PinStyle {
            color: roles.valid.into(),
            border_color: Color {
                a: 0.4,
                ..roles.valid
            }
            .into(),
            border_width: PIN_CUTOUT_RADIUS - PIN_RADIUS,
            // The cutout is geometry: holding it across statuses keeps one node
            // silhouette in the shape cache instead of one per drag state.
            ..base
        },
    }
}

/// Complete theme-derived anchor style.
///
/// The core is furniture, not a mark: a 6 unit dot on the node recipe (body
/// fill, border silhouette), because it is a thing you grab rather than a thing
/// you aim at, while an occupied orbit's ring takes the wire color because it is
/// the path a cable runs on. `Selected` mirrors the node's accent border, and
/// `ValidTarget` mirrors the pin's `success`-derived valid color, so the three
/// accents keep meaning exactly one thing each across the whole widget.
///
/// `orbit_offset`/`orbit_spacing` are geometry, not paint: they are the radii
/// cables are laid tangent to, resolved before the path is built, so changing
/// them reshapes the cable rather than recoloring it. Orbit 0 stands 8 units
/// clear of the core's edge, and the spacing is wide enough that two wraps read
/// as separate strands at zoom 1.
pub fn default_anchor_style(theme: &Theme, status: AnchorStatus) -> AnchorStyle {
    let roles = Roles::of(theme);

    let base = AnchorStyle {
        core_size: crate::node_graph::DEFAULT_CORE_SIZE,
        core_radius: 3.0,
        core_color: roles.body.into(),
        core_border_color: roles.border.into(),
        core_border_width: 1.0,
        orbit_offset: crate::node_graph::DEFAULT_ORBIT_OFFSET,
        orbit_spacing: crate::node_graph::DEFAULT_ORBIT_SPACING,
        ring_color: Color {
            a: 0.35,
            ..roles.wire
        }
        .into(),
        ring_width: 1.0,
        offered_ring_color: Color {
            a: 0.1575,
            ..roles.wire
        }
        .into(),
    };

    match status {
        AnchorStatus::Idle => base,
        AnchorStatus::Hovered => AnchorStyle {
            core_border_color: Color {
                a: 0.6,
                ..roles.accent
            }
            .into(),
            core_border_width: 1.5,
            ..base
        },
        AnchorStatus::ValidTarget => AnchorStyle {
            core_color: roles.valid.into(),
            ring_color: Color {
                a: 0.7,
                ..roles.valid
            }
            .into(),
            offered_ring_color: Color {
                a: 0.315,
                ..roles.valid
            }
            .into(),
            ..base
        },
    }
}

/// Complete theme-derived edge style with status feedback: `Idle` is a 2px solid
/// stroke in the theme's wire color; `PendingCut` tints the stroke with the
/// theme's edge-cutting color.
///
/// The default stroke is a single concrete color. To make an edge follow its
/// connected pins (e.g. a port-typed color), build the gradient from each
/// endpoint's [`PinInfo`](crate::PinInfo) in the edge `style` closure and
/// struct-update over this base.
pub fn default_edge_style(theme: &Theme, status: EdgeStatus) -> EdgeStyle {
    let roles = Roles::of(theme);
    // Unused-color sentinel for the off fields (border, outlines, shadow).
    let none = Color::TRANSPARENT;
    let base = EdgeStyle {
        stroke_color: roles.wire.into(),
        pattern: Pattern::solid(2.0),
        stroke_outline_width: 0.0,
        stroke_outline_color: none.into(),
        border_color: none.into(),
        border_width: 0.0,
        border_gap: 0.5,
        border_outline_width: 0.0,
        border_outline_color: none.into(),
        border_background: none.into(),
        shadow_color: none.into(),
        shadow_expand: 0.0,
        shadow_blur: 0.0,
        shadow_offset: (0.0, 0.0),
        glow_color: Color {
            a: 0.45,
            ..roles.wire
        }
        .into(),
        glow_width: 6.0,
        curve: EdgeCurve::BezierCubic,
    };

    match status {
        EdgeStatus::Idle => base,
        EdgeStatus::PendingCut => EdgeStyle {
            // An edge marked for cutting takes the cutting tool's own color, so
            // the trail and its victims read as one gesture.
            stroke_color: roles.danger.into(),
            glow_color: Color {
                a: 0.45,
                ..roles.danger
            }
            .into(),
            ..base
        },
    }
}

/// Theme-derived style of the selection box.
///
/// The accent hue at two alphas: a translucent wash so nodes stay legible
/// underneath, and an opaque-enough outline to read against both.
pub fn default_selection_box_style(theme: &Theme) -> SelectionBoxStyle {
    let accent = Roles::of(theme).accent;
    SelectionBoxStyle {
        fill: Color { a: 0.15, ..accent },
        border_color: Color { a: 0.75, ..accent },
        border_width: 1.5,
    }
}

/// Theme-derived style of the edge-cutting trail.
///
/// Cutting is destructive, so it paints in the theme's `danger` color rather
/// than the accent.
pub fn default_cutting_tool_style(theme: &Theme) -> CuttingToolStyle {
    CuttingToolStyle {
        color: Roles::of(theme).danger,
        width: 3.0,
    }
}

/// Theme-derived style of an edge particle: the accent color, since a
/// particle marks activity the way the selection does.
pub fn default_particle_style(theme: &Theme) -> ParticleStyle {
    ParticleStyle {
        color: Roles::of(theme).accent,
        radius: 4.0,
    }
}

/// Theme-derived canvas: the theme's window background untouched, and a grid
/// one perceptual elevation step above it - the same ladder node bodies ride
/// on, so canvas, grid and node read as one material at three depths.
///
/// The grid is opaque rather than a translucent wash: an alpha over the canvas
/// makes the line's weight depend on what it happens to cross, and nothing
/// crosses an infinite plane predictably.
pub fn default_graph_style(theme: &Theme) -> GraphStyle {
    let roles = Roles::of(theme);
    GraphStyle {
        background_color: roles.canvas,
        tiling: Some(TilingBackground::grid(40.0, 1.0, roles.grid)),
    }
}

/// Theme-derived style of the minimap overlay.
///
/// The pane is a node body's elevation, slightly translucent: the map is a
/// surface above the canvas, and reading as one is what separates it from the
/// graph it floats over. The node marks are the wire rung of the legibility
/// ladder and the viewport rectangle the terminal rung above it, at two alphas
/// like the selection box. The accent stays reserved for selection, which is
/// exactly what [`selected_node_color`](MinimapStyle::selected_node_color)
/// spends it on.
pub fn default_minimap_style(theme: &Theme) -> MinimapStyle {
    let roles = Roles::of(theme);
    MinimapStyle {
        background: Color {
            a: 0.9,
            ..roles.body
        },
        border_color: roles.border,
        border_width: 1.0,
        node_color: roles.wire,
        selected_node_color: roles.accent,
        viewport_fill: Color {
            a: 0.12,
            ..roles.terminal
        },
        viewport_border_color: Color {
            a: 0.8,
            ..roles.terminal
        },
        viewport_border_width: 1.0,
    }
}

#[cfg(test)]
mod tests {
    use super::super::ColorQuad;
    use super::*;

    /// Selection is expressed on four independent channels, not just the border,
    /// and every one of them is style-level: switching selection must not cost a
    /// geometry rebuild. Asserted across every theme, since a channel that
    /// collapses does so in the palette that made its accent awkward, not in
    /// `Theme::Dark`.
    #[test]
    fn selected_node_reads_as_brought_forward() {
        for theme in Theme::ALL {
            let idle = default_node_style(theme, NodeStatus::Idle);
            let sel = default_node_style(theme, NodeStatus::Selected);

            assert_ne!(
                sel.border_color, idle.border_color,
                "{theme}: the border must leave the neutral ramp for the accent",
            );
            assert_ne!(
                sel.fill_color, idle.fill_color,
                "{theme}: the body must carry the selection when the border is \
                 off-screen",
            );
            assert!(
                sel.border_outline_width > 0.0,
                "{theme}: a halo ring distinguishes selection at any zoom",
            );
            assert!(
                sel.shadow_distance > idle.shadow_distance,
                "{theme}: the shadow deepens, reinforcing the z-promotion",
            );

            // Geometry-affecting fields must match, or the shape cache gains an
            // entry per selection state.
            assert_eq!(sel.corner_radius, idle.corner_radius);
            assert_eq!(sel.shadow_offset, idle.shadow_offset);
        }
    }

    /// A node is an object, not an overlay. A translucent body shows the grid and
    /// the edges running behind it straight through the content, which is the one
    /// change that makes every theme look cheap at once - so no theme may opt out.
    #[test]
    fn a_node_body_is_opaque_in_every_theme() {
        for theme in Theme::ALL {
            for status in [NodeStatus::Idle, NodeStatus::Selected] {
                assert_eq!(
                    default_node_style(theme, status).opacity,
                    1.0,
                    "{theme}: a node body must be opaque",
                );
            }
        }
    }

    /// Pins are marks, not accents. Sharing `primary` with selection would make
    /// "this node is selected" and "this is a connection point" the same color,
    /// and would hide the pins of any theme whose `primary` collides with its
    /// background. Holds in both pin states: a valid target has its own accent.
    #[test]
    fn a_pin_never_borrows_the_selection_accent() {
        for theme in Theme::ALL {
            let selected = default_node_style(theme, NodeStatus::Selected).border_color;
            for status in [PinStatus::Idle, PinStatus::ValidTarget] {
                assert_ne!(
                    default_pin_style(theme, status).color,
                    selected,
                    "{theme}: a {status:?} pin wears the selection accent",
                );
            }
        }
    }

    /// A drop target you cannot see is a drag you have to guess at. `ValidTarget`
    /// must differ from `Idle` in fill AND wear a halo, in every theme - the
    /// status argument is not decoration on the signature.
    #[test]
    fn a_valid_drop_target_is_visible_in_every_theme() {
        for theme in Theme::ALL {
            let idle = default_pin_style(theme, PinStatus::Idle);
            let valid = default_pin_style(theme, PinStatus::ValidTarget);

            assert_ne!(
                valid.color, idle.color,
                "{theme}: a valid drop target paints like an idle pin",
            );
            assert!(
                valid.border_width > 0.0,
                "{theme}: a valid drop target has no halo",
            );
        }
    }

    /// Valid-target feedback must be paint only.
    ///
    /// Every geometry-bearing pin field has to survive a status change: `radius`
    /// and `shape` decide the indicator's recipe, `cutout_radius` the node
    /// silhouette that is punched around it, and both are content-addressed
    /// shapes held across frames. A default that moved any of them would rebuild
    /// and re-cache a shape per drag state, on every node in the graph, for the
    /// duration of a drag. The remaining fields are color bands, which are free.
    #[test]
    fn valid_target_feedback_costs_no_geometry() {
        for theme in Theme::ALL {
            let idle = default_pin_style(theme, PinStatus::Idle);
            let valid = default_pin_style(theme, PinStatus::ValidTarget);

            assert_eq!(valid.radius, idle.radius, "{theme}: the indicator resizes");
            assert_eq!(valid.shape, idle.shape, "{theme}: the indicator reshapes");
            assert_eq!(
                valid.cutout_radius, idle.cutout_radius,
                "{theme}: the node silhouette changes with pin status",
            );
        }
    }

    /// The well has to be wider than the mark in it, or the pin overruns the hole
    /// and sits on the body's own border instead of in a socket. The halo is sized
    /// to fill exactly that gap.
    #[test]
    fn a_pin_fits_inside_the_well_it_opens() {
        let idle = default_pin_style(&Theme::Dark, PinStatus::Idle);
        assert!(
            idle.cutout_radius > idle.radius,
            "the pin overruns its well"
        );

        let valid = default_pin_style(&Theme::Dark, PinStatus::ValidTarget);
        assert_eq!(
            valid.radius + valid.border_width,
            valid.cutout_radius,
            "the valid-target halo must reach the rim of the well, no further",
        );
    }

    /// An edge marked for cutting must take the cutting tool's own color, so the
    /// trail and the edges it will destroy read as one gesture.
    #[test]
    fn pending_cut_tints_stroke_with_the_cutting_tool_color() {
        let t = Theme::Dark;
        let o = default_edge_style(&t, EdgeStatus::PendingCut);
        assert_eq!(
            o.stroke_color,
            ColorQuad::solid(default_cutting_tool_style(&t).color)
        );
    }
}