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#[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#[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#[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#[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#[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 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#[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#[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
272pub trait RichNodeRenderer: Send + Sync {
274 fn render(&self, input: &NodeRenderInput<'_>, rect: CanvasRect) -> NodeRenderLayout;
275}
276
277pub trait EguiNodeWidgetRenderer: Send + Sync {
279 fn render_widgets(
280 &self,
281 ui: &mut Ui,
282 input: &NodeWidgetRenderInput<'_>,
283 ) -> EguiNodeWidgetRenderOutcome;
284}
285
286#[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#[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#[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
381pub 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}