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::open::{OpenOutcome, OpenRequest};
14use super::termination::Termination;
15use crate::color::{ColorDepth, Rgb};
16use crate::env::Env;
17use crate::event::{Event, KeyEvent, MouseButton, MouseEvent, MouseKind};
18use crate::icons::GlyphMode;
19use crate::keymap::Modifiers;
20use crate::widget::PointerShape;
21
22const KEY_INTERVAL: Duration = Duration::from_millis(150);
25
26pub struct Harness<A: App> {
33 engine: Engine<A>,
34 buffer: Buffer,
35 now: Duration,
36}
37
38impl<A: App> Harness<A> {
39 pub fn new(app: A, width: u16, height: u16) -> Self {
45 Self::with_env(app, Env::builtin(), width, height)
46 }
47
48 pub fn with_env(app: A, env: Env, width: u16, height: u16) -> Self {
50 let mut harness = Self {
51 engine: Engine::new(app, env, TaskMode::Inline),
52 buffer: Buffer::empty(BufferRect::new(0, 0, width, height)),
53 now: Duration::ZERO,
54 };
55 harness.render();
56 harness
57 }
58
59 pub fn render(&mut self) -> &mut Self {
61 self.settle_tasks();
62 self.engine.render(&mut self.buffer, self.now);
63 self.write_out();
64 for _ in 0..3 {
66 let due = self.engine.deadline().is_some_and(|deadline| deadline <= self.now);
67 if !self.engine.dirty && !due {
68 break;
69 }
70 self.engine.render(&mut self.buffer, self.now);
71 self.write_out();
72 }
73 self
74 }
75
76 #[cfg(all(test, feature = "image"))]
79 pub(crate) fn present_to<W: std::io::Write>(&mut self, screen: &mut super::present::Screen<W>) -> bool {
80 let now = self.now;
81 let engine = &mut self.engine;
82 screen
83 .present(|buffer| {
84 engine.render(buffer, now);
85 engine.painted()
86 })
87 .expect("a frame")
88 }
89
90 fn write_out(&self) {
94 let blank = Buffer::empty(self.buffer.area);
95 let _ = blank.diff(&self.buffer);
96 }
97
98 fn settle_tasks(&mut self) {
103 self.engine.run_queued_work();
104 loop {
105 self.engine.task_clock.settle(self.now);
106 if self.engine.poll_tasks() == 0 {
107 break;
108 }
109 }
110 }
111
112 pub fn send(&mut self, message: A::Msg) -> &mut Self {
114 self.engine.update(message);
115 self.render()
116 }
117
118 pub fn press(&mut self, chord: &str) -> &mut Self {
120 self.now += KEY_INTERVAL;
121 self.engine.handle(Event::Key(KeyEvent::press(chord)), self.now);
122 self.render()
123 }
124
125 pub fn type_text(&mut self, text: &str) -> &mut Self {
127 for c in text.chars() {
128 let chord = match c {
129 ' ' => "space".to_owned(),
130 '+' => "+".to_owned(),
131 c if c.is_uppercase() => format!("shift+{}", c.to_lowercase()),
132 c => c.to_string(),
133 };
134 self.press(&chord);
135 }
136 self
137 }
138
139 pub fn events(&mut self, events: &[Event]) -> &mut Self {
146 for event in events {
147 self.engine.handle(event.clone(), self.now);
148 }
149 self.render()
150 }
151
152 #[cfg(test)]
154 pub(crate) fn inject(&mut self, event: Event, at: Duration) -> &mut Self {
155 self.engine.handle(event, at);
156 self.render()
157 }
158
159 pub fn paste(&mut self, text: &str) -> &mut Self {
161 self.engine.handle(Event::Paste(text.to_owned()), self.now);
162 self.render()
163 }
164
165 pub fn click(&mut self, x: i32, y: i32) -> &mut Self {
167 self.mouse(MouseKind::Down(MouseButton::Left), x, y);
168 self.mouse(MouseKind::Up(MouseButton::Left), x, y)
169 }
170
171 pub fn click_text(&mut self, text: &str) -> &mut Self {
177 let (x, y) = self.find(text).unwrap_or_else(|| panic!("`{text}` is not on screen:\n{}", self.screen()));
178 self.click(x, y)
179 }
180
181 pub fn drag(&mut self, from: (i32, i32), to: (i32, i32)) -> &mut Self {
183 self.mouse(MouseKind::Down(MouseButton::Left), from.0, from.1);
184 self.mouse(MouseKind::Drag(MouseButton::Left), to.0, to.1);
185 self.mouse(MouseKind::Up(MouseButton::Left), to.0, to.1)
186 }
187
188 pub fn hover(&mut self, x: i32, y: i32) -> &mut Self {
190 self.mouse(MouseKind::Moved, x, y)
191 }
192
193 pub fn mouse(&mut self, kind: MouseKind, x: i32, y: i32) -> &mut Self {
195 self.engine.handle(Event::Mouse(MouseEvent { kind, x, y, mods: Modifiers::default() }), self.now);
196 self.render()
197 }
198
199 pub fn advance(&mut self, duration: Duration) -> &mut Self {
208 self.now += duration;
209 self.engine.tick(self.now);
210 self.engine.end_when_due(self.now);
211 self.render()
212 }
213
214 pub fn terminate(&mut self, cause: Termination) -> &mut Self {
251 self.engine.terminate(cause, self.now);
252 self.render()
253 }
254
255 pub fn key(&mut self, event: KeyEvent) -> &mut Self {
260 self.engine.handle(Event::Key(event), self.now);
261 self.render()
262 }
263
264 pub fn set_theme(&mut self, id: &str) -> &mut Self {
266 self.engine.env.set_theme(id);
267 self.render()
268 }
269
270 pub fn set_locale(&mut self, code: &str) -> &mut Self {
272 self.engine.env.set_locale(code);
273 self.render()
274 }
275
276 pub fn set_region(&mut self, region: Option<&str>) -> &mut Self {
278 self.engine.env.set_region(region);
279 self.render()
280 }
281
282 pub fn set_reduced_motion(&mut self, reduced: bool) -> &mut Self {
284 self.engine.env.set_reduced_motion(reduced);
285 self.render()
286 }
287
288 pub fn set_depth(&mut self, depth: ColorDepth) -> &mut Self {
292 self.engine.env.set_depth(depth);
293 self.render()
294 }
295
296 pub fn set_graphics(&mut self, graphics: crate::graphics::Graphics) -> &mut Self {
302 self.engine.env.set_terminal_graphics(graphics);
303 self.render()
304 }
305
306 pub fn set_remote(&mut self, remote: bool) -> &mut Self {
311 self.engine.env.set_remote(remote);
312 self.render()
313 }
314
315 pub fn set_glyph_mode(&mut self, mode: GlyphMode) -> &mut Self {
317 self.engine.env.set_glyph_mode(mode);
318 self.render()
319 }
320
321 pub fn resize(&mut self, width: u16, height: u16) -> &mut Self {
325 self.buffer = Buffer::empty(BufferRect::new(0, 0, width, height));
326 self.engine.dirty = true;
327 self.render()
328 }
329
330 #[must_use]
333 pub fn screen(&self) -> String {
334 let mut out = String::new();
335 for y in 0..self.buffer.area.height {
336 out.push_str(self.row(y).0.trim_end());
337 out.push('\n');
338 }
339 out
340 }
341
342 #[must_use]
345 pub fn html(&self, caption: &str) -> String {
346 let area = self.buffer.area;
347 let escape = |text: &str| text.replace('&', "&").replace('<', "<").replace('>', ">");
348 let css = |color: Color| rgb(color).map_or_else(|| "inherit".to_owned(), |c| c.to_string());
349 let mut out = format!("<figure><figcaption>{}</figcaption><div class=\"screen\">", escape(caption));
350 for y in 0..area.height {
351 out.push_str("<div class=\"row\">");
352 for x in visible_columns(&self.buffer, y) {
353 let cell = &self.buffer[(x, y)];
354 let modifier = cell.modifier;
355 let weight = if modifier.contains(Modifier::BOLD) { "font-weight:700;" } else { "" };
356 let style = if modifier.contains(Modifier::ITALIC) { "font-style:italic;" } else { "" };
357 let line = if modifier.contains(Modifier::UNDERLINED) { "text-decoration:underline;" } else { "" };
358 out.push_str(&format!(
359 "<span style=\"color:{};background:{};width:{}ch;{weight}{style}{line}\">{}</span>",
360 css(cell.fg),
361 css(cell.bg),
362 crate::text::width(cell.symbol()).max(1),
363 escape(cell.symbol())
364 ));
365 }
366 out.push_str("</div>");
367 }
368 out.push_str("</div></figure>");
369 out
370 }
371
372 #[must_use]
375 pub fn find(&self, text: &str) -> Option<(i32, i32)> {
376 (0..self.buffer.area.height).find_map(|y| {
377 let (line, columns) = self.row(y);
378 line.find(text).map(|byte| (i32::from(columns[byte]), i32::from(y)))
379 })
380 }
381
382 fn row(&self, y: u16) -> (String, Vec<u16>) {
384 let mut line = String::new();
385 let mut columns = Vec::new();
386 for x in visible_columns(&self.buffer, y) {
387 let symbol = self.buffer[(x, y)].symbol();
388 columns.extend(std::iter::repeat_n(x, symbol.len()));
389 line.push_str(symbol);
390 }
391 (line, columns)
392 }
393
394 #[must_use]
400 pub fn fg(&self, x: u16, y: u16) -> Option<Rgb> {
401 rgb(self.buffer[(x, y)].fg)
402 }
403
404 #[must_use]
410 pub fn bg(&self, x: u16, y: u16) -> Option<Rgb> {
411 rgb(self.buffer[(x, y)].bg)
412 }
413
414 #[must_use]
420 pub fn is_bold(&self, x: u16, y: u16) -> bool {
421 self.buffer[(x, y)].modifier.contains(Modifier::BOLD)
422 }
423
424 #[must_use]
426 pub fn buffer(&self) -> &Buffer {
427 &self.buffer
428 }
429
430 #[must_use]
432 pub fn app(&self) -> &A {
433 &self.engine.app
434 }
435
436 #[must_use]
438 pub fn env(&self) -> &Env {
439 &self.engine.env
440 }
441
442 #[must_use]
446 pub fn pointer_shape(&self) -> PointerShape {
447 self.engine.pointer_shape()
448 }
449
450 #[must_use]
452 pub fn copied(&self) -> &[String] {
453 &self.engine.clipboard
454 }
455
456 #[must_use]
458 pub fn clipboard(&self) -> Option<&str> {
459 self.engine.clipboard_text.as_deref()
460 }
461
462 pub fn set_system_clipboard(&mut self, text: Option<&str>) -> &mut Self {
467 let system = super::clipboard::SystemClipboard::Fixed(text.map(str::to_owned));
468 self.engine.clipboard_reader.set_system(system);
469 self
470 }
471
472 #[must_use]
477 pub fn handoffs(&self) -> &[HandoffRequest] {
478 self.engine.handoff_requests()
479 }
480
481 pub fn set_handoff_outcome(&mut self, outcome: HandoffOutcome) -> &mut Self {
484 self.engine.set_handoff_outcome(outcome);
485 self
486 }
487
488 #[must_use]
492 pub fn detached_handoffs(&self) -> &[HandoffRequest] {
493 self.engine.detached_requests()
494 }
495
496 pub fn set_detached_outcome(&mut self, outcome: DetachedOutcome) -> &mut Self {
507 self.engine.set_detached_outcome(outcome);
508 self
509 }
510
511 #[must_use]
519 pub fn opens(&self) -> &[OpenRequest] {
520 self.engine.open_requests()
521 }
522
523 pub fn set_open_outcome(&mut self, outcome: OpenOutcome) -> &mut Self {
525 self.engine.set_open_outcome(outcome);
526 self
527 }
528
529 #[cfg(feature = "updates")]
535 #[must_use]
536 pub fn update_checks(&self) -> &[super::UpdateCheckRequest] {
537 self.engine.update_checks()
538 }
539
540 #[cfg(feature = "updates")]
545 pub fn set_latest_version(&mut self, latest: Option<&str>) -> &mut Self {
546 self.engine.set_latest_version(latest.map(str::to_owned));
547 self.render()
548 }
549
550 #[must_use]
552 pub fn quit_requested(&self) -> bool {
553 self.engine.quit
554 }
555
556 #[must_use]
558 pub fn is_focused(&self, name: &str) -> bool {
559 self.engine.interaction.focused.is_some_and(|id| self.engine.frame.names.get(&id).is_some_and(|n| n == name))
560 }
561}
562
563#[must_use]
565pub fn html_page(fragments: &[String]) -> String {
566 format!(
567 "<!doctype html><meta charset=\"utf-8\"><title>Quvyta review</title><style>\
568 body{{background:#050507;margin:24px;font-family:'JetBrainsMono Nerd Font Mono','JetBrains Mono',monospace}}\
569 figure{{margin:0 0 28px}}figcaption{{color:#8a8f99;font:12px sans-serif;margin-bottom:6px}}\
570 .screen{{display:inline-block;font-size:14px;line-height:19px;white-space:pre}}\
571 .row{{display:flex;height:19px}}.row span{{display:inline-block;overflow:hidden}}</style>{}",
572 fragments.concat()
573 )
574}
575
576fn visible_columns(buffer: &Buffer, y: u16) -> impl Iterator<Item = u16> + '_ {
580 let mut covered = 0u16;
581 (0..buffer.area.width).filter(move |&x| {
582 if covered > 0 {
583 covered -= 1;
584 return false;
585 }
586 let symbol = buffer[(x, y)].symbol();
587 covered = crate::text::width(symbol).saturating_sub(1);
588 !symbol.is_empty()
589 })
590}
591
592fn rgb(color: Color) -> Option<Rgb> {
593 match color {
594 Color::Rgb(r, g, b) => Some(Rgb::new(r, g, b)),
595 _ => None,
596 }
597}
598
599#[cfg(test)]
600mod resize_tests {
601 use super::Harness;
602 use crate::runtime::{App, Command};
603 use crate::widget::View;
604 use crate::widgets::Text;
605
606 struct Greeting;
607
608 impl App for Greeting {
609 type Msg = ();
610 fn update(&mut self, (): ()) -> Command<()> {
611 Command::none()
612 }
613 fn view(&self, ui: &mut View<'_, ()>) {
614 ui.add(Text::new("container engines"));
615 }
616 }
617
618 #[test]
619 fn resize_redraws_the_whole_screen_at_the_new_size() {
620 let mut harness = Harness::new(Greeting, 30, 2);
621 assert_eq!(harness.screen(), "container engines\n\n");
622 harness.resize(9, 1);
623 assert_eq!(harness.screen(), "container\n");
624 harness.resize(0, 0);
625 assert_eq!(harness.screen(), "");
626 harness.resize(40, 3);
627 assert_eq!((harness.buffer().area.width, harness.buffer().area.height), (40, 3));
628 assert_eq!(harness.screen(), "container engines\n\n\n");
629 }
630
631 struct Raw;
632
633 impl App for Raw {
634 type Msg = ();
635 fn update(&mut self, (): ()) -> Command<()> {
636 Command::none()
637 }
638 fn view(&self, ui: &mut View<'_, ()>) {
639 ui.add(Text::new("bell\u{7} tab\t\u{1b}[1mé\r"));
640 }
641 }
642
643 #[test]
644 fn a_control_character_handed_to_any_widget_never_reaches_a_cell() {
645 let harness = Harness::new(Raw, 30, 1);
646 assert_eq!(harness.screen(), "bell tab [1mé\n", "each control character is a blank cell");
647 }
648}
649
650#[cfg(test)]
651mod handoff_tests {
652 use std::ffi::OsString;
653
654 use super::Harness;
655 use crate::runtime::{App, Command, Handoff, HandoffOutcome};
656 use crate::widget::View;
657 use crate::widgets::{Button, Text};
658
659 #[derive(Default)]
661 struct Installer {
662 outcomes: Vec<HandoffOutcome>,
663 }
664
665 #[derive(Clone)]
666 enum Msg {
667 Authorize,
668 Done(HandoffOutcome),
669 }
670
671 impl App for Installer {
672 type Msg = Msg;
673 fn update(&mut self, msg: Msg) -> Command<Msg> {
674 match msg {
675 Msg::Authorize => Command::handoff(
676 Handoff::new("sudo", Msg::Done).arg("-v").notice("Authorizing the installation").pause(false),
677 ),
678 Msg::Done(outcome) => {
679 self.outcomes.push(outcome);
680 Command::none()
681 }
682 }
683 }
684 fn view(&self, ui: &mut View<'_, Msg>) {
685 ui.add(Button::new("Authorize").on_press(Msg::Authorize)).id("authorize");
686 let text = match self.outcomes.last() {
687 None => "not asked yet".to_owned(),
688 Some(HandoffOutcome::Finished { code }) => format!("finished {code:?}"),
689 Some(HandoffOutcome::Failed(reason)) => format!("failed {reason}"),
690 };
691 ui.add(Text::new(text));
692 }
693 }
694
695 #[test]
696 fn a_handoff_is_recorded_and_answered_with_the_outcome_the_test_set() {
697 let mut harness = Harness::new(Installer::default(), 40, 3);
698 assert!(harness.handoffs().is_empty(), "nothing was asked for yet");
699 harness.send(Msg::Authorize);
700 let asked = harness.handoffs();
701 assert_eq!(asked.len(), 1);
702 assert_eq!(asked[0].program, OsString::from("sudo"));
703 assert_eq!(asked[0].args, vec![OsString::from("-v")]);
704 assert_eq!(asked[0].notice.as_deref(), Some("Authorizing the installation"));
705 assert!(!asked[0].pause);
706 assert_eq!(harness.app().outcomes, [HandoffOutcome::Finished { code: Some(0) }]);
708 assert!(harness.screen().contains("finished Some(0)"), "{}", harness.screen());
709 }
710
711 #[test]
712 fn the_outcome_a_test_sets_reaches_the_application() {
713 let mut harness = Harness::new(Installer::default(), 40, 3);
714 harness.set_handoff_outcome(HandoffOutcome::Finished { code: Some(1) });
715 harness.send(Msg::Authorize);
716 assert_eq!(harness.app().outcomes, [HandoffOutcome::Finished { code: Some(1) }]);
717 harness.set_handoff_outcome(HandoffOutcome::Failed("sudo is not installed".to_owned()));
718 harness.send(Msg::Authorize);
719 assert_eq!(harness.app().outcomes.len(), 2);
720 assert!(harness.screen().contains("failed sudo is not installed"), "{}", harness.screen());
721 assert_eq!(harness.handoffs().len(), 2, "both requests are kept, oldest first");
722 }
723
724 #[test]
725 fn several_handoffs_are_answered_one_after_another() {
726 let mut harness = Harness::new(Installer::default(), 40, 3);
727 harness.send(Msg::Authorize).send(Msg::Authorize).send(Msg::Authorize);
728 assert_eq!(harness.handoffs().len(), 3);
729 assert_eq!(harness.app().outcomes.len(), 3);
730 }
731}
732
733#[cfg(test)]
734mod wide_text_tests {
735 use super::Harness;
736 use crate::runtime::{App, Command};
737 use crate::widget::View;
738 use crate::widgets::{Button, Text};
739
740 #[derive(Default)]
742 struct Firewall {
743 presses: u32,
744 }
745
746 impl App for Firewall {
747 type Msg = ();
748 fn update(&mut self, (): ()) -> Command<()> {
749 self.presses += 1;
750 Command::none()
751 }
752 fn view(&self, ui: &mut View<'_, ()>) {
753 ui.add(Text::new("状态 防火墙 on"));
754 ui.add(Button::new("启用").on_press(()));
755 }
756 }
757
758 fn with_covered_cells_as_spaces(harness: &mut Harness<Firewall>) {
762 let area = harness.buffer.area;
763 for y in 0..area.height {
764 let mut covered = 0;
765 for x in 0..area.width {
766 let cell = &mut harness.buffer[(x, y)];
767 if covered > 0 {
768 covered -= 1;
769 cell.reset();
770 continue;
771 }
772 covered = crate::text::width(cell.symbol()).saturating_sub(1);
773 }
774 }
775 }
776
777 fn firewall() -> Harness<Firewall> {
778 let mut harness = Harness::new(Firewall::default(), 30, 3);
779 with_covered_cells_as_spaces(&mut harness);
780 harness
781 }
782
783 #[test]
784 fn the_screen_reads_wide_text_without_gaps() {
785 let harness = firewall();
786 let screen = harness.screen();
787 assert!(screen.starts_with("状态 防火墙 on\n"), "{screen}");
788 assert!(screen.contains("防火墙"), "{screen}");
789 }
790
791 #[test]
792 fn find_gives_the_column_a_wide_text_is_drawn_in() {
793 let harness = firewall();
794 assert_eq!(harness.find("防火墙"), Some((5, 0)));
795 assert_eq!(harness.find("on"), Some((12, 0)), "text after wide characters keeps its column");
796 let (x, y) = harness.find("启用").expect("the button label is on screen");
797 assert_eq!(harness.buffer()[(u16::try_from(x).unwrap(), u16::try_from(y).unwrap())].symbol(), "启");
798 }
799
800 #[test]
801 fn click_text_presses_a_wide_label() {
802 let mut harness = firewall();
803 harness.click_text("启用");
804 assert_eq!(harness.app().presses, 1);
805 }
806
807 #[test]
808 fn html_draws_a_wide_character_once() {
809 let harness = firewall();
810 let html = harness.html("wide");
811 let first_row = html.split("<div class=\"row\">").nth(1).expect("a first row");
812 assert_eq!(first_row.matches("<span").count(), 30 - 5, "five characters take two cells each: {first_row}");
813 assert!(first_row.contains(">防</span><span"), "{first_row}");
814 }
815}
816
817#[cfg(test)]
818mod graphics_tests {
819 use super::Harness;
820 use crate::color::ColorDepth;
821 use crate::graphics::Graphics;
822 use crate::icons::GlyphMode;
823 use crate::runtime::{App, Command};
824 use crate::widget::View;
825 use crate::widgets::Text;
826
827 struct Pictures;
829
830 impl App for Pictures {
831 type Msg = ();
832 fn update(&mut self, (): ()) -> Command<()> {
833 Command::none()
834 }
835 fn view(&self, ui: &mut View<'_, ()>) {
836 let graphics = ui.env().graphics();
837 ui.add(Text::new(graphics.name()));
838 }
839 }
840
841 #[test]
842 fn a_harness_answers_half_blocks_until_told_otherwise() {
843 let mut harness = Harness::new(Pictures, 20, 1);
844 assert_eq!(harness.screen(), "halfblock\n", "no terminal is asked in a test");
845 harness.set_graphics(Graphics::Kitty);
846 assert_eq!(harness.screen(), "kitty\n");
847 harness.set_graphics(Graphics::Sixel);
848 assert_eq!(harness.screen(), "sixel\n");
849 }
850
851 #[test]
852 fn the_rules_still_apply_to_what_a_harness_is_told() {
853 let mut harness = Harness::new(Pictures, 20, 1);
854 harness.set_graphics(Graphics::Kitty).set_depth(ColorDepth::Ansi16);
855 assert_eq!(harness.screen(), "none\n", "16 colours show no picture");
856 harness.set_depth(ColorDepth::TrueColor).set_glyph_mode(GlyphMode::Ascii);
857 assert_eq!(harness.screen(), "none\n", "ASCII glyphs show no picture");
858 harness.set_glyph_mode(GlyphMode::Unicode);
859 assert_eq!(harness.screen(), "kitty\n");
860 }
861
862 struct Link;
864
865 impl App for Link {
866 type Msg = ();
867 fn update(&mut self, (): ()) -> Command<()> {
868 Command::none()
869 }
870 fn view(&self, ui: &mut View<'_, ()>) {
871 ui.add(Text::new(if ui.env().remote() { "remote" } else { "local" }));
872 }
873 }
874
875 #[test]
876 fn a_harness_draws_a_remote_screen_when_told_so() {
877 let mut harness = Harness::new(Link, 20, 1);
878 assert_eq!(harness.screen(), "local\n", "a test draws the same wherever it runs");
879 harness.set_remote(true);
880 assert_eq!(harness.screen(), "remote\n");
881 harness.set_remote(false);
882 assert_eq!(harness.screen(), "local\n");
883 }
884}