Skip to main content

ferridriver_test/
interactive.rs

1//! Interactive key handler for watch mode.
2//!
3//! Architecture (matches Playwright's pattern):
4//! - Raw mode is ONLY active during the idle wait period (between test runs)
5//! - Raw mode is DISABLED during test execution so output renders correctly
6//! - The watch loop in runner.rs owns the raw mode lifecycle
7//!
8//! The KeyHandler spawns a background thread that polls crossterm events.
9//! Events are only read when raw mode is active (the thread polls a flag).
10//! Commands flow through an async channel to the watch loop.
11
12use std::io::Write;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15
16/// Watch mode command from keyboard input.
17#[derive(Debug, Clone)]
18pub enum WatchCommand {
19  /// Run all tests ('a').
20  RunAll,
21  /// Run only previously failed tests ('f').
22  RunFailed,
23  /// Re-run with current filter (Enter).
24  Rerun,
25  /// Enter filter mode, then apply pattern ('p' -> type -> Enter).
26  FilterByName(String),
27  /// Quit watch mode ('q').
28  Quit,
29}
30
31/// Interactive key handler for watch mode.
32pub struct KeyHandler {
33  rx: async_channel::Receiver<WatchCommand>,
34  active: Arc<AtomicBool>,
35  _handle: std::thread::JoinHandle<()>,
36}
37
38impl KeyHandler {
39  /// Create the key handler. Does NOT enable raw mode — the watch loop controls that.
40  ///
41  /// # Errors
42  ///
43  /// Returns an error if the terminal doesn't support raw mode (non-TTY).
44  pub fn new() -> ferridriver::error::Result<Self> {
45    use ferridriver::FerriError;
46    // Verify TTY support by briefly enabling/disabling raw mode.
47    crossterm::terminal::enable_raw_mode()
48      .map_err(|e| FerriError::unsupported(format!("raw mode not supported: {e}")))?;
49    let _ = crossterm::terminal::disable_raw_mode();
50
51    let (tx, rx) = async_channel::bounded(16);
52    let active = Arc::new(AtomicBool::new(false));
53    let active_clone = Arc::clone(&active);
54
55    let handle = std::thread::Builder::new()
56      .name("ferridriver-keyhandler".into())
57      .spawn(move || key_poll_loop(&tx, &active_clone))
58      .map_err(|e| FerriError::backend(format!("spawn key handler: {e}")))?;
59
60    Ok(Self {
61      rx,
62      active,
63      _handle: handle,
64    })
65  }
66
67  /// Receive the next key command (async).
68  pub async fn recv(&self) -> Option<WatchCommand> {
69    self.rx.recv().await.ok()
70  }
71
72  /// Enter interactive mode: enable raw mode and start accepting keypresses.
73  /// Call after test run completes, before the idle wait.
74  pub fn enter_interactive(&self) {
75    let _ = crossterm::terminal::enable_raw_mode();
76    self.active.store(true, Ordering::Release);
77  }
78
79  /// Leave interactive mode: disable raw mode so output renders correctly.
80  /// Call before running tests.
81  pub fn leave_interactive(&self) {
82    self.active.store(false, Ordering::Release);
83    let _ = crossterm::terminal::disable_raw_mode();
84  }
85}
86
87impl Drop for KeyHandler {
88  fn drop(&mut self) {
89    self.active.store(false, Ordering::Release);
90    let _ = crossterm::terminal::disable_raw_mode();
91  }
92}
93
94/// Print the interactive hint after a run (raw mode should be OFF when calling this).
95pub fn print_watch_hint() {
96  let mut stderr = std::io::stderr();
97  let _ = writeln!(stderr);
98  let _ = writeln!(stderr, "\x1b[2mWatching for changes...\x1b[0m");
99  let _ = writeln!(
100    stderr,
101    "\x1b[2mPress \x1b[0m\x1b[1ma\x1b[0m\x1b[2m to run all, \
102     \x1b[0m\x1b[1mf\x1b[0m\x1b[2m to run failed, \
103     \x1b[0m\x1b[1mp\x1b[0m\x1b[2m to filter, \
104     \x1b[0m\x1b[1mq\x1b[0m\x1b[2m to quit.\x1b[0m"
105  );
106  let _ = stderr.flush();
107}
108
109/// Blocking poll loop. Only reads key events when `active` is true.
110fn key_poll_loop(tx: &async_channel::Sender<WatchCommand>, active: &AtomicBool) {
111  use crossterm::event::{self, Event, KeyCode, KeyModifiers};
112
113  loop {
114    if tx.is_closed() {
115      break;
116    }
117
118    // When not active, sleep and retry. The watch loop will set active=true
119    // after test output is complete and raw mode is enabled.
120    if !active.load(Ordering::Acquire) {
121      std::thread::sleep(std::time::Duration::from_millis(50));
122      continue;
123    }
124
125    // Poll with short timeout so we can recheck the active flag.
126    if !event::poll(std::time::Duration::from_millis(100)).unwrap_or(false) {
127      continue;
128    }
129
130    let Ok(Event::Key(key)) = event::read() else {
131      continue;
132    };
133
134    // Ctrl+C — quit.
135    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
136      let _ = tx.try_send(WatchCommand::Quit);
137      break;
138    }
139
140    let cmd = match key.code {
141      KeyCode::Char('a') => Some(WatchCommand::RunAll),
142      KeyCode::Char('f') => Some(WatchCommand::RunFailed),
143      KeyCode::Char('q') => Some(WatchCommand::Quit),
144      KeyCode::Enter => Some(WatchCommand::Rerun),
145      KeyCode::Char('p') => {
146        // Leave raw mode for line input, then re-enter.
147        let _ = crossterm::terminal::disable_raw_mode();
148        let pattern = read_filter_pattern();
149        let _ = crossterm::terminal::enable_raw_mode();
150        if pattern.is_empty() {
151          None
152        } else {
153          Some(WatchCommand::FilterByName(pattern))
154        }
155      },
156      _ => None,
157    };
158
159    if let Some(cmd) = cmd {
160      let is_quit = matches!(cmd, WatchCommand::Quit);
161      let _ = tx.try_send(cmd);
162      if is_quit {
163        break;
164      }
165    }
166  }
167}
168
169/// Read a filter pattern from stdin (cooked mode).
170fn read_filter_pattern() -> String {
171  let mut stderr = std::io::stderr();
172  let _ = write!(stderr, "\r\n\x1b[1mFilter pattern:\x1b[0m ");
173  let _ = stderr.flush();
174
175  let mut input = String::new();
176  if std::io::stdin().read_line(&mut input).is_ok() {
177    input.trim().to_string()
178  } else {
179    String::new()
180  }
181}