1use std::fmt::Write as _;
4use std::sync::Arc;
5
6use snafu::{OptionExt as _, ResultExt as _};
7use tracing::Instrument as _;
8
9const DEFAULT_TIMEOUT: u32 = 500;
11
12#[non_exhaustive]
20pub enum Input {
21 Characters(String),
24 Event(String),
26}
27
28#[non_exhaustive]
34pub struct SteppableTerminal {
35 pub shadow_terminal: crate::shadow_terminal::ShadowTerminal,
37 pub pty_task_handle: std::sync::Arc<
39 tokio::sync::Mutex<tokio::task::JoinHandle<Result<(), crate::errors::PTYError>>>,
40 >,
41 pub pty_input_tx: tokio::sync::mpsc::Sender<crate::pty::BytesFromSTDIN>,
43}
44
45impl SteppableTerminal {
46 #[inline]
51 pub async fn start(
52 config: crate::shadow_terminal::Config,
53 ) -> Result<Self, crate::errors::SteppableTerminalError> {
54 let (surface_output_tx, _) = tokio::sync::mpsc::channel(1);
55 let mut shadow_terminal =
56 crate::shadow_terminal::ShadowTerminal::new(config, surface_output_tx);
57
58 let (pty_input_tx, pty_input_rx) = tokio::sync::mpsc::channel(2048);
59 let pty_task_handle = shadow_terminal.start(pty_input_rx);
60
61 let mut steppable = Self {
62 shadow_terminal,
63 pty_task_handle: std::sync::Arc::new(tokio::sync::Mutex::new(pty_task_handle)),
64 pty_input_tx,
65 };
66
67 for i in 0i8..=100 {
68 if i == 100 {
69 snafu::whatever!("Shadow Terminal didn't start in time.");
70 }
71 steppable
72 .render_all_output()
73 .await
74 .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
75 let mut screen = steppable.screen_as_string()?;
76 screen.retain(|character| !character.is_whitespace());
77 if !screen.is_empty() {
78 break;
79 }
80 tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
81 }
82
83 Ok(steppable)
84 }
85
86 #[inline]
92 pub fn kill(&self) -> Result<(), crate::errors::SteppableTerminalError> {
93 tracing::info!("Killing Steppable Terminal...");
94 self.shadow_terminal.kill().with_whatever_context(|err| {
95 format!("Couldn't call `ShadowTerminal.kill()` from SteppableTerminal: {err:?}")
96 })?;
97
98 let current_span = tracing::Span::current();
99 let pty_handle_arc = Arc::clone(&self.pty_task_handle);
100 let tokio_runtime = tokio::runtime::Handle::current();
101 let result = std::thread::spawn(move || {
102 tokio_runtime.block_on(
103 async {
104 tracing::trace!("Starting manual loop to wait for PTY task handle to finish");
105 let pty_handle = pty_handle_arc.lock().await;
106 for i in 0i64..=100 {
107 tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
108 if i == 100 {
109 tracing::error!(
110 "Couldn't leave ShadowTerminal handle in 100 iterations"
111 );
112 break;
113 }
114 if pty_handle.is_finished() {
115 tracing::trace!("`pty_handle.finished()` returned `true`");
116 break;
117 }
118 }
119 }
120 .instrument(current_span),
121 );
122 })
123 .join();
124 if let Err(error) = result {
125 snafu::whatever!("Error in thread that spawns PTY handle waiter: {error:?}");
126 }
127
128 Ok(())
129 }
130
131 #[inline]
140 pub fn send_input(&self, input: Input) -> Result<(), crate::errors::PTYError> {
141 match input {
142 Input::Characters(characters) => {
143 for char in characters.chars() {
144 let mut buffer: crate::pty::BytesFromSTDIN = [0; 128];
145 char.encode_utf8(&mut buffer);
146
147 self.pty_input_tx
148 .try_send(buffer)
149 .with_whatever_context(|err| {
150 format!("Couldn't send character input ({char}): {err:?}")
151 })?;
152
153 std::thread::sleep(std::time::Duration::from_millis(1));
154 }
155 }
156
157 Input::Event(event) => {
158 for chunk in event.as_bytes().chunks(128) {
159 let mut buffer: crate::pty::BytesFromSTDIN = [0; 128];
160 crate::pty::PTY::add_bytes_to_buffer(&mut buffer, chunk)?;
161
162 self.pty_input_tx
163 .try_send(buffer)
164 .with_whatever_context(|err| {
165 format!("Couldn't send input event ({event:?}): {err:?}")
166 })?;
167 }
168
169 std::thread::sleep(std::time::Duration::from_millis(1));
170 }
171 }
172
173 Ok(())
174 }
175
176 #[inline]
182 pub fn send_command(&self, command: &str) -> Result<(), crate::errors::PTYError> {
183 self.paste_string(command)?;
184 self.send_input(Input::Characters("\n".to_owned()))?;
185
186 Ok(())
187 }
188
189 #[inline]
194 pub fn paste_string(&self, string: &str) -> Result<(), crate::errors::PTYError> {
195 let paste_start = "\x1b[200~";
196 let paste_end = "\x1b[201~";
197 let pastable_string = format!("{paste_start}{string}{paste_end}");
198
199 self.send_input(Input::Event(pastable_string))?;
200
201 Ok(())
202 }
203
204 #[inline]
212 pub async fn render_all_output(&mut self) -> Result<(), crate::errors::PTYError> {
213 loop {
214 let result = self.shadow_terminal.channels.output_rx.try_recv();
215 match result {
216 Ok(bytes) => {
217 self.shadow_terminal
218 .accumulated_pty_output
219 .append(&mut bytes.to_vec());
220
221 Box::pin(self.shadow_terminal.handle_pty_output())
222 .await
223 .with_whatever_context(|err| {
224 format!("Couldn't handle PTY output: {err:?}")
225 })?;
226 tracing::trace!("Wezterm shadow terminal advanced {} bytes", bytes.len());
227 }
228 Err(_) => break,
229 }
230 }
231
232 Ok(())
233 }
234
235 #[inline]
240 pub fn get_scrollback_position(
241 &mut self,
242 ) -> Result<usize, crate::errors::SteppableTerminalError> {
243 let screen = self.shadow_terminal.terminal.screen();
244 let scrollback_position: usize = screen
245 .phys_to_stable_row_index(0)
246 .try_into()
247 .with_whatever_context(|err| format!("Couldn't scrollback position to usize: {err}"))?;
248
249 Ok(scrollback_position)
250 }
251
252 #[inline]
257 pub fn screen_as_string(&mut self) -> Result<String, crate::errors::SteppableTerminalError> {
258 let size = self.shadow_terminal.terminal.get_size();
259 let mut screen = self.shadow_terminal.terminal.screen().clone();
260 let mut output = String::new();
261
262 for y in 0..size.rows {
263 for x in 0..size.cols {
264 let maybe_cell = screen.get_cell(
265 x,
266 y.try_into().with_whatever_context(|err| {
267 format!("Couldn't convert cell index to i64: {err}")
268 })?,
269 );
270 if let Some(cell) = maybe_cell {
271 write!(output, "{}", cell.str())
272 .with_whatever_context(|_| "Couldn't write screen output")?;
273 }
274 }
275 writeln!(output).with_whatever_context(|_| "Couldn't write screen output")?;
276 }
277
278 Ok(output)
279 }
280
281 #[inline]
286 pub fn get_coords_of_cell_by_content(&mut self, content: &str) -> Option<(usize, usize)> {
287 let size = self.shadow_terminal.terminal.get_size();
288 let mut screen = self.shadow_terminal.terminal.screen().clone();
289 for y_usize in 0..size.rows {
290 let result = y_usize.try_into();
291
292 #[expect(
293 clippy::unreachable,
294 reason = "I assume that get_size() wouldn't return anything thet get_cell can't consume"
295 )]
296 let Ok(y) = result
297 else {
298 unreachable!()
299 };
300 for x in 0..size.cols {
301 let maybe_cell = screen.get_cell(x, y);
302 if let Some(cell) = maybe_cell {
303 if cell.str() == content {
304 return Some((x, y_usize));
305 }
306 }
307 }
308 }
309
310 None
311 }
312
313 #[inline]
318 pub fn get_cell_at(
319 &mut self,
320 x: usize,
321 y: usize,
322 ) -> Result<Option<wezterm_term::Cell>, crate::errors::SteppableTerminalError> {
323 let size = self.shadow_terminal.terminal.get_size();
324 let mut screen = self.shadow_terminal.terminal.screen().clone();
325 let scrollback = self.get_scrollback_position()?;
326 for row in scrollback..size.rows {
327 for col in 0..size.cols {
328 if !(x == col && y == row - scrollback) {
329 continue;
330 }
331
332 let maybe_cell = screen.get_cell(
333 col,
334 row.try_into().with_whatever_context(|err| {
335 format!("Couldn't convert cell index to i64: {err}")
336 })?,
337 );
338
339 if let Some(cell) = maybe_cell {
340 return Ok(Some(cell.clone()));
341 }
342 }
343 }
344
345 Ok(None)
346 }
347
348 #[inline]
353 pub fn get_string_at(
354 &mut self,
355 x: usize,
356 y: usize,
357 length: usize,
358 ) -> Result<String, crate::errors::SteppableTerminalError> {
359 let mut string = String::new();
360 for col in x..(x + length) {
361 let maybe_cell = self.get_cell_at(col, y)?;
362 if let Some(cell) = maybe_cell {
363 string = format!("{string}{}", cell.str());
364 }
365 }
366
367 Ok(string)
368 }
369
370 #[expect(clippy::print_stderr, reason = "This is a debugging function")]
375 #[inline]
376 pub fn dump_screen(&mut self) -> Result<(), crate::errors::SteppableTerminalError> {
377 let size = self.shadow_terminal.terminal.get_size();
378 let current_screen = self.screen_as_string()?;
379 eprintln!("Current Tattoy screen ({}x{})", size.cols, size.rows);
380 eprintln!("{current_screen}");
381 Ok(())
382 }
383
384 #[tracing::instrument(name = "get_prompt")]
391 #[inline]
392 pub async fn get_prompt_string(
393 command: Vec<std::ffi::OsString>,
394 ) -> Result<String, crate::errors::SteppableTerminalError> {
395 tracing::info!("Starting `get_prompt` terminal instance...");
396 let config = crate::shadow_terminal::Config {
397 width: 30,
398 height: 10,
399 command,
400 ..crate::shadow_terminal::Config::default()
401 };
402 let mut stepper = Box::pin(Self::start(config)).await?;
403 let mut output = stepper.screen_as_string()?;
404 tracing::info!("Finished `get_prompt` terminal instance.");
405
406 output.retain(|character| !character.is_whitespace());
407 Ok(output)
408 }
409
410 #[inline]
418 pub async fn wait_for_any_change(
419 &mut self,
420 ) -> Result<(), crate::errors::SteppableTerminalError> {
421 let initial_screen = self.screen_as_string()?;
422 for i in 0..=DEFAULT_TIMEOUT {
423 if i == DEFAULT_TIMEOUT {
424 snafu::whatever!("No change detected in {DEFAULT_TIMEOUT} milliseconds.");
425 }
426 self.render_all_output()
427 .await
428 .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
429 let current_screen = self.screen_as_string()?;
430 if initial_screen != current_screen {
431 break;
432 }
433 tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
434 }
435
436 Ok(())
437 }
438
439 #[inline]
445 pub async fn wait_for_string(
446 &mut self,
447 string: &str,
448 maybe_timeout: Option<u32>,
449 ) -> Result<(), crate::errors::SteppableTerminalError> {
450 let timeout = maybe_timeout.map_or(DEFAULT_TIMEOUT, |ms| ms);
451
452 for i in 0u32..=timeout {
453 self.render_all_output()
454 .await
455 .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
456 let current_screen = self.screen_as_string()?;
457 if current_screen.contains(string) {
458 break;
459 }
460 if i == timeout {
461 self.dump_screen()?;
462 snafu::whatever!("'{string}' not found after {timeout} milliseconds.");
463 }
464 tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
465 }
466
467 Ok(())
468 }
469
470 #[inline]
476 pub async fn wait_for_string_at(
477 &mut self,
478 string_to_find: &str,
479 x: usize,
480 y: usize,
481 maybe_timeout: Option<u32>,
482 ) -> Result<(), crate::errors::SteppableTerminalError> {
483 let timeout = maybe_timeout.map_or(DEFAULT_TIMEOUT, |ms| ms);
484
485 for i in 0u32..=timeout {
486 self.render_all_output()
487 .await
488 .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
489 let found_string = self.get_string_at(x, y, string_to_find.chars().count())?;
490 if found_string == string_to_find {
491 break;
492 }
493 if i == timeout {
494 self.dump_screen()?;
495 snafu::whatever!(
496 "'{string_to_find}' not found at {x}x{y} after {timeout} milliseconds."
497 );
498 }
499 tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
500 }
501
502 Ok(())
503 }
504
505 #[inline]
507 async fn wait_for_color_at(
508 &mut self,
509 maybe_colour: Option<(f32, f32, f32, f32)>,
510 is_fg_colour: bool,
511 x: usize,
512 y: usize,
513 maybe_timeout: Option<u32>,
514 ) -> Result<(), crate::errors::SteppableTerminalError> {
515 let timeout = maybe_timeout.map_or(DEFAULT_TIMEOUT, |ms| ms);
516 let colour = match maybe_colour {
517 Some(colour) => Self::make_colour_attribute(colour.0, colour.1, colour.2, colour.3),
518 None => termwiz::color::ColorAttribute::Default,
519 };
520
521 for i in 0u32..=timeout {
522 self.render_all_output()
523 .await
524 .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
525 let cell = self.get_cell_at(x, y)?;
526 let attributes = cell
527 .clone()
528 .with_whatever_context(|| format!("Couldn't find cell at: {x}x{y}"))?
529 .attrs()
530 .clone();
531
532 if is_fg_colour && attributes.foreground() == colour {
533 break;
534 }
535 if !is_fg_colour && attributes.background() == colour {
536 break;
537 }
538 if i == timeout {
539 self.dump_screen()?;
540 snafu::whatever!(
541 "'{colour:?}' not found in cell ({:?}) at {x}x{y} after {timeout} milliseconds.",
542 cell
543 );
544 }
545 tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
546 }
547
548 Ok(())
549 }
550
551 #[inline]
557 pub async fn wait_for_bg_color_at(
558 &mut self,
559 maybe_colour: Option<(f32, f32, f32, f32)>,
560 x: usize,
561 y: usize,
562 maybe_timeout: Option<u32>,
563 ) -> Result<(), crate::errors::SteppableTerminalError> {
564 self.wait_for_color_at(maybe_colour, false, x, y, maybe_timeout)
565 .await
566 }
567
568 #[inline]
574 pub async fn wait_for_fg_color_at(
575 &mut self,
576 maybe_colour: Option<(f32, f32, f32, f32)>,
577 x: usize,
578 y: usize,
579 maybe_timeout: Option<u32>,
580 ) -> Result<(), crate::errors::SteppableTerminalError> {
581 self.wait_for_color_at(maybe_colour, true, x, y, maybe_timeout)
582 .await
583 }
584
585 #[inline]
591 pub async fn wait_for_colors_at(
592 &mut self,
593 background_colour: Option<(f32, f32, f32, f32)>,
594 foreground_colour: Option<(f32, f32, f32, f32)>,
595 x: usize,
596 y: usize,
597 maybe_timeout: Option<u32>,
598 ) -> Result<(), crate::errors::SteppableTerminalError> {
599 self.wait_for_color_at(foreground_colour, true, x, y, maybe_timeout)
600 .await?;
601 self.wait_for_color_at(background_colour, false, x, y, maybe_timeout)
602 .await?;
603
604 Ok(())
605 }
606
607 #[inline]
609 #[must_use]
610 pub const fn extract_colour(
611 colour_attribute: termwiz::color::ColorAttribute,
612 ) -> Option<termwiz::color::SrgbaTuple> {
613 match colour_attribute {
614 termwiz::color::ColorAttribute::TrueColorWithPaletteFallback(srgba_tuple, _)
615 | termwiz::color::ColorAttribute::TrueColorWithDefaultFallback(srgba_tuple) => {
616 Some(srgba_tuple)
617 }
618 termwiz::color::ColorAttribute::PaletteIndex(_)
619 | termwiz::color::ColorAttribute::Default => None,
620 }
621 }
622
623 const fn make_colour_attribute(
625 red: f32,
626 green: f32,
627 blue: f32,
628 alpha: f32,
629 ) -> termwiz::color::ColorAttribute {
630 termwiz::color::ColorAttribute::TrueColorWithDefaultFallback(termwiz::color::SrgbaTuple(
631 red, green, blue, alpha,
632 ))
633 }
634}
635
636impl Drop for SteppableTerminal {
637 #[inline]
638 fn drop(&mut self) {
639 tracing::trace!("Running SteppableTerminal.drop()");
640 let result = self.kill();
641 if let Err(error) = result {
642 tracing::error!("{error:?}");
643 }
644 }
645}
646
647#[cfg(test)]
648mod test {
649
650 fn setup_logging() {
652 tracing_subscriber::fmt()
653 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
654 .without_time()
655 .init();
656 }
657
658 #[cfg(not(target_os = "windows"))]
659 #[tokio::test(flavor = "multi_thread")]
660 async fn basic_interactivity() {
661 let mut stepper = Box::pin(crate::tests::helpers::run(None, None)).await;
662
663 stepper.send_command("nano --version").unwrap();
664 stepper.wait_for_string("GNU nano", None).await.unwrap();
665 let output = stepper.screen_as_string().unwrap();
666 assert!(output.contains("GNU nano, version"));
667 }
668
669 #[cfg(not(target_os = "windows"))]
670 #[tokio::test(flavor = "multi_thread")]
671 async fn resizing() {
672 let mut stepper = Box::pin(crate::tests::helpers::run(None, None)).await;
673 stepper.send_command("nano --restricted").unwrap();
674 stepper.wait_for_string("GNU nano", None).await.unwrap();
675
676 let size = stepper.shadow_terminal.terminal.get_size();
677 let bottom = size.rows - 1;
678 let right = size.cols - 1;
679 let menu_item_paste = stepper.get_string_at(right - 10, bottom, 5).unwrap();
680 assert_eq!(menu_item_paste, "Paste");
681
682 stepper
683 .shadow_terminal
684 .resize(
685 u16::try_from(size.cols + 3).unwrap(),
686 u16::try_from(size.rows + 3).unwrap(),
687 )
688 .unwrap();
689 let resized_size = stepper.shadow_terminal.terminal.get_size();
690 let resized_bottom = resized_size.rows - 1;
691 let resized_right = resized_size.cols - 1;
692 stepper
693 .wait_for_string_at("^X Exit", 0, resized_bottom, Some(1000))
694 .await
695 .unwrap();
696 let resized_menu_item_paste = stepper
697 .get_string_at(resized_right - 10, resized_bottom, 5)
698 .unwrap();
699 assert_eq!(resized_menu_item_paste, "Paste");
700 }
701
702 #[cfg(not(target_os = "windows"))]
703 #[tokio::test(flavor = "multi_thread")]
704 async fn cursor_position_response() {
705 let mut stepper = Box::pin(crate::tests::helpers::run(Some(100), None)).await;
706
707 let command = "sleep 0.1; echo -en \"\\E[6n\"; read -sdR CURPOS; echo ${CURPOS#*[}";
710
711 stepper.send_command(command).unwrap();
712
713 stepper.wait_for_string("1;0", None).await.unwrap();
714 }
715
716 #[cfg(not(target_os = "windows"))]
717 #[tokio::test(flavor = "multi_thread")]
718 async fn wide_characters() {
719 setup_logging();
720
721 let mut stepper = Box::pin(crate::tests::helpers::run(Some(100), None)).await;
722 let columns = stepper.shadow_terminal.terminal.get_size().cols;
723 let full_row = "😀".repeat(columns.div_euclid(2));
724
725 let command = format!("echo {full_row}");
726 stepper.send_command(command.as_str()).unwrap();
727
728 let raw_with_spaces = full_row
729 .chars()
730 .map(|character| character.to_string())
731 .collect::<Vec<String>>()
732 .join(" ");
733
734 stepper
735 .wait_for_string(&raw_with_spaces, None)
736 .await
737 .unwrap();
738 }
739}