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}