Skip to main content

jellyflow_runtime/runtime/
chrome.rs

1//! Renderer-neutral node chrome placement helpers.
2//!
3//! Runtime owns the semantic facts that make adapter chrome portable: which affordances exist,
4//! when they are visible, their node-relative placement, and the resize constraints associated
5//! with resize affordances. Adapters still own the concrete widgets, focus handling, popovers, and
6//! visual styling.
7
8use serde::{Deserialize, Serialize};
9
10use crate::runtime::resize::NodeResizeConstraints;
11use crate::schema::{
12    NodeChromeDescriptor, NodeChromeKind, NodeChromePlacement, NodeChromeVisibility,
13};
14use jellyflow_core::core::{CanvasPoint, CanvasRect, CanvasSize, NodeId};
15
16const DEFAULT_CONTROL_SIZE: f32 = 16.0;
17const DEFAULT_BAR_HEIGHT: f32 = 28.0;
18const DEFAULT_MARGIN: f32 = 8.0;
19const MIN_ZOOM_SCALE: f32 = 0.25;
20
21fn default_zoom() -> f32 {
22    1.0
23}
24
25fn is_false(value: &bool) -> bool {
26    !*value
27}
28
29/// Adapter-facing UI state used to resolve node chrome visibility.
30#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub struct NodeChromeState {
32    #[serde(default, skip_serializing_if = "is_false")]
33    pub selected: bool,
34    #[serde(default, skip_serializing_if = "is_false")]
35    pub hovered: bool,
36    #[serde(default, skip_serializing_if = "is_false")]
37    pub focused: bool,
38}
39
40impl NodeChromeState {
41    pub fn selected() -> Self {
42        Self {
43            selected: true,
44            hovered: false,
45            focused: false,
46        }
47    }
48
49    pub fn hovered() -> Self {
50        Self {
51            selected: false,
52            hovered: true,
53            focused: false,
54        }
55    }
56
57    pub fn focused() -> Self {
58        Self {
59            selected: false,
60            hovered: false,
61            focused: true,
62        }
63    }
64
65    pub fn is_visible(self, descriptor: &NodeChromeDescriptor) -> bool {
66        descriptor.is_visible_for_state(self.selected, self.hovered, self.focused)
67    }
68}
69
70/// View-independent sizing policy for semantic chrome placement.
71#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
72pub struct NodeChromeLayoutPolicy {
73    pub control_size: f32,
74    pub bar_height: f32,
75    pub margin: f32,
76    #[serde(default = "default_zoom")]
77    pub zoom: f32,
78}
79
80impl Default for NodeChromeLayoutPolicy {
81    fn default() -> Self {
82        Self {
83            control_size: DEFAULT_CONTROL_SIZE,
84            bar_height: DEFAULT_BAR_HEIGHT,
85            margin: DEFAULT_MARGIN,
86            zoom: 1.0,
87        }
88    }
89}
90
91impl NodeChromeLayoutPolicy {
92    pub fn with_control_size(mut self, control_size: f32) -> Self {
93        self.control_size = control_size;
94        self
95    }
96
97    pub fn with_bar_height(mut self, bar_height: f32) -> Self {
98        self.bar_height = bar_height;
99        self
100    }
101
102    pub fn with_margin(mut self, margin: f32) -> Self {
103        self.margin = margin;
104        self
105    }
106
107    pub fn with_zoom(mut self, zoom: f32) -> Self {
108        self.zoom = zoom;
109        self
110    }
111
112    fn scale(self) -> f32 {
113        if self.zoom.is_finite() && self.zoom > 0.0 {
114            (1.0 / self.zoom).max(MIN_ZOOM_SCALE)
115        } else {
116            1.0
117        }
118    }
119
120    fn normalized(self) -> Self {
121        let scale = self.scale();
122        Self {
123            control_size: positive_or_default(self.control_size, DEFAULT_CONTROL_SIZE) * scale,
124            bar_height: positive_or_default(self.bar_height, DEFAULT_BAR_HEIGHT) * scale,
125            margin: positive_or_default(self.margin, DEFAULT_MARGIN) * scale,
126            zoom: self.zoom,
127        }
128    }
129}
130
131/// Headless request to resolve adapter-owned chrome around one node.
132#[derive(Debug, Clone, PartialEq)]
133pub struct NodeChromeFactsRequest<'a> {
134    pub node: NodeId,
135    pub node_rect: CanvasRect,
136    pub descriptors: &'a [NodeChromeDescriptor],
137    pub state: NodeChromeState,
138    pub policy: NodeChromeLayoutPolicy,
139    pub resize_constraints: NodeResizeConstraints,
140}
141
142impl<'a> NodeChromeFactsRequest<'a> {
143    pub fn new(
144        node: NodeId,
145        node_rect: CanvasRect,
146        descriptors: &'a [NodeChromeDescriptor],
147    ) -> Self {
148        Self {
149            node,
150            node_rect,
151            descriptors,
152            state: NodeChromeState::default(),
153            policy: NodeChromeLayoutPolicy::default(),
154            resize_constraints: NodeResizeConstraints::default(),
155        }
156    }
157
158    pub fn with_state(mut self, state: NodeChromeState) -> Self {
159        self.state = state;
160        self
161    }
162
163    pub fn with_policy(mut self, policy: NodeChromeLayoutPolicy) -> Self {
164        self.policy = policy;
165        self
166    }
167
168    pub fn with_resize_constraints(mut self, constraints: NodeResizeConstraints) -> Self {
169        self.resize_constraints = constraints;
170        self
171    }
172}
173
174/// Resolved adapter-owned chrome facts for one node.
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176pub struct NodeChromeFacts {
177    pub node: NodeId,
178    pub chrome: Vec<ResolvedNodeChrome>,
179}
180
181impl NodeChromeFacts {
182    pub fn is_empty(&self) -> bool {
183        self.chrome.is_empty()
184    }
185
186    pub fn get(&self, key: impl AsRef<str>) -> Option<&ResolvedNodeChrome> {
187        let key = key.as_ref();
188        self.chrome.iter().find(|chrome| chrome.key == key)
189    }
190}
191
192/// One visible semantic chrome affordance resolved in canvas space against node bounds.
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194pub struct ResolvedNodeChrome {
195    pub key: String,
196    pub kind: NodeChromeKind,
197    pub placement: NodeChromePlacement,
198    pub rect: CanvasRect,
199    pub visibility: NodeChromeVisibility,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub label: Option<String>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub renderer_key: Option<String>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub icon_key: Option<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub order: Option<i32>,
208    #[serde(default, skip_serializing_if = "is_false")]
209    pub interactive: bool,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub resize_constraints: Option<NodeResizeConstraints>,
212}
213
214impl ResolvedNodeChrome {
215    pub fn contains_point(&self, point: CanvasPoint) -> bool {
216        point.x >= self.rect.origin.x
217            && point.x <= self.rect.origin.x + self.rect.size.width
218            && point.y >= self.rect.origin.y
219            && point.y <= self.rect.origin.y + self.rect.size.height
220    }
221}
222
223/// Resolves semantic node chrome into canvas-space geometry facts.
224pub fn resolve_node_chrome_facts(request: NodeChromeFactsRequest<'_>) -> Option<NodeChromeFacts> {
225    if !request.node_rect.is_positive_finite() {
226        return None;
227    }
228
229    let policy = request.policy.normalized();
230    let mut chrome: Vec<_> = request
231        .descriptors
232        .iter()
233        .filter(|descriptor| request.state.is_visible(descriptor))
234        .filter_map(|descriptor| {
235            let rect = chrome_rect(
236                request.node_rect,
237                descriptor.kind,
238                descriptor.placement,
239                policy,
240            )?;
241            Some(ResolvedNodeChrome {
242                key: descriptor.key.clone(),
243                kind: descriptor.kind,
244                placement: descriptor.placement,
245                rect,
246                visibility: descriptor.effective_visibility(),
247                label: descriptor.label.clone(),
248                renderer_key: descriptor.renderer_key.clone(),
249                icon_key: descriptor.icon_key.clone(),
250                order: descriptor.order,
251                interactive: descriptor.interactive,
252                resize_constraints: (descriptor.kind == NodeChromeKind::Resizer)
253                    .then_some(request.resize_constraints),
254            })
255        })
256        .collect();
257
258    chrome.sort_by(|left, right| {
259        left.order
260            .unwrap_or(0)
261            .cmp(&right.order.unwrap_or(0))
262            .then_with(|| left.key.cmp(&right.key))
263    });
264
265    Some(NodeChromeFacts {
266        node: request.node,
267        chrome,
268    })
269}
270
271fn chrome_rect(
272    node_rect: CanvasRect,
273    kind: NodeChromeKind,
274    placement: NodeChromePlacement,
275    policy: NodeChromeLayoutPolicy,
276) -> Option<CanvasRect> {
277    let size = chrome_size(node_rect, kind, placement, policy)?;
278    let margin = policy.margin;
279    let left = node_rect.origin.x;
280    let top = node_rect.origin.y;
281    let right = left + node_rect.size.width;
282    let bottom = top + node_rect.size.height;
283
284    let origin = match placement {
285        NodeChromePlacement::Top => CanvasPoint {
286            x: left + (node_rect.size.width - size.width) / 2.0,
287            y: top - margin - size.height,
288        },
289        NodeChromePlacement::TopRight => CanvasPoint {
290            x: right - size.width,
291            y: top - margin - size.height,
292        },
293        NodeChromePlacement::Right => CanvasPoint {
294            x: right + margin,
295            y: top + (node_rect.size.height - size.height) / 2.0,
296        },
297        NodeChromePlacement::BottomRight => CanvasPoint {
298            x: right - size.width,
299            y: bottom + margin,
300        },
301        NodeChromePlacement::Bottom => CanvasPoint {
302            x: left + (node_rect.size.width - size.width) / 2.0,
303            y: bottom + margin,
304        },
305        NodeChromePlacement::BottomLeft => CanvasPoint {
306            x: left,
307            y: bottom + margin,
308        },
309        NodeChromePlacement::Left => CanvasPoint {
310            x: left - margin - size.width,
311            y: top + (node_rect.size.height - size.height) / 2.0,
312        },
313        NodeChromePlacement::TopLeft => CanvasPoint {
314            x: left,
315            y: top - margin - size.height,
316        },
317        NodeChromePlacement::InsideHeader => CanvasPoint {
318            x: left + margin,
319            y: top + margin,
320        },
321        NodeChromePlacement::InsideFooter => CanvasPoint {
322            x: left + margin,
323            y: bottom - margin - size.height,
324        },
325    };
326
327    let rect = CanvasRect { origin, size };
328    rect.is_positive_finite().then_some(rect)
329}
330
331fn chrome_size(
332    node_rect: CanvasRect,
333    kind: NodeChromeKind,
334    placement: NodeChromePlacement,
335    policy: NodeChromeLayoutPolicy,
336) -> Option<CanvasSize> {
337    let inline_width = (node_rect.size.width - policy.margin * 2.0).max(policy.control_size);
338    let size = match kind {
339        NodeChromeKind::Resizer | NodeChromeKind::InspectorAnchor => CanvasSize {
340            width: policy.control_size,
341            height: policy.control_size,
342        },
343        NodeChromeKind::Toolbar => {
344            let width = match placement {
345                NodeChromePlacement::Left | NodeChromePlacement::Right => policy.control_size,
346                _ => node_rect
347                    .size
348                    .width
349                    .min(160.0 * policy.scale())
350                    .max(64.0 * policy.scale()),
351            };
352            CanvasSize {
353                width,
354                height: policy.bar_height,
355            }
356        }
357        NodeChromeKind::StatusStrip
358        | NodeChromeKind::ValidationBanner
359        | NodeChromeKind::RunActionStrip => CanvasSize {
360            width: inline_width,
361            height: policy.bar_height,
362        },
363    };
364    size.is_positive_finite().then_some(size)
365}
366
367fn positive_or_default(value: f32, default: f32) -> f32 {
368    if value.is_finite() && value > 0.0 {
369        value
370    } else {
371        default
372    }
373}