Skip to main content

konjure_lang/
ui.rs

1//! Rust-owned projection of language controls into portable semantic UI.
2//!
3//! Hosts never interpret component fields or invent callback names. They display
4//! [`UiDocument`] and return [`UiEvent`]; Rust resolves the checked function value.
5//! A live document revision identifies the loaded source, not the simulation tick:
6//! stepping cannot invalidate a click, but replacing a program always does. Inputs
7//! are checked against the current node and bounds before the callback is invoked.
8
9use crate::{LiveCommand, LiveDiagnostic, LiveError, LiveRuntime, Snapshot, Value};
10use konjure_sdk::presentation::{
11    PresentationError, UiActionId, UiDocument, UiDocumentId, UiEvent, UiEventAction, UiNode,
12    UiNodeId, UiNodeKind, UiPlacement,
13};
14
15struct Binding {
16    node: UiNodeId,
17    entity: u64,
18    component: String,
19}
20
21fn project(
22    snapshot: &Snapshot,
23    revision: u64,
24) -> Result<(UiDocument, Vec<Binding>), PresentationError> {
25    let mut document = UiDocument {
26        id: UiDocumentId("scene-controls".into()),
27        revision,
28        placement: UiPlacement::Screen,
29        modal: false,
30        nodes: Vec::new(),
31    };
32    let mut bindings = Vec::new();
33    for entity in &snapshot.entities {
34        for (component, value) in &entity.components {
35            let Value::Record(record) = value else {
36                continue;
37            };
38            let text = |name: &str| match record.fields.get(name) {
39                Some(Value::Text(value)) => value.to_string(),
40                _ => String::new(),
41            };
42            let number = |name: &str| match record.fields.get(name) {
43                Some(Value::Number(value)) => *value as f32,
44                _ => f32::NAN,
45            };
46            let action = UiActionId(
47                u32::try_from(bindings.len() + 1).map_err(|_| PresentationError::TooManyNodes)?,
48            );
49            let kind = match component.as_str() {
50                "Button" => UiNodeKind::Button {
51                    label: text("label"),
52                    action,
53                },
54                "Slider" => UiNodeKind::Slider {
55                    label: text("label"),
56                    value: number("value"),
57                    minimum: number("min"),
58                    maximum: number("max"),
59                    step: None,
60                    action,
61                },
62                "Text" => UiNodeKind::Text {
63                    text: text("value"),
64                },
65                _ => continue,
66            };
67            let node = UiNodeId(format!("entity_{}.{}", entity.id, component));
68            let interactive = matches!(kind, UiNodeKind::Button { .. } | UiNodeKind::Slider { .. });
69            if interactive {
70                bindings.push(Binding {
71                    node: node.clone(),
72                    entity: entity.id,
73                    component: component.clone(),
74                });
75            }
76            document.nodes.push(UiNode {
77                id: node,
78                enabled: !snapshot.finished
79                    && (!interactive
80                        || matches!(record.fields.get("action"), Some(Value::Function(_)))),
81                kind,
82            });
83        }
84    }
85    document.validate()?;
86    Ok((document, bindings))
87}
88
89/// Projects current language controls without executing callbacks or opening host resources.
90///
91/// # Errors
92/// Returns a presentation error for invalid control bounds or excessive UI content.
93pub fn document(snapshot: &Snapshot, revision: u64) -> Result<UiDocument, PresentationError> {
94    project(snapshot, revision).map(|(document, _)| document)
95}
96
97fn command(
98    snapshot: &Snapshot,
99    revision: u64,
100    event: &UiEvent,
101) -> Result<LiveCommand, PresentationError> {
102    let (document, bindings) = project(snapshot, revision)?;
103    document.validate_event(event)?;
104    let binding = bindings
105        .iter()
106        .find(|binding| binding.node == event.node)
107        .ok_or(PresentationError::UnknownNode)?;
108    let arguments = match event.action {
109        UiEventAction::Activate { .. } => Vec::new(),
110        UiEventAction::SetSlider { value, .. } => vec![Value::Number(f64::from(value))],
111        _ => return Err(PresentationError::WrongEventForNode),
112    };
113    Ok(LiveCommand::InvokeComponent {
114        entity: binding.entity,
115        component: binding.component.clone(),
116        field: "action".into(),
117        arguments,
118    })
119}
120
121fn live_error(error: PresentationError) -> LiveError {
122    LiveError::Runtime {
123        diagnostics: vec![LiveDiagnostic {
124            code: "ui_event".into(),
125            message: error.to_string(),
126        }],
127    }
128}
129
130impl LiveRuntime {
131    /// Returns source-revision-bound controls for this accepted session.
132    pub fn ui_document(&self) -> Result<UiDocument, LiveError> {
133        document(
134            &self.snapshot().ok_or(LiveError::NotLoaded)?,
135            self.source_revision(),
136        )
137        .map_err(live_error)
138    }
139
140    /// Validates a displayed control and resolves its Rust-owned callback command.
141    /// The relay orders the returned command through [`Self::apply`].
142    pub fn ui_command(&self, event: &UiEvent) -> Result<LiveCommand, LiveError> {
143        command(
144            &self.snapshot().ok_or(LiveError::NotLoaded)?,
145            self.source_revision(),
146            event,
147        )
148        .map_err(live_error)
149    }
150}
151
152impl crate::spatial::SpatialRuntime {
153    /// Returns semantic controls for a standalone interpreter instance.
154    pub fn ui_document(&self) -> Result<UiDocument, PresentationError> {
155        document(&self.snapshot(), 0)
156    }
157
158    /// Validates and executes one semantic input transactionally in a standalone instance.
159    pub fn invoke_ui(&mut self, event: &UiEvent) -> Result<Snapshot, crate::Diagnostic> {
160        let command = command(&self.snapshot(), 0, event).map_err(|error| {
161            crate::Diagnostic::new("ui_event", error.to_string(), crate::Span::default())
162        })?;
163        let LiveCommand::InvokeComponent {
164            entity,
165            component,
166            field,
167            arguments,
168        } = command
169        else {
170            unreachable!()
171        };
172        self.invoke_component(entity, &component, &field, &arguments)?;
173        Ok(self.snapshot())
174    }
175}