Skip to main content

katui_rt/
headless.rs

1//! A headless driver: runs an [`App`] with no terminal, so a test can type
2//! bytes, deliver messages, resize the screen and read back every frame's
3//! bytes.
4
5use std::io;
6use std::sync::mpsc::{self, Receiver};
7
8use katui_proto::input::{InputEvent, InputParser};
9use katui_scene::CellSize;
10
11use crate::app::{App, AppEvent, Control};
12use crate::events::{Signal, Waker};
13
14/// Drives an [`App`] the way the event loop does, minus the terminal.
15#[derive(Debug)]
16pub struct Headless<A: App> {
17    app: A,
18    size: CellSize,
19    parser: InputParser,
20    messages: Receiver<Signal<A::Message>>,
21    pending: Control,
22}
23
24impl<A: App> Headless<A>
25where
26    A::Message: Send + 'static,
27{
28    /// Start `app` on a screen of `size` cells.
29    pub fn start(mut app: A, size: CellSize) -> Self {
30        let (sender, messages) = mpsc::channel();
31        app.start(Waker(sender));
32        Self {
33            app,
34            size,
35            parser: InputParser::default(),
36            messages,
37            pending: Control::Redraw,
38        }
39    }
40
41    /// Feed `bytes` as if typed, with a lone escape counting as Esc.
42    pub fn type_bytes(&mut self, bytes: &[u8]) {
43        let mut events = self.parser.feed(bytes);
44        events.extend(self.parser.idle());
45        for event in events {
46            self.input(event);
47        }
48    }
49
50    /// Deliver one input event; answers to pacing queries stay with the
51    /// loop, as they do on a terminal.
52    pub fn input(&mut self, event: InputEvent) {
53        if !crate::app::is_terminal_answer(&event) {
54            self.update(AppEvent::Input(event));
55        }
56    }
57
58    /// Deliver every message the app's threads queued so far.
59    pub fn deliver_messages(&mut self) {
60        while let Ok(signal) = self.messages.try_recv() {
61            if let Signal::Message(message) = signal {
62                self.update(AppEvent::Message(message));
63            }
64        }
65    }
66
67    /// Resize the screen and let it settle, the way a window resize ends.
68    pub fn resize(&mut self, size: CellSize) {
69        self.size = size;
70        self.update(AppEvent::Resized(size));
71        self.update(AppEvent::Repaint);
72        self.pending = crate::app::merge(self.pending, Control::Redraw);
73    }
74
75    /// Draw one frame when the app asked for one; `None` when it waits or
76    /// quit.
77    pub fn frame(&mut self) -> io::Result<Option<Vec<u8>>> {
78        if !matches!(self.pending, Control::Redraw | Control::Animate) {
79            return Ok(None);
80        }
81        let mut bytes = Vec::new();
82        self.pending = self.app.draw(&mut bytes, self.size)?;
83        Ok(Some(bytes))
84    }
85
86    /// Whether the app asked to quit.
87    pub fn quit(&self) -> bool {
88        self.pending == Control::Quit
89    }
90
91    /// The app, for asserting on its state.
92    pub fn app(&self) -> &A {
93        &self.app
94    }
95
96    fn update(&mut self, event: AppEvent<A::Message>) {
97        let control = self.app.update(event);
98        self.pending = crate::app::merge(self.pending, control);
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use std::io;
105
106    use katui_proto::input::{InputEvent, Key};
107    use katui_scene::CellSize;
108
109    use super::Headless;
110    use crate::app::{App, AppEvent, Control};
111    use crate::events::Waker;
112
113    #[derive(Default)]
114    struct Counter {
115        typed: String,
116        waker: Option<Waker<u32>>,
117        total: u32,
118    }
119
120    impl App for Counter {
121        type Message = u32;
122
123        fn start(&mut self, waker: Waker<u32>) {
124            waker.send(5);
125            self.waker = Some(waker);
126        }
127
128        fn update(&mut self, event: AppEvent<u32>) -> Control {
129            match event {
130                AppEvent::Input(event) if event.is_quit_key() => Control::Quit,
131                AppEvent::Input(InputEvent::Key(Key::Char(letter), _)) => {
132                    self.typed.push(letter);
133                    Control::Redraw
134                }
135                AppEvent::Message(amount) => {
136                    self.total += amount;
137                    Control::Redraw
138                }
139                _ => Control::Wait,
140            }
141        }
142
143        fn draw(&mut self, out: &mut Vec<u8>, size: CellSize) -> io::Result<Control> {
144            out.extend(
145                format!(
146                    "{}x{} {} {}",
147                    size.columns, size.rows, self.typed, self.total
148                )
149                .bytes(),
150            );
151            Ok(Control::Wait)
152        }
153    }
154
155    fn size(columns: u16, rows: u16) -> CellSize {
156        CellSize { columns, rows }
157    }
158
159    #[test]
160    fn a_headless_app_types_receives_resizes_and_quits() {
161        let mut driver = Headless::start(Counter::default(), size(80, 24));
162        assert_eq!(
163            driver.frame().expect("drawing works"),
164            Some(b"80x24  0".to_vec())
165        );
166        assert_eq!(
167            driver.frame().expect("drawing works"),
168            None,
169            "a waiting app draws nothing"
170        );
171        driver.type_bytes(b"hi");
172        driver.deliver_messages();
173        assert_eq!(
174            driver.frame().expect("drawing works"),
175            Some(b"80x24 hi 5".to_vec())
176        );
177        driver.type_bytes(b"\x1b[?62;4c");
178        assert_eq!(
179            driver.frame().expect("drawing works"),
180            None,
181            "an answer is no key and draws nothing"
182        );
183        driver.resize(size(40, 10));
184        assert_eq!(
185            driver.frame().expect("drawing works"),
186            Some(b"40x10 hi 5".to_vec()),
187            "a resize redraws"
188        );
189        driver.type_bytes(b"!");
190        assert_eq!(
191            driver.frame().expect("drawing works"),
192            Some(b"40x10 hi! 5".to_vec())
193        );
194        driver.type_bytes(b"\x03");
195        assert!(driver.quit());
196        assert_eq!(driver.app().total, 5);
197    }
198}