Skip to main content

demo_hello_world/
lib.rs

1// `SpawnNode` carries a whole `NodeType`, whose config variants hold every
2// style field of a node, so the message enum is inherently lopsided.
3#![allow(clippy::large_enum_variant)]
4#![doc = include_str!("../README.md")]
5#![doc = r#"<link rel="stylesheet" href="../gallery/pkg/demo.css"><script type="module" src="../gallery/pkg/demo-loader.js"></script>"#]
6
7mod ids;
8mod nodes;
9#[cfg(not(target_arch = "wasm32"))]
10mod persistence;
11mod rig;
12mod style_overlay;
13
14// The trait methods are named directly only by the native `main`.
15#[cfg(not(target_arch = "wasm32"))]
16use demo_common::Demo;
17use iced::{
18    Color, Event, Length, Point, Size, Subscription, Task, Theme, Vector, event, keyboard,
19    widget::{container, opaque, stack, text},
20    window,
21};
22use iced_nodegraph::{
23    AnchorStatus, AnchorStyle, ColorQuad, CuttingToolStyle, EdgeStatus, EdgeStyle, FocusOptions,
24    FocusTarget, GraphStyle, Minimap, MinimapStyle, NodeStatus, NodeStyle, PinRef, PinStatus,
25    PinStyle, SelectionBoxStyle, anchor as ng_anchor, default_anchor_style,
26    default_cutting_tool_style, default_edge_style, default_graph_style, default_minimap_style,
27    default_node_style, default_pin_style, default_selection_box_style, edge as ng_edge,
28    node as ng_node,
29};
30use iced_nodegraph::{EdgeCurve, PinShape, TilingKind};
31use iced_palette::{
32    Command, Shortcut, command, command_palette, find_matching_shortcut, focus_input,
33    get_filtered_command_index, get_filtered_count, is_toggle_shortcut, navigate_down, navigate_up,
34};
35use ids::{EdgeData, EdgeId, HelloIds, NodeId, PinLabel, generate_edge_id, generate_node_id};
36use nodes::{
37    AlphaNode, AnchorConfigInputs, BoolToggleConfig, ClassCandidate, ColorQuadNode, ConfigNodeType,
38    CuttingToolConfigInputs, EdgeConfigInputs, EdgeSection, EdgeSections, FloatSliderConfig,
39    GraphConfigInputs, InputNodeType, IntSliderConfig, MathNodeState, MathOperation,
40    MinimapConfigInputs, NodeConfigInputs, NodeSection, NodeSections, NodeType, NodeValue,
41    PatternType, PinConfigInputs, SelectionBoxConfigInputs, Vec2Node, alpha_node,
42    anchor_config_node, bool_toggle_node, catalog_node, color_picker_node, color_preset_node,
43    color_quad_node, cutting_tool_config_node, edge_config_node, edge_curve_selector_node,
44    float_slider_node, frame_node, graph_config_node, int_slider_node, math_node,
45    minimap_config_node, node, node_class_node, node_config_node, pattern_type_selector_node,
46    pin_config_node, pin_shape_selector_node, selection_box_config_node, theme_extended_node,
47    theme_node, tiling_kind_selector_node, vec2_node,
48};
49use std::collections::{HashMap, HashSet};
50use style_overlay::{
51    AnchorOverlay, CuttingToolOverlay, EdgeOverlay, GraphOverlay, MinimapOverlay, NodeOverlay,
52    PinOverlay, SelectionBoxOverlay,
53};
54
55/// Runs the demo natively, restoring the persisted graph and window geometry.
56///
57/// The gallery boots the pristine scene through [`Demo::boot`] instead, so this
58/// entry point and the `persistence` module it needs are native-only.
59#[cfg(not(target_arch = "wasm32"))]
60pub fn main() -> iced::Result {
61    let window_settings = {
62        // Try to load saved window settings
63        let (position, size, maximized) = persistence::load_state()
64            .map(|s| (s.window_position, s.window_size, s.window_maximized))
65            .unwrap_or((None, None, None));
66
67        iced::window::Settings {
68            position: position
69                .map(|(x, y)| iced::window::Position::Specific(Point::new(x as f32, y as f32)))
70                .unwrap_or(iced::window::Position::Centered),
71            size: size
72                .map(|(w, h)| iced::Size::new(w as f32, h as f32))
73                .unwrap_or(iced::Size::new(1280.0, 800.0)),
74            maximized: maximized.unwrap_or(false),
75            ..Default::default()
76        }
77    };
78
79    iced::application(
80        Application::new_persisted,
81        Application::update,
82        Application::view,
83    )
84    .subscription(Application::subscription)
85    .title("Hello World - iced_nodegraph Demo")
86    .theme(Application::theme)
87    .window(window_settings)
88    .run()
89}
90
91#[derive(Debug, Clone)]
92enum ApplicationMessage {
93    EdgeConnected {
94        from: PinRef<HelloIds>,
95        to: PinRef<HelloIds>,
96    },
97    EdgeDisconnected {
98        from: PinRef<HelloIds>,
99        to: PinRef<HelloIds>,
100    },
101    /// Edges the cutting tool destroyed, named by their own ids.
102    EdgesCut(Vec<EdgeId>),
103    // Routing anchors
104    AnchorCreated {
105        edge: EdgeId,
106        position: Point,
107    },
108    AnchorMoved {
109        anchor: usize,
110        position: Point,
111    },
112    AnchorDeleted(usize),
113    RouteAttached {
114        edge: EdgeId,
115        anchor: usize,
116    },
117    RouteDetached {
118        edge: EdgeId,
119        anchor: usize,
120    },
121    /// Every gesture that can orphan an anchor ends here, which is when one no
122    /// cable names any more is safe to drop: during a drag it is still a target
123    /// the same drag may put the cable back onto.
124    DragEnded,
125    ToggleCommandPalette,
126    CommandPaletteInput(String),
127    CommandPaletteNavigateUp,
128    CommandPaletteNavigateDown,
129    CommandPaletteSelect(usize),
130    CommandPaletteConfirm,
131    CommandPaletteCancel,
132    ExecuteShortcut(String),
133    CommandPaletteNavigate(usize),
134    SpawnNode {
135        node_type: NodeType,
136    },
137    ChangeTheme(Theme),
138    CameraChanged {
139        position: Point,
140        zoom: f32,
141    },
142    /// Flies the camera to the fixed demo node.
143    FocusNode,
144    WindowResized(iced::Size),
145    WindowMoved(Point),
146    WindowMaximizedChanged(bool),
147    NavigateToSubmenu(String),
148    // Selection-related messages
149    SelectionChanged(Vec<NodeId>),
150    CloneNodes(Vec<NodeId>),
151    DeleteNodes(Vec<NodeId>),
152    NodesMoved {
153        delta: Vector,
154        node_ids: Vec<NodeId>,
155    },
156    // State export for Claude
157    ExportState,
158    /// Reset the entire app to its initial state (clears graph, config, and the
159    /// persisted save file).
160    Reset,
161    /// Move keyboard focus to the next / previous focusable widget (Tab /
162    /// Shift+Tab), e.g. between a slider node's min/max/step fields.
163    FocusNext,
164    FocusPrevious,
165    // Input node value changes
166    SliderChanged {
167        node_id: NodeId,
168        value: f32,
169    },
170    IntSliderChanged {
171        node_id: NodeId,
172        value: i32,
173    },
174    BoolChanged {
175        node_id: NodeId,
176        value: bool,
177    },
178    EdgeCurveChanged {
179        node_id: NodeId,
180        value: EdgeCurve,
181    },
182    PinShapeChanged {
183        node_id: NodeId,
184        value: PinShape,
185    },
186    PatternTypeChanged {
187        node_id: NodeId,
188        value: PatternType,
189    },
190    TilingKindChanged {
191        node_id: NodeId,
192        value: TilingKind,
193    },
194    ColorChanged {
195        node_id: NodeId,
196        color: Color,
197    },
198    // Collapsible node messages
199    ToggleNodeExpanded {
200        node_id: NodeId,
201    },
202    UpdateFloatSliderConfig {
203        node_id: NodeId,
204        config: FloatSliderConfig,
205    },
206    UpdateIntSliderConfig {
207        node_id: NodeId,
208        config: IntSliderConfig,
209    },
210    // Config section collapse/expand
211    ToggleEdgeSection {
212        node_id: NodeId,
213        section: EdgeSection,
214    },
215    ToggleNodeSection {
216        node_id: NodeId,
217        section: NodeSection,
218    },
219    /// The Node Class node's pick list chose (or lost) its target node.
220    NodeClassTargetChanged {
221        node_id: NodeId,
222        target: Option<NodeId>,
223    },
224}
225
226#[derive(Debug, Clone, PartialEq)]
227enum PaletteView {
228    Main,
229    Submenu(String),
230}
231
232/// Output types from config nodes for propagation
233#[derive(Debug, Clone)]
234enum ConfigOutput {
235    Node(NodeOverlay),
236    Edge(EdgeOverlay),
237    Pin(PinOverlay),
238    Graph(GraphOverlay),
239    Anchor(AnchorOverlay),
240    SelectionBox(SelectionBoxOverlay),
241    CuttingTool(CuttingToolOverlay),
242    Minimap(MinimapOverlay),
243}
244
245/// Alpha of an edge's hover glow, over the same type colors as its stroke.
246const EDGE_GLOW_OPACITY: f32 = 0.45;
247
248/// Maps a pin's data-type marker to its semantic color. Pins carry no style;
249/// the owning node colors them via `pin_style`, keyed on this.
250fn pin_color_for(ty: std::any::TypeId) -> Color {
251    use nodes::{colors, pins};
252    use std::any::TypeId;
253    if ty == TypeId::of::<pins::ColorData>() {
254        colors::PIN_COLOR
255    } else if ty == TypeId::of::<pins::Float>() || ty == TypeId::of::<pins::Int>() {
256        colors::PIN_NUMBER
257    } else if ty == TypeId::of::<pins::Bool>() {
258        colors::PIN_BOOL
259    } else if ty == TypeId::of::<pins::StringData>() {
260        colors::PIN_STRING
261    } else if ty == TypeId::of::<pins::Email>() {
262        colors::PIN_EMAIL
263    } else if ty == TypeId::of::<pins::DateTime>() {
264        colors::PIN_DATETIME
265    } else if ty == TypeId::of::<pins::NodeConfigData>()
266        || ty == TypeId::of::<pins::EdgeConfigData>()
267        || ty == TypeId::of::<pins::PinConfigData>()
268        || ty == TypeId::of::<pins::GraphConfigData>()
269        || ty == TypeId::of::<pins::AnchorConfigData>()
270        || ty == TypeId::of::<pins::SelectionBoxConfigData>()
271        || ty == TypeId::of::<pins::CuttingToolConfigData>()
272        || ty == TypeId::of::<pins::MinimapConfigData>()
273    {
274        colors::PIN_CONFIG
275    } else {
276        colors::PIN_ANY
277    }
278}
279
280/// The widget id of the demo's graph, addressed by the
281/// [`iced_nodegraph::focus`] task the "Focus node" command runs.
282fn graph_id() -> iced::widget::Id {
283    iced::widget::Id::new("graph")
284}
285
286/// The overlays the config rig produced, one per `iced_nodegraph::Catalog`
287/// class and status, resolved against the theme base at draw time. A status
288/// overlay is layered over its idle base by the accessors, the way the
289/// library's `Selected` default is a struct-update over the idle one.
290#[derive(Debug, Clone, Default)]
291struct ComputedCatalog {
292    node: NodeOverlay,
293    node_selected: NodeOverlay,
294    pin: PinOverlay,
295    pin_valid_target: PinOverlay,
296    edge: EdgeOverlay,
297    edge_pending_cut: EdgeOverlay,
298    drag_edge: EdgeOverlay,
299    anchor: AnchorOverlay,
300    anchor_hovered: AnchorOverlay,
301    anchor_valid_target: AnchorOverlay,
302    drag_anchor: AnchorOverlay,
303    graph: GraphOverlay,
304    selection_box: SelectionBoxOverlay,
305    cutting_tool: CuttingToolOverlay,
306    minimap: MinimapOverlay,
307    /// Per-node classes from `NodeClass` nodes, keyed by target node.
308    node_classes: HashMap<NodeId, NodeOverlay>,
309}
310
311impl ComputedCatalog {
312    fn node(&self, status: NodeStatus) -> NodeOverlay {
313        match status {
314            NodeStatus::Idle => self.node.clone(),
315            NodeStatus::Selected => self.node_selected.merge(&self.node),
316        }
317    }
318
319    fn pin(&self, status: PinStatus) -> PinOverlay {
320        match status {
321            PinStatus::Idle => self.pin.clone(),
322            PinStatus::ValidTarget => self.pin_valid_target.merge(&self.pin),
323        }
324    }
325
326    fn edge(&self, status: EdgeStatus) -> EdgeOverlay {
327        match status {
328            EdgeStatus::Idle => self.edge.clone(),
329            EdgeStatus::PendingCut => self.edge_pending_cut.merge(&self.edge),
330        }
331    }
332
333    fn anchor(&self, status: AnchorStatus) -> AnchorOverlay {
334        match status {
335            AnchorStatus::Idle => self.anchor.clone(),
336            AnchorStatus::Hovered => self.anchor_hovered.merge(&self.anchor),
337            AnchorStatus::ValidTarget => self.anchor_valid_target.merge(&self.anchor),
338        }
339    }
340}
341
342struct Application {
343    /// Nodes stored by unique ID
344    nodes: HashMap<NodeId, (Point, NodeType)>,
345    /// Node order for deterministic iteration
346    node_order: Vec<NodeId>,
347    /// Edges stored by unique ID
348    edges: HashMap<EdgeId, EdgeData>,
349    /// Edge order for deterministic iteration
350    edge_order: Vec<EdgeId>,
351    /// Currently selected nodes
352    selected_nodes: HashSet<NodeId>,
353    /// Nodes with expanded options panels
354    expanded_nodes: HashSet<NodeId>,
355    /// Section expansion state for EdgeConfig nodes
356    edge_config_sections: HashMap<NodeId, EdgeSections>,
357    /// Section expansion state for NodeConfig nodes
358    node_config_sections: HashMap<NodeId, NodeSections>,
359    command_palette_open: bool,
360    command_input: String,
361    current_theme: Theme,
362    palette_view: PaletteView,
363    palette_selected_index: usize,
364    palette_preview_theme: Option<Theme>,
365    palette_original_theme: Option<Theme>,
366    /// The overlays the config rig produced this propagation
367    computed: ComputedCatalog,
368    /// Config outputs queued per sink node (Catalog or Node Class) until phase 4
369    pending_configs: HashMap<NodeId, Vec<(PinLabel, ConfigOutput)>>,
370    /// Anchor id paired with its world position.
371    anchors: Vec<(usize, Point)>,
372    /// The next anchor id to mint. Only ever grows, so a deleted anchor's id is
373    /// never handed to a different anchor.
374    next_anchor: usize,
375    /// Current viewport size for spawn-at-center calculation
376    viewport_size: iced::Size,
377    /// Current camera position from NodeGraph
378    camera_position: Point,
379    /// Current camera zoom from NodeGraph
380    camera_zoom: f32,
381    /// Window position (x, y) for persistence
382    window_position: Option<(i32, i32)>,
383    /// Window size (width, height) for persistence
384    window_size: Option<(u32, u32)>,
385    /// Whether window is maximized
386    window_maximized: Option<bool>,
387}
388
389impl Default for Application {
390    fn default() -> Self {
391        use nodes::pins::workflow;
392
393        // Create nodes with stable NanoIDs
394        let node0_id = generate_node_id();
395        let node1_id = generate_node_id();
396        let node2_id = generate_node_id();
397        let node3_id = generate_node_id();
398
399        let mut nodes = HashMap::new();
400        nodes.insert(
401            node0_id.clone(),
402            (
403                Point::new(45.5, 149.0),
404                NodeType::Workflow("email_trigger".to_string()),
405            ),
406        );
407        nodes.insert(
408            node1_id.clone(),
409            (
410                Point::new(274.5, 227.5),
411                NodeType::Workflow("email_parser".to_string()),
412            ),
413        );
414        nodes.insert(
415            node2_id.clone(),
416            (
417                Point::new(459.5, 432.5),
418                NodeType::Workflow("filter".to_string()),
419            ),
420        );
421        nodes.insert(
422            node3_id.clone(),
423            (
424                Point::new(679.0, 252.5),
425                NodeType::Workflow("calendar".to_string()),
426            ),
427        );
428
429        let mut node_order = vec![
430            node0_id.clone(),
431            node1_id.clone(),
432            node2_id.clone(),
433            node3_id.clone(),
434        ];
435
436        // Create edges with stable NanoIDs and string pin labels
437        let mut edges = HashMap::new();
438        let mut edge_order = Vec::new();
439
440        // Edge 0: email_trigger "on email" -> email_parser "email"
441        let edge0_id = generate_edge_id();
442        edges.insert(
443            edge0_id.clone(),
444            EdgeData::new(
445                node0_id.clone(),
446                workflow::ON_EMAIL,
447                node1_id.clone(),
448                workflow::EMAIL,
449            ),
450        );
451        edge_order.push(edge0_id);
452
453        // Edge 1: email_parser "subject" -> filter "input"
454        let edge1_id = generate_edge_id();
455        edges.insert(
456            edge1_id.clone(),
457            EdgeData::new(
458                node1_id.clone(),
459                workflow::SUBJECT,
460                node2_id.clone(),
461                workflow::INPUT,
462            ),
463        );
464        edge_order.push(edge1_id);
465
466        // Edge 2: email_parser "datetime" -> calendar "datetime", wrapped
467        // around the boot anchor so the anchor class is visible at boot.
468        let edge2_id = generate_edge_id();
469        edges.insert(
470            edge2_id.clone(),
471            EdgeData {
472                route: vec![0],
473                ..EdgeData::new(
474                    node1_id.clone(),
475                    workflow::DATETIME,
476                    node3_id.clone(),
477                    workflow::DATETIME,
478                )
479            },
480        );
481        edge_order.push(edge2_id);
482
483        // Edge 3: filter "matches" -> calendar "title"
484        let edge3_id = generate_edge_id();
485        edges.insert(
486            edge3_id.clone(),
487            EdgeData::new(
488                node2_id.clone(),
489                workflow::MATCHES,
490                node3_id.clone(),
491                workflow::TITLE,
492            ),
493        );
494        edge_order.push(edge3_id);
495
496        // The config rig sits to the right of the workflow (which ends at x
497        // about 900) and classes the calendar node.
498        let rig = rig::build(Point::new(1000.0, 0.0), node3_id);
499        for (id, position, node_type) in rig.nodes {
500            nodes.insert(id.clone(), (position, node_type));
501            node_order.push(id);
502        }
503        for (id, edge) in rig.edges {
504            edges.insert(id.clone(), edge);
505            edge_order.push(id);
506        }
507
508        let mut app = Self {
509            nodes,
510            node_order,
511            edges,
512            edge_order,
513            selected_nodes: HashSet::new(),
514            expanded_nodes: HashSet::new(),
515            edge_config_sections: HashMap::new(),
516            node_config_sections: HashMap::new(),
517            command_palette_open: false,
518            command_input: String::new(),
519            current_theme: Theme::CatppuccinFrappe,
520            palette_view: PaletteView::Main,
521            palette_selected_index: 0,
522            palette_preview_theme: None,
523            palette_original_theme: None,
524            computed: ComputedCatalog::default(),
525            pending_configs: HashMap::new(),
526            anchors: vec![(0, Point::new(560.0, 160.0))],
527            next_anchor: 1,
528            viewport_size: iced::Size::new(800.0, 600.0), // Default size
529            camera_position: Point::ORIGIN,
530            camera_zoom: 1.0,
531            window_position: None,
532            window_size: None,
533            window_maximized: None,
534        };
535        // The rig is wired, so the catalog is resolved from the first frame.
536        app.propagate_values();
537        app
538    }
539}
540
541impl Application {
542    #[cfg(not(target_arch = "wasm32"))]
543    fn new_persisted() -> Self {
544        // Try to load saved state, fall back to default
545        {
546            match persistence::load_state() {
547                Ok(saved) => {
548                    let (
549                        nodes,
550                        node_order,
551                        edges,
552                        edge_order,
553                        theme,
554                        camera_pos,
555                        camera_zoom,
556                        window_pos,
557                        window_size,
558                        edge_config_sections,
559                        node_config_sections,
560                        window_maximized,
561                        anchors,
562                        next_anchor,
563                    ) = saved.to_app();
564                    println!(
565                        "Loaded saved state: {} nodes, {} edges",
566                        nodes.len(),
567                        edges.len()
568                    );
569                    let mut app = Self {
570                        nodes,
571                        node_order,
572                        edges,
573                        edge_order,
574                        current_theme: theme,
575                        camera_position: camera_pos,
576                        camera_zoom,
577                        window_position: window_pos,
578                        window_size,
579                        edge_config_sections,
580                        node_config_sections,
581                        window_maximized,
582                        anchors,
583                        next_anchor,
584                        ..Self::default()
585                    };
586                    // Apply computed styles from config nodes immediately
587                    app.propagate_values();
588                    return app;
589                }
590                Err(e) => {
591                    println!("No saved state found: {}", e);
592                }
593            }
594        }
595        Self::default()
596    }
597
598    /// Resets the entire app to its initial state, keeping only the live window
599    /// geometry so the OS window does not jump. Clears any stale config/style and
600    /// overwrites the persisted save file with the fresh default.
601    fn reset_to_default(&mut self) {
602        let viewport_size = self.viewport_size;
603        let window_position = self.window_position;
604        let window_size = self.window_size;
605        let window_maximized = self.window_maximized;
606        *self = Self {
607            viewport_size,
608            window_position,
609            window_size,
610            window_maximized,
611            ..Self::default()
612        };
613        self.propagate_values();
614        self.save_state();
615    }
616
617    /// Saves current state to disk (native only). Tests drive `update`
618    /// freely, so under `cfg(test)` nothing touches the user's save file.
619    #[cfg(all(not(target_arch = "wasm32"), not(test)))]
620    fn save_state(&self) {
621        let saved = persistence::SavedState::from_app(
622            &self.nodes,
623            &self.node_order,
624            &self.edges,
625            &self.edge_order,
626            &self.current_theme,
627            self.camera_position,
628            self.camera_zoom,
629            self.window_position,
630            self.window_size,
631            &self.edge_config_sections,
632            &self.node_config_sections,
633            self.window_maximized,
634            &self.anchors,
635            self.next_anchor,
636        );
637        if let Err(e) = persistence::save_state(&saved) {
638            eprintln!("Failed to save state: {}", e);
639        }
640    }
641
642    #[cfg(any(target_arch = "wasm32", test))]
643    fn save_state(&self) {}
644
645    /// Calculate spawn position at screen center, converted to world coordinates.
646    fn spawn_position(&self) -> Point {
647        // Screen center
648        let screen_center_x = self.viewport_size.width / 2.0;
649        let screen_center_y = self.viewport_size.height / 2.0;
650
651        // Convert to world coordinates: world = screen / zoom - camera_position
652        let world_x = screen_center_x / self.camera_zoom - self.camera_position.x;
653        let world_y = screen_center_y / self.camera_zoom - self.camera_position.y;
654
655        // Offset for node size (approximate center, ~100x80 typical node)
656        Point::new(world_x - 50.0, world_y - 40.0)
657    }
658
659    /// Export current graph state to a file for Claude to read and update demos.
660    /// Format is designed to be human-readable and easily parseable.
661    #[cfg(not(target_arch = "wasm32"))]
662    fn export_state_to_file(&self) {
663        use std::io::Write;
664
665        // Create out/ directory if it doesn't exist
666        let out_dir = std::path::Path::new("out");
667        if !out_dir.exists()
668            && let Err(e) = std::fs::create_dir(out_dir)
669        {
670            eprintln!("Failed to create out/ directory: {}", e);
671            return;
672        }
673
674        // Generate random filename
675        let filename = Self::generate_random_name();
676        let path = out_dir.join(format!("{}.txt", filename));
677
678        let mut output = String::new();
679        output.push_str("# Graph State Export\n");
680        output.push_str(
681            "# Generated by hello_world demo - use this to update demo initial state\n\n",
682        );
683
684        // Export nodes
685        output.push_str("## Nodes\n");
686        output.push_str(&format!("# Total: {} nodes\n\n", self.nodes.len()));
687
688        for node_id in &self.node_order {
689            if let Some((pos, node_type)) = self.nodes.get(node_id) {
690                output.push_str(&format!("Node {}: ({:.1}, {:.1})\n", node_id, pos.x, pos.y));
691                match node_type {
692                    NodeType::Workflow(name) => {
693                        output.push_str(&format!("  Type: Workflow(\"{}\")\n", name));
694                    }
695                    NodeType::Input(input) => {
696                        output.push_str(&format!("  Type: Input({:?})\n", input));
697                    }
698                    NodeType::Config(config) => {
699                        output.push_str(&format!("  Type: Config({:?})\n", config));
700                    }
701                    NodeType::Math(state) => {
702                        output.push_str(&format!("  Type: Math({:?})\n", state));
703                    }
704                    NodeType::ColorQuad(state) => {
705                        output.push_str(&format!("  Type: ColorQuad({:?})\n", state));
706                    }
707                    NodeType::Vec2(state) => {
708                        output.push_str(&format!("  Type: Vec2({:?})\n", state));
709                    }
710                    NodeType::Alpha(state) => {
711                        output.push_str(&format!("  Type: Alpha({:?})\n", state));
712                    }
713                    NodeType::Theme => {
714                        output.push_str("  Type: Theme\n");
715                    }
716                    NodeType::ThemeExtended => {
717                        output.push_str("  Type: ThemeExtended\n");
718                    }
719                    NodeType::Frame { label, size } => {
720                        output.push_str(&format!(
721                            "  Type: Frame(\"{}\", {:.0}x{:.0})\n",
722                            label, size.width, size.height
723                        ));
724                    }
725                }
726                output.push('\n');
727            }
728        }
729
730        // Export edges
731        output.push_str("## Edges\n");
732        output.push_str(&format!("# Total: {} edges\n\n", self.edges.len()));
733
734        for edge_id in &self.edge_order {
735            if let Some(edge) = self.edges.get(edge_id) {
736                output.push_str(&format!(
737                    "Edge {}: Node {}.Pin \"{}\" -> Node {}.Pin \"{}\"\n",
738                    edge_id, edge.from_node, edge.from_pin, edge.to_node, edge.to_pin
739                ));
740            }
741        }
742
743        // Export JSON snippet for easy copy-paste
744        output.push_str("\n## JSON Format (for state.json)\n\n");
745        output.push_str("```json\n");
746        output.push_str("{\n  \"nodes\": [\n");
747        for (i, node_id) in self.node_order.iter().enumerate() {
748            if let Some((pos, node_type)) = self.nodes.get(node_id) {
749                let type_str = match node_type {
750                    NodeType::Workflow(name) => {
751                        format!("{{\"type\": \"Workflow\", \"name\": \"{}\"}}", name)
752                    }
753                    _ => format!("{:?}", node_type),
754                };
755                let comma = if i < self.node_order.len() - 1 {
756                    ","
757                } else {
758                    ""
759                };
760                output.push_str(&format!(
761                    "    {{\"id\": \"{}\", \"x\": {:.1}, \"y\": {:.1}, \"node_type\": {}}}{}\n",
762                    node_id, pos.x, pos.y, type_str, comma
763                ));
764            }
765        }
766        output.push_str("  ],\n  \"edges\": [\n");
767        for (i, edge_id) in self.edge_order.iter().enumerate() {
768            if let Some(edge) = self.edges.get(edge_id) {
769                let comma = if i < self.edge_order.len() - 1 {
770                    ","
771                } else {
772                    ""
773                };
774                output.push_str(&format!(
775                    "    {{\"id\": \"{}\", \"from_node\": \"{}\", \"from_pin\": \"{}\", \"to_node\": \"{}\", \"to_pin\": \"{}\"}}{}\n",
776                    edge_id, edge.from_node, edge.from_pin, edge.to_node, edge.to_pin, comma
777                ));
778            }
779        }
780        output.push_str("  ]\n}\n");
781        output.push_str("```\n");
782
783        // Write to file
784        match std::fs::File::create(&path) {
785            Ok(mut file) => {
786                if let Err(e) = file.write_all(output.as_bytes()) {
787                    eprintln!("Failed to write state export: {}", e);
788                } else {
789                    println!("State exported to: {}", path.display());
790                }
791            }
792            Err(e) => {
793                eprintln!("Failed to create export file: {}", e);
794            }
795        }
796    }
797
798    /// Generate a random two-word name for export files
799    #[cfg(not(target_arch = "wasm32"))]
800    fn generate_random_name() -> String {
801        use std::time::{SystemTime, UNIX_EPOCH};
802
803        const ADJECTIVES: &[&str] = &[
804            "swift", "bright", "calm", "bold", "keen", "warm", "cool", "wild", "soft", "sharp",
805            "quick", "slow", "deep", "wide", "tall", "tiny", "grand", "pure", "rare", "wise",
806            "fair", "dark", "light", "fresh",
807        ];
808        const NOUNS: &[&str] = &[
809            "river", "mountain", "forest", "ocean", "meadow", "valley", "canyon", "island",
810            "sunset", "sunrise", "thunder", "breeze", "garden", "crystal", "shadow", "ember",
811            "falcon", "phoenix", "dragon", "tiger", "wolf", "eagle", "raven", "fox",
812        ];
813
814        // Simple random using system time nanoseconds
815        let nanos = SystemTime::now()
816            .duration_since(UNIX_EPOCH)
817            .map(|d| d.as_nanos())
818            .unwrap_or(0);
819
820        let adj_idx = (nanos % ADJECTIVES.len() as u128) as usize;
821        let noun_idx = ((nanos / 7) % NOUNS.len() as u128) as usize;
822
823        format!("{}-{}", ADJECTIVES[adj_idx], NOUNS[noun_idx])
824    }
825
826    #[cfg(target_arch = "wasm32")]
827    fn export_state_to_file(&self) {
828        // WASM: State export not available in browser
829    }
830
831    /// The theme the rig reads palette colors from: the palette preview while
832    /// one is open, otherwise the applied theme.
833    fn effective_theme(&self) -> &Theme {
834        self.palette_preview_theme
835            .as_ref()
836            .unwrap_or(&self.current_theme)
837    }
838
839    /// The value a node emits on a given output pin. For most nodes this is the
840    /// pin-agnostic `output_value()`; the Theme nodes resolve per-pin colors from
841    /// the effective theme.
842    fn pin_output_value(&self, node_id: &NodeId, pin: &PinLabel) -> Option<NodeValue> {
843        match self.nodes.get(node_id) {
844            Some((_, NodeType::Theme | NodeType::ThemeExtended)) => {
845                theme_color(self.effective_theme(), pin).map(NodeValue::Color)
846            }
847            Some((_, node_type)) => node_type.output_value(),
848            None => None,
849        }
850    }
851
852    /// Recomputes every config node's inputs and the [`ComputedCatalog`] from
853    /// the current edges, in four phases: reset, feed the combiner nodes until
854    /// stable, feed the config nodes, then collect what reaches a sink.
855    fn propagate_values(&mut self) {
856        let mut new_computed = ComputedCatalog::default();
857        self.pending_configs.clear();
858
859        // Phase 1: Reset all config node and combiner node inputs to defaults
860        for (_, node_type) in self.nodes.values_mut() {
861            match node_type {
862                NodeType::Config(config) => match config {
863                    ConfigNodeType::NodeConfig(inputs) => *inputs = NodeConfigInputs::default(),
864                    ConfigNodeType::EdgeConfig(inputs) => *inputs = EdgeConfigInputs::default(),
865                    ConfigNodeType::PinConfig(inputs) => *inputs = PinConfigInputs::default(),
866                    ConfigNodeType::GraphConfig(inputs) => *inputs = GraphConfigInputs::default(),
867                    ConfigNodeType::AnchorConfig(inputs) => *inputs = AnchorConfigInputs::default(),
868                    ConfigNodeType::SelectionBoxConfig(inputs) => {
869                        *inputs = SelectionBoxConfigInputs::default()
870                    }
871                    ConfigNodeType::CuttingToolConfig(inputs) => {
872                        *inputs = CuttingToolConfigInputs::default()
873                    }
874                    ConfigNodeType::MinimapConfig(inputs) => {
875                        *inputs = MinimapConfigInputs::default()
876                    }
877                    ConfigNodeType::Catalog { connected } => connected.clear(),
878                    // `target` is host state and survives; only the connection
879                    // flag is recomputed.
880                    ConfigNodeType::NodeClass {
881                        has_node_config, ..
882                    } => *has_node_config = false,
883                },
884                NodeType::Math(state) => {
885                    state.input_a = None;
886                    state.input_b = None;
887                }
888                NodeType::ColorQuad(state) => *state = ColorQuadNode::default(),
889                NodeType::Vec2(state) => *state = Vec2Node::default(),
890                NodeType::Alpha(state) => *state = AlphaNode::default(),
891                _ => {}
892            }
893        }
894
895        // Phase 1.5: Propagate values INTO combiner nodes (iteratively for chaining)
896        // Combiners can be chained (e.g., (A+B)*C, palette -> Alpha), so we
897        // iterate until stable
898        let edges_snapshot: Vec<_> = self.edges.values().cloned().collect();
899
900        // We need multiple passes because combiner chains require the source
901        // to have computed its result before the target can use it
902        const MAX_ITERATIONS: usize = 10;
903        for _ in 0..MAX_ITERATIONS {
904            let mut changed = false;
905
906            for edge in &edges_snapshot {
907                // Feed the target's combiner inputs from the source's output, in
908                // both edge directions (edges connect either way).
909                let forward = self.pin_output_value(&edge.from_node, &edge.from_pin);
910                if let Some(value) = forward
911                    && let Some((_, node)) = self.nodes.get_mut(&edge.to_node)
912                    && feed_combiner_input(node, &edge.to_pin, &value)
913                {
914                    changed = true;
915                }
916
917                let reverse = self.pin_output_value(&edge.to_node, &edge.to_pin);
918                if let Some(value) = reverse
919                    && let Some((_, node)) = self.nodes.get_mut(&edge.from_node)
920                    && feed_combiner_input(node, &edge.from_pin, &value)
921                {
922                    changed = true;
923                }
924            }
925
926            if !changed {
927                break;
928            }
929        }
930
931        // Phase 2: Feed every value-emitting node (inputs, combiners, theme
932        // pins) into the config node at the other end, in both edge directions.
933        // Config nodes emit no pin value, so a config -> config edge is skipped.
934        for edge in &edges_snapshot {
935            if let Some(value) = self.pin_output_value(&edge.from_node, &edge.from_pin) {
936                self.apply_value_to_config_node(&edge.to_node, &edge.to_pin, &value);
937            }
938            if let Some(value) = self.pin_output_value(&edge.to_node, &edge.to_pin) {
939                self.apply_value_to_config_node(&edge.from_node, &edge.from_pin, &value);
940            }
941        }
942
943        // Phase 3: After all inputs applied, process Config -> sink connections
944        // Now config nodes have their updated inputs, so we can build configs
945        for edge in &edges_snapshot {
946            if self.is_sink(&edge.to_node) {
947                self.connect_config_to_sink(&edge.from_node, &edge.to_node, &edge.to_pin);
948            }
949            if self.is_sink(&edge.from_node) {
950                self.connect_config_to_sink(&edge.to_node, &edge.from_node, &edge.from_pin);
951            }
952        }
953
954        // Phase 4: Collect what reached a sink into the computed catalog
955        self.apply_sinks(&mut new_computed);
956
957        self.computed = new_computed;
958    }
959
960    /// Whether `id` names a Catalog or Node Class node.
961    fn is_sink(&self, id: &NodeId) -> bool {
962        matches!(
963            self.nodes.get(id),
964            Some((
965                _,
966                NodeType::Config(ConfigNodeType::Catalog { .. } | ConfigNodeType::NodeClass { .. })
967            ))
968        )
969    }
970
971    /// Applies an input value to a specific pin on a config node
972    fn apply_value_to_config_node(
973        &mut self,
974        node_id: &NodeId,
975        pin_label: &PinLabel,
976        value: &NodeValue,
977    ) {
978        use nodes::pins::{
979            anchor as apin, cutting_tool as cpin, edge as epin, graph as gpin, minimap as mpin,
980            node as npin, pin as ppin, selection_box as spin,
981        };
982
983        let Some((_, node_type)) = self.nodes.get_mut(node_id) else {
984            return;
985        };
986
987        let NodeType::Config(config) = node_type else {
988            return;
989        };
990
991        match config {
992            ConfigNodeType::NodeConfig(inputs) => {
993                // NodeConfig pin labels mirror NodeStyle: fill, border, pattern, shadow
994                if *pin_label == npin::FILL_COLOR {
995                    inputs.fill_color = value.as_color_quad();
996                } else if *pin_label == npin::CORNER_RADIUS {
997                    inputs.corner_radius = value.as_float();
998                } else if *pin_label == npin::OPACITY {
999                    inputs.opacity = value.as_float();
1000                } else if *pin_label == npin::BORDER_COLOR {
1001                    inputs.border_color = value.as_color_quad();
1002                } else if *pin_label == npin::BORDER_WIDTH {
1003                    inputs.border_width = value.as_float();
1004                } else if *pin_label == npin::BORDER_OUTLINE_WIDTH {
1005                    inputs.border_outline_width = value.as_float();
1006                } else if *pin_label == npin::BORDER_OUTLINE_COLOR {
1007                    inputs.border_outline_color = value.as_color_quad();
1008                } else if *pin_label == npin::PATTERN {
1009                    inputs.pattern_type = value.as_pattern_type();
1010                } else if *pin_label == npin::DASH {
1011                    inputs.dash_length = value.as_float();
1012                } else if *pin_label == npin::GAP {
1013                    inputs.gap_length = value.as_float();
1014                } else if *pin_label == npin::ANGLE {
1015                    // Convert degrees from slider to radians for pattern angle
1016                    inputs.pattern_angle = value.as_float().map(|deg| deg.to_radians());
1017                } else if *pin_label == npin::SPEED {
1018                    inputs.animation_speed = value.as_float();
1019                } else if *pin_label == npin::SHADOW_COLOR {
1020                    inputs.shadow_color = value.as_color_quad();
1021                } else if *pin_label == npin::SHADOW_DISTANCE {
1022                    inputs.shadow_distance = value.as_float();
1023                } else if *pin_label == npin::SHADOW_OFFSET {
1024                    inputs.shadow_offset = value.as_vec2();
1025                }
1026            }
1027            ConfigNodeType::EdgeConfig(inputs) => {
1028                // EdgeConfig pin labels
1029                if *pin_label == epin::STROKE_COLOR {
1030                    inputs.stroke_color = value.as_color_quad();
1031                } else if *pin_label == epin::THICKNESS {
1032                    inputs.thickness = value.as_float();
1033                } else if *pin_label == epin::CURVE {
1034                    inputs.curve = value.as_edge_curve();
1035                } else if *pin_label == epin::PATTERN {
1036                    inputs.pattern_type = value.as_pattern_type();
1037                } else if *pin_label == epin::DASH {
1038                    inputs.dash_length = value.as_float();
1039                } else if *pin_label == epin::GAP {
1040                    inputs.gap_length = value.as_float();
1041                } else if *pin_label == epin::DOT_RADIUS {
1042                    inputs.dot_radius = value.as_float();
1043                } else if *pin_label == epin::ANGLE {
1044                    // Convert degrees from slider to radians for pattern angle
1045                    inputs.pattern_angle = value.as_float().map(|deg| deg.to_radians());
1046                } else if *pin_label == epin::SPEED {
1047                    inputs.animation_speed = value.as_float();
1048                // Stroke outline
1049                } else if *pin_label == epin::STROKE_OUTLINE_WIDTH {
1050                    inputs.stroke_outline_width = value.as_float();
1051                } else if *pin_label == epin::STROKE_OUTLINE_COLOR {
1052                    inputs.stroke_outline_color = value.as_color_quad();
1053                // Border settings
1054                } else if *pin_label == epin::BORDER_WIDTH {
1055                    inputs.border_width = value.as_float();
1056                } else if *pin_label == epin::BORDER_GAP {
1057                    inputs.border_gap = value.as_float();
1058                } else if *pin_label == epin::BORDER_COLOR {
1059                    inputs.border_color = value.as_color_quad();
1060                } else if *pin_label == epin::BORDER_BACKGROUND {
1061                    inputs.border_background = value.as_color_quad();
1062                } else if *pin_label == epin::BORDER_OUTLINE_WIDTH {
1063                    inputs.border_outline_width = value.as_float();
1064                } else if *pin_label == epin::BORDER_OUTLINE_COLOR {
1065                    inputs.border_outline_color = value.as_color_quad();
1066                // Shadow settings
1067                } else if *pin_label == epin::SHADOW_BLUR {
1068                    inputs.shadow_blur = value.as_float();
1069                } else if *pin_label == epin::SHADOW_EXPAND {
1070                    inputs.shadow_expand = value.as_float();
1071                } else if *pin_label == epin::SHADOW_COLOR {
1072                    inputs.shadow_color = value.as_color_quad();
1073                } else if *pin_label == epin::SHADOW_OFFSET {
1074                    inputs.shadow_offset = value.as_vec2();
1075                // Hover glow
1076                } else if *pin_label == epin::GLOW_COLOR {
1077                    inputs.glow_color = value.as_color_quad();
1078                } else if *pin_label == epin::GLOW_WIDTH {
1079                    inputs.glow_width = value.as_float();
1080                }
1081            }
1082            ConfigNodeType::PinConfig(inputs) => {
1083                // PinConfig pin labels mirror PinStyle
1084                if *pin_label == ppin::COLOR {
1085                    inputs.color = value.as_color_quad();
1086                } else if *pin_label == ppin::RADIUS {
1087                    inputs.radius = value.as_float();
1088                } else if *pin_label == ppin::CUTOUT_RADIUS {
1089                    inputs.cutout_radius = value.as_float();
1090                } else if *pin_label == ppin::SHAPE {
1091                    inputs.shape = value.as_pin_shape();
1092                } else if *pin_label == ppin::BORDER_COLOR {
1093                    inputs.border_color = value.as_color_quad();
1094                } else if *pin_label == ppin::BORDER_WIDTH {
1095                    inputs.border_width = value.as_float();
1096                }
1097            }
1098            ConfigNodeType::GraphConfig(inputs) => {
1099                // GraphConfig pin labels mirror GraphStyle + TilingBackground.
1100                if *pin_label == gpin::BACKGROUND {
1101                    inputs.background_color = value.as_color_quad();
1102                } else if *pin_label == gpin::TILING_KIND {
1103                    inputs.tiling_kind = value.as_tiling_kind();
1104                } else if *pin_label == gpin::SPACING {
1105                    inputs.tiling_spacing = value.as_float();
1106                } else if *pin_label == gpin::THICKNESS {
1107                    inputs.tiling_thickness = value.as_float();
1108                } else if *pin_label == gpin::LINE_COLOR {
1109                    inputs.tiling_color = value.as_color_quad();
1110                }
1111            }
1112            ConfigNodeType::AnchorConfig(inputs) => {
1113                if *pin_label == apin::CORE_SIZE {
1114                    inputs.core_size = value.as_float();
1115                } else if *pin_label == apin::CORE_RADIUS {
1116                    inputs.core_radius = value.as_float();
1117                } else if *pin_label == apin::CORE_COLOR {
1118                    inputs.core_color = value.as_color_quad();
1119                } else if *pin_label == apin::CORE_BORDER_COLOR {
1120                    inputs.core_border_color = value.as_color_quad();
1121                } else if *pin_label == apin::CORE_BORDER_WIDTH {
1122                    inputs.core_border_width = value.as_float();
1123                } else if *pin_label == apin::ORBIT_OFFSET {
1124                    inputs.orbit_offset = value.as_float();
1125                } else if *pin_label == apin::ORBIT_SPACING {
1126                    inputs.orbit_spacing = value.as_float();
1127                } else if *pin_label == apin::RING_COLOR {
1128                    inputs.ring_color = value.as_color_quad();
1129                } else if *pin_label == apin::RING_WIDTH {
1130                    inputs.ring_width = value.as_float();
1131                } else if *pin_label == apin::OFFERED_RING_COLOR {
1132                    inputs.offered_ring_color = value.as_color_quad();
1133                }
1134            }
1135            ConfigNodeType::SelectionBoxConfig(inputs) => {
1136                if *pin_label == spin::FILL {
1137                    inputs.fill = value.as_color_quad();
1138                } else if *pin_label == spin::BORDER_COLOR {
1139                    inputs.border_color = value.as_color_quad();
1140                } else if *pin_label == spin::BORDER_WIDTH {
1141                    inputs.border_width = value.as_float();
1142                }
1143            }
1144            ConfigNodeType::CuttingToolConfig(inputs) => {
1145                if *pin_label == cpin::COLOR {
1146                    inputs.color = value.as_color_quad();
1147                } else if *pin_label == cpin::WIDTH {
1148                    inputs.width = value.as_float();
1149                }
1150            }
1151            ConfigNodeType::MinimapConfig(inputs) => {
1152                if *pin_label == mpin::BACKGROUND {
1153                    inputs.background = value.as_color_quad();
1154                } else if *pin_label == mpin::BORDER_COLOR {
1155                    inputs.border_color = value.as_color_quad();
1156                } else if *pin_label == mpin::BORDER_WIDTH {
1157                    inputs.border_width = value.as_float();
1158                } else if *pin_label == mpin::NODE_COLOR {
1159                    inputs.node_color = value.as_color_quad();
1160                } else if *pin_label == mpin::SELECTED_NODE_COLOR {
1161                    inputs.selected_node_color = value.as_color_quad();
1162                } else if *pin_label == mpin::VIEWPORT_FILL {
1163                    inputs.viewport_fill = value.as_color_quad();
1164                } else if *pin_label == mpin::VIEWPORT_BORDER_COLOR {
1165                    inputs.viewport_border_color = value.as_color_quad();
1166                } else if *pin_label == mpin::VIEWPORT_BORDER_WIDTH {
1167                    inputs.viewport_border_width = value.as_float();
1168                }
1169            }
1170            // The sinks take whole configs, not values (phase 3).
1171            ConfigNodeType::Catalog { .. } | ConfigNodeType::NodeClass { .. } => {}
1172        }
1173    }
1174
1175    /// Whether a Catalog input pin takes the given output kind.
1176    fn catalog_input_accepts(pin: PinLabel, output: &ConfigOutput) -> bool {
1177        use nodes::pins::cfg;
1178        match output {
1179            ConfigOutput::Node(_) => pin == cfg::NODE_CONFIG || pin == cfg::NODE_SELECTED,
1180            ConfigOutput::Pin(_) => pin == cfg::PIN_CONFIG || pin == cfg::PIN_VALID_TARGET,
1181            ConfigOutput::Edge(_) => {
1182                pin == cfg::EDGE_CONFIG || pin == cfg::EDGE_PENDING_CUT || pin == cfg::DRAG_EDGE
1183            }
1184            ConfigOutput::Anchor(_) => {
1185                pin == cfg::ANCHOR
1186                    || pin == cfg::ANCHOR_HOVERED
1187                    || pin == cfg::ANCHOR_VALID_TARGET
1188                    || pin == cfg::DRAG_ANCHOR
1189            }
1190            ConfigOutput::Graph(_) => pin == cfg::GRAPH_CONFIG,
1191            ConfigOutput::SelectionBox(_) => pin == cfg::SELECTION_BOX,
1192            ConfigOutput::CuttingTool(_) => pin == cfg::CUTTING_TOOL,
1193            ConfigOutput::Minimap(_) => pin == cfg::MINIMAP,
1194        }
1195    }
1196
1197    /// Queues a config node's built overlay on a sink's input pin, if the
1198    /// sink accepts that kind there, and marks the input as connected.
1199    fn connect_config_to_sink(
1200        &mut self,
1201        config_node_id: &NodeId,
1202        sink_node_id: &NodeId,
1203        sink_pin: &PinLabel,
1204    ) {
1205        use nodes::pins::cfg;
1206
1207        // Build the config from the CURRENT state of the config node (not the snapshot)
1208        let built = match self.nodes.get(config_node_id) {
1209            Some((_, NodeType::Config(config))) => match config {
1210                ConfigNodeType::NodeConfig(inputs) => Some(ConfigOutput::Node(inputs.build())),
1211                ConfigNodeType::EdgeConfig(inputs) => Some(ConfigOutput::Edge(inputs.build())),
1212                ConfigNodeType::PinConfig(inputs) => Some(ConfigOutput::Pin(inputs.build())),
1213                ConfigNodeType::GraphConfig(inputs) => Some(ConfigOutput::Graph(inputs.build())),
1214                ConfigNodeType::AnchorConfig(inputs) => Some(ConfigOutput::Anchor(inputs.build())),
1215                ConfigNodeType::SelectionBoxConfig(inputs) => {
1216                    Some(ConfigOutput::SelectionBox(inputs.build()))
1217                }
1218                ConfigNodeType::CuttingToolConfig(inputs) => {
1219                    Some(ConfigOutput::CuttingTool(inputs.build()))
1220                }
1221                ConfigNodeType::MinimapConfig(inputs) => {
1222                    Some(ConfigOutput::Minimap(inputs.build()))
1223                }
1224                ConfigNodeType::Catalog { .. } | ConfigNodeType::NodeClass { .. } => None,
1225            },
1226            _ => None,
1227        };
1228        let Some(output) = built else {
1229            return;
1230        };
1231
1232        let accepted = match self.nodes.get_mut(sink_node_id) {
1233            Some((_, NodeType::Config(ConfigNodeType::Catalog { connected }))) => {
1234                let ok = Self::catalog_input_accepts(sink_pin, &output);
1235                if ok {
1236                    connected.insert(sink_pin);
1237                }
1238                ok
1239            }
1240            Some((
1241                _,
1242                NodeType::Config(ConfigNodeType::NodeClass {
1243                    has_node_config, ..
1244                }),
1245            )) => {
1246                let ok = *sink_pin == cfg::NODE_CONFIG && matches!(output, ConfigOutput::Node(_));
1247                if ok {
1248                    *has_node_config = true;
1249                }
1250                ok
1251            }
1252            _ => false,
1253        };
1254
1255        if accepted {
1256            self.pending_configs
1257                .entry(sink_node_id.clone())
1258                .or_default()
1259                .push((*sink_pin, output));
1260        }
1261    }
1262
1263    /// Folds the queued sink inputs into the computed catalog. On the Catalog
1264    /// node each pin names one field; a Node Class node with a target adds a
1265    /// per-node class. A later config wins over an earlier one on the same pin.
1266    fn apply_sinks(&mut self, computed: &mut ComputedCatalog) {
1267        use nodes::pins::cfg;
1268
1269        for (node_id, (_, node_type)) in &self.nodes {
1270            let Some(configs) = self.pending_configs.get(node_id) else {
1271                continue;
1272            };
1273            match node_type {
1274                NodeType::Config(ConfigNodeType::Catalog { .. }) => {
1275                    for (pin, output) in configs {
1276                        match output {
1277                            ConfigOutput::Node(o) => {
1278                                let slot = if *pin == cfg::NODE_SELECTED {
1279                                    &mut computed.node_selected
1280                                } else {
1281                                    &mut computed.node
1282                                };
1283                                *slot = o.merge(slot);
1284                            }
1285                            ConfigOutput::Pin(o) => {
1286                                let slot = if *pin == cfg::PIN_VALID_TARGET {
1287                                    &mut computed.pin_valid_target
1288                                } else {
1289                                    &mut computed.pin
1290                                };
1291                                *slot = o.merge(slot);
1292                            }
1293                            ConfigOutput::Edge(o) => {
1294                                let slot = if *pin == cfg::EDGE_PENDING_CUT {
1295                                    &mut computed.edge_pending_cut
1296                                } else if *pin == cfg::DRAG_EDGE {
1297                                    &mut computed.drag_edge
1298                                } else {
1299                                    &mut computed.edge
1300                                };
1301                                *slot = o.merge(slot);
1302                            }
1303                            ConfigOutput::Anchor(o) => {
1304                                let slot = if *pin == cfg::ANCHOR_HOVERED {
1305                                    &mut computed.anchor_hovered
1306                                } else if *pin == cfg::ANCHOR_VALID_TARGET {
1307                                    &mut computed.anchor_valid_target
1308                                } else if *pin == cfg::DRAG_ANCHOR {
1309                                    &mut computed.drag_anchor
1310                                } else {
1311                                    &mut computed.anchor
1312                                };
1313                                *slot = o.merge(slot);
1314                            }
1315                            ConfigOutput::Graph(o) => computed.graph = o.merge(&computed.graph),
1316                            ConfigOutput::SelectionBox(o) => {
1317                                computed.selection_box = o.merge(&computed.selection_box)
1318                            }
1319                            ConfigOutput::CuttingTool(o) => {
1320                                computed.cutting_tool = o.merge(&computed.cutting_tool)
1321                            }
1322                            ConfigOutput::Minimap(o) => {
1323                                computed.minimap = o.merge(&computed.minimap)
1324                            }
1325                        }
1326                    }
1327                }
1328                NodeType::Config(ConfigNodeType::NodeClass {
1329                    target: Some(target),
1330                    has_node_config: true,
1331                }) => {
1332                    for (_, output) in configs {
1333                        if let ConfigOutput::Node(o) = output {
1334                            computed
1335                                .node_classes
1336                                .entry(target.clone())
1337                                .and_modify(|c| *c = o.merge(c))
1338                                .or_insert_with(|| o.clone());
1339                        }
1340                    }
1341                }
1342                _ => {}
1343            }
1344        }
1345        // Clear pending configs after application
1346        self.pending_configs.clear();
1347    }
1348}
1349
1350impl demo_common::Demo for Application {
1351    type Message = ApplicationMessage;
1352
1353    fn boot() -> (Self, Task<ApplicationMessage>) {
1354        (Self::default(), Task::none())
1355    }
1356
1357    fn update(&mut self, message: ApplicationMessage) -> Task<ApplicationMessage> {
1358        match message {
1359            ApplicationMessage::EdgeConnected { from, to } => {
1360                let edge_id = generate_edge_id();
1361                self.edges.insert(
1362                    edge_id.clone(),
1363                    EdgeData::new(from.node_id, from.pin_id, to.node_id, to.pin_id),
1364                );
1365                self.edge_order.push(edge_id);
1366                self.propagate_values();
1367                self.save_state();
1368                Task::none()
1369            }
1370            ApplicationMessage::EdgesCut(ids) => {
1371                // The widget names the edges it cut, so no endpoint search is needed.
1372                for id in &ids {
1373                    self.edges.remove(id);
1374                }
1375                self.edge_order.retain(|id| !ids.contains(id));
1376                self.drop_unused_anchors();
1377                self.propagate_values();
1378                self.save_state();
1379                Task::none()
1380            }
1381            ApplicationMessage::AnchorCreated { edge, position } => {
1382                let id = self.next_anchor;
1383                self.next_anchor += 1;
1384                // An anchor nothing wraps is not something this host keeps, so
1385                // it is only pushed once an edge has taken it. The id stays
1386                // spent either way and can never name a second anchor.
1387                if let Some(data) = self.edges.get_mut(&edge) {
1388                    data.route.push(id);
1389                    self.anchors.push((id, position));
1390                }
1391                self.save_state();
1392                Task::none()
1393            }
1394            ApplicationMessage::AnchorMoved { anchor, position } => {
1395                if let Some((_, p)) = self.anchors.iter_mut().find(|(id, _)| *id == anchor) {
1396                    *p = position;
1397                }
1398                self.save_state();
1399                Task::none()
1400            }
1401            ApplicationMessage::AnchorDeleted(anchor) => {
1402                self.anchors.retain(|(id, _)| *id != anchor);
1403                for data in self.edges.values_mut() {
1404                    data.route.retain(|id| *id != anchor);
1405                }
1406                self.save_state();
1407                Task::none()
1408            }
1409            ApplicationMessage::RouteAttached { edge, anchor } => {
1410                if let Some(data) = self.edges.get_mut(&edge)
1411                    && !data.route.contains(&anchor)
1412                {
1413                    data.route.push(anchor);
1414                }
1415                self.save_state();
1416                Task::none()
1417            }
1418            ApplicationMessage::RouteDetached { edge, anchor } => {
1419                if let Some(data) = self.edges.get_mut(&edge) {
1420                    data.route.retain(|id| *id != anchor);
1421                }
1422                // No collection here: a detach fires DURING the drag, and the
1423                // anchor just left is exactly the one the drag may put the
1424                // cable straight back onto. Dropping it now would delete a live
1425                // drop target mid-gesture.
1426                self.save_state();
1427                Task::none()
1428            }
1429            ApplicationMessage::DragEnded => {
1430                self.drop_unused_anchors();
1431                self.save_state();
1432                Task::none()
1433            }
1434            ApplicationMessage::EdgeDisconnected { from, to } => {
1435                // Unsnap during a drag: no host edge exists to name, so match the
1436                // endpoint pair. Cuts arrive through `EdgesCut` instead.
1437                let edge_to_remove: Option<EdgeId> = self
1438                    .edges
1439                    .iter()
1440                    .find(|(_, e)| {
1441                        e.from_node == from.node_id
1442                            && e.from_pin == from.pin_id
1443                            && e.to_node == to.node_id
1444                            && e.to_pin == to.pin_id
1445                    })
1446                    .map(|(id, _)| id.clone());
1447
1448                if let Some(edge_id) = edge_to_remove {
1449                    self.edges.remove(&edge_id);
1450                    self.edge_order.retain(|id| id != &edge_id);
1451                }
1452                self.drop_unused_anchors();
1453                self.propagate_values();
1454                self.save_state();
1455                Task::none()
1456            }
1457            ApplicationMessage::ToggleCommandPalette => {
1458                self.command_palette_open = !self.command_palette_open;
1459                if !self.command_palette_open {
1460                    if let Some(original) = self.palette_original_theme.take() {
1461                        self.current_theme = original;
1462                    }
1463                    self.palette_preview_theme = None;
1464                    self.propagate_values();
1465                    self.command_input.clear();
1466                    self.palette_view = PaletteView::Main;
1467                    self.palette_selected_index = 0;
1468                    Task::none()
1469                } else {
1470                    self.palette_original_theme = Some(self.current_theme.clone());
1471                    self.palette_view = PaletteView::Main;
1472                    self.palette_selected_index = 0;
1473                    focus_input()
1474                }
1475            }
1476            ApplicationMessage::CommandPaletteInput(input) => {
1477                self.command_input = input;
1478                self.palette_selected_index = 0;
1479                Task::none()
1480            }
1481            ApplicationMessage::ExecuteShortcut(cmd_id) => match cmd_id.as_str() {
1482                "add_node" => {
1483                    self.command_palette_open = true;
1484                    self.palette_original_theme = Some(self.current_theme.clone());
1485                    self.palette_view = PaletteView::Submenu("nodes".to_string());
1486                    self.palette_selected_index = 0;
1487                    self.command_input.clear();
1488                    focus_input()
1489                }
1490                "change_theme" => {
1491                    self.command_palette_open = true;
1492                    self.palette_original_theme = Some(self.current_theme.clone());
1493                    self.palette_view = PaletteView::Submenu("themes".to_string());
1494                    self.palette_selected_index = 0;
1495                    self.command_input.clear();
1496                    focus_input()
1497                }
1498                "export_state" => {
1499                    self.export_state_to_file();
1500                    Task::none()
1501                }
1502                _ => Task::none(),
1503            },
1504            ApplicationMessage::CommandPaletteNavigate(new_index) => {
1505                if !self.command_palette_open {
1506                    return Task::none();
1507                }
1508                self.palette_selected_index = new_index;
1509
1510                if let PaletteView::Submenu(ref submenu) = self.palette_view
1511                    && submenu == "themes"
1512                {
1513                    let (_, commands) = self.build_palette_commands();
1514                    if let Some(original_idx) = get_filtered_command_index(
1515                        &self.command_input,
1516                        &commands,
1517                        self.palette_selected_index,
1518                    ) {
1519                        let themes = Self::get_available_themes();
1520                        if original_idx < themes.len() {
1521                            self.palette_preview_theme = Some(themes[original_idx].clone());
1522                            // The rig's palette pins follow the previewed theme.
1523                            self.propagate_values();
1524                        }
1525                    }
1526                }
1527                Task::none()
1528            }
1529            ApplicationMessage::CommandPaletteNavigateUp => {
1530                if !self.command_palette_open {
1531                    return Task::none();
1532                }
1533                let (_, commands) = self.build_palette_commands();
1534                let filtered_count = get_filtered_count(&self.command_input, &commands);
1535                let new_index = navigate_up(self.palette_selected_index, filtered_count);
1536                self.update(ApplicationMessage::CommandPaletteNavigate(new_index))
1537            }
1538            ApplicationMessage::CommandPaletteNavigateDown => {
1539                if !self.command_palette_open {
1540                    return Task::none();
1541                }
1542                let (_, commands) = self.build_palette_commands();
1543                let filtered_count = get_filtered_count(&self.command_input, &commands);
1544                let new_index = navigate_down(self.palette_selected_index, filtered_count);
1545                self.update(ApplicationMessage::CommandPaletteNavigate(new_index))
1546            }
1547            ApplicationMessage::CommandPaletteSelect(index) => {
1548                if !self.command_palette_open {
1549                    return Task::none();
1550                }
1551                self.palette_selected_index = index;
1552                self.update(ApplicationMessage::CommandPaletteConfirm)
1553            }
1554            ApplicationMessage::CommandPaletteConfirm => {
1555                if !self.command_palette_open {
1556                    return Task::none();
1557                }
1558                let (_, commands) = self.build_palette_commands();
1559                let Some(original_idx) = get_filtered_command_index(
1560                    &self.command_input,
1561                    &commands,
1562                    self.palette_selected_index,
1563                ) else {
1564                    return Task::none();
1565                };
1566
1567                use iced_palette::CommandAction;
1568                let cmd = &commands[original_idx];
1569                match &cmd.action {
1570                    CommandAction::Message(msg) => {
1571                        let msg = msg.clone();
1572                        self.command_input.clear();
1573                        self.palette_selected_index = 0;
1574                        match msg {
1575                            ApplicationMessage::NavigateToSubmenu(submenu) => {
1576                                self.palette_view = PaletteView::Submenu(submenu);
1577                                focus_input()
1578                            }
1579                            ApplicationMessage::SpawnNode { node_type } => {
1580                                let new_id = generate_node_id();
1581                                let pos = self.spawn_position();
1582                                self.nodes.insert(new_id.clone(), (pos, node_type));
1583                                self.node_order.push(new_id.clone());
1584                                self.selected_nodes = HashSet::from([new_id]);
1585                                self.command_palette_open = false;
1586                                self.palette_view = PaletteView::Main;
1587                                self.save_state();
1588                                Task::none()
1589                            }
1590                            ApplicationMessage::ChangeTheme(theme) => {
1591                                self.current_theme = theme;
1592                                self.palette_preview_theme = None;
1593                                self.palette_original_theme = None;
1594                                self.command_palette_open = false;
1595                                self.palette_view = PaletteView::Main;
1596                                // Theme nodes feed palette colors into configs;
1597                                // recompute so styling follows the new theme.
1598                                self.propagate_values();
1599                                self.save_state();
1600                                Task::none()
1601                            }
1602                            ApplicationMessage::ExportState => {
1603                                self.command_palette_open = false;
1604                                self.palette_view = PaletteView::Main;
1605                                self.export_state_to_file();
1606                                Task::none()
1607                            }
1608                            ApplicationMessage::Reset => {
1609                                self.reset_to_default();
1610                                Task::none()
1611                            }
1612                            ApplicationMessage::FocusNode => {
1613                                self.command_palette_open = false;
1614                                self.palette_view = PaletteView::Main;
1615                                self.focus_first_node()
1616                            }
1617                            _ => Task::none(),
1618                        }
1619                    }
1620                    _ => Task::none(),
1621                }
1622            }
1623            ApplicationMessage::CommandPaletteCancel => {
1624                if !self.command_palette_open {
1625                    return Task::none();
1626                }
1627                if let Some(original) = self.palette_original_theme.take() {
1628                    self.current_theme = original;
1629                }
1630                self.palette_preview_theme = None;
1631                self.propagate_values();
1632                self.command_palette_open = false;
1633                self.command_input.clear();
1634                self.palette_view = PaletteView::Main;
1635                self.palette_selected_index = 0;
1636                Task::none()
1637            }
1638            ApplicationMessage::SpawnNode { node_type } => {
1639                let new_id = generate_node_id();
1640                let pos = self.spawn_position();
1641                self.nodes.insert(new_id.clone(), (pos, node_type));
1642                self.node_order.push(new_id.clone());
1643                self.selected_nodes = HashSet::from([new_id]);
1644                self.command_palette_open = false;
1645                self.command_input.clear();
1646                self.palette_view = PaletteView::Main;
1647                self.save_state();
1648                Task::none()
1649            }
1650            ApplicationMessage::CameraChanged { position, zoom } => {
1651                self.camera_position = position;
1652                self.camera_zoom = zoom;
1653                self.save_state();
1654                Task::none()
1655            }
1656            ApplicationMessage::FocusNode => self.focus_first_node(),
1657            ApplicationMessage::WindowResized(size) => {
1658                self.viewport_size = size;
1659                self.window_size = Some((size.width as u32, size.height as u32));
1660                // Query maximize state on resize - it may have changed
1661                window::oldest()
1662                    .and_then(window::is_maximized)
1663                    .map(ApplicationMessage::WindowMaximizedChanged)
1664            }
1665            ApplicationMessage::WindowMoved(position) => {
1666                self.window_position = Some((position.x as i32, position.y as i32));
1667                self.save_state();
1668                Task::none()
1669            }
1670            ApplicationMessage::WindowMaximizedChanged(maximized) => {
1671                self.window_maximized = Some(maximized);
1672                self.save_state();
1673                Task::none()
1674            }
1675            ApplicationMessage::ChangeTheme(theme) => {
1676                self.current_theme = theme;
1677                self.command_palette_open = false;
1678                self.command_input.clear();
1679                self.palette_view = PaletteView::Main;
1680                // Theme nodes feed palette colors into configs; recompute so
1681                // styling follows the new theme.
1682                self.propagate_values();
1683                self.save_state();
1684                Task::none()
1685            }
1686            ApplicationMessage::NavigateToSubmenu(submenu) => {
1687                self.palette_view = PaletteView::Submenu(submenu);
1688                self.command_input.clear();
1689                focus_input()
1690            }
1691            ApplicationMessage::ExportState => {
1692                self.export_state_to_file();
1693                Task::none()
1694            }
1695            ApplicationMessage::Reset => {
1696                self.reset_to_default();
1697                Task::none()
1698            }
1699            ApplicationMessage::FocusNext => iced::widget::operation::focus_next(),
1700            ApplicationMessage::FocusPrevious => iced::widget::operation::focus_previous(),
1701            ApplicationMessage::SelectionChanged(node_ids) => {
1702                self.selected_nodes = node_ids.into_iter().collect();
1703                Task::none()
1704            }
1705            ApplicationMessage::CloneNodes(node_ids) => {
1706                let offset = Vector::new(50.0, 50.0);
1707                let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
1708                let mut new_ids = Vec::new();
1709
1710                // Clone selected nodes
1711                for old_id in &node_ids {
1712                    if let Some((pos, node_type)) = self.nodes.get(old_id) {
1713                        let new_id = generate_node_id();
1714                        let new_pos = Point::new(pos.x + offset.x, pos.y + offset.y);
1715                        self.nodes
1716                            .insert(new_id.clone(), (new_pos, node_type.clone()));
1717                        self.node_order.push(new_id.clone());
1718                        id_map.insert(old_id.clone(), new_id.clone());
1719                        new_ids.push(new_id);
1720                    }
1721                }
1722
1723                // Clone edges between selected nodes
1724                let edges_to_clone: Vec<_> = self
1725                    .edges
1726                    .iter()
1727                    .filter(|(_, e)| {
1728                        node_ids.contains(&e.from_node) && node_ids.contains(&e.to_node)
1729                    })
1730                    .map(|(_, e)| e.clone())
1731                    .collect();
1732
1733                for edge in edges_to_clone {
1734                    if let (Some(new_from), Some(new_to)) =
1735                        (id_map.get(&edge.from_node), id_map.get(&edge.to_node))
1736                    {
1737                        let new_edge_id = generate_edge_id();
1738                        self.edges.insert(
1739                            new_edge_id.clone(),
1740                            EdgeData::new(
1741                                new_from.clone(),
1742                                edge.from_pin,
1743                                new_to.clone(),
1744                                edge.to_pin,
1745                            ),
1746                        );
1747                        self.edge_order.push(new_edge_id);
1748                    }
1749                }
1750
1751                self.selected_nodes = new_ids.into_iter().collect();
1752                self.propagate_values();
1753                self.save_state();
1754                Task::none()
1755            }
1756            ApplicationMessage::DeleteNodes(node_ids) => {
1757                self.delete_nodes(&node_ids);
1758                self.propagate_values();
1759                self.save_state();
1760                Task::none()
1761            }
1762            ApplicationMessage::NodesMoved { delta, node_ids } => {
1763                for node_id in node_ids {
1764                    if let Some((pos, _)) = self.nodes.get_mut(&node_id) {
1765                        pos.x += delta.x;
1766                        pos.y += delta.y;
1767                    }
1768                }
1769                self.save_state();
1770                Task::none()
1771            }
1772            ApplicationMessage::SliderChanged { node_id, value } => {
1773                if let Some((_, NodeType::Input(InputNodeType::FloatSlider { value: v, .. }))) =
1774                    self.nodes.get_mut(&node_id)
1775                {
1776                    *v = value;
1777                    self.propagate_values();
1778                }
1779                Task::none()
1780            }
1781            ApplicationMessage::IntSliderChanged { node_id, value } => {
1782                if let Some((_, NodeType::Input(InputNodeType::IntSlider { value: v, .. }))) =
1783                    self.nodes.get_mut(&node_id)
1784                {
1785                    *v = value;
1786                    self.propagate_values();
1787                }
1788                Task::none()
1789            }
1790            ApplicationMessage::BoolChanged { node_id, value } => {
1791                if let Some((_, NodeType::Input(InputNodeType::BoolToggle { value: v, .. }))) =
1792                    self.nodes.get_mut(&node_id)
1793                {
1794                    *v = value;
1795                    self.propagate_values();
1796                }
1797                Task::none()
1798            }
1799            ApplicationMessage::EdgeCurveChanged { node_id, value } => {
1800                if let Some((_, NodeType::Input(InputNodeType::EdgeCurveSelector { value: v }))) =
1801                    self.nodes.get_mut(&node_id)
1802                {
1803                    *v = value;
1804                    self.propagate_values();
1805                }
1806                Task::none()
1807            }
1808            ApplicationMessage::PinShapeChanged { node_id, value } => {
1809                if let Some((_, NodeType::Input(InputNodeType::PinShapeSelector { value: v }))) =
1810                    self.nodes.get_mut(&node_id)
1811                {
1812                    *v = value;
1813                    self.propagate_values();
1814                }
1815                Task::none()
1816            }
1817            ApplicationMessage::PatternTypeChanged { node_id, value } => {
1818                if let Some((_, NodeType::Input(InputNodeType::PatternTypeSelector { value: v }))) =
1819                    self.nodes.get_mut(&node_id)
1820                {
1821                    *v = value;
1822                    self.propagate_values();
1823                }
1824                Task::none()
1825            }
1826            ApplicationMessage::TilingKindChanged { node_id, value } => {
1827                if let Some((_, NodeType::Input(InputNodeType::TilingKindSelector { value: v }))) =
1828                    self.nodes.get_mut(&node_id)
1829                {
1830                    *v = value;
1831                    self.propagate_values();
1832                }
1833                Task::none()
1834            }
1835            ApplicationMessage::ColorChanged { node_id, color } => {
1836                if let Some((_, node_type)) = self.nodes.get_mut(&node_id) {
1837                    match node_type {
1838                        NodeType::Input(InputNodeType::ColorPicker { color: c }) => {
1839                            *c = color;
1840                            self.propagate_values();
1841                        }
1842                        NodeType::Input(InputNodeType::ColorPreset { color: c }) => {
1843                            *c = color;
1844                            self.propagate_values();
1845                        }
1846                        _ => {}
1847                    }
1848                }
1849                Task::none()
1850            }
1851            ApplicationMessage::ToggleNodeExpanded { node_id } => {
1852                if self.expanded_nodes.contains(&node_id) {
1853                    self.expanded_nodes.remove(&node_id);
1854                } else {
1855                    self.expanded_nodes.insert(node_id);
1856                }
1857                Task::none()
1858            }
1859            ApplicationMessage::UpdateFloatSliderConfig { node_id, config } => {
1860                if let Some((_, NodeType::Input(InputNodeType::FloatSlider { config: c, value }))) =
1861                    self.nodes.get_mut(&node_id)
1862                {
1863                    // Clamp value to new range if needed
1864                    *value = value.clamp(config.min, config.max);
1865                    *c = config;
1866                }
1867                Task::none()
1868            }
1869            ApplicationMessage::UpdateIntSliderConfig { node_id, config } => {
1870                if let Some((_, NodeType::Input(InputNodeType::IntSlider { config: c, value }))) =
1871                    self.nodes.get_mut(&node_id)
1872                {
1873                    // Clamp value to new range if needed
1874                    *value = (*value).clamp(config.min, config.max);
1875                    *c = config;
1876                }
1877                Task::none()
1878            }
1879            ApplicationMessage::ToggleEdgeSection { node_id, section } => {
1880                let sections = self
1881                    .edge_config_sections
1882                    .entry(node_id)
1883                    .or_insert_with(EdgeSections::new_all_expanded);
1884                match section {
1885                    EdgeSection::Stroke => sections.stroke = !sections.stroke,
1886                    EdgeSection::Pattern => sections.pattern = !sections.pattern,
1887                    EdgeSection::Border => sections.border = !sections.border,
1888                    EdgeSection::Shadow => sections.shadow = !sections.shadow,
1889                }
1890                Task::none()
1891            }
1892            ApplicationMessage::ToggleNodeSection { node_id, section } => {
1893                let sections = self
1894                    .node_config_sections
1895                    .entry(node_id)
1896                    .or_insert_with(NodeSections::new_all_expanded);
1897                match section {
1898                    NodeSection::Fill => sections.fill = !sections.fill,
1899                    NodeSection::Border => sections.border = !sections.border,
1900                    NodeSection::Pattern => sections.pattern = !sections.pattern,
1901                    NodeSection::Shadow => sections.shadow = !sections.shadow,
1902                }
1903                Task::none()
1904            }
1905            ApplicationMessage::NodeClassTargetChanged { node_id, target } => {
1906                if let Some((_, NodeType::Config(ConfigNodeType::NodeClass { target: t, .. }))) =
1907                    self.nodes.get_mut(&node_id)
1908                {
1909                    *t = target;
1910                    self.propagate_values();
1911                    self.save_state();
1912                }
1913                Task::none()
1914            }
1915        }
1916    }
1917
1918    fn theme(&self) -> Theme {
1919        self.effective_theme().clone()
1920    }
1921
1922    fn set_theme(&mut self, theme: Theme) {
1923        self.current_theme = theme;
1924        self.palette_preview_theme = None;
1925        // The rig's palette pins follow the theme.
1926        self.propagate_values();
1927    }
1928
1929    fn view(&self) -> iced::Element<'_, ApplicationMessage> {
1930        use iced_nodegraph::NodeGraph;
1931
1932        let theme = self.effective_theme();
1933
1934        // The Node Class pick list offers every workflow node; each Node Class
1935        // node takes its own copy.
1936        let candidates: Vec<ClassCandidate> = self
1937            .node_order
1938            .iter()
1939            .filter_map(|id| match self.nodes.get(id) {
1940                Some((_, NodeType::Workflow(name))) => Some(ClassCandidate {
1941                    id: id.clone(),
1942                    label: format!("{} {}", name, &id[..6.min(id.len())]),
1943                }),
1944                _ => None,
1945            })
1946            .collect();
1947
1948        let mut ng: NodeGraph<'_, HelloIds, ApplicationMessage, iced::Theme, iced::Renderer> =
1949            NodeGraph::new()
1950                .id(graph_id())
1951                .on_connect(|from: PinRef<HelloIds>, to: PinRef<HelloIds>| {
1952                    ApplicationMessage::EdgeConnected { from, to }
1953                })
1954                .on_disconnect(|from: PinRef<HelloIds>, to: PinRef<HelloIds>| {
1955                    ApplicationMessage::EdgeDisconnected { from, to }
1956                })
1957                .on_edge_delete(ApplicationMessage::EdgesCut)
1958                .on_move(|delta, node_ids| ApplicationMessage::NodesMoved { delta, node_ids })
1959                .on_select(ApplicationMessage::SelectionChanged)
1960                .on_clone(ApplicationMessage::CloneNodes)
1961                .on_delete(ApplicationMessage::DeleteNodes)
1962                .on_camera(|position, zoom| ApplicationMessage::CameraChanged { position, zoom })
1963                .on_anchor_move(|anchor, position| ApplicationMessage::AnchorMoved {
1964                    anchor,
1965                    position,
1966                })
1967                .on_anchor_create(|edge, position| ApplicationMessage::AnchorCreated {
1968                    edge,
1969                    position,
1970                })
1971                .on_anchor_delete(ApplicationMessage::AnchorDeleted)
1972                .on_route_attach(|edge, anchor| ApplicationMessage::RouteAttached { edge, anchor })
1973                .on_route_detach(|edge, anchor| ApplicationMessage::RouteDetached { edge, anchor })
1974                .on_drag_end(|| ApplicationMessage::DragEnded)
1975                .camera(self.camera_position, self.camera_zoom)
1976                .minimap(Minimap::default())
1977                // A connection is valid only between opposite directions (output ->
1978                // input) carrying the same data type (the pin's TypeId marker). Color
1979                // and ColorQuad share the `ColorData` marker, so a color pin accepts
1980                // both a picker and the ColorQuad builder; Vec2 only matches Vec2.
1981                .can_connect(|from, to| {
1982                    from.direction() != to.direction() && from.info() == to.info()
1983                })
1984                // Every class the widget resolves is a method on `self`: the
1985                // computed catalog over the theme-derived base, see `node_style`
1986                // and its siblings.
1987                .selection_box_style(|theme| self.selection_box_style(theme))
1988                .cutting_tool_style(|theme| self.cutting_tool_style(theme))
1989                .minimap_style(|theme| self.minimap_style(theme))
1990                .dragging_edge_style(|theme, source| self.drag_edge_style(theme, *source.info()))
1991                .dragging_anchor_style(|theme, status| {
1992                    self.computed
1993                        .drag_anchor
1994                        .resolve_over(default_anchor_style(theme, status))
1995                })
1996                .graph_style(|theme| self.graph_style(theme))
1997                .anchors(self.anchors.iter().map(|&(id, position)| {
1998                    ng_anchor(id, position).style(|theme, status| self.anchor_style(theme, status))
1999                }));
2000
2001        // Add all nodes from state (in order)
2002        for node_id in &self.node_order {
2003            let Some((position, node_type)) = self.nodes.get(node_id) else {
2004                continue;
2005            };
2006            let node_id_clone = node_id.clone();
2007            let element: iced::Element<'_, ApplicationMessage> = match node_type {
2008                NodeType::Workflow(name) => node(name.as_str(), theme),
2009                NodeType::Input(input) => match input {
2010                    InputNodeType::FloatSlider { config, value } => {
2011                        let id = node_id_clone.clone();
2012                        let expanded = self.expanded_nodes.contains(node_id);
2013                        float_slider_node(
2014                            theme,
2015                            *value,
2016                            config,
2017                            expanded,
2018                            {
2019                                let id = id.clone();
2020                                move |v| ApplicationMessage::SliderChanged {
2021                                    node_id: id.clone(),
2022                                    value: v,
2023                                }
2024                            },
2025                            {
2026                                let id = id.clone();
2027                                move |cfg| ApplicationMessage::UpdateFloatSliderConfig {
2028                                    node_id: id.clone(),
2029                                    config: cfg,
2030                                }
2031                            },
2032                            ApplicationMessage::ToggleNodeExpanded { node_id: id },
2033                        )
2034                    }
2035                    InputNodeType::IntSlider { config, value } => {
2036                        let id = node_id_clone.clone();
2037                        let expanded = self.expanded_nodes.contains(node_id);
2038                        int_slider_node(
2039                            theme,
2040                            *value,
2041                            config,
2042                            expanded,
2043                            {
2044                                let id = id.clone();
2045                                move |v| ApplicationMessage::IntSliderChanged {
2046                                    node_id: id.clone(),
2047                                    value: v,
2048                                }
2049                            },
2050                            {
2051                                let id = id.clone();
2052                                move |cfg| ApplicationMessage::UpdateIntSliderConfig {
2053                                    node_id: id.clone(),
2054                                    config: cfg,
2055                                }
2056                            },
2057                            ApplicationMessage::ToggleNodeExpanded { node_id: id },
2058                        )
2059                    }
2060                    InputNodeType::BoolToggle { config, value } => {
2061                        let id = node_id_clone.clone();
2062                        bool_toggle_node(theme, *value, config, move |v| {
2063                            ApplicationMessage::BoolChanged {
2064                                node_id: id.clone(),
2065                                value: v,
2066                            }
2067                        })
2068                    }
2069                    InputNodeType::EdgeCurveSelector { value } => {
2070                        let id = node_id_clone.clone();
2071                        edge_curve_selector_node(theme, *value, move |v| {
2072                            ApplicationMessage::EdgeCurveChanged {
2073                                node_id: id.clone(),
2074                                value: v,
2075                            }
2076                        })
2077                    }
2078                    InputNodeType::PinShapeSelector { value } => {
2079                        let id = node_id_clone.clone();
2080                        pin_shape_selector_node(theme, *value, move |v| {
2081                            ApplicationMessage::PinShapeChanged {
2082                                node_id: id.clone(),
2083                                value: v,
2084                            }
2085                        })
2086                    }
2087                    InputNodeType::PatternTypeSelector { value } => {
2088                        let id = node_id_clone.clone();
2089                        pattern_type_selector_node(theme, *value, move |v| {
2090                            ApplicationMessage::PatternTypeChanged {
2091                                node_id: id.clone(),
2092                                value: v,
2093                            }
2094                        })
2095                    }
2096                    InputNodeType::TilingKindSelector { value } => {
2097                        let id = node_id_clone.clone();
2098                        tiling_kind_selector_node(theme, *value, move |v| {
2099                            ApplicationMessage::TilingKindChanged {
2100                                node_id: id.clone(),
2101                                value: v,
2102                            }
2103                        })
2104                    }
2105                    InputNodeType::ColorPicker { color } => {
2106                        let id = node_id_clone.clone();
2107                        color_picker_node(theme, *color, move |c| {
2108                            ApplicationMessage::ColorChanged {
2109                                node_id: id.clone(),
2110                                color: c,
2111                            }
2112                        })
2113                    }
2114                    InputNodeType::ColorPreset { color } => {
2115                        let id = node_id_clone.clone();
2116                        color_preset_node(theme, *color, move |c| {
2117                            ApplicationMessage::ColorChanged {
2118                                node_id: id.clone(),
2119                                color: c,
2120                            }
2121                        })
2122                    }
2123                },
2124                NodeType::Config(config) => match config {
2125                    ConfigNodeType::NodeConfig(inputs) => {
2126                        let id = node_id_clone.clone();
2127                        let sections = self
2128                            .node_config_sections
2129                            .get(&id)
2130                            .cloned()
2131                            .unwrap_or_else(NodeSections::new_all_expanded);
2132                        node_config_node(theme, inputs, &sections, move |section| {
2133                            ApplicationMessage::ToggleNodeSection {
2134                                node_id: id.clone(),
2135                                section,
2136                            }
2137                        })
2138                    }
2139                    ConfigNodeType::EdgeConfig(inputs) => {
2140                        let id = node_id_clone.clone();
2141                        let sections = self
2142                            .edge_config_sections
2143                            .get(&id)
2144                            .cloned()
2145                            .unwrap_or_else(EdgeSections::new_all_expanded);
2146                        edge_config_node(theme, inputs, &sections, move |section| {
2147                            ApplicationMessage::ToggleEdgeSection {
2148                                node_id: id.clone(),
2149                                section,
2150                            }
2151                        })
2152                    }
2153                    ConfigNodeType::PinConfig(inputs) => pin_config_node(theme, inputs),
2154                    ConfigNodeType::GraphConfig(inputs) => graph_config_node(theme, inputs),
2155                    ConfigNodeType::AnchorConfig(inputs) => anchor_config_node(theme, inputs),
2156                    ConfigNodeType::SelectionBoxConfig(inputs) => {
2157                        selection_box_config_node(theme, inputs)
2158                    }
2159                    ConfigNodeType::CuttingToolConfig(inputs) => {
2160                        cutting_tool_config_node(theme, inputs)
2161                    }
2162                    ConfigNodeType::MinimapConfig(inputs) => minimap_config_node(theme, inputs),
2163                    ConfigNodeType::Catalog { connected } => catalog_node(theme, connected),
2164                    ConfigNodeType::NodeClass {
2165                        target,
2166                        has_node_config,
2167                    } => {
2168                        let id = node_id_clone.clone();
2169                        node_class_node(
2170                            theme,
2171                            *has_node_config,
2172                            target.as_ref(),
2173                            candidates.clone(),
2174                            move |target| ApplicationMessage::NodeClassTargetChanged {
2175                                node_id: id.clone(),
2176                                target,
2177                            },
2178                        )
2179                    }
2180                },
2181                NodeType::Math(state) => math_node(theme, state),
2182                NodeType::ColorQuad(state) => color_quad_node(theme, state),
2183                NodeType::Vec2(state) => vec2_node(theme, state),
2184                NodeType::Alpha(state) => alpha_node(theme, state),
2185                NodeType::Theme => theme_node(theme),
2186                NodeType::ThemeExtended => theme_extended_node(theme),
2187                NodeType::Frame { label, size } => frame_node(theme, label, *size),
2188            };
2189
2190            let mut node = ng_node(node_id.clone(), *position, element)
2191                .style(|theme, status| self.node_style(node_id, theme, status))
2192                .pin_style(|theme, pin, _other, status| self.pin_style(*pin.info(), theme, status));
2193            if matches!(node_type, NodeType::Frame { .. }) {
2194                node = node.frame();
2195            }
2196            ng = ng.push_node(node);
2197        }
2198
2199        for edge_id in &self.edge_order {
2200            if let Some(edge_data) = self.edges.get(edge_id) {
2201                let from = PinRef::new(edge_data.from_node.clone(), edge_data.from_pin);
2202                let to = PinRef::new(edge_data.to_node.clone(), edge_data.to_pin);
2203                ng = ng.push_edge(
2204                    ng_edge(edge_id.clone(), from, to)
2205                        .route(edge_data.route.iter().copied())
2206                        .style(|theme, status, start, end| {
2207                            self.edge_style(theme, status, *start.info(), *end.info())
2208                        }),
2209                );
2210            }
2211        }
2212
2213        let graph_view: iced::Element<'_, ApplicationMessage> = ng.into();
2214
2215        // Always use the same widget structure to preserve NodeGraph state
2216        // The command palette is conditionally shown as an overlay
2217        let overlay: iced::Element<'_, ApplicationMessage> = if self.command_palette_open {
2218            let (_, commands) = self.build_palette_commands();
2219            // `opaque` blocks wheel events from reaching the NodeGraph behind
2220            // the palette; `command_palette`'s own `mouse_area` only captures
2221            // `on_press`, not scroll.
2222            opaque(command_palette(
2223                &self.command_input,
2224                &commands,
2225                self.palette_selected_index,
2226                ApplicationMessage::CommandPaletteInput,
2227                ApplicationMessage::CommandPaletteSelect,
2228                ApplicationMessage::CommandPaletteNavigate,
2229                || ApplicationMessage::CommandPaletteCancel,
2230            ))
2231        } else {
2232            // Invisible placeholder to maintain widget tree structure
2233            container(text("")).width(0).height(0).into()
2234        };
2235
2236        stack!(graph_view, overlay)
2237            .width(Length::Fill)
2238            .height(Length::Fill)
2239            .into()
2240    }
2241
2242    fn subscription(&self) -> Subscription<ApplicationMessage> {
2243        Subscription::batch(vec![
2244            event::listen_with(handle_keyboard_event),
2245            event::listen_with(|event, _, _| match event {
2246                Event::Window(window::Event::Resized(size)) => {
2247                    Some(ApplicationMessage::WindowResized(size))
2248                }
2249                Event::Window(window::Event::Moved(position)) => {
2250                    Some(ApplicationMessage::WindowMoved(position))
2251                }
2252                _ => None,
2253            }),
2254        ])
2255    }
2256}
2257
2258/// Boots this demo for the gallery.
2259pub fn scene() -> (
2260    Box<dyn demo_common::Scene>,
2261    iced::Task<demo_common::SceneMessage>,
2262) {
2263    demo_common::erase::<Application>()
2264}
2265
2266impl Application {
2267    fn get_main_commands_with_shortcuts() -> Vec<Command<ApplicationMessage>> {
2268        vec![
2269            command("add_node", "Add Node")
2270                .description("Add a new node to the graph")
2271                .shortcut(Shortcut::cmd('n'))
2272                .action(ApplicationMessage::ExecuteShortcut("add_node".to_string())),
2273            command("change_theme", "Change Theme")
2274                .description("Switch to a different color theme")
2275                .shortcut(Shortcut::cmd('t'))
2276                .action(ApplicationMessage::ExecuteShortcut(
2277                    "change_theme".to_string(),
2278                )),
2279            command("export_state", "Export State")
2280                .description("Export graph state to file for Claude")
2281                .shortcut(Shortcut::cmd('e'))
2282                .action(ApplicationMessage::ExecuteShortcut(
2283                    "export_state".to_string(),
2284                )),
2285        ]
2286    }
2287
2288    fn get_available_themes() -> Vec<Theme> {
2289        vec![
2290            Theme::Dark,
2291            Theme::Light,
2292            Theme::Dracula,
2293            Theme::Nord,
2294            Theme::SolarizedLight,
2295            Theme::SolarizedDark,
2296            Theme::GruvboxLight,
2297            Theme::GruvboxDark,
2298            Theme::CatppuccinLatte,
2299            Theme::CatppuccinFrappe,
2300            Theme::CatppuccinMacchiato,
2301            Theme::CatppuccinMocha,
2302            Theme::TokyoNight,
2303            Theme::TokyoNightStorm,
2304            Theme::TokyoNightLight,
2305            Theme::KanagawaWave,
2306            Theme::KanagawaDragon,
2307            Theme::KanagawaLotus,
2308            Theme::Moonfly,
2309            Theme::Nightfly,
2310            Theme::Oxocarbon,
2311            Theme::Ferra,
2312        ]
2313    }
2314
2315    fn get_theme_name(theme: &Theme) -> &'static str {
2316        match theme {
2317            Theme::Dark => "Dark",
2318            Theme::Light => "Light",
2319            Theme::Dracula => "Dracula",
2320            Theme::Nord => "Nord",
2321            Theme::SolarizedLight => "Solarized Light",
2322            Theme::SolarizedDark => "Solarized Dark",
2323            Theme::GruvboxLight => "Gruvbox Light",
2324            Theme::GruvboxDark => "Gruvbox Dark",
2325            Theme::CatppuccinLatte => "Catppuccin Latte",
2326            Theme::CatppuccinFrappe => "Catppuccin Frappe",
2327            Theme::CatppuccinMacchiato => "Catppuccin Macchiato",
2328            Theme::CatppuccinMocha => "Catppuccin Mocha",
2329            Theme::TokyoNight => "Tokyo Night",
2330            Theme::TokyoNightStorm => "Tokyo Night Storm",
2331            Theme::TokyoNightLight => "Tokyo Night Light",
2332            Theme::KanagawaWave => "Kanagawa Wave",
2333            Theme::KanagawaDragon => "Kanagawa Dragon",
2334            Theme::KanagawaLotus => "Kanagawa Lotus",
2335            Theme::Moonfly => "Moonfly",
2336            Theme::Nightfly => "Nightfly",
2337            Theme::Oxocarbon => "Oxocarbon",
2338            Theme::Ferra => "Ferra",
2339            _ => "Unknown",
2340        }
2341    }
2342
2343    /// Frames the first workflow node: the "Focus node" command's target, run
2344    /// with the default eased tween. `Home` and `f` cover All and Selection;
2345    /// this exercises [`FocusTarget::Node`].
2346    fn focus_first_node(&self) -> Task<ApplicationMessage> {
2347        match self.node_order.first() {
2348            Some(target) => iced_nodegraph::focus(
2349                graph_id(),
2350                FocusTarget::<HelloIds>::Node(target.clone()),
2351                FocusOptions::default(),
2352            ),
2353            None => Task::none(),
2354        }
2355    }
2356
2357    // ---------------------------------------------------------------------
2358    // Style resolution: the computed catalog over the theme-derived base.
2359    // With nothing connected an overlay is empty and the base is exactly the
2360    // widget's own default.
2361    // ---------------------------------------------------------------------
2362
2363    /// A node's style: its per-node class (a Node Class node targeting it)
2364    /// wins over the Catalog's status class, which is the selected overlay
2365    /// layered over the idle one; the theme base fills the rest.
2366    fn node_style(&self, node_id: &NodeId, theme: &Theme, status: NodeStatus) -> NodeStyle {
2367        let global = self.computed.node(status);
2368        let overlay = match self.computed.node_classes.get(node_id) {
2369            Some(class) => class.merge(&global),
2370            None => global,
2371        };
2372        overlay.resolve_over(default_node_style(theme, status))
2373    }
2374
2375    /// A pin's style: the Catalog's pin class wins over the pin's data-type
2376    /// color, then the status default fills the rest.
2377    fn pin_style(&self, info: std::any::TypeId, theme: &Theme, status: PinStatus) -> PinStyle {
2378        self.computed
2379            .pin(status)
2380            .merge(&PinOverlay::new().color(pin_color_for(info)))
2381            .resolve_over(default_pin_style(theme, status))
2382    }
2383
2384    /// An edge's style: the Catalog's edge class over an arc between its two
2385    /// pins' data-type colors, with the hover glow on the same arc at
2386    /// [`EDGE_GLOW_OPACITY`].
2387    fn edge_style(
2388        &self,
2389        theme: &Theme,
2390        status: EdgeStatus,
2391        start: std::any::TypeId,
2392        end: std::any::TypeId,
2393    ) -> EdgeStyle {
2394        let type_colors = ColorQuad::arc(pin_color_for(start), pin_color_for(end));
2395        let base = EdgeStyle {
2396            stroke_color: type_colors,
2397            glow_color: type_colors.with_opacity(EDGE_GLOW_OPACITY),
2398            ..default_edge_style(theme, status)
2399        };
2400        self.computed.edge(status).resolve_over(base)
2401    }
2402
2403    /// The loose edge's style: the Catalog's drag-edge class over the held
2404    /// pin's data-type color on both ends.
2405    fn drag_edge_style(&self, theme: &Theme, source: std::any::TypeId) -> EdgeStyle {
2406        let base = EdgeStyle {
2407            stroke_color: ColorQuad::solid(pin_color_for(source)),
2408            ..default_edge_style(theme, EdgeStatus::Idle)
2409        };
2410        self.computed.drag_edge.resolve_over(base)
2411    }
2412
2413    fn anchor_style(&self, theme: &Theme, status: AnchorStatus) -> AnchorStyle {
2414        self.computed
2415            .anchor(status)
2416            .resolve_over(default_anchor_style(theme, status))
2417    }
2418
2419    fn graph_style(&self, theme: &Theme) -> GraphStyle {
2420        self.computed.graph.resolve_over(default_graph_style(theme))
2421    }
2422
2423    fn selection_box_style(&self, theme: &Theme) -> SelectionBoxStyle {
2424        self.computed
2425            .selection_box
2426            .resolve_over(default_selection_box_style(theme))
2427    }
2428
2429    fn cutting_tool_style(&self, theme: &Theme) -> CuttingToolStyle {
2430        self.computed
2431            .cutting_tool
2432            .resolve_over(default_cutting_tool_style(theme))
2433    }
2434
2435    fn minimap_style(&self, theme: &Theme) -> MinimapStyle {
2436        self.computed
2437            .minimap
2438            .resolve_over(default_minimap_style(theme))
2439    }
2440
2441    /// Drops every anchor no route names any more.
2442    ///
2443    /// The library keeps an anchor as long as the host pushes it, cables or no
2444    /// cables - so "the last cable left, the anchor goes" is a policy, and this
2445    /// is where this host states it.
2446    fn drop_unused_anchors(&mut self) {
2447        let routed: HashSet<usize> = self
2448            .edges
2449            .values()
2450            .flat_map(|e| e.route.iter().copied())
2451            .collect();
2452        self.anchors.retain(|(id, _)| routed.contains(id));
2453    }
2454
2455    /// Removes the nodes, the edges touching them, the anchors that leaves
2456    /// unrouted, and the selection; a Node Class whose target is among them
2457    /// points at nothing afterwards.
2458    fn delete_nodes(&mut self, node_ids: &[NodeId]) {
2459        for node_id in node_ids {
2460            self.nodes.remove(node_id);
2461            self.node_order.retain(|id| id != node_id);
2462
2463            let edges_to_remove: Vec<_> = self
2464                .edges
2465                .iter()
2466                .filter(|(_, e)| &e.from_node == node_id || &e.to_node == node_id)
2467                .map(|(id, _)| id.clone())
2468                .collect();
2469            for edge_id in edges_to_remove {
2470                self.edges.remove(&edge_id);
2471                self.edge_order.retain(|id| id != &edge_id);
2472            }
2473        }
2474
2475        for (_, node_type) in self.nodes.values_mut() {
2476            if let NodeType::Config(ConfigNodeType::NodeClass {
2477                target: Some(target),
2478                ..
2479            }) = node_type
2480                && node_ids.contains(target)
2481            {
2482                *node_type = NodeType::Config(ConfigNodeType::NodeClass {
2483                    target: None,
2484                    has_node_config: false,
2485                });
2486            }
2487        }
2488
2489        self.drop_unused_anchors();
2490        self.selected_nodes.clear();
2491    }
2492
2493    fn build_palette_commands(&self) -> (&'static str, Vec<Command<ApplicationMessage>>) {
2494        match &self.palette_view {
2495            PaletteView::Main => {
2496                let commands = vec![
2497                    command("add_node", "Add Node")
2498                        .description("Add a new node to the graph")
2499                        .shortcut(Shortcut::cmd('n'))
2500                        .action(ApplicationMessage::NavigateToSubmenu("nodes".to_string())),
2501                    command("change_theme", "Change Theme")
2502                        .description("Switch to a different color theme")
2503                        .shortcut(Shortcut::cmd('t'))
2504                        .action(ApplicationMessage::NavigateToSubmenu("themes".to_string())),
2505                    command("export_state", "Export State")
2506                        .description("Export graph state to file for Claude")
2507                        .shortcut(Shortcut::cmd('e'))
2508                        .action(ApplicationMessage::ExportState),
2509                    command("reset", "Reset")
2510                        .description("Reset the app to its initial state")
2511                        .action(ApplicationMessage::Reset),
2512                    command("focus_node", "Focus node")
2513                        .description("Fly the camera to a fixed demo node (eased tween)")
2514                        .action(ApplicationMessage::FocusNode),
2515                ];
2516                ("Command Palette", commands)
2517            }
2518            PaletteView::Submenu(submenu) if submenu == "nodes" => {
2519                let commands = vec![
2520                    // Workflow nodes
2521                    command("workflow", "Workflow Nodes")
2522                        .description("Original demo nodes")
2523                        .action(ApplicationMessage::NavigateToSubmenu(
2524                            "workflow_nodes".to_string(),
2525                        )),
2526                    // Input nodes
2527                    command("inputs", "Input Nodes")
2528                        .description("Sliders, color pickers, etc.")
2529                        .action(ApplicationMessage::NavigateToSubmenu(
2530                            "input_nodes".to_string(),
2531                        )),
2532                    // Math nodes
2533                    command("math", "Math Nodes")
2534                        .description("Add, Subtract, Multiply, Divide")
2535                        .action(ApplicationMessage::NavigateToSubmenu(
2536                            "math_nodes".to_string(),
2537                        )),
2538                    // Config nodes
2539                    command("config", "Style Config Nodes")
2540                        .description("Configure node and edge styling")
2541                        .action(ApplicationMessage::NavigateToSubmenu(
2542                            "config_nodes".to_string(),
2543                        )),
2544                ];
2545                ("Add Node", commands)
2546            }
2547            PaletteView::Submenu(submenu) if submenu == "workflow_nodes" => {
2548                let workflow_nodes = vec!["email_trigger", "email_parser", "filter", "calendar"];
2549                let commands = workflow_nodes
2550                    .into_iter()
2551                    .map(|name| {
2552                        command(name, name).action(ApplicationMessage::SpawnNode {
2553                            node_type: NodeType::Workflow(name.to_string()),
2554                        })
2555                    })
2556                    .collect();
2557                ("Workflow Nodes", commands)
2558            }
2559            PaletteView::Submenu(submenu) if submenu == "input_nodes" => {
2560                let commands = vec![
2561                    command("float_slider", "Float Slider")
2562                        .description("Generic float slider (0-20)")
2563                        .action(ApplicationMessage::SpawnNode {
2564                            node_type: NodeType::Input(InputNodeType::FloatSlider {
2565                                config: FloatSliderConfig::default(),
2566                                value: 5.0,
2567                            }),
2568                        }),
2569                    command("pattern_angle", "Pattern Angle")
2570                        .description("Angle for Arrowed/Angled patterns (-90 to 90 degrees)")
2571                        .action(ApplicationMessage::SpawnNode {
2572                            node_type: NodeType::Input(InputNodeType::FloatSlider {
2573                                config: FloatSliderConfig::pattern_angle(),
2574                                value: 45.0,
2575                            }),
2576                        }),
2577                    command("color_picker", "Color Picker (RGB)")
2578                        .description("Full RGB color picker with sliders")
2579                        .action(ApplicationMessage::SpawnNode {
2580                            node_type: NodeType::Input(InputNodeType::ColorPicker {
2581                                color: Color::from_rgb(0.5, 0.5, 0.5),
2582                            }),
2583                        }),
2584                    command("color_preset", "Color Presets")
2585                        .description("Quick color selection from presets")
2586                        .action(ApplicationMessage::SpawnNode {
2587                            node_type: NodeType::Input(InputNodeType::ColorPreset {
2588                                color: Color::from_rgb(0.5, 0.5, 0.5),
2589                            }),
2590                        }),
2591                    command("int_slider", "Int Slider")
2592                        .description("Integer slider (0-100)")
2593                        .action(ApplicationMessage::SpawnNode {
2594                            node_type: NodeType::Input(InputNodeType::IntSlider {
2595                                config: IntSliderConfig::default(),
2596                                value: 50,
2597                            }),
2598                        }),
2599                    command("bool_toggle", "Boolean Toggle")
2600                        .description("Toggle for boolean values")
2601                        .action(ApplicationMessage::SpawnNode {
2602                            node_type: NodeType::Input(InputNodeType::BoolToggle {
2603                                config: BoolToggleConfig::default(),
2604                                value: true,
2605                            }),
2606                        }),
2607                    command("edge_curve", "Edge Curve Selector")
2608                        .description("Select edge curve (Bezier, Line, Orthogonal)")
2609                        .action(ApplicationMessage::SpawnNode {
2610                            node_type: NodeType::Input(InputNodeType::EdgeCurveSelector {
2611                                value: EdgeCurve::BezierCubic,
2612                            }),
2613                        }),
2614                    command("pin_shape", "Pin Shape Selector")
2615                        .description("Select pin shape (Circle, Square)")
2616                        .action(ApplicationMessage::SpawnNode {
2617                            node_type: NodeType::Input(InputNodeType::PinShapeSelector {
2618                                value: PinShape::Circle,
2619                            }),
2620                        }),
2621                    command("pattern_type", "Pattern Type Selector")
2622                        .description("Select edge pattern (Solid, Dashed, Dotted)")
2623                        .action(ApplicationMessage::SpawnNode {
2624                            node_type: NodeType::Input(InputNodeType::PatternTypeSelector {
2625                                value: PatternType::Solid,
2626                            }),
2627                        }),
2628                    command("tiling_kind", "Tiling Kind Selector")
2629                        .description("Select canvas tiling (Grid, Dots, Triangles, Hex)")
2630                        .action(ApplicationMessage::SpawnNode {
2631                            node_type: NodeType::Input(InputNodeType::TilingKindSelector {
2632                                value: TilingKind::Grid,
2633                            }),
2634                        }),
2635                    command("theme", "Theme")
2636                        .description("Active theme's basic palette as color outputs")
2637                        .action(ApplicationMessage::SpawnNode {
2638                            node_type: NodeType::Theme,
2639                        }),
2640                    command("theme_extended", "Theme Extended")
2641                        .description("Extended palette (base/weak/strong) as color outputs")
2642                        .action(ApplicationMessage::SpawnNode {
2643                            node_type: NodeType::ThemeExtended,
2644                        }),
2645                ];
2646                ("Input Nodes", commands)
2647            }
2648            PaletteView::Submenu(submenu) if submenu == "math_nodes" => {
2649                let commands = vec![
2650                    command("add", "Add").description("A + B").action(
2651                        ApplicationMessage::SpawnNode {
2652                            node_type: NodeType::Math(MathNodeState::new(MathOperation::Add)),
2653                        },
2654                    ),
2655                    command("subtract", "Subtract").description("A - B").action(
2656                        ApplicationMessage::SpawnNode {
2657                            node_type: NodeType::Math(MathNodeState::new(MathOperation::Subtract)),
2658                        },
2659                    ),
2660                    command("multiply", "Multiply").description("A * B").action(
2661                        ApplicationMessage::SpawnNode {
2662                            node_type: NodeType::Math(MathNodeState::new(MathOperation::Multiply)),
2663                        },
2664                    ),
2665                    command("divide", "Divide").description("A / B").action(
2666                        ApplicationMessage::SpawnNode {
2667                            node_type: NodeType::Math(MathNodeState::new(MathOperation::Divide)),
2668                        },
2669                    ),
2670                ];
2671                ("Math Nodes", commands)
2672            }
2673            PaletteView::Submenu(submenu) if submenu == "config_nodes" => {
2674                let commands = vec![
2675                    command("node_config", "Node Config")
2676                        .description("Node config with all fields and inheritance")
2677                        .action(ApplicationMessage::SpawnNode {
2678                            node_type: NodeType::Config(ConfigNodeType::NodeConfig(
2679                                NodeConfigInputs::default(),
2680                            )),
2681                        }),
2682                    command("edge_config", "Edge Config")
2683                        .description("Edge config with colors, thickness, type")
2684                        .action(ApplicationMessage::SpawnNode {
2685                            node_type: NodeType::Config(ConfigNodeType::EdgeConfig(
2686                                EdgeConfigInputs::default(),
2687                            )),
2688                        }),
2689                    command("pin_config", "Pin Config")
2690                        .description("Pin configuration with shape, color, radius")
2691                        .action(ApplicationMessage::SpawnNode {
2692                            node_type: NodeType::Config(ConfigNodeType::PinConfig(
2693                                PinConfigInputs::default(),
2694                            )),
2695                        }),
2696                    command("graph_config", "Graph Config")
2697                        .description("Canvas background and tiling (grid/dots/...)")
2698                        .action(ApplicationMessage::SpawnNode {
2699                            node_type: NodeType::Config(ConfigNodeType::GraphConfig(
2700                                GraphConfigInputs::default(),
2701                            )),
2702                        }),
2703                    command("anchor_config", "Anchor Config")
2704                        .description("Anchor core, border and orbit ring")
2705                        .action(ApplicationMessage::SpawnNode {
2706                            node_type: NodeType::Config(ConfigNodeType::AnchorConfig(
2707                                AnchorConfigInputs::default(),
2708                            )),
2709                        }),
2710                    command("selection_box_config", "Selection Box Config")
2711                        .description("Selection box fill and border")
2712                        .action(ApplicationMessage::SpawnNode {
2713                            node_type: NodeType::Config(ConfigNodeType::SelectionBoxConfig(
2714                                SelectionBoxConfigInputs::default(),
2715                            )),
2716                        }),
2717                    command("cutting_tool_config", "Cutting Tool Config")
2718                        .description("Edge-cutting trail color and width")
2719                        .action(ApplicationMessage::SpawnNode {
2720                            node_type: NodeType::Config(ConfigNodeType::CuttingToolConfig(
2721                                CuttingToolConfigInputs::default(),
2722                            )),
2723                        }),
2724                    command("minimap_config", "Minimap Config")
2725                        .description("Minimap background, marks and viewport")
2726                        .action(ApplicationMessage::SpawnNode {
2727                            node_type: NodeType::Config(ConfigNodeType::MinimapConfig(
2728                                MinimapConfigInputs::default(),
2729                            )),
2730                        }),
2731                    // Builder nodes
2732                    command("color_quad", "Color Quad")
2733                        .description("Combine 4 corner colors into one ColorQuad")
2734                        .action(ApplicationMessage::SpawnNode {
2735                            node_type: NodeType::ColorQuad(ColorQuadNode::default()),
2736                        }),
2737                    command("vec2", "Vec2")
2738                        .description("Combine x and y into a 2D vector (e.g. offset)")
2739                        .action(ApplicationMessage::SpawnNode {
2740                            node_type: NodeType::Vec2(Vec2Node::default()),
2741                        }),
2742                    command("alpha", "Alpha")
2743                        .description("Replace a color's alpha")
2744                        .action(ApplicationMessage::SpawnNode {
2745                            node_type: NodeType::Alpha(AlphaNode::default()),
2746                        }),
2747                    // Sinks
2748                    command("catalog", "Catalog")
2749                        .description("One input per style class and status")
2750                        .action(ApplicationMessage::SpawnNode {
2751                            node_type: NodeType::Config(ConfigNodeType::Catalog {
2752                                connected: HashSet::new(),
2753                            }),
2754                        }),
2755                    command("node_class", "Node Class")
2756                        .description("Assign a node config to one node")
2757                        .action(ApplicationMessage::SpawnNode {
2758                            node_type: NodeType::Config(ConfigNodeType::NodeClass {
2759                                target: None,
2760                                has_node_config: false,
2761                            }),
2762                        }),
2763                    // Layout
2764                    command("frame", "Frame")
2765                        .description("A titled region that moves the nodes laid over it")
2766                        .action(ApplicationMessage::SpawnNode {
2767                            node_type: NodeType::Frame {
2768                                label: "Frame".to_string(),
2769                                size: Size::new(400.0, 300.0),
2770                            },
2771                        }),
2772                ];
2773                ("Style Config Nodes", commands)
2774            }
2775            PaletteView::Submenu(submenu) if submenu == "themes" => {
2776                let commands = Self::get_available_themes()
2777                    .iter()
2778                    .map(|theme| {
2779                        let name = Self::get_theme_name(theme);
2780                        command(name, name).action(ApplicationMessage::ChangeTheme(theme.clone()))
2781                    })
2782                    .collect();
2783                ("Choose Theme", commands)
2784            }
2785            _ => ("Command Palette", vec![]),
2786        }
2787    }
2788}
2789
2790/// Resolves a Theme node output pin to a color from the theme's extended palette.
2791/// Returns None for unknown pins.
2792/// Resolves a Theme / Theme Extended node output pin to its color. Basic
2793/// [`theme`](nodes::pins::theme) labels map to the flat [`Theme::palette`];
2794/// [`theme_ext`](nodes::pins::theme_ext) labels map to the graded
2795/// [`Theme::extended_palette`]. The two label sets are disjoint, so one lookup
2796/// serves both node kinds.
2797fn theme_color(theme: &Theme, pin: &PinLabel) -> Option<iced::Color> {
2798    use nodes::pins::{theme as t, theme_ext as x};
2799
2800    // Basic palette (flat entries).
2801    let pal = theme.palette();
2802    if *pin == t::BACKGROUND {
2803        return Some(pal.background);
2804    } else if *pin == t::TEXT {
2805        return Some(pal.text);
2806    } else if *pin == t::PRIMARY {
2807        return Some(pal.primary);
2808    } else if *pin == t::SUCCESS {
2809        return Some(pal.success);
2810    } else if *pin == t::WARNING {
2811        return Some(pal.warning);
2812    } else if *pin == t::DANGER {
2813        return Some(pal.danger);
2814    }
2815
2816    // Extended palette (base/weak/strong per accent group).
2817    let p = theme.extended_palette();
2818    let color = if *pin == x::BACKGROUND_BASE {
2819        p.background.base.color
2820    } else if *pin == x::BACKGROUND_WEAK {
2821        p.background.weak.color
2822    } else if *pin == x::BACKGROUND_STRONG {
2823        p.background.strong.color
2824    } else if *pin == x::PRIMARY_BASE {
2825        p.primary.base.color
2826    } else if *pin == x::PRIMARY_WEAK {
2827        p.primary.weak.color
2828    } else if *pin == x::PRIMARY_STRONG {
2829        p.primary.strong.color
2830    } else if *pin == x::SECONDARY_BASE {
2831        p.secondary.base.color
2832    } else if *pin == x::SECONDARY_WEAK {
2833        p.secondary.weak.color
2834    } else if *pin == x::SECONDARY_STRONG {
2835        p.secondary.strong.color
2836    } else if *pin == x::SUCCESS_BASE {
2837        p.success.base.color
2838    } else if *pin == x::SUCCESS_WEAK {
2839        p.success.weak.color
2840    } else if *pin == x::SUCCESS_STRONG {
2841        p.success.strong.color
2842    } else if *pin == x::WARNING_BASE {
2843        p.warning.base.color
2844    } else if *pin == x::WARNING_WEAK {
2845        p.warning.weak.color
2846    } else if *pin == x::WARNING_STRONG {
2847        p.warning.strong.color
2848    } else if *pin == x::DANGER_BASE {
2849        p.danger.base.color
2850    } else if *pin == x::DANGER_WEAK {
2851        p.danger.weak.color
2852    } else if *pin == x::DANGER_STRONG {
2853        p.danger.strong.color
2854    } else {
2855        return None;
2856    };
2857    Some(color)
2858}
2859
2860/// Feeds a propagated `value` into a combiner node's input pin (Math, ColorQuad,
2861/// Vec2 or Alpha). Returns true if the stored input changed, so the propagation
2862/// loop knows to keep iterating. No-op for any other node type or unknown pin.
2863fn feed_combiner_input(node: &mut NodeType, pin: &PinLabel, value: &NodeValue) -> bool {
2864    use nodes::pins::{build as pin_build, math as pin_math};
2865
2866    let mut changed = false;
2867    match node {
2868        NodeType::Math(state) => {
2869            if let Some(f) = value.as_float() {
2870                if *pin == pin_math::A && state.input_a != Some(f) {
2871                    state.input_a = Some(f);
2872                    changed = true;
2873                } else if *pin == pin_math::B && state.input_b != Some(f) {
2874                    state.input_b = Some(f);
2875                    changed = true;
2876                }
2877            }
2878        }
2879        NodeType::ColorQuad(state) => {
2880            if let Some(c) = value.as_color() {
2881                let slot = if *pin == pin_build::NEAR_START {
2882                    Some(&mut state.near_start)
2883                } else if *pin == pin_build::NEAR_END {
2884                    Some(&mut state.near_end)
2885                } else if *pin == pin_build::FAR_START {
2886                    Some(&mut state.far_start)
2887                } else if *pin == pin_build::FAR_END {
2888                    Some(&mut state.far_end)
2889                } else {
2890                    None
2891                };
2892                if let Some(slot) = slot
2893                    && *slot != Some(c)
2894                {
2895                    *slot = Some(c);
2896                    changed = true;
2897                }
2898            }
2899        }
2900        NodeType::Vec2(state) => {
2901            if let Some(f) = value.as_float() {
2902                if *pin == pin_build::X && state.x != Some(f) {
2903                    state.x = Some(f);
2904                    changed = true;
2905                } else if *pin == pin_build::Y && state.y != Some(f) {
2906                    state.y = Some(f);
2907                    changed = true;
2908                }
2909            }
2910        }
2911        NodeType::Alpha(state) => {
2912            if *pin == pin_build::ALPHA_COLOR {
2913                let c = value.as_color_quad().map(|q| q.near_start);
2914                if c.is_some() && state.color != c {
2915                    state.color = c;
2916                    changed = true;
2917                }
2918            } else if *pin == pin_build::ALPHA {
2919                let a = value.as_float();
2920                if a.is_some() && state.alpha != a {
2921                    state.alpha = a;
2922                    changed = true;
2923                }
2924            }
2925        }
2926        _ => {}
2927    }
2928    changed
2929}
2930
2931fn handle_keyboard_event(
2932    event: Event,
2933    _status: iced::event::Status,
2934    _window: iced::window::Id,
2935) -> Option<ApplicationMessage> {
2936    match event {
2937        Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
2938            if is_toggle_shortcut(&key, modifiers) {
2939                return Some(ApplicationMessage::ToggleCommandPalette);
2940            }
2941
2942            if modifiers.command() {
2943                let main_commands = Application::get_main_commands_with_shortcuts();
2944                if let Some(cmd_id) = find_matching_shortcut(&main_commands, &key, modifiers) {
2945                    return Some(ApplicationMessage::ExecuteShortcut(cmd_id.to_string()));
2946                }
2947            }
2948
2949            match key {
2950                keyboard::Key::Named(keyboard::key::Named::Tab) => {
2951                    if modifiers.shift() {
2952                        Some(ApplicationMessage::FocusPrevious)
2953                    } else {
2954                        Some(ApplicationMessage::FocusNext)
2955                    }
2956                }
2957                keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
2958                    Some(ApplicationMessage::CommandPaletteNavigateUp)
2959                }
2960                keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
2961                    Some(ApplicationMessage::CommandPaletteNavigateDown)
2962                }
2963                keyboard::Key::Named(keyboard::key::Named::Enter) => {
2964                    Some(ApplicationMessage::CommandPaletteConfirm)
2965                }
2966                keyboard::Key::Named(keyboard::key::Named::Escape) => {
2967                    Some(ApplicationMessage::CommandPaletteCancel)
2968                }
2969                _ => None,
2970            }
2971        }
2972        _ => None,
2973    }
2974}
2975
2976#[cfg(test)]
2977mod tests {
2978    use super::*;
2979    use nodes::{MathNodeState, MathOperation, NodeType};
2980
2981    // === Math Operation Tests ===
2982
2983    #[test]
2984    fn test_math_add() {
2985        let op = MathOperation::Add;
2986        assert_eq!(op.compute(5.0, 3.0), 8.0);
2987        assert_eq!(op.symbol(), "+");
2988        assert_eq!(op.name(), "Add");
2989    }
2990
2991    #[test]
2992    fn test_math_subtract() {
2993        let op = MathOperation::Subtract;
2994        assert_eq!(op.compute(5.0, 3.0), 2.0);
2995        assert_eq!(op.compute(3.0, 5.0), -2.0);
2996        assert_eq!(op.symbol(), "-");
2997    }
2998
2999    #[test]
3000    fn test_math_multiply() {
3001        let op = MathOperation::Multiply;
3002        assert_eq!(op.compute(5.0, 3.0), 15.0);
3003        assert_eq!(op.compute(0.0, 100.0), 0.0);
3004        assert_eq!(op.symbol(), "*");
3005    }
3006
3007    #[test]
3008    fn test_math_divide() {
3009        let op = MathOperation::Divide;
3010        assert_eq!(op.compute(6.0, 2.0), 3.0);
3011        assert_eq!(op.symbol(), "/");
3012    }
3013
3014    #[test]
3015    fn test_math_divide_by_zero() {
3016        let op = MathOperation::Divide;
3017        let result = op.compute(5.0, 0.0);
3018        assert!(result.is_infinite());
3019    }
3020
3021    // === MathNodeState Tests ===
3022
3023    #[test]
3024    fn test_math_node_result_with_both_inputs() {
3025        let mut state = MathNodeState::new(MathOperation::Add);
3026        state.input_a = Some(10.0);
3027        state.input_b = Some(5.0);
3028        assert_eq!(state.result(), Some(15.0));
3029    }
3030
3031    #[test]
3032    fn test_math_node_result_with_missing_a() {
3033        let mut state = MathNodeState::new(MathOperation::Add);
3034        state.input_a = None;
3035        state.input_b = Some(5.0);
3036        assert_eq!(state.result(), None);
3037    }
3038
3039    #[test]
3040    fn test_math_node_result_with_missing_b() {
3041        let mut state = MathNodeState::new(MathOperation::Add);
3042        state.input_a = Some(10.0);
3043        state.input_b = None;
3044        assert_eq!(state.result(), None);
3045    }
3046
3047    // === NodeType Output Value Tests ===
3048
3049    #[test]
3050    fn test_math_node_output_value() {
3051        let mut state = MathNodeState::new(MathOperation::Multiply);
3052        state.input_a = Some(4.0);
3053        state.input_b = Some(3.0);
3054        let node_type = NodeType::Math(state);
3055
3056        let output = node_type.output_value();
3057        assert!(output.is_some());
3058        if let Some(NodeValue::Float(f)) = output {
3059            assert_eq!(f, 12.0);
3060        } else {
3061            panic!("Expected Float value");
3062        }
3063    }
3064
3065    #[test]
3066    fn test_math_node_output_value_no_result() {
3067        let state = MathNodeState::new(MathOperation::Add); // No inputs
3068        let node_type = NodeType::Math(state);
3069        assert!(node_type.output_value().is_none());
3070    }
3071
3072    #[test]
3073    fn test_input_node_output_value() {
3074        let input = InputNodeType::FloatSlider {
3075            config: FloatSliderConfig::default(),
3076            value: 7.5,
3077        };
3078        let node_type = NodeType::Input(input);
3079
3080        let output = node_type.output_value();
3081        assert!(output.is_some());
3082        if let Some(NodeValue::Float(f)) = output {
3083            assert!((f - 7.5).abs() < 0.001);
3084        } else {
3085            panic!("Expected Float value");
3086        }
3087    }
3088
3089    // === Propagation chain tests ===
3090
3091    /// An application with an empty graph, ready for nodes and edges.
3092    fn empty_app() -> Application {
3093        let mut app = Application::default();
3094        app.nodes.clear();
3095        app.node_order.clear();
3096        app.edges.clear();
3097        app.edge_order.clear();
3098        app.anchors.clear();
3099        app
3100    }
3101
3102    fn add(app: &mut Application, node_type: NodeType) -> NodeId {
3103        let id = generate_node_id();
3104        app.nodes.insert(id.clone(), (Point::ORIGIN, node_type));
3105        app.node_order.push(id.clone());
3106        id
3107    }
3108
3109    fn wire(app: &mut Application, from: &NodeId, fp: PinLabel, to: &NodeId, tp: PinLabel) {
3110        let e = generate_edge_id();
3111        app.edges
3112            .insert(e.clone(), EdgeData::new(from.clone(), fp, to.clone(), tp));
3113        app.edge_order.push(e);
3114    }
3115
3116    fn catalog(app: &mut Application) -> NodeId {
3117        add(
3118            app,
3119            NodeType::Config(ConfigNodeType::Catalog {
3120                connected: HashSet::new(),
3121            }),
3122        )
3123    }
3124
3125    fn node_config(app: &mut Application) -> NodeId {
3126        add(
3127            app,
3128            NodeType::Config(ConfigNodeType::NodeConfig(NodeConfigInputs::default())),
3129        )
3130    }
3131
3132    fn slider(app: &mut Application, value: f32) -> NodeId {
3133        add(
3134            app,
3135            NodeType::Input(InputNodeType::FloatSlider {
3136                config: FloatSliderConfig::default(),
3137                value,
3138            }),
3139        )
3140    }
3141
3142    fn picker(app: &mut Application, color: Color) -> NodeId {
3143        add(app, NodeType::Input(InputNodeType::ColorPicker { color }))
3144    }
3145
3146    /// The Catalog inputs the given node reports as connected.
3147    fn connected(app: &Application, catalog: &NodeId) -> HashSet<PinLabel> {
3148        match app.nodes.get(catalog) {
3149            Some((_, NodeType::Config(ConfigNodeType::Catalog { connected }))) => connected.clone(),
3150            _ => panic!("catalog node missing"),
3151        }
3152    }
3153
3154    #[test]
3155    fn test_node_config_chain_applies_to_catalog() {
3156        // ColorPicker -> NodeConfig.fill_color, NodeConfig -> Catalog.node.
3157        use nodes::pins;
3158        let mut app = empty_app();
3159        let red = Color::from_rgb(1.0, 0.0, 0.0);
3160        let picker = picker(&mut app, red);
3161        let cfg = node_config(&mut app);
3162        let catalog = catalog(&mut app);
3163        wire(
3164            &mut app,
3165            &picker,
3166            pins::input::COLOR,
3167            &cfg,
3168            pins::node::FILL_COLOR,
3169        );
3170        wire(
3171            &mut app,
3172            &cfg,
3173            pins::cfg::NODE_OUT,
3174            &catalog,
3175            pins::cfg::NODE_CONFIG,
3176        );
3177
3178        app.propagate_values();
3179
3180        assert!(connected(&app, &catalog).contains(pins::cfg::NODE_CONFIG));
3181        assert_eq!(
3182            app.computed.node.fill_color.map(|q| q.near_start),
3183            Some(red),
3184            "computed node class did not receive the config fill color",
3185        );
3186    }
3187
3188    #[test]
3189    fn test_catalog_rejects_mismatched_kind() {
3190        // A node config wired into the `graph` input is not a graph config:
3191        // nothing is connected and nothing is computed.
3192        use nodes::pins;
3193        let mut app = empty_app();
3194        let picker = picker(&mut app, Color::WHITE);
3195        let cfg = node_config(&mut app);
3196        let catalog = catalog(&mut app);
3197        wire(
3198            &mut app,
3199            &picker,
3200            pins::input::COLOR,
3201            &cfg,
3202            pins::node::FILL_COLOR,
3203        );
3204        wire(
3205            &mut app,
3206            &cfg,
3207            pins::cfg::NODE_OUT,
3208            &catalog,
3209            pins::cfg::GRAPH_CONFIG,
3210        );
3211
3212        app.propagate_values();
3213
3214        assert!(connected(&app, &catalog).is_empty());
3215        assert!(app.computed.node.fill_color.is_none());
3216        assert!(app.computed.graph.background_color.is_none());
3217    }
3218
3219    #[test]
3220    fn test_graph_config_chain_applies_to_catalog() {
3221        // ColorPicker -> GraphConfig.background and TilingKind ->
3222        // GraphConfig.tiling_kind, then GraphConfig -> Catalog.graph. The
3223        // computed overlay carries both and resolve_over installs them.
3224        use nodes::pins;
3225        let mut app = empty_app();
3226        let blue = Color::from_rgb(0.0, 0.0, 1.0);
3227        let picker = picker(&mut app, blue);
3228        let kind = add(
3229            &mut app,
3230            NodeType::Input(InputNodeType::TilingKindSelector {
3231                value: TilingKind::Dots,
3232            }),
3233        );
3234        let cfg = add(
3235            &mut app,
3236            NodeType::Config(ConfigNodeType::GraphConfig(GraphConfigInputs::default())),
3237        );
3238        let catalog = catalog(&mut app);
3239        wire(
3240            &mut app,
3241            &picker,
3242            pins::input::COLOR,
3243            &cfg,
3244            pins::graph::BACKGROUND,
3245        );
3246        wire(
3247            &mut app,
3248            &kind,
3249            pins::input::VALUE,
3250            &cfg,
3251            pins::graph::TILING_KIND,
3252        );
3253        wire(
3254            &mut app,
3255            &cfg,
3256            pins::cfg::GRAPH_OUT,
3257            &catalog,
3258            pins::cfg::GRAPH_CONFIG,
3259        );
3260
3261        app.propagate_values();
3262
3263        assert!(connected(&app, &catalog).contains(pins::cfg::GRAPH_CONFIG));
3264        assert_eq!(app.computed.graph.background_color, Some(blue));
3265        assert_eq!(app.computed.graph.tiling_kind, Some(TilingKind::Dots));
3266        let resolved = app
3267            .computed
3268            .graph
3269            .resolve_over(default_graph_style(&app.current_theme));
3270        assert_eq!(resolved.background_color, blue);
3271        assert_eq!(resolved.tiling.map(|t| t.kind), Some(TilingKind::Dots));
3272    }
3273
3274    #[test]
3275    fn test_node_config_shadow_chain_with_vec2() {
3276        // ColorPicker -> shadow_color, slider -> shadow_distance, two sliders
3277        // -> Vec2 -> shadow_offset, NodeConfig -> Catalog.node.
3278        use nodes::pins;
3279        let mut app = empty_app();
3280        let red = Color::from_rgb(1.0, 0.0, 0.0);
3281        let picker = picker(&mut app, red);
3282        let dist = slider(&mut app, 8.0);
3283        let sx = slider(&mut app, 5.0);
3284        let sy = slider(&mut app, 7.0);
3285        let vec2 = add(&mut app, NodeType::Vec2(Vec2Node::default()));
3286        let cfg = node_config(&mut app);
3287        let catalog = catalog(&mut app);
3288        wire(
3289            &mut app,
3290            &picker,
3291            pins::input::COLOR,
3292            &cfg,
3293            pins::node::SHADOW_COLOR,
3294        );
3295        wire(
3296            &mut app,
3297            &dist,
3298            pins::input::VALUE,
3299            &cfg,
3300            pins::node::SHADOW_DISTANCE,
3301        );
3302        wire(&mut app, &sx, pins::input::VALUE, &vec2, pins::build::X);
3303        wire(&mut app, &sy, pins::input::VALUE, &vec2, pins::build::Y);
3304        wire(
3305            &mut app,
3306            &vec2,
3307            pins::build::VEC2_OUT,
3308            &cfg,
3309            pins::node::SHADOW_OFFSET,
3310        );
3311        wire(
3312            &mut app,
3313            &cfg,
3314            pins::cfg::NODE_OUT,
3315            &catalog,
3316            pins::cfg::NODE_CONFIG,
3317        );
3318
3319        app.propagate_values();
3320
3321        let node = &app.computed.node;
3322        assert_eq!(node.shadow_color, Some(red));
3323        assert_eq!(node.shadow_distance, Some(8.0));
3324        assert_eq!(node.shadow_offset, Some((5.0, 7.0)));
3325    }
3326
3327    #[test]
3328    fn test_theme_node_feeds_config() {
3329        // Theme.primary -> NodeConfig.fill_color -> Catalog.node carries the
3330        // active theme's primary color.
3331        use nodes::pins;
3332        let mut app = empty_app();
3333        let theme = add(&mut app, NodeType::Theme);
3334        let cfg = node_config(&mut app);
3335        let catalog = catalog(&mut app);
3336        wire(
3337            &mut app,
3338            &theme,
3339            pins::theme::PRIMARY,
3340            &cfg,
3341            pins::node::FILL_COLOR,
3342        );
3343        wire(
3344            &mut app,
3345            &cfg,
3346            pins::cfg::NODE_OUT,
3347            &catalog,
3348            pins::cfg::NODE_CONFIG,
3349        );
3350
3351        let expected = app.current_theme.palette().primary;
3352        app.propagate_values();
3353        assert_eq!(
3354            app.computed.node.fill_color.map(|q| q.near_start),
3355            Some(expected),
3356        );
3357    }
3358
3359    #[test]
3360    fn test_theme_extended_node_feeds_config() {
3361        // ThemeExtended.primary_strong -> NodeConfig.fill_color -> Catalog.node
3362        // carries the extended palette's strong primary.
3363        use nodes::pins;
3364        let mut app = empty_app();
3365        let theme = add(&mut app, NodeType::ThemeExtended);
3366        let cfg = node_config(&mut app);
3367        let catalog = catalog(&mut app);
3368        wire(
3369            &mut app,
3370            &theme,
3371            pins::theme_ext::PRIMARY_STRONG,
3372            &cfg,
3373            pins::node::FILL_COLOR,
3374        );
3375        wire(
3376            &mut app,
3377            &cfg,
3378            pins::cfg::NODE_OUT,
3379            &catalog,
3380            pins::cfg::NODE_CONFIG,
3381        );
3382
3383        let expected = app.current_theme.extended_palette().primary.strong.color;
3384        app.propagate_values();
3385        assert_eq!(
3386            app.computed.node.fill_color.map(|q| q.near_start),
3387            Some(expected),
3388        );
3389    }
3390
3391    #[test]
3392    fn test_palette_preview_recolors_rig() {
3393        // With a preview theme open, the palette pins read the previewed
3394        // theme, not the applied one.
3395        use nodes::pins;
3396        let mut app = empty_app();
3397        let theme = add(&mut app, NodeType::Theme);
3398        let cfg = node_config(&mut app);
3399        let catalog = catalog(&mut app);
3400        wire(
3401            &mut app,
3402            &theme,
3403            pins::theme::PRIMARY,
3404            &cfg,
3405            pins::node::FILL_COLOR,
3406        );
3407        wire(
3408            &mut app,
3409            &cfg,
3410            pins::cfg::NODE_OUT,
3411            &catalog,
3412            pins::cfg::NODE_CONFIG,
3413        );
3414
3415        app.current_theme = Theme::Dark;
3416        app.palette_preview_theme = Some(Theme::Light);
3417        app.propagate_values();
3418        assert_eq!(
3419            app.computed.node.fill_color.map(|q| q.near_start),
3420            Some(Theme::Light.palette().primary),
3421        );
3422    }
3423
3424    #[test]
3425    fn catalog_status_input_layers_over_idle() {
3426        // Idle fill red on `node`, selected border blue on `node:selected`:
3427        // the selected class carries both, the idle class only the fill.
3428        use nodes::pins;
3429        let mut app = empty_app();
3430        let red = Color::from_rgb(1.0, 0.0, 0.0);
3431        let blue = Color::from_rgb(0.0, 0.0, 1.0);
3432        let red_picker = picker(&mut app, red);
3433        let blue_picker = picker(&mut app, blue);
3434        let idle_cfg = node_config(&mut app);
3435        let selected_cfg = node_config(&mut app);
3436        let catalog = catalog(&mut app);
3437        wire(
3438            &mut app,
3439            &red_picker,
3440            pins::input::COLOR,
3441            &idle_cfg,
3442            pins::node::FILL_COLOR,
3443        );
3444        wire(
3445            &mut app,
3446            &blue_picker,
3447            pins::input::COLOR,
3448            &selected_cfg,
3449            pins::node::BORDER_COLOR,
3450        );
3451        wire(
3452            &mut app,
3453            &idle_cfg,
3454            pins::cfg::NODE_OUT,
3455            &catalog,
3456            pins::cfg::NODE_CONFIG,
3457        );
3458        wire(
3459            &mut app,
3460            &selected_cfg,
3461            pins::cfg::NODE_OUT,
3462            &catalog,
3463            pins::cfg::NODE_SELECTED,
3464        );
3465
3466        app.propagate_values();
3467
3468        let selected = app.computed.node(NodeStatus::Selected);
3469        assert_eq!(selected.fill_color.map(|q| q.near_start), Some(red));
3470        assert_eq!(selected.border_color.map(|q| q.near_start), Some(blue));
3471        let idle = app.computed.node(NodeStatus::Idle);
3472        assert_eq!(idle.fill_color.map(|q| q.near_start), Some(red));
3473        assert!(idle.border_color.is_none());
3474    }
3475
3476    #[test]
3477    fn node_class_targets_one_node() {
3478        // A Node Class with a target adds a per-node class and nothing to the
3479        // global one; without a target it contributes nothing.
3480        use nodes::pins;
3481        let mut app = empty_app();
3482        let green = Color::from_rgb(0.0, 1.0, 0.0);
3483        let workflow = add(&mut app, NodeType::Workflow("filter".to_string()));
3484        let picker = picker(&mut app, green);
3485        let cfg = node_config(&mut app);
3486        let class = add(
3487            &mut app,
3488            NodeType::Config(ConfigNodeType::NodeClass {
3489                target: Some(workflow.clone()),
3490                has_node_config: false,
3491            }),
3492        );
3493        wire(
3494            &mut app,
3495            &picker,
3496            pins::input::COLOR,
3497            &cfg,
3498            pins::node::FILL_COLOR,
3499        );
3500        wire(
3501            &mut app,
3502            &cfg,
3503            pins::cfg::NODE_OUT,
3504            &class,
3505            pins::cfg::NODE_CONFIG,
3506        );
3507
3508        app.propagate_values();
3509
3510        assert_eq!(
3511            app.computed.node_classes[&workflow]
3512                .fill_color
3513                .map(|q| q.near_start),
3514            Some(green),
3515        );
3516        assert!(app.computed.node.fill_color.is_none());
3517        assert!(matches!(
3518            app.nodes.get(&class),
3519            Some((
3520                _,
3521                NodeType::Config(ConfigNodeType::NodeClass {
3522                    has_node_config: true,
3523                    ..
3524                })
3525            ))
3526        ));
3527
3528        if let Some((_, NodeType::Config(ConfigNodeType::NodeClass { target, .. }))) =
3529            app.nodes.get_mut(&class)
3530        {
3531            *target = None;
3532        }
3533        app.propagate_values();
3534        assert!(app.computed.node_classes.is_empty());
3535    }
3536
3537    #[test]
3538    fn deleting_the_target_clears_the_node_class() {
3539        let mut app = empty_app();
3540        let workflow = add(&mut app, NodeType::Workflow("filter".to_string()));
3541        let class = add(
3542            &mut app,
3543            NodeType::Config(ConfigNodeType::NodeClass {
3544                target: Some(workflow.clone()),
3545                has_node_config: false,
3546            }),
3547        );
3548
3549        app.delete_nodes(&[workflow]);
3550
3551        assert!(matches!(
3552            app.nodes.get(&class),
3553            Some((
3554                _,
3555                NodeType::Config(ConfigNodeType::NodeClass { target: None, .. })
3556            ))
3557        ));
3558    }
3559
3560    #[test]
3561    fn alpha_builder_applies_alpha_to_palette_color() {
3562        // ThemeExtended.primary_base -> Alpha (alpha 0.25) -> SelectionBox.fill
3563        // -> Catalog.selection_box: the fill is the palette color at 0.25.
3564        use nodes::pins;
3565        let mut app = empty_app();
3566        let theme = add(&mut app, NodeType::ThemeExtended);
3567        let alpha_slider = slider(&mut app, 0.25);
3568        let alpha = add(&mut app, NodeType::Alpha(AlphaNode::default()));
3569        let cfg = add(
3570            &mut app,
3571            NodeType::Config(ConfigNodeType::SelectionBoxConfig(
3572                SelectionBoxConfigInputs::default(),
3573            )),
3574        );
3575        let catalog = catalog(&mut app);
3576        wire(
3577            &mut app,
3578            &theme,
3579            pins::theme_ext::PRIMARY_BASE,
3580            &alpha,
3581            pins::build::ALPHA_COLOR,
3582        );
3583        wire(
3584            &mut app,
3585            &alpha_slider,
3586            pins::input::VALUE,
3587            &alpha,
3588            pins::build::ALPHA,
3589        );
3590        wire(
3591            &mut app,
3592            &alpha,
3593            pins::build::ALPHA_OUT,
3594            &cfg,
3595            pins::selection_box::FILL,
3596        );
3597        wire(
3598            &mut app,
3599            &cfg,
3600            pins::cfg::SELECTION_BOX_OUT,
3601            &catalog,
3602            pins::cfg::SELECTION_BOX,
3603        );
3604
3605        app.propagate_values();
3606
3607        let fill = app.computed.selection_box.fill.expect("fill not computed");
3608        let base = app.current_theme.extended_palette().primary.base.color;
3609        assert_eq!(fill.a, 0.25);
3610        assert_eq!((fill.r, fill.g, fill.b), (base.r, base.g, base.b));
3611    }
3612
3613    /// The four facts that prove the boot rig is complete and wired.
3614    fn assert_rig_complete(app: &Application) {
3615        use nodes::pins;
3616        let catalog_id = app
3617            .node_order
3618            .iter()
3619            .find(|id| {
3620                matches!(
3621                    app.nodes.get(*id),
3622                    Some((_, NodeType::Config(ConfigNodeType::Catalog { .. })))
3623                )
3624            })
3625            .expect("boot scene has no Catalog node");
3626        let connected = connected(app, catalog_id);
3627        for input in pins::cfg::CATALOG_INPUTS {
3628            assert!(connected.contains(input), "Catalog input {input} unwired");
3629        }
3630        assert_eq!(app.computed.node.corner_radius, Some(8.0));
3631        assert_eq!(app.computed.node.opacity, Some(0.88));
3632        assert_eq!(app.computed.graph.tiling_spacing, Some(40.0));
3633        assert_eq!(app.computed.node_classes.len(), 1);
3634    }
3635
3636    #[test]
3637    fn boot_rig_wires_every_catalog_input() {
3638        let mut app = Application::default();
3639        app.propagate_values();
3640        assert_rig_complete(&app);
3641    }
3642
3643    #[test]
3644    fn boot_rig_feeds_every_config_field() {
3645        // Every field pin of every config node in the rig has a source: the
3646        // rig is the demo's proof that the node system covers the Catalog.
3647        let mut app = Application::default();
3648        app.propagate_values();
3649
3650        // Pins the rig deliberately leaves unwired to keep data-type coloring.
3651        let optional: &[(&str, PinLabel)] = &[
3652            ("Pin", nodes::pins::pin::COLOR),
3653            ("Edge", nodes::pins::edge::STROKE_COLOR),
3654            ("Edge", nodes::pins::edge::GLOW_COLOR),
3655        ];
3656        let unwired = |title: &str, label: PinLabel, is_none: bool| {
3657            assert!(
3658                !is_none || optional.contains(&(title, label)),
3659                "{title} config: {label} has no source"
3660            );
3661        };
3662
3663        // The rig's "Node" frame (idle class) is the one whose config carries
3664        // the corner radius; the others are partial by design.
3665        let node_inputs: Vec<&NodeConfigInputs> = app
3666            .nodes
3667            .values()
3668            .filter_map(|(_, t)| match t {
3669                NodeType::Config(ConfigNodeType::NodeConfig(i)) => Some(i),
3670                _ => None,
3671            })
3672            .collect();
3673        let idle = node_inputs
3674            .iter()
3675            .find(|i| i.corner_radius.is_some())
3676            .expect("idle node config");
3677        for (label, none) in [
3678            (nodes::pins::node::FILL_COLOR, idle.fill_color.is_none()),
3679            (nodes::pins::node::OPACITY, idle.opacity.is_none()),
3680            (nodes::pins::node::BORDER_COLOR, idle.border_color.is_none()),
3681            (nodes::pins::node::BORDER_WIDTH, idle.border_width.is_none()),
3682            (
3683                nodes::pins::node::BORDER_OUTLINE_WIDTH,
3684                idle.border_outline_width.is_none(),
3685            ),
3686            (
3687                nodes::pins::node::BORDER_OUTLINE_COLOR,
3688                idle.border_outline_color.is_none(),
3689            ),
3690            (nodes::pins::node::PATTERN, idle.pattern_type.is_none()),
3691            (nodes::pins::node::DASH, idle.dash_length.is_none()),
3692            (nodes::pins::node::GAP, idle.gap_length.is_none()),
3693            (nodes::pins::node::ANGLE, idle.pattern_angle.is_none()),
3694            (nodes::pins::node::SPEED, idle.animation_speed.is_none()),
3695            (nodes::pins::node::SHADOW_COLOR, idle.shadow_color.is_none()),
3696            (
3697                nodes::pins::node::SHADOW_DISTANCE,
3698                idle.shadow_distance.is_none(),
3699            ),
3700            (
3701                nodes::pins::node::SHADOW_OFFSET,
3702                idle.shadow_offset.is_none(),
3703            ),
3704        ] {
3705            unwired("Node", label, none);
3706        }
3707
3708        let edge = app
3709            .nodes
3710            .values()
3711            .find_map(|(_, t)| match t {
3712                NodeType::Config(ConfigNodeType::EdgeConfig(i)) if i.curve.is_some() => Some(i),
3713                _ => None,
3714            })
3715            .expect("idle edge config");
3716        for (label, none) in [
3717            (nodes::pins::edge::STROKE_COLOR, edge.stroke_color.is_none()),
3718            (nodes::pins::edge::THICKNESS, edge.thickness.is_none()),
3719            (
3720                nodes::pins::edge::STROKE_OUTLINE_WIDTH,
3721                edge.stroke_outline_width.is_none(),
3722            ),
3723            (
3724                nodes::pins::edge::STROKE_OUTLINE_COLOR,
3725                edge.stroke_outline_color.is_none(),
3726            ),
3727            (nodes::pins::edge::PATTERN, edge.pattern_type.is_none()),
3728            (nodes::pins::edge::DASH, edge.dash_length.is_none()),
3729            (nodes::pins::edge::GAP, edge.gap_length.is_none()),
3730            (nodes::pins::edge::DOT_RADIUS, edge.dot_radius.is_none()),
3731            (nodes::pins::edge::ANGLE, edge.pattern_angle.is_none()),
3732            (nodes::pins::edge::SPEED, edge.animation_speed.is_none()),
3733            (nodes::pins::edge::BORDER_WIDTH, edge.border_width.is_none()),
3734            (nodes::pins::edge::BORDER_GAP, edge.border_gap.is_none()),
3735            (nodes::pins::edge::BORDER_COLOR, edge.border_color.is_none()),
3736            (
3737                nodes::pins::edge::BORDER_BACKGROUND,
3738                edge.border_background.is_none(),
3739            ),
3740            (
3741                nodes::pins::edge::BORDER_OUTLINE_WIDTH,
3742                edge.border_outline_width.is_none(),
3743            ),
3744            (
3745                nodes::pins::edge::BORDER_OUTLINE_COLOR,
3746                edge.border_outline_color.is_none(),
3747            ),
3748            (nodes::pins::edge::SHADOW_COLOR, edge.shadow_color.is_none()),
3749            (nodes::pins::edge::SHADOW_BLUR, edge.shadow_blur.is_none()),
3750            (
3751                nodes::pins::edge::SHADOW_EXPAND,
3752                edge.shadow_expand.is_none(),
3753            ),
3754            (
3755                nodes::pins::edge::SHADOW_OFFSET,
3756                edge.shadow_offset.is_none(),
3757            ),
3758            (nodes::pins::edge::GLOW_COLOR, edge.glow_color.is_none()),
3759            (nodes::pins::edge::GLOW_WIDTH, edge.glow_width.is_none()),
3760        ] {
3761            unwired("Edge", label, none);
3762        }
3763
3764        let pin = app
3765            .nodes
3766            .values()
3767            .find_map(|(_, t)| match t {
3768                NodeType::Config(ConfigNodeType::PinConfig(i)) if i.radius.is_some() => Some(i),
3769                _ => None,
3770            })
3771            .expect("idle pin config");
3772        for (label, none) in [
3773            (nodes::pins::pin::COLOR, pin.color.is_none()),
3774            (nodes::pins::pin::CUTOUT_RADIUS, pin.cutout_radius.is_none()),
3775            (nodes::pins::pin::SHAPE, pin.shape.is_none()),
3776            (nodes::pins::pin::BORDER_COLOR, pin.border_color.is_none()),
3777            (nodes::pins::pin::BORDER_WIDTH, pin.border_width.is_none()),
3778        ] {
3779            unwired("Pin", label, none);
3780        }
3781
3782        // The single-instance classes: every field is set.
3783        let anchor = app.computed.anchor(AnchorStatus::Idle);
3784        assert!(anchor.core_size.is_some() && anchor.ring_width.is_some());
3785        assert!(anchor.core_color.is_some() && anchor.ring_color.is_some());
3786        assert!(anchor.core_border_color.is_some() && anchor.core_border_width.is_some());
3787        assert!(anchor.orbit_offset.is_some() && anchor.orbit_spacing.is_some());
3788        assert!(anchor.core_radius.is_some());
3789        assert!(anchor.offered_ring_color.is_some());
3790
3791        let g = &app.computed.graph;
3792        assert!(g.background_color.is_some() && g.tiling_kind.is_some());
3793        assert!(g.tiling_thickness.is_some() && g.tiling_color.is_some());
3794
3795        let s = &app.computed.selection_box;
3796        assert!(s.fill.is_some() && s.border_color.is_some() && s.border_width.is_some());
3797
3798        let c = &app.computed.cutting_tool;
3799        assert!(c.color.is_some() && c.width.is_some());
3800
3801        let m = &app.computed.minimap;
3802        assert!(m.background.is_some() && m.border_color.is_some());
3803        assert!(m.border_width.is_some() && m.node_color.is_some());
3804        assert!(m.selected_node_color.is_some() && m.viewport_fill.is_some());
3805        assert!(m.viewport_border_color.is_some() && m.viewport_border_width.is_some());
3806
3807        // Translucency arrives through the Alpha builders.
3808        assert!(s.fill.unwrap().a < 1.0);
3809        assert!(m.background.unwrap().a < 1.0);
3810    }
3811
3812    #[cfg(not(target_arch = "wasm32"))]
3813    #[test]
3814    fn persistence_round_trips_the_rig() {
3815        let app = Application::default();
3816        let saved = persistence::SavedState::from_app(
3817            &app.nodes,
3818            &app.node_order,
3819            &app.edges,
3820            &app.edge_order,
3821            &app.current_theme,
3822            app.camera_position,
3823            app.camera_zoom,
3824            None,
3825            None,
3826            &app.edge_config_sections,
3827            &app.node_config_sections,
3828            None,
3829            &app.anchors,
3830            app.next_anchor,
3831        );
3832        let json = serde_json::to_string(&saved).expect("serialize");
3833        let loaded: persistence::SavedState = serde_json::from_str(&json).expect("parse");
3834        let (nodes, node_order, edges, edge_order, _, _, _, _, _, _, _, _, anchors, next_anchor) =
3835            loaded.to_app();
3836
3837        let mut restored = Application {
3838            nodes,
3839            node_order,
3840            edges,
3841            edge_order,
3842            anchors,
3843            next_anchor,
3844            ..Application::default()
3845        };
3846        restored.propagate_values();
3847        assert_rig_complete(&restored);
3848        assert_eq!(restored.anchors, app.anchors);
3849        assert_eq!(restored.next_anchor, 1);
3850        let routed: Vec<_> = restored
3851            .edges
3852            .values()
3853            .filter(|e| !e.route.is_empty())
3854            .collect();
3855        assert_eq!(routed.len(), 1);
3856        assert_eq!(routed[0].route, vec![0]);
3857    }
3858
3859    #[test]
3860    fn test_node_config_shadow_resolves() {
3861        use iced_nodegraph::{ColorQuad, NodeStatus, NodeStyle};
3862
3863        // A NodeConfig with shadow fields set must carry them through build()
3864        // (overlay) and resolve_over() (concrete style) unchanged.
3865        let inputs = NodeConfigInputs {
3866            shadow_color: Some(ColorQuad::solid(Color::from_rgb(1.0, 0.0, 0.0))),
3867            shadow_distance: Some(12.0),
3868            shadow_offset: Some((4.0, 6.0)),
3869            ..Default::default()
3870        };
3871        let overlay = inputs.build();
3872        assert_eq!(overlay.shadow_color, Some(Color::from_rgb(1.0, 0.0, 0.0)));
3873        assert_eq!(overlay.shadow_distance, Some(12.0));
3874        assert_eq!(overlay.shadow_offset, Some((4.0, 6.0)));
3875
3876        // The comment preset carries no shadow; the overlay must install one.
3877        let resolved = overlay.resolve_over(NodeStyle::comment(&Theme::Dark, NodeStatus::Idle));
3878        assert_eq!(resolved.shadow_color, Color::from_rgb(1.0, 0.0, 0.0));
3879        assert_eq!(resolved.shadow_distance, 12.0);
3880        assert_eq!(resolved.shadow_offset, (4.0, 6.0));
3881    }
3882
3883    // === Boot scene: what the widget resolves ===
3884
3885    /// The workflow node the boot rig's Node Class targets.
3886    fn class_target(app: &Application) -> NodeId {
3887        app.nodes
3888            .values()
3889            .find_map(|(_, t)| match t {
3890                NodeType::Config(ConfigNodeType::NodeClass {
3891                    target: Some(id), ..
3892                }) => Some(id.clone()),
3893                _ => None,
3894            })
3895            .expect("boot rig has a targeted Node Class")
3896    }
3897
3898    #[test]
3899    fn classed_node_keeps_its_tint_while_selected() {
3900        let app = Application::default();
3901        let theme = app.effective_theme().clone();
3902        let palette = theme.extended_palette();
3903        let calendar = class_target(&app);
3904
3905        let idle = app.node_style(&calendar, &theme, NodeStatus::Idle);
3906        let selected = app.node_style(&calendar, &theme, NodeStatus::Selected);
3907        // The class wins for what it sets (fill and border color)...
3908        assert_eq!(idle.fill_color.near_start, palette.warning.weak.color);
3909        assert_eq!(selected.fill_color.near_start, palette.warning.weak.color);
3910        assert_eq!(selected.border_color.near_start, palette.warning.base.color);
3911        // ...and the selected class supplies the rest.
3912        assert_eq!(selected.border_pattern.thickness, 2.0);
3913        assert_eq!(selected.border_outline_width, 3.0);
3914        assert_eq!(selected.shadow_distance, 11.0);
3915        assert_eq!(idle.border_pattern.thickness, 1.0);
3916
3917        // An unclassed node takes the Catalog's selected class as a whole.
3918        let other = app.node_order[0].clone();
3919        let plain = app.node_style(&other, &theme, NodeStatus::Selected);
3920        assert_eq!(plain.fill_color.near_start, palette.primary.weak.color);
3921        assert_eq!(plain.border_color.near_start, palette.primary.base.color);
3922    }
3923
3924    #[test]
3925    fn hovered_anchor_takes_the_accent() {
3926        let app = Application::default();
3927        let theme = app.effective_theme().clone();
3928        let primary = theme.extended_palette().primary.base.color;
3929
3930        let idle = app.anchor_style(&theme, AnchorStatus::Idle);
3931        let hovered = app.anchor_style(&theme, AnchorStatus::Hovered);
3932        assert_eq!(idle.core_border_width, 1.0);
3933        assert_eq!(hovered.core_border_width, 1.5);
3934        let accent = hovered.core_border_color.near_start;
3935        assert_eq!(
3936            (accent.r, accent.g, accent.b),
3937            (primary.r, primary.g, primary.b)
3938        );
3939        assert_eq!(accent.a, 0.6);
3940        // Fields the hovered frame does not set come from the idle class.
3941        assert_eq!(hovered.orbit_offset, idle.orbit_offset);
3942        assert_eq!(hovered.core_size, 6.0);
3943    }
3944
3945    #[test]
3946    fn chrome_resolves_from_the_rig() {
3947        let app = Application::default();
3948        let theme = app.effective_theme().clone();
3949        let palette = theme.extended_palette();
3950
3951        assert_eq!(app.selection_box_style(&theme).fill.a, 0.15);
3952        assert_eq!(app.cutting_tool_style(&theme).width, 3.0);
3953        assert_eq!(
3954            app.cutting_tool_style(&theme).color,
3955            palette.danger.base.color
3956        );
3957        assert_eq!(app.minimap_style(&theme).background.a, 0.9);
3958        let graph = app.graph_style(&theme);
3959        assert_eq!(graph.background_color, palette.background.base.color);
3960        assert_eq!(graph.tiling.map(|t| t.spacing), Some(40.0));
3961        let float = TypeId::of::<nodes::pins::Float>();
3962        assert_eq!(app.pin_style(float, &theme, PinStatus::Idle).radius, 5.0);
3963        assert_eq!(
3964            app.edge_style(&theme, EdgeStatus::PendingCut, float, float)
3965                .stroke_color
3966                .near_start,
3967            palette.danger.base.color
3968        );
3969    }
3970
3971    // === Headless interaction: real events through `view()` ===
3972
3973    use iced::mouse;
3974    use iced_test::Simulator;
3975    use std::any::TypeId;
3976
3977    type Sim<'a> = Simulator<'a, ApplicationMessage, Theme, iced::Renderer>;
3978
3979    /// The boot scene at the identity camera, large enough to hold the rig's
3980    /// first frames, so layout coordinates are world coordinates.
3981    fn simulator(app: &Application) -> Sim<'_> {
3982        Simulator::with_size(
3983            iced::Settings::default(),
3984            Size::new(2000.0, 1600.0),
3985            app.view(),
3986        )
3987    }
3988
3989    /// Runs the simulator's messages through `update` once the simulator has
3990    /// released its borrow of `app`.
3991    fn apply(app: &mut Application, msgs: Vec<ApplicationMessage>) {
3992        for m in msgs {
3993            let _ = app.update(m);
3994        }
3995    }
3996
3997    fn moved(p: Point) -> iced::Event {
3998        iced::Event::Mouse(mouse::Event::CursorMoved { position: p })
3999    }
4000
4001    fn click(ui: &mut Sim<'_>, at: Point) {
4002        ui.point_at(at);
4003        ui.simulate([
4004            moved(at),
4005            iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
4006            iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
4007        ]);
4008    }
4009
4010    #[test]
4011    fn clicking_the_corner_radius_slider_reshapes_every_node() {
4012        let mut app = Application::default();
4013        let theme = app.effective_theme().clone();
4014        let calendar = class_target(&app);
4015        assert_eq!(
4016            app.node_style(&calendar, &theme, NodeStatus::Idle)
4017                .corner_radius,
4018            8.0
4019        );
4020
4021        let msgs: Vec<_> = {
4022            let mut ui = simulator(&app);
4023            // The slider node: title bar (text + 4px padding), then a body with
4024            // 10px padding holding the 100px-wide, 16px-tall slider.
4025            let title = ui.find("corner_radius").expect("corner_radius slider node");
4026            let bounds = title.bounds();
4027            let slider_left = bounds.x + 4.0 + 1.0;
4028            let slider_y = bounds.y + bounds.height + 4.0 + 10.0 + 8.0;
4029            click(&mut ui, Point::new(slider_left + 75.0, slider_y));
4030            ui.into_messages().collect()
4031        };
4032        apply(&mut app, msgs.clone());
4033        let moved_to = msgs
4034            .iter()
4035            .find_map(|m| match m {
4036                ApplicationMessage::SliderChanged { value, .. } => Some(*value),
4037                _ => None,
4038            })
4039            .expect("the click reached the slider");
4040        assert!((20.0..=28.0).contains(&moved_to), "slider value {moved_to}");
4041        assert_eq!(app.computed.node.corner_radius, Some(moved_to));
4042        for id in &app.node_order {
4043            if matches!(app.nodes[id].1, NodeType::Workflow(_)) {
4044                assert_eq!(
4045                    app.node_style(id, &theme, NodeStatus::Idle).corner_radius,
4046                    moved_to
4047                );
4048            }
4049        }
4050    }
4051
4052    #[test]
4053    fn selection_box_alpha_slider_drives_its_opacity() {
4054        use nodes::pins;
4055        let mut app = Application::default();
4056        let theme = app.effective_theme().clone();
4057        assert_eq!(app.selection_box_style(&theme).fill.a, 0.15);
4058
4059        // Walk the rig backwards: SelectionBoxConfig.fill <- Alpha <- slider.
4060        let cfg = app
4061            .nodes
4062            .iter()
4063            .find_map(|(id, (_, t))| {
4064                matches!(t, NodeType::Config(ConfigNodeType::SelectionBoxConfig(_)))
4065                    .then(|| id.clone())
4066            })
4067            .expect("selection box config");
4068        let alpha = app
4069            .edges
4070            .values()
4071            .find(|e| e.to_node == cfg && e.to_pin == pins::selection_box::FILL)
4072            .map(|e| e.from_node.clone())
4073            .expect("alpha node feeding fill");
4074        let slider = app
4075            .edges
4076            .values()
4077            .find(|e| e.to_node == alpha && e.to_pin == pins::build::ALPHA)
4078            .map(|e| e.from_node.clone())
4079            .expect("slider feeding alpha");
4080
4081        let _ = app.update(ApplicationMessage::SliderChanged {
4082            node_id: slider,
4083            value: 0.6,
4084        });
4085        assert_eq!(app.selection_box_style(&theme).fill.a, 0.6);
4086    }
4087
4088    #[test]
4089    fn node_class_pick_list_retargets_through_the_graph() {
4090        let mut app = Application::default();
4091        let theme = app.effective_theme().clone();
4092        let before = class_target(&app);
4093        let first_workflow = app.node_order[0].clone();
4094        assert_ne!(before, first_workflow);
4095
4096        // A 1024x768 window panned onto the Node Class node: its layout
4097        // coordinates exceed the window, so the menu only works if the graph
4098        // anchors pop-outs in screen space.
4099        app.camera_position = Point::new(-1500.0, -400.0);
4100        app.camera_zoom = 1.0;
4101        let msgs: Vec<_> = {
4102            let mut ui = Simulator::new(app.view());
4103            // The pick list sits right of the "target" label; opening it and
4104            // clicking the first row picks the first workflow node. `find`
4105            // reports layout coordinates; the clicks are screen pixels.
4106            let label = ui.find("target").expect("Node Class target row");
4107            let bounds = label.bounds();
4108            let pan = Vector::new(app.camera_position.x, app.camera_position.y);
4109            let picker = Point::new(bounds.x + bounds.width + 6.0 + 30.0, bounds.center_y()) + pan;
4110            assert!(picker.x > 0.0 && picker.x < 1024.0 && picker.y > 0.0 && picker.y < 768.0);
4111            click(&mut ui, picker);
4112            click(&mut ui, Point::new(picker.x, picker.y + 24.0));
4113            ui.into_messages().collect()
4114        };
4115        apply(&mut app, msgs.clone());
4116        assert!(
4117            msgs.iter().any(|m| matches!(
4118                m,
4119                ApplicationMessage::NodeClassTargetChanged { target: Some(t), .. } if *t == first_workflow
4120            )),
4121            "pick list did not report the new target: {msgs:?}"
4122        );
4123        assert_eq!(class_target(&app), first_workflow);
4124        let palette = theme.extended_palette();
4125        assert_eq!(
4126            app.node_style(&first_workflow, &theme, NodeStatus::Idle)
4127                .fill_color
4128                .near_start,
4129            palette.warning.weak.color
4130        );
4131        assert_ne!(
4132            app.node_style(&before, &theme, NodeStatus::Idle)
4133                .fill_color
4134                .near_start,
4135            palette.warning.weak.color
4136        );
4137    }
4138
4139    #[test]
4140    fn test_edge_config_inputs_pattern_type_dashed() {
4141        let inputs = EdgeConfigInputs {
4142            pattern_type: Some(PatternType::Dashed),
4143            thickness: Some(3.0),
4144            dash_length: Some(10.0),
4145            gap_length: Some(5.0),
4146            ..Default::default()
4147        };
4148
4149        let config = inputs.build();
4150        let pattern = config.pattern.expect("pattern should be Some");
4151        assert_eq!(pattern.thickness, 3.0);
4152        assert!(
4153            matches!(pattern.pattern_type, iced_nodegraph::PatternType::Dashed { dash, gap, .. } if (dash - 10.0).abs() < 0.01 && (gap - 5.0).abs() < 0.01),
4154            "Expected Dashed pattern, got {:?}",
4155            pattern.pattern_type
4156        );
4157    }
4158
4159    #[test]
4160    fn test_edge_config_inputs_pattern_type_arrowed() {
4161        let inputs = EdgeConfigInputs {
4162            pattern_type: Some(PatternType::Arrowed),
4163            ..Default::default()
4164        };
4165
4166        let config = inputs.build();
4167        let pattern = config.pattern.expect("pattern should be Some");
4168        assert!(
4169            matches!(
4170                pattern.pattern_type,
4171                iced_nodegraph::PatternType::Arrowed { .. }
4172            ),
4173            "Expected Arrowed pattern, got {:?}",
4174            pattern.pattern_type
4175        );
4176    }
4177
4178    #[test]
4179    fn test_edge_config_inputs_pattern_type_dotted() {
4180        let inputs = EdgeConfigInputs {
4181            pattern_type: Some(PatternType::Dotted),
4182            dot_radius: Some(3.0),
4183            gap_length: Some(4.0),
4184            ..Default::default()
4185        };
4186
4187        let config = inputs.build();
4188        let pattern = config.pattern.expect("pattern should be Some");
4189        assert!(
4190            matches!(
4191                pattern.pattern_type,
4192                iced_nodegraph::PatternType::Dotted { .. }
4193            ),
4194            "Expected Dotted pattern, got {:?}",
4195            pattern.pattern_type
4196        );
4197    }
4198
4199    #[test]
4200    fn test_edge_config_inputs_pattern_type_dash_dotted() {
4201        let inputs = EdgeConfigInputs {
4202            pattern_type: Some(PatternType::DashDotted),
4203            ..Default::default()
4204        };
4205
4206        let config = inputs.build();
4207        let pattern = config.pattern.expect("pattern should be Some");
4208        assert!(
4209            matches!(
4210                pattern.pattern_type,
4211                iced_nodegraph::PatternType::DashDotted { .. }
4212            ),
4213            "Expected DashDotted pattern, got {:?}",
4214            pattern.pattern_type
4215        );
4216    }
4217
4218    #[test]
4219    fn test_edge_config_inputs_pattern_preserved_through_build() {
4220        // Verify the full pipeline: EdgeConfigInputs -> build() -> EdgeConfig
4221        // Pattern, border, shadow must all survive
4222        let inputs = EdgeConfigInputs {
4223            pattern_type: Some(PatternType::Dashed),
4224            thickness: Some(4.0),
4225            dash_length: Some(8.0),
4226            gap_length: Some(4.0),
4227            animation_speed: Some(50.0),
4228            stroke_color: Some(iced_nodegraph::ColorQuad::arc(
4229                Color::from_rgb(1.0, 0.0, 0.0),
4230                Color::from_rgb(0.0, 0.0, 1.0),
4231            )),
4232            border_width: Some(2.0),
4233            border_gap: Some(1.0),
4234            shadow_blur: Some(6.0),
4235            shadow_expand: Some(3.0),
4236            ..Default::default()
4237        };
4238
4239        let config = inputs.build();
4240
4241        // Pattern
4242        let pattern = config.pattern.expect("pattern must be present");
4243        assert_eq!(pattern.thickness, 4.0);
4244        assert!(matches!(
4245            pattern.pattern_type,
4246            iced_nodegraph::PatternType::Dashed { .. }
4247        ));
4248        assert!((pattern.flow_speed - 50.0).abs() < 0.01);
4249
4250        // Colors: arc gradient start -> end
4251        let stroke = config.stroke_color.expect("stroke color present");
4252        assert_eq!(stroke.near_start, Color::from_rgb(1.0, 0.0, 0.0));
4253        assert_eq!(stroke.near_end, Color::from_rgb(0.0, 0.0, 1.0));
4254
4255        // Border
4256        assert_eq!(config.border_width, Some(2.0));
4257        assert_eq!(config.border_gap, Some(1.0));
4258
4259        // Shadow
4260        assert_eq!(config.shadow_blur, Some(6.0));
4261        assert_eq!(config.shadow_expand, Some(3.0));
4262    }
4263}