1use alloc::{string::String, vec, vec::Vec};
10use core::fmt;
11use serde::{Deserialize, Serialize};
12
13use crate::domain::{Transform, wire_u64};
14
15pub const MAX_UI_NODES: usize = 128;
17pub const MAX_UI_ID_BYTES: usize = 128;
19pub 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#[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#[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 Mono,
62 Left,
64 Right,
66}
67
68#[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 pub view: ViewIdentity,
76 pub x: i32,
78 pub y: i32,
80 pub width: u32,
82 pub height: u32,
84}
85
86#[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 Mono {
95 surface_width: u32,
96 surface_height: u32,
97 view: Viewport,
98 },
99 Stereo {
101 surface_width: u32,
102 surface_height: u32,
103 left: Viewport,
104 right: Viewport,
105 },
106}
107
108impl DeviceTarget {
109 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#[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 Screen,
165 HeadLocked,
167 World { anchor: RoomAnchorId },
169}
170
171#[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 pub id: RoomAnchorId,
180 pub transform: Transform,
182}
183
184#[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 pub room_anchors: Vec<ResolvedRoomAnchor>,
192}
193
194#[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 pub id: UiDocumentId,
203 #[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 pub placement: UiPlacement,
214 #[serde(default)]
219 pub modal: bool,
220 pub nodes: Vec<UiNode>,
222}
223
224impl UiDocument {
225 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 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 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#[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 pub id: UiNodeId,
381 pub enabled: bool,
383 #[serde(flatten)]
385 pub kind: UiNodeKind,
386}
387
388#[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 Panel { label: Option<String> },
397 Text { text: String },
399 Button { label: String, action: UiActionId },
401 Slider {
403 label: String,
404 value: f32,
405 minimum: f32,
406 maximum: f32,
407 step: Option<f32>,
408 action: UiActionId,
409 },
410 TextInput {
412 label: String,
413 value: String,
414 max_bytes: u16,
415 action: UiActionId,
416 },
417 Toggle {
419 label: String,
420 value: bool,
421 action: UiActionId,
422 },
423 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#[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 Forward,
447 Backward,
449}
450
451#[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 pub document: UiDocumentId,
458 #[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 pub node: UiNodeId,
465 #[serde(flatten)]
467 pub action: UiEventAction,
468}
469
470#[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 { action: UiActionId },
479 SetSlider { action: UiActionId, value: f32 },
481 SetText { action: UiActionId, value: String },
483 SetToggle { action: UiActionId, value: bool },
485}
486
487#[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 pub node: UiNodeId,
495 pub action: UiActionId,
497}
498
499#[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 pub viewport: Viewport,
507 pub document: UiDocumentId,
509 #[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#[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 Screen,
525 HeadLocked,
527 World {
529 anchor: RoomAnchorId,
530 transform: Transform,
531 },
532}
533
534#[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 pub placement: ResolvedUiPlacement,
542 pub routes: Vec<ViewRoute>,
544}
545
546impl PresentationPlan {
547 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#[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 InvalidIdentifier,
591 InvalidMonoView,
593 InvalidStereoViews,
595 EmptyViewport,
597 ViewportOutOfBounds,
599 OverlappingStereoViewports,
601 TooManyNodes,
603 DuplicateNodeId,
605 UnresolvedRoomAnchor,
607 InvalidRoomAnchorTransform,
609 TextTooLong,
611 InvalidSlider,
613 InvalidTextInput,
615 WrongDocument,
617 StaleRevision,
619 UnknownNode,
621 DisabledNode,
623 WrongEventForNode,
625 SliderValueOutOfRange,
627 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}