use crate::id::NodeId;
use petgraph::graph::NodeIndex;
use petgraph::stable_graph::StableDiGraph;
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Color {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
}
const HEX_LUT: [u8; 256] = {
let mut lut = [255; 256];
let mut i = 0;
while i < 10 {
lut[(b'0' + i) as usize] = i;
i += 1;
}
let mut i = 0;
while i < 6 {
lut[(b'a' + i) as usize] = i + 10;
lut[(b'A' + i) as usize] = i + 10;
i += 1;
}
lut
};
#[inline(always)]
pub fn hex_val(c: u8) -> Option<u8> {
let val = HEX_LUT[c as usize];
if val != 255 { Some(val) } else { None }
}
impl Color {
pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
Self { r, g, b, a }
}
pub fn from_hex(hex: &str) -> Option<Self> {
let hex = hex.strip_prefix('#').unwrap_or(hex);
let bytes = hex.as_bytes();
match bytes.len() {
3 => {
let r = hex_val(bytes[0])?;
let g = hex_val(bytes[1])?;
let b = hex_val(bytes[2])?;
Some(Self::rgba(
(r * 17) as f32 / 255.0,
(g * 17) as f32 / 255.0,
(b * 17) as f32 / 255.0,
1.0,
))
}
4 => {
let r = hex_val(bytes[0])?;
let g = hex_val(bytes[1])?;
let b = hex_val(bytes[2])?;
let a = hex_val(bytes[3])?;
Some(Self::rgba(
(r * 17) as f32 / 255.0,
(g * 17) as f32 / 255.0,
(b * 17) as f32 / 255.0,
(a * 17) as f32 / 255.0,
))
}
6 => {
let r = hex_val(bytes[0])? << 4 | hex_val(bytes[1])?;
let g = hex_val(bytes[2])? << 4 | hex_val(bytes[3])?;
let b = hex_val(bytes[4])? << 4 | hex_val(bytes[5])?;
Some(Self::rgba(
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
1.0,
))
}
8 => {
let r = hex_val(bytes[0])? << 4 | hex_val(bytes[1])?;
let g = hex_val(bytes[2])? << 4 | hex_val(bytes[3])?;
let b = hex_val(bytes[4])? << 4 | hex_val(bytes[5])?;
let a = hex_val(bytes[6])? << 4 | hex_val(bytes[7])?;
Some(Self::rgba(
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
a as f32 / 255.0,
))
}
_ => None,
}
}
pub fn to_hex(&self) -> String {
let r = (self.r * 255.0).round() as u8;
let g = (self.g * 255.0).round() as u8;
let b = (self.b * 255.0).round() as u8;
let a = (self.a * 255.0).round() as u8;
if a == 255 {
format!("#{r:02X}{g:02X}{b:02X}")
} else {
format!("#{r:02X}{g:02X}{b:02X}{a:02X}")
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
pub offset: f32, pub color: Color,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Paint {
Solid(Color),
LinearGradient {
angle: f32, stops: Vec<GradientStop>,
},
RadialGradient {
stops: Vec<GradientStop>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stroke {
pub paint: Paint,
pub width: f32,
pub cap: StrokeCap,
pub join: StrokeJoin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StrokeCap {
Butt,
Round,
Square,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StrokeJoin {
Miter,
Round,
Bevel,
}
impl Default for Stroke {
fn default() -> Self {
Self {
paint: Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0)),
width: 1.0,
cap: StrokeCap::Butt,
join: StrokeJoin::Miter,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FontSpec {
pub family: String,
pub weight: u16, pub size: f32,
}
impl Default for FontSpec {
fn default() -> Self {
Self {
family: "Inter".into(),
weight: 400,
size: 14.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PathCmd {
MoveTo(f32, f32),
LineTo(f32, f32),
QuadTo(f32, f32, f32, f32), CubicTo(f32, f32, f32, f32, f32, f32), Close,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ImageSource {
File(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ImageFit {
#[default]
Cover,
Contain,
Fill,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shadow {
pub offset_x: f32,
pub offset_y: f32,
pub blur: f32,
pub color: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum TextAlign {
Left,
#[default]
Center,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum TextVAlign {
Top,
#[default]
Middle,
Bottom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum HPlace {
Left,
#[default]
Center,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum VPlace {
Top,
#[default]
Middle,
Bottom,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Properties {
pub fill: Option<Paint>,
pub stroke: Option<Stroke>,
pub font: Option<FontSpec>,
pub corner_radius: Option<f32>,
pub opacity: Option<f32>,
pub shadow: Option<Shadow>,
pub text_align: Option<TextAlign>,
pub text_valign: Option<TextVAlign>,
pub scale: Option<f32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnimTrigger {
Hover,
Press,
Enter, Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Easing {
Linear,
EaseIn,
EaseOut,
EaseInOut,
Spring,
CubicBezier(f32, f32, f32, f32),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnimKeyframe {
pub trigger: AnimTrigger,
pub duration_ms: u32,
pub easing: Easing,
pub properties: AnimProperties,
pub delay_ms: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AnimProperties {
pub fill: Option<Paint>,
pub opacity: Option<f32>,
pub scale: Option<f32>,
pub rotate: Option<f32>, pub translate: Option<(f32, f32)>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Import {
pub path: String,
pub namespace: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Constraint {
CenterIn(NodeId),
Offset { from: NodeId, dx: f32, dy: f32 },
FillParent { pad: f32 },
Position { x: f32, y: f32 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ArrowKind {
#[default]
None,
Start,
End,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum CurveKind {
#[default]
Straight,
Smooth,
Step,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum EdgeAnchor {
Node(NodeId),
Point(f32, f32),
}
impl EdgeAnchor {
pub fn node_id(&self) -> Option<NodeId> {
match self {
Self::Node(id) => Some(*id),
Self::Point(_, _) => None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EdgeDefaults {
pub props: Properties,
pub arrow: Option<ArrowKind>,
pub curve: Option<CurveKind>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub id: NodeId,
pub from: EdgeAnchor,
pub to: EdgeAnchor,
pub text_child: Option<NodeId>,
pub props: Properties,
pub use_styles: SmallVec<[NodeId; 2]>,
pub arrow: ArrowKind,
pub curve: CurveKind,
pub spec: Option<String>,
pub animations: SmallVec<[AnimKeyframe; 2]>,
pub flow: Option<FlowAnim>,
pub label_offset: Option<(f32, f32)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FlowKind {
Pulse,
Dash,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct FlowAnim {
pub kind: FlowKind,
pub duration_ms: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum LayoutMode {
Free { pad: f32 },
Column { gap: f32, pad: f32 },
Row { gap: f32, pad: f32 },
Grid { cols: u32, gap: f32, pad: f32 },
}
impl Default for LayoutMode {
fn default() -> Self {
LayoutMode::Free { pad: 0.0 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NodeKind {
Root,
Generic,
Group,
Frame {
width: f32,
height: f32,
clip: bool,
layout: LayoutMode,
},
Rect { width: f32, height: f32 },
Ellipse { rx: f32, ry: f32 },
Path { commands: Vec<PathCmd> },
Image {
source: ImageSource,
width: f32,
height: f32,
fit: ImageFit,
},
Text {
content: String,
max_width: Option<f32>,
},
}
impl NodeKind {
pub fn kind_name(&self) -> &'static str {
match self {
Self::Root => "root",
Self::Generic => "generic",
Self::Group => "group",
Self::Frame { .. } => "frame",
Self::Rect { .. } => "rect",
Self::Ellipse { .. } => "ellipse",
Self::Path { .. } => "path",
Self::Image { .. } => "image",
Self::Text { .. } => "text",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneNode {
pub id: NodeId,
pub kind: NodeKind,
pub props: Properties,
pub use_styles: SmallVec<[NodeId; 2]>,
pub constraints: SmallVec<[Constraint; 2]>,
pub animations: SmallVec<[AnimKeyframe; 2]>,
pub spec: Option<String>,
pub comments: Vec<String>,
pub place: Option<(HPlace, VPlace)>,
pub locked: bool,
}
impl SceneNode {
pub fn new(id: NodeId, kind: NodeKind) -> Self {
Self {
id,
kind,
props: Properties::default(),
use_styles: SmallVec::new(),
constraints: SmallVec::new(),
animations: SmallVec::new(),
spec: None,
comments: Vec::new(),
place: None,
locked: false,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct GraphSnapshot {
pub node_hashes: HashMap<NodeId, u64>,
pub edge_hashes: HashMap<NodeId, u64>,
}
#[derive(Debug, Clone)]
pub struct SceneGraph {
pub graph: StableDiGraph<SceneNode, ()>,
pub root: NodeIndex,
pub styles: HashMap<NodeId, Properties>,
pub id_index: HashMap<NodeId, NodeIndex>,
pub edges: Vec<Edge>,
pub imports: Vec<Import>,
pub sorted_child_order: HashMap<NodeIndex, Vec<NodeIndex>>,
pub edge_defaults: Option<EdgeDefaults>,
}
impl SceneGraph {
#[must_use]
pub fn new() -> Self {
let mut graph = StableDiGraph::new();
let root_node = SceneNode::new(NodeId::intern("root"), NodeKind::Root);
let root = graph.add_node(root_node);
let mut id_index = HashMap::new();
id_index.insert(NodeId::intern("root"), root);
Self {
graph,
root,
styles: HashMap::new(),
id_index,
edges: Vec::new(),
imports: Vec::new(),
sorted_child_order: HashMap::new(),
edge_defaults: None,
}
}
pub fn add_node(&mut self, parent: NodeIndex, node: SceneNode) -> NodeIndex {
let id = node.id;
let idx = self.graph.add_node(node);
self.graph.add_edge(parent, idx, ());
self.id_index.insert(id, idx);
idx
}
pub fn remove_node(&mut self, idx: NodeIndex) -> Option<SceneNode> {
let removed = self.graph.remove_node(idx);
if let Some(removed_node) = &removed {
self.id_index.remove(&removed_node.id);
}
removed
}
pub fn get_by_id(&self, id: NodeId) -> Option<&SceneNode> {
self.id_index.get(&id).map(|idx| &self.graph[*idx])
}
pub fn get_by_id_mut(&mut self, id: NodeId) -> Option<&mut SceneNode> {
self.id_index
.get(&id)
.copied()
.map(|idx| &mut self.graph[idx])
}
pub fn index_of(&self, id: NodeId) -> Option<NodeIndex> {
self.id_index.get(&id).copied()
}
pub fn parent(&self, idx: NodeIndex) -> Option<NodeIndex> {
self.graph
.neighbors_directed(idx, petgraph::Direction::Incoming)
.next()
}
pub fn reparent_node(&mut self, child: NodeIndex, new_parent: NodeIndex) {
if let Some(old_parent) = self.parent(child)
&& let Some(edge) = self.graph.find_edge(old_parent, child)
{
self.graph.remove_edge(edge);
if let Some(order) = self.sorted_child_order.get_mut(&old_parent) {
order.retain(|&idx| idx != child);
}
}
self.graph.add_edge(new_parent, child, ());
}
pub fn children(&self, idx: NodeIndex) -> Vec<NodeIndex> {
if let Some(order) = self.sorted_child_order.get(&idx) {
return order
.iter()
.copied()
.filter(|&c| self.graph.find_edge(idx, c).is_some())
.collect();
}
let mut children: Vec<NodeIndex> = self
.graph
.neighbors_directed(idx, petgraph::Direction::Outgoing)
.collect();
children.sort();
children
}
pub fn send_backward(&mut self, child: NodeIndex) -> bool {
let parent = match self.parent(child) {
Some(p) => p,
None => return false,
};
let siblings = self.children(parent);
let pos = match siblings.iter().position(|&s| s == child) {
Some(p) => p,
None => return false,
};
if pos == 0 {
return false; }
self.rebuild_child_order(parent, &siblings, pos, pos - 1)
}
pub fn bring_forward(&mut self, child: NodeIndex) -> bool {
let parent = match self.parent(child) {
Some(p) => p,
None => return false,
};
let siblings = self.children(parent);
let pos = match siblings.iter().position(|&s| s == child) {
Some(p) => p,
None => return false,
};
if pos >= siblings.len() - 1 {
return false; }
self.rebuild_child_order(parent, &siblings, pos, pos + 1)
}
pub fn send_to_back(&mut self, child: NodeIndex) -> bool {
let parent = match self.parent(child) {
Some(p) => p,
None => return false,
};
let siblings = self.children(parent);
let pos = match siblings.iter().position(|&s| s == child) {
Some(p) => p,
None => return false,
};
if pos == 0 {
return false;
}
self.rebuild_child_order(parent, &siblings, pos, 0)
}
pub fn bring_to_front(&mut self, child: NodeIndex) -> bool {
let parent = match self.parent(child) {
Some(p) => p,
None => return false,
};
let siblings = self.children(parent);
let pos = match siblings.iter().position(|&s| s == child) {
Some(p) => p,
None => return false,
};
let last = siblings.len() - 1;
if pos == last {
return false;
}
self.rebuild_child_order(parent, &siblings, pos, last)
}
pub fn move_child_to_index(&mut self, child: NodeIndex, target_index: usize) -> bool {
let parent = match self.parent(child) {
Some(p) => p,
None => return false,
};
let siblings = self.children(parent);
let from = match siblings.iter().position(|&s| s == child) {
Some(p) => p,
None => return false,
};
let to = target_index.min(siblings.len().saturating_sub(1));
if from == to {
return false;
}
self.rebuild_child_order(parent, &siblings, from, to)
}
fn rebuild_child_order(
&mut self,
parent: NodeIndex,
siblings: &[NodeIndex],
from: usize,
to: usize,
) -> bool {
for &sib in siblings {
if let Some(edge) = self.graph.find_edge(parent, sib) {
self.graph.remove_edge(edge);
}
}
let mut new_order: Vec<NodeIndex> = siblings.to_vec();
let child = new_order.remove(from);
new_order.insert(to, child);
for &sib in &new_order {
self.graph.add_edge(parent, sib, ());
}
self.sorted_child_order.insert(parent, new_order);
true
}
pub fn define_style(&mut self, name: NodeId, style: Properties) {
self.styles.insert(name, style);
}
pub fn resolve_style(&self, node: &SceneNode, active_triggers: &[AnimTrigger]) -> Properties {
let mut resolved = Properties::default();
for style_id in &node.use_styles {
if let Some(base) = self.styles.get(style_id) {
merge_style(&mut resolved, base);
}
}
merge_style(&mut resolved, &node.props);
for anim in &node.animations {
if active_triggers.contains(&anim.trigger) {
if anim.properties.fill.is_some() {
resolved.fill = anim.properties.fill.clone();
}
if anim.properties.opacity.is_some() {
resolved.opacity = anim.properties.opacity;
}
if anim.properties.scale.is_some() {
resolved.scale = anim.properties.scale;
}
}
}
resolved
}
pub fn rebuild_index(&mut self) {
self.id_index.clear();
for idx in self.graph.node_indices() {
let id = self.graph[idx].id;
self.id_index.insert(id, idx);
}
}
pub fn resolve_style_for_edge(
&self,
edge: &Edge,
active_triggers: &[AnimTrigger],
) -> Properties {
let mut resolved = Properties::default();
for style_id in &edge.use_styles {
if let Some(base) = self.styles.get(style_id) {
merge_style(&mut resolved, base);
}
}
merge_style(&mut resolved, &edge.props);
for anim in &edge.animations {
if active_triggers.contains(&anim.trigger) {
if anim.properties.fill.is_some() {
resolved.fill = anim.properties.fill.clone();
}
if anim.properties.opacity.is_some() {
resolved.opacity = anim.properties.opacity;
}
if anim.properties.scale.is_some() {
resolved.scale = anim.properties.scale;
}
}
}
resolved
}
pub fn effective_target(&self, leaf_id: NodeId, selected: &[NodeId]) -> NodeId {
let leaf_idx = match self.index_of(leaf_id) {
Some(idx) => idx,
None => return leaf_id,
};
let mut groups_bottom_up: Vec<NodeId> = Vec::new();
let mut cursor = self.parent(leaf_idx);
while let Some(parent_idx) = cursor {
if parent_idx == self.root {
break;
}
if matches!(self.graph[parent_idx].kind, NodeKind::Group) {
groups_bottom_up.push(self.graph[parent_idx].id);
}
cursor = self.parent(parent_idx);
}
groups_bottom_up.reverse();
let deepest_selected_pos = groups_bottom_up
.iter()
.rposition(|gid| selected.contains(gid));
match deepest_selected_pos {
None => {
if let Some(top) = groups_bottom_up.first() {
return *top;
}
}
Some(pos) if pos + 1 < groups_bottom_up.len() => {
return groups_bottom_up[pos + 1];
}
Some(_) => {
}
}
leaf_id
}
pub fn is_ancestor_of(&self, ancestor_id: NodeId, descendant_id: NodeId) -> bool {
if ancestor_id == descendant_id {
return false;
}
let mut current_idx = match self.index_of(descendant_id) {
Some(idx) => idx,
None => return false,
};
while let Some(parent_idx) = self.parent(current_idx) {
if self.graph[parent_idx].id == ancestor_id {
return true;
}
if matches!(self.graph[parent_idx].kind, NodeKind::Root) {
break;
}
current_idx = parent_idx;
}
false
}
}
impl Default for SceneGraph {
fn default() -> Self {
Self::new()
}
}
fn merge_style(dst: &mut Properties, src: &Properties) {
if src.fill.is_some() {
dst.fill = src.fill.clone();
}
if src.stroke.is_some() {
dst.stroke = src.stroke.clone();
}
if src.font.is_some() {
dst.font = src.font.clone();
}
if src.corner_radius.is_some() {
dst.corner_radius = src.corner_radius;
}
if src.opacity.is_some() {
dst.opacity = src.opacity;
}
if src.shadow.is_some() {
dst.shadow = src.shadow.clone();
}
if src.text_align.is_some() {
dst.text_align = src.text_align;
}
if src.text_valign.is_some() {
dst.text_valign = src.text_valign;
}
if src.scale.is_some() {
dst.scale = src.scale;
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ResolvedBounds {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl ResolvedBounds {
pub fn contains(&self, px: f32, py: f32) -> bool {
px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
}
pub fn center(&self) -> (f32, f32) {
(self.x + self.width / 2.0, self.y + self.height / 2.0)
}
pub fn intersects_rect(&self, rx: f32, ry: f32, rw: f32, rh: f32) -> bool {
self.x < rx + rw
&& self.x + self.width > rx
&& self.y < ry + rh
&& self.y + self.height > ry
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scene_graph_basics() {
let mut sg = SceneGraph::new();
let rect = SceneNode::new(
NodeId::intern("box1"),
NodeKind::Rect {
width: 100.0,
height: 50.0,
},
);
let idx = sg.add_node(sg.root, rect);
assert!(sg.get_by_id(NodeId::intern("box1")).is_some());
assert_eq!(sg.children(sg.root).len(), 1);
assert_eq!(sg.children(sg.root)[0], idx);
}
#[test]
fn color_hex_roundtrip() {
let c = Color::from_hex("#6C5CE7").unwrap();
assert_eq!(c.to_hex(), "#6C5CE7");
let c2 = Color::from_hex("#FF000080").unwrap();
assert!((c2.a - 128.0 / 255.0).abs() < 0.01);
assert!(c2.to_hex().len() == 9); }
#[test]
fn style_merging() {
let mut sg = SceneGraph::new();
sg.define_style(
NodeId::intern("base"),
Properties {
fill: Some(Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0))),
font: Some(FontSpec {
family: "Inter".into(),
weight: 400,
size: 14.0,
}),
..Default::default()
},
);
let mut node = SceneNode::new(
NodeId::intern("txt"),
NodeKind::Text {
content: "hi".into(),
max_width: None,
},
);
node.use_styles.push(NodeId::intern("base"));
node.props.font = Some(FontSpec {
family: "Inter".into(),
weight: 700,
size: 24.0,
});
let resolved = sg.resolve_style(&node, &[]);
assert!(resolved.fill.is_some());
let f = resolved.font.unwrap();
assert_eq!(f.weight, 700);
assert_eq!(f.size, 24.0);
}
#[test]
fn style_merging_align() {
let mut sg = SceneGraph::new();
sg.define_style(
NodeId::intern("centered"),
Properties {
text_align: Some(TextAlign::Center),
text_valign: Some(TextVAlign::Middle),
..Default::default()
},
);
let mut node = SceneNode::new(
NodeId::intern("overridden"),
NodeKind::Text {
content: "hello".into(),
max_width: None,
},
);
node.use_styles.push(NodeId::intern("centered"));
node.props.text_align = Some(TextAlign::Right);
let resolved = sg.resolve_style(&node, &[]);
assert_eq!(resolved.text_align, Some(TextAlign::Right));
assert_eq!(resolved.text_valign, Some(TextVAlign::Middle));
}
#[test]
fn test_effective_target_group_selects_group_first() {
let mut sg = SceneGraph::new();
let group_id = NodeId::intern("my_group");
let rect_id = NodeId::intern("my_rect");
let group = SceneNode::new(group_id, NodeKind::Group);
let rect = SceneNode::new(
rect_id,
NodeKind::Rect {
width: 10.0,
height: 10.0,
},
);
let group_idx = sg.add_node(sg.root, group);
sg.add_node(group_idx, rect);
assert_eq!(sg.effective_target(rect_id, &[]), group_id);
assert_eq!(sg.effective_target(rect_id, &[group_id]), rect_id);
assert_eq!(sg.effective_target(group_id, &[]), group_id);
}
#[test]
fn test_effective_target_nested_groups_selects_topmost() {
let mut sg = SceneGraph::new();
let outer_id = NodeId::intern("group_outer");
let inner_id = NodeId::intern("group_inner");
let leaf_id = NodeId::intern("rect_leaf");
let outer = SceneNode::new(outer_id, NodeKind::Group);
let inner = SceneNode::new(inner_id, NodeKind::Group);
let leaf = SceneNode::new(
leaf_id,
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
);
let outer_idx = sg.add_node(sg.root, outer);
let inner_idx = sg.add_node(outer_idx, inner);
sg.add_node(inner_idx, leaf);
assert_eq!(sg.effective_target(leaf_id, &[]), outer_id);
assert_eq!(sg.effective_target(leaf_id, &[outer_id]), inner_id);
assert_eq!(sg.effective_target(leaf_id, &[outer_id, inner_id]), leaf_id);
assert_eq!(sg.effective_target(leaf_id, &[inner_id]), leaf_id);
}
#[test]
fn test_effective_target_nested_drill_down_three_levels() {
let mut sg = SceneGraph::new();
let a_id = NodeId::intern("group_a");
let b_id = NodeId::intern("group_b");
let c_id = NodeId::intern("group_c");
let leaf_id = NodeId::intern("deep_leaf");
let a = SceneNode::new(a_id, NodeKind::Group);
let b = SceneNode::new(b_id, NodeKind::Group);
let c = SceneNode::new(c_id, NodeKind::Group);
let leaf = SceneNode::new(
leaf_id,
NodeKind::Rect {
width: 10.0,
height: 10.0,
},
);
let a_idx = sg.add_node(sg.root, a);
let b_idx = sg.add_node(a_idx, b);
let c_idx = sg.add_node(b_idx, c);
sg.add_node(c_idx, leaf);
assert_eq!(sg.effective_target(leaf_id, &[]), a_id);
assert_eq!(sg.effective_target(leaf_id, &[a_id]), b_id);
assert_eq!(sg.effective_target(leaf_id, &[b_id]), c_id);
assert_eq!(sg.effective_target(leaf_id, &[c_id]), leaf_id);
}
#[test]
fn test_visual_highlight_differs_from_selected() {
let mut sg = SceneGraph::new();
let group_id = NodeId::intern("card");
let child_id = NodeId::intern("card_title");
let group = SceneNode::new(group_id, NodeKind::Group);
let child = SceneNode::new(
child_id,
NodeKind::Text {
content: "Title".into(),
max_width: None,
},
);
let group_idx = sg.add_node(sg.root, group);
sg.add_node(group_idx, child);
let logical_target = sg.effective_target(child_id, &[]);
assert_eq!(logical_target, group_id);
assert_ne!(child_id, logical_target);
let drilled = sg.effective_target(child_id, &[group_id]);
assert_eq!(drilled, child_id);
}
#[test]
fn test_effective_target_no_group() {
let mut sg = SceneGraph::new();
let rect_id = NodeId::intern("standalone_rect");
let rect = SceneNode::new(
rect_id,
NodeKind::Rect {
width: 10.0,
height: 10.0,
},
);
sg.add_node(sg.root, rect);
assert_eq!(sg.effective_target(rect_id, &[]), rect_id);
}
#[test]
fn test_is_ancestor_of() {
let mut sg = SceneGraph::new();
let group_id = NodeId::intern("grp");
let rect_id = NodeId::intern("r1");
let other_id = NodeId::intern("other");
let group = SceneNode::new(group_id, NodeKind::Group);
let rect = SceneNode::new(
rect_id,
NodeKind::Rect {
width: 10.0,
height: 10.0,
},
);
let other = SceneNode::new(
other_id,
NodeKind::Rect {
width: 5.0,
height: 5.0,
},
);
let group_idx = sg.add_node(sg.root, group);
sg.add_node(group_idx, rect);
sg.add_node(sg.root, other);
assert!(sg.is_ancestor_of(group_id, rect_id));
assert!(sg.is_ancestor_of(NodeId::intern("root"), rect_id));
assert!(!sg.is_ancestor_of(rect_id, group_id));
assert!(!sg.is_ancestor_of(group_id, group_id));
assert!(!sg.is_ancestor_of(other_id, rect_id));
}
#[test]
fn test_resolve_style_scale_animation() {
let sg = SceneGraph::new();
let mut node = SceneNode::new(
NodeId::intern("btn"),
NodeKind::Rect {
width: 100.0,
height: 40.0,
},
);
node.props.fill = Some(Paint::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0)));
node.animations.push(AnimKeyframe {
trigger: AnimTrigger::Press,
duration_ms: 100,
easing: Easing::EaseOut,
properties: AnimProperties {
scale: Some(0.97),
..Default::default()
},
delay_ms: None,
});
let resolved = sg.resolve_style(&node, &[]);
assert!(resolved.scale.is_none());
let resolved = sg.resolve_style(&node, &[AnimTrigger::Press]);
assert_eq!(resolved.scale, Some(0.97));
assert!(resolved.fill.is_some());
}
#[test]
fn z_order_bring_forward() {
let mut sg = SceneGraph::new();
let a = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("a"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let _b = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("b"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let _c = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("c"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let ids: Vec<&str> = sg
.children(sg.root)
.iter()
.map(|&i| sg.graph[i].id.as_str())
.collect();
assert_eq!(ids, vec!["a", "b", "c"]);
let changed = sg.bring_forward(a);
assert!(changed);
let ids: Vec<&str> = sg
.children(sg.root)
.iter()
.map(|&i| sg.graph[i].id.as_str())
.collect();
assert_eq!(ids, vec!["b", "a", "c"]);
}
#[test]
fn z_order_send_backward() {
let mut sg = SceneGraph::new();
let _a = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("a"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let _b = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("b"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let c = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("c"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let changed = sg.send_backward(c);
assert!(changed);
let ids: Vec<&str> = sg
.children(sg.root)
.iter()
.map(|&i| sg.graph[i].id.as_str())
.collect();
assert_eq!(ids, vec!["a", "c", "b"]);
}
#[test]
fn z_order_bring_to_front() {
let mut sg = SceneGraph::new();
let a = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("a"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let _b = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("b"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let _c = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("c"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let changed = sg.bring_to_front(a);
assert!(changed);
let ids: Vec<&str> = sg
.children(sg.root)
.iter()
.map(|&i| sg.graph[i].id.as_str())
.collect();
assert_eq!(ids, vec!["b", "c", "a"]);
}
#[test]
fn z_order_send_to_back() {
let mut sg = SceneGraph::new();
let _a = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("a"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let _b = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("b"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let c = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("c"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let changed = sg.send_to_back(c);
assert!(changed);
let ids: Vec<&str> = sg
.children(sg.root)
.iter()
.map(|&i| sg.graph[i].id.as_str())
.collect();
assert_eq!(ids, vec!["c", "a", "b"]);
}
#[test]
fn z_order_emitter_roundtrip() {
use crate::emitter::emit_document;
use crate::parser::parse_document;
let mut sg = SceneGraph::new();
let a = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("a"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let _b = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("b"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let _c = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("c"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
sg.bring_to_front(a);
let text = emit_document(&sg);
let reparsed = parse_document(&text).unwrap();
let ids: Vec<&str> = reparsed
.children(reparsed.root)
.iter()
.map(|&i| reparsed.graph[i].id.as_str())
.collect();
assert_eq!(
ids,
vec!["b", "c", "a"],
"Z-order should survive emit→parse roundtrip. Emitted:\n{}",
text
);
}
#[test]
fn move_child_to_index_basic() {
let mut sg = SceneGraph::new();
let a = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("a"),
NodeKind::Rect {
width: 10.0,
height: 10.0,
},
),
);
let _b = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("b"),
NodeKind::Rect {
width: 10.0,
height: 10.0,
},
),
);
let _c = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("c"),
NodeKind::Rect {
width: 10.0,
height: 10.0,
},
),
);
assert!(sg.move_child_to_index(a, 2));
let ids: Vec<&str> = sg
.children(sg.root)
.iter()
.map(|&i| sg.graph[i].id.as_str())
.collect();
assert_eq!(ids, vec!["b", "c", "a"]);
}
#[test]
fn move_child_to_index_out_of_bounds() {
let mut sg = SceneGraph::new();
let a = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("a"),
NodeKind::Rect {
width: 10.0,
height: 10.0,
},
),
);
let _b = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("b"),
NodeKind::Rect {
width: 10.0,
height: 10.0,
},
),
);
assert!(sg.move_child_to_index(a, 999));
let ids: Vec<&str> = sg
.children(sg.root)
.iter()
.map(|&i| sg.graph[i].id.as_str())
.collect();
assert_eq!(ids, vec!["b", "a"]);
}
#[test]
fn move_child_to_index_noop() {
let mut sg = SceneGraph::new();
let a = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("a"),
NodeKind::Rect {
width: 10.0,
height: 10.0,
},
),
);
assert!(!sg.move_child_to_index(a, 0));
}
#[test]
fn reparent_then_children_correct() {
let mut sg = SceneGraph::new();
let group = sg.add_node(
sg.root,
SceneNode::new(NodeId::intern("g"), NodeKind::Group),
);
let rect = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("r"),
NodeKind::Rect {
width: 10.0,
height: 10.0,
},
),
);
assert_eq!(sg.children(sg.root).len(), 2);
sg.reparent_node(rect, group);
assert_eq!(sg.children(sg.root).len(), 1);
assert_eq!(sg.children(group).len(), 1);
assert_eq!(sg.children(group)[0], rect);
sg.reparent_node(rect, sg.root);
assert_eq!(sg.children(sg.root).len(), 2);
assert_eq!(sg.children(group).len(), 0);
}
#[test]
fn reparent_after_reorder_no_ghost_children() {
let mut sg = SceneGraph::new();
let parent = sg.add_node(
sg.root,
SceneNode::new(
NodeId::intern("parent"),
NodeKind::Rect {
width: 200.0,
height: 200.0,
},
),
);
let child_a = sg.add_node(
parent,
SceneNode::new(
NodeId::intern("a"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
let child_b = sg.add_node(
parent,
SceneNode::new(
NodeId::intern("b"),
NodeKind::Rect {
width: 50.0,
height: 50.0,
},
),
);
assert!(sg.move_child_to_index(child_b, 0));
assert!(sg.sorted_child_order.contains_key(&parent));
assert_eq!(sg.children(parent), vec![child_b, child_a]);
sg.reparent_node(child_b, sg.root);
let parent_children = sg.children(parent);
assert_eq!(
parent_children.len(),
1,
"parent should have exactly 1 child after reparent, got {:?}",
parent_children
);
assert_eq!(parent_children[0], child_a);
let root_children = sg.children(sg.root);
assert_eq!(root_children.len(), 2);
assert!(root_children.contains(&parent));
assert!(root_children.contains(&child_b));
let emitted = crate::emitter::emit_document(&sg);
let count = emitted.matches("@b").count();
assert_eq!(
count, 1,
"@b should appear exactly once in emitted text, found {count} times:\n{emitted}"
);
}
}