Skip to main content

agpu/
app.rs

1//! Application runner — the agpu equivalent of eframe's `run_native`.
2//!
3//! Uses winit for windowing and event dispatch, wgpu for GPU rendering,
4//! and integrates the agpu `Model` trait to drive the Elm architecture.
5//!
6//! Every layer is agent-discoverable via the ontology.
7
8use crate::context::GpuContext;
9use crate::core::{Position, Rect};
10use crate::event::{HitMap, convert_window_event};
11use crate::ontology::{OntologyRegistry, SemanticRole, UiNode, UiTree};
12use crate::painter::AgpuPainter;
13use crate::renderer::ShapeRenderer;
14use crate::runtime::{Command, Frame, Model, ProgramOptions, Subscription};
15use crate::text::TextEngine;
16use crate::types::BackendPreference;
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19use winit::application::ApplicationHandler;
20use winit::event::WindowEvent;
21use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
22use winit::window::{Window, WindowId};
23
24/// The agpu application runner.
25///
26/// Wraps an agpu `Model` and drives it with wgpu rendering and winit events.
27pub struct AgpuApp<M: Model> {
28    model: M,
29    options: ProgramOptions,
30}
31
32impl<M: Model + 'static> AgpuApp<M> {
33    /// Create a new application with the given model.
34    pub fn new(model: M) -> Self {
35        Self {
36            model,
37            options: ProgramOptions::default(),
38        }
39    }
40
41    /// Override the default program options.
42    pub fn with_options(mut self, options: ProgramOptions) -> Self {
43        self.options = options;
44        self
45    }
46
47    /// Set the GPU backend preference.
48    pub fn with_backend(mut self, preference: BackendPreference) -> Self {
49        self.options.backend = preference;
50        self
51    }
52
53    /// Run the application. Blocks until the window closes.
54    pub fn run(self) -> Result<(), Box<dyn std::error::Error>> {
55        let event_loop = EventLoop::new()?;
56        event_loop.set_control_flow(ControlFlow::Poll);
57
58        let mut handler = AppHandler::Uninitialised {
59            model: self.model,
60            options: self.options,
61        };
62
63        event_loop.run_app(&mut handler)?;
64        Ok(())
65    }
66}
67
68// ── Internal handler ────────────────────────────────────────────────
69
70/// Internal state machine — uninitialised until the first `resumed` call
71/// because wgpu/winit require an active event loop to create the window.
72enum AppHandler<M: Model> {
73    Uninitialised {
74        model: M,
75        options: ProgramOptions,
76    },
77    Running(Box<RunningApp<M>>),
78    /// Sentinel after an error or close.
79    Exited,
80}
81
82struct RunningApp<M: Model> {
83    model: M,
84    window: Arc<Window>,
85    gpu: GpuContext,
86    surface: wgpu::Surface<'static>,
87    surface_config: wgpu::SurfaceConfiguration,
88    surface_format: wgpu::TextureFormat,
89    shapes: ShapeRenderer,
90    text: TextEngine,
91    hit_map: HitMap,
92    ontology: OntologyRegistry,
93    running: bool,
94    cursor_position: Position,
95    tick_rate: Option<Duration>,
96    last_tick: Instant,
97    msaa_texture: Option<wgpu::TextureView>,
98    active_timers: Vec<(String, Duration, Instant)>,
99    pending_delays: Vec<(String, Instant)>,
100}
101
102impl<M: Model + 'static> RunningApp<M> {
103    fn new(
104        model: M,
105        options: &ProgramOptions,
106        event_loop: &ActiveEventLoop,
107    ) -> Result<Self, Box<dyn std::error::Error>> {
108        let attrs = Window::default_attributes()
109            .with_title(model.title())
110            .with_inner_size(winit::dpi::LogicalSize::new(
111                options.width as f64,
112                options.height as f64,
113            ))
114            .with_resizable(options.resizable);
115
116        let window = Arc::new(event_loop.create_window(attrs)?);
117
118        // Create a temporary instance to create the surface,
119        // then let GpuContext handle backend selection with fallback.
120        let preference = options.backend;
121        let backends = preference.to_backends();
122        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
123            backends,
124            ..Default::default()
125        });
126
127        let surface = instance.create_surface(window.clone())?;
128
129        let gpu = pollster::block_on(GpuContext::new(&surface, preference))?;
130
131        let size = window.inner_size();
132        let surface_caps = surface.get_capabilities(gpu.adapter());
133        let format = surface_caps
134            .formats
135            .iter()
136            .find(|f| !f.is_srgb())
137            .copied()
138            .unwrap_or(surface_caps.formats[0]);
139
140        let present_mode = if options.vsync {
141            wgpu::PresentMode::AutoVsync
142        } else {
143            wgpu::PresentMode::AutoNoVsync
144        };
145
146        let surface_config = wgpu::SurfaceConfiguration {
147            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
148            format,
149            width: size.width.max(1),
150            height: size.height.max(1),
151            present_mode,
152            alpha_mode: surface_caps.alpha_modes[0],
153            view_formats: vec![],
154            desired_maximum_frame_latency: 3,
155        };
156        surface.configure(gpu.device(), &surface_config);
157
158        let shapes = ShapeRenderer::new(
159            &gpu,
160            format,
161            options.width,
162            options.height,
163            options.msaa_samples,
164        );
165        let text = TextEngine::new(&gpu, format);
166
167        let msaa_texture = if options.msaa_samples > 1 {
168            Some(create_msaa_texture(
169                gpu.device(),
170                format,
171                size.width.max(1),
172                size.height.max(1),
173                options.msaa_samples,
174            ))
175        } else {
176            None
177        };
178
179        let mut ontology = OntologyRegistry::new();
180        model.register_ontology(&mut ontology);
181
182        let init_cmd = model.init();
183
184        let mut app = Self {
185            model,
186            window,
187            gpu,
188            surface,
189            surface_config,
190            surface_format: format,
191            shapes,
192            text,
193            hit_map: HitMap::new(),
194            ontology,
195            running: true,
196            cursor_position: Position::ZERO,
197            tick_rate: options.tick_rate,
198            last_tick: Instant::now(),
199            msaa_texture,
200            active_timers: Vec::new(),
201            pending_delays: Vec::new(),
202        };
203
204        app.process_command(init_cmd);
205        Ok(app)
206    }
207
208    fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
209        if new_size.width > 0 && new_size.height > 0 {
210            self.surface_config.width = new_size.width;
211            self.surface_config.height = new_size.height;
212            self.surface
213                .configure(self.gpu.device(), &self.surface_config);
214            self.shapes
215                .set_viewport(new_size.width as f32, new_size.height as f32);
216            if self.shapes.sample_count() > 1 {
217                self.msaa_texture = Some(create_msaa_texture(
218                    self.gpu.device(),
219                    self.surface_format,
220                    new_size.width,
221                    new_size.height,
222                    self.shapes.sample_count(),
223                ));
224            }
225        }
226    }
227
228    fn render(&mut self) {
229        let frame = match self.surface.get_current_texture() {
230            Ok(f) => f,
231            Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
232                self.surface
233                    .configure(self.gpu.device(), &self.surface_config);
234                return;
235            }
236            Err(wgpu::SurfaceError::OutOfMemory) => {
237                log::error!("agpu: out of GPU memory");
238                self.running = false;
239                return;
240            }
241            Err(e) => {
242                log::warn!("agpu: surface error: {e:?}");
243                return;
244            }
245        };
246
247        let view = frame
248            .texture
249            .create_view(&wgpu::TextureViewDescriptor::default());
250
251        // Begin frame
252        self.shapes.begin_frame();
253        self.hit_map.clear();
254
255        let area = Rect::new(
256            0.0,
257            0.0,
258            self.surface_config.width as f32,
259            self.surface_config.height as f32,
260        );
261
262        // Run model view
263        {
264            let mut painter = AgpuPainter::new(&mut self.shapes, &mut self.text);
265            let mut frame = Frame::new(area, &mut self.hit_map, &mut painter);
266            self.model.view(&mut frame);
267
268            let nodes = frame.take_nodes();
269            if !nodes.is_empty() {
270                let mut root = UiNode::new("root", SemanticRole::Container);
271                root.children = nodes;
272                self.ontology.set_tree(UiTree::new(root));
273            }
274        }
275
276        // GPU render pass
277        let mut encoder =
278            self.gpu
279                .device()
280                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
281                    label: Some("agpu_encoder"),
282                });
283
284        {
285            let (target_view, resolve_target) = if let Some(msaa_view) = &self.msaa_texture {
286                (msaa_view, Some(&view))
287            } else {
288                (&view, None)
289            };
290            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
291                label: Some("agpu_pass"),
292                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
293                    view: target_view,
294                    resolve_target,
295                    ops: wgpu::Operations {
296                        load: wgpu::LoadOp::Clear(wgpu::Color {
297                            r: 0.08,
298                            g: 0.08,
299                            b: 0.10,
300                            a: 1.0,
301                        }),
302                        store: wgpu::StoreOp::Store,
303                    },
304                })],
305                depth_stencil_attachment: None,
306                timestamp_writes: None,
307                occlusion_query_set: None,
308            });
309
310            self.shapes.flush(&self.gpu, &mut pass);
311            self.text.flush(
312                &self.gpu,
313                &mut pass,
314                self.surface_config.width,
315                self.surface_config.height,
316            );
317        }
318
319        self.gpu.queue().submit(std::iter::once(encoder.finish()));
320        frame.present();
321        self.text.trim();
322    }
323
324    fn process_command(&mut self, cmd: Command<M::Msg>) {
325        match cmd {
326            Command::None => {}
327            Command::Quit => {
328                self.running = false;
329            }
330            Command::Batch(cmds) => {
331                for c in cmds {
332                    self.process_command(c);
333                }
334            }
335            Command::Message(msg) => {
336                let cmd = self.model.update(msg);
337                self.process_command(cmd);
338            }
339            Command::SetTickRate(d) => {
340                self.tick_rate = Some(d);
341            }
342            Command::ExportOntology => {
343                self.model.register_ontology(&mut self.ontology);
344            }
345            Command::AgentAction {
346                agent_id,
347                action,
348                params,
349            } => {
350                // Validate params against the ontology schema if a node exists
351                if let Some(node) = self.ontology.find_node(&agent_id) {
352                    if let Err(e) =
353                        self.ontology
354                            .validate_action_params(&node.widget_type, &action, &params)
355                    {
356                        log::warn!("AgentAction validation failed for {agent_id}.{action}: {e}");
357                        return;
358                    }
359                }
360                // Dispatch to the model as an event
361                let ev = crate::event::Event::AgentAction {
362                    agent_id,
363                    action,
364                    params,
365                };
366                if let Some(msg) = self.model.handle_event(ev) {
367                    let cmd = self.model.update(msg);
368                    self.process_command(cmd);
369                }
370            }
371            Command::Task(task) => {
372                let msg = task();
373                let cmd = self.model.update(msg);
374                self.process_command(cmd);
375            }
376            Command::TaskWithTimeout {
377                task,
378                timeout,
379                on_timeout,
380            } => {
381                use std::sync::mpsc;
382                let (tx, rx) = mpsc::channel();
383                std::thread::spawn(move || {
384                    let result = task();
385                    let _ = tx.send(result);
386                });
387                let msg = match rx.recv_timeout(timeout) {
388                    Ok(result) => result,
389                    Err(_) => on_timeout,
390                };
391                let cmd = self.model.update(msg);
392                self.process_command(cmd);
393            }
394            Command::TaskCancellable { task, token } => {
395                let msg = task(token);
396                let cmd = self.model.update(msg);
397                self.process_command(cmd);
398            }
399        }
400    }
401
402    fn refresh_subscriptions(&mut self) {
403        let subs = self.model.subscriptions();
404        // Reconcile active timers
405        let new_ids: Vec<String> = subs
406            .iter()
407            .filter_map(|s| match s {
408                Subscription::Timer { id, .. } => Some(id.clone()),
409                _ => None,
410            })
411            .collect();
412        // Remove timers no longer subscribed
413        self.active_timers.retain(|(id, _, _)| new_ids.contains(id));
414        // Add new timers
415        for sub in &subs {
416            if let Subscription::Timer { id, interval, .. } = sub {
417                if !self.active_timers.iter().any(|(aid, _, _)| aid == id) {
418                    self.active_timers
419                        .push((id.clone(), *interval, Instant::now()));
420                }
421            }
422        }
423        // Add new delays
424        for sub in subs {
425            if let Subscription::Delay { id, duration, .. } = &sub {
426                if !self.pending_delays.iter().any(|(did, _)| did == id) {
427                    self.pending_delays
428                        .push((id.clone(), Instant::now() + *duration));
429                }
430            }
431        }
432    }
433}
434
435// ── winit ApplicationHandler ────────────────────────────────────────
436
437impl<M: Model + 'static> ApplicationHandler for AppHandler<M> {
438    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
439        // Initialise on first resume
440        if let AppHandler::Uninitialised { .. } = self {
441            let taken = std::mem::replace(self, AppHandler::Exited);
442            if let AppHandler::Uninitialised { model, options } = taken {
443                match RunningApp::new(model, &options, event_loop) {
444                    Ok(app) => {
445                        *self = AppHandler::Running(Box::new(app));
446                    }
447                    Err(e) => {
448                        log::error!("agpu: failed to initialise: {e}");
449                        event_loop.exit();
450                    }
451                }
452            }
453        }
454    }
455
456    fn window_event(
457        &mut self,
458        event_loop: &ActiveEventLoop,
459        _window_id: WindowId,
460        event: WindowEvent,
461    ) {
462        let app = match self {
463            AppHandler::Running(app) => app.as_mut(),
464            _ => return,
465        };
466
467        // Handle resize before converting events
468        if let WindowEvent::Resized(size) = event {
469            app.resize(size);
470        }
471
472        // Track cursor position
473        if let WindowEvent::CursorMoved { position, .. } = &event {
474            app.cursor_position = Position::new(position.x as f32, position.y as f32);
475        }
476
477        // Convert and dispatch events
478        let events = convert_window_event(&event);
479        for mut ev in events {
480            // Patch mouse events with current cursor position
481            if let crate::event::Event::Mouse(ref mut mouse) = ev {
482                if mouse.position == Position::ZERO {
483                    mouse.position = app.cursor_position;
484                }
485            }
486            if let Some(msg) = app.model.handle_event(ev) {
487                let cmd = app.model.update(msg);
488                app.process_command(cmd);
489            }
490        }
491
492        // Redraw
493        if let WindowEvent::RedrawRequested = event {
494            app.render();
495        }
496
497        if !app.running {
498            event_loop.exit();
499        }
500    }
501
502    fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
503        if let AppHandler::Running(app) = self {
504            let now = Instant::now();
505
506            // Tick subscriptions: check active timers
507            let mut fired_timer_ids = Vec::new();
508            for (id, interval, last_fire) in &mut app.active_timers {
509                if now.duration_since(*last_fire) >= *interval {
510                    *last_fire = now;
511                    fired_timer_ids.push(id.clone());
512                }
513            }
514            if !fired_timer_ids.is_empty() {
515                // Re-fetch subscriptions to get message factories
516                let subs = app.model.subscriptions();
517                for sub in &subs {
518                    if let Subscription::Timer { id, msg, .. } = sub {
519                        if fired_timer_ids.contains(id) {
520                            let m = msg();
521                            let cmd = app.model.update(m);
522                            app.process_command(cmd);
523                        }
524                    }
525                }
526            }
527
528            // Check pending one-shot delays
529            let mut fired_delays = Vec::new();
530            app.pending_delays.retain(|(id, deadline)| {
531                if now >= *deadline {
532                    fired_delays.push(id.clone());
533                    false
534                } else {
535                    true
536                }
537            });
538            if !fired_delays.is_empty() {
539                let subs = app.model.subscriptions();
540                for sub in subs {
541                    if let Subscription::Delay { id, msg, .. } = sub {
542                        if fired_delays.contains(&id) {
543                            let cmd = app.model.update(msg);
544                            app.process_command(cmd);
545                        }
546                    }
547                }
548            }
549
550            // Main tick
551            match app.tick_rate {
552                Some(rate) => {
553                    if now.duration_since(app.last_tick) >= rate {
554                        app.last_tick = now;
555                        if let Some(msg) = app.model.handle_event(crate::event::Event::Tick) {
556                            let cmd = app.model.update(msg);
557                            app.process_command(cmd);
558                        }
559                        app.window.request_redraw();
560                    }
561                }
562                None => {
563                    app.window.request_redraw();
564                }
565            }
566
567            // Refresh subscription state after updates
568            app.refresh_subscriptions();
569        }
570    }
571}
572
573/// Create a multisampled texture view for MSAA rendering.
574fn create_msaa_texture(
575    device: &wgpu::Device,
576    format: wgpu::TextureFormat,
577    width: u32,
578    height: u32,
579    sample_count: u32,
580) -> wgpu::TextureView {
581    let texture = device.create_texture(&wgpu::TextureDescriptor {
582        label: Some("agpu_msaa_texture"),
583        size: wgpu::Extent3d {
584            width,
585            height,
586            depth_or_array_layers: 1,
587        },
588        mip_level_count: 1,
589        sample_count,
590        dimension: wgpu::TextureDimension::D2,
591        format,
592        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
593        view_formats: &[],
594    });
595    texture.create_view(&wgpu::TextureViewDescriptor::default())
596}