Skip to main content

konjure_sdk/
presentation.rs

1//! Portable semantic UI documents and per-view presentation routing.
2//!
3//! This module describes what an assistant or scene wants to present. Platform
4//! adapters own pixels, graphics handles, eye textures, input devices, and room
5//! tracking implementations. A [`PresentationPlan`] makes every target view
6//! explicit so a stereo adapter cannot accidentally present one monoscopic
7//! dialog for both eyes.
8
9use alloc::{string::String, vec, vec::Vec};
10use core::fmt;
11use serde::{Deserialize, Serialize};
12
13use crate::domain::{Transform, wire_u64};
14
15/// Maximum nodes accepted in one portable UI document.
16pub const MAX_UI_NODES: usize = 128;
17/// Maximum UTF-8 byte length of a UI identifier, action, or resource name.
18pub const MAX_UI_ID_BYTES: usize = 128;
19/// Maximum UTF-8 byte length of visible UI text or text input value.
20pub const MAX_UI_TEXT_BYTES: usize = 4 * 1024;
21
22macro_rules! identifier {
23    ($name:ident, $docs:literal) => {
24        #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25        #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
26        #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
27        #[serde(transparent)]
28        #[doc = $docs]
29        pub struct $name(pub String);
30    };
31}
32
33identifier!(UiDocumentId, "Owned identifier for a semantic UI document.");
34identifier!(
35    UiNodeId,
36    "Owned identifier for a node within a UI document."
37);
38identifier!(
39    UiResourceId,
40    "Opaque identifier for an adapter-resolved image resource."
41);
42identifier!(
43    RoomAnchorId,
44    "Identifier for a tracked room anchor supplied by an adapter."
45);
46
47/// Opaque application action identifier carried by UI events.
48#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
49#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
50#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
51#[serde(transparent)]
52pub struct UiActionId(pub u32);
53
54/// A named target view; it has no platform graphics handle.
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
57#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum ViewIdentity {
60    /// The only view on a monoscopic target.
61    Mono,
62    /// The left view of a stereo target.
63    Left,
64    /// The right view of a stereo target.
65    Right,
66}
67
68/// A positive, half-open rectangular region in adapter-defined logical pixels.
69#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
70#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct Viewport {
74    /// Explicit identity of the view rendered into this region.
75    pub view: ViewIdentity,
76    /// Horizontal origin in logical pixels.
77    pub x: i32,
78    /// Vertical origin in logical pixels.
79    pub y: i32,
80    /// Positive width in logical pixels.
81    pub width: u32,
82    /// Positive height in logical pixels.
83    pub height: u32,
84}
85
86/// Adapter-declared presentation topology.
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
89#[allow(missing_docs)]
90#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(tag = "kind", rename_all = "snake_case")]
92pub enum DeviceTarget {
93    /// One explicitly named monoscopic view.
94    Mono {
95        surface_width: u32,
96        surface_height: u32,
97        view: Viewport,
98    },
99    /// Exactly one left and one right view. Their viewports must not overlap.
100    Stereo {
101        surface_width: u32,
102        surface_height: u32,
103        left: Viewport,
104        right: Viewport,
105    },
106}
107
108impl DeviceTarget {
109    /// Rejects malformed view identities, empty regions, and overlapping stereo regions.
110    ///
111    /// # Errors
112    ///
113    /// Returns an error when the topology cannot safely route a document to
114    /// its declared view or views.
115    pub fn validate(&self) -> Result<(), PresentationError> {
116        match self {
117            Self::Mono {
118                surface_width,
119                surface_height,
120                view,
121            } => {
122                if view.view != ViewIdentity::Mono {
123                    return Err(PresentationError::InvalidMonoView);
124                }
125                validate_surface(*surface_width, *surface_height)?;
126                validate_viewport(*view, *surface_width, *surface_height)
127            }
128            Self::Stereo {
129                surface_width,
130                surface_height,
131                left,
132                right,
133            } => {
134                if left.view != ViewIdentity::Left || right.view != ViewIdentity::Right {
135                    return Err(PresentationError::InvalidStereoViews);
136                }
137                validate_surface(*surface_width, *surface_height)?;
138                validate_viewport(*left, *surface_width, *surface_height)?;
139                validate_viewport(*right, *surface_width, *surface_height)?;
140                if viewports_overlap(*left, *right) {
141                    return Err(PresentationError::OverlappingStereoViewports);
142                }
143                Ok(())
144            }
145        }
146    }
147
148    fn views(&self) -> Vec<Viewport> {
149        match self {
150            Self::Mono { view, .. } => vec![*view],
151            Self::Stereo { left, right, .. } => vec![*left, *right],
152        }
153    }
154}
155
156/// Placement requested by a semantic UI document.
157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
158#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
159#[allow(missing_docs)]
160#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(tag = "kind", rename_all = "snake_case")]
162pub enum UiPlacement {
163    /// Place in the adapter's ordinary screen layer.
164    Screen,
165    /// Place relative to the current head pose; this does not claim room anchoring.
166    HeadLocked,
167    /// Place relative to an explicitly tracked room anchor.
168    World { anchor: RoomAnchorId },
169}
170
171/// A room anchor resolved by a presentation adapter before plan construction.
172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
174#[allow(missing_docs)]
175#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177pub struct ResolvedRoomAnchor {
178    /// Stable identity referenced by a world-placed document.
179    pub id: RoomAnchorId,
180    /// Current room-space transform supplied by the adapter.
181    pub transform: Transform,
182}
183
184/// Adapter-owned resolved spatial state needed to construct a presentation plan.
185#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
186#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
187#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct PresentationContext {
190    /// Currently resolved room anchors. An empty list explicitly cannot satisfy world placement.
191    pub room_anchors: Vec<ResolvedRoomAnchor>,
192}
193
194/// One semantic UI document, independent of a device's view or graphics APIs.
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
197#[allow(missing_docs)]
198#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
199#[serde(deny_unknown_fields)]
200pub struct UiDocument {
201    /// Stable document identifier.
202    pub id: UiDocumentId,
203    /// Source and interaction epoch, serialized as a decimal string.
204    ///
205    /// Increment this when the node structure, declared actions, or interaction
206    /// policy changes. It is not a per-value or per-frame tick; adapters validate
207    /// input values against the current document state at dispatch time.
208    #[serde(with = "wire_u64")]
209    #[cfg_attr(feature = "schema", schemars(with = "String"))]
210    #[cfg_attr(feature = "typescript", ts(type = "string"))]
211    pub revision: u64,
212    /// Requested spatial placement; adapters never silently replace it.
213    pub placement: UiPlacement,
214    /// Whether this document captures platform navigation and draws a scrim.
215    ///
216    /// Modal documents prevent scene navigation while active. Modeless documents
217    /// remain an overlay, so scene controls such as sliders do not hide the scene.
218    #[serde(default)]
219    pub modal: bool,
220    /// Flat, deterministic document order for typed UI nodes.
221    pub nodes: Vec<UiNode>,
222}
223
224impl UiDocument {
225    /// Validates document structure and required adapter capabilities.
226    ///
227    /// # Errors
228    ///
229    /// Returns an error for malformed identifiers, bounds, duplicate nodes, or
230    /// a world placement requested without room tracking.
231    pub fn validate(&self) -> Result<(), PresentationError> {
232        validate_identifier(&self.id.0)?;
233        if self.nodes.len() > MAX_UI_NODES {
234            return Err(PresentationError::TooManyNodes);
235        }
236        for (index, node) in self.nodes.iter().enumerate() {
237            validate_node(node)?;
238            if self.nodes[..index].iter().any(|prior| prior.id == node.id) {
239                return Err(PresentationError::DuplicateNodeId);
240            }
241        }
242        Ok(())
243    }
244
245    /// Validates an input event against this exact document revision and node role.
246    ///
247    /// This method checks only the semantic contract. Applying an accepted
248    /// action remains an application responsibility.
249    ///
250    /// # Errors
251    ///
252    /// Returns an error for stale documents, disabled nodes, mismatched actions,
253    /// malformed text, or slider values outside the node's declared range.
254    pub fn validate_event(&self, event: &UiEvent) -> Result<ValidatedUiEvent, PresentationError> {
255        self.validate()?;
256        if event.document != self.id {
257            return Err(PresentationError::WrongDocument);
258        }
259        if event.revision != self.revision {
260            return Err(PresentationError::StaleRevision);
261        }
262        let node = self
263            .nodes
264            .iter()
265            .find(|node| node.id == event.node)
266            .ok_or(PresentationError::UnknownNode)?;
267        if !node.enabled {
268            return Err(PresentationError::DisabledNode);
269        }
270        match (&node.kind, &event.action) {
271            (UiNodeKind::Button { action, .. }, UiEventAction::Activate { action: received })
272                if action == received =>
273            {
274                Ok(ValidatedUiEvent {
275                    node: node.id.clone(),
276                    action: *received,
277                })
278            }
279            (
280                UiNodeKind::Slider {
281                    minimum,
282                    maximum,
283                    step,
284                    action,
285                    ..
286                },
287                UiEventAction::SetSlider {
288                    action: received,
289                    value,
290                },
291            ) if action == received => {
292                if !value.is_finite()
293                    || *value < *minimum
294                    || *value > *maximum
295                    || !slider_step_matches(*value, *minimum, *step)
296                {
297                    return Err(PresentationError::SliderValueOutOfRange);
298                }
299                Ok(ValidatedUiEvent {
300                    node: node.id.clone(),
301                    action: *received,
302                })
303            }
304            (
305                UiNodeKind::TextInput {
306                    max_bytes, action, ..
307                },
308                UiEventAction::SetText {
309                    action: received,
310                    value,
311                },
312            ) if action == received => {
313                if value.len() > *max_bytes as usize {
314                    return Err(PresentationError::TextValueTooLong);
315                }
316                Ok(ValidatedUiEvent {
317                    node: node.id.clone(),
318                    action: *received,
319                })
320            }
321            (
322                UiNodeKind::Toggle { action, .. },
323                UiEventAction::SetToggle {
324                    action: received, ..
325                },
326            ) if action == received => Ok(ValidatedUiEvent {
327                node: node.id.clone(),
328                action: *received,
329            }),
330            _ => Err(PresentationError::WrongEventForNode),
331        }
332    }
333
334    /// Returns the next enabled focusable node in document order, wrapping at either end.
335    pub fn next_focus(
336        &self,
337        current: Option<&UiNodeId>,
338        direction: UiNavigation,
339    ) -> Option<UiNodeId> {
340        let focusable: Vec<&UiNode> = self
341            .nodes
342            .iter()
343            .filter(|node| node.enabled && node.kind.focusable())
344            .collect();
345        if focusable.is_empty() {
346            return None;
347        }
348        let current_index = current.and_then(|id| focusable.iter().position(|node| &node.id == id));
349        let next = match (current_index, direction) {
350            (Some(index), UiNavigation::Forward) => (index + 1) % focusable.len(),
351            (Some(0), UiNavigation::Backward) => focusable.len() - 1,
352            (Some(index), UiNavigation::Backward) => index - 1,
353            (None, UiNavigation::Forward) => 0,
354            (None, UiNavigation::Backward) => focusable.len() - 1,
355        };
356        Some(focusable[next].id.clone())
357    }
358}
359
360impl PresentationContext {
361    fn resolve(&self, anchor: &RoomAnchorId) -> Result<Transform, PresentationError> {
362        let resolved = self
363            .room_anchors
364            .iter()
365            .find(|candidate| candidate.id == *anchor)
366            .ok_or(PresentationError::UnresolvedRoomAnchor)?;
367        validate_identifier(&resolved.id.0)?;
368        validate_transform(resolved.transform)?;
369        Ok(resolved.transform)
370    }
371}
372
373/// A typed node in a flat semantic UI document.
374#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
375#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
376#[allow(missing_docs)]
377#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
378pub struct UiNode {
379    /// Identifier unique within the containing document.
380    pub id: UiNodeId,
381    /// Whether the node accepts focus and input.
382    pub enabled: bool,
383    /// Typed semantic role and values.
384    #[serde(flatten)]
385    pub kind: UiNodeKind,
386}
387
388/// Semantic role and typed values of a UI node.
389#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
390#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
391#[allow(missing_docs)]
392#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
393#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
394pub enum UiNodeKind {
395    /// A noninteractive visual grouping surface.
396    Panel { label: Option<String> },
397    /// Noninteractive visible text.
398    Text { text: String },
399    /// An activatable command.
400    Button { label: String, action: UiActionId },
401    /// A bounded scalar input.
402    Slider {
403        label: String,
404        value: f32,
405        minimum: f32,
406        maximum: f32,
407        step: Option<f32>,
408        action: UiActionId,
409    },
410    /// A bounded UTF-8 text input.
411    TextInput {
412        label: String,
413        value: String,
414        max_bytes: u16,
415        action: UiActionId,
416    },
417    /// A Boolean input.
418    Toggle {
419        label: String,
420        value: bool,
421        action: UiActionId,
422    },
423    /// An adapter-resolved image with an accessible text alternative.
424    ImageResource { resource: UiResourceId, alt: String },
425}
426
427impl UiNodeKind {
428    fn focusable(&self) -> bool {
429        matches!(
430            self,
431            Self::Button { .. }
432                | Self::Slider { .. }
433                | Self::TextInput { .. }
434                | Self::Toggle { .. }
435        )
436    }
437}
438
439/// Direction for deterministic focus navigation.
440#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
441#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
442#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "snake_case")]
444pub enum UiNavigation {
445    /// Move toward later nodes in document order.
446    Forward,
447    /// Move toward earlier nodes in document order.
448    Backward,
449}
450
451/// Input reported by a platform adapter for a specific document revision.
452#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
453#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
454#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
455pub struct UiEvent {
456    /// Document that the adapter displayed.
457    pub document: UiDocumentId,
458    /// Document revision displayed by the adapter, serialized as a decimal string.
459    #[serde(with = "wire_u64")]
460    #[cfg_attr(feature = "schema", schemars(with = "String"))]
461    #[cfg_attr(feature = "typescript", ts(type = "string"))]
462    pub revision: u64,
463    /// Node receiving the event.
464    pub node: UiNodeId,
465    /// Typed input and requested semantic action.
466    #[serde(flatten)]
467    pub action: UiEventAction,
468}
469
470/// Typed user input accepted only by a matching node role.
471#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
472#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
473#[allow(missing_docs)]
474#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
475#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
476pub enum UiEventAction {
477    /// Activate a button's declared action.
478    Activate { action: UiActionId },
479    /// Set a slider to a bounded scalar value.
480    SetSlider { action: UiActionId, value: f32 },
481    /// Set a text input value.
482    SetText { action: UiActionId, value: String },
483    /// Set a toggle value.
484    SetToggle { action: UiActionId, value: bool },
485}
486
487/// Event proven to target an enabled node in the displayed document revision.
488#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
489#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
490#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
491#[serde(deny_unknown_fields)]
492pub struct ValidatedUiEvent {
493    /// Enabled node that accepted the input.
494    pub node: UiNodeId,
495    /// Declared application action requested by that node.
496    pub action: UiActionId,
497}
498
499/// A centrally produced routing instruction for one adapter view.
500#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
501#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
502#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
503#[serde(deny_unknown_fields)]
504pub struct ViewRoute {
505    /// Target view receiving this semantic document.
506    pub viewport: Viewport,
507    /// Document identifier presented to this view.
508    pub document: UiDocumentId,
509    /// Exact document revision presented to this view, serialized as a decimal string.
510    #[serde(with = "wire_u64")]
511    #[cfg_attr(feature = "schema", schemars(with = "String"))]
512    #[cfg_attr(feature = "typescript", ts(type = "string"))]
513    pub revision: u64,
514}
515
516/// Placement ready for a renderer after all required room anchors are resolved.
517#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
518#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
519#[allow(missing_docs)]
520#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
521#[serde(tag = "kind", rename_all = "snake_case")]
522pub enum ResolvedUiPlacement {
523    /// Render in the adapter's ordinary screen layer.
524    Screen,
525    /// Render relative to the current head pose.
526    HeadLocked,
527    /// Render using this exact adapter-resolved room transform.
528    World {
529        anchor: RoomAnchorId,
530        transform: Transform,
531    },
532}
533
534/// Validated mapping of one semantic document onto every view of a target.
535#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
536#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
537#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
538#[serde(deny_unknown_fields)]
539pub struct PresentationPlan {
540    /// Resolved placement that every route in this plan must use.
541    pub placement: ResolvedUiPlacement,
542    /// One route per declared target view.
543    pub routes: Vec<ViewRoute>,
544}
545
546impl PresentationPlan {
547    /// Validates semantic and target contracts then routes the document to every explicit view.
548    ///
549    /// # Errors
550    ///
551    /// Returns any document capability or target topology error without selecting
552    /// a fallback placement or view.
553    pub fn build(
554        document: &UiDocument,
555        target: &DeviceTarget,
556        context: &PresentationContext,
557    ) -> Result<Self, PresentationError> {
558        document.validate()?;
559        target.validate()?;
560        let placement = match &document.placement {
561            UiPlacement::Screen => ResolvedUiPlacement::Screen,
562            UiPlacement::HeadLocked => ResolvedUiPlacement::HeadLocked,
563            UiPlacement::World { anchor } => ResolvedUiPlacement::World {
564                anchor: anchor.clone(),
565                transform: context.resolve(anchor)?,
566            },
567        };
568        Ok(Self {
569            placement,
570            routes: target
571                .views()
572                .into_iter()
573                .map(|viewport| ViewRoute {
574                    viewport,
575                    document: document.id.clone(),
576                    revision: document.revision,
577                })
578                .collect(),
579        })
580    }
581}
582
583/// Rejection reason for presentation documents, topology, or input.
584#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
585#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
586#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
587#[serde(rename_all = "snake_case")]
588pub enum PresentationError {
589    /// An identifier is empty, too long, or contains unsupported characters.
590    InvalidIdentifier,
591    /// A target has a monoscopic viewport with a non-monoscopic identity.
592    InvalidMonoView,
593    /// A stereo target does not declare one left and one right viewport.
594    InvalidStereoViews,
595    /// A target surface or viewport has zero dimensions.
596    EmptyViewport,
597    /// A viewport extends outside the declared target surface.
598    ViewportOutOfBounds,
599    /// Stereo viewports occupy overlapping regions.
600    OverlappingStereoViewports,
601    /// A document exceeds its node bound.
602    TooManyNodes,
603    /// More than one node uses an identifier.
604    DuplicateNodeId,
605    /// A world placement's anchor is absent from the resolved adapter context.
606    UnresolvedRoomAnchor,
607    /// A resolved room anchor has a nonfinite or non-rigid transform.
608    InvalidRoomAnchorTransform,
609    /// Node text or labels exceed their portable bound.
610    TextTooLong,
611    /// A slider's values or step are malformed.
612    InvalidSlider,
613    /// A text input's current value exceeds its declared bound.
614    InvalidTextInput,
615    /// An event names another document.
616    WrongDocument,
617    /// An event targets an older or newer document revision.
618    StaleRevision,
619    /// An event names no node in the document.
620    UnknownNode,
621    /// An event targets a disabled node.
622    DisabledNode,
623    /// The event kind or action does not match the node role.
624    WrongEventForNode,
625    /// A slider event is nonfinite, outside range, or misses its step.
626    SliderValueOutOfRange,
627    /// A text input event exceeds the node's declared bound.
628    TextValueTooLong,
629}
630
631impl fmt::Display for PresentationError {
632    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
633        formatter.write_str(match self {
634            Self::InvalidIdentifier => "invalid presentation identifier",
635            Self::InvalidMonoView => "monoscopic target must use the mono view identity",
636            Self::InvalidStereoViews => {
637                "stereo target must use exactly left and right view identities"
638            }
639            Self::EmptyViewport => "target surface and viewport dimensions must be positive",
640            Self::ViewportOutOfBounds => "viewport extends outside the target surface",
641            Self::OverlappingStereoViewports => "stereo viewports must not overlap",
642            Self::TooManyNodes => "UI document has too many nodes",
643            Self::DuplicateNodeId => "UI document has duplicate node identifiers",
644            Self::UnresolvedRoomAnchor => "world UI placement requires a resolved room anchor",
645            Self::InvalidRoomAnchorTransform => "resolved room anchor transform is invalid",
646            Self::TextTooLong => "UI text exceeds the portable bound",
647            Self::InvalidSlider => "slider bounds or step are invalid",
648            Self::InvalidTextInput => "text input value exceeds its declared bound",
649            Self::WrongDocument => "UI event targets another document",
650            Self::StaleRevision => "UI event targets a stale document revision",
651            Self::UnknownNode => "UI event targets an unknown node",
652            Self::DisabledNode => "UI event targets a disabled node",
653            Self::WrongEventForNode => "UI event does not match the node role or action",
654            Self::SliderValueOutOfRange => "slider event value is invalid",
655            Self::TextValueTooLong => "text input event value exceeds its declared bound",
656        })
657    }
658}
659
660#[cfg(feature = "std")]
661impl std::error::Error for PresentationError {}
662
663fn validate_identifier(value: &str) -> Result<(), PresentationError> {
664    if value.is_empty()
665        || value.len() > MAX_UI_ID_BYTES
666        || !value
667            .bytes()
668            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/'))
669    {
670        return Err(PresentationError::InvalidIdentifier);
671    }
672    Ok(())
673}
674
675fn validate_text(value: &str) -> Result<(), PresentationError> {
676    if value.len() > MAX_UI_TEXT_BYTES {
677        Err(PresentationError::TextTooLong)
678    } else {
679        Ok(())
680    }
681}
682
683fn validate_node(node: &UiNode) -> Result<(), PresentationError> {
684    validate_identifier(&node.id.0)?;
685    match &node.kind {
686        UiNodeKind::Panel { label } => {
687            if let Some(label) = label {
688                validate_text(label)?;
689            }
690        }
691        UiNodeKind::Text { text } => validate_text(text)?,
692        UiNodeKind::Button { label, .. } => {
693            validate_text(label)?;
694        }
695        UiNodeKind::Slider {
696            label,
697            value,
698            minimum,
699            maximum,
700            step,
701            ..
702        } => {
703            validate_text(label)?;
704            if !value.is_finite()
705                || !minimum.is_finite()
706                || !maximum.is_finite()
707                || minimum > maximum
708                || value < minimum
709                || value > maximum
710                || step.is_some_and(|step| !step.is_finite() || step <= 0.0)
711                || !slider_step_matches(*value, *minimum, *step)
712            {
713                return Err(PresentationError::InvalidSlider);
714            }
715        }
716        UiNodeKind::TextInput {
717            label,
718            value,
719            max_bytes,
720            ..
721        } => {
722            validate_text(label)?;
723            if *max_bytes == 0 || value.len() > *max_bytes as usize {
724                return Err(PresentationError::InvalidTextInput);
725            }
726        }
727        UiNodeKind::Toggle { label, .. } => {
728            validate_text(label)?;
729        }
730        UiNodeKind::ImageResource { resource, alt } => {
731            validate_identifier(&resource.0)?;
732            validate_text(alt)?;
733        }
734    }
735    Ok(())
736}
737
738fn validate_surface(width: u32, height: u32) -> Result<(), PresentationError> {
739    if width == 0 || height == 0 {
740        Err(PresentationError::EmptyViewport)
741    } else {
742        Ok(())
743    }
744}
745
746fn validate_viewport(
747    viewport: Viewport,
748    surface_width: u32,
749    surface_height: u32,
750) -> Result<(), PresentationError> {
751    if viewport.width == 0 || viewport.height == 0 {
752        return Err(PresentationError::EmptyViewport);
753    }
754    let x_end = i64::from(viewport.x) + i64::from(viewport.width);
755    let y_end = i64::from(viewport.y) + i64::from(viewport.height);
756    if viewport.x < 0
757        || viewport.y < 0
758        || x_end > i64::from(surface_width)
759        || y_end > i64::from(surface_height)
760    {
761        return Err(PresentationError::ViewportOutOfBounds);
762    }
763    Ok(())
764}
765
766fn viewports_overlap(left: Viewport, right: Viewport) -> bool {
767    let left_x_end = i64::from(left.x) + i64::from(left.width);
768    let left_y_end = i64::from(left.y) + i64::from(left.height);
769    let right_x_end = i64::from(right.x) + i64::from(right.width);
770    let right_y_end = i64::from(right.y) + i64::from(right.height);
771    i64::from(left.x) < right_x_end
772        && i64::from(right.x) < left_x_end
773        && i64::from(left.y) < right_y_end
774        && i64::from(right.y) < left_y_end
775}
776
777fn slider_step_matches(value: f32, minimum: f32, step: Option<f32>) -> bool {
778    match step {
779        None => true,
780        Some(step) => {
781            let steps = (value - minimum) / step;
782            (steps - libm::roundf(steps)).abs() <= 1e-4
783        }
784    }
785}
786
787fn validate_transform(transform: Transform) -> Result<(), PresentationError> {
788    let translation = transform.translation;
789    let rotation = transform.rotation;
790    let scale = transform.scale;
791    if !translation.x.is_finite()
792        || !translation.y.is_finite()
793        || !translation.z.is_finite()
794        || !rotation.x.is_finite()
795        || !rotation.y.is_finite()
796        || !rotation.z.is_finite()
797        || !rotation.w.is_finite()
798        || !scale.x.is_finite()
799        || !scale.y.is_finite()
800        || !scale.z.is_finite()
801        || scale.x <= 0.0
802        || scale.y <= 0.0
803        || scale.z <= 0.0
804    {
805        return Err(PresentationError::InvalidRoomAnchorTransform);
806    }
807    let length_squared = rotation.x * rotation.x
808        + rotation.y * rotation.y
809        + rotation.z * rotation.z
810        + rotation.w * rotation.w;
811    if (length_squared - 1.0).abs() > 1e-9 {
812        return Err(PresentationError::InvalidRoomAnchorTransform);
813    }
814    Ok(())
815}