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#[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
28pub struct FrameHook<'a, A: Stores> {
30 pub session: &'a Session<A>,
31 pub published: &'a Publication,
32 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 pub fn set_background(&mut self, rgb: [f32; 3]) {
57 *self.background = background_color(rgb);
58 }
59
60 pub fn post(&mut self, topic: &'static str, values: &[f32]) {
62 self.posts.push(topic, values);
63 }
64
65 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 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
121pub 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 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}