1use std::{sync::atomic::Ordering::*, time::Duration};
2
3use device::RuntimeDevice;
4use thedes_async_util::{
5 non_blocking::spsc::watch::AtomicMessage,
6 timer::Timer,
7};
8use thiserror::Error;
9use tokio::task::JoinError;
10use tokio_util::sync::CancellationToken;
11
12use crate::{app::App, audio, grapheme, input, screen, status::Status};
13
14pub mod device;
15
16pub(crate) type JoinSet = tokio::task::JoinSet<Result<(), Error>>;
17
18#[derive(Debug, Error)]
19pub enum Error {
20 #[error("Failed to render to the screen")]
21 Screen(
22 #[from]
23 #[source]
24 screen::Error,
25 ),
26 #[error("Failed to process input events")]
27 Input(
28 #[from]
29 #[source]
30 input::Error,
31 ),
32 #[error("Failed to manage audio system")]
33 Audio(
34 #[from]
35 #[source]
36 audio::Error,
37 ),
38 #[error("Failed to initialize runtime device")]
39 DeviceInit(
40 #[from]
41 #[source]
42 device::Error,
43 ),
44 #[error("Failed to join a task")]
45 JoinError(
46 #[from]
47 #[source]
48 JoinError,
49 ),
50 #[error("App was unexpectedly cancelled")]
51 AppCancelled,
52}
53
54#[derive(Debug)]
55pub struct Config {
56 cancel_token: CancellationToken,
57 screen: screen::Config,
58 input: input::Config,
59 tick_period: Duration,
60 device: Option<Box<dyn RuntimeDevice>>,
61}
62
63impl Config {
64 pub fn new() -> Self {
65 Self {
66 cancel_token: CancellationToken::new(),
67 screen: screen::Config::new(),
68 input: input::Config::new(),
69 tick_period: Duration::from_millis(8),
70 device: None,
71 }
72 }
73
74 pub fn with_cancel_token(self, token: CancellationToken) -> Self {
75 Self { cancel_token: token, ..self }
76 }
77
78 pub fn with_screen(self, config: screen::Config) -> Self {
79 Self { screen: config, ..self }
80 }
81
82 pub fn with_input(self, config: input::Config) -> Self {
83 Self { input: config, ..self }
84 }
85
86 pub fn with_tick_period(self, duration: Duration) -> Self {
87 Self { tick_period: duration, ..self }
88 }
89
90 pub fn with_device(self, device: Box<dyn RuntimeDevice>) -> Self {
91 Self { device: Some(device), ..self }
92 }
93
94 pub async fn run<F, A>(self, app_scope: F) -> Result<A::Output, Error>
95 where
96 F: FnOnce(App) -> A,
97 A: Future + Send + 'static,
98 A::Output: Send + 'static,
99 {
100 let mut device = self.device.unwrap_or_else(device::native::open);
101 let _panic_restore_guard = device.open_panic_restore_guard();
102
103 let grapheme_registry = grapheme::Registry::new();
104 let timer = Timer::new(self.tick_period);
105 let status = Status::new();
106
107 let mut join_set = JoinSet::new();
108
109 let audio_device = device.open_audio_device();
110 let audio_handles = audio::Config::new().open(
111 audio::OpenResources {
112 device: audio_device,
113 cancel_token: self.cancel_token.clone(),
114 timer: timer.clone(),
115 },
116 &mut join_set,
117 );
118
119 let input_handles = self.input.open(
120 input::OpenResources {
121 device: device.open_input_device(),
122 status: status.clone(),
123 },
124 &mut join_set,
125 );
126
127 let screen_handles = self.screen.open(
128 screen::OpenResources {
129 device: device.open_screen_device(),
130 grapheme_registry: grapheme_registry.clone(),
131 cancel_token: self.cancel_token.clone(),
132 timer: timer.clone(),
133 term_size_watch: input_handles.term_size,
134 status,
135 },
136 &mut join_set,
137 );
138
139 device.blocking_init()?;
140
141 let app = App {
142 grapheme_registry,
143 tick_session: timer.new_session(),
144 events: input_handles.events,
145 canvas: screen_handles.canvas,
146 audio_controller: audio_handles.controller,
147 cancel_token: self.cancel_token.clone(),
148 };
149 let app_output = app.run(&mut join_set, app_scope);
150
151 let mut errors = Vec::new();
152 while let Some(join_result) = join_set.join_next().await {
153 let result = match join_result {
154 Ok(Ok(())) => {
155 tracing::trace!("Joined task OK");
156 Ok(())
157 },
158 Ok(Err(error)) => Err(error),
159 Err(error) => Err(error.into()),
160 };
161
162 if let Err(error) = result {
163 tracing::error!("Failed to join task: {error:#?}");
164 self.cancel_token.cancel();
165 errors.push(error);
166 }
167 }
168
169 if let Err(error) = device.blocking_shutdown() {
170 errors.push(error.into());
171 }
172
173 let mut result = Ok(app_output.take(Relaxed));
174 for error in errors {
175 if result.is_ok() {
176 self.cancel_token.cancel();
177 }
178 match error {
179 Error::JoinError(join_error) if join_error.is_panic() => {
180 std::panic::resume_unwind(join_error.into_panic())
181 },
182 _ => (),
183 }
184 if result.is_ok() {
185 result = Err(error);
186 }
187 }
188 result.and_then(|maybe_output| maybe_output.ok_or(Error::AppCancelled))
189 }
190}
191
192#[cfg(test)]
193mod test {
194 use std::time::Duration;
195
196 use thiserror::Error;
197 use tokio::{task, time::timeout};
198 use tokio_util::sync::CancellationToken;
199
200 use crate::{
201 color::{BasicColor, ColorPair},
202 event::{Event, InternalEvent, Key, KeyEvent, ResizeEvent},
203 geometry::CoordPair,
204 mutation::{MutationExt, Set},
205 runtime::{Config, device::mock::RuntimeDeviceMock},
206 screen::{self, Command},
207 tile::{MutateColors, MutateGrapheme},
208 };
209
210 #[derive(Debug, Error)]
211 enum TestError {
212 #[error("Failed to interact with screen canvas")]
213 CanvasFlush(
214 #[from]
215 #[source]
216 crate::screen::FlushError,
217 ),
218 }
219
220 async fn tui_main(mut app: crate::App) -> Result<(), TestError> {
221 let colors = ColorPair {
222 foreground: BasicColor::Black.into(),
223 background: BasicColor::LightGreen.into(),
224 };
225 let message = "Hello, World!";
226
227 'main: loop {
228 let mut x = 0;
229 for ch in message.chars() {
230 app.canvas.queue([Command::new_mutation(
231 CoordPair { x, y: 0 },
232 MutateGrapheme(Set(ch.into()))
233 .then(MutateColors(Set(colors))),
234 )]);
235 x += 1;
236 }
237 if app.canvas.flush().is_err() {
238 eprintln!("Screen command receiver disconnected");
239 break;
240 }
241 let Ok(events) = app.events.read_until_now() else {
242 eprintln!("Event sender disconnected");
243 break;
244 };
245 for event in events {
246 match event {
247 Event::Key(KeyEvent {
248 alt: false,
249 ctrl: false,
250 shift: false,
251 main_key: Key::Char('q') | Key::Char('Q') | Key::Esc,
252 }) => break 'main,
253 _ => (),
254 }
255 }
256 tokio::select! {
257 _ = app.tick_session.tick() => (),
258 _ = app.cancel_token.cancelled() => break,
259 }
260 }
261
262 Ok(())
263 }
264
265 #[tokio::test(flavor = "multi_thread")]
266 async fn quit_on_q_with_default_canvas_size() {
267 let device_mock = RuntimeDeviceMock::new(CoordPair { y: 24, x: 80 });
268 let device = device_mock.open();
269 let config = Config::new()
270 .with_screen(screen::Config::new())
271 .with_device(device);
272
273 device_mock.input().publish_ok([InternalEvent::External(Event::Key(
274 KeyEvent {
275 alt: false,
276 ctrl: false,
277 shift: false,
278 main_key: Key::Char('q'),
279 },
280 ))]);
281
282 let runtime_future = task::spawn(config.run(tui_main));
283 timeout(Duration::from_millis(200), runtime_future)
284 .await
285 .unwrap()
286 .unwrap()
287 .unwrap()
288 .unwrap();
289 }
290
291 #[tokio::test(flavor = "multi_thread")]
292 async fn quit_on_esc() {
293 let device_mock = RuntimeDeviceMock::new(CoordPair { y: 24, x: 80 });
294 let device = device_mock.open();
295 let config = Config::new()
296 .with_screen(
297 screen::Config::new()
298 .with_canvas_size(CoordPair { y: 22, x: 78 }),
299 )
300 .with_device(device);
301
302 device_mock.input().publish_ok([InternalEvent::External(Event::Key(
303 KeyEvent {
304 alt: false,
305 ctrl: false,
306 shift: false,
307 main_key: Key::Esc,
308 },
309 ))]);
310
311 let runtime_future = task::spawn(config.run(tui_main));
312 timeout(Duration::from_millis(200), runtime_future)
313 .await
314 .unwrap()
315 .unwrap()
316 .unwrap()
317 .unwrap();
318 }
319
320 #[tokio::test(flavor = "multi_thread")]
321 async fn print_message() {
322 let device_mock = RuntimeDeviceMock::new(CoordPair { y: 24, x: 80 });
323 device_mock.screen().enable_command_log();
324 let device = device_mock.open();
325 let config = Config::new()
326 .with_tick_period(Duration::from_millis(1))
327 .with_screen(
328 screen::Config::new()
329 .with_canvas_size(CoordPair { y: 22, x: 78 }),
330 )
331 .with_device(device);
332
333 let runtime_future = task::spawn(config.run(tui_main));
334
335 tokio::time::sleep(Duration::from_millis(10)).await;
336
337 device_mock.input().publish_ok([InternalEvent::External(Event::Key(
338 KeyEvent {
339 alt: false,
340 ctrl: false,
341 shift: false,
342 main_key: Key::Char('q'),
343 },
344 ))]);
345
346 timeout(Duration::from_millis(200), runtime_future)
347 .await
348 .unwrap()
349 .unwrap()
350 .unwrap()
351 .unwrap();
352
353 let command_log = device_mock.screen().take_command_log().unwrap();
354
355 let message = "Hello, World!";
356 for ch in message.chars() {
357 assert_eq!(
358 message.chars().filter(|resize_ch| *resize_ch == ch).count(),
359 command_log
360 .iter()
361 .flatten()
362 .filter(|command| **command
363 == screen::device::Command::Write(ch))
364 .count(),
365 "expected {ch} to occur once, commands: {command_log:#?}",
366 );
367 }
368 }
369
370 #[tokio::test(flavor = "multi_thread")]
371 async fn cancel_token_stops() {
372 let device_mock = RuntimeDeviceMock::new(CoordPair { y: 24, x: 80 });
373 let device = device_mock.open();
374 let cancel_token = CancellationToken::new();
375 let config = Config::new()
376 .with_cancel_token(cancel_token.clone())
377 .with_screen(
378 screen::Config::new()
379 .with_canvas_size(CoordPair { y: 22, x: 78 }),
380 )
381 .with_device(device);
382
383 let runtime_future = task::spawn(config.run(tui_main));
384 cancel_token.cancel();
385 timeout(Duration::from_millis(200), runtime_future)
386 .await
387 .unwrap()
388 .unwrap()
389 .unwrap()
390 .unwrap();
391 }
392
393 #[tokio::test(flavor = "multi_thread")]
394 async fn resize_too_small() {
395 let device_mock = RuntimeDeviceMock::new(CoordPair { y: 24, x: 80 });
396 let device = device_mock.open();
397 let config = Config::new()
398 .with_screen(
399 screen::Config::new()
400 .with_canvas_size(CoordPair { y: 22, x: 78 }),
401 )
402 .with_device(device);
403
404 device_mock.input().publish_ok([InternalEvent::Resize(ResizeEvent {
405 size: CoordPair { y: 23, x: 79 },
406 })]);
407
408 let runtime_future = task::spawn(config.run(tui_main));
409 tokio::time::sleep(Duration::from_millis(50)).await;
410
411 device_mock.input().publish_ok([InternalEvent::External(Event::Key(
412 KeyEvent {
413 alt: false,
414 ctrl: false,
415 shift: false,
416 main_key: Key::Esc,
417 },
418 ))]);
419 tokio::time::sleep(Duration::from_millis(50)).await;
420 assert!(!device_mock.panic_restore().called());
421
422 device_mock.input().publish_ok([InternalEvent::Resize(ResizeEvent {
423 size: CoordPair { y: 24, x: 80 },
424 })]);
425 tokio::time::sleep(Duration::from_millis(50)).await;
426 device_mock.input().publish_ok([InternalEvent::External(Event::Key(
427 KeyEvent {
428 alt: false,
429 ctrl: false,
430 shift: false,
431 main_key: Key::Esc,
432 },
433 ))]);
434
435 timeout(Duration::from_millis(200), runtime_future)
436 .await
437 .unwrap()
438 .unwrap()
439 .unwrap()
440 .unwrap();
441
442 assert!(device_mock.panic_restore().called());
443 }
444}