Skip to main content

rmux_sdk/
actions.rs

1//! High-level terminal actions built on existing pane input primitives.
2
3use crate::{Input, Locator, Pane, PaneSet, Result};
4
5/// Keyboard actions for one pane.
6#[derive(Debug, Clone)]
7pub struct PaneKeyboard {
8    pane: Pane,
9}
10
11impl PaneKeyboard {
12    pub(crate) const fn new(pane: Pane) -> Self {
13        Self { pane }
14    }
15
16    /// Sends literal text to the pane. No newline is appended.
17    pub async fn type_text(&self, text: impl AsRef<str>) -> Result<()> {
18        self.pane.send_text(text.as_ref()).await
19    }
20
21    /// Sends one tmux-compatible key token to the pane.
22    ///
23    /// Common Playwright-style spellings such as `Control+C` and `Ctrl+C`
24    /// are normalized to tmux-style `C-c` key tokens.
25    pub async fn press(&self, key: impl AsRef<str>) -> Result<()> {
26        self.pane.send_key(normalize_key_token(key.as_ref())).await
27    }
28}
29
30impl Pane {
31    /// Returns high-level keyboard actions for this pane.
32    #[must_use]
33    pub fn keyboard(&self) -> PaneKeyboard {
34        PaneKeyboard::new(self.clone())
35    }
36
37    /// Returns high-level mouse actions for this pane.
38    #[must_use]
39    pub fn mouse(&self) -> PaneMouse {
40        PaneMouse::new(self.clone())
41    }
42}
43
44/// Keyboard actions broadcast to a pane set.
45#[derive(Debug, Clone)]
46pub struct PaneSetKeyboard {
47    panes: PaneSet,
48}
49
50impl PaneSetKeyboard {
51    pub(crate) fn new(panes: PaneSet) -> Self {
52        Self { panes }
53    }
54
55    /// Sends literal text to every pane in this set.
56    pub async fn type_text(&self, text: impl AsRef<str>) -> Result<()> {
57        self.panes.broadcast(Input::text(text.as_ref())).await?;
58        Ok(())
59    }
60
61    /// Sends one key token to every pane in this set.
62    pub async fn press(&self, key: impl AsRef<str>) -> Result<()> {
63        let key = normalize_key_token(key.as_ref());
64        self.panes.broadcast(Input::key(&key)).await?;
65        Ok(())
66    }
67}
68
69impl PaneSet {
70    /// Returns high-level keyboard actions broadcast to this pane set.
71    #[must_use]
72    pub fn keyboard(&self) -> PaneSetKeyboard {
73        PaneSetKeyboard::new(self.clone())
74    }
75}
76
77/// Mouse actions for one pane.
78///
79/// These helpers inject terminal mouse-report escape sequences into the pane's
80/// foreground process. They do not operate below the PTY like a real terminal
81/// emulator would. Applications that understand SGR mouse reports can react to
82/// them; shells and programs that do not may treat the bytes as literal input.
83#[derive(Debug, Clone)]
84pub struct PaneMouse {
85    pane: Pane,
86}
87
88impl PaneMouse {
89    pub(crate) const fn new(pane: Pane) -> Self {
90        Self { pane }
91    }
92
93    /// Moves the terminal mouse cursor to a zero-based row and column.
94    ///
95    /// This sends an SGR mouse-mode motion sequence as input bytes. Callers
96    /// should only use it with applications that are prepared to parse SGR
97    /// mouse reports; other applications may render or buffer the bytes.
98    pub async fn move_to(&self, row: u16, col: u16) -> Result<()> {
99        self.pane
100            .send_text(sgr_mouse_sequence(35, row, col, true))
101            .await
102    }
103
104    /// Sends a primary-button click at a zero-based row and column.
105    ///
106    /// This sends SGR mouse press and release sequences as input bytes. It is
107    /// intentionally minimal: terminals have no DOM hit target, so higher-level
108    /// semantics belong to the application under test.
109    pub async fn click(&self, row: u16, col: u16) -> Result<()> {
110        self.pane
111            .send_text(sgr_mouse_sequence(0, row, col, true))
112            .await?;
113        self.pane
114            .send_text(sgr_mouse_sequence(0, row, col, false))
115            .await
116    }
117}
118
119/// Clearing strategy used by [`Locator::fill_with`].
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
121#[non_exhaustive]
122pub enum FillStrategy {
123    /// Send `C-u` before typing. This matches common readline-like prompts.
124    ControlU,
125    /// Send `Backspace` `n` times before typing.
126    Backspace(usize),
127    /// Do not attempt to clear existing terminal input before typing.
128    None,
129}
130
131impl Locator {
132    /// Clicks the strict visible text match for this locator.
133    pub async fn click(self) -> Result<()> {
134        let (_snapshot, item) = self.resolve_strict_with_wait().await?;
135        self.pane()
136            .mouse()
137            .click(item.text_match.start_row, item.text_match.start_col)
138            .await
139    }
140
141    /// Moves the mouse to the strict visible text match for this locator.
142    pub async fn hover(self) -> Result<()> {
143        let (_snapshot, item) = self.resolve_strict_with_wait().await?;
144        self.pane()
145            .mouse()
146            .move_to(item.text_match.start_row, item.text_match.start_col)
147            .await
148    }
149
150    /// Best-effort terminal fill for the current prompt line.
151    ///
152    /// A terminal has no input element to focus. The locator is used for
153    /// strictness and synchronization only; text is still typed at the current
154    /// terminal cursor. The default clearing strategy sends `C-u`, which is
155    /// intended for common readline-like shells and REPLs.
156    pub async fn fill(self, text: impl AsRef<str>) -> Result<()> {
157        self.fill_with(text, FillStrategy::ControlU).await
158    }
159
160    /// Best-effort terminal fill with an explicit clearing strategy.
161    ///
162    /// This method still types at the terminal cursor. It does not move focus
163    /// to the matched text because terminals do not expose DOM-like controls.
164    pub async fn fill_with(self, text: impl AsRef<str>, strategy: FillStrategy) -> Result<()> {
165        let pane = self.pane().clone();
166        let (_snapshot, _item) = self.resolve_strict_with_wait().await?;
167        let keyboard = pane.keyboard();
168        match strategy {
169            FillStrategy::ControlU => keyboard.press("C-u").await?,
170            FillStrategy::Backspace(count) => {
171                for _ in 0..count {
172                    keyboard.press("Backspace").await?;
173                }
174            }
175            FillStrategy::None => {}
176        }
177        keyboard.type_text(text.as_ref()).await
178    }
179}
180
181fn normalize_key_token(key: &str) -> String {
182    let Some((modifiers, key_name)) = key.rsplit_once('+') else {
183        return key.to_owned();
184    };
185    if key_name.is_empty() {
186        return key.to_owned();
187    }
188
189    let mut normalized = Vec::new();
190    for modifier in modifiers.split('+') {
191        match modifier.to_ascii_lowercase().as_str() {
192            "control" | "ctrl" => normalized.push("C"),
193            "alt" | "meta" | "option" => normalized.push("M"),
194            "shift" => normalized.push("S"),
195            _ => return key.to_owned(),
196        }
197    }
198    if normalized.is_empty() {
199        return key.to_owned();
200    }
201
202    let has_shift = normalized.contains(&"S");
203    let control_only = normalized.len() == 1 && normalized[0] == "C";
204    let key_name = if control_only || (normalized.contains(&"C") && !has_shift) {
205        key_name.to_ascii_lowercase()
206    } else {
207        key_name.to_owned()
208    };
209    format!("{}-{key_name}", normalized.join("-"))
210}
211
212#[cfg(test)]
213fn control_key(rest: &str) -> String {
214    let lowered = rest.to_ascii_lowercase();
215    format!("C-{lowered}")
216}
217
218fn sgr_mouse_sequence(button: u16, row: u16, col: u16, press: bool) -> String {
219    let suffix = if press { 'M' } else { 'm' };
220    let row = row.saturating_add(1);
221    let col = col.saturating_add(1);
222    format!("\x1b[<{button};{col};{row}{suffix}")
223}
224
225#[cfg(test)]
226mod tests {
227    use super::{control_key, normalize_key_token, sgr_mouse_sequence};
228
229    #[test]
230    fn keyboard_tokens_preserve_plain_keys_and_normalize_control_spellings() {
231        assert_eq!(normalize_key_token("Enter"), "Enter");
232        assert_eq!(normalize_key_token("Backspace"), "Backspace");
233        assert_eq!(normalize_key_token("Control+C"), "C-c");
234        assert_eq!(normalize_key_token("Control+["), "C-[");
235        assert_eq!(normalize_key_token("Ctrl+Z"), "C-z");
236        assert_eq!(normalize_key_token("ctrl+c"), "C-c");
237        assert_eq!(normalize_key_token("Alt+x"), "M-x");
238        assert_eq!(normalize_key_token("Meta+x"), "M-x");
239        assert_eq!(normalize_key_token("Option+x"), "M-x");
240        assert_eq!(normalize_key_token("Shift+Tab"), "S-Tab");
241        assert_eq!(normalize_key_token("Control+Shift+T"), "C-S-T");
242        assert_eq!(normalize_key_token("Hyper+X"), "Hyper+X");
243    }
244
245    #[test]
246    fn control_key_lowercases_only_the_control_suffix() {
247        assert_eq!(control_key("C"), "C-c");
248        assert_eq!(control_key("Break"), "C-break");
249    }
250
251    #[test]
252    fn mouse_sequences_use_zero_based_input_and_sgr_coordinates() {
253        assert_eq!(sgr_mouse_sequence(35, 0, 0, true), "\x1b[<35;1;1M");
254        assert_eq!(sgr_mouse_sequence(0, 2, 4, true), "\x1b[<0;5;3M");
255        assert_eq!(sgr_mouse_sequence(0, 2, 4, false), "\x1b[<0;5;3m");
256    }
257
258    #[test]
259    fn mouse_sequences_saturate_at_terminal_protocol_bounds() {
260        assert_eq!(
261            sgr_mouse_sequence(0, u16::MAX, u16::MAX, true),
262            "\x1b[<0;65535;65535M"
263        );
264    }
265}