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}