thedes_tui_core/
screen.rs

1use std::{collections::BTreeSet, mem, panic};
2
3use device::{ScreenDevice, ScreenDeviceExt};
4use thedes_async_util::{
5    non_blocking,
6    timer::{TickSession, Timer},
7};
8use thedes_geometry::rect;
9use thiserror::Error;
10use tokio::task;
11use tokio_util::sync::CancellationToken;
12
13use crate::{
14    color::{BasicColor, Color, ColorPair},
15    geometry::{Coord, CoordPair},
16    grapheme,
17    input::TermSizeWatch,
18    mutation::{BoxedMutation, Mutation},
19    runtime,
20    status::Status,
21    tile::{Tile, TileMutationError},
22};
23
24pub mod device;
25
26#[derive(Debug, Error)]
27#[error("Point is outside of screen canvas rectangle")]
28pub struct InvalidCanvasPoint {
29    #[from]
30    source: rect::HorzAreaError<usize>,
31}
32
33#[derive(Debug, Error)]
34#[error("Index is outside of screen canvas buffer bounds")]
35pub struct InvalidCanvasIndex {
36    #[from]
37    source: rect::InvalidArea<usize>,
38}
39
40#[derive(Debug, Error)]
41pub enum Error {
42    #[error("Failed to control screen device")]
43    Device(
44        #[from]
45        #[source]
46        device::Error,
47    ),
48    #[error("Invalid terminal point while drawing canvas, point {0}")]
49    InvalidTermPoint(CoordPair),
50    #[error("Invalid canvas point while drawing canvas")]
51    InvalidCanvasPoint(
52        #[from]
53        #[source]
54        InvalidCanvasPoint,
55    ),
56    #[error("Invalid canvas index while drawing canvas")]
57    InvalidCanvasIndex(
58        #[from]
59        #[source]
60        InvalidCanvasIndex,
61    ),
62    #[error("Found unknown grapheme ID")]
63    UnknownGrapheme(
64        #[from]
65        #[source]
66        grapheme::UnknownId,
67    ),
68    #[error("Failed to register grapheme")]
69    NotGrapheme(
70        #[from]
71        #[source]
72        grapheme::NotGrapheme,
73    ),
74    #[error("Failed to mutate tile contents in canvas point {0}")]
75    TileMutation(CoordPair, #[source] TileMutationError),
76}
77
78#[derive(Debug, Error)]
79#[error(transparent)]
80pub struct FlushError {
81    inner: non_blocking::spsc::unbounded::SendError<Vec<Command>>,
82}
83
84impl FlushError {
85    fn new(
86        inner: non_blocking::spsc::unbounded::SendError<Vec<Command>>,
87    ) -> Self {
88        Self { inner }
89    }
90
91    pub fn into_bounced_commands(self) -> Vec<Command> {
92        self.inner.into_message()
93    }
94}
95
96#[derive(Debug)]
97pub enum Command {
98    Mutation(CoordPair, Box<dyn BoxedMutation<Tile>>),
99    ClearScreen(Color),
100}
101
102impl Command {
103    pub fn new_mutation<M>(canvas_point: CoordPair, mutation: M) -> Self
104    where
105        M: Mutation<Tile>,
106    {
107        Self::Mutation(canvas_point, Box::new(mutation))
108    }
109
110    pub fn new_clear_screen<C>(color: C) -> Self
111    where
112        C: Into<Color>,
113    {
114        Self::ClearScreen(color.into())
115    }
116}
117
118#[derive(Debug)]
119pub(crate) struct OpenResources {
120    pub device: Box<dyn ScreenDevice>,
121    pub timer: Timer,
122    pub cancel_token: CancellationToken,
123    pub grapheme_registry: grapheme::Registry,
124    pub term_size_watch: TermSizeWatch,
125    pub status: Status,
126}
127
128#[derive(Debug, Clone)]
129pub struct Config {
130    canvas_size: CoordPair,
131    default_colors: ColorPair,
132}
133
134impl Config {
135    pub fn new() -> Self {
136        Self {
137            canvas_size: CoordPair { y: 22, x: 78 },
138            default_colors: ColorPair {
139                background: BasicColor::Black.into(),
140                foreground: BasicColor::White.into(),
141            },
142        }
143    }
144
145    pub fn with_canvas_size(self, size: CoordPair) -> Self {
146        Self { canvas_size: size, ..self }
147    }
148
149    pub fn with_default_color(self, color: ColorPair) -> Self {
150        Self { default_colors: color, ..self }
151    }
152
153    pub fn canvas_size(&self) -> CoordPair {
154        self.canvas_size
155    }
156
157    pub(crate) fn open(
158        self,
159        mut resources: OpenResources,
160        join_set: &mut runtime::JoinSet,
161    ) -> ScreenHandles {
162        let canvas_size = self.canvas_size;
163        let (command_sender, command_receiver) =
164            non_blocking::spsc::unbounded::channel();
165        let status = resources.status.clone();
166
167        join_set.spawn(async move {
168            let initial_term_size = task::block_in_place(|| {
169                resources.device.blocking_get_size().map_err(Error::from)
170            })?;
171            let renderer = Renderer::new(
172                self,
173                resources,
174                initial_term_size,
175                command_receiver,
176            );
177            renderer.run().await
178        });
179
180        let canvas_handle =
181            CanvasHandle::new(canvas_size, status, command_sender);
182        ScreenHandles { canvas: canvas_handle }
183    }
184}
185
186#[derive(Debug)]
187pub(crate) struct ScreenHandles {
188    pub canvas: CanvasHandle,
189}
190
191#[derive(Debug)]
192struct Renderer {
193    device: Box<dyn ScreenDevice>,
194    device_queue: Vec<device::Command>,
195    ticker: TickSession,
196    cancel_token: CancellationToken,
197    term_size: CoordPair,
198    canvas_size: CoordPair,
199    default_colors: ColorPair,
200    current_colors: ColorPair,
201    current_position: CoordPair,
202    working_buf: Box<[Tile]>,
203    displayed_buf: Box<[Tile]>,
204    dirty: BTreeSet<CoordPair>,
205    grapheme_registry: grapheme::Registry,
206    command_receiver: non_blocking::spsc::unbounded::Receiver<Vec<Command>>,
207    term_size_watch: TermSizeWatch,
208    status: Status,
209}
210
211impl Renderer {
212    pub fn new(
213        config: Config,
214        resources: OpenResources,
215        term_size: CoordPair,
216        command_receiver: non_blocking::spsc::unbounded::Receiver<Vec<Command>>,
217    ) -> Self {
218        let tile_buf_size = usize::from(config.canvas_size.x)
219            * usize::from(config.canvas_size.y);
220        let tile_buf = Box::<[Tile]>::from(vec![
221            Tile {
222                grapheme: grapheme::Id::from(' '),
223                colors: config.default_colors,
224            };
225            tile_buf_size
226        ]);
227
228        Self {
229            device: resources.device,
230            device_queue: Vec::new(),
231            ticker: resources.timer.new_session(),
232            cancel_token: resources.cancel_token,
233            term_size,
234            canvas_size: config.canvas_size,
235            default_colors: config.default_colors,
236            current_colors: config.default_colors,
237            current_position: CoordPair { x: 0, y: 0 },
238            working_buf: tile_buf.clone(),
239            displayed_buf: tile_buf,
240            dirty: BTreeSet::new(),
241            grapheme_registry: resources.grapheme_registry,
242            command_receiver,
243            term_size_watch: resources.term_size_watch,
244            status: resources.status,
245        }
246    }
247
248    pub async fn run(mut self) -> Result<(), runtime::Error> {
249        let run_result = self.do_run().await;
250        self.shutdown().await.expect("Screen shutdown failed");
251        run_result
252    }
253
254    async fn do_run(&mut self) -> Result<(), runtime::Error> {
255        self.init().await?;
256
257        let mut commands = Vec::<Command>::new();
258
259        loop {
260            if !self.check_term_size_change().await? {
261                break;
262            }
263            self.render().await?;
264
265            tokio::select! {
266                _ = self.ticker.tick() => (),
267                _ = self.cancel_token.cancelled() => {
268                    tracing::info!("Screen token cancellation detected");
269                    break
270                },
271            }
272
273            if !self.check_term_size_change().await? {
274                break;
275            }
276            if !self.execute_commands_sent(&mut commands)? {
277                break;
278            }
279
280            tokio::select! {
281                _ = self.ticker.tick() => (),
282                _ = self.cancel_token.cancelled() => {
283                    tracing::info!("Screen token cancellation detected");
284                    break
285                },
286            }
287        }
288
289        Ok(())
290    }
291
292    fn queue(&mut self, commands: impl IntoIterator<Item = device::Command>) {
293        self.device_queue.extend(commands);
294    }
295
296    async fn flush(&mut self) -> Result<(), Error> {
297        let _ = self.device.send(self.device_queue.drain(..));
298        self.device.flush().await?;
299        Ok(())
300    }
301
302    async fn init(&mut self) -> Result<(), Error> {
303        self.enter()?;
304        let term_size = self.term_size;
305        self.status.set_blocked_from_sizes(self.canvas_size, self.term_size);
306        self.term_size_changed(term_size).await?;
307        Ok(())
308    }
309
310    async fn shutdown(&mut self) -> Result<(), Error> {
311        self.leave();
312        self.flush().await?;
313        Ok(())
314    }
315
316    async fn render(&mut self) -> Result<(), Error> {
317        if !self.needs_resize() {
318            self.draw_working_canvas()?;
319            self.flush().await?;
320        }
321        Ok(())
322    }
323
324    pub fn needs_resize(&self) -> bool {
325        self.status.is_blocked()
326    }
327
328    fn draw_working_canvas(&mut self) -> Result<(), Error> {
329        for canvas_point in mem::take(&mut self.dirty) {
330            let tile = self.get(canvas_point)?;
331            let term_point = self.canvas_to_term(canvas_point);
332            self.draw_tile(term_point, tile)?;
333        }
334        self.displayed_buf.clone_from(&self.working_buf);
335        Ok(())
336    }
337
338    fn enter(&mut self) -> Result<(), Error> {
339        self.queue([device::Command::Enter, device::Command::HideCursor]);
340        Ok(())
341    }
342
343    fn leave(&mut self) {
344        self.queue([
345            device::Command::ShowCursor,
346            device::Command::ResetBackground,
347            device::Command::ResetForeground,
348            device::Command::Clear,
349            device::Command::Leave,
350        ]);
351    }
352
353    async fn check_term_size_change(&mut self) -> Result<bool, Error> {
354        let Ok(term_size_message) = self.term_size_watch.recv() else {
355            tracing::info!("Terminal size watch sender disconnected");
356            return Ok(false);
357        };
358        if let Some(new_term_size) = term_size_message {
359            self.term_size_changed(new_term_size).await?;
360        }
361        Ok(true)
362    }
363
364    async fn term_size_changed(
365        &mut self,
366        new_term_size: CoordPair,
367    ) -> Result<(), Error> {
368        self.status.set_blocked_from_sizes(self.canvas_size, new_term_size);
369
370        let position = self.current_position;
371        self.move_to(position)?;
372        let colors = self.current_colors;
373        self.change_colors(colors)?;
374        self.dirty.clear();
375        let space = grapheme::Id::from(' ');
376
377        for (i, (working, displayed)) in self
378            .working_buf
379            .iter_mut()
380            .zip(&mut self.displayed_buf[..])
381            .enumerate()
382        {
383            *displayed = Tile { grapheme: space, colors: self.default_colors };
384            if *displayed != *working {
385                let point = self
386                    .canvas_size
387                    .map(usize::from)
388                    .as_rect_size(thedes_geometry::CoordPair::from_axes(|_| 0))
389                    .checked_bot_right_of_horz_area(&i)
390                    .map_err(InvalidCanvasIndex::from)?
391                    .map(|a| a as Coord);
392                self.dirty.insert(point);
393            }
394        }
395
396        self.term_size = new_term_size;
397        if self.needs_resize() {
398            self.draw_resize_msg()?;
399        } else {
400            self.draw_reset()?;
401        }
402
403        self.flush().await?;
404
405        Ok(())
406    }
407
408    fn move_to(&mut self, term_point: CoordPair) -> Result<(), Error> {
409        if term_point.zip2(self.term_size).any(|(point, size)| point >= size) {
410            Err(Error::InvalidTermPoint(term_point))?
411        }
412        self.queue([device::Command::MoveCursor(term_point)]);
413        self.current_position = term_point;
414        Ok(())
415    }
416
417    fn change_colors(&mut self, colors: ColorPair) -> Result<(), Error> {
418        self.change_foreground(colors.foreground)?;
419        self.change_background(colors.background)?;
420        Ok(())
421    }
422
423    fn change_foreground(&mut self, color: Color) -> Result<(), Error> {
424        self.queue([device::Command::SetForeground(color)]);
425        self.current_colors.foreground = color;
426        Ok(())
427    }
428
429    fn change_background(&mut self, color: Color) -> Result<(), Error> {
430        self.queue([device::Command::SetBackground(color)]);
431        self.current_colors.background = color;
432        Ok(())
433    }
434
435    fn clear_term(&mut self, background: Color) -> Result<(), Error> {
436        if background != self.current_colors.background {
437            self.change_background(background)?;
438        }
439        self.queue([device::Command::Clear]);
440        Ok(())
441    }
442
443    fn draw_reset_hor_line(
444        &mut self,
445        y: Coord,
446        x_start: Coord,
447        x_end: Coord,
448    ) -> Result<(), Error> {
449        let tile = Tile {
450            colors: self.default_colors,
451            grapheme: self.grapheme_registry.get_or_register("━")?,
452        };
453        for x in x_start .. x_end {
454            self.draw_tile(CoordPair { x, y }, tile)?;
455        }
456        Ok(())
457    }
458
459    fn draw_reset(&mut self) -> Result<(), Error> {
460        self.move_to(CoordPair { y: 0, x: 0 })?;
461        self.clear_term(self.default_colors.background)?;
462
463        let margin_top_left = self.top_left_margin();
464        let margin_bottom_right = self.bottom_right_margin();
465
466        let tile = Tile {
467            grapheme: self.grapheme_registry.get_or_register("┏")?,
468            colors: self.default_colors,
469        };
470        self.draw_tile(margin_top_left - 1, tile)?;
471        self.draw_reset_hor_line(
472            margin_top_left.y - 1,
473            margin_top_left.x,
474            margin_bottom_right.x,
475        )?;
476        let tile = Tile {
477            grapheme: self.grapheme_registry.get_or_register("┓")?,
478            colors: self.default_colors,
479        };
480        self.draw_tile(
481            CoordPair { x: margin_bottom_right.x, y: margin_top_left.y - 1 },
482            tile,
483        )?;
484
485        let tile = Tile {
486            grapheme: self.grapheme_registry.get_or_register("┃")?,
487            colors: self.default_colors,
488        };
489        for y in margin_top_left.y .. margin_bottom_right.y {
490            self.draw_tile(CoordPair { x: margin_top_left.x - 1, y }, tile)?;
491            self.draw_tile(CoordPair { x: margin_bottom_right.x, y }, tile)?;
492        }
493
494        let tile = Tile {
495            grapheme: self.grapheme_registry.get_or_register("┗")?,
496            colors: self.default_colors,
497        };
498        self.draw_tile(
499            CoordPair { x: margin_top_left.x - 1, y: margin_bottom_right.y },
500            tile,
501        )?;
502        self.draw_reset_hor_line(
503            margin_bottom_right.y,
504            margin_top_left.x,
505            margin_bottom_right.x,
506        )?;
507        let tile = Tile {
508            grapheme: self.grapheme_registry.get_or_register("┛")?,
509            colors: self.default_colors,
510        };
511        self.draw_tile(margin_bottom_right, tile)?;
512
513        Ok(())
514    }
515
516    fn draw_resize_msg(&mut self) -> Result<(), Error> {
517        let graphemes: Vec<_> = self
518            .grapheme_registry
519            .get_or_register_many(&format!(
520                "RESIZE {}x{}",
521                self.canvas_size.x + 2,
522                self.canvas_size.y + 2
523            ))
524            .collect();
525        self.move_to(CoordPair { y: 0, x: 0 })?;
526        self.clear_term(self.default_colors.background)?;
527        for (grapheme, i) in graphemes.into_iter().zip(0 ..) {
528            self.draw_tile(
529                CoordPair { x: i, y: 0 },
530                Tile { colors: self.default_colors, grapheme },
531            )?;
532        }
533        Ok(())
534    }
535
536    fn draw_tile(
537        &mut self,
538        term_point: CoordPair,
539        tile: Tile,
540    ) -> Result<(), Error> {
541        if self.current_position != term_point {
542            self.move_to(term_point)?;
543        }
544        if self.current_colors.foreground != tile.colors.foreground {
545            self.change_foreground(tile.colors.foreground)?;
546        }
547        if self.current_colors.background != tile.colors.background {
548            self.change_background(tile.colors.background)?;
549        }
550        self.draw_grapheme(tile.grapheme)?;
551        Ok(())
552    }
553
554    fn draw_grapheme(&mut self, id: grapheme::Id) -> Result<(), Error> {
555        self.grapheme_registry.lookup(id, |result| {
556            result.map(|chars| {
557                self.device_queue.extend(chars.map(device::Command::Write))
558            })
559        })?;
560        self.current_position.x += 1;
561        if self.current_position.x >= self.term_size.x {
562            self.current_position.x = 0;
563            self.current_position.y += 1;
564            if self.current_position.y >= self.term_size.y {
565                self.move_to(CoordPair { y: 0, x: 0 })?;
566            }
567        }
568        Ok(())
569    }
570
571    fn top_left_margin(&self) -> CoordPair {
572        (self.term_size - self.canvas_size + 1) / 2
573    }
574
575    fn bottom_right_margin(&self) -> CoordPair {
576        self.top_left_margin() + self.canvas_size
577    }
578
579    fn get(&self, canvas_point: CoordPair) -> Result<Tile, InvalidCanvasPoint> {
580        let index = self.point_to_index(canvas_point)?;
581        Ok(self.working_buf[index])
582    }
583
584    fn set(
585        &mut self,
586        canvas_point: CoordPair,
587        tile: Tile,
588    ) -> Result<Tile, InvalidCanvasPoint> {
589        let index = self.point_to_index(canvas_point)?;
590        if tile == self.displayed_buf[index] {
591            self.dirty.remove(&canvas_point);
592        } else {
593            self.dirty.insert(canvas_point);
594        }
595        let old = mem::replace(&mut self.working_buf[index], tile);
596        Ok(old)
597    }
598
599    fn execute_commands_sent(
600        &mut self,
601        buf: &mut Vec<Command>,
602    ) -> Result<bool, Error> {
603        let Ok(command_iterator) = self.command_receiver.recv_many() else {
604            tracing::info!("Screen command sender disconnected");
605            return Ok(false);
606        };
607        buf.extend(command_iterator.flatten());
608        for command in buf.drain(..) {
609            self.execute_command(command)?;
610        }
611        Ok(true)
612    }
613
614    fn execute_command(&mut self, command: Command) -> Result<(), Error> {
615        match command {
616            Command::Mutation(canvas_point, mutation) => {
617                self.execute_mutation(canvas_point, mutation)
618            },
619            Command::ClearScreen(color) => self.execute_clear_screen(color),
620        }
621    }
622
623    fn execute_mutation(
624        &mut self,
625        canvas_point: CoordPair,
626        mutation: Box<dyn BoxedMutation<Tile>>,
627    ) -> Result<(), Error> {
628        let curr_tile = self.get(canvas_point)?;
629        let new_tile = mutation
630            .mutate_boxed(curr_tile)
631            .map_err(|source| Error::TileMutation(canvas_point, source))?;
632        self.set(canvas_point, new_tile)?;
633        Ok(())
634    }
635
636    fn execute_clear_screen(&mut self, color: Color) -> Result<(), Error> {
637        for y in 0 .. self.canvas_size.y {
638            for x in 0 .. self.canvas_size.x {
639                self.set(
640                    CoordPair { y, x },
641                    Tile {
642                        colors: ColorPair {
643                            background: color,
644                            foreground: color,
645                        },
646                        grapheme: ' '.into(),
647                    },
648                )?;
649            }
650        }
651        Ok(())
652    }
653
654    fn point_to_index(
655        &self,
656        canvas_point: CoordPair,
657    ) -> Result<usize, InvalidCanvasPoint> {
658        let index = self
659            .canvas_size
660            .map(usize::from)
661            .as_rect_size(thedes_geometry::CoordPair::from_axes(|_| 0))
662            .checked_horz_area_down_to(canvas_point.map(usize::from))?;
663        Ok(index)
664    }
665
666    fn canvas_to_term(&self, canvas_point: CoordPair) -> CoordPair {
667        canvas_point + self.top_left_margin()
668    }
669}
670
671#[derive(Debug)]
672pub struct CanvasHandle {
673    size: CoordPair,
674    status: Status,
675    command_sender: non_blocking::spsc::unbounded::Sender<Vec<Command>>,
676    command_queue: Vec<Command>,
677}
678
679impl CanvasHandle {
680    fn new(
681        canvas_size: CoordPair,
682        status: Status,
683        command_sender: non_blocking::spsc::unbounded::Sender<Vec<Command>>,
684    ) -> Self {
685        Self {
686            size: canvas_size,
687            status,
688            command_sender,
689            command_queue: Vec::new(),
690        }
691    }
692
693    pub fn is_connected(&self) -> bool {
694        self.command_sender.is_connected()
695    }
696
697    pub fn size(&self) -> CoordPair {
698        self.size
699    }
700
701    pub fn queue<I>(&mut self, commands: I)
702    where
703        I: IntoIterator<Item = Command>,
704    {
705        self.command_queue.extend(commands);
706    }
707
708    pub fn flush(&mut self) -> Result<(), FlushError> {
709        let commands = mem::take(&mut self.command_queue);
710        self.command_sender.send(commands).map_err(FlushError::new)
711    }
712
713    pub fn is_blocked(&self) -> bool {
714        self.status.is_blocked()
715    }
716}
717
718#[cfg(test)]
719mod test {
720    use std::time::Duration;
721
722    use thedes_async_util::{non_blocking, timer::Timer};
723    use tokio::time::timeout;
724    use tokio_util::sync::CancellationToken;
725
726    use crate::{
727        color::{BasicColor, ColorPair},
728        geometry::CoordPair,
729        grapheme,
730        input::TermSizeWatch,
731        mutation::{MutationExt, Set},
732        runtime::JoinSet,
733        screen::{
734            Command,
735            Config,
736            OpenResources,
737            device::{self, mock::ScreenDeviceMock},
738        },
739        status::Status,
740        tile::{MutateColors, MutateGrapheme},
741    };
742
743    #[tokio::test(flavor = "multi_thread")]
744    async fn init_sends_command() {
745        let device_mock = ScreenDeviceMock::new(CoordPair { y: 40, x: 90 });
746        device_mock.enable_command_log();
747        let device = device_mock.open();
748
749        let timer = Timer::new(Duration::from_millis(4));
750        let mut tick_session = timer.new_session();
751        let cancel_token = CancellationToken::new();
752        let grapheme_registry = grapheme::Registry::new();
753        let (_term_size_sender, term_size_receiver) =
754            non_blocking::spsc::watch::channel();
755        let term_size_watch = TermSizeWatch::new(term_size_receiver);
756
757        let mut join_set = JoinSet::new();
758
759        let resources = OpenResources {
760            device,
761            timer,
762            cancel_token,
763            grapheme_registry,
764            term_size_watch,
765            status: Status::new(),
766        };
767
768        let handles = Config::new()
769            .with_canvas_size(CoordPair { y: 22, x: 78 })
770            .open(resources, &mut join_set);
771        tick_session.tick().await;
772        tick_session.tick().await;
773        drop(tick_session);
774        drop(handles);
775
776        let results = timeout(Duration::from_millis(200), join_set.join_all())
777            .await
778            .unwrap();
779        for result in results {
780            result.unwrap();
781        }
782
783        let command_log = device_mock.take_command_log().unwrap();
784        assert_ne!(command_log, &[] as &[Vec<device::Command>]);
785
786        assert!(
787            command_log.iter().flatten().next().is_some(),
788            "commands: {command_log:#?}",
789        );
790    }
791
792    #[tokio::test(flavor = "multi_thread")]
793    async fn init_flushes() {
794        let device_mock = ScreenDeviceMock::new(CoordPair { y: 40, x: 90 });
795        device_mock.enable_command_log();
796        let device = device_mock.open();
797
798        let timer = Timer::new(Duration::from_millis(4));
799        let mut tick_session = timer.new_session();
800        let cancel_token = CancellationToken::new();
801        let grapheme_registry = grapheme::Registry::new();
802        let (_term_size_sender, term_size_receiver) =
803            non_blocking::spsc::watch::channel();
804        let term_size_watch = TermSizeWatch::new(term_size_receiver);
805
806        let mut join_set = JoinSet::new();
807
808        let resources = OpenResources {
809            device,
810            timer,
811            cancel_token,
812            grapheme_registry,
813            term_size_watch,
814            status: Status::new(),
815        };
816
817        let handles = Config::new()
818            .with_canvas_size(CoordPair { y: 22, x: 78 })
819            .open(resources, &mut join_set);
820        tick_session.tick().await;
821        tick_session.tick().await;
822        drop(tick_session);
823        drop(handles);
824
825        let results = timeout(Duration::from_millis(200), join_set.join_all())
826            .await
827            .unwrap();
828        for result in results {
829            result.unwrap();
830        }
831
832        let command_log = device_mock.take_command_log().unwrap();
833        assert!(command_log.len() > 1);
834        assert!(
835            command_log.iter().flatten().next().is_some(),
836            "commands: {command_log:#?}",
837        );
838    }
839
840    #[tokio::test(flavor = "multi_thread")]
841    async fn mutation_sends_command() {
842        let device_mock = ScreenDeviceMock::new(CoordPair { y: 40, x: 90 });
843        device_mock.enable_command_log();
844        let device = device_mock.open();
845
846        let timer = Timer::new(Duration::from_millis(4));
847        let mut tick_session = timer.new_session();
848        let cancel_token = CancellationToken::new();
849        let grapheme_registry = grapheme::Registry::new();
850        let (_term_size_sender, term_size_receiver) =
851            non_blocking::spsc::watch::channel();
852        let term_size_watch = TermSizeWatch::new(term_size_receiver);
853
854        let mut join_set = JoinSet::new();
855
856        let resources = OpenResources {
857            device,
858            timer,
859            cancel_token,
860            grapheme_registry,
861            term_size_watch,
862            status: Status::new(),
863        };
864
865        let mut handles = Config::new()
866            .with_canvas_size(CoordPair { y: 22, x: 78 })
867            .open(resources, &mut join_set);
868        handles.canvas.queue([Command::new_mutation(
869            CoordPair { y: 12, x: 30 },
870            MutateColors(Set(ColorPair {
871                foreground: BasicColor::DarkCyan.into(),
872                background: BasicColor::LightGreen.into(),
873            }))
874            .then(MutateGrapheme(Set('B'.into()))),
875        )]);
876        handles.canvas.flush().unwrap();
877        tick_session.tick().await;
878        tick_session.tick().await;
879        drop(tick_session);
880        drop(handles);
881
882        let results = timeout(Duration::from_millis(200), join_set.join_all())
883            .await
884            .unwrap();
885        for result in results {
886            result.unwrap();
887        }
888
889        let command_log = device_mock.take_command_log().unwrap();
890
891        assert_eq!(
892            1,
893            command_log
894                .iter()
895                .flatten()
896                .filter(|command| **command == device::Command::Write('B'))
897                .count(),
898            "commands: {command_log:#?}",
899        );
900
901        assert_eq!(
902            1,
903            command_log
904                .iter()
905                .flatten()
906                .filter(|command| **command
907                    == device::Command::SetForeground(
908                        BasicColor::DarkCyan.into()
909                    ))
910                .count(),
911            "commands: {command_log:#?}",
912        );
913
914        assert_eq!(
915            1,
916            command_log
917                .iter()
918                .flatten()
919                .filter(|command| **command
920                    == device::Command::SetBackground(
921                        BasicColor::LightGreen.into()
922                    ))
923                .count(),
924            "commands: {command_log:#?}",
925        );
926    }
927
928    #[tokio::test(flavor = "multi_thread")]
929    async fn clear_screen_sends_command() {
930        let device_mock = ScreenDeviceMock::new(CoordPair { y: 40, x: 90 });
931        device_mock.enable_command_log();
932        let device = device_mock.open();
933
934        let timer = Timer::new(Duration::from_millis(4));
935        let mut tick_session = timer.new_session();
936        let cancel_token = CancellationToken::new();
937        let grapheme_registry = grapheme::Registry::new();
938        let (_term_size_sender, term_size_receiver) =
939            non_blocking::spsc::watch::channel();
940        let term_size_watch = TermSizeWatch::new(term_size_receiver);
941
942        let mut join_set = JoinSet::new();
943
944        let resources = OpenResources {
945            device,
946            timer,
947            cancel_token,
948            grapheme_registry,
949            term_size_watch,
950            status: Status::new(),
951        };
952
953        let mut handles = Config::new()
954            .with_canvas_size(CoordPair { y: 20, x: 60 })
955            .open(resources, &mut join_set);
956        handles
957            .canvas
958            .queue([Command::ClearScreen(BasicColor::DarkRed.into())]);
959        handles.canvas.flush().unwrap();
960        tick_session.tick().await;
961        tick_session.tick().await;
962        drop(tick_session);
963        drop(handles);
964
965        let results = timeout(Duration::from_millis(200), join_set.join_all())
966            .await
967            .unwrap();
968        for result in results {
969            result.unwrap();
970        }
971
972        let command_log = device_mock.take_command_log().unwrap();
973
974        assert_eq!(
975            20 * 60,
976            command_log
977                .iter()
978                .flatten()
979                .filter(|command| **command == device::Command::Write(' '))
980                .count(),
981            "commands: {command_log:#?}",
982        );
983
984        assert_eq!(
985            1,
986            command_log
987                .iter()
988                .flatten()
989                .filter(|command| **command
990                    == device::Command::SetBackground(
991                        BasicColor::DarkRed.into()
992                    ))
993                .count(),
994            "commands: {command_log:#?}",
995        );
996    }
997
998    #[tokio::test(flavor = "multi_thread")]
999    async fn resize_too_small() {
1000        let device_mock = ScreenDeviceMock::new(CoordPair { y: 40, x: 90 });
1001        device_mock.enable_command_log();
1002        let device = device_mock.open();
1003
1004        let timer = Timer::new(Duration::from_millis(4));
1005        let mut tick_session = timer.new_session();
1006        let cancel_token = CancellationToken::new();
1007        let grapheme_registry = grapheme::Registry::new();
1008        let (mut term_size_sender, term_size_receiver) =
1009            non_blocking::spsc::watch::channel();
1010        let term_size_watch = TermSizeWatch::new(term_size_receiver);
1011
1012        let mut join_set = JoinSet::new();
1013
1014        let resources = OpenResources {
1015            device,
1016            timer,
1017            cancel_token,
1018            grapheme_registry,
1019            term_size_watch,
1020            status: Status::new(),
1021        };
1022
1023        let handles = Config::new()
1024            .with_canvas_size(CoordPair { y: 24, x: 80 })
1025            .open(resources, &mut join_set);
1026        term_size_sender.send(CoordPair { y: 26, x: 81 }).unwrap();
1027        tick_session.tick().await;
1028        tick_session.tick().await;
1029        assert!(handles.canvas.is_blocked());
1030        drop(tick_session);
1031        drop(handles);
1032
1033        let results = timeout(Duration::from_millis(200), join_set.join_all())
1034            .await
1035            .unwrap();
1036        for result in results {
1037            result.unwrap();
1038        }
1039
1040        let command_log = device_mock.take_command_log().unwrap();
1041
1042        let resize_msg = "RESIZE 82x26";
1043        for ch in resize_msg.chars() {
1044            assert_eq!(
1045                resize_msg.chars().filter(|resize_ch| *resize_ch == ch).count(),
1046                command_log
1047                    .iter()
1048                    .flatten()
1049                    .filter(|command| **command == device::Command::Write(ch))
1050                    .count(),
1051                "expected {ch} to occur once, commands: {command_log:#?}",
1052            );
1053        }
1054    }
1055
1056    #[tokio::test(flavor = "multi_thread")]
1057    async fn resize_min_size() {
1058        let device_mock = ScreenDeviceMock::new(CoordPair { y: 40, x: 90 });
1059        device_mock.enable_command_log();
1060        let device = device_mock.open();
1061
1062        let timer = Timer::new(Duration::from_millis(4));
1063        let mut tick_session = timer.new_session();
1064        let cancel_token = CancellationToken::new();
1065        let grapheme_registry = grapheme::Registry::new();
1066        let (mut term_size_sender, term_size_receiver) =
1067            non_blocking::spsc::watch::channel();
1068        let term_size_watch = TermSizeWatch::new(term_size_receiver);
1069
1070        let mut join_set = JoinSet::new();
1071
1072        let resources = OpenResources {
1073            device,
1074            timer,
1075            cancel_token,
1076            grapheme_registry,
1077            term_size_watch,
1078            status: Status::new(),
1079        };
1080
1081        let mut handles = Config::new()
1082            .with_canvas_size(CoordPair { y: 24, x: 80 })
1083            .open(resources, &mut join_set);
1084        term_size_sender.send(CoordPair { y: 26, x: 82 }).unwrap();
1085        handles.canvas.queue([Command::new_mutation(
1086            CoordPair { y: 12, x: 30 },
1087            MutateColors(Set(ColorPair {
1088                foreground: BasicColor::DarkCyan.into(),
1089                background: BasicColor::LightGreen.into(),
1090            }))
1091            .then(MutateGrapheme(Set('B'.into()))),
1092        )]);
1093        handles.canvas.flush().unwrap();
1094        tick_session.tick().await;
1095        tick_session.tick().await;
1096        drop(tick_session);
1097        drop(handles);
1098
1099        let results = timeout(Duration::from_millis(200), join_set.join_all())
1100            .await
1101            .unwrap();
1102        for result in results {
1103            result.unwrap();
1104        }
1105
1106        let command_log = device_mock.take_command_log().unwrap();
1107
1108        let resize_msg = "RESIZE 82x26";
1109        let mut occurences = 0;
1110
1111        for ch in resize_msg.chars() {
1112            if command_log
1113                .iter()
1114                .flatten()
1115                .filter(|command| **command == device::Command::Write(ch))
1116                .count()
1117                >= resize_msg
1118                    .chars()
1119                    .filter(|resize_ch| *resize_ch == ch)
1120                    .count()
1121            {
1122                occurences += 1;
1123            }
1124        }
1125
1126        assert_ne!(occurences, resize_msg.len(), "commands: {command_log:#?}",);
1127    }
1128
1129    #[tokio::test(flavor = "multi_thread")]
1130    async fn mutation_sends_command_after_resize_correction() {
1131        let device_mock = ScreenDeviceMock::new(CoordPair { y: 40, x: 90 });
1132        device_mock.enable_command_log();
1133        let device = device_mock.open();
1134
1135        let timer = Timer::new(Duration::from_millis(4));
1136        let mut tick_session = timer.new_session();
1137        let cancel_token = CancellationToken::new();
1138        let grapheme_registry = grapheme::Registry::new();
1139        let (mut term_size_sender, term_size_receiver) =
1140            non_blocking::spsc::watch::channel();
1141        let term_size_watch = TermSizeWatch::new(term_size_receiver);
1142
1143        let mut join_set = JoinSet::new();
1144
1145        let resources = OpenResources {
1146            device,
1147            timer,
1148            cancel_token,
1149            grapheme_registry,
1150            term_size_watch,
1151            status: Status::new(),
1152        };
1153
1154        let mut handles = Config::new()
1155            .with_canvas_size(CoordPair { y: 24, x: 80 })
1156            .open(resources, &mut join_set);
1157        term_size_sender.send(CoordPair { y: 26, x: 81 }).unwrap();
1158        tick_session.tick().await;
1159        tick_session.tick().await;
1160        term_size_sender.send(CoordPair { y: 26, x: 82 }).unwrap();
1161        tick_session.tick().await;
1162        tick_session.tick().await;
1163        handles.canvas.queue([Command::new_mutation(
1164            CoordPair { y: 12, x: 30 },
1165            MutateColors(Set(ColorPair {
1166                foreground: BasicColor::DarkCyan.into(),
1167                background: BasicColor::LightGreen.into(),
1168            }))
1169            .then(MutateGrapheme(Set('B'.into()))),
1170        )]);
1171        handles.canvas.flush().unwrap();
1172        tick_session.tick().await;
1173        tick_session.tick().await;
1174        drop(tick_session);
1175        drop(handles);
1176
1177        let results = timeout(Duration::from_millis(200), join_set.join_all())
1178            .await
1179            .unwrap();
1180        for result in results {
1181            result.unwrap();
1182        }
1183
1184        let command_log = device_mock.take_command_log().unwrap();
1185
1186        assert_eq!(
1187            1,
1188            command_log
1189                .iter()
1190                .flatten()
1191                .filter(|command| **command == device::Command::Write('B'))
1192                .count(),
1193            "commands: {command_log:#?}",
1194        );
1195
1196        assert_eq!(
1197            1,
1198            command_log
1199                .iter()
1200                .flatten()
1201                .filter(|command| **command
1202                    == device::Command::SetForeground(
1203                        BasicColor::DarkCyan.into()
1204                    ))
1205                .count(),
1206            "commands: {command_log:#?}",
1207        );
1208
1209        assert_eq!(
1210            1,
1211            command_log
1212                .iter()
1213                .flatten()
1214                .filter(|command| **command
1215                    == device::Command::SetBackground(
1216                        BasicColor::LightGreen.into()
1217                    ))
1218                .count(),
1219            "commands: {command_log:#?}",
1220        );
1221    }
1222
1223    #[tokio::test(flavor = "multi_thread")]
1224    async fn cancel_token_stops() {
1225        let device_mock = ScreenDeviceMock::new(CoordPair { y: 40, x: 90 });
1226        device_mock.enable_command_log();
1227        let device = device_mock.open();
1228
1229        let timer = Timer::new(Duration::from_millis(4));
1230        let tick_session = timer.new_session();
1231        let cancel_token = CancellationToken::new();
1232        let grapheme_registry = grapheme::Registry::new();
1233        let (_term_size_sender, term_size_receiver) =
1234            non_blocking::spsc::watch::channel();
1235        let term_size_watch = TermSizeWatch::new(term_size_receiver);
1236
1237        let mut join_set = JoinSet::new();
1238
1239        let resources = OpenResources {
1240            device,
1241            timer,
1242            cancel_token: cancel_token.clone(),
1243            grapheme_registry,
1244            term_size_watch,
1245            status: Status::new(),
1246        };
1247
1248        let _handles = Config::new()
1249            .with_canvas_size(CoordPair { y: 22, x: 78 })
1250            .open(resources, &mut join_set);
1251        drop(tick_session);
1252        cancel_token.cancel();
1253
1254        let results = timeout(Duration::from_millis(200), join_set.join_all())
1255            .await
1256            .unwrap();
1257        for result in results {
1258            result.unwrap();
1259        }
1260    }
1261
1262    #[tokio::test(flavor = "multi_thread")]
1263    async fn drop_term_size_stops() {
1264        let device_mock = ScreenDeviceMock::new(CoordPair { y: 40, x: 90 });
1265        device_mock.enable_command_log();
1266        let device = device_mock.open();
1267
1268        let timer = Timer::new(Duration::from_millis(4));
1269        let tick_session = timer.new_session();
1270        let cancel_token = CancellationToken::new();
1271        let grapheme_registry = grapheme::Registry::new();
1272        let (term_size_sender, term_size_receiver) =
1273            non_blocking::spsc::watch::channel();
1274        let term_size_watch = TermSizeWatch::new(term_size_receiver);
1275
1276        let mut join_set = JoinSet::new();
1277
1278        let resources = OpenResources {
1279            device,
1280            timer,
1281            cancel_token: cancel_token.clone(),
1282            grapheme_registry,
1283            term_size_watch,
1284            status: Status::new(),
1285        };
1286
1287        let _handles = Config::new()
1288            .with_canvas_size(CoordPair { y: 22, x: 78 })
1289            .open(resources, &mut join_set);
1290        drop(tick_session);
1291        drop(term_size_sender);
1292
1293        let results = timeout(Duration::from_millis(200), join_set.join_all())
1294            .await
1295            .unwrap();
1296        for result in results {
1297            result.unwrap();
1298        }
1299    }
1300
1301    #[tokio::test(flavor = "multi_thread")]
1302    async fn drop_canvas_handle_stops() {
1303        let device_mock = ScreenDeviceMock::new(CoordPair { y: 40, x: 90 });
1304        device_mock.enable_command_log();
1305        let device = device_mock.open();
1306
1307        let timer = Timer::new(Duration::from_millis(4));
1308        let tick_session = timer.new_session();
1309        let cancel_token = CancellationToken::new();
1310        let grapheme_registry = grapheme::Registry::new();
1311        let (_term_size_sender, term_size_receiver) =
1312            non_blocking::spsc::watch::channel();
1313        let term_size_watch = TermSizeWatch::new(term_size_receiver);
1314
1315        let mut join_set = JoinSet::new();
1316
1317        let resources = OpenResources {
1318            device,
1319            timer,
1320            cancel_token: cancel_token.clone(),
1321            grapheme_registry,
1322            term_size_watch,
1323            status: Status::new(),
1324        };
1325
1326        let handles = Config::new()
1327            .with_canvas_size(CoordPair { y: 22, x: 78 })
1328            .open(resources, &mut join_set);
1329        drop(tick_session);
1330        drop(handles.canvas);
1331
1332        let results = timeout(Duration::from_millis(200), join_set.join_all())
1333            .await
1334            .unwrap();
1335        for result in results {
1336            result.unwrap();
1337        }
1338    }
1339}