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.send_input(Input::Characters(format!("{command}\n")))?;
184
185 Ok(())
186 }
187
188 #[inline]
203 pub fn send_command_with_osc_paste(
204 &self,
205 command: &str,
206 ) -> Result<(), crate::errors::PTYError> {
207 self.paste_string(command)?;
208 self.send_input(Input::Characters("\n".to_owned()))?;
209
210 Ok(())
211 }
212
213 #[inline]
218 pub fn paste_string(&self, string: &str) -> Result<(), crate::errors::PTYError> {
219 let paste_start = "\x1b[200~";
220 let paste_end = "\x1b[201~";
221 let pastable_string = format!("{paste_start}{string}{paste_end}");
222
223 self.send_input(Input::Event(pastable_string))?;
224
225 Ok(())
226 }
227
228 #[inline]
236 pub async fn render_all_output(&mut self) -> Result<(), crate::errors::PTYError> {
237 loop {
238 let result = self.shadow_terminal.channels.output_rx.try_recv();
239 match result {
240 Ok(bytes) => {
241 self.shadow_terminal
242 .accumulated_pty_output
243 .append(&mut bytes.to_vec());
244
245 Box::pin(self.shadow_terminal.handle_pty_output())
246 .await
247 .with_whatever_context(|err| {
248 format!("Couldn't handle PTY output: {err:?}")
249 })?;
250 tracing::trace!("Wezterm shadow terminal advanced {} bytes", bytes.len());
251 }
252 Err(_) => break,
253 }
254 }
255
256 Ok(())
257 }
258
259 #[inline]
264 pub fn get_scrollback_position(
265 &mut self,
266 ) -> Result<usize, crate::errors::SteppableTerminalError> {
267 let screen = self.shadow_terminal.terminal.screen();
268 let scrollback_position: usize = screen
269 .phys_to_stable_row_index(0)
270 .try_into()
271 .with_whatever_context(|err| format!("Couldn't scrollback position to usize: {err}"))?;
272
273 Ok(scrollback_position)
274 }
275
276 #[inline]
281 pub fn screen_as_string(&mut self) -> Result<String, crate::errors::SteppableTerminalError> {
282 let size = self.shadow_terminal.terminal.get_size();
283 let mut screen = self.shadow_terminal.terminal.screen().clone();
284 let mut output = String::new();
285
286 for y in 0..size.rows {
287 for x in 0..size.cols {
288 let maybe_cell = screen.get_cell(
289 x,
290 y.try_into().with_whatever_context(|err| {
291 format!("Couldn't convert cell index to i64: {err}")
292 })?,
293 );
294 if let Some(cell) = maybe_cell {
295 write!(output, "{}", cell.str())
296 .with_whatever_context(|_| "Couldn't write screen output")?;
297 }
298 }
299 writeln!(output).with_whatever_context(|_| "Couldn't write screen output")?;
300 }
301
302 Ok(output)
303 }
304
305 #[inline]
310 pub fn get_coords_of_cell_by_content(&mut self, content: &str) -> Option<(usize, usize)> {
311 let size = self.shadow_terminal.terminal.get_size();
312 let mut screen = self.shadow_terminal.terminal.screen().clone();
313 for y_usize in 0..size.rows {
314 let result = y_usize.try_into();
315
316 #[expect(
317 clippy::unreachable,
318 reason = "I assume that get_size() wouldn't return anything thet get_cell can't consume"
319 )]
320 let Ok(y) = result
321 else {
322 unreachable!()
323 };
324 for x in 0..size.cols {
325 let maybe_cell = screen.get_cell(x, y);
326 if let Some(cell) = maybe_cell {
327 if cell.str() == content {
328 return Some((x, y_usize));
329 }
330 }
331 }
332 }
333
334 None
335 }
336
337 #[inline]
342 pub fn get_cell_at(
343 &mut self,
344 x: usize,
345 y: usize,
346 ) -> Result<Option<wezterm_term::Cell>, crate::errors::SteppableTerminalError> {
347 let size = self.shadow_terminal.terminal.get_size();
348 let mut screen = self.shadow_terminal.terminal.screen().clone();
349 let scrollback = self.get_scrollback_position()?;
350 for row in scrollback..size.rows {
351 for col in 0..size.cols {
352 if !(x == col && y == row - scrollback) {
353 continue;
354 }
355
356 let maybe_cell = screen.get_cell(
357 col,
358 row.try_into().with_whatever_context(|err| {
359 format!("Couldn't convert cell index to i64: {err}")
360 })?,
361 );
362
363 if let Some(cell) = maybe_cell {
364 return Ok(Some(cell.clone()));
365 }
366 }
367 }
368
369 Ok(None)
370 }
371
372 #[inline]
377 pub fn get_string_at(
378 &mut self,
379 x: usize,
380 y: usize,
381 length: usize,
382 ) -> Result<String, crate::errors::SteppableTerminalError> {
383 let mut string = String::new();
384 for col in x..(x + length) {
385 let maybe_cell = self.get_cell_at(col, y)?;
386 if let Some(cell) = maybe_cell {
387 string = format!("{string}{}", cell.str());
388 }
389 }
390
391 Ok(string)
392 }
393
394 #[expect(clippy::print_stderr, reason = "This is a debugging function")]
399 #[inline]
400 pub fn dump_screen(&mut self) -> Result<(), crate::errors::SteppableTerminalError> {
401 let size = self.shadow_terminal.terminal.get_size();
402 let current_screen = self.screen_as_string()?;
403 eprintln!("Current Tattoy screen ({}x{})", size.cols, size.rows);
404 eprintln!("{current_screen}");
405 Ok(())
406 }
407
408 #[tracing::instrument(name = "get_prompt")]
415 #[inline]
416 pub async fn get_prompt_string(
417 command: Vec<std::ffi::OsString>,
418 ) -> Result<String, crate::errors::SteppableTerminalError> {
419 tracing::info!("Starting `get_prompt` terminal instance...");
420 let config = crate::shadow_terminal::Config {
421 width: 30,
422 height: 10,
423 command,
424 ..crate::shadow_terminal::Config::default()
425 };
426 let mut stepper = Box::pin(Self::start(config)).await?;
427 let mut output = stepper.screen_as_string()?;
428 tracing::info!("Finished `get_prompt` terminal instance.");
429
430 output.retain(|character| !character.is_whitespace());
431 Ok(output)
432 }
433
434 #[inline]
442 pub async fn wait_for_any_change(
443 &mut self,
444 ) -> Result<(), crate::errors::SteppableTerminalError> {
445 let initial_screen = self.screen_as_string()?;
446 for i in 0..=DEFAULT_TIMEOUT {
447 if i == DEFAULT_TIMEOUT {
448 snafu::whatever!("No change detected in {DEFAULT_TIMEOUT} milliseconds.");
449 }
450 self.render_all_output()
451 .await
452 .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
453 let current_screen = self.screen_as_string()?;
454 if initial_screen != current_screen {
455 break;
456 }
457 tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
458 }
459
460 self.dump_screen()?;
461
462 Ok(())
463 }
464
465 #[inline]
471 pub async fn wait_for_string(
472 &mut self,
473 string: &str,
474 maybe_timeout: Option<u32>,
475 ) -> Result<(), crate::errors::SteppableTerminalError> {
476 let timeout = maybe_timeout.map_or(DEFAULT_TIMEOUT, |ms| ms);
477
478 for i in 0u32..=timeout {
479 self.render_all_output()
480 .await
481 .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
482 let current_screen = self.screen_as_string()?;
483 if current_screen.contains(string) {
484 break;
485 }
486 if i == timeout {
487 self.dump_screen()?;
488 snafu::whatever!("'{string}' not found after {timeout} milliseconds.");
489 }
490 tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
491 }
492
493 Ok(())
494 }
495
496 #[inline]
502 pub async fn wait_for_string_at(
503 &mut self,
504 string_to_find: &str,
505 x: usize,
506 y: usize,
507 maybe_timeout: Option<u32>,
508 ) -> Result<(), crate::errors::SteppableTerminalError> {
509 let timeout = maybe_timeout.map_or(DEFAULT_TIMEOUT, |ms| ms);
510
511 for i in 0u32..=timeout {
512 self.render_all_output()
513 .await
514 .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
515 let found_string = self.get_string_at(x, y, string_to_find.chars().count())?;
516 if found_string == string_to_find {
517 break;
518 }
519 if i == timeout {
520 self.dump_screen()?;
521 snafu::whatever!(
522 "'{string_to_find}' not found at {x}x{y} after {timeout} milliseconds."
523 );
524 }
525 tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
526 }
527
528 Ok(())
529 }
530
531 #[inline]
533 async fn wait_for_color_at(
534 &mut self,
535 maybe_colour: Option<(f32, f32, f32, f32)>,
536 is_fg_colour: bool,
537 x: usize,
538 y: usize,
539 maybe_timeout: Option<u32>,
540 ) -> Result<(), crate::errors::SteppableTerminalError> {
541 let timeout = maybe_timeout.map_or(DEFAULT_TIMEOUT, |ms| ms);
542 let colour = match maybe_colour {
543 Some(colour) => Self::make_colour_attribute(colour.0, colour.1, colour.2, colour.3),
544 None => termwiz::color::ColorAttribute::Default,
545 };
546
547 for i in 0u32..=timeout {
548 self.render_all_output()
549 .await
550 .with_whatever_context(|err| format!("Couldn't render output: {err:?}"))?;
551 let cell = self.get_cell_at(x, y)?;
552 let attributes = cell
553 .clone()
554 .with_whatever_context(|| format!("Couldn't find cell at: {x}x{y}"))?
555 .attrs()
556 .clone();
557
558 if is_fg_colour && attributes.foreground() == colour {
559 break;
560 }
561 if !is_fg_colour && attributes.background() == colour {
562 break;
563 }
564 if i == timeout {
565 self.dump_screen()?;
566 snafu::whatever!(
567 "'{colour:?}' not found in cell ({:?}) at {x}x{y} after {timeout} milliseconds.",
568 cell
569 );
570 }
571 tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
572 }
573
574 Ok(())
575 }
576
577 #[inline]
583 pub async fn wait_for_bg_color_at(
584 &mut self,
585 maybe_colour: Option<(f32, f32, f32, f32)>,
586 x: usize,
587 y: usize,
588 maybe_timeout: Option<u32>,
589 ) -> Result<(), crate::errors::SteppableTerminalError> {
590 self.wait_for_color_at(maybe_colour, false, x, y, maybe_timeout)
591 .await
592 }
593
594 #[inline]
600 pub async fn wait_for_fg_color_at(
601 &mut self,
602 maybe_colour: Option<(f32, f32, f32, f32)>,
603 x: usize,
604 y: usize,
605 maybe_timeout: Option<u32>,
606 ) -> Result<(), crate::errors::SteppableTerminalError> {
607 self.wait_for_color_at(maybe_colour, true, x, y, maybe_timeout)
608 .await
609 }
610
611 #[inline]
617 pub async fn wait_for_colors_at(
618 &mut self,
619 background_colour: Option<(f32, f32, f32, f32)>,
620 foreground_colour: Option<(f32, f32, f32, f32)>,
621 x: usize,
622 y: usize,
623 maybe_timeout: Option<u32>,
624 ) -> Result<(), crate::errors::SteppableTerminalError> {
625 self.wait_for_color_at(foreground_colour, true, x, y, maybe_timeout)
626 .await?;
627 self.wait_for_color_at(background_colour, false, x, y, maybe_timeout)
628 .await?;
629
630 Ok(())
631 }
632
633 #[inline]
635 #[must_use]
636 pub const fn extract_colour(
637 colour_attribute: termwiz::color::ColorAttribute,
638 ) -> Option<termwiz::color::SrgbaTuple> {
639 match colour_attribute {
640 termwiz::color::ColorAttribute::TrueColorWithPaletteFallback(srgba_tuple, _)
641 | termwiz::color::ColorAttribute::TrueColorWithDefaultFallback(srgba_tuple) => {
642 Some(srgba_tuple)
643 }
644 termwiz::color::ColorAttribute::PaletteIndex(_)
645 | termwiz::color::ColorAttribute::Default => None,
646 }
647 }
648
649 const fn make_colour_attribute(
651 red: f32,
652 green: f32,
653 blue: f32,
654 alpha: f32,
655 ) -> termwiz::color::ColorAttribute {
656 termwiz::color::ColorAttribute::TrueColorWithDefaultFallback(termwiz::color::SrgbaTuple(
657 red, green, blue, alpha,
658 ))
659 }
660}
661
662impl Drop for SteppableTerminal {
663 #[inline]
664 fn drop(&mut self) {
665 tracing::trace!("Running SteppableTerminal.drop()");
666 let result = self.kill();
667 if let Err(error) = result {
668 tracing::error!("{error:?}");
669 }
670 }
671}
672
673#[cfg(test)]
674mod test {
675
676 fn setup_logging() {
678 tracing_subscriber::fmt()
679 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
680 .without_time()
681 .init();
682 }
683
684 #[cfg(not(target_os = "windows"))]
685 #[tokio::test(flavor = "multi_thread")]
686 async fn basic_interactivity() {
687 let mut stepper = Box::pin(crate::tests::helpers::run(None, None)).await;
688
689 stepper.send_command("nano --version").unwrap();
690 stepper.wait_for_string("GNU nano", None).await.unwrap();
691 let output = stepper.screen_as_string().unwrap();
692 assert!(output.contains("GNU nano, version"));
693 }
694
695 #[cfg(not(target_os = "windows"))]
696 #[tokio::test(flavor = "multi_thread")]
697 async fn resizing() {
698 let mut stepper = Box::pin(crate::tests::helpers::run(None, None)).await;
699 stepper.send_command("nano --restricted").unwrap();
700 stepper.wait_for_string("GNU nano", None).await.unwrap();
701
702 let size = stepper.shadow_terminal.terminal.get_size();
703 let bottom = size.rows - 1;
704 let right = size.cols - 1;
705 let menu_item_paste = stepper.get_string_at(right - 10, bottom, 5).unwrap();
706 assert_eq!(menu_item_paste, "Paste");
707
708 stepper
709 .shadow_terminal
710 .resize(
711 u16::try_from(size.cols + 3).unwrap(),
712 u16::try_from(size.rows + 3).unwrap(),
713 )
714 .unwrap();
715 let resized_size = stepper.shadow_terminal.terminal.get_size();
716 let resized_bottom = resized_size.rows - 1;
717 let resized_right = resized_size.cols - 1;
718 stepper
719 .wait_for_string_at("^X Exit", 0, resized_bottom, Some(1000))
720 .await
721 .unwrap();
722 let resized_menu_item_paste = stepper
723 .get_string_at(resized_right - 10, resized_bottom, 5)
724 .unwrap();
725 assert_eq!(resized_menu_item_paste, "Paste");
726 }
727
728 #[cfg(not(target_os = "windows"))]
729 #[tokio::test(flavor = "multi_thread")]
730 async fn cursor_position_response() {
731 let mut stepper = Box::pin(crate::tests::helpers::run(Some(100), None)).await;
732
733 let command = "sleep 0.1; echo -en \"\\E[6n\"; read -sdR CURPOS; echo ${CURPOS#*[}";
736
737 stepper.send_command(command).unwrap();
738
739 stepper.wait_for_string("1;0", None).await.unwrap();
740 }
741
742 #[cfg(not(target_os = "windows"))]
743 #[tokio::test(flavor = "multi_thread")]
744 async fn wide_characters() {
745 setup_logging();
746
747 let mut stepper = Box::pin(crate::tests::helpers::run(Some(100), None)).await;
748 let columns = stepper.shadow_terminal.terminal.get_size().cols;
749 let full_row = "😀".repeat(columns.div_euclid(2));
750
751 let command = format!("echo {full_row}");
752 stepper.send_command(command.as_str()).unwrap();
753
754 let raw_with_spaces = full_row
755 .chars()
756 .map(|character| character.to_string())
757 .collect::<Vec<String>>()
758 .join(" ");
759
760 stepper
761 .wait_for_string(&raw_with_spaces, None)
762 .await
763 .unwrap();
764 }
765}