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;
20
21const KEY_INTERVAL: Duration = Duration::from_millis(150);
24
25pub struct Harness<A: App> {
32 engine: Engine<A>,
33 buffer: Buffer,
34 now: Duration,
35}
36
37impl<A: App> Harness<A> {
38 pub fn new(app: A, width: u16, height: u16) -> Self {
44 Self::with_env(app, Env::builtin(), width, height)
45 }
46
47 pub fn with_env(app: A, env: Env, width: u16, height: u16) -> Self {
49 let mut harness = Self {
50 engine: Engine::new(app, env, TaskMode::Inline),
51 buffer: Buffer::empty(BufferRect::new(0, 0, width, height)),
52 now: Duration::ZERO,
53 };
54 harness.render();
55 harness
56 }
57
58 pub fn render(&mut self) -> &mut Self {
60 self.settle_tasks();
61 self.engine.render(&mut self.buffer, self.now);
62 self.write_out();
63 for _ in 0..3 {
65 let due = self.engine.deadline().is_some_and(|deadline| deadline <= self.now);
66 if !self.engine.dirty && !due {
67 break;
68 }
69 self.engine.render(&mut self.buffer, self.now);
70 self.write_out();
71 }
72 self
73 }
74
75 fn write_out(&self) {
79 let blank = Buffer::empty(self.buffer.area);
80 let _ = blank.diff(&self.buffer);
81 }
82
83 fn settle_tasks(&mut self) {
88 self.engine.run_queued_work();
89 loop {
90 self.engine.task_clock.settle(self.now);
91 if self.engine.poll_tasks() == 0 {
92 break;
93 }
94 }
95 }
96
97 pub fn send(&mut self, message: A::Msg) -> &mut Self {
99 self.engine.update(message);
100 self.render()
101 }
102
103 pub fn press(&mut self, chord: &str) -> &mut Self {
105 self.now += KEY_INTERVAL;
106 self.engine.handle(Event::Key(KeyEvent::press(chord)), self.now);
107 self.render()
108 }
109
110 pub fn type_text(&mut self, text: &str) -> &mut Self {
112 for c in text.chars() {
113 let chord = match c {
114 ' ' => "space".to_owned(),
115 '+' => "+".to_owned(),
116 c if c.is_uppercase() => format!("shift+{}", c.to_lowercase()),
117 c => c.to_string(),
118 };
119 self.press(&chord);
120 }
121 self
122 }
123
124 pub fn events(&mut self, events: &[Event]) -> &mut Self {
131 for event in events {
132 self.engine.handle(event.clone(), self.now);
133 }
134 self.render()
135 }
136
137 #[cfg(test)]
139 pub(crate) fn inject(&mut self, event: Event, at: Duration) -> &mut Self {
140 self.engine.handle(event, at);
141 self.render()
142 }
143
144 pub fn paste(&mut self, text: &str) -> &mut Self {
146 self.engine.handle(Event::Paste(text.to_owned()), self.now);
147 self.render()
148 }
149
150 pub fn click(&mut self, x: i32, y: i32) -> &mut Self {
152 self.mouse(MouseKind::Down(MouseButton::Left), x, y);
153 self.mouse(MouseKind::Up(MouseButton::Left), x, y)
154 }
155
156 pub fn click_text(&mut self, text: &str) -> &mut Self {
162 let (x, y) = self.find(text).unwrap_or_else(|| panic!("`{text}` is not on screen:\n{}", self.screen()));
163 self.click(x, y)
164 }
165
166 pub fn drag(&mut self, from: (i32, i32), to: (i32, i32)) -> &mut Self {
168 self.mouse(MouseKind::Down(MouseButton::Left), from.0, from.1);
169 self.mouse(MouseKind::Drag(MouseButton::Left), to.0, to.1);
170 self.mouse(MouseKind::Up(MouseButton::Left), to.0, to.1)
171 }
172
173 pub fn hover(&mut self, x: i32, y: i32) -> &mut Self {
175 self.mouse(MouseKind::Moved, x, y)
176 }
177
178 pub fn mouse(&mut self, kind: MouseKind, x: i32, y: i32) -> &mut Self {
180 self.engine.handle(Event::Mouse(MouseEvent { kind, x, y, mods: Modifiers::default() }), self.now);
181 self.render()
182 }
183
184 pub fn advance(&mut self, duration: Duration) -> &mut Self {
193 self.now += duration;
194 self.engine.tick(self.now);
195 self.engine.end_when_due(self.now);
196 self.render()
197 }
198
199 pub fn terminate(&mut self, cause: Termination) -> &mut Self {
236 self.engine.terminate(cause, self.now);
237 self.render()
238 }
239
240 pub fn key(&mut self, event: KeyEvent) -> &mut Self {
245 self.engine.handle(Event::Key(event), self.now);
246 self.render()
247 }
248
249 pub fn set_theme(&mut self, id: &str) -> &mut Self {
251 self.engine.env.set_theme(id);
252 self.render()
253 }
254
255 pub fn set_locale(&mut self, code: &str) -> &mut Self {
257 self.engine.env.set_locale(code);
258 self.render()
259 }
260
261 pub fn set_region(&mut self, region: Option<&str>) -> &mut Self {
263 self.engine.env.set_region(region);
264 self.render()
265 }
266
267 pub fn set_reduced_motion(&mut self, reduced: bool) -> &mut Self {
269 self.engine.env.set_reduced_motion(reduced);
270 self.render()
271 }
272
273 pub fn set_depth(&mut self, depth: ColorDepth) -> &mut Self {
277 self.engine.env.set_depth(depth);
278 self.render()
279 }
280
281 pub fn set_glyph_mode(&mut self, mode: GlyphMode) -> &mut Self {
283 self.engine.env.set_glyph_mode(mode);
284 self.render()
285 }
286
287 pub fn resize(&mut self, width: u16, height: u16) -> &mut Self {
291 self.buffer = Buffer::empty(BufferRect::new(0, 0, width, height));
292 self.engine.dirty = true;
293 self.render()
294 }
295
296 #[must_use]
299 pub fn screen(&self) -> String {
300 let mut out = String::new();
301 for y in 0..self.buffer.area.height {
302 out.push_str(self.row(y).0.trim_end());
303 out.push('\n');
304 }
305 out
306 }
307
308 #[must_use]
311 pub fn html(&self, caption: &str) -> String {
312 let area = self.buffer.area;
313 let escape = |text: &str| text.replace('&', "&").replace('<', "<").replace('>', ">");
314 let css = |color: Color| rgb(color).map_or_else(|| "inherit".to_owned(), |c| c.to_string());
315 let mut out = format!("<figure><figcaption>{}</figcaption><div class=\"screen\">", escape(caption));
316 for y in 0..area.height {
317 out.push_str("<div class=\"row\">");
318 for x in visible_columns(&self.buffer, y) {
319 let cell = &self.buffer[(x, y)];
320 let modifier = cell.modifier;
321 let weight = if modifier.contains(Modifier::BOLD) { "font-weight:700;" } else { "" };
322 let style = if modifier.contains(Modifier::ITALIC) { "font-style:italic;" } else { "" };
323 let line = if modifier.contains(Modifier::UNDERLINED) { "text-decoration:underline;" } else { "" };
324 out.push_str(&format!(
325 "<span style=\"color:{};background:{};width:{}ch;{weight}{style}{line}\">{}</span>",
326 css(cell.fg),
327 css(cell.bg),
328 crate::text::width(cell.symbol()).max(1),
329 escape(cell.symbol())
330 ));
331 }
332 out.push_str("</div>");
333 }
334 out.push_str("</div></figure>");
335 out
336 }
337
338 #[must_use]
341 pub fn find(&self, text: &str) -> Option<(i32, i32)> {
342 (0..self.buffer.area.height).find_map(|y| {
343 let (line, columns) = self.row(y);
344 line.find(text).map(|byte| (i32::from(columns[byte]), i32::from(y)))
345 })
346 }
347
348 fn row(&self, y: u16) -> (String, Vec<u16>) {
350 let mut line = String::new();
351 let mut columns = Vec::new();
352 for x in visible_columns(&self.buffer, y) {
353 let symbol = self.buffer[(x, y)].symbol();
354 columns.extend(std::iter::repeat_n(x, symbol.len()));
355 line.push_str(symbol);
356 }
357 (line, columns)
358 }
359
360 #[must_use]
366 pub fn fg(&self, x: u16, y: u16) -> Option<Rgb> {
367 rgb(self.buffer[(x, y)].fg)
368 }
369
370 #[must_use]
376 pub fn bg(&self, x: u16, y: u16) -> Option<Rgb> {
377 rgb(self.buffer[(x, y)].bg)
378 }
379
380 #[must_use]
386 pub fn is_bold(&self, x: u16, y: u16) -> bool {
387 self.buffer[(x, y)].modifier.contains(Modifier::BOLD)
388 }
389
390 #[must_use]
392 pub fn buffer(&self) -> &Buffer {
393 &self.buffer
394 }
395
396 #[must_use]
398 pub fn app(&self) -> &A {
399 &self.engine.app
400 }
401
402 #[must_use]
404 pub fn env(&self) -> &Env {
405 &self.engine.env
406 }
407
408 #[must_use]
410 pub fn copied(&self) -> &[String] {
411 &self.engine.clipboard
412 }
413
414 #[must_use]
416 pub fn clipboard(&self) -> Option<&str> {
417 self.engine.clipboard_text.as_deref()
418 }
419
420 pub fn set_system_clipboard(&mut self, text: Option<&str>) -> &mut Self {
425 let system = super::clipboard::SystemClipboard::Fixed(text.map(str::to_owned));
426 self.engine.clipboard_reader.set_system(system);
427 self
428 }
429
430 #[must_use]
435 pub fn handoffs(&self) -> &[HandoffRequest] {
436 self.engine.handoff_requests()
437 }
438
439 pub fn set_handoff_outcome(&mut self, outcome: HandoffOutcome) -> &mut Self {
442 self.engine.set_handoff_outcome(outcome);
443 self
444 }
445
446 #[must_use]
450 pub fn detached_handoffs(&self) -> &[HandoffRequest] {
451 self.engine.detached_requests()
452 }
453
454 pub fn set_detached_outcome(&mut self, outcome: DetachedOutcome) -> &mut Self {
465 self.engine.set_detached_outcome(outcome);
466 self
467 }
468
469 #[must_use]
477 pub fn opens(&self) -> &[OpenRequest] {
478 self.engine.open_requests()
479 }
480
481 pub fn set_open_outcome(&mut self, outcome: OpenOutcome) -> &mut Self {
483 self.engine.set_open_outcome(outcome);
484 self
485 }
486
487 #[cfg(feature = "updates")]
493 #[must_use]
494 pub fn update_checks(&self) -> &[super::UpdateCheckRequest] {
495 self.engine.update_checks()
496 }
497
498 #[cfg(feature = "updates")]
503 pub fn set_latest_version(&mut self, latest: Option<&str>) -> &mut Self {
504 self.engine.set_latest_version(latest.map(str::to_owned));
505 self.render()
506 }
507
508 #[must_use]
510 pub fn quit_requested(&self) -> bool {
511 self.engine.quit
512 }
513
514 #[must_use]
516 pub fn is_focused(&self, name: &str) -> bool {
517 self.engine.interaction.focused.is_some_and(|id| self.engine.frame.names.get(&id).is_some_and(|n| n == name))
518 }
519}
520
521#[must_use]
523pub fn html_page(fragments: &[String]) -> String {
524 format!(
525 "<!doctype html><meta charset=\"utf-8\"><title>Quvyta review</title><style>\
526 body{{background:#050507;margin:24px;font-family:'JetBrainsMono Nerd Font Mono','JetBrains Mono',monospace}}\
527 figure{{margin:0 0 28px}}figcaption{{color:#8a8f99;font:12px sans-serif;margin-bottom:6px}}\
528 .screen{{display:inline-block;font-size:14px;line-height:19px;white-space:pre}}\
529 .row{{display:flex;height:19px}}.row span{{display:inline-block;overflow:hidden}}</style>{}",
530 fragments.concat()
531 )
532}
533
534fn visible_columns(buffer: &Buffer, y: u16) -> impl Iterator<Item = u16> + '_ {
538 let mut covered = 0u16;
539 (0..buffer.area.width).filter(move |&x| {
540 if covered > 0 {
541 covered -= 1;
542 return false;
543 }
544 let symbol = buffer[(x, y)].symbol();
545 covered = crate::text::width(symbol).saturating_sub(1);
546 !symbol.is_empty()
547 })
548}
549
550fn rgb(color: Color) -> Option<Rgb> {
551 match color {
552 Color::Rgb(r, g, b) => Some(Rgb::new(r, g, b)),
553 _ => None,
554 }
555}
556
557#[cfg(test)]
558mod resize_tests {
559 use super::Harness;
560 use crate::runtime::{App, Command};
561 use crate::widget::View;
562 use crate::widgets::Text;
563
564 struct Greeting;
565
566 impl App for Greeting {
567 type Msg = ();
568 fn update(&mut self, (): ()) -> Command<()> {
569 Command::none()
570 }
571 fn view(&self, ui: &mut View<'_, ()>) {
572 ui.add(Text::new("container engines"));
573 }
574 }
575
576 #[test]
577 fn resize_redraws_the_whole_screen_at_the_new_size() {
578 let mut harness = Harness::new(Greeting, 30, 2);
579 assert_eq!(harness.screen(), "container engines\n\n");
580 harness.resize(9, 1);
581 assert_eq!(harness.screen(), "container\n");
582 harness.resize(0, 0);
583 assert_eq!(harness.screen(), "");
584 harness.resize(40, 3);
585 assert_eq!((harness.buffer().area.width, harness.buffer().area.height), (40, 3));
586 assert_eq!(harness.screen(), "container engines\n\n\n");
587 }
588
589 struct Raw;
590
591 impl App for Raw {
592 type Msg = ();
593 fn update(&mut self, (): ()) -> Command<()> {
594 Command::none()
595 }
596 fn view(&self, ui: &mut View<'_, ()>) {
597 ui.add(Text::new("bell\u{7} tab\t\u{1b}[1mé\r"));
598 }
599 }
600
601 #[test]
602 fn a_control_character_handed_to_any_widget_never_reaches_a_cell() {
603 let harness = Harness::new(Raw, 30, 1);
604 assert_eq!(harness.screen(), "bell tab [1mé\n", "each control character is a blank cell");
605 }
606}
607
608#[cfg(test)]
609mod handoff_tests {
610 use std::ffi::OsString;
611
612 use super::Harness;
613 use crate::runtime::{App, Command, Handoff, HandoffOutcome};
614 use crate::widget::View;
615 use crate::widgets::{Button, Text};
616
617 #[derive(Default)]
619 struct Installer {
620 outcomes: Vec<HandoffOutcome>,
621 }
622
623 #[derive(Clone)]
624 enum Msg {
625 Authorize,
626 Done(HandoffOutcome),
627 }
628
629 impl App for Installer {
630 type Msg = Msg;
631 fn update(&mut self, msg: Msg) -> Command<Msg> {
632 match msg {
633 Msg::Authorize => Command::handoff(
634 Handoff::new("sudo", Msg::Done).arg("-v").notice("Authorizing the installation").pause(false),
635 ),
636 Msg::Done(outcome) => {
637 self.outcomes.push(outcome);
638 Command::none()
639 }
640 }
641 }
642 fn view(&self, ui: &mut View<'_, Msg>) {
643 ui.add(Button::new("Authorize").on_press(Msg::Authorize)).id("authorize");
644 let text = match self.outcomes.last() {
645 None => "not asked yet".to_owned(),
646 Some(HandoffOutcome::Finished { code }) => format!("finished {code:?}"),
647 Some(HandoffOutcome::Failed(reason)) => format!("failed {reason}"),
648 };
649 ui.add(Text::new(text));
650 }
651 }
652
653 #[test]
654 fn a_handoff_is_recorded_and_answered_with_the_outcome_the_test_set() {
655 let mut harness = Harness::new(Installer::default(), 40, 3);
656 assert!(harness.handoffs().is_empty(), "nothing was asked for yet");
657 harness.send(Msg::Authorize);
658 let asked = harness.handoffs();
659 assert_eq!(asked.len(), 1);
660 assert_eq!(asked[0].program, OsString::from("sudo"));
661 assert_eq!(asked[0].args, vec![OsString::from("-v")]);
662 assert_eq!(asked[0].notice.as_deref(), Some("Authorizing the installation"));
663 assert!(!asked[0].pause);
664 assert_eq!(harness.app().outcomes, [HandoffOutcome::Finished { code: Some(0) }]);
666 assert!(harness.screen().contains("finished Some(0)"), "{}", harness.screen());
667 }
668
669 #[test]
670 fn the_outcome_a_test_sets_reaches_the_application() {
671 let mut harness = Harness::new(Installer::default(), 40, 3);
672 harness.set_handoff_outcome(HandoffOutcome::Finished { code: Some(1) });
673 harness.send(Msg::Authorize);
674 assert_eq!(harness.app().outcomes, [HandoffOutcome::Finished { code: Some(1) }]);
675 harness.set_handoff_outcome(HandoffOutcome::Failed("sudo is not installed".to_owned()));
676 harness.send(Msg::Authorize);
677 assert_eq!(harness.app().outcomes.len(), 2);
678 assert!(harness.screen().contains("failed sudo is not installed"), "{}", harness.screen());
679 assert_eq!(harness.handoffs().len(), 2, "both requests are kept, oldest first");
680 }
681
682 #[test]
683 fn several_handoffs_are_answered_one_after_another() {
684 let mut harness = Harness::new(Installer::default(), 40, 3);
685 harness.send(Msg::Authorize).send(Msg::Authorize).send(Msg::Authorize);
686 assert_eq!(harness.handoffs().len(), 3);
687 assert_eq!(harness.app().outcomes.len(), 3);
688 }
689}
690
691#[cfg(test)]
692mod wide_text_tests {
693 use super::Harness;
694 use crate::runtime::{App, Command};
695 use crate::widget::View;
696 use crate::widgets::{Button, Text};
697
698 #[derive(Default)]
700 struct Firewall {
701 presses: u32,
702 }
703
704 impl App for Firewall {
705 type Msg = ();
706 fn update(&mut self, (): ()) -> Command<()> {
707 self.presses += 1;
708 Command::none()
709 }
710 fn view(&self, ui: &mut View<'_, ()>) {
711 ui.add(Text::new("状态 防火墙 on"));
712 ui.add(Button::new("启用").on_press(()));
713 }
714 }
715
716 fn with_covered_cells_as_spaces(harness: &mut Harness<Firewall>) {
720 let area = harness.buffer.area;
721 for y in 0..area.height {
722 let mut covered = 0;
723 for x in 0..area.width {
724 let cell = &mut harness.buffer[(x, y)];
725 if covered > 0 {
726 covered -= 1;
727 cell.reset();
728 continue;
729 }
730 covered = crate::text::width(cell.symbol()).saturating_sub(1);
731 }
732 }
733 }
734
735 fn firewall() -> Harness<Firewall> {
736 let mut harness = Harness::new(Firewall::default(), 30, 3);
737 with_covered_cells_as_spaces(&mut harness);
738 harness
739 }
740
741 #[test]
742 fn the_screen_reads_wide_text_without_gaps() {
743 let harness = firewall();
744 let screen = harness.screen();
745 assert!(screen.starts_with("状态 防火墙 on\n"), "{screen}");
746 assert!(screen.contains("防火墙"), "{screen}");
747 }
748
749 #[test]
750 fn find_gives_the_column_a_wide_text_is_drawn_in() {
751 let harness = firewall();
752 assert_eq!(harness.find("防火墙"), Some((5, 0)));
753 assert_eq!(harness.find("on"), Some((12, 0)), "text after wide characters keeps its column");
754 let (x, y) = harness.find("启用").expect("the button label is on screen");
755 assert_eq!(harness.buffer()[(u16::try_from(x).unwrap(), u16::try_from(y).unwrap())].symbol(), "启");
756 }
757
758 #[test]
759 fn click_text_presses_a_wide_label() {
760 let mut harness = firewall();
761 harness.click_text("启用");
762 assert_eq!(harness.app().presses, 1);
763 }
764
765 #[test]
766 fn html_draws_a_wide_character_once() {
767 let harness = firewall();
768 let html = harness.html("wide");
769 let first_row = html.split("<div class=\"row\">").nth(1).expect("a first row");
770 assert_eq!(first_row.matches("<span").count(), 30 - 5, "five characters take two cells each: {first_row}");
771 assert!(first_row.contains(">防</span><span"), "{first_row}");
772 }
773}