Skip to main content

thedes_tui_core/
runtime.rs

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}