Skip to main content

loam_app/session/
app.rs

1use loam_console::ConsoleWriter;
2#[cfg(feature = "egui")]
3use loam_egui::egui;
4use loam_render::pass::{FramePass, Section};
5use loam_runtime::host::HostConfig;
6use loam_runtime::host::HostError;
7use loam_runtime::{ActionId, Growth, Input, Publication, Session, Stores};
8
9use super::commands::{CommandInbox, CommandSender};
10use super::console::{SessionConsole, Submit};
11use super::cursor::{CursorCapture, CursorPolicy};
12use super::pacing::Pacer;
13use crate::args::Args;
14use crate::capture::{CaptureRequest, CaptureUnavailable};
15use crate::script::{driver_from_args, ScriptDriver};
16
17/// Runs before the frame boundary with read-only session input.
18#[non_exhaustive]
19pub struct InputHook<'a, A: Stores> {
20    pub session: &'a Session<A>,
21    pub input: &'a Input,
22    #[cfg(feature = "egui")]
23    pub ui: Option<&'a egui::Context>,
24    pub size: (u32, u32),
25    pub sender: &'a CommandSender<A>,
26}
27
28/// Runs between publication and presentation.
29pub struct FrameHook<'a, A: Stores> {
30    pub session: &'a Session<A>,
31    pub published: &'a Publication,
32    /// The alpha `published` was drawn at; pass it to `drawn_pose` for anything drawn beside the instances.
33    pub alpha: f32,
34    pub sections: &'a [Section],
35    #[cfg(feature = "egui")]
36    pub ui: Option<&'a egui::Context>,
37    pub size: (u32, u32),
38    pub sender: &'a CommandSender<A>,
39    pub capture: CaptureControl<'a>,
40    pub(crate) cursor: &'a mut CursorCapture,
41    pub(crate) background: &'a mut wgpu::Color,
42    pub(crate) posts: &'a mut Posts,
43    pub(crate) redraw: &'a mut bool,
44}
45
46impl<A: Stores> FrameHook<'_, A> {
47    pub fn capture_cursor(&mut self, enabled: bool, policy: CursorPolicy) {
48        self.cursor.capture(enabled, policy);
49    }
50
51    pub fn cursor_locked(&self) -> bool {
52        self.cursor.locked()
53    }
54
55    /// Linear light; the present clear shows wherever no Background pass paints.
56    pub fn set_background(&mut self, rgb: [f32; 3]) {
57        *self.background = background_color(rgb);
58    }
59
60    /// Sent to the page as one `loam-post` event after this frame (native hosts discard it); positions belong in normalized canvas coordinates in [0, 1].
61    pub fn post(&mut self, topic: &'static str, values: &[f32]) {
62        self.posts.push(topic, values);
63    }
64
65    /// Draws this frame under `Redraw::OnChange`.
66    pub fn request_redraw(&mut self) {
67        *self.redraw = true;
68    }
69}
70
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
72pub enum Redraw {
73    #[default]
74    EveryFrame,
75    /// Draws only when the publication content, root eye, size or background changed since the last draw, or a hook called `request_redraw`.
76    OnChange,
77}
78
79#[derive(Default)]
80pub(crate) struct Posts {
81    entries: Vec<(&'static str, std::ops::Range<usize>)>,
82    values: Vec<f32>,
83}
84
85impl Posts {
86    fn push(&mut self, topic: &'static str, values: &[f32]) {
87        let range = self.values.len()..self.values.len() + values.len();
88        self.values.extend_from_slice(values);
89        self.entries.push((topic, range));
90    }
91
92    pub(crate) fn clear(&mut self) {
93        self.entries.clear();
94        self.values.clear();
95    }
96
97    #[cfg(any(target_arch = "wasm32", test))]
98    pub(crate) fn iter(&self) -> impl Iterator<Item = (&'static str, &[f32])> {
99        self.entries
100            .iter()
101            .map(|(topic, range)| (*topic, &self.values[range.clone()]))
102    }
103}
104
105const DEFAULT_BACKGROUND: wgpu::Color = wgpu::Color {
106    r: 0.02,
107    g: 0.02,
108    b: 0.03,
109    a: 1.0,
110};
111
112fn background_color(rgb: [f32; 3]) -> wgpu::Color {
113    wgpu::Color {
114        r: f64::from(rgb[0]),
115        g: f64::from(rgb[1]),
116        b: f64::from(rgb[2]),
117        a: 1.0,
118    }
119}
120
121/// Queues capture starts and stops that the host drains at the end of the same frame.
122pub struct CaptureControl<'a> {
123    requests: &'a mut Vec<CaptureRequest>,
124    supported: bool,
125}
126
127const CAPTURE_SUPPORTED: bool = cfg!(all(feature = "capture", not(target_arch = "wasm32")));
128
129fn queue_capture(
130    requests: &mut Vec<CaptureRequest>,
131    request: CaptureRequest,
132    supported: bool,
133) -> Result<(), CaptureUnavailable> {
134    if !supported {
135        return Err(CaptureUnavailable);
136    }
137    requests.push(request);
138    Ok(())
139}
140
141impl<'a> CaptureControl<'a> {
142    pub(crate) fn new(requests: &'a mut Vec<CaptureRequest>) -> Self {
143        Self::with_support(requests, CAPTURE_SUPPORTED)
144    }
145
146    fn with_support(requests: &'a mut Vec<CaptureRequest>, supported: bool) -> Self {
147        Self {
148            requests,
149            supported,
150        }
151    }
152
153    pub fn start(&mut self, request: CaptureRequest) -> Result<(), CaptureUnavailable> {
154        queue_capture(self.requests, request, self.supported)
155    }
156
157    pub fn stop(&mut self) -> Result<(), CaptureUnavailable> {
158        self.start(CaptureRequest::Stop)
159    }
160}
161
162pub(crate) type FrameFn<A> = Box<dyn FnMut(&mut FrameHook<'_, A>)>;
163pub(crate) type InputFn<A> = Box<dyn FnMut(&InputHook<'_, A>)>;
164
165pub struct SessionApp<A: Stores> {
166    pub config: HostConfig,
167    pub args: Args,
168    pub(crate) pacer: Pacer,
169    pub(crate) vsync: Option<bool>,
170    pub(crate) passes: Vec<Box<dyn FramePass>>,
171    pub(crate) input: Option<InputFn<A>>,
172    pub(crate) fault_recovery: Option<ActionId>,
173    pub(crate) frame: Option<FrameFn<A>>,
174    pub(crate) commands: CommandInbox<A>,
175    pub(crate) console: SessionConsole<A>,
176    pub(crate) captures: Vec<CaptureRequest>,
177    pub(crate) background: wgpu::Color,
178    pub(crate) redraw: Redraw,
179    #[cfg(feature = "egui")]
180    pub(crate) debug_layer: bool,
181    pub(crate) script: Option<ScriptDriver>,
182}
183
184impl<A: Stores> SessionApp<A> {
185    pub fn new(config: HostConfig) -> Self {
186        Self::with_args(config, Args::current())
187    }
188
189    pub fn with_args(config: HostConfig, args: Args) -> Self {
190        let commands = CommandInbox::default();
191        let console = SessionConsole::new(commands.sender());
192        let mut host = Self {
193            config,
194            args,
195            pacer: Pacer::default(),
196            vsync: None,
197            passes: Vec::new(),
198            input: None,
199            fault_recovery: None,
200            frame: None,
201            commands,
202            console,
203            captures: Vec::new(),
204            background: DEFAULT_BACKGROUND,
205            redraw: Redraw::default(),
206            #[cfg(feature = "egui")]
207            debug_layer: true,
208            script: None,
209        };
210        host.apply_args();
211        host
212    }
213
214    pub fn apply_args(&mut self) {
215        if let Some(fps) = self.args.parse::<f32>("fps") {
216            self.pacer.set_target_fps(fps);
217        }
218        if let Some(vsync) = match self.args.get("vsync") {
219            Some("on") | Some("1") => Some(true),
220            Some("off") | Some("0") => Some(false),
221            _ => None,
222        } {
223            self.vsync = Some(vsync);
224        }
225        match driver_from_args(&self.args) {
226            Ok(driver) => self.script = driver,
227            Err(error) => tracing::error!("--script ignored: {error:#}"),
228        }
229    }
230
231    pub fn pass(mut self, pass: Box<dyn FramePass>) -> Self {
232        self.passes.push(pass);
233        self
234    }
235
236    pub fn on_frame(mut self, hook: impl FnMut(&mut FrameHook<'_, A>) + 'static) -> Self {
237        self.frame = Some(Box::new(hook));
238        self
239    }
240
241    pub fn on_input(mut self, hook: impl FnMut(&InputHook<'_, A>) + 'static) -> Self {
242        self.input = Some(Box::new(hook));
243        self
244    }
245
246    pub fn recover_on_fault(mut self, action: ActionId) -> Self {
247        self.fault_recovery = Some(action);
248        self
249    }
250
251    pub fn sender(&self) -> CommandSender<A> {
252        self.commands.sender()
253    }
254
255    pub fn boundary(
256        &mut self,
257        session: &mut Session<A>,
258        input: Input,
259    ) -> Result<Growth, HostError> {
260        let fault = session.faulted_phase().and_then(|_| session.phase_error());
261        let recovery = self.commands.recover(session);
262        let recovered = fault.filter(|_| recovery.is_ok() && session.faulted_phase().is_none());
263        let result = recovery.map_err(HostError::from).and_then(|()| {
264            self.commands.drain(session);
265            session.boundary(input).map_err(HostError::from)
266        });
267        if let Some(fault) = recovered {
268            self.console.note(format!("recovered: {fault}"));
269        }
270        self.commands.collect(session);
271        self.console.collect();
272        result
273    }
274
275    pub fn command(
276        mut self,
277        name: &'static str,
278        help: &'static str,
279        handler: impl FnMut(&[&str], &mut Submit<A>, &mut ConsoleWriter) -> anyhow::Result<()> + 'static,
280    ) -> Self {
281        self.console.register(name, help, handler);
282        self
283    }
284
285    pub fn target_fps(mut self, fps: f32) -> Self {
286        self.pacer.set_target_fps(fps);
287        self
288    }
289
290    pub fn vsync(mut self, enabled: bool) -> Self {
291        self.vsync = Some(enabled);
292        self
293    }
294
295    /// Linear light; the present clear shows wherever no Background pass paints.
296    pub fn background(mut self, rgb: [f32; 3]) -> Self {
297        self.background = background_color(rgb);
298        self
299    }
300
301    pub fn redraw(mut self, policy: Redraw) -> Self {
302        self.redraw = policy;
303        self
304    }
305
306    pub fn capture(mut self, request: CaptureRequest) -> Result<Self, CaptureUnavailable> {
307        queue_capture(&mut self.captures, request, CAPTURE_SUPPORTED)?;
308        Ok(self)
309    }
310
311    #[cfg_attr(not(feature = "egui"), allow(unused_mut))]
312    pub fn debug_layer(mut self, enabled: bool) -> Self {
313        #[cfg(feature = "egui")]
314        {
315            self.debug_layer = enabled;
316        }
317        #[cfg(not(feature = "egui"))]
318        let _ = enabled;
319        self
320    }
321
322    pub fn console_mut(&mut self) -> &mut SessionConsole<A> {
323        &mut self.console
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    loam_runtime::stores! {
332        #[derive(Default)]
333        pub struct Bare {}
334    }
335
336    #[test]
337    fn unsupported_capture_refuses_without_retaining_requests() {
338        let mut requests = Vec::new();
339        let mut control = CaptureControl::with_support(&mut requests, false);
340        assert_eq!(
341            control.start(CaptureRequest::OneShot {
342                stage: crate::capture::CaptureStage::Post,
343                dir: None,
344                name: None,
345            }),
346            Err(CaptureUnavailable)
347        );
348        assert_eq!(control.stop(), Err(CaptureUnavailable));
349        assert!(requests.is_empty());
350        #[cfg(any(not(feature = "capture"), target_arch = "wasm32"))]
351        {
352            let app = SessionApp::<Bare>::with_args(
353                HostConfig::new("capture", loam_runtime::Bindings::new()),
354                Args::default(),
355            );
356            assert!(matches!(
357                app.capture(CaptureRequest::Stop),
358                Err(CaptureUnavailable)
359            ));
360        }
361    }
362}