Skip to main content

falsegreen_ui_core/
lib.rs

1//! Versioned normalized UI representation.
2//!
3//! This crate deliberately contains no renderer, browser, or acceptance authority. It is
4//! the stable data boundary between a UI runtime and independent evidence evaluation.
5
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, BTreeSet};
9use thiserror::Error;
10
11pub const UI_EVIDENCE_SCHEMA: &str = "falsegreen.ui-evidence/v1";
12pub const UI_PROTOCOL_VERSION: &str = "falsegreen.ui-interaction/v1";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub struct Viewport {
16    pub width: u32,
17    pub height: u32,
18    pub dpr_milli: u32,
19}
20
21impl Viewport {
22    pub const fn new(width: u32, height: u32) -> Self {
23        Self {
24            width,
25            height,
26            dpr_milli: 1000,
27        }
28    }
29
30    pub fn dpr(self) -> f32 {
31        self.dpr_milli as f32 / 1000.0
32    }
33
34    pub fn bounds(self) -> Rect {
35        Rect::new(0.0, 0.0, self.width as f32, self.height as f32)
36    }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
40pub struct Rect {
41    pub x: f32,
42    pub y: f32,
43    pub width: f32,
44    pub height: f32,
45}
46
47impl Rect {
48    pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
49        Self {
50            x,
51            y,
52            width,
53            height,
54        }
55    }
56
57    pub fn right(self) -> f32 {
58        self.x + self.width
59    }
60    pub fn bottom(self) -> f32 {
61        self.y + self.height
62    }
63    pub fn area(self) -> f32 {
64        self.width.max(0.0) * self.height.max(0.0)
65    }
66
67    pub fn contains(self, x: f32, y: f32) -> bool {
68        x >= self.x && x <= self.right() && y >= self.y && y <= self.bottom()
69    }
70
71    pub fn intersection(self, other: Self) -> Option<Self> {
72        let x = self.x.max(other.x);
73        let y = self.y.max(other.y);
74        let right = self.right().min(other.right());
75        let bottom = self.bottom().min(other.bottom());
76        if right <= x || bottom <= y {
77            None
78        } else {
79            Some(Self::new(x, y, right - x, bottom - y))
80        }
81    }
82
83    pub fn intersects(self, other: Self) -> bool {
84        self.intersection(other).is_some()
85    }
86
87    pub fn visible_fraction(self, clip: Option<Self>) -> f32 {
88        let Some(clip) = clip else { return 1.0 };
89        if self.area() <= 0.0 {
90            return 0.0;
91        }
92        self.intersection(clip)
93            .map(|r| r.area() / self.area())
94            .unwrap_or(0.0)
95            .clamp(0.0, 1.0)
96    }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "kebab-case")]
101pub enum Role {
102    Application,
103    Main,
104    Navigation,
105    Article,
106    Group,
107    Heading,
108    StaticText,
109    Alert,
110    Status,
111    Button,
112    TextField,
113    Image,
114    Dialog,
115    Overlay,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
119#[serde(rename_all = "kebab-case")]
120pub enum Overflow {
121    #[default]
122    Visible,
123    Hidden,
124    Scroll,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct NodeState {
129    pub visible: bool,
130    pub enabled: bool,
131    pub focusable: bool,
132    pub focused: bool,
133    pub selected: bool,
134    pub expanded: Option<bool>,
135    pub checked: Option<bool>,
136    pub pressed: Option<bool>,
137}
138
139impl Default for NodeState {
140    fn default() -> Self {
141        Self {
142            visible: true,
143            enabled: true,
144            focusable: false,
145            focused: false,
146            selected: false,
147            expanded: None,
148            checked: None,
149            pressed: None,
150        }
151    }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct AssetBinding {
156    pub logical_name: String,
157    pub kind: String,
158    pub sha256: String,
159}
160
161impl AssetBinding {
162    pub fn new(logical_name: impl Into<String>, kind: impl Into<String>, bytes: &[u8]) -> Self {
163        Self {
164            logical_name: logical_name.into(),
165            kind: kind.into(),
166            sha256: sha256_hex(bytes),
167        }
168    }
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(tag = "kind", rename_all = "kebab-case")]
173pub enum PaintPrimitive {
174    Fill {
175        rect: Rect,
176        color: [u8; 4],
177        radius: f32,
178    },
179    Stroke {
180        rect: Rect,
181        color: [u8; 4],
182        width: f32,
183        radius: f32,
184    },
185    Text {
186        rect: Rect,
187        color: [u8; 4],
188        text: String,
189        font_family: String,
190        font_size: f32,
191    },
192    Asset {
193        rect: Rect,
194        asset: AssetBinding,
195    },
196}
197
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct UiNode {
200    pub id: String,
201    pub role: Role,
202    pub semantic_label: Option<String>,
203    pub text: Option<String>,
204    pub value: Option<String>,
205    pub state: NodeState,
206    pub bounds: Rect,
207    pub clip: Option<Rect>,
208    pub overflow: Overflow,
209    pub z_index: i32,
210    pub parent_id: Option<String>,
211    pub children: Vec<String>,
212    pub data_bindings: BTreeMap<String, String>,
213    /// Renderer-facing event listener names observed on this exact stable target.
214    #[serde(default)]
215    pub event_listeners: Vec<String>,
216    pub transition_id: Option<String>,
217    pub paint: Vec<PaintPrimitive>,
218    pub asset: Option<AssetBinding>,
219}
220
221impl UiNode {
222    pub fn new(id: impl Into<String>, role: Role, bounds: Rect) -> Self {
223        Self {
224            id: id.into(),
225            role,
226            semantic_label: None,
227            text: None,
228            value: None,
229            state: NodeState::default(),
230            bounds,
231            clip: None,
232            overflow: Overflow::Visible,
233            z_index: 0,
234            parent_id: None,
235            children: Vec::new(),
236            data_bindings: BTreeMap::new(),
237            event_listeners: Vec::new(),
238            transition_id: None,
239            paint: Vec::new(),
240            asset: None,
241        }
242    }
243
244    pub fn label(mut self, label: impl Into<String>) -> Self {
245        self.semantic_label = Some(label.into());
246        self
247    }
248    pub fn text(mut self, text: impl Into<String>) -> Self {
249        self.text = Some(text.into());
250        self
251    }
252    pub fn value(mut self, value: impl Into<String>) -> Self {
253        self.value = Some(value.into());
254        self
255    }
256    pub fn parent(mut self, parent: impl Into<String>) -> Self {
257        self.parent_id = Some(parent.into());
258        self
259    }
260    pub fn z(mut self, z_index: i32) -> Self {
261        self.z_index = z_index;
262        self
263    }
264    pub fn clip(mut self, clip: Rect) -> Self {
265        self.clip = Some(clip);
266        self
267    }
268    pub fn paint(mut self, primitive: PaintPrimitive) -> Self {
269        self.paint.push(primitive);
270        self
271    }
272    pub fn bind(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
273        self.data_bindings.insert(key.into(), value.into());
274        self
275    }
276    pub fn transition(mut self, id: impl Into<String>) -> Self {
277        self.transition_id = Some(id.into());
278        self
279    }
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283pub struct UiTree {
284    pub schema: String,
285    pub viewport: Viewport,
286    pub root_id: String,
287    pub nodes: Vec<UiNode>,
288    pub tree_digest: String,
289}
290
291impl UiTree {
292    pub fn new(viewport: Viewport, root_id: impl Into<String>, nodes: Vec<UiNode>) -> Self {
293        let mut tree = Self {
294            schema: UI_EVIDENCE_SCHEMA.into(),
295            viewport,
296            root_id: root_id.into(),
297            nodes,
298            tree_digest: String::new(),
299        };
300        tree.tree_digest = tree.compute_digest();
301        tree
302    }
303
304    pub fn node(&self, id: &str) -> Option<&UiNode> {
305        self.nodes.iter().find(|node| node.id == id)
306    }
307
308    pub fn visible_node(&self, id: &str) -> Option<&UiNode> {
309        self.node(id)
310            .filter(|node| self.is_effectively_visible(&node.id))
311    }
312
313    pub fn is_effectively_visible(&self, id: &str) -> bool {
314        let mut current = self.node(id);
315        let mut seen = BTreeSet::new();
316        while let Some(node) = current {
317            if !seen.insert(node.id.as_str()) {
318                return false;
319            }
320            if !node.state.visible || node.bounds.visible_fraction(node.clip) <= 0.0 {
321                return false;
322            }
323            current = node
324                .parent_id
325                .as_deref()
326                .and_then(|parent| self.node(parent));
327        }
328        true
329    }
330
331    pub fn compute_digest(&self) -> String {
332        let mut copy = self.clone();
333        copy.tree_digest.clear();
334        let bytes = serde_json::to_vec(&copy).expect("normalized UI tree is serializable");
335        sha256_hex(&bytes)
336    }
337
338    pub fn refresh_digest(&mut self) {
339        self.tree_digest = self.compute_digest();
340    }
341
342    pub fn validate(&self) -> Result<ValidationReport, ValidationError> {
343        if self.schema != UI_EVIDENCE_SCHEMA {
344            return Err(ValidationError::Schema(self.schema.clone()));
345        }
346        let mut seen = BTreeSet::new();
347        for node in &self.nodes {
348            validate_id(&node.id)?;
349            if !seen.insert(node.id.clone()) {
350                return Err(ValidationError::DuplicateId(node.id.clone()));
351            }
352        }
353        if self.node(&self.root_id).is_none() {
354            return Err(ValidationError::MissingRoot(self.root_id.clone()));
355        }
356        for node in &self.nodes {
357            if let Some(parent) = &node.parent_id {
358                let Some(parent_node) = self.node(parent) else {
359                    return Err(ValidationError::MissingParent {
360                        node: node.id.clone(),
361                        parent: parent.clone(),
362                    });
363                };
364                if !parent_node.children.iter().any(|child| child == &node.id) {
365                    return Err(ValidationError::HierarchyDrift(node.id.clone()));
366                }
367            }
368            for child in &node.children {
369                let Some(child_node) = self.node(child) else {
370                    return Err(ValidationError::MissingChild {
371                        node: node.id.clone(),
372                        child: child.clone(),
373                    });
374                };
375                if child_node.parent_id.as_deref() != Some(node.id.as_str()) {
376                    return Err(ValidationError::HierarchyDrift(child.clone()));
377                }
378            }
379        }
380        let root_count = self
381            .nodes
382            .iter()
383            .filter(|node| node.parent_id.is_none())
384            .count();
385        if root_count != 1 {
386            return Err(ValidationError::RootCount(root_count));
387        }
388        for node in &self.nodes {
389            let mut current = node.id.as_str();
390            let mut path = BTreeSet::new();
391            while let Some(parent) = self
392                .node(current)
393                .and_then(|candidate| candidate.parent_id.as_deref())
394            {
395                if !path.insert(parent) {
396                    return Err(ValidationError::HierarchyDrift(node.id.clone()));
397                }
398                current = parent;
399            }
400        }
401        Ok(ValidationReport {
402            node_count: self.nodes.len(),
403            ids: seen.into_iter().collect(),
404        })
405    }
406
407    pub fn topmost_at(&self, x: f32, y: f32) -> Option<&UiNode> {
408        self.nodes
409            .iter()
410            .filter(|node| {
411                self.is_effectively_visible(&node.id)
412                    && node.state.enabled
413                    && node.bounds.contains(x, y)
414            })
415            .max_by_key(|node| node.z_index)
416    }
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420pub struct ValidationReport {
421    pub node_count: usize,
422    pub ids: Vec<String>,
423}
424
425#[derive(Debug, Error, Clone, PartialEq, Eq)]
426pub enum ValidationError {
427    #[error("unsupported normalized tree schema: {0}")]
428    Schema(String),
429    #[error("stable verification ID is missing or malformed: {0}")]
430    MissingId(String),
431    #[error("duplicate stable verification ID: {0}")]
432    DuplicateId(String),
433    #[error("root node is missing: {0}")]
434    MissingRoot(String),
435    #[error("node {node} names missing parent {parent}")]
436    MissingParent { node: String, parent: String },
437    #[error("node {node} names missing child {child}")]
438    MissingChild { node: String, child: String },
439    #[error("parent/child hierarchy drift at {0}")]
440    HierarchyDrift(String),
441    #[error("normalized tree must have one root, found {0}")]
442    RootCount(usize),
443}
444
445pub fn validate_id(id: &str) -> Result<(), ValidationError> {
446    if id.is_empty()
447        || !id
448            .bytes()
449            .all(|byte| byte.is_ascii_alphanumeric() || b"_-.:".contains(&byte))
450    {
451        return Err(ValidationError::MissingId(id.to_string()));
452    }
453    Ok(())
454}
455
456pub fn sha256_hex(bytes: &[u8]) -> String {
457    let mut hasher = Sha256::new();
458    hasher.update(bytes);
459    hex::encode(hasher.finalize())
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    fn sample_tree() -> UiTree {
467        let root = UiNode::new("root", Role::Application, Rect::new(0.0, 0.0, 100.0, 100.0)).paint(
468            PaintPrimitive::Fill {
469                rect: Rect::new(0.0, 0.0, 100.0, 100.0),
470                color: [0, 0, 0, 255],
471                radius: 0.0,
472            },
473        );
474        let child =
475            UiNode::new("save", Role::Button, Rect::new(10.0, 10.0, 40.0, 20.0)).parent("root");
476        let mut tree = UiTree::new(Viewport::new(100, 100), "root", vec![root, child]);
477        tree.nodes[0].children.push("save".into());
478        tree.refresh_digest();
479        tree
480    }
481
482    #[test]
483    fn stable_tree_digest_ignores_stored_digest() {
484        let tree = sample_tree();
485        let mut altered = tree.clone();
486        altered.tree_digest = "attacker-edit".into();
487        assert_eq!(tree.compute_digest(), altered.compute_digest());
488    }
489
490    #[test]
491    fn rejects_duplicate_and_retargeted_identity() {
492        let mut tree = sample_tree();
493        tree.nodes.push(UiNode::new(
494            "save",
495            Role::StaticText,
496            Rect::new(0.0, 0.0, 1.0, 1.0),
497        ));
498        assert!(matches!(tree.validate(), Err(ValidationError::DuplicateId(id)) if id == "save"));
499    }
500
501    #[test]
502    fn visible_fraction_and_overlap_are_objective() {
503        let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
504        assert_eq!(
505            rect.visible_fraction(Some(Rect::new(0.0, 0.0, 50.0, 100.0))),
506            0.5
507        );
508        assert!(rect.intersects(Rect::new(99.0, 99.0, 5.0, 5.0)));
509    }
510
511    #[test]
512    fn rejects_hierarchy_cycles() {
513        let a = UiNode::new("a", Role::Application, Rect::new(0.0, 0.0, 10.0, 10.0)).parent("b");
514        let b = UiNode::new("b", Role::Group, Rect::new(0.0, 0.0, 10.0, 10.0)).parent("a");
515        let tree = UiTree::new(Viewport::new(10, 10), "a", vec![a, b]);
516        assert!(tree.validate().is_err());
517    }
518}