Skip to main content

qframe/runtime/
harness.rs

1//! Driving an application in tests: no terminal, a fake clock and inline background work.
2
3use std::time::Duration;
4
5use ratatui_core::buffer::Buffer;
6use ratatui_core::layout::Rect as BufferRect;
7use ratatui_core::style::{Color, Modifier};
8
9use super::app::App;
10use super::engine::{Engine, TaskMode};
11use crate::color::Rgb;
12use crate::env::Env;
13use crate::event::{Event, KeyEvent, MouseButton, MouseEvent, MouseKind};
14use crate::icons::GlyphMode;
15use crate::keymap::Modifiers;
16
17/// Time the fake clock moves before every simulated key press, so presses are never mistaken
18/// for a held key.
19const KEY_INTERVAL: Duration = Duration::from_millis(150);
20
21/// Runs an [`App`] against an in-memory screen.
22///
23/// Every input method renders afterwards, like the real runtime does. Work of
24/// [`Command::perform`](super::Command::perform) runs inline, one round per step like one pass of
25/// the terminal loop: work that performs again runs at the next step, so a chain of performs
26/// takes one [`Harness::render`] per link and an endless one never blocks a test.
27pub struct Harness<A: App> {
28    engine: Engine<A>,
29    buffer: Buffer,
30    now: Duration,
31}
32
33impl<A: App> Harness<A> {
34    /// A harness with the built-in environment and a `width` × `height` screen, already rendered.
35    pub fn new(app: A, width: u16, height: u16) -> Self {
36        Self::with_env(app, Env::builtin(), width, height)
37    }
38
39    /// A harness with a custom environment.
40    pub fn with_env(app: A, env: Env, width: u16, height: u16) -> Self {
41        let mut harness = Self {
42            engine: Engine::new(app, env, TaskMode::Inline),
43            buffer: Buffer::empty(BufferRect::new(0, 0, width, height)),
44            now: Duration::ZERO,
45        };
46        harness.render();
47        harness
48    }
49
50    /// Paints the current view.
51    pub fn render(&mut self) -> &mut Self {
52        self.settle_tasks();
53        self.engine.render(&mut self.buffer, self.now);
54        // Settling hover or scrolling to a focused widget can ask for one more frame at once.
55        for _ in 0..3 {
56            let due = self.engine.deadline().is_some_and(|deadline| deadline <= self.now);
57            if !self.engine.dirty && !due {
58                break;
59            }
60            self.engine.render(&mut self.buffer, self.now);
61        }
62        self
63    }
64
65    /// Runs the perform work queued so far, then lets background tasks run up to the fake clock:
66    /// every task works until it sleeps past `now` or ends, and everything they sent is applied.
67    /// Tasks started by those messages settle too. Perform work queued meanwhile runs at the next
68    /// step, so work that performs again never keeps a step from ending.
69    fn settle_tasks(&mut self) {
70        self.engine.run_queued_work();
71        loop {
72            self.engine.task_clock.settle(self.now);
73            if self.engine.poll_tasks() == 0 {
74                break;
75            }
76        }
77    }
78
79    /// Delivers `message` to the application as if a widget had sent it, then renders.
80    pub fn send(&mut self, message: A::Msg) -> &mut Self {
81        self.engine.update(message);
82        self.render()
83    }
84
85    /// Presses a key chord such as `"ctrl+s"`, `"tab"` or `"?"`.
86    pub fn press(&mut self, chord: &str) -> &mut Self {
87        self.now += KEY_INTERVAL;
88        self.engine.handle(Event::Key(KeyEvent::press(chord)), self.now);
89        self.render()
90    }
91
92    /// Types `text` one character at a time.
93    pub fn type_text(&mut self, text: &str) -> &mut Self {
94        for c in text.chars() {
95            let chord = match c {
96                ' ' => "space".to_owned(),
97                '+' => "+".to_owned(),
98                c if c.is_uppercase() => format!("shift+{}", c.to_lowercase()),
99                c => c.to_string(),
100            };
101            self.press(&chord);
102        }
103        self
104    }
105
106    /// Delivers `events` in order at the current clock time and renders once afterwards, the
107    /// way the terminal loop handles every event waiting between two frames. Widgets see the
108    /// later events with what the earlier ones changed but the rects of the frame before, e.g. a
109    /// click where a submenu was drawn that a key delivered in the same call already closed.
110    pub fn events(&mut self, events: &[Event]) -> &mut Self {
111        for event in events {
112            self.engine.handle(event.clone(), self.now);
113        }
114        self.render()
115    }
116
117    /// Delivers `event` at an exact clock time, without moving the clock.
118    #[cfg(test)]
119    pub(crate) fn inject(&mut self, event: Event, at: Duration) -> &mut Self {
120        self.engine.handle(event, at);
121        self.render()
122    }
123
124    /// Pastes `text`.
125    pub fn paste(&mut self, text: &str) -> &mut Self {
126        self.engine.handle(Event::Paste(text.to_owned()), self.now);
127        self.render()
128    }
129
130    /// Clicks the left button on a cell.
131    pub fn click(&mut self, x: i32, y: i32) -> &mut Self {
132        self.mouse(MouseKind::Down(MouseButton::Left), x, y);
133        self.mouse(MouseKind::Up(MouseButton::Left), x, y)
134    }
135
136    /// Clicks the first cell of the first occurrence of `text` on screen.
137    ///
138    /// # Panics
139    ///
140    /// Panics when `text` is not on screen.
141    pub fn click_text(&mut self, text: &str) -> &mut Self {
142        let (x, y) = self.find(text).unwrap_or_else(|| panic!("`{text}` is not on screen:\n{}", self.screen()));
143        self.click(x, y)
144    }
145
146    /// Presses the left button on `from`, drags to `to` and releases there.
147    pub fn drag(&mut self, from: (i32, i32), to: (i32, i32)) -> &mut Self {
148        self.mouse(MouseKind::Down(MouseButton::Left), from.0, from.1);
149        self.mouse(MouseKind::Drag(MouseButton::Left), to.0, to.1);
150        self.mouse(MouseKind::Up(MouseButton::Left), to.0, to.1)
151    }
152
153    /// Moves the pointer to a cell.
154    pub fn hover(&mut self, x: i32, y: i32) -> &mut Self {
155        self.mouse(MouseKind::Moved, x, y)
156    }
157
158    /// Sends a mouse event.
159    pub fn mouse(&mut self, kind: MouseKind, x: i32, y: i32) -> &mut Self {
160        self.engine.handle(Event::Mouse(MouseEvent { kind, x, y, mods: Modifiers::default() }), self.now);
161        self.render()
162    }
163
164    /// Moves the fake clock forward and renders.
165    pub fn advance(&mut self, duration: Duration) -> &mut Self {
166        self.now += duration;
167        self.engine.tick(self.now);
168        self.render()
169    }
170
171    /// Delivers a key event exactly as given, without moving the clock: a
172    /// [`KeyKind::Repeat`](crate::event::KeyKind::Repeat) or
173    /// [`KeyKind::Release`](crate::event::KeyKind::Release) from a terminal with the kitty
174    /// keyboard protocol, or a press repeated by a held key.
175    pub fn key(&mut self, event: KeyEvent) -> &mut Self {
176        self.engine.handle(Event::Key(event), self.now);
177        self.render()
178    }
179
180    /// Switches theme, as `Command::set_theme` would.
181    pub fn set_theme(&mut self, id: &str) -> &mut Self {
182        self.engine.env.set_theme(id);
183        self.render()
184    }
185
186    /// Switches language, as `Command::set_locale` would.
187    pub fn set_locale(&mut self, code: &str) -> &mut Self {
188        self.engine.env.set_locale(code);
189        self.render()
190    }
191
192    /// Turns reduced motion on or off.
193    pub fn set_reduced_motion(&mut self, reduced: bool) -> &mut Self {
194        self.engine.env.set_reduced_motion(reduced);
195        self.render()
196    }
197
198    /// Switches the glyph column drawn.
199    pub fn set_glyph_mode(&mut self, mode: GlyphMode) -> &mut Self {
200        self.engine.env.set_glyph_mode(mode);
201        self.render()
202    }
203
204    /// Resizes the screen to `width` × `height` and renders, as a terminal resize does in the
205    /// runtime: the backend hands the engine a fresh, empty buffer of the new size and the next
206    /// frame is drawn in full.
207    pub fn resize(&mut self, width: u16, height: u16) -> &mut Self {
208        self.buffer = Buffer::empty(BufferRect::new(0, 0, width, height));
209        self.engine.dirty = true;
210        self.render()
211    }
212
213    /// The screen as text, one line per row, trailing spaces removed.
214    #[must_use]
215    pub fn screen(&self) -> String {
216        let mut out = String::new();
217        for y in 0..self.buffer.area.height {
218            out.push_str(self.row(y).0.trim_end());
219            out.push('\n');
220        }
221        out
222    }
223
224    /// The screen as a self-contained HTML fragment with colours and weights, for looking at
225    /// renders in a browser. Wrap fragments with [`html_page`] to get a document.
226    #[must_use]
227    pub fn html(&self, caption: &str) -> String {
228        let area = self.buffer.area;
229        let escape = |text: &str| text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
230        let css = |color: Color| rgb(color).map_or_else(|| "inherit".to_owned(), |c| c.to_string());
231        let mut out = format!("<figure><figcaption>{}</figcaption><div class=\"screen\">", escape(caption));
232        for y in 0..area.height {
233            out.push_str("<div class=\"row\">");
234            for x in 0..area.width {
235                let cell = &self.buffer[(x, y)];
236                if cell.symbol().is_empty() {
237                    continue;
238                }
239                let modifier = cell.modifier;
240                let weight = if modifier.contains(Modifier::BOLD) { "font-weight:700;" } else { "" };
241                let style = if modifier.contains(Modifier::ITALIC) { "font-style:italic;" } else { "" };
242                let line = if modifier.contains(Modifier::UNDERLINED) { "text-decoration:underline;" } else { "" };
243                out.push_str(&format!(
244                    "<span style=\"color:{};background:{};width:{}ch;{weight}{style}{line}\">{}</span>",
245                    css(cell.fg),
246                    css(cell.bg),
247                    crate::text::width(cell.symbol()).max(1),
248                    escape(cell.symbol())
249                ));
250            }
251            out.push_str("</div>");
252        }
253        out.push_str("</div></figure>");
254        out
255    }
256
257    /// Screen position of the first occurrence of `text`, in cells.
258    #[must_use]
259    pub fn find(&self, text: &str) -> Option<(i32, i32)> {
260        (0..self.buffer.area.height).find_map(|y| {
261            let (line, columns) = self.row(y);
262            line.find(text).map(|byte| (i32::from(columns[byte]), i32::from(y)))
263        })
264    }
265
266    /// Row `y` of the screen as text, with the column each byte of that text was drawn in.
267    fn row(&self, y: u16) -> (String, Vec<u16>) {
268        let mut line = String::new();
269        let mut columns = Vec::new();
270        for x in 0..self.buffer.area.width {
271            let symbol = self.buffer[(x, y)].symbol();
272            columns.extend(std::iter::repeat_n(x, symbol.len()));
273            line.push_str(symbol);
274        }
275        (line, columns)
276    }
277
278    /// Text colour of a cell.
279    ///
280    /// # Panics
281    ///
282    /// Panics when the cell is outside the screen.
283    #[must_use]
284    pub fn fg(&self, x: u16, y: u16) -> Option<Rgb> {
285        rgb(self.buffer[(x, y)].fg)
286    }
287
288    /// Background colour of a cell.
289    ///
290    /// # Panics
291    ///
292    /// Panics when the cell is outside the screen.
293    #[must_use]
294    pub fn bg(&self, x: u16, y: u16) -> Option<Rgb> {
295        rgb(self.buffer[(x, y)].bg)
296    }
297
298    /// Whether a cell is bold.
299    ///
300    /// # Panics
301    ///
302    /// Panics when the cell is outside the screen.
303    #[must_use]
304    pub fn is_bold(&self, x: u16, y: u16) -> bool {
305        self.buffer[(x, y)].modifier.contains(Modifier::BOLD)
306    }
307
308    /// The rendered buffer.
309    #[must_use]
310    pub fn buffer(&self) -> &Buffer {
311        &self.buffer
312    }
313
314    /// The application.
315    #[must_use]
316    pub fn app(&self) -> &A {
317        &self.engine.app
318    }
319
320    /// The environment.
321    #[must_use]
322    pub fn env(&self) -> &Env {
323        &self.engine.env
324    }
325
326    /// Texts copied to the clipboard so far.
327    #[must_use]
328    pub fn copied(&self) -> &[String] {
329        &self.engine.clipboard
330    }
331
332    /// The in-process clipboard: the text copied last, if any.
333    #[must_use]
334    pub fn clipboard(&self) -> Option<&str> {
335        self.engine.clipboard_text.as_deref()
336    }
337
338    /// Stands in for the system clipboard that pasting reads first: `Some` text as if the user
339    /// had copied it in another program, `None` for an empty clipboard (the start). A harness
340    /// never reads the real clipboard or asks a terminal, so without this pasting uses the text
341    /// the application copied last.
342    pub fn set_system_clipboard(&mut self, text: Option<&str>) -> &mut Self {
343        let system = super::clipboard::SystemClipboard::Fixed(text.map(str::to_owned));
344        self.engine.clipboard_reader.set_system(system);
345        self
346    }
347
348    /// Whether the application asked to quit.
349    #[must_use]
350    pub fn quit_requested(&self) -> bool {
351        self.engine.quit
352    }
353
354    /// Whether the widget named `name` has keyboard focus.
355    #[must_use]
356    pub fn is_focused(&self, name: &str) -> bool {
357        self.engine.interaction.focused.is_some_and(|id| self.engine.frame.names.get(&id).is_some_and(|n| n == name))
358    }
359}
360
361/// Wraps [`Harness::html`] fragments in an HTML document that lays screens out on a dark page.
362#[must_use]
363pub fn html_page(fragments: &[String]) -> String {
364    format!(
365        "<!doctype html><meta charset=\"utf-8\"><title>Quvyta review</title><style>\
366         body{{background:#050507;margin:24px;font-family:'JetBrainsMono Nerd Font Mono','JetBrains Mono',monospace}}\
367         figure{{margin:0 0 28px}}figcaption{{color:#8a8f99;font:12px sans-serif;margin-bottom:6px}}\
368         .screen{{display:inline-block;font-size:14px;line-height:19px;white-space:pre}}\
369         .row{{display:flex;height:19px}}.row span{{display:inline-block;overflow:hidden}}</style>{}",
370        fragments.concat()
371    )
372}
373
374fn rgb(color: Color) -> Option<Rgb> {
375    match color {
376        Color::Rgb(r, g, b) => Some(Rgb::new(r, g, b)),
377        _ => None,
378    }
379}
380
381#[cfg(test)]
382mod resize_tests {
383    use super::Harness;
384    use crate::runtime::{App, Command};
385    use crate::widget::View;
386    use crate::widgets::Text;
387
388    struct Greeting;
389
390    impl App for Greeting {
391        type Msg = ();
392        fn update(&mut self, (): ()) -> Command<()> {
393            Command::none()
394        }
395        fn view(&self, ui: &mut View<'_, ()>) {
396            ui.add(Text::new("container engines"));
397        }
398    }
399
400    #[test]
401    fn resize_redraws_the_whole_screen_at_the_new_size() {
402        let mut harness = Harness::new(Greeting, 30, 2);
403        assert_eq!(harness.screen(), "container engines\n\n");
404        harness.resize(9, 1);
405        assert_eq!(harness.screen(), "container\n");
406        harness.resize(0, 0);
407        assert_eq!(harness.screen(), "");
408        harness.resize(40, 3);
409        assert_eq!((harness.buffer().area.width, harness.buffer().area.height), (40, 3));
410        assert_eq!(harness.screen(), "container engines\n\n\n");
411    }
412}