1use 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
17const KEY_INTERVAL: Duration = Duration::from_millis(150);
20
21pub struct Harness<A: App> {
28 engine: Engine<A>,
29 buffer: Buffer,
30 now: Duration,
31}
32
33impl<A: App> Harness<A> {
34 pub fn new(app: A, width: u16, height: u16) -> Self {
36 Self::with_env(app, Env::builtin(), width, height)
37 }
38
39 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 pub fn render(&mut self) -> &mut Self {
52 self.settle_tasks();
53 self.engine.render(&mut self.buffer, self.now);
54 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 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 pub fn send(&mut self, message: A::Msg) -> &mut Self {
81 self.engine.update(message);
82 self.render()
83 }
84
85 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 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 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 #[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 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 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 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 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 pub fn hover(&mut self, x: i32, y: i32) -> &mut Self {
155 self.mouse(MouseKind::Moved, x, y)
156 }
157
158 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 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 pub fn key(&mut self, event: KeyEvent) -> &mut Self {
176 self.engine.handle(Event::Key(event), self.now);
177 self.render()
178 }
179
180 pub fn set_theme(&mut self, id: &str) -> &mut Self {
182 self.engine.env.set_theme(id);
183 self.render()
184 }
185
186 pub fn set_locale(&mut self, code: &str) -> &mut Self {
188 self.engine.env.set_locale(code);
189 self.render()
190 }
191
192 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 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 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 #[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 #[must_use]
227 pub fn html(&self, caption: &str) -> String {
228 let area = self.buffer.area;
229 let escape = |text: &str| text.replace('&', "&").replace('<', "<").replace('>', ">");
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 #[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 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 #[must_use]
284 pub fn fg(&self, x: u16, y: u16) -> Option<Rgb> {
285 rgb(self.buffer[(x, y)].fg)
286 }
287
288 #[must_use]
294 pub fn bg(&self, x: u16, y: u16) -> Option<Rgb> {
295 rgb(self.buffer[(x, y)].bg)
296 }
297
298 #[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 #[must_use]
310 pub fn buffer(&self) -> &Buffer {
311 &self.buffer
312 }
313
314 #[must_use]
316 pub fn app(&self) -> &A {
317 &self.engine.app
318 }
319
320 #[must_use]
322 pub fn env(&self) -> &Env {
323 &self.engine.env
324 }
325
326 #[must_use]
328 pub fn copied(&self) -> &[String] {
329 &self.engine.clipboard
330 }
331
332 #[must_use]
334 pub fn clipboard(&self) -> Option<&str> {
335 self.engine.clipboard_text.as_deref()
336 }
337
338 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 #[must_use]
350 pub fn quit_requested(&self) -> bool {
351 self.engine.quit
352 }
353
354 #[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#[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}