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