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::detached::DetachedOutcome;
11use super::engine::{Engine, TaskMode};
12use super::handoff::{HandoffOutcome, HandoffRequest};
13use super::termination::Termination;
14use crate::color::{ColorDepth, Rgb};
15use crate::env::Env;
16use crate::event::{Event, KeyEvent, MouseButton, MouseEvent, MouseKind};
17use crate::icons::GlyphMode;
18use crate::keymap::Modifiers;
19
20const KEY_INTERVAL: Duration = Duration::from_millis(150);
23
24pub struct Harness<A: App> {
31 engine: Engine<A>,
32 buffer: Buffer,
33 now: Duration,
34}
35
36impl<A: App> Harness<A> {
37 pub fn new(app: A, width: u16, height: u16) -> Self {
43 Self::with_env(app, Env::builtin(), width, height)
44 }
45
46 pub fn with_env(app: A, env: Env, width: u16, height: u16) -> Self {
48 let mut harness = Self {
49 engine: Engine::new(app, env, TaskMode::Inline),
50 buffer: Buffer::empty(BufferRect::new(0, 0, width, height)),
51 now: Duration::ZERO,
52 };
53 harness.render();
54 harness
55 }
56
57 pub fn render(&mut self) -> &mut Self {
59 self.settle_tasks();
60 self.engine.render(&mut self.buffer, self.now);
61 for _ in 0..3 {
63 let due = self.engine.deadline().is_some_and(|deadline| deadline <= self.now);
64 if !self.engine.dirty && !due {
65 break;
66 }
67 self.engine.render(&mut self.buffer, self.now);
68 }
69 self
70 }
71
72 fn settle_tasks(&mut self) {
77 self.engine.run_queued_work();
78 loop {
79 self.engine.task_clock.settle(self.now);
80 if self.engine.poll_tasks() == 0 {
81 break;
82 }
83 }
84 }
85
86 pub fn send(&mut self, message: A::Msg) -> &mut Self {
88 self.engine.update(message);
89 self.render()
90 }
91
92 pub fn press(&mut self, chord: &str) -> &mut Self {
94 self.now += KEY_INTERVAL;
95 self.engine.handle(Event::Key(KeyEvent::press(chord)), self.now);
96 self.render()
97 }
98
99 pub fn type_text(&mut self, text: &str) -> &mut Self {
101 for c in text.chars() {
102 let chord = match c {
103 ' ' => "space".to_owned(),
104 '+' => "+".to_owned(),
105 c if c.is_uppercase() => format!("shift+{}", c.to_lowercase()),
106 c => c.to_string(),
107 };
108 self.press(&chord);
109 }
110 self
111 }
112
113 pub fn events(&mut self, events: &[Event]) -> &mut Self {
118 for event in events {
119 self.engine.handle(event.clone(), self.now);
120 }
121 self.render()
122 }
123
124 #[cfg(test)]
126 pub(crate) fn inject(&mut self, event: Event, at: Duration) -> &mut Self {
127 self.engine.handle(event, at);
128 self.render()
129 }
130
131 pub fn paste(&mut self, text: &str) -> &mut Self {
133 self.engine.handle(Event::Paste(text.to_owned()), self.now);
134 self.render()
135 }
136
137 pub fn click(&mut self, x: i32, y: i32) -> &mut Self {
139 self.mouse(MouseKind::Down(MouseButton::Left), x, y);
140 self.mouse(MouseKind::Up(MouseButton::Left), x, y)
141 }
142
143 pub fn click_text(&mut self, text: &str) -> &mut Self {
149 let (x, y) = self.find(text).unwrap_or_else(|| panic!("`{text}` is not on screen:\n{}", self.screen()));
150 self.click(x, y)
151 }
152
153 pub fn drag(&mut self, from: (i32, i32), to: (i32, i32)) -> &mut Self {
155 self.mouse(MouseKind::Down(MouseButton::Left), from.0, from.1);
156 self.mouse(MouseKind::Drag(MouseButton::Left), to.0, to.1);
157 self.mouse(MouseKind::Up(MouseButton::Left), to.0, to.1)
158 }
159
160 pub fn hover(&mut self, x: i32, y: i32) -> &mut Self {
162 self.mouse(MouseKind::Moved, x, y)
163 }
164
165 pub fn mouse(&mut self, kind: MouseKind, x: i32, y: i32) -> &mut Self {
167 self.engine.handle(Event::Mouse(MouseEvent { kind, x, y, mods: Modifiers::default() }), self.now);
168 self.render()
169 }
170
171 pub fn advance(&mut self, duration: Duration) -> &mut Self {
180 self.now += duration;
181 self.engine.tick(self.now);
182 self.engine.end_when_due(self.now);
183 self.render()
184 }
185
186 pub fn terminate(&mut self, cause: Termination) -> &mut Self {
223 self.engine.terminate(cause, self.now);
224 self.render()
225 }
226
227 pub fn key(&mut self, event: KeyEvent) -> &mut Self {
232 self.engine.handle(Event::Key(event), self.now);
233 self.render()
234 }
235
236 pub fn set_theme(&mut self, id: &str) -> &mut Self {
238 self.engine.env.set_theme(id);
239 self.render()
240 }
241
242 pub fn set_locale(&mut self, code: &str) -> &mut Self {
244 self.engine.env.set_locale(code);
245 self.render()
246 }
247
248 pub fn set_region(&mut self, region: Option<&str>) -> &mut Self {
250 self.engine.env.set_region(region);
251 self.render()
252 }
253
254 pub fn set_reduced_motion(&mut self, reduced: bool) -> &mut Self {
256 self.engine.env.set_reduced_motion(reduced);
257 self.render()
258 }
259
260 pub fn set_depth(&mut self, depth: ColorDepth) -> &mut Self {
264 self.engine.env.set_depth(depth);
265 self.render()
266 }
267
268 pub fn set_glyph_mode(&mut self, mode: GlyphMode) -> &mut Self {
270 self.engine.env.set_glyph_mode(mode);
271 self.render()
272 }
273
274 pub fn resize(&mut self, width: u16, height: u16) -> &mut Self {
278 self.buffer = Buffer::empty(BufferRect::new(0, 0, width, height));
279 self.engine.dirty = true;
280 self.render()
281 }
282
283 #[must_use]
286 pub fn screen(&self) -> String {
287 let mut out = String::new();
288 for y in 0..self.buffer.area.height {
289 out.push_str(self.row(y).0.trim_end());
290 out.push('\n');
291 }
292 out
293 }
294
295 #[must_use]
298 pub fn html(&self, caption: &str) -> String {
299 let area = self.buffer.area;
300 let escape = |text: &str| text.replace('&', "&").replace('<', "<").replace('>', ">");
301 let css = |color: Color| rgb(color).map_or_else(|| "inherit".to_owned(), |c| c.to_string());
302 let mut out = format!("<figure><figcaption>{}</figcaption><div class=\"screen\">", escape(caption));
303 for y in 0..area.height {
304 out.push_str("<div class=\"row\">");
305 for x in visible_columns(&self.buffer, y) {
306 let cell = &self.buffer[(x, y)];
307 let modifier = cell.modifier;
308 let weight = if modifier.contains(Modifier::BOLD) { "font-weight:700;" } else { "" };
309 let style = if modifier.contains(Modifier::ITALIC) { "font-style:italic;" } else { "" };
310 let line = if modifier.contains(Modifier::UNDERLINED) { "text-decoration:underline;" } else { "" };
311 out.push_str(&format!(
312 "<span style=\"color:{};background:{};width:{}ch;{weight}{style}{line}\">{}</span>",
313 css(cell.fg),
314 css(cell.bg),
315 crate::text::width(cell.symbol()).max(1),
316 escape(cell.symbol())
317 ));
318 }
319 out.push_str("</div>");
320 }
321 out.push_str("</div></figure>");
322 out
323 }
324
325 #[must_use]
328 pub fn find(&self, text: &str) -> Option<(i32, i32)> {
329 (0..self.buffer.area.height).find_map(|y| {
330 let (line, columns) = self.row(y);
331 line.find(text).map(|byte| (i32::from(columns[byte]), i32::from(y)))
332 })
333 }
334
335 fn row(&self, y: u16) -> (String, Vec<u16>) {
337 let mut line = String::new();
338 let mut columns = Vec::new();
339 for x in visible_columns(&self.buffer, y) {
340 let symbol = self.buffer[(x, y)].symbol();
341 columns.extend(std::iter::repeat_n(x, symbol.len()));
342 line.push_str(symbol);
343 }
344 (line, columns)
345 }
346
347 #[must_use]
353 pub fn fg(&self, x: u16, y: u16) -> Option<Rgb> {
354 rgb(self.buffer[(x, y)].fg)
355 }
356
357 #[must_use]
363 pub fn bg(&self, x: u16, y: u16) -> Option<Rgb> {
364 rgb(self.buffer[(x, y)].bg)
365 }
366
367 #[must_use]
373 pub fn is_bold(&self, x: u16, y: u16) -> bool {
374 self.buffer[(x, y)].modifier.contains(Modifier::BOLD)
375 }
376
377 #[must_use]
379 pub fn buffer(&self) -> &Buffer {
380 &self.buffer
381 }
382
383 #[must_use]
385 pub fn app(&self) -> &A {
386 &self.engine.app
387 }
388
389 #[must_use]
391 pub fn env(&self) -> &Env {
392 &self.engine.env
393 }
394
395 #[must_use]
397 pub fn copied(&self) -> &[String] {
398 &self.engine.clipboard
399 }
400
401 #[must_use]
403 pub fn clipboard(&self) -> Option<&str> {
404 self.engine.clipboard_text.as_deref()
405 }
406
407 pub fn set_system_clipboard(&mut self, text: Option<&str>) -> &mut Self {
412 let system = super::clipboard::SystemClipboard::Fixed(text.map(str::to_owned));
413 self.engine.clipboard_reader.set_system(system);
414 self
415 }
416
417 #[must_use]
422 pub fn handoffs(&self) -> &[HandoffRequest] {
423 self.engine.handoff_requests()
424 }
425
426 pub fn set_handoff_outcome(&mut self, outcome: HandoffOutcome) -> &mut Self {
429 self.engine.set_handoff_outcome(outcome);
430 self
431 }
432
433 #[must_use]
437 pub fn detached_handoffs(&self) -> &[HandoffRequest] {
438 self.engine.detached_requests()
439 }
440
441 pub fn set_detached_outcome(&mut self, outcome: DetachedOutcome) -> &mut Self {
452 self.engine.set_detached_outcome(outcome);
453 self
454 }
455
456 #[must_use]
458 pub fn quit_requested(&self) -> bool {
459 self.engine.quit
460 }
461
462 #[must_use]
464 pub fn is_focused(&self, name: &str) -> bool {
465 self.engine.interaction.focused.is_some_and(|id| self.engine.frame.names.get(&id).is_some_and(|n| n == name))
466 }
467}
468
469#[must_use]
471pub fn html_page(fragments: &[String]) -> String {
472 format!(
473 "<!doctype html><meta charset=\"utf-8\"><title>Quvyta review</title><style>\
474 body{{background:#050507;margin:24px;font-family:'JetBrainsMono Nerd Font Mono','JetBrains Mono',monospace}}\
475 figure{{margin:0 0 28px}}figcaption{{color:#8a8f99;font:12px sans-serif;margin-bottom:6px}}\
476 .screen{{display:inline-block;font-size:14px;line-height:19px;white-space:pre}}\
477 .row{{display:flex;height:19px}}.row span{{display:inline-block;overflow:hidden}}</style>{}",
478 fragments.concat()
479 )
480}
481
482fn visible_columns(buffer: &Buffer, y: u16) -> impl Iterator<Item = u16> + '_ {
486 let mut covered = 0u16;
487 (0..buffer.area.width).filter(move |&x| {
488 if covered > 0 {
489 covered -= 1;
490 return false;
491 }
492 let symbol = buffer[(x, y)].symbol();
493 covered = crate::text::width(symbol).saturating_sub(1);
494 !symbol.is_empty()
495 })
496}
497
498fn rgb(color: Color) -> Option<Rgb> {
499 match color {
500 Color::Rgb(r, g, b) => Some(Rgb::new(r, g, b)),
501 _ => None,
502 }
503}
504
505#[cfg(test)]
506mod resize_tests {
507 use super::Harness;
508 use crate::runtime::{App, Command};
509 use crate::widget::View;
510 use crate::widgets::Text;
511
512 struct Greeting;
513
514 impl App for Greeting {
515 type Msg = ();
516 fn update(&mut self, (): ()) -> Command<()> {
517 Command::none()
518 }
519 fn view(&self, ui: &mut View<'_, ()>) {
520 ui.add(Text::new("container engines"));
521 }
522 }
523
524 #[test]
525 fn resize_redraws_the_whole_screen_at_the_new_size() {
526 let mut harness = Harness::new(Greeting, 30, 2);
527 assert_eq!(harness.screen(), "container engines\n\n");
528 harness.resize(9, 1);
529 assert_eq!(harness.screen(), "container\n");
530 harness.resize(0, 0);
531 assert_eq!(harness.screen(), "");
532 harness.resize(40, 3);
533 assert_eq!((harness.buffer().area.width, harness.buffer().area.height), (40, 3));
534 assert_eq!(harness.screen(), "container engines\n\n\n");
535 }
536}
537
538#[cfg(test)]
539mod handoff_tests {
540 use std::ffi::OsString;
541
542 use super::Harness;
543 use crate::runtime::{App, Command, Handoff, HandoffOutcome};
544 use crate::widget::View;
545 use crate::widgets::{Button, Text};
546
547 #[derive(Default)]
549 struct Installer {
550 outcomes: Vec<HandoffOutcome>,
551 }
552
553 #[derive(Clone)]
554 enum Msg {
555 Authorize,
556 Done(HandoffOutcome),
557 }
558
559 impl App for Installer {
560 type Msg = Msg;
561 fn update(&mut self, msg: Msg) -> Command<Msg> {
562 match msg {
563 Msg::Authorize => Command::handoff(
564 Handoff::new("sudo", Msg::Done).arg("-v").notice("Authorizing the installation").pause(false),
565 ),
566 Msg::Done(outcome) => {
567 self.outcomes.push(outcome);
568 Command::none()
569 }
570 }
571 }
572 fn view(&self, ui: &mut View<'_, Msg>) {
573 ui.add(Button::new("Authorize").on_press(Msg::Authorize)).id("authorize");
574 let text = match self.outcomes.last() {
575 None => "not asked yet".to_owned(),
576 Some(HandoffOutcome::Finished { code }) => format!("finished {code:?}"),
577 Some(HandoffOutcome::Failed(reason)) => format!("failed {reason}"),
578 };
579 ui.add(Text::new(text));
580 }
581 }
582
583 #[test]
584 fn a_handoff_is_recorded_and_answered_with_the_outcome_the_test_set() {
585 let mut harness = Harness::new(Installer::default(), 40, 3);
586 assert!(harness.handoffs().is_empty(), "nothing was asked for yet");
587 harness.send(Msg::Authorize);
588 let asked = harness.handoffs();
589 assert_eq!(asked.len(), 1);
590 assert_eq!(asked[0].program, OsString::from("sudo"));
591 assert_eq!(asked[0].args, vec![OsString::from("-v")]);
592 assert_eq!(asked[0].notice.as_deref(), Some("Authorizing the installation"));
593 assert!(!asked[0].pause);
594 assert_eq!(harness.app().outcomes, [HandoffOutcome::Finished { code: Some(0) }]);
596 assert!(harness.screen().contains("finished Some(0)"), "{}", harness.screen());
597 }
598
599 #[test]
600 fn the_outcome_a_test_sets_reaches_the_application() {
601 let mut harness = Harness::new(Installer::default(), 40, 3);
602 harness.set_handoff_outcome(HandoffOutcome::Finished { code: Some(1) });
603 harness.send(Msg::Authorize);
604 assert_eq!(harness.app().outcomes, [HandoffOutcome::Finished { code: Some(1) }]);
605 harness.set_handoff_outcome(HandoffOutcome::Failed("sudo is not installed".to_owned()));
606 harness.send(Msg::Authorize);
607 assert_eq!(harness.app().outcomes.len(), 2);
608 assert!(harness.screen().contains("failed sudo is not installed"), "{}", harness.screen());
609 assert_eq!(harness.handoffs().len(), 2, "both requests are kept, oldest first");
610 }
611
612 #[test]
613 fn several_handoffs_are_answered_one_after_another() {
614 let mut harness = Harness::new(Installer::default(), 40, 3);
615 harness.send(Msg::Authorize).send(Msg::Authorize).send(Msg::Authorize);
616 assert_eq!(harness.handoffs().len(), 3);
617 assert_eq!(harness.app().outcomes.len(), 3);
618 }
619}
620
621#[cfg(test)]
622mod wide_text_tests {
623 use super::Harness;
624 use crate::runtime::{App, Command};
625 use crate::widget::View;
626 use crate::widgets::{Button, Text};
627
628 #[derive(Default)]
630 struct Firewall {
631 presses: u32,
632 }
633
634 impl App for Firewall {
635 type Msg = ();
636 fn update(&mut self, (): ()) -> Command<()> {
637 self.presses += 1;
638 Command::none()
639 }
640 fn view(&self, ui: &mut View<'_, ()>) {
641 ui.add(Text::new("状态 防火墙 on"));
642 ui.add(Button::new("启用").on_press(()));
643 }
644 }
645
646 fn with_covered_cells_as_spaces(harness: &mut Harness<Firewall>) {
650 let area = harness.buffer.area;
651 for y in 0..area.height {
652 let mut covered = 0;
653 for x in 0..area.width {
654 let cell = &mut harness.buffer[(x, y)];
655 if covered > 0 {
656 covered -= 1;
657 cell.reset();
658 continue;
659 }
660 covered = crate::text::width(cell.symbol()).saturating_sub(1);
661 }
662 }
663 }
664
665 fn firewall() -> Harness<Firewall> {
666 let mut harness = Harness::new(Firewall::default(), 30, 3);
667 with_covered_cells_as_spaces(&mut harness);
668 harness
669 }
670
671 #[test]
672 fn the_screen_reads_wide_text_without_gaps() {
673 let harness = firewall();
674 let screen = harness.screen();
675 assert!(screen.starts_with("状态 防火墙 on\n"), "{screen}");
676 assert!(screen.contains("防火墙"), "{screen}");
677 }
678
679 #[test]
680 fn find_gives_the_column_a_wide_text_is_drawn_in() {
681 let harness = firewall();
682 assert_eq!(harness.find("防火墙"), Some((5, 0)));
683 assert_eq!(harness.find("on"), Some((12, 0)), "text after wide characters keeps its column");
684 let (x, y) = harness.find("启用").expect("the button label is on screen");
685 assert_eq!(harness.buffer()[(u16::try_from(x).unwrap(), u16::try_from(y).unwrap())].symbol(), "启");
686 }
687
688 #[test]
689 fn click_text_presses_a_wide_label() {
690 let mut harness = firewall();
691 harness.click_text("启用");
692 assert_eq!(harness.app().presses, 1);
693 }
694
695 #[test]
696 fn html_draws_a_wide_character_once() {
697 let harness = firewall();
698 let html = harness.html("wide");
699 let first_row = html.split("<div class=\"row\">").nth(1).expect("a first row");
700 assert_eq!(first_row.matches("<span").count(), 30 - 5, "five characters take two cells each: {first_row}");
701 assert!(first_row.contains(">防</span><span"), "{first_row}");
702 }
703}