Skip to main content

agpu/
runtime.rs

1//! Elm-architecture runtime for agpu applications.
2//!
3//! - **Model**: Application state
4//! - **Message**: Events that update state
5//! - **Update**: Pure function `(Model, Msg) -> (Model, Command)`
6//! - **View**: Pure function `Model -> UI description`
7
8use std::time::Duration;
9
10use crate::core::Rect;
11use crate::ontology::OntologyRegistry;
12
13/// A token that can be checked to determine if a task should be cancelled.
14#[derive(Clone)]
15pub struct CancellationToken {
16    cancelled: std::sync::Arc<std::sync::atomic::AtomicBool>,
17}
18
19impl CancellationToken {
20    /// Create a new cancellation token.
21    pub fn new() -> Self {
22        Self {
23            cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
24        }
25    }
26
27    /// Check if cancellation has been requested.
28    pub fn is_cancelled(&self) -> bool {
29        self.cancelled.load(std::sync::atomic::Ordering::Relaxed)
30    }
31
32    /// Request cancellation.
33    pub fn cancel(&self) {
34        self.cancelled
35            .store(true, std::sync::atomic::Ordering::Relaxed);
36    }
37}
38
39impl Default for CancellationToken {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45/// A command returned from [`Model::update`] to request side effects.
46pub enum Command<Msg> {
47    /// No operation.
48    None,
49    /// Quit the application.
50    Quit,
51    /// Execute multiple commands.
52    Batch(Vec<Command<Msg>>),
53    /// Produce a message asynchronously after the current update.
54    Message(Msg),
55    /// Set the tick interval for animation / periodic updates.
56    SetTickRate(Duration),
57    /// Request that the agent ontology registry be exported to JSON.
58    ExportOntology,
59    /// Execute an agent action on a widget identified by agent_id.
60    AgentAction {
61        agent_id: String,
62        action: String,
63        params: serde_json::Value,
64    },
65    /// Spawn an asynchronous task that eventually produces a message.
66    Task(Box<dyn FnOnce() -> Msg + Send>),
67    /// Spawn an async task with a timeout. If the task doesn't complete
68    /// within the given duration, the timeout message is delivered instead.
69    TaskWithTimeout {
70        task: Box<dyn FnOnce() -> Msg + Send>,
71        timeout: Duration,
72        on_timeout: Msg,
73    },
74    /// Spawn a cancellable async task. The closure receives a [`CancellationToken`]
75    /// that it can poll to exit early.
76    TaskCancellable {
77        task: Box<dyn FnOnce(CancellationToken) -> Msg + Send>,
78        token: CancellationToken,
79    },
80}
81
82impl<Msg: std::fmt::Debug> std::fmt::Debug for Command<Msg> {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::None => write!(f, "None"),
86            Self::Quit => write!(f, "Quit"),
87            Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
88            Self::Message(msg) => f.debug_tuple("Message").field(msg).finish(),
89            Self::SetTickRate(d) => f.debug_tuple("SetTickRate").field(d).finish(),
90            Self::ExportOntology => write!(f, "ExportOntology"),
91            Self::AgentAction {
92                agent_id,
93                action,
94                params,
95            } => f
96                .debug_struct("AgentAction")
97                .field("agent_id", agent_id)
98                .field("action", action)
99                .field("params", params)
100                .finish(),
101            Self::Task(_) => write!(f, "Task(<fn>)"),
102            Self::TaskWithTimeout { timeout, .. } => {
103                write!(f, "TaskWithTimeout({}ms)", timeout.as_millis())
104            }
105            Self::TaskCancellable { .. } => write!(f, "TaskCancellable(<fn>)"),
106        }
107    }
108}
109
110/// The core trait for application models (Elm Architecture).
111pub trait Model: Sized {
112    /// The message type for this application.
113    type Msg: Send + 'static;
114
115    /// Handle a message and return an updated model plus optional command.
116    fn update(&mut self, msg: Self::Msg) -> Command<Self::Msg>;
117
118    /// Render the model into the GUI frame.
119    fn view(&self, frame: &mut Frame<'_>);
120
121    /// Convert a raw event into an application message.
122    /// Return `None` to ignore the event.
123    fn handle_event(&self, event: crate::event::Event) -> Option<Self::Msg>;
124
125    /// Called once at startup. Return an initial command.
126    fn init(&self) -> Command<Self::Msg> {
127        Command::None
128    }
129
130    /// Called when the agent ontology is exported. Override to customize.
131    fn register_ontology(&self, _registry: &mut OntologyRegistry) {}
132
133    /// Application title (used as window title).
134    fn title(&self) -> &str {
135        "agpu App"
136    }
137
138    /// Return subscriptions for this model. Called after each update.
139    fn subscriptions(&self) -> Vec<Subscription<Self::Msg>> {
140        Vec::new()
141    }
142
143    /// Return the current route. Override for multi-page apps.
144    fn current_route(&self) -> &str {
145        "/"
146    }
147}
148
149/// A rendering frame — abstraction over the GUI backend.
150///
151/// During `Model::view`, the frame provides methods to draw widgets
152/// and manage the UI tree for agent discoverability.
153pub struct Frame<'a> {
154    /// The available drawing area.
155    pub area: Rect,
156    /// The hit map for mouse routing.
157    pub hit_map: &'a mut crate::event::HitMap,
158    /// The ontology tree being built for this frame.
159    ui_nodes: Vec<crate::ontology::UiNode>,
160    /// The painter for this frame.
161    painter: &'a mut dyn crate::paint::Painter,
162}
163
164impl<'a> Frame<'a> {
165    /// Create a new frame with the given area, hit map, and painter.
166    pub fn new(
167        area: Rect,
168        hit_map: &'a mut crate::event::HitMap,
169        painter: &'a mut dyn crate::paint::Painter,
170    ) -> Self {
171        Self {
172            area,
173            hit_map,
174            ui_nodes: Vec::new(),
175            painter,
176        }
177    }
178
179    /// Get a mutable reference to the painter for this frame.
180    pub fn painter(&mut self) -> &mut dyn crate::paint::Painter {
181        self.painter
182    }
183
184    /// Register a widget in the UI tree for agent discoverability.
185    pub fn register_widget(&mut self, node: crate::ontology::UiNode) {
186        self.ui_nodes.push(node);
187    }
188
189    /// Register a hitbox for mouse event routing.
190    pub fn register_hitbox(&mut self, agent_id: impl Into<String>, bounds: Rect, z_order: u32) {
191        self.hit_map.register(agent_id, bounds, z_order);
192    }
193
194    /// Take the collected UI nodes (consumed by the runtime after rendering).
195    pub fn take_nodes(&mut self) -> Vec<crate::ontology::UiNode> {
196        std::mem::take(&mut self.ui_nodes)
197    }
198}
199
200/// Configuration for the application runner.
201pub struct ProgramOptions {
202    /// Tick interval for animation. `None` disables ticking.
203    pub tick_rate: Option<Duration>,
204    /// Initial window width in logical pixels.
205    pub width: f32,
206    /// Initial window height in logical pixels.
207    pub height: f32,
208    /// Whether to start in fullscreen.
209    pub fullscreen: bool,
210    /// Whether the window is resizable.
211    pub resizable: bool,
212    /// Whether to enable vsync.
213    pub vsync: bool,
214    /// Whether to use a transparent window.
215    pub transparent: bool,
216    /// GPU backend preference (Vulkan-first by default).
217    pub backend: crate::types::BackendPreference,
218    /// MSAA sample count (1 = disabled, 4 = recommended).
219    pub msaa_samples: u32,
220}
221
222impl Default for ProgramOptions {
223    fn default() -> Self {
224        Self {
225            tick_rate: Some(Duration::from_millis(16)), // ~60fps
226            width: 800.0,
227            height: 600.0,
228            fullscreen: false,
229            resizable: true,
230            vsync: true,
231            transparent: false,
232            backend: crate::types::BackendPreference::default(),
233            msaa_samples: 4,
234        }
235    }
236}
237
238// ── Subscription system ─────────────────────────────────────────────
239
240/// An Elm-style subscription — a source of events managed by the runtime.
241pub enum Subscription<Msg> {
242    /// Timer subscription: produces a message at the given interval.
243    Timer {
244        /// Unique identifier for this subscription.
245        id: String,
246        /// The interval between messages.
247        interval: Duration,
248        /// Factory function producing the message each tick.
249        msg: Box<dyn Fn() -> Msg + Send>,
250    },
251    /// One-shot delay: produces a single message after the given duration.
252    Delay {
253        id: String,
254        duration: Duration,
255        msg: Msg,
256    },
257}
258
259impl<Msg: std::fmt::Debug> std::fmt::Debug for Subscription<Msg> {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match self {
262            Self::Timer { id, interval, .. } => f
263                .debug_struct("Timer")
264                .field("id", id)
265                .field("interval", interval)
266                .finish(),
267            Self::Delay { id, duration, msg } => f
268                .debug_struct("Delay")
269                .field("id", id)
270                .field("duration", duration)
271                .field("msg", msg)
272                .finish(),
273        }
274    }
275}
276
277// ── Router ──────────────────────────────────────────────────────────
278
279/// Simple URL-style router for multi-page applications.
280#[derive(Debug, Clone)]
281pub struct Router {
282    current: String,
283    history: Vec<String>,
284}
285
286impl Router {
287    /// Create a new router starting at the given route.
288    pub fn new(initial: impl Into<String>) -> Self {
289        let initial = initial.into();
290        Self {
291            current: initial.clone(),
292            history: vec![initial],
293        }
294    }
295
296    /// Navigate to a new route, pushing the current one onto the history stack.
297    pub fn navigate(&mut self, route: impl Into<String>) {
298        let route = route.into();
299        self.history.push(self.current.clone());
300        self.current = route;
301    }
302
303    /// Go back to the previous route. Returns `true` if successful.
304    pub fn back(&mut self) -> bool {
305        if let Some(prev) = self.history.pop() {
306            self.current = prev;
307            true
308        } else {
309            false
310        }
311    }
312
313    /// Get the current route.
314    pub fn current(&self) -> &str {
315        &self.current
316    }
317
318    /// Get the depth of the history stack.
319    pub fn history_len(&self) -> usize {
320        self.history.len()
321    }
322
323    /// Match a route pattern against the current route.
324    /// Supports simple patterns like "/users/:id" where `:id` matches any segment.
325    pub fn matches(&self, pattern: &str) -> Option<Vec<(String, String)>> {
326        let route_parts: Vec<&str> = self.current.split('/').collect();
327        let pattern_parts: Vec<&str> = pattern.split('/').collect();
328
329        if route_parts.len() != pattern_parts.len() {
330            return None;
331        }
332
333        let mut params = Vec::new();
334        for (r, p) in route_parts.iter().zip(pattern_parts.iter()) {
335            if let Some(name) = p.strip_prefix(':') {
336                params.push((name.to_string(), r.to_string()));
337            } else if r != p {
338                return None;
339            }
340        }
341        Some(params)
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn cancellation_token_not_cancelled_initially() {
351        let token = CancellationToken::new();
352        assert!(!token.is_cancelled());
353    }
354
355    #[test]
356    fn cancellation_token_cancel() {
357        let token = CancellationToken::new();
358        token.cancel();
359        assert!(token.is_cancelled());
360    }
361
362    #[test]
363    fn cancellation_token_clone_shares_state() {
364        let token = CancellationToken::new();
365        let clone = token.clone();
366        token.cancel();
367        assert!(clone.is_cancelled());
368    }
369
370    #[test]
371    fn cancellation_token_default() {
372        let token = CancellationToken::default();
373        assert!(!token.is_cancelled());
374    }
375
376    #[test]
377    fn program_options_defaults() {
378        let opts = ProgramOptions::default();
379        assert_eq!(opts.width, 800.0);
380        assert_eq!(opts.height, 600.0);
381        assert!(!opts.fullscreen);
382        assert!(opts.resizable);
383        assert!(opts.vsync);
384        assert!(!opts.transparent);
385        assert!(opts.tick_rate.is_some());
386    }
387
388    #[test]
389    fn command_debug_variants() {
390        let none: Command<String> = Command::None;
391        assert_eq!(format!("{:?}", none), "None");
392
393        let quit: Command<String> = Command::Quit;
394        assert_eq!(format!("{:?}", quit), "Quit");
395
396        let msg: Command<String> = Command::Message("hello".into());
397        assert!(format!("{:?}", msg).contains("hello"));
398
399        let export: Command<String> = Command::ExportOntology;
400        assert_eq!(format!("{:?}", export), "ExportOntology");
401    }
402
403    #[test]
404    fn frame_take_nodes() {
405        let mut hit_map = crate::event::HitMap::new();
406        let mut painter = crate::paint::NullPainter;
407        let mut frame = Frame::new(
408            Rect::new(0.0, 0.0, 800.0, 600.0),
409            &mut hit_map,
410            &mut painter,
411        );
412
413        assert!(frame.take_nodes().is_empty());
414
415        frame.register_widget(crate::ontology::UiNode::new(
416            "Button",
417            crate::ontology::SemanticRole::Action,
418        ));
419        let nodes = frame.take_nodes();
420        assert_eq!(nodes.len(), 1);
421        assert!(frame.take_nodes().is_empty()); // consumed
422    }
423
424    #[test]
425    fn frame_register_hitbox() {
426        let mut hit_map = crate::event::HitMap::new();
427        let mut painter = crate::paint::NullPainter;
428        let bounds = Rect::new(10.0, 10.0, 50.0, 50.0);
429        {
430            let mut frame = Frame::new(
431                Rect::new(0.0, 0.0, 800.0, 600.0),
432                &mut hit_map,
433                &mut painter,
434            );
435            frame.register_hitbox("btn-1", bounds, 0);
436        }
437        assert_eq!(
438            hit_map.hit_test(crate::core::Position::new(30.0, 30.0)),
439            Some("btn-1")
440        );
441    }
442
443    #[test]
444    fn router_basic_navigation() {
445        let mut router = Router::new("/");
446        assert_eq!(router.current(), "/");
447        router.navigate("/about");
448        assert_eq!(router.current(), "/about");
449        assert!(router.back());
450        assert_eq!(router.current(), "/");
451    }
452
453    #[test]
454    fn router_pattern_matching() {
455        let router = Router::new("/users/42");
456        let params = router.matches("/users/:id").unwrap();
457        assert_eq!(params.len(), 1);
458        assert_eq!(params[0].0, "id");
459        assert_eq!(params[0].1, "42");
460
461        assert!(router.matches("/posts/:id").is_none());
462    }
463
464    #[test]
465    fn router_history_depth() {
466        let mut router = Router::new("/");
467        assert_eq!(router.history_len(), 1);
468        router.navigate("/a");
469        assert_eq!(router.history_len(), 2);
470        router.navigate("/b");
471        assert_eq!(router.history_len(), 3);
472        router.back();
473        assert_eq!(router.history_len(), 2);
474    }
475
476    #[test]
477    fn program_options_msaa_default() {
478        let opts = ProgramOptions::default();
479        assert_eq!(opts.msaa_samples, 4);
480    }
481
482    #[test]
483    fn subscription_timer_debug() {
484        let sub: Subscription<String> = Subscription::Timer {
485            id: "test".into(),
486            interval: Duration::from_secs(1),
487            msg: Box::new(|| "tick".into()),
488        };
489        let dbg = format!("{:?}", sub);
490        assert!(dbg.contains("Timer"));
491        assert!(dbg.contains("test"));
492    }
493}