1use std::collections::HashMap;
7
8use fret_core::scene::DashPatternV1;
9use fret_core::window::ColorScheme;
10use fret_core::{Color, Px};
11use fret_ui::ThemeSnapshot;
12use serde::Deserialize;
13
14pub fn parse_node_graph_theme_presets_v1(
20 raw: &str,
21) -> Result<NodeGraphThemePresetsV1, serde_json::Error> {
22 serde_json::from_str(raw)
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum NodeGraphPresetFamily {
27 WorkflowClean,
28 SchematicContrast,
29 GraphDark,
30}
31
32impl NodeGraphPresetFamily {
33 pub fn all() -> [Self; 3] {
34 [
35 Self::WorkflowClean,
36 Self::SchematicContrast,
37 Self::GraphDark,
38 ]
39 }
40
41 pub fn display_name(self) -> &'static str {
42 match self {
43 Self::WorkflowClean => "WorkflowClean",
44 Self::SchematicContrast => "SchematicContrast",
45 Self::GraphDark => "GraphDark",
46 }
47 }
48
49 pub fn preset_id(self) -> &'static str {
50 match self {
51 Self::WorkflowClean => "workflow_clean",
52 Self::SchematicContrast => "schematic_contrast",
53 Self::GraphDark => "graph_dark",
54 }
55 }
56}
57
58pub fn theme_derived_presets(theme: &ThemeSnapshot) -> NodeGraphThemePresetsV1 {
63 NodeGraphThemePresetsV1 {
64 schema_version: "node_graph_theme_presets.v1".to_string(),
65 notes: "derived from ThemeSnapshot (with opt-out for GraphDark on light themes)"
66 .to_string(),
67 presets: vec![
68 theme_derived_preset(theme, NodeGraphPresetFamily::WorkflowClean),
69 theme_derived_preset(theme, NodeGraphPresetFamily::SchematicContrast),
70 theme_derived_preset(theme, NodeGraphPresetFamily::GraphDark),
71 ],
72 }
73}
74
75fn theme_derived_preset(
76 theme: &ThemeSnapshot,
77 family: NodeGraphPresetFamily,
78) -> NodeGraphThemePresetV1 {
79 fn alpha(mut c: Color, a: f32) -> Color {
80 c.a = a;
81 c
82 }
83
84 fn mix(a: Color, b: Color, t: f32) -> Color {
85 let t = t.clamp(0.0, 1.0);
86 Color {
87 r: a.r + (b.r - a.r) * t,
88 g: a.g + (b.g - a.g) * t,
89 b: a.b + (b.b - a.b) * t,
90 a: a.a + (b.a - a.a) * t,
91 }
92 }
93
94 fn tint(base: Color, accent: Color, amount: f32) -> Color {
95 let mut out = mix(base, accent, amount);
96 out.a = 1.0;
97 out
98 }
99
100 let scheme_is_dark = theme.color_scheme == Some(ColorScheme::Dark);
101
102 let background = theme.color_token("background");
103 let foreground = theme.color_token("foreground");
104 let border = theme.color_token("border");
105 let ring = theme.color_token("ring");
106 let card = theme.color_token("card");
107 let card_foreground = theme.color_token("card-foreground");
108 let muted_foreground = theme.color_token("muted-foreground");
109 let accent = theme.color_token("accent");
110 let primary = theme.color_token("primary");
111 let destructive = theme.color_token("destructive");
112
113 let chart_1 = theme.color_token("chart-1");
114 let chart_2 = theme.color_token("chart-2");
115 let chart_3 = theme.color_token("chart-3");
116 let chart_4 = theme.color_token("chart-4");
117 let chart_5 = theme.color_token("chart-5");
118
119 let kind_colors = [
120 ("source", chart_1),
121 ("compute", chart_2),
122 ("condition", chart_3),
123 ("output", chart_4),
124 ("utility", chart_5),
125 ("preview", destructive),
126 ];
127
128 let (canvas_bg, grid_minor, grid_major, node_bg, node_border, node_border_selected, title_text) =
129 match family {
130 NodeGraphPresetFamily::WorkflowClean => (
131 background,
132 alpha(border, 0.50),
133 alpha(border, 0.80),
134 card,
135 alpha(border, 1.0),
136 alpha(ring, 1.0),
137 card_foreground,
138 ),
139 NodeGraphPresetFamily::SchematicContrast => (
140 background,
141 alpha(border, 0.90),
142 alpha(border, 1.0),
143 card,
144 alpha(foreground, 1.0),
145 alpha(foreground, 1.0),
146 theme.color_token("primary-foreground"),
147 ),
148 NodeGraphPresetFamily::GraphDark => {
149 if scheme_is_dark {
150 (
151 background,
152 alpha(border, 0.35),
153 alpha(border, 0.70),
154 tint(card, border, 0.20),
155 alpha(border, 1.0),
156 alpha(ring, 1.0),
157 card_foreground,
158 )
159 } else {
160 (
163 background,
164 alpha(border, 0.50),
165 alpha(border, 0.80),
166 card,
167 alpha(border, 1.0),
168 alpha(ring, 1.0),
169 card_foreground,
170 )
171 }
172 }
173 };
174
175 let header_default = match family {
176 NodeGraphPresetFamily::WorkflowClean => tint(card, border, 0.10),
177 NodeGraphPresetFamily::SchematicContrast => alpha(theme.color_token("secondary"), 1.0),
178 NodeGraphPresetFamily::GraphDark => tint(node_bg, border, 0.20),
179 };
180
181 let mut header_by_kind: HashMap<String, RgbaV1> = HashMap::new();
182 for (k, c) in kind_colors {
183 let header = match family {
184 NodeGraphPresetFamily::WorkflowClean => tint(card, c, 0.22),
185 NodeGraphPresetFamily::SchematicContrast => alpha(c, 1.0),
186 NodeGraphPresetFamily::GraphDark => {
187 if scheme_is_dark {
188 tint(node_bg, c, 0.35)
189 } else {
190 tint(card, c, 0.22)
191 }
192 }
193 };
194 header_by_kind.insert(k.to_string(), header.into());
195 }
196
197 let (ring_sel, ring_focus) = match family {
198 NodeGraphPresetFamily::WorkflowClean => (
199 NodeRingTokensV1 {
200 color: alpha(primary, 0.40).into(),
201 width_px: 3.0,
202 pad_px: 2.0,
203 },
204 NodeRingTokensV1 {
205 color: alpha(primary, 0.60).into(),
206 width_px: 2.0,
207 pad_px: 1.0,
208 },
209 ),
210 NodeGraphPresetFamily::SchematicContrast => (
211 NodeRingTokensV1 {
212 color: alpha(theme.color_token("chart-4"), 1.0).into(),
213 width_px: 4.0,
214 pad_px: 0.0,
215 },
216 NodeRingTokensV1 {
217 color: alpha(theme.color_token("chart-5"), 1.0).into(),
218 width_px: 4.0,
219 pad_px: 0.0,
220 },
221 ),
222 NodeGraphPresetFamily::GraphDark => (
223 NodeRingTokensV1 {
224 color: alpha(ring, 1.0).into(),
225 width_px: 3.0,
226 pad_px: 2.0,
227 },
228 NodeRingTokensV1 {
229 color: alpha(accent, 1.0).into(),
230 width_px: 3.0,
231 pad_px: 2.0,
232 },
233 ),
234 };
235
236 let (hover, invalid, convertible) = match family {
237 NodeGraphPresetFamily::WorkflowClean => (
238 alpha(theme.color_token("chart-1"), 1.0),
239 alpha(destructive, 1.0),
240 alpha(theme.color_token("chart-1"), 1.0),
241 ),
242 NodeGraphPresetFamily::SchematicContrast => (
243 alpha(foreground, 1.0),
244 alpha(destructive, 1.0),
245 alpha(theme.color_token("chart-1"), 1.0),
246 ),
247 NodeGraphPresetFamily::GraphDark => (
248 alpha(theme.color_token("chart-1"), 1.0),
249 alpha(destructive, 1.0),
250 alpha(theme.color_token("chart-1"), 1.0),
251 ),
252 };
253
254 let port_data = match family {
255 NodeGraphPresetFamily::WorkflowClean => PortTokensV1 {
256 fill: alpha(muted_foreground, 0.85).into(),
257 stroke: alpha(muted_foreground, 1.0).into(),
258 stroke_width_px: 1.0,
259 inner_scale: 1.0,
260 shape: PortShapeKindV1::Circle,
261 },
262 NodeGraphPresetFamily::SchematicContrast => PortTokensV1 {
263 fill: alpha(theme.color_token("chart-1"), 1.0).into(),
264 stroke: alpha(foreground, 1.0).into(),
265 stroke_width_px: 2.0,
266 inner_scale: 1.0,
267 shape: PortShapeKindV1::Circle,
268 },
269 NodeGraphPresetFamily::GraphDark => PortTokensV1 {
270 fill: alpha(theme.color_token("chart-2"), 1.0).into(),
271 stroke: alpha(theme.color_token("chart-2"), 1.0).into(),
272 stroke_width_px: 1.5,
273 inner_scale: 1.0,
274 shape: PortShapeKindV1::Circle,
275 },
276 };
277
278 let port_exec = match family {
279 NodeGraphPresetFamily::WorkflowClean => PortTokensV1 {
280 fill: alpha(card, 1.0).into(),
281 stroke: alpha(foreground, 0.85).into(),
282 stroke_width_px: 1.5,
283 inner_scale: 0.0,
284 shape: PortShapeKindV1::Circle,
285 },
286 NodeGraphPresetFamily::SchematicContrast => PortTokensV1 {
287 fill: alpha(theme.color_token("chart-3"), 1.0).into(),
288 stroke: alpha(foreground, 1.0).into(),
289 stroke_width_px: 2.0,
290 inner_scale: 0.0,
291 shape: PortShapeKindV1::Circle,
292 },
293 NodeGraphPresetFamily::GraphDark => PortTokensV1 {
294 fill: alpha(destructive, 0.65).into(),
295 stroke: alpha(destructive, 1.0).into(),
296 stroke_width_px: 1.5,
297 inner_scale: 0.0,
298 shape: PortShapeKindV1::Circle,
299 },
300 };
301
302 let port_preview = PortTokensV1 {
303 fill: header_by_kind
304 .get("preview")
305 .copied()
306 .unwrap_or_else(|| alpha(destructive, 1.0).into()),
307 stroke: alpha(destructive, 1.0).into(),
308 stroke_width_px: 1.0,
309 inner_scale: 0.5,
310 shape: PortShapeKindV1::Circle,
311 };
312
313 let (wire_data, wire_exec, wire_preview) = match family {
314 NodeGraphPresetFamily::WorkflowClean => (
315 alpha(muted_foreground, 1.0),
316 alpha(theme.color_token("secondary-foreground"), 1.0),
317 alpha(border, 1.0),
318 ),
319 NodeGraphPresetFamily::SchematicContrast => (
320 alpha(theme.color_token("chart-1"), 1.0),
321 alpha(theme.color_token("chart-3"), 1.0),
322 alpha(theme.color_token("chart-4"), 1.0),
323 ),
324 NodeGraphPresetFamily::GraphDark => (
325 alpha(theme.color_token("chart-2"), 1.0),
326 alpha(destructive, 1.0),
327 alpha(theme.color_token("chart-4"), 1.0),
328 ),
329 };
330
331 let (highlight_sel, highlight_hov) = match family {
332 NodeGraphPresetFamily::WorkflowClean => (
333 WireHighlightTokensV1 {
334 width_mul: 0.65,
335 alpha_mul: 0.80,
336 color: None,
337 },
338 WireHighlightTokensV1 {
339 width_mul: 0.70,
340 alpha_mul: 0.95,
341 color: None,
342 },
343 ),
344 NodeGraphPresetFamily::SchematicContrast => (
345 WireHighlightTokensV1 {
346 width_mul: 0.70,
347 alpha_mul: 0.90,
348 color: None,
349 },
350 WireHighlightTokensV1 {
351 width_mul: 0.75,
352 alpha_mul: 1.0,
353 color: None,
354 },
355 ),
356 NodeGraphPresetFamily::GraphDark => (
357 WireHighlightTokensV1 {
358 width_mul: 0.65,
359 alpha_mul: 0.85,
360 color: Some(alpha(convertible, 1.0).into()),
361 },
362 WireHighlightTokensV1 {
363 width_mul: 0.70,
364 alpha_mul: 0.95,
365 color: Some(alpha(hover, 1.0).into()),
366 },
367 ),
368 };
369
370 NodeGraphThemePresetV1 {
371 id: family.preset_id().to_string(),
372 display_name: family.display_name().to_string(),
373 intent: match family {
374 NodeGraphPresetFamily::WorkflowClean => "theme-derived, clean, minimal".to_string(),
375 NodeGraphPresetFamily::SchematicContrast => "theme-derived, high contrast".to_string(),
376 NodeGraphPresetFamily::GraphDark => {
377 if scheme_is_dark {
378 "theme-derived, dark with neon accents".to_string()
379 } else {
380 "theme-derived, dark family (opted-out on light theme)".to_string()
381 }
382 }
383 },
384 paint_only_tokens: PaintOnlyTokensV1 {
385 canvas: CanvasTokensV1 {
386 background: canvas_bg.into(),
387 },
388 grid: GridTokensV1 {
389 minor_color: grid_minor.into(),
390 major_color: grid_major.into(),
391 },
392 text: TextTokensV1 {
393 primary: title_text.into(),
394 muted: muted_foreground.into(),
395 },
396 node: NodeTokensV1 {
397 body_background: node_bg.into(),
398 border: node_border.into(),
399 border_selected: node_border_selected.into(),
400 header_background_default: header_default.into(),
401 header_by_kind,
402 title_text: title_text.into(),
403 ring_selected: ring_sel,
404 ring_focused: ring_focus,
405 },
406 port: PortThemeTokensV1 {
407 by_port_kind: PortKindTokensV1 {
408 data: port_data,
409 exec: port_exec,
410 preview: port_preview,
411 },
412 },
413 wire: WireTokensV1 {
414 data_color: wire_data.into(),
415 exec_color: wire_exec.into(),
416 preview_color: wire_preview.into(),
417 dash_preview: DashPatternTokensV1 {
418 dash_px: 4.0,
419 gap_px: 4.0,
420 phase_px: 0.0,
421 },
422 dash_invalid: DashPatternTokensV1 {
423 dash_px: 6.0,
424 gap_px: 3.0,
425 phase_px: 0.0,
426 },
427 dash_emphasis: DashPatternTokensV1 {
428 dash_px: 2.0,
429 gap_px: 2.0,
430 phase_px: 0.0,
431 },
432 highlight_selected: Some(highlight_sel),
433 highlight_hovered: Some(highlight_hov),
434 marker_exec_end: Some(EdgeMarkerTokensV1 {
435 kind: EdgeMarkerKindTokensV1::Arrow,
436 size_px: 12.0,
437 }),
438 marker_exec_start: Some(EdgeMarkerTokensV1 {
439 kind: EdgeMarkerKindTokensV1::Arrow,
440 size_px: 8.0,
441 }),
442 marker_data_end: None,
443 marker_data_start: None,
444 marker_size_mul_selected: Some(1.15),
445 marker_size_mul_hovered: Some(1.25),
446 },
447 states: StateTokensV1 {
448 hover: StateColorV1 {
449 color: hover.into(),
450 },
451 invalid: StateColorV1 {
452 color: invalid.into(),
453 },
454 convertible: StateColorV1 {
455 color: convertible.into(),
456 },
457 disabled: DisabledStateV1 { alpha_mul: 0.5 },
458 },
459 },
460 layout_tokens: None,
461 interaction_state_matrix: serde_json::Value::Null,
462 example_compositions: serde_json::Value::Null,
463 a11y_notes: serde_json::Value::Null,
464 }
465}
466
467#[derive(Debug, Clone, Copy, Deserialize)]
468pub struct RgbaV1 {
469 pub r: f32,
470 pub g: f32,
471 pub b: f32,
472 pub a: f32,
473}
474
475impl From<RgbaV1> for Color {
476 fn from(v: RgbaV1) -> Self {
477 Color {
478 r: v.r,
479 g: v.g,
480 b: v.b,
481 a: v.a,
482 }
483 }
484}
485
486impl From<Color> for RgbaV1 {
487 fn from(v: Color) -> Self {
488 RgbaV1 {
489 r: v.r,
490 g: v.g,
491 b: v.b,
492 a: v.a,
493 }
494 }
495}
496
497#[derive(Debug, Clone, Copy, Deserialize)]
498pub struct DashPatternTokensV1 {
499 pub dash_px: f32,
500 pub gap_px: f32,
501 pub phase_px: f32,
502}
503
504impl DashPatternTokensV1 {
505 pub fn into_dash(self) -> DashPatternV1 {
506 DashPatternV1::new(Px(self.dash_px), Px(self.gap_px), Px(self.phase_px))
507 }
508}
509
510#[derive(Debug, Clone, Copy, Deserialize)]
511pub struct NodeRingTokensV1 {
512 pub color: RgbaV1,
513 pub width_px: f32,
514 pub pad_px: f32,
515}
516
517#[derive(Debug, Clone, Deserialize)]
518pub struct NodeGraphThemePresetsV1 {
519 #[allow(dead_code)]
520 pub schema_version: String,
521 #[allow(dead_code)]
522 pub notes: String,
523 pub presets: Vec<NodeGraphThemePresetV1>,
524}
525
526#[derive(Debug, Clone, Deserialize)]
527pub struct NodeGraphThemePresetV1 {
528 pub id: String,
529 #[allow(dead_code)]
530 pub display_name: String,
531 #[allow(dead_code)]
532 pub intent: String,
533 pub paint_only_tokens: PaintOnlyTokensV1,
534 #[serde(default)]
535 pub layout_tokens: Option<LayoutTokensV1>,
536 #[serde(default)]
537 #[allow(dead_code)]
538 pub interaction_state_matrix: serde_json::Value,
539 #[serde(default)]
540 #[allow(dead_code)]
541 pub example_compositions: serde_json::Value,
542 #[serde(default)]
543 #[allow(dead_code)]
544 pub a11y_notes: serde_json::Value,
545}
546
547#[derive(Debug, Clone, Deserialize)]
548pub struct LayoutTokensV1 {
549 #[allow(dead_code)]
550 pub optional: bool,
551 #[serde(default)]
552 pub grid_minor_width_px: Option<f32>,
553 #[serde(default)]
554 #[allow(dead_code)]
555 pub grid_major_width_px: Option<f32>,
556 #[allow(dead_code)]
557 pub node_corner_radius_px: Option<f32>,
558 #[allow(dead_code)]
559 pub node_header_height_px: Option<f32>,
560 #[allow(dead_code)]
561 pub pin_radius_px: Option<f32>,
562 #[allow(dead_code)]
563 pub wire_width_px: Option<f32>,
564}
565
566#[derive(Debug, Clone, Deserialize)]
567pub struct PaintOnlyTokensV1 {
568 pub canvas: CanvasTokensV1,
569 pub grid: GridTokensV1,
570 #[allow(dead_code)]
571 pub text: TextTokensV1,
572 pub node: NodeTokensV1,
573 pub port: PortThemeTokensV1,
574 pub wire: WireTokensV1,
575 pub states: StateTokensV1,
576}
577
578#[derive(Debug, Clone, Copy, Deserialize)]
579pub struct CanvasTokensV1 {
580 pub background: RgbaV1,
581}
582
583#[derive(Debug, Clone, Copy, Deserialize)]
584pub struct GridTokensV1 {
585 pub minor_color: RgbaV1,
586 pub major_color: RgbaV1,
587}
588
589#[derive(Debug, Clone, Copy, Deserialize)]
590pub struct TextTokensV1 {
591 #[allow(dead_code)]
592 pub primary: RgbaV1,
593 #[allow(dead_code)]
594 pub muted: RgbaV1,
595}
596
597#[derive(Debug, Clone, Deserialize)]
598pub struct NodeTokensV1 {
599 pub body_background: RgbaV1,
600 pub border: RgbaV1,
601 pub border_selected: RgbaV1,
602 pub header_background_default: RgbaV1,
603 pub header_by_kind: HashMap<String, RgbaV1>,
604 pub title_text: RgbaV1,
605 pub ring_selected: NodeRingTokensV1,
606 pub ring_focused: NodeRingTokensV1,
607}
608
609#[derive(Debug, Clone, Deserialize)]
610pub struct PortThemeTokensV1 {
611 pub by_port_kind: PortKindTokensV1,
612}
613
614#[derive(Debug, Clone, Deserialize)]
615pub struct PortKindTokensV1 {
616 pub data: PortTokensV1,
617 pub exec: PortTokensV1,
618 #[allow(dead_code)]
619 pub preview: PortTokensV1,
620}
621
622#[derive(Debug, Clone, Copy, Deserialize)]
623pub struct PortTokensV1 {
624 pub fill: RgbaV1,
625 pub stroke: RgbaV1,
626 pub stroke_width_px: f32,
627 pub inner_scale: f32,
628 pub shape: PortShapeKindV1,
629}
630
631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
632pub enum PortShapeKindV1 {
633 Circle,
634 Diamond,
635 Triangle,
636}
637
638impl<'de> Deserialize<'de> for PortShapeKindV1 {
639 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
640 where
641 D: serde::Deserializer<'de>,
642 {
643 let s = String::deserialize(deserializer)?;
644 match s.as_str() {
645 "Circle" => Ok(PortShapeKindV1::Circle),
646 "Diamond" => Ok(PortShapeKindV1::Diamond),
647 "Triangle" => Ok(PortShapeKindV1::Triangle),
648 _ => Ok(PortShapeKindV1::Circle),
649 }
650 }
651}
652
653#[derive(Debug, Clone, Deserialize)]
654pub struct WireTokensV1 {
655 pub data_color: RgbaV1,
656 pub exec_color: RgbaV1,
657 pub preview_color: RgbaV1,
658 pub dash_preview: DashPatternTokensV1,
659 pub dash_invalid: DashPatternTokensV1,
660 pub dash_emphasis: DashPatternTokensV1,
661 #[serde(default)]
662 pub highlight_selected: Option<WireHighlightTokensV1>,
663 #[serde(default)]
664 pub highlight_hovered: Option<WireHighlightTokensV1>,
665 #[serde(default)]
666 pub marker_exec_end: Option<EdgeMarkerTokensV1>,
667 #[serde(default)]
668 pub marker_exec_start: Option<EdgeMarkerTokensV1>,
669 #[serde(default)]
670 pub marker_data_end: Option<EdgeMarkerTokensV1>,
671 #[serde(default)]
672 pub marker_data_start: Option<EdgeMarkerTokensV1>,
673 #[serde(default)]
674 pub marker_size_mul_selected: Option<f32>,
675 #[serde(default)]
676 pub marker_size_mul_hovered: Option<f32>,
677}
678
679#[derive(Debug, Clone, Copy, Deserialize)]
680pub struct EdgeMarkerTokensV1 {
681 pub kind: EdgeMarkerKindTokensV1,
682 pub size_px: f32,
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
686pub enum EdgeMarkerKindTokensV1 {
687 Arrow,
688}
689
690impl<'de> Deserialize<'de> for EdgeMarkerKindTokensV1 {
691 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
692 where
693 D: serde::Deserializer<'de>,
694 {
695 let s = String::deserialize(deserializer)?;
696 match s.as_str() {
697 "Arrow" => Ok(EdgeMarkerKindTokensV1::Arrow),
698 _ => Ok(EdgeMarkerKindTokensV1::Arrow),
699 }
700 }
701}
702
703#[derive(Debug, Clone, Copy, Deserialize)]
704pub struct WireHighlightTokensV1 {
705 pub width_mul: f32,
706 pub alpha_mul: f32,
707 #[serde(default)]
708 pub color: Option<RgbaV1>,
709}
710
711#[derive(Debug, Clone, Deserialize)]
712pub struct StateTokensV1 {
713 pub hover: StateColorV1,
714 pub invalid: StateColorV1,
715 pub convertible: StateColorV1,
716 #[allow(dead_code)]
717 pub disabled: DisabledStateV1,
718}
719
720#[derive(Debug, Clone, Copy, Deserialize)]
721pub struct StateColorV1 {
722 pub color: RgbaV1,
723}
724
725#[derive(Debug, Clone, Copy, Deserialize)]
726pub struct DisabledStateV1 {
727 #[allow(dead_code)]
728 pub alpha_mul: f32,
729}