1use crate::id::NodeId;
9use petgraph::graph::{DiGraph, NodeIndex};
10use serde::{Deserialize, Serialize};
11use smallvec::SmallVec;
12use std::collections::HashMap;
13
14#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
18pub struct Color {
19 pub r: f32,
20 pub g: f32,
21 pub b: f32,
22 pub a: f32,
23}
24
25impl Color {
26 pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
27 Self { r, g, b, a }
28 }
29
30 pub fn from_hex(hex: &str) -> Option<Self> {
32 let hex = hex.strip_prefix('#')?;
33 match hex.len() {
34 3 => {
35 let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
36 let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
37 let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
38 Some(Self::rgba(
39 r as f32 / 255.0,
40 g as f32 / 255.0,
41 b as f32 / 255.0,
42 1.0,
43 ))
44 }
45 4 => {
46 let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
47 let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
48 let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
49 let a = u8::from_str_radix(&hex[3..4].repeat(2), 16).ok()?;
50 Some(Self::rgba(
51 r as f32 / 255.0,
52 g as f32 / 255.0,
53 b as f32 / 255.0,
54 a as f32 / 255.0,
55 ))
56 }
57 6 => {
58 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
59 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
60 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
61 Some(Self::rgba(
62 r as f32 / 255.0,
63 g as f32 / 255.0,
64 b as f32 / 255.0,
65 1.0,
66 ))
67 }
68 8 => {
69 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
70 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
71 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
72 let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
73 Some(Self::rgba(
74 r as f32 / 255.0,
75 g as f32 / 255.0,
76 b as f32 / 255.0,
77 a as f32 / 255.0,
78 ))
79 }
80 _ => None,
81 }
82 }
83
84 pub fn to_hex(&self) -> String {
86 let r = (self.r * 255.0).round() as u8;
87 let g = (self.g * 255.0).round() as u8;
88 let b = (self.b * 255.0).round() as u8;
89 let a = (self.a * 255.0).round() as u8;
90 if a == 255 {
91 format!("#{r:02X}{g:02X}{b:02X}")
92 } else {
93 format!("#{r:02X}{g:02X}{b:02X}{a:02X}")
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct GradientStop {
101 pub offset: f32, pub color: Color,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub enum Paint {
108 Solid(Color),
109 LinearGradient {
110 angle: f32, stops: Vec<GradientStop>,
112 },
113 RadialGradient {
114 stops: Vec<GradientStop>,
115 },
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Stroke {
122 pub paint: Paint,
123 pub width: f32,
124 pub cap: StrokeCap,
125 pub join: StrokeJoin,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129pub enum StrokeCap {
130 Butt,
131 Round,
132 Square,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136pub enum StrokeJoin {
137 Miter,
138 Round,
139 Bevel,
140}
141
142impl Default for Stroke {
143 fn default() -> Self {
144 Self {
145 paint: Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0)),
146 width: 1.0,
147 cap: StrokeCap::Butt,
148 join: StrokeJoin::Miter,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct FontSpec {
157 pub family: String,
158 pub weight: u16, pub size: f32,
160}
161
162impl Default for FontSpec {
163 fn default() -> Self {
164 Self {
165 family: "Inter".into(),
166 weight: 400,
167 size: 14.0,
168 }
169 }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
176pub enum PathCmd {
177 MoveTo(f32, f32),
178 LineTo(f32, f32),
179 QuadTo(f32, f32, f32, f32), CubicTo(f32, f32, f32, f32, f32, f32), Close,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct Shadow {
188 pub offset_x: f32,
189 pub offset_y: f32,
190 pub blur: f32,
191 pub color: Color,
192}
193
194#[derive(Debug, Clone, Default, Serialize, Deserialize)]
198pub struct Style {
199 pub fill: Option<Paint>,
200 pub stroke: Option<Stroke>,
201 pub font: Option<FontSpec>,
202 pub corner_radius: Option<f32>,
203 pub opacity: Option<f32>,
204 pub shadow: Option<Shadow>,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub enum AnimTrigger {
212 Hover,
213 Press,
214 Enter, Custom(String),
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220pub enum Easing {
221 Linear,
222 EaseIn,
223 EaseOut,
224 EaseInOut,
225 Spring,
226 CubicBezier(f32, f32, f32, f32),
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct AnimKeyframe {
232 pub trigger: AnimTrigger,
233 pub duration_ms: u32,
234 pub easing: Easing,
235 pub properties: AnimProperties,
236}
237
238#[derive(Debug, Clone, Default, Serialize, Deserialize)]
240pub struct AnimProperties {
241 pub fill: Option<Paint>,
242 pub opacity: Option<f32>,
243 pub scale: Option<f32>,
244 pub rotate: Option<f32>, pub translate: Option<(f32, f32)>,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub enum Annotation {
254 Description(String),
256 Accept(String),
258 Status(String),
260 Priority(String),
262 Tag(String),
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
270pub enum Constraint {
271 CenterIn(NodeId),
273 Offset { from: NodeId, dx: f32, dy: f32 },
275 FillParent { pad: f32 },
277 Absolute { x: f32, y: f32 },
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
283pub enum LayoutMode {
284 Free,
286 Column { gap: f32, pad: f32 },
288 Row { gap: f32, pad: f32 },
290 Grid { cols: u32, gap: f32, pad: f32 },
292}
293
294impl Default for LayoutMode {
295 fn default() -> Self {
296 Self::Free
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
304pub enum NodeKind {
305 Root,
307
308 Group { layout: LayoutMode },
310
311 Rect { width: f32, height: f32 },
313
314 Ellipse { rx: f32, ry: f32 },
316
317 Path { commands: Vec<PathCmd> },
319
320 Text { content: String },
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct SceneNode {
327 pub id: NodeId,
329
330 pub kind: NodeKind,
332
333 pub style: Style,
335
336 pub use_styles: SmallVec<[NodeId; 2]>,
338
339 pub constraints: SmallVec<[Constraint; 2]>,
341
342 pub animations: SmallVec<[AnimKeyframe; 2]>,
344
345 pub annotations: Vec<Annotation>,
347}
348
349impl SceneNode {
350 pub fn new(id: NodeId, kind: NodeKind) -> Self {
351 Self {
352 id,
353 kind,
354 style: Style::default(),
355 use_styles: SmallVec::new(),
356 constraints: SmallVec::new(),
357 animations: SmallVec::new(),
358 annotations: Vec::new(),
359 }
360 }
361}
362
363#[derive(Debug, Clone)]
370pub struct SceneGraph {
371 pub graph: DiGraph<SceneNode, ()>,
373
374 pub root: NodeIndex,
376
377 pub styles: HashMap<NodeId, Style>,
379
380 pub id_index: HashMap<NodeId, NodeIndex>,
382}
383
384impl SceneGraph {
385 #[must_use]
387 pub fn new() -> Self {
388 let mut graph = DiGraph::new();
389 let root_node = SceneNode::new(NodeId::intern("root"), NodeKind::Root);
390 let root = graph.add_node(root_node);
391
392 let mut id_index = HashMap::new();
393 id_index.insert(NodeId::intern("root"), root);
394
395 Self {
396 graph,
397 root,
398 styles: HashMap::new(),
399 id_index,
400 }
401 }
402
403 pub fn add_node(&mut self, parent: NodeIndex, node: SceneNode) -> NodeIndex {
405 let id = node.id;
406 let idx = self.graph.add_node(node);
407 self.graph.add_edge(parent, idx, ());
408 self.id_index.insert(id, idx);
409 idx
410 }
411
412 pub fn get_by_id(&self, id: NodeId) -> Option<&SceneNode> {
414 self.id_index.get(&id).map(|idx| &self.graph[*idx])
415 }
416
417 pub fn get_by_id_mut(&mut self, id: NodeId) -> Option<&mut SceneNode> {
419 self.id_index
420 .get(&id)
421 .copied()
422 .map(|idx| &mut self.graph[idx])
423 }
424
425 pub fn index_of(&self, id: NodeId) -> Option<NodeIndex> {
427 self.id_index.get(&id).copied()
428 }
429
430 pub fn children(&self, idx: NodeIndex) -> Vec<NodeIndex> {
432 self.graph
433 .neighbors_directed(idx, petgraph::Direction::Outgoing)
434 .collect()
435 }
436
437 pub fn define_style(&mut self, name: NodeId, style: Style) {
439 self.styles.insert(name, style);
440 }
441
442 pub fn resolve_style(&self, node: &SceneNode) -> Style {
444 let mut resolved = Style::default();
445
446 for style_id in &node.use_styles {
448 if let Some(base) = self.styles.get(style_id) {
449 merge_style(&mut resolved, base);
450 }
451 }
452
453 merge_style(&mut resolved, &node.style);
455
456 resolved
457 }
458
459 pub fn rebuild_index(&mut self) {
461 self.id_index.clear();
462 for idx in self.graph.node_indices() {
463 let id = self.graph[idx].id;
464 self.id_index.insert(id, idx);
465 }
466 }
467}
468
469impl Default for SceneGraph {
470 fn default() -> Self {
471 Self::new()
472 }
473}
474
475fn merge_style(dst: &mut Style, src: &Style) {
477 if src.fill.is_some() {
478 dst.fill = src.fill.clone();
479 }
480 if src.stroke.is_some() {
481 dst.stroke = src.stroke.clone();
482 }
483 if src.font.is_some() {
484 dst.font = src.font.clone();
485 }
486 if src.corner_radius.is_some() {
487 dst.corner_radius = src.corner_radius;
488 }
489 if src.opacity.is_some() {
490 dst.opacity = src.opacity;
491 }
492 if src.shadow.is_some() {
493 dst.shadow = src.shadow.clone();
494 }
495}
496
497#[derive(Debug, Clone, Copy, Default)]
501pub struct ResolvedBounds {
502 pub x: f32,
503 pub y: f32,
504 pub width: f32,
505 pub height: f32,
506}
507
508impl ResolvedBounds {
509 pub fn contains(&self, px: f32, py: f32) -> bool {
510 px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
511 }
512
513 pub fn center(&self) -> (f32, f32) {
514 (self.x + self.width / 2.0, self.y + self.height / 2.0)
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn scene_graph_basics() {
524 let mut sg = SceneGraph::new();
525 let rect = SceneNode::new(
526 NodeId::intern("box1"),
527 NodeKind::Rect {
528 width: 100.0,
529 height: 50.0,
530 },
531 );
532 let idx = sg.add_node(sg.root, rect);
533
534 assert!(sg.get_by_id(NodeId::intern("box1")).is_some());
535 assert_eq!(sg.children(sg.root).len(), 1);
536 assert_eq!(sg.children(sg.root)[0], idx);
537 }
538
539 #[test]
540 fn color_hex_roundtrip() {
541 let c = Color::from_hex("#6C5CE7").unwrap();
542 assert_eq!(c.to_hex(), "#6C5CE7");
543
544 let c2 = Color::from_hex("#FF000080").unwrap();
545 assert!((c2.a - 128.0 / 255.0).abs() < 0.01);
546 assert!(c2.to_hex().len() == 9); }
548
549 #[test]
550 fn style_merging() {
551 let mut sg = SceneGraph::new();
552 sg.define_style(
553 NodeId::intern("base"),
554 Style {
555 fill: Some(Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0))),
556 font: Some(FontSpec {
557 family: "Inter".into(),
558 weight: 400,
559 size: 14.0,
560 }),
561 ..Default::default()
562 },
563 );
564
565 let mut node = SceneNode::new(
566 NodeId::intern("txt"),
567 NodeKind::Text {
568 content: "hi".into(),
569 },
570 );
571 node.use_styles.push(NodeId::intern("base"));
572 node.style.font = Some(FontSpec {
573 family: "Inter".into(),
574 weight: 700,
575 size: 24.0,
576 });
577
578 let resolved = sg.resolve_style(&node);
579 assert!(resolved.fill.is_some());
581 let f = resolved.font.unwrap();
583 assert_eq!(f.weight, 700);
584 assert_eq!(f.size, 24.0);
585 }
586}