Skip to main content

loam_app/session/
console.rs

1use std::sync::{Arc, Mutex, MutexGuard};
2
3use loam_console::{cmd, CommandLine, Console, ConsoleWriter, HistoryLine};
4use loam_runtime::{AppCommand, Dispatch, Stores};
5
6use super::commands::{CommandSender, Reported};
7
8#[derive(Default)]
9struct ConsoleState {
10    controls: Controls,
11    responses: Vec<HistoryLine>,
12}
13
14type Shared = Arc<Mutex<ConsoleState>>;
15
16fn lock(shared: &Shared) -> MutexGuard<'_, ConsoleState> {
17    shared.lock().unwrap_or_else(|error| error.into_inner())
18}
19
20#[derive(Clone, Copy, Debug, Default, PartialEq)]
21pub struct Controls {
22    pub target_fps: Option<f32>,
23    pub vsync: Option<bool>,
24}
25
26pub struct Submit<A: Stores> {
27    commands: CommandSender<A>,
28    console: Shared,
29}
30
31impl<A: Stores> Submit<A> {
32    pub fn inspect(
33        &mut self,
34        name: &'static str,
35        read: impl FnMut(&Dispatch<'_, A>, &mut ConsoleWriter) + Send + 'static,
36    ) {
37        self.commands.app(Inspect {
38            name,
39            read,
40            console: self.console.clone(),
41        });
42    }
43
44    pub fn app(&mut self, command: impl AppCommand<A>) {
45        self.commands.app(command);
46    }
47
48    pub fn app_fn(
49        &mut self,
50        name: &'static str,
51        apply: impl FnMut(&mut Dispatch<'_, A>) + Send + 'static,
52    ) {
53        self.commands.app_fn(name, apply);
54    }
55
56    pub fn reset(&mut self) {
57        self.commands.reset();
58    }
59
60    pub fn target_fps(&mut self, fps: f32) {
61        lock(&self.console).controls.target_fps = Some(fps);
62    }
63
64    pub fn vsync(&mut self, enabled: bool) {
65        lock(&self.console).controls.vsync = Some(enabled);
66    }
67}
68
69struct Inspect<F> {
70    name: &'static str,
71    read: F,
72    console: Shared,
73}
74
75impl<A: Stores, F> AppCommand<A> for Inspect<F>
76where
77    F: FnMut(&Dispatch<'_, A>, &mut ConsoleWriter) + Send + 'static,
78{
79    fn name(&self) -> &'static str {
80        self.name
81    }
82
83    fn apply(
84        &mut self,
85        dispatch: &mut Dispatch<'_, A>,
86    ) -> Result<loam_runtime::Outcome, loam_runtime::Rejection> {
87        let mut output = ConsoleWriter::new();
88        (self.read)(dispatch, &mut output);
89        lock(&self.console).responses.extend(output.take_lines());
90        Ok(loam_runtime::Outcome::Done)
91    }
92}
93
94pub struct SessionConsole<A: Stores> {
95    console: Console<Submit<A>>,
96    submit: Submit<A>,
97    state: Shared,
98    completed: Vec<Reported>,
99}
100
101impl<A: Stores> SessionConsole<A> {
102    pub(crate) fn new(sender: CommandSender<A>) -> Self {
103        let state = Shared::default();
104        let mut console = Self {
105            console: Console::new(),
106            submit: Submit {
107                commands: sender.reported(),
108                console: state.clone(),
109            },
110            state,
111            completed: Vec::new(),
112        };
113        crate::trace::register_command(&mut console.console);
114        console.register(
115            "recover",
116            "restore the initial session after a fault",
117            |args, submit: &mut Submit<A>, out| {
118                if args.is_empty() {
119                    submit.reset();
120                } else {
121                    out.line("usage: recover");
122                }
123                Ok(())
124            },
125        );
126        console.register(
127            "fps",
128            "show or set the frame cap; unlimited removes it",
129            |args, submit: &mut Submit<A>, out| {
130                match args.first().copied() {
131                    Some("unlimited" | "off" | "0") => {
132                        submit.target_fps(0.0);
133                        out.line("fps: unlimited; applies on the next frame");
134                    }
135                    Some(other) => match other.parse::<f32>() {
136                        Ok(fps) if fps > 0.0 && fps <= MAX_ACCEPTED_FPS => {
137                            submit.target_fps(fps);
138                            out.line(format!("fps: target {fps:.1}; applies on the next frame"));
139                        }
140                        _ => out.line(format!(
141                            "usage: fps <n> | unlimited  (n in (0, {MAX_ACCEPTED_FPS:.0}])"
142                        )),
143                    },
144                    None => out.line("usage: fps <n> | unlimited"),
145                }
146                Ok(())
147            },
148        );
149        console.register(
150            "vsync",
151            "set the surface present mode (on = Fifo, off = Mailbox or Immediate)",
152            |args, submit: &mut Submit<A>, out| {
153                match args.first().copied() {
154                    Some("on") => {
155                        submit.vsync(true);
156                        out.line("vsync: Fifo requested; applies on the next frame");
157                    }
158                    Some("off") => {
159                        submit.vsync(false);
160                        out.line(
161                            "vsync: Mailbox or Immediate requested; applies on the next frame",
162                        );
163                    }
164                    _ => out.line("usage: vsync on | off"),
165                }
166                Ok(())
167            },
168        );
169        console
170    }
171
172    pub fn register(
173        &mut self,
174        name: &'static str,
175        help: &'static str,
176        mut handler: impl FnMut(&[&str], &mut Submit<A>, &mut ConsoleWriter) -> anyhow::Result<()>
177            + 'static,
178    ) {
179        self.console
180            .register(cmd(name, help, move |args, submit: &mut Submit<A>, out| {
181                handler(args, submit, out)
182            }));
183    }
184
185    pub fn has(&self, name: &str) -> bool {
186        self.console.has_command(name)
187    }
188
189    pub fn ui(&self) -> &Console<Submit<A>> {
190        &self.console
191    }
192
193    pub fn ui_mut(&mut self) -> &mut Console<Submit<A>> {
194        &mut self.console
195    }
196
197    pub fn take_controls(&mut self) -> Controls {
198        std::mem::take(&mut lock(&self.state).controls)
199    }
200
201    pub fn execute(&mut self, line: &str) {
202        self.console.execute(line);
203    }
204
205    pub fn submit(&mut self, command: impl AppCommand<A>) {
206        self.submit.app(command);
207    }
208
209    pub(crate) fn note(&mut self, line: String) {
210        self.console.write(HistoryLine::output(line));
211    }
212
213    pub(crate) fn collect(&mut self) {
214        let mut state = lock(&self.state);
215        for line in state.responses.drain(..) {
216            self.console.write(line);
217        }
218        drop(state);
219        let dropped = self.submit.commands.take_reported(&mut self.completed);
220        for reported in self.completed.drain(..) {
221            let line = match reported.outcome {
222                Ok(outcome) => format!("{}: {outcome}", reported.name),
223                Err(rejection) => format!("{}: rejected, {rejection}", reported.name),
224            };
225            self.console.write(HistoryLine::output(line));
226        }
227        if dropped > 0 {
228            self.console.write(HistoryLine::output(format!(
229                "commands: {dropped} results omitted"
230            )));
231        }
232    }
233
234    pub fn dispatch_pending(&mut self) -> bool {
235        let lines = self.console.drain_pending();
236        let dispatched = !lines.is_empty();
237        for line in lines {
238            let Some(parsed) = CommandLine::parse(&line) else {
239                continue;
240            };
241            self.console
242                .dispatch(&parsed.name, &parsed.arg_refs(), &mut self.submit);
243        }
244        dispatched
245    }
246}
247
248const MAX_ACCEPTED_FPS: f32 = 1000.0;
249
250#[cfg(test)]
251mod tests {
252    use loam_runtime::{
253        Bindings, Ctx, DomainError, HostConfig, Input, Phase, Rejection, Session, SimConfig,
254        SpawnBundle,
255    };
256
257    use super::*;
258    use crate::args::Args;
259    use crate::session::SessionApp;
260
261    loam_runtime::stores! {
262        #[derive(Default)]
263        pub struct Counted {
264            hits: Value<u32>,
265        }
266    }
267
268    #[test]
269    fn a_console_commands_session_result_reaches_the_console_at_the_next_boundary() {
270        let mut session = Session::new(Counted::default(), SimConfig::default());
271        let mut app =
272            SessionApp::with_args(HostConfig::new("console", Bindings::new()), Args::default());
273        app.console
274            .register("bump", "raise the counter", |_args, submit, out| {
275                submit.app_fn("bump", |d: &mut Dispatch<'_, Counted>| {
276                    *d.app.hits.get_mut() += 1;
277                });
278                out.line("queued");
279                Ok(())
280            });
281        app.console
282            .register("count", "read the counter", |_args, submit, _out| {
283                submit.inspect("count", |dispatch, out| {
284                    out.line(format!("count={}", dispatch.app.hits.get()));
285                });
286                Ok(())
287            });
288        app.console.execute("bump");
289        app.console.execute("count");
290        app.console.dispatch_pending();
291        assert!(
292            !history(&app.console)
293                .iter()
294                .any(|line| line.contains("done")),
295            "the result cannot exist before the boundary that applies the command"
296        );
297
298        app.boundary(&mut session, Input::default())
299            .expect("boundary");
300
301        assert_eq!(*session.app.hits.get(), 1);
302        assert!(history(&app.console).iter().any(|line| line == "count=1"));
303        assert!(
304            history(&app.console)
305                .iter()
306                .any(|line| line == "bump: done"),
307            "the console never matched its request to the session result: {:?}",
308            history(&app.console)
309        );
310    }
311
312    #[test]
313    fn a_refused_command_from_an_unreported_sender_reaches_the_console_history() {
314        let mut session = Session::new(Counted::default(), SimConfig::default());
315        let mut app =
316            SessionApp::with_args(HostConfig::new("console", Bindings::new()), Args::default());
317        app.sender()
318            .try_app_fn("refused", |_d: &mut Dispatch<'_, Counted>| {
319                Err(Rejection::Unsupported("not now"))
320            });
321        app.sender()
322            .app_fn("accepted", |d: &mut Dispatch<'_, Counted>| {
323                *d.app.hits.get_mut() += 1;
324            });
325
326        app.boundary(&mut session, Input::default())
327            .expect("boundary");
328
329        let lines = history(&app.console);
330        assert!(
331            lines
332                .iter()
333                .any(|line| line == "refused: rejected, unsupported: not now"),
334            "the refusal never reached the console: {lines:?}"
335        );
336        assert!(
337            !lines.iter().any(|line| line.starts_with("accepted")),
338            "an unreported success reached the console: {lines:?}"
339        );
340    }
341
342    #[test]
343    fn a_refusal_line_names_the_entity_and_what_went_wrong_with_it() {
344        let mut session = Session::new(Counted::default(), SimConfig::default());
345        let mut app =
346            SessionApp::with_args(HostConfig::new("console", Bindings::new()), Args::default());
347        let entity = session
348            .dispatch(|d| d.spawn(SpawnBundle::new()))
349            .expect("a bare entity");
350        app.sender()
351            .try_app_fn("nudge", move |_d: &mut Dispatch<'_, Counted>| {
352                Err(Rejection::Domain(DomainError::Stale(entity)))
353            });
354
355        app.boundary(&mut session, Input::default())
356            .expect("boundary");
357
358        let expected = format!("nudge: rejected, {entity} is stale");
359        let lines = history(&app.console);
360        assert!(
361            lines.contains(&expected),
362            "the refusal did not name the entity: {lines:?}"
363        );
364        assert!(
365            !lines.iter().any(|line| line.contains("Stale(")),
366            "a refusal reached the console as Debug text: {lines:?}"
367        );
368    }
369
370    #[test]
371    fn a_refusal_from_a_system_submitted_command_reaches_the_console() {
372        let mut session = Session::new(Counted::default(), SimConfig::default());
373        session.system(Phase::Dispatch, "keys", |ctx: Ctx<'_, Counted>| {
374            ctx.commands
375                .try_app_fn("strip", |_d: &mut Dispatch<'_, Counted>| {
376                    Err(Rejection::Unsupported("filmstrip belongs to Rotate"))
377                });
378            Ok(())
379        });
380        let mut app =
381            SessionApp::with_args(HostConfig::new("console", Bindings::new()), Args::default());
382
383        app.boundary(&mut session, Input::default())
384            .expect("boundary");
385
386        let lines = history(&app.console);
387        assert!(
388            lines
389                .contains(&"strip: rejected, unsupported: filmstrip belongs to Rotate".to_string()),
390            "a system-submitted refusal never reached the console: {lines:?}"
391        );
392    }
393
394    fn history(console: &SessionConsole<Counted>) -> Vec<String> {
395        console
396            .ui()
397            .history()
398            .iter()
399            .map(|line| line.text.clone())
400            .collect()
401    }
402}