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 for _ in 0..3 {
64 let due = self.engine.deadline().is_some_and(|deadline| deadline <= self.now);
65 if !self.engine.dirty && !due {
66 break;
67 }
68 self.engine.render(&mut self.buffer, self.now);
69 }
70 self
71 }
72
73 fn settle_tasks(&mut self) {
78 self.engine.run_queued_work();
79 loop {
80 self.engine.task_clock.settle(self.now);
81 if self.engine.poll_tasks() == 0 {
82 break;
83 }
84 }
85 }
86
87 pub fn send(&mut self, message: A::Msg) -> &mut Self {
89 self.engine.update(message);
90 self.render()
91 }
92
93 pub fn press(&mut self, chord: &str) -> &mut Self {
95 self.now += KEY_INTERVAL;
96 self.engine.handle(Event::Key(KeyEvent::press(chord)), self.now);
97 self.render()
98 }
99
100 pub fn type_text(&mut self, text: &str) -> &mut Self {
102 for c in text.chars() {
103 let chord = match c {
104 ' ' => "space".to_owned(),
105 '+' => "+".to_owned(),
106 c if c.is_uppercase() => format!("shift+{}", c.to_lowercase()),
107 c => c.to_string(),
108 };
109 self.press(&chord);
110 }
111 self
112 }
113
114 pub fn events(&mut self, events: &[Event]) -> &mut Self {
121 for event in events {
122 self.engine.handle(event.clone(), self.now);
123 }
124 self.render()
125 }
126
127 #[cfg(test)]
129 pub(crate) fn inject(&mut self, event: Event, at: Duration) -> &mut Self {
130 self.engine.handle(event, at);
131 self.render()
132 }
133
134 pub fn paste(&mut self, text: &str) -> &mut Self {
136 self.engine.handle(Event::Paste(text.to_owned()), self.now);
137 self.render()
138 }
139
140 pub fn click(&mut self, x: i32, y: i32) -> &mut Self {
142 self.mouse(MouseKind::Down(MouseButton::Left), x, y);
143 self.mouse(MouseKind::Up(MouseButton::Left), x, y)
144 }
145
146 pub fn click_text(&mut self, text: &str) -> &mut Self {
152 let (x, y) = self.find(text).unwrap_or_else(|| panic!("`{text}` is not on screen:\n{}", self.screen()));
153 self.click(x, y)
154 }
155
156 pub fn drag(&mut self, from: (i32, i32), to: (i32, i32)) -> &mut Self {
158 self.mouse(MouseKind::Down(MouseButton::Left), from.0, from.1);
159 self.mouse(MouseKind::Drag(MouseButton::Left), to.0, to.1);
160 self.mouse(MouseKind::Up(MouseButton::Left), to.0, to.1)
161 }
162
163 pub fn hover(&mut self, x: i32, y: i32) -> &mut Self {
165 self.mouse(MouseKind::Moved, x, y)
166 }
167
168 pub fn mouse(&mut self, kind: MouseKind, x: i32, y: i32) -> &mut Self {
170 self.engine.handle(Event::Mouse(MouseEvent { kind, x, y, mods: Modifiers::default() }), self.now);
171 self.render()
172 }
173
174 pub fn advance(&mut self, duration: Duration) -> &mut Self {
183 self.now += duration;
184 self.engine.tick(self.now);
185 self.engine.end_when_due(self.now);
186 self.render()
187 }
188
189 pub fn terminate(&mut self, cause: Termination) -> &mut Self {
226 self.engine.terminate(cause, self.now);
227 self.render()
228 }
229
230 pub fn key(&mut self, event: KeyEvent) -> &mut Self {
235 self.engine.handle(Event::Key(event), self.now);
236 self.render()
237 }
238
239 pub fn set_theme(&mut self, id: &str) -> &mut Self {
241 self.engine.env.set_theme(id);
242 self.render()
243 }
244
245 pub fn set_locale(&mut self, code: &str) -> &mut Self {
247 self.engine.env.set_locale(code);
248 self.render()
249 }
250
251 pub fn set_region(&mut self, region: Option<&str>) -> &mut Self {
253 self.engine.env.set_region(region);
254 self.render()
255 }
256
257 pub fn set_reduced_motion(&mut self, reduced: bool) -> &mut Self {
259 self.engine.env.set_reduced_motion(reduced);
260 self.render()
261 }
262
263 pub fn set_depth(&mut self, depth: ColorDepth) -> &mut Self {
267 self.engine.env.set_depth(depth);
268 self.render()
269 }
270
271 pub fn set_glyph_mode(&mut self, mode: GlyphMode) -> &mut Self {
273 self.engine.env.set_glyph_mode(mode);
274 self.render()
275 }
276
277 pub fn resize(&mut self, width: u16, height: u16) -> &mut Self {
281 self.buffer = Buffer::empty(BufferRect::new(0, 0, width, height));
282 self.engine.dirty = true;
283 self.render()
284 }
285
286 #[must_use]
289 pub fn screen(&self) -> String {
290 let mut out = String::new();
291 for y in 0..self.buffer.area.height {
292 out.push_str(self.row(y).0.trim_end());
293 out.push('\n');
294 }
295 out
296 }
297
298 #[must_use]
301 pub fn html(&self, caption: &str) -> String {
302 let area = self.buffer.area;
303 let escape = |text: &str| text.replace('&', "&").replace('<', "<").replace('>', ">");
304 let css = |color: Color| rgb(color).map_or_else(|| "inherit".to_owned(), |c| c.to_string());
305 let mut out = format!("<figure><figcaption>{}</figcaption><div class=\"screen\">", escape(caption));
306 for y in 0..area.height {
307 out.push_str("<div class=\"row\">");
308 for x in visible_columns(&self.buffer, y) {
309 let cell = &self.buffer[(x, y)];
310 let modifier = cell.modifier;
311 let weight = if modifier.contains(Modifier::BOLD) { "font-weight:700;" } else { "" };
312 let style = if modifier.contains(Modifier::ITALIC) { "font-style:italic;" } else { "" };
313 let line = if modifier.contains(Modifier::UNDERLINED) { "text-decoration:underline;" } else { "" };
314 out.push_str(&format!(
315 "<span style=\"color:{};background:{};width:{}ch;{weight}{style}{line}\">{}</span>",
316 css(cell.fg),
317 css(cell.bg),
318 crate::text::width(cell.symbol()).max(1),
319 escape(cell.symbol())
320 ));
321 }
322 out.push_str("</div>");
323 }
324 out.push_str("</div></figure>");
325 out
326 }
327
328 #[must_use]
331 pub fn find(&self, text: &str) -> Option<(i32, i32)> {
332 (0..self.buffer.area.height).find_map(|y| {
333 let (line, columns) = self.row(y);
334 line.find(text).map(|byte| (i32::from(columns[byte]), i32::from(y)))
335 })
336 }
337
338 fn row(&self, y: u16) -> (String, Vec<u16>) {
340 let mut line = String::new();
341 let mut columns = Vec::new();
342 for x in visible_columns(&self.buffer, y) {
343 let symbol = self.buffer[(x, y)].symbol();
344 columns.extend(std::iter::repeat_n(x, symbol.len()));
345 line.push_str(symbol);
346 }
347 (line, columns)
348 }
349
350 #[must_use]
356 pub fn fg(&self, x: u16, y: u16) -> Option<Rgb> {
357 rgb(self.buffer[(x, y)].fg)
358 }
359
360 #[must_use]
366 pub fn bg(&self, x: u16, y: u16) -> Option<Rgb> {
367 rgb(self.buffer[(x, y)].bg)
368 }
369
370 #[must_use]
376 pub fn is_bold(&self, x: u16, y: u16) -> bool {
377 self.buffer[(x, y)].modifier.contains(Modifier::BOLD)
378 }
379
380 #[must_use]
382 pub fn buffer(&self) -> &Buffer {
383 &self.buffer
384 }
385
386 #[must_use]
388 pub fn app(&self) -> &A {
389 &self.engine.app
390 }
391
392 #[must_use]
394 pub fn env(&self) -> &Env {
395 &self.engine.env
396 }
397
398 #[must_use]
400 pub fn copied(&self) -> &[String] {
401 &self.engine.clipboard
402 }
403
404 #[must_use]
406 pub fn clipboard(&self) -> Option<&str> {
407 self.engine.clipboard_text.as_deref()
408 }
409
410 pub fn set_system_clipboard(&mut self, text: Option<&str>) -> &mut Self {
415 let system = super::clipboard::SystemClipboard::Fixed(text.map(str::to_owned));
416 self.engine.clipboard_reader.set_system(system);
417 self
418 }
419
420 #[must_use]
425 pub fn handoffs(&self) -> &[HandoffRequest] {
426 self.engine.handoff_requests()
427 }
428
429 pub fn set_handoff_outcome(&mut self, outcome: HandoffOutcome) -> &mut Self {
432 self.engine.set_handoff_outcome(outcome);
433 self
434 }
435
436 #[must_use]
440 pub fn detached_handoffs(&self) -> &[HandoffRequest] {
441 self.engine.detached_requests()
442 }
443
444 pub fn set_detached_outcome(&mut self, outcome: DetachedOutcome) -> &mut Self {
455 self.engine.set_detached_outcome(outcome);
456 self
457 }
458
459 #[must_use]
467 pub fn opens(&self) -> &[OpenRequest] {
468 self.engine.open_requests()
469 }
470
471 pub fn set_open_outcome(&mut self, outcome: OpenOutcome) -> &mut Self {
473 self.engine.set_open_outcome(outcome);
474 self
475 }
476
477 #[cfg(feature = "updates")]
483 #[must_use]
484 pub fn update_checks(&self) -> &[super::UpdateCheckRequest] {
485 self.engine.update_checks()
486 }
487
488 #[cfg(feature = "updates")]
493 pub fn set_latest_version(&mut self, latest: Option<&str>) -> &mut Self {
494 self.engine.set_latest_version(latest.map(str::to_owned));
495 self.render()
496 }
497
498 #[must_use]
500 pub fn quit_requested(&self) -> bool {
501 self.engine.quit
502 }
503
504 #[must_use]
506 pub fn is_focused(&self, name: &str) -> bool {
507 self.engine.interaction.focused.is_some_and(|id| self.engine.frame.names.get(&id).is_some_and(|n| n == name))
508 }
509}
510
511#[must_use]
513pub fn html_page(fragments: &[String]) -> String {
514 format!(
515 "<!doctype html><meta charset=\"utf-8\"><title>Quvyta review</title><style>\
516 body{{background:#050507;margin:24px;font-family:'JetBrainsMono Nerd Font Mono','JetBrains Mono',monospace}}\
517 figure{{margin:0 0 28px}}figcaption{{color:#8a8f99;font:12px sans-serif;margin-bottom:6px}}\
518 .screen{{display:inline-block;font-size:14px;line-height:19px;white-space:pre}}\
519 .row{{display:flex;height:19px}}.row span{{display:inline-block;overflow:hidden}}</style>{}",
520 fragments.concat()
521 )
522}
523
524fn visible_columns(buffer: &Buffer, y: u16) -> impl Iterator<Item = u16> + '_ {
528 let mut covered = 0u16;
529 (0..buffer.area.width).filter(move |&x| {
530 if covered > 0 {
531 covered -= 1;
532 return false;
533 }
534 let symbol = buffer[(x, y)].symbol();
535 covered = crate::text::width(symbol).saturating_sub(1);
536 !symbol.is_empty()
537 })
538}
539
540fn rgb(color: Color) -> Option<Rgb> {
541 match color {
542 Color::Rgb(r, g, b) => Some(Rgb::new(r, g, b)),
543 _ => None,
544 }
545}
546
547#[cfg(test)]
548mod resize_tests {
549 use super::Harness;
550 use crate::runtime::{App, Command};
551 use crate::widget::View;
552 use crate::widgets::Text;
553
554 struct Greeting;
555
556 impl App for Greeting {
557 type Msg = ();
558 fn update(&mut self, (): ()) -> Command<()> {
559 Command::none()
560 }
561 fn view(&self, ui: &mut View<'_, ()>) {
562 ui.add(Text::new("container engines"));
563 }
564 }
565
566 #[test]
567 fn resize_redraws_the_whole_screen_at_the_new_size() {
568 let mut harness = Harness::new(Greeting, 30, 2);
569 assert_eq!(harness.screen(), "container engines\n\n");
570 harness.resize(9, 1);
571 assert_eq!(harness.screen(), "container\n");
572 harness.resize(0, 0);
573 assert_eq!(harness.screen(), "");
574 harness.resize(40, 3);
575 assert_eq!((harness.buffer().area.width, harness.buffer().area.height), (40, 3));
576 assert_eq!(harness.screen(), "container engines\n\n\n");
577 }
578}
579
580#[cfg(test)]
581mod handoff_tests {
582 use std::ffi::OsString;
583
584 use super::Harness;
585 use crate::runtime::{App, Command, Handoff, HandoffOutcome};
586 use crate::widget::View;
587 use crate::widgets::{Button, Text};
588
589 #[derive(Default)]
591 struct Installer {
592 outcomes: Vec<HandoffOutcome>,
593 }
594
595 #[derive(Clone)]
596 enum Msg {
597 Authorize,
598 Done(HandoffOutcome),
599 }
600
601 impl App for Installer {
602 type Msg = Msg;
603 fn update(&mut self, msg: Msg) -> Command<Msg> {
604 match msg {
605 Msg::Authorize => Command::handoff(
606 Handoff::new("sudo", Msg::Done).arg("-v").notice("Authorizing the installation").pause(false),
607 ),
608 Msg::Done(outcome) => {
609 self.outcomes.push(outcome);
610 Command::none()
611 }
612 }
613 }
614 fn view(&self, ui: &mut View<'_, Msg>) {
615 ui.add(Button::new("Authorize").on_press(Msg::Authorize)).id("authorize");
616 let text = match self.outcomes.last() {
617 None => "not asked yet".to_owned(),
618 Some(HandoffOutcome::Finished { code }) => format!("finished {code:?}"),
619 Some(HandoffOutcome::Failed(reason)) => format!("failed {reason}"),
620 };
621 ui.add(Text::new(text));
622 }
623 }
624
625 #[test]
626 fn a_handoff_is_recorded_and_answered_with_the_outcome_the_test_set() {
627 let mut harness = Harness::new(Installer::default(), 40, 3);
628 assert!(harness.handoffs().is_empty(), "nothing was asked for yet");
629 harness.send(Msg::Authorize);
630 let asked = harness.handoffs();
631 assert_eq!(asked.len(), 1);
632 assert_eq!(asked[0].program, OsString::from("sudo"));
633 assert_eq!(asked[0].args, vec![OsString::from("-v")]);
634 assert_eq!(asked[0].notice.as_deref(), Some("Authorizing the installation"));
635 assert!(!asked[0].pause);
636 assert_eq!(harness.app().outcomes, [HandoffOutcome::Finished { code: Some(0) }]);
638 assert!(harness.screen().contains("finished Some(0)"), "{}", harness.screen());
639 }
640
641 #[test]
642 fn the_outcome_a_test_sets_reaches_the_application() {
643 let mut harness = Harness::new(Installer::default(), 40, 3);
644 harness.set_handoff_outcome(HandoffOutcome::Finished { code: Some(1) });
645 harness.send(Msg::Authorize);
646 assert_eq!(harness.app().outcomes, [HandoffOutcome::Finished { code: Some(1) }]);
647 harness.set_handoff_outcome(HandoffOutcome::Failed("sudo is not installed".to_owned()));
648 harness.send(Msg::Authorize);
649 assert_eq!(harness.app().outcomes.len(), 2);
650 assert!(harness.screen().contains("failed sudo is not installed"), "{}", harness.screen());
651 assert_eq!(harness.handoffs().len(), 2, "both requests are kept, oldest first");
652 }
653
654 #[test]
655 fn several_handoffs_are_answered_one_after_another() {
656 let mut harness = Harness::new(Installer::default(), 40, 3);
657 harness.send(Msg::Authorize).send(Msg::Authorize).send(Msg::Authorize);
658 assert_eq!(harness.handoffs().len(), 3);
659 assert_eq!(harness.app().outcomes.len(), 3);
660 }
661}
662
663#[cfg(test)]
664mod wide_text_tests {
665 use super::Harness;
666 use crate::runtime::{App, Command};
667 use crate::widget::View;
668 use crate::widgets::{Button, Text};
669
670 #[derive(Default)]
672 struct Firewall {
673 presses: u32,
674 }
675
676 impl App for Firewall {
677 type Msg = ();
678 fn update(&mut self, (): ()) -> Command<()> {
679 self.presses += 1;
680 Command::none()
681 }
682 fn view(&self, ui: &mut View<'_, ()>) {
683 ui.add(Text::new("状态 防火墙 on"));
684 ui.add(Button::new("启用").on_press(()));
685 }
686 }
687
688 fn with_covered_cells_as_spaces(harness: &mut Harness<Firewall>) {
692 let area = harness.buffer.area;
693 for y in 0..area.height {
694 let mut covered = 0;
695 for x in 0..area.width {
696 let cell = &mut harness.buffer[(x, y)];
697 if covered > 0 {
698 covered -= 1;
699 cell.reset();
700 continue;
701 }
702 covered = crate::text::width(cell.symbol()).saturating_sub(1);
703 }
704 }
705 }
706
707 fn firewall() -> Harness<Firewall> {
708 let mut harness = Harness::new(Firewall::default(), 30, 3);
709 with_covered_cells_as_spaces(&mut harness);
710 harness
711 }
712
713 #[test]
714 fn the_screen_reads_wide_text_without_gaps() {
715 let harness = firewall();
716 let screen = harness.screen();
717 assert!(screen.starts_with("状态 防火墙 on\n"), "{screen}");
718 assert!(screen.contains("防火墙"), "{screen}");
719 }
720
721 #[test]
722 fn find_gives_the_column_a_wide_text_is_drawn_in() {
723 let harness = firewall();
724 assert_eq!(harness.find("防火墙"), Some((5, 0)));
725 assert_eq!(harness.find("on"), Some((12, 0)), "text after wide characters keeps its column");
726 let (x, y) = harness.find("启用").expect("the button label is on screen");
727 assert_eq!(harness.buffer()[(u16::try_from(x).unwrap(), u16::try_from(y).unwrap())].symbol(), "启");
728 }
729
730 #[test]
731 fn click_text_presses_a_wide_label() {
732 let mut harness = firewall();
733 harness.click_text("启用");
734 assert_eq!(harness.app().presses, 1);
735 }
736
737 #[test]
738 fn html_draws_a_wide_character_once() {
739 let harness = firewall();
740 let html = harness.html("wide");
741 let first_row = html.split("<div class=\"row\">").nth(1).expect("a first row");
742 assert_eq!(first_row.matches("<span").count(), 30 - 5, "five characters take two cells each: {first_row}");
743 assert!(first_row.contains(">防</span><span"), "{first_row}");
744 }
745}