Skip to main content

jellyflow_egui/
renderer.rs

1use std::collections::BTreeMap;
2
3use eframe::egui::{
4    Align, Button, Color32, ComboBox, CornerRadius, DragValue, Id, Label, Layout, Pos2, Rect,
5    Slider, Stroke, TextEdit, Ui, UiBuilder, Vec2,
6};
7use jellyflow::core::{CanvasPoint, CanvasRect, CanvasSize, Node, NodeId};
8use jellyflow::runtime::schema::{
9    NodeControlBinding, NodeControlBindingSource, NodeControlDescriptor, NodeControlKind,
10    NodeControlOptionSource, NodeControlValidationRule, NodeKindViewDescriptor,
11    NodeRepeatableCollectionDescriptor, NodeRepeatableItemProjection, NodeSurfaceSlotDescriptor,
12    NodeSurfaceSlotKind,
13};
14
15/// Visual style mapped from an adapter-owned renderer key.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct NodeRendererStyle {
18    pub fill: Color32,
19    pub stroke: Color32,
20    pub accent: Color32,
21    pub text: Color32,
22}
23
24impl NodeRendererStyle {
25    pub const fn new(fill: Color32, stroke: Color32, accent: Color32, text: Color32) -> Self {
26        Self {
27            fill,
28            stroke,
29            accent,
30            text,
31        }
32    }
33
34    pub const fn task() -> Self {
35        Self::new(
36            Color32::from_rgb(252, 253, 255),
37            Color32::from_rgb(198, 207, 219),
38            Color32::from_rgb(42, 104, 176),
39            Color32::from_rgb(31, 41, 55),
40        )
41    }
42
43    pub const fn decision() -> Self {
44        Self::new(
45            Color32::from_rgb(255, 252, 246),
46            Color32::from_rgb(219, 201, 168),
47            Color32::from_rgb(188, 113, 32),
48            Color32::from_rgb(61, 46, 28),
49        )
50    }
51
52    pub const fn data() -> Self {
53        Self::new(
54            Color32::from_rgb(247, 252, 250),
55            Color32::from_rgb(177, 207, 196),
56            Color32::from_rgb(22, 128, 96),
57            Color32::from_rgb(27, 53, 48),
58        )
59    }
60
61    pub const fn output() -> Self {
62        Self::new(
63            Color32::from_rgb(253, 251, 255),
64            Color32::from_rgb(206, 195, 222),
65            Color32::from_rgb(111, 88, 161),
66            Color32::from_rgb(49, 38, 70),
67        )
68    }
69
70    pub const fn topic() -> Self {
71        Self::new(
72            Color32::from_rgb(249, 252, 255),
73            Color32::from_rgb(186, 205, 225),
74            Color32::from_rgb(31, 105, 168),
75            Color32::from_rgb(26, 45, 68),
76        )
77    }
78
79    pub const fn idea() -> Self {
80        Self::new(
81            Color32::from_rgb(252, 253, 248),
82            Color32::from_rgb(195, 207, 163),
83            Color32::from_rgb(88, 128, 54),
84            Color32::from_rgb(43, 55, 34),
85        )
86    }
87
88    pub const fn section() -> Self {
89        Self::new(
90            Color32::from_rgb(252, 252, 255),
91            Color32::from_rgb(198, 201, 216),
92            Color32::from_rgb(70, 91, 148),
93            Color32::from_rgb(42, 44, 68),
94        )
95    }
96
97    pub const fn source() -> Self {
98        Self::new(
99            Color32::from_rgb(255, 252, 247),
100            Color32::from_rgb(215, 196, 169),
101            Color32::from_rgb(145, 94, 46),
102            Color32::from_rgb(64, 48, 34),
103        )
104    }
105
106    pub const fn fallback() -> Self {
107        Self::new(
108            Color32::from_rgb(252, 252, 251),
109            Color32::from_rgb(202, 202, 196),
110            Color32::from_rgb(91, 91, 82),
111            Color32::from_rgb(36, 36, 32),
112        )
113    }
114
115    pub fn selected_stroke(self) -> Stroke {
116        Stroke::new(2.0, self.accent)
117    }
118}
119
120/// Adapter-owned renderer state passed to rich node renderers.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub struct NodeRendererState {
123    pub selected: bool,
124    pub hovered: bool,
125    pub focused: bool,
126    pub dragging: bool,
127    pub resizing: bool,
128    pub connection_preview: bool,
129    pub valid_target: bool,
130    pub invalid_target: bool,
131    pub disabled: bool,
132    pub hidden: bool,
133    pub diagnostic: bool,
134}
135
136/// Renderer-neutral input for adapter-owned rich node renderers.
137#[derive(Debug, Clone)]
138pub struct NodeRenderInput<'a> {
139    pub id: NodeId,
140    pub node: &'a Node,
141    pub descriptor: &'a NodeKindViewDescriptor,
142    pub state: NodeRendererState,
143    pub style: NodeRendererStyle,
144}
145
146/// Zoom-aware node content level for adapter-owned widget renderers.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum NodeContentLevel {
149    Full,
150    Compact,
151    Shell,
152}
153
154impl NodeContentLevel {
155    pub fn from_zoom(zoom: f32) -> Self {
156        if zoom >= 0.62 {
157            Self::Full
158        } else if zoom >= 0.18 {
159            Self::Compact
160        } else {
161            Self::Shell
162        }
163    }
164
165    pub fn from_zoom_and_size(zoom: f32, size: Vec2) -> Self {
166        let zoom_level = Self::from_zoom(zoom);
167        if size.x < 48.0 || size.y < 24.0 {
168            Self::Shell
169        } else if size.x < 150.0 || size.y < 90.0 {
170            zoom_level.min_detail(Self::Compact)
171        } else {
172            zoom_level
173        }
174    }
175
176    pub fn shows_text(self) -> bool {
177        matches!(self, Self::Full | Self::Compact)
178    }
179
180    pub fn shows_detail(self) -> bool {
181        matches!(self, Self::Full)
182    }
183
184    fn min_detail(self, cap: Self) -> Self {
185        match (self, cap) {
186            (Self::Shell, _) | (_, Self::Shell) => Self::Shell,
187            (Self::Compact, _) | (_, Self::Compact) => Self::Compact,
188            (Self::Full, Self::Full) => Self::Full,
189        }
190    }
191
192    fn renders_slot_kind(self, slot_kind: Option<NodeSurfaceSlotKind>) -> bool {
193        match self {
194            Self::Full => true,
195            Self::Compact => matches!(
196                slot_kind,
197                Some(NodeSurfaceSlotKind::FieldRow | NodeSurfaceSlotKind::PortRail)
198            ),
199            Self::Shell => false,
200        }
201    }
202}
203
204/// egui-specific widget rendering input for rich node internals.
205#[derive(Debug)]
206pub struct NodeWidgetRenderInput<'a> {
207    pub id: NodeId,
208    pub node: &'a Node,
209    pub descriptor: &'a NodeKindViewDescriptor,
210    pub state: NodeRendererState,
211    pub style: NodeRendererStyle,
212    pub layout: &'a NodeRenderLayout,
213    pub node_rect: Rect,
214    /// Screen-space clip rect inherited from the canvas viewport.
215    ///
216    /// Widget renderers should intersect child widget rects with this value before painting.
217    pub clip_rect: Rect,
218    pub zoom: f32,
219    pub content_level: NodeContentLevel,
220}
221
222impl NodeWidgetRenderInput<'_> {
223    pub fn region_screen_rect(&self, region: &NodeInteractiveRegion) -> Option<Rect> {
224        self.node_local_screen_rect(region.rect)
225    }
226
227    pub fn node_local_screen_rect(&self, rect: CanvasRect) -> Option<Rect> {
228        let rect = node_local_rect_to_screen(self.node_rect, rect, self.zoom)
229            .intersect(self.node_rect)
230            .intersect(self.clip_rect);
231        rect.is_positive().then_some(rect)
232    }
233}
234
235/// Renderer output consumed by the egui canvas fallback painter.
236#[derive(Debug, Clone, PartialEq)]
237pub struct NodeRenderLayout {
238    pub title: String,
239    pub summary: Option<String>,
240    pub min_size: CanvasSize,
241    pub body_rect: CanvasRect,
242    pub interactive_regions: Vec<NodeInteractiveRegion>,
243}
244
245impl NodeRenderLayout {
246    pub fn fallback(input: &NodeRenderInput<'_>, rect: CanvasRect) -> Self {
247        Self {
248            title: node_title(input).unwrap_or_else(|| input.descriptor.title.clone()),
249            summary: node_summary(input),
250            min_size: input.descriptor.default_size.unwrap_or(CanvasSize {
251                width: 160.0,
252                height: 80.0,
253            }),
254            body_rect: rect,
255            interactive_regions: Vec::new(),
256        }
257    }
258}
259
260/// Named hit-test or event region produced by a rich renderer.
261///
262/// The rect is node-local and relative to the node's top-left corner.
263#[derive(Debug, Clone, PartialEq)]
264pub struct NodeInteractiveRegion {
265    pub key: String,
266    pub slot_kind: Option<NodeSurfaceSlotKind>,
267    pub rect: CanvasRect,
268    pub label: Option<String>,
269    pub z_index: i32,
270}
271
272/// Adapter-owned renderer contract. Implementors may measure rich node bodies without mutating graph state.
273pub trait RichNodeRenderer: Send + Sync {
274    fn render(&self, input: &NodeRenderInput<'_>, rect: CanvasRect) -> NodeRenderLayout;
275}
276
277/// Adapter-owned egui widget renderer for drawing controls inside a node.
278pub trait EguiNodeWidgetRenderer: Send + Sync {
279    fn render_widgets(
280        &self,
281        ui: &mut Ui,
282        input: &NodeWidgetRenderInput<'_>,
283    ) -> EguiNodeWidgetRenderOutcome;
284}
285
286/// Result of an egui node widget render pass.
287#[derive(Debug, Clone, PartialEq, Default)]
288pub struct EguiNodeWidgetRenderOutcome {
289    pub rendered: bool,
290    pub changed: bool,
291    pub control_edits: Vec<EguiNodeControlEdit>,
292    pub repeatable_actions: Vec<EguiNodeRepeatableAction>,
293}
294
295impl EguiNodeWidgetRenderOutcome {
296    pub fn rendered() -> Self {
297        Self {
298            rendered: true,
299            ..Self::default()
300        }
301    }
302
303    pub fn mark_rendered(&mut self) {
304        self.rendered = true;
305    }
306
307    pub fn push_control_edit(&mut self, edit: EguiNodeControlEdit) {
308        self.rendered = true;
309        self.changed = true;
310        self.control_edits.push(edit);
311    }
312
313    pub fn push_repeatable_action(&mut self, action: EguiNodeRepeatableAction) {
314        self.rendered = true;
315        self.changed = true;
316        self.repeatable_actions.push(action);
317    }
318
319    pub fn merge(&mut self, other: Self) {
320        self.rendered |= other.rendered;
321        self.changed |= other.changed;
322        self.control_edits.extend(other.control_edits);
323        self.repeatable_actions.extend(other.repeatable_actions);
324    }
325}
326
327/// Adapter-local edit emitted by egui controls after resolving renderer-neutral bindings.
328#[derive(Debug, Clone, PartialEq)]
329pub struct EguiNodeControlEdit {
330    pub control_key: String,
331    pub binding: NodeControlBinding,
332    pub value: serde_json::Value,
333}
334
335impl EguiNodeControlEdit {
336    pub fn new(
337        control_key: impl Into<String>,
338        binding: NodeControlBinding,
339        value: serde_json::Value,
340    ) -> Self {
341        Self {
342            control_key: control_key.into(),
343            binding,
344            value,
345        }
346    }
347}
348
349/// Adapter-local command emitted by repeatable collection controls.
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub enum EguiNodeRepeatableAction {
352    Add {
353        collection_key: String,
354    },
355    Remove {
356        collection_key: String,
357        item_id: String,
358    },
359    Move {
360        collection_key: String,
361        item_id: String,
362        direction: EguiRepeatableMoveDirection,
363    },
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum EguiRepeatableMoveDirection {
368    Up,
369    Down,
370}
371
372#[derive(Debug, Clone, Copy)]
373struct FallbackRichNodeRenderer;
374
375impl RichNodeRenderer for FallbackRichNodeRenderer {
376    fn render(&self, input: &NodeRenderInput<'_>, rect: CanvasRect) -> NodeRenderLayout {
377        NodeRenderLayout::fallback(input, rect)
378    }
379}
380
381/// Adapter-owned renderer catalog keyed by `NodeKindViewDescriptor::renderer_key`.
382pub struct RendererCatalog {
383    fallback: NodeRendererStyle,
384    by_renderer_key: BTreeMap<String, NodeRendererStyle>,
385    rich_renderers: BTreeMap<String, Box<dyn RichNodeRenderer>>,
386    widget_renderers: BTreeMap<String, Box<dyn EguiNodeWidgetRenderer>>,
387}
388
389impl std::fmt::Debug for RendererCatalog {
390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        f.debug_struct("RendererCatalog")
392            .field("fallback", &self.fallback)
393            .field("style_count", &self.by_renderer_key.len())
394            .field("rich_renderer_count", &self.rich_renderers.len())
395            .field("widget_renderer_count", &self.widget_renderers.len())
396            .finish()
397    }
398}
399
400impl Default for RendererCatalog {
401    fn default() -> Self {
402        Self::with_builtin_styles()
403    }
404}
405
406#[derive(Debug, Clone, Copy, Default)]
407pub struct FieldListNodeRenderer;
408
409impl RendererCatalog {
410    pub fn new() -> Self {
411        Self {
412            fallback: NodeRendererStyle::fallback(),
413            by_renderer_key: BTreeMap::new(),
414            rich_renderers: BTreeMap::new(),
415            widget_renderers: BTreeMap::new(),
416        }
417    }
418
419    pub fn with_builtin_styles() -> Self {
420        let mut catalog = Self::new();
421        catalog
422            .register("task-card", NodeRendererStyle::task())
423            .register("decision-card", NodeRendererStyle::decision())
424            .register("data-card", NodeRendererStyle::data())
425            .register("output-card", NodeRendererStyle::output())
426            .register("topic-card", NodeRendererStyle::topic())
427            .register("idea-card", NodeRendererStyle::idea())
428            .register("section-card", NodeRendererStyle::section())
429            .register("source-card", NodeRendererStyle::source())
430            .register("table-card", NodeRendererStyle::section())
431            .register("shader-card", NodeRendererStyle::data())
432            .register_rich("decision-card", FieldListNodeRenderer)
433            .register_widgets("decision-card", FieldListNodeRenderer)
434            .register_rich("table-card", FieldListNodeRenderer)
435            .register_widgets("table-card", FieldListNodeRenderer)
436            .register_rich("shader-card", FieldListNodeRenderer)
437            .register_widgets("shader-card", FieldListNodeRenderer);
438        catalog
439    }
440
441    pub fn register(
442        &mut self,
443        renderer_key: impl Into<String>,
444        style: NodeRendererStyle,
445    ) -> &mut Self {
446        self.by_renderer_key.insert(renderer_key.into(), style);
447        self
448    }
449
450    pub fn register_rich(
451        &mut self,
452        renderer_key: impl Into<String>,
453        renderer: impl RichNodeRenderer + 'static,
454    ) -> &mut Self {
455        self.rich_renderers
456            .insert(renderer_key.into(), Box::new(renderer));
457        self
458    }
459
460    pub fn register_widgets(
461        &mut self,
462        renderer_key: impl Into<String>,
463        renderer: impl EguiNodeWidgetRenderer + 'static,
464    ) -> &mut Self {
465        self.widget_renderers
466            .insert(renderer_key.into(), Box::new(renderer));
467        self
468    }
469
470    pub fn render_node(&self, input: &NodeRenderInput<'_>, rect: CanvasRect) -> NodeRenderLayout {
471        self.rich_renderers
472            .get(&input.descriptor.renderer_key)
473            .map(|renderer| renderer.as_ref())
474            .unwrap_or(&FallbackRichNodeRenderer)
475            .render(input, rect)
476    }
477
478    pub fn render_widgets(
479        &self,
480        ui: &mut Ui,
481        input: &NodeWidgetRenderInput<'_>,
482    ) -> EguiNodeWidgetRenderOutcome {
483        self.widget_renderers
484            .get(&input.descriptor.renderer_key)
485            .map(|renderer| renderer.render_widgets(ui, input))
486            .unwrap_or_default()
487    }
488
489    pub fn has_widget_renderer(&self, renderer_key: &str) -> bool {
490        self.widget_renderers.contains_key(renderer_key)
491    }
492
493    pub fn style_for_descriptor(&self, descriptor: &NodeKindViewDescriptor) -> NodeRendererStyle {
494        self.style_for_key(&descriptor.renderer_key)
495    }
496
497    pub fn style_for_key(&self, renderer_key: &str) -> NodeRendererStyle {
498        self.by_renderer_key
499            .get(renderer_key)
500            .copied()
501            .unwrap_or(self.fallback)
502    }
503}
504
505fn node_summary(input: &NodeRenderInput<'_>) -> Option<String> {
506    let summary = input
507        .node
508        .data
509        .get("summary")
510        .and_then(|value| value.as_str())
511        .unwrap_or("");
512    (!summary.is_empty()).then(|| summary.to_owned())
513}
514
515fn node_title(input: &NodeRenderInput<'_>) -> Option<String> {
516    let title = input
517        .node
518        .data
519        .get("title")
520        .and_then(|value| value.as_str())
521        .unwrap_or("");
522    (!title.is_empty()).then(|| title.to_owned())
523}
524
525impl RichNodeRenderer for FieldListNodeRenderer {
526    fn render(&self, input: &NodeRenderInput<'_>, rect: CanvasRect) -> NodeRenderLayout {
527        let mut layout = NodeRenderLayout::fallback(input, rect);
528        let fields = input
529            .node
530            .data
531            .get("fields")
532            .and_then(|value| value.as_object());
533        let field_slots = semantic_slots(input, NodeSurfaceSlotKind::FieldRow);
534        let badge_slots = semantic_slots(input, NodeSurfaceSlotKind::Badge);
535        let metric_slots = semantic_slots(input, NodeSurfaceSlotKind::MetricBadge);
536        let status_slots = semantic_slots(input, NodeSurfaceSlotKind::StatusBanner);
537        let rail_slots = semantic_slots(input, NodeSurfaceSlotKind::PortRail);
538        let config_slots = semantic_slots(input, NodeSurfaceSlotKind::ConfigGroup);
539        let preview_slots = semantic_slots(input, NodeSurfaceSlotKind::Preview);
540        let nested_slots = semantic_slots(input, NodeSurfaceSlotKind::NestedRegion);
541        let action_slots = semantic_slots(input, NodeSurfaceSlotKind::ActionRow);
542        let repeatable_collections = &input.descriptor.repeatable_collections;
543        let keys = fields
544            .map(|fields| field_keys(input, fields, &field_slots))
545            .unwrap_or_default();
546        let metrics = FieldListMetrics::new(rect.size);
547        let field_count = keys.len();
548
549        for slot in badge_slots.into_iter().chain(metric_slots) {
550            let text = semantic_slot_chip_text(&input.node.data, slot, fields);
551            let width = semantic_chip_width(&text, 32.0, rect.size.width * 0.42);
552            let chip_rect = CanvasRect {
553                origin: CanvasPoint {
554                    x: (rect.size.width - 14.0 - width).max(14.0),
555                    y: 12.0,
556                },
557                size: CanvasSize {
558                    width,
559                    height: 18.0,
560                },
561            };
562            layout.interactive_regions.push(NodeInteractiveRegion {
563                key: slot.key.clone(),
564                slot_kind: Some(slot.kind),
565                rect: chip_rect,
566                label: Some(text),
567                z_index: 2,
568            });
569        }
570
571        for (index, key) in keys.into_iter().enumerate() {
572            let row_rect = metrics.row_rect(index);
573            layout.interactive_regions.push(NodeInteractiveRegion {
574                key: format!("field.{key}"),
575                slot_kind: Some(NodeSurfaceSlotKind::FieldRow),
576                rect: row_rect,
577                label: fields
578                    .and_then(|fields| fields.get(&key))
579                    .map(field_value_label),
580                z_index: 1,
581            });
582        }
583        let mut cursor_y = metrics.bottom_after(field_count);
584
585        for slot in rail_slots
586            .into_iter()
587            .chain(config_slots)
588            .chain(preview_slots)
589            .chain(nested_slots)
590        {
591            let title = semantic_slot_title(slot);
592            let lines = semantic_slot_lines(&input.node.data, slot, fields);
593            let height = semantic_nested_region_height(title.as_deref(), &lines);
594            let block_rect = CanvasRect {
595                origin: CanvasPoint {
596                    x: 14.0,
597                    y: cursor_y,
598                },
599                size: CanvasSize {
600                    width: (rect.size.width - 28.0).max(96.0),
601                    height,
602                },
603            };
604            layout.interactive_regions.push(NodeInteractiveRegion {
605                key: slot.key.clone(),
606                slot_kind: Some(slot.kind),
607                rect: block_rect,
608                label: title,
609                z_index: 1,
610            });
611            cursor_y = block_rect.origin.y + block_rect.size.height + 8.0;
612        }
613
614        for slot in status_slots {
615            let title = semantic_slot_title(slot);
616            let lines = semantic_slot_lines(&input.node.data, slot, fields);
617            let height = semantic_nested_region_height(title.as_deref(), &lines).min(42.0);
618            let block_rect = CanvasRect {
619                origin: CanvasPoint {
620                    x: 14.0,
621                    y: cursor_y,
622                },
623                size: CanvasSize {
624                    width: (rect.size.width - 28.0).max(96.0),
625                    height,
626                },
627            };
628            layout.interactive_regions.push(NodeInteractiveRegion {
629                key: slot.key.clone(),
630                slot_kind: Some(NodeSurfaceSlotKind::StatusBanner),
631                rect: block_rect,
632                label: title,
633                z_index: 1,
634            });
635            cursor_y = block_rect.origin.y + block_rect.size.height + 8.0;
636        }
637
638        for collection in repeatable_collections {
639            let title = repeatable_collection_title(collection);
640            let header_rect = CanvasRect {
641                origin: CanvasPoint {
642                    x: 14.0,
643                    y: cursor_y,
644                },
645                size: CanvasSize {
646                    width: (rect.size.width - 28.0).max(96.0),
647                    height: 20.0,
648                },
649            };
650            layout.interactive_regions.push(NodeInteractiveRegion {
651                key: repeatable_header_region_key(collection),
652                slot_kind: Some(NodeSurfaceSlotKind::ActionRow),
653                rect: header_rect,
654                label: Some(title),
655                z_index: 1,
656            });
657            cursor_y = header_rect.origin.y + header_rect.size.height + 4.0;
658
659            let items = collection.item_projections(&input.node.data);
660            if items.is_empty() {
661                let empty_rect = CanvasRect {
662                    origin: CanvasPoint {
663                        x: 14.0,
664                        y: cursor_y,
665                    },
666                    size: CanvasSize {
667                        width: (rect.size.width - 28.0).max(96.0),
668                        height: 20.0,
669                    },
670                };
671                layout.interactive_regions.push(NodeInteractiveRegion {
672                    key: repeatable_empty_region_key(collection),
673                    slot_kind: Some(NodeSurfaceSlotKind::NestedRegion),
674                    rect: empty_rect,
675                    label: collection.empty_label.clone(),
676                    z_index: 1,
677                });
678                cursor_y = empty_rect.origin.y + empty_rect.size.height + 8.0;
679                continue;
680            }
681
682            for item in items {
683                let row_height = repeatable_item_region_height(&item);
684                let item_rect = CanvasRect {
685                    origin: CanvasPoint {
686                        x: 14.0,
687                        y: cursor_y,
688                    },
689                    size: CanvasSize {
690                        width: (rect.size.width - 28.0).max(96.0),
691                        height: row_height,
692                    },
693                };
694                layout.interactive_regions.push(NodeInteractiveRegion {
695                    key: repeatable_item_region_key(&item),
696                    slot_kind: repeatable_item_slot_kind(&item),
697                    rect: item_rect,
698                    label: Some(repeatable_item_label(&item)),
699                    z_index: 1,
700                });
701                cursor_y = item_rect.origin.y + item_rect.size.height + 4.0;
702            }
703            cursor_y += 4.0;
704        }
705
706        for slot in action_slots {
707            let items = semantic_slot_lines(&input.node.data, slot, fields);
708            let height = semantic_action_region_height(&items);
709            let block_rect = CanvasRect {
710                origin: CanvasPoint {
711                    x: 14.0,
712                    y: cursor_y,
713                },
714                size: CanvasSize {
715                    width: (rect.size.width - 28.0).max(96.0),
716                    height,
717                },
718            };
719            if items.is_empty() {
720                layout.interactive_regions.push(NodeInteractiveRegion {
721                    key: slot.key.clone(),
722                    slot_kind: Some(NodeSurfaceSlotKind::ActionRow),
723                    rect: block_rect,
724                    label: semantic_slot_title(slot),
725                    z_index: 1,
726                });
727            } else {
728                let mut chip_y = block_rect.origin.y;
729                for (index, item) in items.into_iter().enumerate() {
730                    let chip_rect = CanvasRect {
731                        origin: CanvasPoint {
732                            x: block_rect.origin.x,
733                            y: chip_y,
734                        },
735                        size: CanvasSize {
736                            width: block_rect.size.width,
737                            height: 18.0,
738                        },
739                    };
740                    layout.interactive_regions.push(NodeInteractiveRegion {
741                        key: format!("{}.{index}", slot.key),
742                        slot_kind: Some(NodeSurfaceSlotKind::ActionRow),
743                        rect: chip_rect,
744                        label: Some(item),
745                        z_index: 1,
746                    });
747                    chip_y += 20.0;
748                }
749            }
750            cursor_y = block_rect.origin.y + block_rect.size.height + 8.0;
751        }
752
753        layout.min_size = metrics.min_size(layout.min_size, field_count);
754        layout.min_size.height = layout.min_size.height.max(cursor_y + 8.0);
755        layout
756    }
757}
758
759impl EguiNodeWidgetRenderer for FieldListNodeRenderer {
760    fn render_widgets(
761        &self,
762        ui: &mut Ui,
763        input: &NodeWidgetRenderInput<'_>,
764    ) -> EguiNodeWidgetRenderOutcome {
765        if input.content_level == NodeContentLevel::Shell {
766            return EguiNodeWidgetRenderOutcome::default();
767        }
768
769        let fields = input
770            .node
771            .data
772            .get("fields")
773            .and_then(|value| value.as_object());
774
775        let show_detail = input.content_level.shows_detail();
776        let mut outcome = EguiNodeWidgetRenderOutcome::default();
777
778        if let Some(fields) = fields {
779            for region in input.layout.interactive_regions.iter().filter(|region| {
780                region.slot_kind == Some(NodeSurfaceSlotKind::FieldRow)
781                    || region.key.starts_with("field.")
782            }) {
783                let key = region
784                    .key
785                    .strip_prefix("field.")
786                    .unwrap_or(region.key.as_str());
787                let slot = field_slot_for_region(input, region, key);
788                let label = fields
789                    .get(key)
790                    .map(field_value_label)
791                    .filter(|label| !label.is_empty());
792                if label.is_none() && slot.is_none_or(|slot| slot.controls.is_empty()) {
793                    continue;
794                }
795                let Some(rect) = input.region_screen_rect(region) else {
796                    continue;
797                };
798                draw_field_region(
799                    ui,
800                    rect,
801                    input,
802                    FieldRegionRender {
803                        key,
804                        label: label.as_deref(),
805                        slot,
806                        fields: Some(fields),
807                        show_detail,
808                    },
809                    &mut outcome,
810                );
811            }
812        }
813
814        for region in input
815            .layout
816            .interactive_regions
817            .iter()
818            .filter(|region| input.content_level.renders_slot_kind(region.slot_kind))
819        {
820            let Some(rect) = input.region_screen_rect(region) else {
821                continue;
822            };
823            if let Some(collection) = repeatable_header_collection(input, region) {
824                draw_repeatable_header(ui, rect, input, collection, &mut outcome);
825            } else if let Some(collection) = repeatable_empty_collection(input, region) {
826                outcome.mark_rendered();
827                draw_semantic_nested_region(
828                    ui,
829                    rect,
830                    collection
831                        .empty_label
832                        .as_deref()
833                        .or(collection.label.as_deref()),
834                    &[],
835                    input.style,
836                );
837            } else if let Some((collection, item)) = repeatable_item_for_region(input, region) {
838                draw_repeatable_item_row(ui, rect, input, collection, &item, &mut outcome);
839            }
840        }
841
842        for region in input.layout.interactive_regions.iter().filter(|region| {
843            input.content_level.renders_slot_kind(region.slot_kind)
844                && region_is_badge(region)
845                && !region_is_repeatable(input, region)
846        }) {
847            let Some(rect) = input.region_screen_rect(region) else {
848                continue;
849            };
850            outcome.mark_rendered();
851            draw_semantic_chip(
852                ui,
853                rect,
854                region.label.as_deref().unwrap_or(region.key.as_str()),
855                input.style.accent.gamma_multiply(0.16),
856                input.style.accent.gamma_multiply(0.52),
857                input.style.text,
858            );
859        }
860
861        for region in input.layout.interactive_regions.iter().filter(|region| {
862            input.content_level.renders_slot_kind(region.slot_kind)
863                && region_is_detail_block(region)
864                && !region_is_repeatable(input, region)
865        }) {
866            let Some(rect) = input.region_screen_rect(region) else {
867                continue;
868            };
869            outcome.mark_rendered();
870            let slot = input
871                .descriptor
872                .surface_slots
873                .iter()
874                .find(|slot| slot.key == region.key);
875            let title = region
876                .label
877                .as_deref()
878                .or_else(|| slot.and_then(|slot| slot.display_label()));
879            let lines = slot
880                .filter(|_| show_detail)
881                .map(|slot| semantic_slot_lines(&input.node.data, slot, fields))
882                .unwrap_or_default();
883            if let Some(slot) = slot.filter(|slot| show_detail && !slot.controls.is_empty()) {
884                draw_control_region(ui, rect, input, title, slot, fields, &mut outcome);
885            } else {
886                draw_semantic_nested_region(ui, rect, title, &lines, input.style);
887            }
888        }
889
890        for region in input.layout.interactive_regions.iter().filter(|region| {
891            input.content_level.renders_slot_kind(region.slot_kind)
892                && region_is_action(region)
893                && !region_is_repeatable(input, region)
894        }) {
895            let Some(rect) = input.region_screen_rect(region) else {
896                continue;
897            };
898            outcome.mark_rendered();
899            let text = region.label.as_deref().unwrap_or(region.key.as_str());
900            draw_semantic_chip(
901                ui,
902                rect,
903                text,
904                input.style.accent.gamma_multiply(0.12),
905                input.style.stroke.gamma_multiply(0.62),
906                input.style.text,
907            );
908        }
909
910        outcome
911    }
912}
913
914struct FieldRegionRender<'a> {
915    key: &'a str,
916    label: Option<&'a str>,
917    slot: Option<&'a NodeSurfaceSlotDescriptor>,
918    fields: Option<&'a serde_json::Map<String, serde_json::Value>>,
919    show_detail: bool,
920}
921
922fn draw_field_region(
923    ui: &mut Ui,
924    rect: Rect,
925    input: &NodeWidgetRenderInput<'_>,
926    request: FieldRegionRender<'_>,
927    outcome: &mut EguiNodeWidgetRenderOutcome,
928) {
929    outcome.mark_rendered();
930    let mut child_ui = ui.new_child(
931        UiBuilder::new()
932            .id_salt(Id::new(("field-region", input.id, request.key)))
933            .max_rect(rect)
934            .layout(Layout::left_to_right(Align::Center)),
935    );
936    child_ui.set_clip_rect(rect);
937    child_ui.set_min_size(rect.size());
938    child_ui.painter().rect_filled(
939        rect,
940        CornerRadius::same(4),
941        Color32::from_rgb(255, 255, 255),
942    );
943    child_ui.painter().rect_stroke(
944        rect,
945        CornerRadius::same(4),
946        Stroke::new(0.75, input.style.stroke.gamma_multiply(0.55)),
947        eframe::egui::StrokeKind::Inside,
948    );
949
950    let mut content_rect = rect.shrink2(Vec2::new(7.0, 2.0));
951    if content_rect.width() <= 6.0 || content_rect.height() <= 6.0 {
952        return;
953    }
954    child_ui.scope_builder(UiBuilder::new().max_rect(content_rect), |ui| {
955        ui.set_clip_rect(content_rect);
956        ui.set_min_size(content_rect.size());
957        ui.horizontal(|ui| {
958            ui.spacing_mut().item_spacing = Vec2::new(6.0, 0.0);
959            if request.show_detail
960                && let Some(badge) = field_badge(request.key)
961            {
962                let badge_width = 24.0f32.min(content_rect.width() * 0.38);
963                let badge_rect = Rect::from_min_size(
964                    content_rect.min,
965                    Vec2::new(badge_width, content_rect.height()),
966                );
967                draw_field_badge(ui, badge_rect, badge, input.style.accent);
968                content_rect.min.x = badge_rect.max.x + 6.0;
969            }
970            ui.scope_builder(UiBuilder::new().max_rect(content_rect), |ui| {
971                ui.set_clip_rect(content_rect);
972                if request.show_detail
973                    && let Some(slot) = request.slot.filter(|slot| !slot.controls.is_empty())
974                {
975                    outcome.merge(draw_slot_controls(
976                        ui,
977                        input,
978                        slot,
979                        ControlRenderContext::node(request.fields, slot),
980                    ));
981                } else if let Some(label) = request.label {
982                    ui.add(
983                        Label::new(
984                            eframe::egui::RichText::new(label)
985                                .small()
986                                .color(input.style.text.gamma_multiply(0.84)),
987                        )
988                        .truncate(),
989                    );
990                }
991            });
992        });
993    });
994}
995
996fn draw_control_region(
997    ui: &mut Ui,
998    rect: Rect,
999    input: &NodeWidgetRenderInput<'_>,
1000    title: Option<&str>,
1001    slot: &NodeSurfaceSlotDescriptor,
1002    fields: Option<&serde_json::Map<String, serde_json::Value>>,
1003    outcome: &mut EguiNodeWidgetRenderOutcome,
1004) {
1005    let painter = ui.painter().with_clip_rect(rect);
1006    painter.rect_filled(
1007        rect,
1008        CornerRadius::same(4),
1009        input.style.fill.gamma_multiply(0.9),
1010    );
1011    painter.rect_stroke(
1012        rect,
1013        CornerRadius::same(4),
1014        Stroke::new(0.8, input.style.stroke.gamma_multiply(0.72)),
1015        eframe::egui::StrokeKind::Inside,
1016    );
1017
1018    let mut content_rect = rect.shrink2(Vec2::new(7.0, 4.0));
1019    if let Some(title) = title {
1020        let title_rect =
1021            Rect::from_min_size(content_rect.min, Vec2::new(content_rect.width(), 13.0));
1022        ui.painter().text(
1023            title_rect.left_top(),
1024            eframe::egui::Align2::LEFT_TOP,
1025            title,
1026            eframe::egui::TextStyle::Small.resolve(ui.style()),
1027            input.style.text,
1028        );
1029        content_rect.min.y = title_rect.max.y + 3.0;
1030    }
1031    if content_rect.is_positive() {
1032        ui.scope_builder(UiBuilder::new().max_rect(content_rect), |ui| {
1033            ui.set_clip_rect(content_rect);
1034            outcome.merge(draw_slot_controls(
1035                ui,
1036                input,
1037                slot,
1038                ControlRenderContext::node(fields, slot),
1039            ));
1040        });
1041    }
1042}
1043
1044#[derive(Debug, Clone, Copy)]
1045struct ControlRenderContext<'a> {
1046    fields: Option<&'a serde_json::Map<String, serde_json::Value>>,
1047    slot: Option<&'a NodeSurfaceSlotDescriptor>,
1048    item_data: Option<&'a serde_json::Value>,
1049    item_path: Option<&'a str>,
1050}
1051
1052impl<'a> ControlRenderContext<'a> {
1053    fn node(
1054        fields: Option<&'a serde_json::Map<String, serde_json::Value>>,
1055        slot: &'a NodeSurfaceSlotDescriptor,
1056    ) -> Self {
1057        Self {
1058            fields,
1059            slot: Some(slot),
1060            item_data: None,
1061            item_path: None,
1062        }
1063    }
1064
1065    fn repeatable(item_data: &'a serde_json::Value, item_path: &'a str) -> Self {
1066        Self {
1067            fields: None,
1068            slot: None,
1069            item_data: Some(item_data),
1070            item_path: Some(item_path),
1071        }
1072    }
1073}
1074
1075fn draw_slot_controls(
1076    ui: &mut Ui,
1077    input: &NodeWidgetRenderInput<'_>,
1078    slot: &NodeSurfaceSlotDescriptor,
1079    context: ControlRenderContext<'_>,
1080) -> EguiNodeWidgetRenderOutcome {
1081    draw_controls(ui, input, &slot.controls, context)
1082}
1083
1084fn draw_controls(
1085    ui: &mut Ui,
1086    input: &NodeWidgetRenderInput<'_>,
1087    controls: &[NodeControlDescriptor],
1088    context: ControlRenderContext<'_>,
1089) -> EguiNodeWidgetRenderOutcome {
1090    let mut outcome = EguiNodeWidgetRenderOutcome::default();
1091    if controls.is_empty() {
1092        return outcome;
1093    }
1094    outcome.mark_rendered();
1095
1096    let mut controls = controls.iter().collect::<Vec<_>>();
1097    controls.sort_by(|a, b| (a.order_key(), &a.key).cmp(&(b.order_key(), &b.key)));
1098
1099    ui.horizontal_wrapped(|ui| {
1100        ui.spacing_mut().item_spacing = Vec2::new(6.0, 2.0);
1101        for control in controls {
1102            let value = control_current_value(&input.node.data, control, context)
1103                .cloned()
1104                .unwrap_or(serde_json::Value::Null);
1105            let write_binding = control_write_binding(control, context);
1106            let enabled = write_binding.is_some()
1107                && !control.editability.read_only
1108                && !control.editability.is_disabled();
1109            let id_salt = Id::new(("control", input.id, &control.key, context.item_path));
1110            let mut edit = None;
1111            ui.add_enabled_ui(enabled, |ui| {
1112                edit = draw_control(ui, input, control, &value, write_binding.clone(), id_salt);
1113            });
1114            if let Some(edit) = edit {
1115                outcome.push_control_edit(edit);
1116            }
1117        }
1118    });
1119
1120    outcome
1121}
1122
1123fn draw_control(
1124    ui: &mut Ui,
1125    input: &NodeWidgetRenderInput<'_>,
1126    control: &NodeControlDescriptor,
1127    value: &serde_json::Value,
1128    write_binding: Option<NodeControlBinding>,
1129    id_salt: Id,
1130) -> Option<EguiNodeControlEdit> {
1131    let binding = write_binding?;
1132    match control.kind {
1133        NodeControlKind::TextInput => {
1134            let mut text = control_text_value(value);
1135            let mut editor = TextEdit::singleline(&mut text).desired_width(control_width(ui, 92.0));
1136            if let Some(placeholder) = &control.presentation.placeholder {
1137                editor = editor.hint_text(placeholder);
1138            }
1139            ui.add(editor)
1140                .changed()
1141                .then(|| EguiNodeControlEdit::new(&control.key, binding, serde_json::json!(text)))
1142        }
1143        NodeControlKind::TextArea | NodeControlKind::Code | NodeControlKind::Expression => {
1144            let mut text = control_text_value(value);
1145            let mut editor = TextEdit::multiline(&mut text)
1146                .desired_width(control_width(ui, 128.0))
1147                .desired_rows(if control.kind == NodeControlKind::TextArea {
1148                    2
1149                } else {
1150                    1
1151                });
1152            if matches!(
1153                control.kind,
1154                NodeControlKind::Code | NodeControlKind::Expression
1155            ) {
1156                editor = editor.font(eframe::egui::TextStyle::Monospace);
1157            }
1158            if let Some(placeholder) = &control.presentation.placeholder {
1159                editor = editor.hint_text(placeholder);
1160            }
1161            ui.add(editor)
1162                .changed()
1163                .then(|| EguiNodeControlEdit::new(&control.key, binding, serde_json::json!(text)))
1164        }
1165        NodeControlKind::NumberInput => {
1166            let mut number = value.as_f64().unwrap_or_default();
1167            ui.add(DragValue::new(&mut number).speed(0.1))
1168                .changed()
1169                .then(|| EguiNodeControlEdit::new(&control.key, binding, serde_json::json!(number)))
1170        }
1171        NodeControlKind::Slider => {
1172            let (min, max) = control_slider_range(control);
1173            let mut number = value.as_f64().unwrap_or(min).clamp(min, max);
1174            ui.add(Slider::new(&mut number, min..=max).show_value(true))
1175                .changed()
1176                .then(|| EguiNodeControlEdit::new(&control.key, binding, serde_json::json!(number)))
1177        }
1178        NodeControlKind::Toggle => {
1179            let mut checked = value.as_bool().unwrap_or_default();
1180            ui.checkbox(&mut checked, control_compact_label(control))
1181                .changed()
1182                .then(|| {
1183                    EguiNodeControlEdit::new(&control.key, binding, serde_json::json!(checked))
1184                })
1185        }
1186        NodeControlKind::Select => draw_select_control(ui, control, value, binding, id_salt, false),
1187        NodeControlKind::Asset | NodeControlKind::VariablePicker | NodeControlKind::PortBinding => {
1188            if control.options.is_empty() {
1189                let text = format!(
1190                    "{}: {}",
1191                    control_compact_label(control),
1192                    option_source_label(&control.option_source)
1193                );
1194                ui.add_enabled(false, Button::new(text));
1195                None
1196            } else {
1197                draw_select_control(ui, control, value, binding, id_salt, false)
1198            }
1199        }
1200        NodeControlKind::MultiSelect => {
1201            ui.add_enabled(false, Button::new(control_preview_label(control, value)));
1202            None
1203        }
1204        NodeControlKind::Color => {
1205            let mut text = control_text_value(value);
1206            ui.horizontal(|ui| {
1207                if let Some(color) = parse_hex_color(&text) {
1208                    let (swatch_rect, _) =
1209                        ui.allocate_exact_size(Vec2::new(14.0, 14.0), eframe::egui::Sense::hover());
1210                    ui.painter()
1211                        .rect_filled(swatch_rect, CornerRadius::same(3), color);
1212                    ui.painter().rect_stroke(
1213                        swatch_rect,
1214                        CornerRadius::same(3),
1215                        Stroke::new(0.75, input.style.stroke),
1216                        eframe::egui::StrokeKind::Inside,
1217                    );
1218                }
1219                ui.add(TextEdit::singleline(&mut text).desired_width(control_width(ui, 72.0)))
1220            })
1221            .inner
1222            .changed()
1223            .then(|| EguiNodeControlEdit::new(&control.key, binding, serde_json::json!(text)))
1224        }
1225    }
1226}
1227
1228fn draw_select_control(
1229    ui: &mut Ui,
1230    control: &NodeControlDescriptor,
1231    value: &serde_json::Value,
1232    binding: NodeControlBinding,
1233    id_salt: Id,
1234    _multi: bool,
1235) -> Option<EguiNodeControlEdit> {
1236    if control.options.is_empty() {
1237        ui.add_enabled(false, Button::new(control_preview_label(control, value)));
1238        return None;
1239    }
1240
1241    let mut selected = value.clone();
1242    let original = selected.clone();
1243    ComboBox::from_id_salt(id_salt)
1244        .selected_text(control_option_label(control, value))
1245        .width(control_width(ui, 96.0))
1246        .show_ui(ui, |ui| {
1247            for option in &control.options {
1248                ui.add_enabled_ui(!option.disabled, |ui| {
1249                    ui.selectable_value(&mut selected, option.value.clone(), &option.label);
1250                });
1251            }
1252        });
1253
1254    (selected != original).then(|| EguiNodeControlEdit::new(&control.key, binding, selected))
1255}
1256
1257fn draw_repeatable_header(
1258    ui: &mut Ui,
1259    rect: Rect,
1260    input: &NodeWidgetRenderInput<'_>,
1261    collection: &NodeRepeatableCollectionDescriptor,
1262    outcome: &mut EguiNodeWidgetRenderOutcome,
1263) {
1264    outcome.mark_rendered();
1265    draw_semantic_chip(
1266        ui,
1267        rect,
1268        collection
1269            .label
1270            .as_deref()
1271            .unwrap_or(collection.key.as_str()),
1272        input.style.accent.gamma_multiply(0.1),
1273        input.style.accent.gamma_multiply(0.42),
1274        input.style.text,
1275    );
1276
1277    let add_disabled = collection.add_disabled_reason(&input.node.data);
1278    let button_rect = Rect::from_min_size(
1279        Pos2::new(rect.right() - 46.0, rect.top() + 2.0),
1280        Vec2::new(40.0, rect.height().max(18.0) - 4.0),
1281    );
1282    ui.scope_builder(UiBuilder::new().max_rect(button_rect), |ui| {
1283        ui.set_clip_rect(button_rect);
1284        if ui
1285            .add_enabled(add_disabled.is_none(), Button::new("Add"))
1286            .on_disabled_hover_text(add_disabled.unwrap_or_default())
1287            .clicked()
1288        {
1289            outcome.push_repeatable_action(EguiNodeRepeatableAction::Add {
1290                collection_key: collection.key.clone(),
1291            });
1292        }
1293    });
1294}
1295
1296fn draw_repeatable_item_row(
1297    ui: &mut Ui,
1298    rect: Rect,
1299    input: &NodeWidgetRenderInput<'_>,
1300    collection: &NodeRepeatableCollectionDescriptor,
1301    item: &NodeRepeatableItemProjection,
1302    outcome: &mut EguiNodeWidgetRenderOutcome,
1303) {
1304    outcome.mark_rendered();
1305    let mut child_ui = ui.new_child(
1306        UiBuilder::new()
1307            .id_salt(Id::new((
1308                "repeatable-item",
1309                input.id,
1310                &collection.key,
1311                &item.item_id,
1312            )))
1313            .max_rect(rect)
1314            .layout(Layout::left_to_right(Align::Center)),
1315    );
1316    child_ui.set_clip_rect(rect);
1317    child_ui.set_min_size(rect.size());
1318    child_ui.painter().rect_filled(
1319        rect,
1320        CornerRadius::same(4),
1321        Color32::from_rgb(255, 255, 255),
1322    );
1323    child_ui.painter().rect_stroke(
1324        rect,
1325        CornerRadius::same(4),
1326        Stroke::new(0.75, input.style.stroke.gamma_multiply(0.55)),
1327        eframe::egui::StrokeKind::Inside,
1328    );
1329
1330    let button_width = if collection.reorderable { 74.0 } else { 32.0 };
1331    let label_width = 58.0f32.min(rect.width() * 0.32);
1332    let label_rect = Rect::from_min_size(
1333        rect.left_top() + Vec2::new(7.0, 2.0),
1334        Vec2::new(label_width, rect.height() - 4.0),
1335    );
1336    draw_field_badge(&mut child_ui, label_rect, &item.item_id, input.style.accent);
1337
1338    let controls_rect = Rect::from_min_max(
1339        Pos2::new(label_rect.max.x + 6.0, rect.top() + 2.0),
1340        Pos2::new(rect.right() - button_width - 8.0, rect.bottom() - 2.0),
1341    );
1342    if controls_rect.is_positive() {
1343        let item_path = format!("{}.{}", collection.item_source, item.item_index);
1344        child_ui.scope_builder(UiBuilder::new().max_rect(controls_rect), |ui| {
1345            ui.set_clip_rect(controls_rect);
1346            let controls = item
1347                .slots
1348                .iter()
1349                .flat_map(|slot| slot.controls.iter().cloned())
1350                .collect::<Vec<_>>();
1351            outcome.merge(draw_controls(
1352                ui,
1353                input,
1354                &controls,
1355                ControlRenderContext::repeatable(&item.item_data, &item_path),
1356            ));
1357        });
1358    } else {
1359        let text_rect = rect.shrink2(Vec2::new(7.0, 2.0));
1360        child_ui.painter().text(
1361            text_rect.left_center(),
1362            eframe::egui::Align2::LEFT_CENTER,
1363            repeatable_item_label(item),
1364            eframe::egui::TextStyle::Small.resolve(child_ui.style()),
1365            input.style.text.gamma_multiply(0.84),
1366        );
1367    }
1368
1369    let buttons_rect = Rect::from_min_max(
1370        Pos2::new(rect.right() - button_width - 4.0, rect.top() + 2.0),
1371        Pos2::new(rect.right() - 4.0, rect.bottom() - 2.0),
1372    );
1373    child_ui.scope_builder(UiBuilder::new().max_rect(buttons_rect), |ui| {
1374        ui.set_clip_rect(buttons_rect);
1375        ui.horizontal(|ui| {
1376            ui.spacing_mut().item_spacing = Vec2::new(2.0, 0.0);
1377            if collection.reorderable {
1378                if ui.add(Button::new("^")).clicked() {
1379                    outcome.push_repeatable_action(EguiNodeRepeatableAction::Move {
1380                        collection_key: collection.key.clone(),
1381                        item_id: item.item_id.clone(),
1382                        direction: EguiRepeatableMoveDirection::Up,
1383                    });
1384                }
1385                if ui.add(Button::new("v")).clicked() {
1386                    outcome.push_repeatable_action(EguiNodeRepeatableAction::Move {
1387                        collection_key: collection.key.clone(),
1388                        item_id: item.item_id.clone(),
1389                        direction: EguiRepeatableMoveDirection::Down,
1390                    });
1391                }
1392            }
1393            let remove_disabled = collection.remove_disabled_reason(&input.node.data);
1394            if ui
1395                .add_enabled(remove_disabled.is_none(), Button::new("Del"))
1396                .on_disabled_hover_text(remove_disabled.unwrap_or_default())
1397                .clicked()
1398            {
1399                outcome.push_repeatable_action(EguiNodeRepeatableAction::Remove {
1400                    collection_key: collection.key.clone(),
1401                    item_id: item.item_id.clone(),
1402                });
1403            }
1404        });
1405    });
1406}
1407
1408fn field_slot_for_region<'a>(
1409    input: &'a NodeWidgetRenderInput<'_>,
1410    region: &NodeInteractiveRegion,
1411    field_key: &str,
1412) -> Option<&'a NodeSurfaceSlotDescriptor> {
1413    input.descriptor.surface_slots.iter().find(|slot| {
1414        slot.key == region.key
1415            || (slot.kind == NodeSurfaceSlotKind::FieldRow && slot.data_key() == Some(field_key))
1416    })
1417}
1418
1419fn control_current_value<'a>(
1420    node_data: &'a serde_json::Value,
1421    control: &NodeControlDescriptor,
1422    context: ControlRenderContext<'a>,
1423) -> Option<&'a serde_json::Value> {
1424    if let Some(item_data) = context.item_data {
1425        return control
1426            .binding
1427            .as_ref()
1428            .and_then(|binding| match binding.source {
1429                NodeControlBindingSource::DataPath | NodeControlBindingSource::Slot => {
1430                    semantic_json_lookup(item_data, &binding.path)
1431                }
1432                NodeControlBindingSource::JsonPointer => item_data.pointer(&binding.path),
1433                NodeControlBindingSource::GraphSymbol | NodeControlBindingSource::PortAnchor => {
1434                    None
1435                }
1436            })
1437            .or_else(|| {
1438                control
1439                    .slot
1440                    .as_deref()
1441                    .and_then(|path| semantic_json_lookup(item_data, path))
1442            });
1443    }
1444
1445    if let Some(binding) = &control.binding {
1446        match binding.source {
1447            NodeControlBindingSource::DataPath => {
1448                return semantic_json_lookup(node_data, &binding.path);
1449            }
1450            NodeControlBindingSource::Slot => {
1451                if let Some(slot) = context.slot
1452                    && slot.kind == NodeSurfaceSlotKind::FieldRow
1453                    && let Some(fields) = context.fields
1454                    && let Some(value) = fields.get(&binding.path)
1455                {
1456                    return Some(value);
1457                }
1458                return semantic_json_lookup(node_data, &binding.path);
1459            }
1460            NodeControlBindingSource::JsonPointer => return node_data.pointer(&binding.path),
1461            NodeControlBindingSource::GraphSymbol | NodeControlBindingSource::PortAnchor => {}
1462        }
1463    }
1464
1465    control
1466        .slot
1467        .as_deref()
1468        .and_then(|path| semantic_json_lookup(node_data, path))
1469        .or_else(|| {
1470            context
1471                .slot
1472                .and_then(|slot| semantic_slot_value(node_data, slot, context.fields))
1473        })
1474}
1475
1476fn control_write_binding(
1477    control: &NodeControlDescriptor,
1478    context: ControlRenderContext<'_>,
1479) -> Option<NodeControlBinding> {
1480    let binding = control
1481        .binding
1482        .clone()
1483        .or_else(|| {
1484            control
1485                .slot
1486                .as_ref()
1487                .map(|slot| NodeControlBinding::data_path(slot.clone()))
1488        })
1489        .or_else(|| {
1490            context
1491                .slot
1492                .and_then(NodeSurfaceSlotDescriptor::data_key)
1493                .map(|path| NodeControlBinding::slot(path.to_owned()))
1494        })?;
1495
1496    if let Some(item_path) = context.item_path {
1497        return match binding.source {
1498            NodeControlBindingSource::DataPath | NodeControlBindingSource::Slot => Some(
1499                NodeControlBinding::data_path(join_data_path(item_path, &binding.path)),
1500            ),
1501            NodeControlBindingSource::JsonPointer
1502            | NodeControlBindingSource::GraphSymbol
1503            | NodeControlBindingSource::PortAnchor => None,
1504        };
1505    }
1506
1507    match binding.source {
1508        NodeControlBindingSource::DataPath | NodeControlBindingSource::JsonPointer => Some(binding),
1509        NodeControlBindingSource::Slot => {
1510            let path = if let Some(slot) = context.slot {
1511                if slot.kind == NodeSurfaceSlotKind::FieldRow {
1512                    join_data_path("fields", &binding.path)
1513                } else {
1514                    binding.path
1515                }
1516            } else {
1517                binding.path
1518            };
1519            Some(NodeControlBinding::data_path(path))
1520        }
1521        NodeControlBindingSource::GraphSymbol | NodeControlBindingSource::PortAnchor => None,
1522    }
1523}
1524
1525fn join_data_path(prefix: &str, suffix: &str) -> String {
1526    if suffix.is_empty() {
1527        prefix.to_owned()
1528    } else {
1529        format!("{prefix}.{suffix}")
1530    }
1531}
1532
1533fn control_text_value(value: &serde_json::Value) -> String {
1534    match value {
1535        serde_json::Value::String(text) => text.clone(),
1536        serde_json::Value::Null => String::new(),
1537        other => semantic_value_preview(other),
1538    }
1539}
1540
1541fn control_compact_label(control: &NodeControlDescriptor) -> &str {
1542    control.display_label().unwrap_or(control.key.as_str())
1543}
1544
1545fn control_preview_label(control: &NodeControlDescriptor, value: &serde_json::Value) -> String {
1546    let preview = semantic_value_preview(value);
1547    if preview.is_empty() {
1548        control_compact_label(control).to_owned()
1549    } else {
1550        format!("{}: {preview}", control_compact_label(control))
1551    }
1552}
1553
1554fn control_option_label(control: &NodeControlDescriptor, value: &serde_json::Value) -> String {
1555    control
1556        .options
1557        .iter()
1558        .find(|option| option.value == *value)
1559        .map(|option| option.label.clone())
1560        .unwrap_or_else(|| control_preview_label(control, value))
1561}
1562
1563fn option_source_label(source: &NodeControlOptionSource) -> &'static str {
1564    match source {
1565        NodeControlOptionSource::Inline => "options",
1566        NodeControlOptionSource::Variables => "variables",
1567        NodeControlOptionSource::Assets => "assets",
1568        NodeControlOptionSource::Ports => "ports",
1569        NodeControlOptionSource::Custom(_) => "custom",
1570    }
1571}
1572
1573fn control_width(ui: &Ui, preferred: f32) -> f32 {
1574    ui.available_width().min(preferred).max(40.0)
1575}
1576
1577fn control_slider_range(control: &NodeControlDescriptor) -> (f64, f64) {
1578    control
1579        .validation
1580        .rules
1581        .iter()
1582        .find_map(|rule| match rule {
1583            NodeControlValidationRule::Range { min, max } => {
1584                Some((min.unwrap_or(0.0), max.unwrap_or(1.0)))
1585            }
1586            _ => None,
1587        })
1588        .filter(|(min, max)| min.is_finite() && max.is_finite() && min < max)
1589        .unwrap_or((0.0, 1.0))
1590}
1591
1592fn parse_hex_color(value: &str) -> Option<Color32> {
1593    let value = value.strip_prefix('#')?;
1594    if value.len() != 6 {
1595        return None;
1596    }
1597    let red = u8::from_str_radix(&value[0..2], 16).ok()?;
1598    let green = u8::from_str_radix(&value[2..4], 16).ok()?;
1599    let blue = u8::from_str_radix(&value[4..6], 16).ok()?;
1600    Some(Color32::from_rgb(red, green, blue))
1601}
1602
1603fn repeatable_collection_title(collection: &NodeRepeatableCollectionDescriptor) -> String {
1604    collection
1605        .label
1606        .clone()
1607        .unwrap_or_else(|| collection.key.clone())
1608}
1609
1610fn repeatable_header_region_key(collection: &NodeRepeatableCollectionDescriptor) -> String {
1611    format!("repeatable.{}.header", collection.key)
1612}
1613
1614fn repeatable_empty_region_key(collection: &NodeRepeatableCollectionDescriptor) -> String {
1615    format!("repeatable.{}.empty", collection.key)
1616}
1617
1618fn repeatable_item_region_key(item: &NodeRepeatableItemProjection) -> String {
1619    item.slots
1620        .first()
1621        .map(|slot| slot.key.clone())
1622        .unwrap_or_else(|| item.slot_key.clone())
1623}
1624
1625fn repeatable_item_slot_kind(item: &NodeRepeatableItemProjection) -> Option<NodeSurfaceSlotKind> {
1626    item.slots
1627        .first()
1628        .map(|slot| slot.kind)
1629        .or(Some(NodeSurfaceSlotKind::FieldRow))
1630}
1631
1632fn repeatable_item_region_height(item: &NodeRepeatableItemProjection) -> f32 {
1633    let control_count = item
1634        .slots
1635        .iter()
1636        .map(|slot| slot.controls.len())
1637        .sum::<usize>();
1638    if control_count > 1 { 28.0 } else { 24.0 }
1639}
1640
1641fn repeatable_item_label(item: &NodeRepeatableItemProjection) -> String {
1642    item.item_data
1643        .get("label")
1644        .or_else(|| item.item_data.get("name"))
1645        .or_else(|| item.item_data.get("title"))
1646        .map(semantic_value_preview)
1647        .filter(|label| !label.is_empty())
1648        .unwrap_or_else(|| item.item_id.clone())
1649}
1650
1651fn repeatable_header_collection<'a>(
1652    input: &'a NodeWidgetRenderInput<'_>,
1653    region: &NodeInteractiveRegion,
1654) -> Option<&'a NodeRepeatableCollectionDescriptor> {
1655    input
1656        .descriptor
1657        .repeatable_collections
1658        .iter()
1659        .find(|collection| region.key == repeatable_header_region_key(collection))
1660}
1661
1662fn repeatable_empty_collection<'a>(
1663    input: &'a NodeWidgetRenderInput<'_>,
1664    region: &NodeInteractiveRegion,
1665) -> Option<&'a NodeRepeatableCollectionDescriptor> {
1666    input
1667        .descriptor
1668        .repeatable_collections
1669        .iter()
1670        .find(|collection| region.key == repeatable_empty_region_key(collection))
1671}
1672
1673fn repeatable_item_for_region<'a>(
1674    input: &'a NodeWidgetRenderInput<'_>,
1675    region: &NodeInteractiveRegion,
1676) -> Option<(
1677    &'a NodeRepeatableCollectionDescriptor,
1678    NodeRepeatableItemProjection,
1679)> {
1680    input
1681        .descriptor
1682        .repeatable_collections
1683        .iter()
1684        .find_map(|collection| {
1685            collection
1686                .item_projections(&input.node.data)
1687                .into_iter()
1688                .find(|item| repeatable_item_region_key(item) == region.key)
1689                .map(|item| (collection, item))
1690        })
1691}
1692
1693fn region_is_repeatable(input: &NodeWidgetRenderInput<'_>, region: &NodeInteractiveRegion) -> bool {
1694    repeatable_header_collection(input, region).is_some()
1695        || repeatable_empty_collection(input, region).is_some()
1696        || repeatable_item_for_region(input, region).is_some()
1697}
1698
1699fn region_is_detail_block(region: &NodeInteractiveRegion) -> bool {
1700    region.slot_kind == Some(NodeSurfaceSlotKind::NestedRegion)
1701        || region.slot_kind == Some(NodeSurfaceSlotKind::ConfigGroup)
1702        || region.slot_kind == Some(NodeSurfaceSlotKind::PortRail)
1703        || region.slot_kind == Some(NodeSurfaceSlotKind::Preview)
1704        || region.slot_kind == Some(NodeSurfaceSlotKind::StatusBanner)
1705        || region.key.starts_with("nested.")
1706        || region.key.starts_with("config.")
1707        || region.key.starts_with("rail.")
1708        || region.key.starts_with("preview.")
1709        || region.key.starts_with("status.")
1710}
1711
1712fn region_is_action(region: &NodeInteractiveRegion) -> bool {
1713    region.slot_kind == Some(NodeSurfaceSlotKind::ActionRow) || region.key.starts_with("actions.")
1714}
1715
1716fn region_is_badge(region: &NodeInteractiveRegion) -> bool {
1717    region.slot_kind == Some(NodeSurfaceSlotKind::Badge)
1718        || region.key.starts_with("badge.")
1719        || region.slot_kind == Some(NodeSurfaceSlotKind::MetricBadge)
1720        || region.key.starts_with("metric.")
1721}
1722
1723fn field_keys(
1724    input: &NodeRenderInput<'_>,
1725    fields: &serde_json::Map<String, serde_json::Value>,
1726    field_slots: &[&NodeSurfaceSlotDescriptor],
1727) -> Vec<String> {
1728    let mut keys = Vec::new();
1729    for slot in field_slots {
1730        if let Some(key) = slot.data_key()
1731            && fields.contains_key(key)
1732            && !keys.iter().any(|existing| existing == key)
1733        {
1734            keys.push(key.to_owned());
1735        }
1736    }
1737
1738    if let Some(order) = input
1739        .node
1740        .data
1741        .get("field_order")
1742        .and_then(|value| value.as_array())
1743    {
1744        for key in order.iter().filter_map(|value| value.as_str()) {
1745            if fields.contains_key(key) && !keys.iter().any(|existing| existing == key) {
1746                keys.push(key.to_owned());
1747            }
1748        }
1749    }
1750
1751    for key in fields.keys() {
1752        if !keys.iter().any(|existing| existing == key) {
1753            keys.push(key.clone());
1754        }
1755    }
1756    keys
1757}
1758
1759fn semantic_slots<'a>(
1760    input: &'a NodeRenderInput<'_>,
1761    kind: NodeSurfaceSlotKind,
1762) -> Vec<&'a NodeSurfaceSlotDescriptor> {
1763    input.descriptor.surface_slots_of_kind(kind)
1764}
1765
1766fn semantic_slot_title(slot: &NodeSurfaceSlotDescriptor) -> Option<String> {
1767    slot.display_label().map(ToOwned::to_owned)
1768}
1769
1770fn semantic_slot_chip_text(
1771    node_data: &serde_json::Value,
1772    slot: &NodeSurfaceSlotDescriptor,
1773    fields: Option<&serde_json::Map<String, serde_json::Value>>,
1774) -> String {
1775    semantic_slot_value(node_data, slot, fields)
1776        .map(semantic_value_preview)
1777        .filter(|text| !text.is_empty())
1778        .or_else(|| semantic_slot_title(slot))
1779        .unwrap_or_else(|| slot.key.clone())
1780}
1781
1782fn semantic_slot_lines(
1783    node_data: &serde_json::Value,
1784    slot: &NodeSurfaceSlotDescriptor,
1785    fields: Option<&serde_json::Map<String, serde_json::Value>>,
1786) -> Vec<String> {
1787    let Some(value) = semantic_slot_value(node_data, slot, fields) else {
1788        return semantic_slot_title(slot).into_iter().collect();
1789    };
1790
1791    match value {
1792        serde_json::Value::Array(items) => items
1793            .iter()
1794            .map(semantic_value_preview)
1795            .filter(|text| !text.is_empty())
1796            .collect(),
1797        serde_json::Value::Object(map) => map
1798            .iter()
1799            .map(|(key, value)| format!("{key}: {}", semantic_value_preview(value)))
1800            .collect(),
1801        other => {
1802            let text = semantic_value_preview(other);
1803            if text.is_empty() {
1804                semantic_slot_title(slot).into_iter().collect()
1805            } else {
1806                text.lines()
1807                    .filter(|line| !line.trim().is_empty())
1808                    .map(ToOwned::to_owned)
1809                    .collect()
1810            }
1811        }
1812    }
1813}
1814
1815fn semantic_slot_value<'a>(
1816    node_data: &'a serde_json::Value,
1817    slot: &NodeSurfaceSlotDescriptor,
1818    fields: Option<&'a serde_json::Map<String, serde_json::Value>>,
1819) -> Option<&'a serde_json::Value> {
1820    let key = slot.data_key()?;
1821    if slot.kind == NodeSurfaceSlotKind::FieldRow
1822        && let Some(fields) = fields
1823        && let Some(value) = fields.get(key)
1824    {
1825        return Some(value);
1826    }
1827    semantic_json_lookup(node_data, key)
1828}
1829
1830fn semantic_json_lookup<'a>(
1831    value: &'a serde_json::Value,
1832    path: &str,
1833) -> Option<&'a serde_json::Value> {
1834    let mut current = value;
1835    for segment in path.split('.') {
1836        current = current.get(segment)?;
1837    }
1838    Some(current)
1839}
1840
1841fn semantic_value_preview(value: &serde_json::Value) -> String {
1842    match value {
1843        serde_json::Value::String(text) => text.clone(),
1844        serde_json::Value::Bool(value) => value.to_string(),
1845        serde_json::Value::Number(value) => value.to_string(),
1846        serde_json::Value::Array(items) => {
1847            let preview = items
1848                .iter()
1849                .take(2)
1850                .map(semantic_value_preview)
1851                .filter(|text| !text.is_empty())
1852                .collect::<Vec<_>>()
1853                .join(" · ");
1854            if preview.is_empty() {
1855                format!("{} items", items.len())
1856            } else if items.len() > 2 {
1857                format!("{preview} …")
1858            } else {
1859                preview
1860            }
1861        }
1862        serde_json::Value::Object(map) => {
1863            if let Some(text) = map.get("label").and_then(serde_json::Value::as_str) {
1864                return text.to_owned();
1865            }
1866            if let Some(text) = map.get("title").and_then(serde_json::Value::as_str) {
1867                return text.to_owned();
1868            }
1869            let preview = map
1870                .iter()
1871                .take(2)
1872                .map(|(key, value)| format!("{key}: {}", semantic_value_preview(value)))
1873                .collect::<Vec<_>>()
1874                .join(" · ");
1875            if preview.is_empty() {
1876                "{}".to_owned()
1877            } else {
1878                preview
1879            }
1880        }
1881        serde_json::Value::Null => String::new(),
1882    }
1883}
1884
1885fn semantic_nested_region_height(title: Option<&str>, lines: &[String]) -> f32 {
1886    let mut height = 18.0;
1887    if title.is_some() {
1888        height += 14.0;
1889    }
1890    if !lines.is_empty() {
1891        height += (lines.len() as f32 * 13.0).min(39.0);
1892    }
1893    height.max(34.0)
1894}
1895
1896fn semantic_action_region_height(items: &[String]) -> f32 {
1897    18.0 + items.len().max(1) as f32 * 20.0
1898}
1899
1900fn semantic_chip_width(text: &str, min_width: f32, max_width: f32) -> f32 {
1901    let estimated = text.chars().count() as f32 * 6.5 + 16.0;
1902    estimated.clamp(min_width, max_width)
1903}
1904
1905#[derive(Debug, Clone, Copy, PartialEq)]
1906struct FieldListMetrics {
1907    left: f32,
1908    top: f32,
1909    row_width: f32,
1910    row_height: f32,
1911    row_gap: f32,
1912    bottom_padding: f32,
1913}
1914
1915impl FieldListMetrics {
1916    fn new(size: CanvasSize) -> Self {
1917        Self {
1918            left: 14.0,
1919            top: 46.0,
1920            row_width: (size.width - 28.0).max(80.0),
1921            row_height: 22.0,
1922            row_gap: 4.0,
1923            bottom_padding: 14.0,
1924        }
1925    }
1926
1927    fn row_rect(self, index: usize) -> CanvasRect {
1928        CanvasRect {
1929            origin: jellyflow::core::CanvasPoint {
1930                x: self.left,
1931                y: self.top + index as f32 * (self.row_height + self.row_gap),
1932            },
1933            size: CanvasSize {
1934                width: self.row_width,
1935                height: self.row_height,
1936            },
1937        }
1938    }
1939
1940    fn bottom_after(self, row_count: usize) -> f32 {
1941        self.top
1942            + row_count as f32 * self.row_height
1943            + row_count.saturating_sub(1) as f32 * self.row_gap
1944            + self.bottom_padding
1945    }
1946
1947    fn min_size(self, current: CanvasSize, row_count: usize) -> CanvasSize {
1948        CanvasSize {
1949            width: current.width.max(self.row_width + self.left * 2.0),
1950            height: current.height.max(self.bottom_after(row_count)),
1951        }
1952    }
1953}
1954
1955fn draw_field_badge(ui: &mut Ui, rect: Rect, badge: &str, color: Color32) {
1956    ui.painter()
1957        .rect_filled(rect, CornerRadius::same(3), color.gamma_multiply(0.12));
1958    ui.painter().text(
1959        rect.center(),
1960        eframe::egui::Align2::CENTER_CENTER,
1961        badge,
1962        eframe::egui::TextStyle::Small.resolve(ui.style()),
1963        color,
1964    );
1965}
1966
1967fn draw_semantic_chip(
1968    ui: &mut Ui,
1969    rect: Rect,
1970    text: &str,
1971    fill: Color32,
1972    stroke: Color32,
1973    text_color: Color32,
1974) {
1975    let painter = ui.painter().with_clip_rect(rect);
1976    painter.rect_filled(rect, CornerRadius::same(4), fill);
1977    painter.rect_stroke(
1978        rect,
1979        CornerRadius::same(4),
1980        Stroke::new(0.8, stroke),
1981        eframe::egui::StrokeKind::Inside,
1982    );
1983    painter.text(
1984        rect.center(),
1985        eframe::egui::Align2::CENTER_CENTER,
1986        text,
1987        eframe::egui::TextStyle::Small.resolve(ui.style()),
1988        text_color,
1989    );
1990}
1991
1992fn draw_semantic_nested_region(
1993    ui: &mut Ui,
1994    rect: Rect,
1995    title: Option<&str>,
1996    lines: &[String],
1997    style: NodeRendererStyle,
1998) {
1999    let painter = ui.painter().with_clip_rect(rect);
2000    painter.rect_filled(rect, CornerRadius::same(4), style.fill.gamma_multiply(0.9));
2001    painter.rect_stroke(
2002        rect,
2003        CornerRadius::same(4),
2004        Stroke::new(0.8, style.stroke.gamma_multiply(0.72)),
2005        eframe::egui::StrokeKind::Inside,
2006    );
2007
2008    let mut y = rect.top() + 5.0;
2009    if let Some(title) = title {
2010        painter.text(
2011            Pos2::new(rect.left() + 7.0, y),
2012            eframe::egui::Align2::LEFT_TOP,
2013            title,
2014            eframe::egui::TextStyle::Small.resolve(ui.style()),
2015            style.text,
2016        );
2017        y += 13.0;
2018    }
2019
2020    for line in lines.iter().take(3) {
2021        painter.text(
2022            Pos2::new(rect.left() + 7.0, y),
2023            eframe::egui::Align2::LEFT_TOP,
2024            line,
2025            eframe::egui::TextStyle::Small.resolve(ui.style()),
2026            style.text.gamma_multiply(0.82),
2027        );
2028        y += 12.0;
2029    }
2030}
2031
2032fn field_value_label(value: &serde_json::Value) -> String {
2033    value
2034        .as_str()
2035        .map(ToOwned::to_owned)
2036        .unwrap_or_else(|| value.to_string())
2037}
2038
2039fn field_badge(key: &str) -> Option<&'static str> {
2040    match key {
2041        "primary_key" | "pk" => Some("PK"),
2042        "foreign_key" | "fk" => Some("FK"),
2043        _ => None,
2044    }
2045}
2046
2047fn node_local_rect_to_screen(node_rect: Rect, local_rect: CanvasRect, zoom: f32) -> Rect {
2048    Rect::from_min_size(
2049        node_rect.min + Vec2::new(local_rect.origin.x * zoom, local_rect.origin.y * zoom),
2050        Vec2::new(local_rect.size.width * zoom, local_rect.size.height * zoom),
2051    )
2052}
2053
2054#[cfg(test)]
2055mod tests {
2056    use super::*;
2057    use jellyflow::core::{CanvasPoint, NodeKindKey};
2058    use jellyflow::runtime::schema::{NodeRepeatableAnchorRule, NodeSurfaceLayoutBudget};
2059
2060    #[derive(Debug, Clone, Copy)]
2061    struct TestRenderer;
2062
2063    impl RichNodeRenderer for TestRenderer {
2064        fn render(&self, input: &NodeRenderInput<'_>, rect: CanvasRect) -> NodeRenderLayout {
2065            let mut layout = NodeRenderLayout::fallback(input, rect);
2066            layout.title = format!("rich:{}", layout.title);
2067            layout.interactive_regions.push(NodeInteractiveRegion {
2068                key: "body".to_owned(),
2069                slot_kind: Some(NodeSurfaceSlotKind::Body),
2070                rect: CanvasRect {
2071                    origin: CanvasPoint::default(),
2072                    size: rect.size,
2073                },
2074                label: None,
2075                z_index: 1,
2076            });
2077            layout
2078        }
2079    }
2080
2081    fn test_descriptor(renderer_key: &str) -> NodeKindViewDescriptor {
2082        NodeKindViewDescriptor {
2083            kind: NodeKindKey::new("demo.rich"),
2084            renderer_key: renderer_key.to_owned(),
2085            title: "Rich".to_owned(),
2086            category: Vec::new(),
2087            keywords: Vec::new(),
2088            default_size: None,
2089            layout_budget: NodeSurfaceLayoutBudget::default(),
2090            ports: Vec::new(),
2091            surface_slots: Vec::new(),
2092            repeatable_collections: Vec::new(),
2093            actions: Vec::new(),
2094            menus: Vec::new(),
2095            inspectors: Vec::new(),
2096            blackboards: Vec::new(),
2097            chrome: Vec::new(),
2098            default_data: serde_json::Value::Null,
2099        }
2100    }
2101
2102    fn test_node() -> Node {
2103        Node {
2104            kind: NodeKindKey::new("demo.rich"),
2105            kind_version: 1,
2106            pos: CanvasPoint::default(),
2107            origin: None,
2108            selectable: None,
2109            focusable: None,
2110            draggable: None,
2111            connectable: None,
2112            deletable: None,
2113            parent: None,
2114            extent: None,
2115            expand_parent: None,
2116            size: None,
2117            hidden: false,
2118            collapsed: false,
2119            ports: Vec::new(),
2120            data: serde_json::json!({ "title": "Node" }),
2121        }
2122    }
2123
2124    #[test]
2125    fn renderer_catalog_falls_back_and_routes_rich_renderers() {
2126        let descriptor = test_descriptor("demo.rich");
2127        let node = test_node();
2128        let input = NodeRenderInput {
2129            id: NodeId::from_u128(1),
2130            node: &node,
2131            descriptor: &descriptor,
2132            state: NodeRendererState::default(),
2133            style: NodeRendererStyle::fallback(),
2134        };
2135        let rect = CanvasRect {
2136            origin: CanvasPoint::default(),
2137            size: CanvasSize {
2138                width: 120.0,
2139                height: 80.0,
2140            },
2141        };
2142        let mut catalog = RendererCatalog::new();
2143
2144        assert_eq!(catalog.render_node(&input, rect).title, "Node");
2145        catalog.register_rich("demo.rich", TestRenderer);
2146        let layout = catalog.render_node(&input, rect);
2147        assert_eq!(layout.title, "rich:Node");
2148        assert_eq!(layout.interactive_regions[0].key, "body");
2149    }
2150
2151    #[test]
2152    fn renderer_catalog_tracks_widget_renderers_separately() {
2153        let mut catalog = RendererCatalog::new();
2154
2155        assert!(!catalog.has_widget_renderer("table-card"));
2156        catalog.register_widgets("table-card", FieldListNodeRenderer);
2157
2158        assert!(catalog.has_widget_renderer("table-card"));
2159        assert!(!catalog.has_widget_renderer("unknown"));
2160    }
2161
2162    #[test]
2163    fn semantic_slots_sort_and_resolve_renderer_neutral_paths() {
2164        let node = Node {
2165            kind: NodeKindKey::new("demo.rich"),
2166            kind_version: 1,
2167            pos: CanvasPoint::default(),
2168            origin: None,
2169            selectable: None,
2170            focusable: None,
2171            draggable: None,
2172            connectable: None,
2173            deletable: None,
2174            parent: None,
2175            extent: None,
2176            expand_parent: None,
2177            size: None,
2178            hidden: false,
2179            collapsed: false,
2180            ports: Vec::new(),
2181            data: serde_json::json!({
2182                "meta": { "model": "gpt-4.1-mini" },
2183                "nested": { "policy": { "guardrails": "Block PII" } },
2184                "actions": { "primary": ["Test prompt", "Open trace"] }
2185            }),
2186        };
2187        let descriptor = NodeKindViewDescriptor {
2188            kind: NodeKindKey::new("demo.rich"),
2189            renderer_key: "demo.rich".to_owned(),
2190            title: "Rich".to_owned(),
2191            category: Vec::new(),
2192            keywords: Vec::new(),
2193            default_size: None,
2194            layout_budget: NodeSurfaceLayoutBudget::default(),
2195            ports: Vec::new(),
2196            surface_slots: vec![
2197                NodeSurfaceSlotDescriptor::badge("badge.model")
2198                    .with_label("Model")
2199                    .with_anchor("meta.model")
2200                    .with_order(2),
2201                NodeSurfaceSlotDescriptor::nested_region("nested.policy")
2202                    .with_label("Policy")
2203                    .with_slot("nested.policy")
2204                    .with_anchor("nested.policy")
2205                    .with_order(0),
2206                NodeSurfaceSlotDescriptor::action_row("actions.primary")
2207                    .with_label("Actions")
2208                    .with_slot("actions.primary")
2209                    .with_anchor("actions.primary")
2210                    .with_order(1),
2211            ],
2212            repeatable_collections: Vec::new(),
2213            actions: Vec::new(),
2214            menus: Vec::new(),
2215            inspectors: Vec::new(),
2216            blackboards: Vec::new(),
2217            chrome: Vec::new(),
2218            default_data: serde_json::Value::Null,
2219        };
2220        let input = NodeRenderInput {
2221            id: NodeId::from_u128(9),
2222            node: &node,
2223            descriptor: &descriptor,
2224            state: NodeRendererState::default(),
2225            style: NodeRendererStyle::fallback(),
2226        };
2227
2228        let slots = semantic_slots(&input, NodeSurfaceSlotKind::NestedRegion);
2229        assert_eq!(slots[0].key, "nested.policy");
2230        assert_eq!(
2231            semantic_slot_lines(&input.node.data, slots[0], None),
2232            vec!["guardrails: Block PII".to_owned()]
2233        );
2234        assert_eq!(
2235            semantic_slot_chip_text(&input.node.data, &descriptor.surface_slots[2], None),
2236            "Test prompt · Open trace"
2237        );
2238    }
2239
2240    #[test]
2241    fn semantic_slot_data_key_resolves_explicit_slot_and_keeps_field_compatibility() {
2242        let field =
2243            NodeSurfaceSlotDescriptor::field_row("field.source").with_anchor("field.source");
2244        assert_eq!(field.data_key(), Some("source"));
2245
2246        let badge = NodeSurfaceSlotDescriptor::badge("badge.model").with_anchor("meta.model");
2247        assert_eq!(badge.data_key(), None);
2248
2249        let nested = NodeSurfaceSlotDescriptor::nested_region("nested.policy")
2250            .with_slot("nested.policy")
2251            .with_anchor("nested.policy");
2252        assert_eq!(nested.data_key(), Some("nested.policy"));
2253    }
2254
2255    #[test]
2256    fn field_row_control_binding_targets_fields_namespace() {
2257        let node_data = serde_json::json!({
2258            "fields": {
2259                "prompt": "Classify customer intent"
2260            }
2261        });
2262        let fields = node_data
2263            .get("fields")
2264            .and_then(serde_json::Value::as_object)
2265            .expect("fields object exists");
2266        let slot = NodeSurfaceSlotDescriptor::field_row("field.prompt")
2267            .with_slot("prompt")
2268            .with_control(
2269                NodeControlDescriptor::text_area("control.prompt")
2270                    .with_binding(NodeControlBinding::slot("prompt")),
2271            );
2272        let control = &slot.controls[0];
2273        let context = ControlRenderContext::node(Some(fields), &slot);
2274
2275        assert_eq!(
2276            control_current_value(&node_data, control, context),
2277            Some(&serde_json::json!("Classify customer intent"))
2278        );
2279        assert_eq!(
2280            control_write_binding(control, context),
2281            Some(NodeControlBinding::data_path("fields.prompt"))
2282        );
2283    }
2284
2285    #[test]
2286    fn repeatable_control_binding_targets_item_index_path() {
2287        let collection = NodeRepeatableCollectionDescriptor::new("table.columns", "columns", "id")
2288            .with_item_template_slot(
2289                NodeSurfaceSlotDescriptor::field_row("column").with_control(
2290                    NodeControlDescriptor::text_input("control.column.name")
2291                        .with_binding(NodeControlBinding::data_path("name")),
2292                ),
2293            );
2294        let node_data = serde_json::json!({
2295            "columns": [
2296                { "id": "id", "name": "id" },
2297                { "id": "email", "name": "email" }
2298            ]
2299        });
2300        let item = collection.item_projections(&node_data)[1].clone();
2301        let control = &item.slots[0].controls[0];
2302        let context = ControlRenderContext::repeatable(&item.item_data, "columns.1");
2303
2304        assert_eq!(
2305            control_current_value(&node_data, control, context),
2306            Some(&serde_json::json!("email"))
2307        );
2308        assert_eq!(
2309            control_write_binding(control, context),
2310            Some(NodeControlBinding::data_path("columns.1.name"))
2311        );
2312    }
2313
2314    #[test]
2315    fn slider_control_range_uses_validation_rule() {
2316        let ranged = NodeControlDescriptor::slider("control.factor").with_validation_rule(
2317            NodeControlValidationRule::Range {
2318                min: Some(-1.0),
2319                max: Some(2.5),
2320            },
2321        );
2322        let fallback = NodeControlDescriptor::slider("control.default");
2323
2324        assert_eq!(control_slider_range(&ranged), (-1.0, 2.5));
2325        assert_eq!(control_slider_range(&fallback), (0.0, 1.0));
2326    }
2327
2328    #[test]
2329    fn field_list_renderer_emits_repeatable_regions_with_stable_item_keys() {
2330        let descriptor = NodeKindViewDescriptor {
2331            kind: NodeKindKey::new("demo.table"),
2332            renderer_key: "table-card".to_owned(),
2333            title: "Table".to_owned(),
2334            category: Vec::new(),
2335            keywords: Vec::new(),
2336            default_size: None,
2337            layout_budget: NodeSurfaceLayoutBudget::default(),
2338            ports: Vec::new(),
2339            surface_slots: Vec::new(),
2340            repeatable_collections: vec![
2341                NodeRepeatableCollectionDescriptor::new("table.columns", "columns", "id")
2342                    .with_label("Columns")
2343                    .with_item_template_slot(
2344                        NodeSurfaceSlotDescriptor::field_row("column").with_control(
2345                            NodeControlDescriptor::text_input("control.column.name")
2346                                .with_binding(NodeControlBinding::data_path("name")),
2347                        ),
2348                    )
2349                    .with_anchor_rule(NodeRepeatableAnchorRule::new(
2350                        "field.column",
2351                        "field.column",
2352                    )),
2353            ],
2354            actions: Vec::new(),
2355            menus: Vec::new(),
2356            inspectors: Vec::new(),
2357            blackboards: Vec::new(),
2358            chrome: Vec::new(),
2359            default_data: serde_json::Value::Null,
2360        };
2361        let node = Node {
2362            kind: NodeKindKey::new("demo.table"),
2363            kind_version: 1,
2364            pos: CanvasPoint::default(),
2365            origin: None,
2366            selectable: None,
2367            focusable: None,
2368            draggable: None,
2369            connectable: None,
2370            deletable: None,
2371            parent: None,
2372            extent: None,
2373            expand_parent: None,
2374            size: None,
2375            hidden: false,
2376            collapsed: false,
2377            ports: Vec::new(),
2378            data: serde_json::json!({
2379                "columns": [
2380                    { "id": "id", "name": "id" },
2381                    { "id": "email", "name": "email" }
2382                ]
2383            }),
2384        };
2385        let input = NodeRenderInput {
2386            id: NodeId::from_u128(21),
2387            node: &node,
2388            descriptor: &descriptor,
2389            state: NodeRendererState::default(),
2390            style: NodeRendererStyle::section(),
2391        };
2392
2393        let layout = RendererCatalog::default().render_node(
2394            &input,
2395            CanvasRect {
2396                origin: CanvasPoint::default(),
2397                size: CanvasSize {
2398                    width: 240.0,
2399                    height: 120.0,
2400                },
2401            },
2402        );
2403
2404        assert!(
2405            layout
2406                .interactive_regions
2407                .iter()
2408                .any(|region| region.key == "repeatable.table.columns.header")
2409        );
2410        assert!(
2411            layout
2412                .interactive_regions
2413                .iter()
2414                .any(|region| region.key == "field.column.email.column")
2415        );
2416    }
2417
2418    #[test]
2419    fn node_content_level_derives_from_zoom() {
2420        assert_eq!(NodeContentLevel::from_zoom(1.0), NodeContentLevel::Full);
2421        assert_eq!(NodeContentLevel::from_zoom(0.5), NodeContentLevel::Compact);
2422        assert_eq!(NodeContentLevel::from_zoom(0.12), NodeContentLevel::Shell);
2423        assert_eq!(
2424            NodeContentLevel::from_zoom_and_size(1.0, Vec2::new(220.0, 120.0)),
2425            NodeContentLevel::Full
2426        );
2427        assert_eq!(
2428            NodeContentLevel::from_zoom_and_size(1.0, Vec2::new(120.0, 70.0)),
2429            NodeContentLevel::Compact
2430        );
2431        assert_eq!(
2432            NodeContentLevel::from_zoom_and_size(1.0, Vec2::new(40.0, 20.0)),
2433            NodeContentLevel::Shell
2434        );
2435        assert!(NodeContentLevel::Full.shows_text());
2436        assert!(NodeContentLevel::Compact.shows_text());
2437        assert!(NodeContentLevel::Full.shows_detail());
2438        assert!(!NodeContentLevel::Compact.shows_detail());
2439        assert!(NodeContentLevel::Full.renders_slot_kind(Some(NodeSurfaceSlotKind::Badge)));
2440        assert!(NodeContentLevel::Compact.renders_slot_kind(Some(NodeSurfaceSlotKind::FieldRow)));
2441        assert!(NodeContentLevel::Compact.renders_slot_kind(Some(NodeSurfaceSlotKind::PortRail)));
2442        assert!(!NodeContentLevel::Compact.renders_slot_kind(Some(NodeSurfaceSlotKind::Badge)));
2443        assert!(
2444            !NodeContentLevel::Compact.renders_slot_kind(Some(NodeSurfaceSlotKind::StatusBanner))
2445        );
2446        assert!(!NodeContentLevel::Shell.renders_slot_kind(Some(NodeSurfaceSlotKind::FieldRow)));
2447    }
2448
2449    #[test]
2450    fn widget_clip_rect_can_clip_node_local_regions() {
2451        let node_rect =
2452            Rect::from_min_size(eframe::egui::pos2(100.0, 20.0), Vec2::new(120.0, 80.0));
2453        let region = CanvasRect {
2454            origin: CanvasPoint { x: 8.0, y: 16.0 },
2455            size: CanvasSize {
2456                width: 100.0,
2457                height: 20.0,
2458            },
2459        };
2460        let clip = Rect::from_min_max(
2461            eframe::egui::pos2(130.0, 0.0),
2462            eframe::egui::pos2(260.0, 100.0),
2463        );
2464        let descriptor = test_descriptor("demo.rich");
2465        let node = test_node();
2466        let layout = NodeRenderLayout::fallback(
2467            &NodeRenderInput {
2468                id: NodeId::from_u128(1),
2469                node: &node,
2470                descriptor: &descriptor,
2471                state: NodeRendererState::default(),
2472                style: NodeRendererStyle::fallback(),
2473            },
2474            CanvasRect {
2475                origin: CanvasPoint::default(),
2476                size: CanvasSize {
2477                    width: 120.0,
2478                    height: 80.0,
2479                },
2480            },
2481        );
2482        let input = NodeWidgetRenderInput {
2483            id: NodeId::from_u128(1),
2484            node: &node,
2485            descriptor: &descriptor,
2486            state: NodeRendererState::default(),
2487            style: NodeRendererStyle::fallback(),
2488            layout: &layout,
2489            node_rect,
2490            clip_rect: clip,
2491            zoom: 1.0,
2492            content_level: NodeContentLevel::Full,
2493        };
2494
2495        let clipped = input
2496            .node_local_screen_rect(region)
2497            .expect("region intersects the widget clip rect");
2498
2499        assert_eq!(clipped.left(), 130.0);
2500        assert_eq!(clipped.right(), 208.0);
2501    }
2502
2503    #[test]
2504    fn widget_region_rect_is_clipped_to_node_bounds() {
2505        let node_rect =
2506            Rect::from_min_size(eframe::egui::pos2(100.0, 20.0), Vec2::new(120.0, 80.0));
2507        let descriptor = test_descriptor("demo.rich");
2508        let node = test_node();
2509        let layout = NodeRenderLayout::fallback(
2510            &NodeRenderInput {
2511                id: NodeId::from_u128(1),
2512                node: &node,
2513                descriptor: &descriptor,
2514                state: NodeRendererState::default(),
2515                style: NodeRendererStyle::fallback(),
2516            },
2517            CanvasRect {
2518                origin: CanvasPoint::default(),
2519                size: CanvasSize {
2520                    width: 120.0,
2521                    height: 80.0,
2522                },
2523            },
2524        );
2525        let input = NodeWidgetRenderInput {
2526            id: NodeId::from_u128(1),
2527            node: &node,
2528            descriptor: &descriptor,
2529            state: NodeRendererState::default(),
2530            style: NodeRendererStyle::fallback(),
2531            layout: &layout,
2532            node_rect,
2533            clip_rect: Rect::from_min_max(
2534                eframe::egui::pos2(0.0, 0.0),
2535                eframe::egui::pos2(300.0, 300.0),
2536            ),
2537            zoom: 1.0,
2538            content_level: NodeContentLevel::Full,
2539        };
2540
2541        let clipped = input
2542            .node_local_screen_rect(CanvasRect {
2543                origin: CanvasPoint { x: 80.0, y: 62.0 },
2544                size: CanvasSize {
2545                    width: 72.0,
2546                    height: 40.0,
2547                },
2548            })
2549            .expect("oversized local rect should still intersect the node");
2550
2551        assert_eq!(clipped.right(), node_rect.right());
2552        assert_eq!(clipped.bottom(), node_rect.bottom());
2553    }
2554
2555    #[test]
2556    fn field_list_metrics_space_rows_and_grow_min_height() {
2557        let metrics = FieldListMetrics::new(CanvasSize {
2558            width: 226.0,
2559            height: 80.0,
2560        });
2561        let first = metrics.row_rect(0);
2562        let second = metrics.row_rect(1);
2563        let min_size = metrics.min_size(
2564            CanvasSize {
2565                width: 160.0,
2566                height: 80.0,
2567            },
2568            3,
2569        );
2570
2571        assert_eq!(first.size.height, 22.0);
2572        assert_eq!(second.origin.y - first.origin.y, 26.0);
2573        assert!(min_size.width >= 226.0);
2574        assert!(min_size.height >= 132.0);
2575    }
2576
2577    #[test]
2578    fn builtin_table_renderer_emits_field_regions_in_node_local_coordinates() {
2579        let descriptor = NodeKindViewDescriptor {
2580            kind: NodeKindKey::new("demo.table"),
2581            renderer_key: "table-card".to_owned(),
2582            title: "Table".to_owned(),
2583            category: Vec::new(),
2584            keywords: Vec::new(),
2585            default_size: None,
2586            layout_budget: NodeSurfaceLayoutBudget::default(),
2587            ports: Vec::new(),
2588            surface_slots: Vec::new(),
2589            repeatable_collections: Vec::new(),
2590            actions: Vec::new(),
2591            menus: Vec::new(),
2592            inspectors: Vec::new(),
2593            blackboards: Vec::new(),
2594            chrome: Vec::new(),
2595            default_data: serde_json::Value::Null,
2596        };
2597        let node = Node {
2598            kind: NodeKindKey::new("demo.table"),
2599            kind_version: 1,
2600            pos: CanvasPoint::default(),
2601            origin: None,
2602            selectable: None,
2603            focusable: None,
2604            draggable: None,
2605            connectable: None,
2606            deletable: None,
2607            parent: None,
2608            extent: None,
2609            expand_parent: None,
2610            size: None,
2611            hidden: false,
2612            collapsed: false,
2613            ports: Vec::new(),
2614            data: serde_json::json!({
2615                "title": "orders",
2616                "field_order": ["primary_key", "foreign_key"],
2617                "fields": {
2618                    "primary_key": "id",
2619                    "foreign_key": "customer_id"
2620                }
2621            }),
2622        };
2623        let input = NodeRenderInput {
2624            id: NodeId::from_u128(2),
2625            node: &node,
2626            descriptor: &descriptor,
2627            state: NodeRendererState::default(),
2628            style: NodeRendererStyle::section(),
2629        };
2630        let layout = RendererCatalog::default().render_node(
2631            &input,
2632            CanvasRect {
2633                origin: CanvasPoint { x: 200.0, y: 100.0 },
2634                size: CanvasSize {
2635                    width: 226.0,
2636                    height: 150.0,
2637                },
2638            },
2639        );
2640
2641        let primary = layout
2642            .interactive_regions
2643            .iter()
2644            .find(|region| region.key == "field.primary_key")
2645            .expect("primary key region exists");
2646        let foreign = layout
2647            .interactive_regions
2648            .iter()
2649            .find(|region| region.key == "field.foreign_key")
2650            .expect("foreign key region exists");
2651        assert_eq!(primary.rect.origin.x, 14.0);
2652        assert_eq!(primary.rect.origin.y, 46.0);
2653        assert_eq!(primary.rect.size.height, 22.0);
2654        assert_eq!(primary.label.as_deref(), Some("id"));
2655        assert!(foreign.rect.origin.y > primary.rect.origin.y);
2656        assert_eq!(foreign.label.as_deref(), Some("customer_id"));
2657    }
2658
2659    #[test]
2660    fn dify_style_regions_keep_field_anchors_before_status_and_actions() {
2661        let descriptor = NodeKindViewDescriptor {
2662            kind: NodeKindKey::new("demo.llm"),
2663            renderer_key: "decision-card".to_owned(),
2664            title: "LLM".to_owned(),
2665            category: Vec::new(),
2666            keywords: Vec::new(),
2667            default_size: None,
2668            layout_budget: NodeSurfaceLayoutBudget::default(),
2669            ports: Vec::new(),
2670            surface_slots: vec![
2671                NodeSurfaceSlotDescriptor::field_row("field.prompt")
2672                    .with_label("Prompt")
2673                    .with_slot("prompt")
2674                    .with_anchor("field.prompt")
2675                    .with_order(0),
2676                NodeSurfaceSlotDescriptor::field_row("field.completion")
2677                    .with_label("Completion")
2678                    .with_slot("completion")
2679                    .with_anchor("field.completion")
2680                    .with_order(1),
2681                NodeSurfaceSlotDescriptor::metric_badge("metric.latency")
2682                    .with_label("Latency")
2683                    .with_slot("metrics.latency"),
2684                NodeSurfaceSlotDescriptor::config_group("config.model")
2685                    .with_label("Config")
2686                    .with_slot("config.model"),
2687                NodeSurfaceSlotDescriptor::status_banner("status.validation")
2688                    .with_label("Status")
2689                    .with_slot("status.validation"),
2690                NodeSurfaceSlotDescriptor::action_row("actions.primary")
2691                    .with_label("Actions")
2692                    .with_slot("actions.primary"),
2693            ],
2694            repeatable_collections: Vec::new(),
2695            actions: Vec::new(),
2696            menus: Vec::new(),
2697            inspectors: Vec::new(),
2698            blackboards: Vec::new(),
2699            chrome: Vec::new(),
2700            default_data: serde_json::Value::Null,
2701        };
2702        let node = Node {
2703            kind: NodeKindKey::new("demo.llm"),
2704            kind_version: 1,
2705            pos: CanvasPoint::default(),
2706            origin: None,
2707            selectable: None,
2708            focusable: None,
2709            draggable: None,
2710            connectable: None,
2711            deletable: None,
2712            parent: None,
2713            extent: None,
2714            expand_parent: None,
2715            size: None,
2716            hidden: false,
2717            collapsed: false,
2718            ports: Vec::new(),
2719            data: serde_json::json!({
2720                "fields": {
2721                    "prompt": "Customer intake + policy",
2722                    "completion": "Priority and route"
2723                },
2724                "metrics": { "latency": "420ms" },
2725                "config": { "model": { "temperature": 0.2 } },
2726                "status": { "validation": "Ready" },
2727                "actions": { "primary": ["Test prompt", "Open trace"] }
2728            }),
2729        };
2730        let input = NodeRenderInput {
2731            id: NodeId::from_u128(3),
2732            node: &node,
2733            descriptor: &descriptor,
2734            state: NodeRendererState::default(),
2735            style: NodeRendererStyle::decision(),
2736        };
2737        let layout = RendererCatalog::default().render_node(
2738            &input,
2739            CanvasRect {
2740                origin: CanvasPoint::default(),
2741                size: CanvasSize {
2742                    width: 268.0,
2743                    height: 228.0,
2744                },
2745            },
2746        );
2747
2748        let prompt = layout
2749            .interactive_regions
2750            .iter()
2751            .find(|region| region.key == "field.prompt")
2752            .expect("prompt field anchor exists");
2753        let completion = layout
2754            .interactive_regions
2755            .iter()
2756            .find(|region| region.key == "field.completion")
2757            .expect("completion field anchor exists");
2758        let status = layout
2759            .interactive_regions
2760            .iter()
2761            .find(|region| region.key == "status.validation")
2762            .expect("status region exists");
2763        let last_action = layout
2764            .interactive_regions
2765            .iter()
2766            .filter(|region| region.slot_kind == Some(NodeSurfaceSlotKind::ActionRow))
2767            .max_by(|a, b| a.rect.origin.y.total_cmp(&b.rect.origin.y))
2768            .expect("action chip exists");
2769
2770        assert_eq!(prompt.rect.origin.y, 46.0);
2771        assert_eq!(completion.rect.origin.y - prompt.rect.origin.y, 26.0);
2772        assert!(
2773            status.rect.origin.y > completion.rect.origin.y,
2774            "decorative/status regions should not push port-bearing field anchors down"
2775        );
2776        assert!(
2777            last_action.rect.origin.y + last_action.rect.size.height <= layout.min_size.height,
2778            "layout minimum height should cover adapter-owned Dify controls"
2779        );
2780    }
2781
2782    #[test]
2783    fn shader_card_renderer_measures_port_rail_config_and_preview_without_fields() {
2784        let descriptor = NodeKindViewDescriptor {
2785            kind: NodeKindKey::new("demo.shader.mix"),
2786            renderer_key: "shader-card".to_owned(),
2787            title: "Mix".to_owned(),
2788            category: Vec::new(),
2789            keywords: Vec::new(),
2790            default_size: None,
2791            layout_budget: NodeSurfaceLayoutBudget::default(),
2792            ports: Vec::new(),
2793            surface_slots: vec![
2794                NodeSurfaceSlotDescriptor::port_rail("rail.inputs")
2795                    .with_label("Inputs")
2796                    .with_slot("ports.inputs")
2797                    .with_anchor("rail.inputs"),
2798                NodeSurfaceSlotDescriptor::config_group("config.factor")
2799                    .with_label("Factor")
2800                    .with_slot("config.factor")
2801                    .with_anchor("config.factor"),
2802                NodeSurfaceSlotDescriptor::preview("preview.result")
2803                    .with_label("Preview")
2804                    .with_slot("preview.result")
2805                    .with_anchor("preview.result"),
2806                NodeSurfaceSlotDescriptor::port_rail("rail.outputs")
2807                    .with_label("Outputs")
2808                    .with_slot("ports.outputs")
2809                    .with_anchor("rail.outputs"),
2810            ],
2811            repeatable_collections: Vec::new(),
2812            actions: Vec::new(),
2813            menus: Vec::new(),
2814            inspectors: Vec::new(),
2815            blackboards: Vec::new(),
2816            chrome: Vec::new(),
2817            default_data: serde_json::Value::Null,
2818        };
2819        let node = Node {
2820            kind: NodeKindKey::new("demo.shader.mix"),
2821            kind_version: 1,
2822            pos: CanvasPoint::default(),
2823            origin: None,
2824            selectable: None,
2825            focusable: None,
2826            draggable: None,
2827            connectable: None,
2828            deletable: None,
2829            parent: None,
2830            extent: None,
2831            expand_parent: None,
2832            size: None,
2833            hidden: false,
2834            collapsed: false,
2835            ports: Vec::new(),
2836            data: serde_json::json!({
2837                "ports": {
2838                    "inputs": ["vec4 albedo", "float factor"],
2839                    "outputs": ["vec4 result"]
2840                },
2841                "config": {
2842                    "factor": { "type": "float", "default": 0.5 }
2843                },
2844                "preview": {
2845                    "result": "gradient"
2846                }
2847            }),
2848        };
2849        let input = NodeRenderInput {
2850            id: NodeId::from_u128(4),
2851            node: &node,
2852            descriptor: &descriptor,
2853            state: NodeRendererState::default(),
2854            style: NodeRendererStyle::data(),
2855        };
2856        let layout = RendererCatalog::default().render_node(
2857            &input,
2858            CanvasRect {
2859                origin: CanvasPoint::default(),
2860                size: CanvasSize {
2861                    width: 236.0,
2862                    height: 190.0,
2863                },
2864            },
2865        );
2866
2867        let keys = layout
2868            .interactive_regions
2869            .iter()
2870            .map(|region| region.key.as_str())
2871            .collect::<Vec<_>>();
2872        assert!(keys.contains(&"rail.inputs"));
2873        assert!(keys.contains(&"config.factor"));
2874        assert!(keys.contains(&"preview.result"));
2875        assert!(keys.contains(&"rail.outputs"));
2876        assert!(
2877            layout
2878                .interactive_regions
2879                .iter()
2880                .any(|region| region.slot_kind == Some(NodeSurfaceSlotKind::PortRail))
2881        );
2882        assert!(
2883            layout
2884                .interactive_regions
2885                .iter()
2886                .any(|region| region.slot_kind == Some(NodeSurfaceSlotKind::Preview))
2887        );
2888    }
2889}