use crate::{Input, Locator, Pane, PaneSet, Result};
#[derive(Debug, Clone)]
pub struct PaneKeyboard {
pane: Pane,
}
impl PaneKeyboard {
pub(crate) const fn new(pane: Pane) -> Self {
Self { pane }
}
pub async fn type_text(&self, text: impl AsRef<str>) -> Result<()> {
self.pane.send_text(text.as_ref()).await
}
pub async fn press(&self, key: impl AsRef<str>) -> Result<()> {
self.pane.send_key(normalize_key_token(key.as_ref())).await
}
}
impl Pane {
#[must_use]
pub fn keyboard(&self) -> PaneKeyboard {
PaneKeyboard::new(self.clone())
}
#[must_use]
pub fn mouse(&self) -> PaneMouse {
PaneMouse::new(self.clone())
}
}
#[derive(Debug, Clone)]
pub struct PaneSetKeyboard {
panes: PaneSet,
}
impl PaneSetKeyboard {
pub(crate) fn new(panes: PaneSet) -> Self {
Self { panes }
}
pub async fn type_text(&self, text: impl AsRef<str>) -> Result<()> {
self.panes.broadcast(Input::text(text.as_ref())).await?;
Ok(())
}
pub async fn press(&self, key: impl AsRef<str>) -> Result<()> {
let key = normalize_key_token(key.as_ref());
self.panes.broadcast(Input::key(&key)).await?;
Ok(())
}
}
impl PaneSet {
#[must_use]
pub fn keyboard(&self) -> PaneSetKeyboard {
PaneSetKeyboard::new(self.clone())
}
}
#[derive(Debug, Clone)]
pub struct PaneMouse {
pane: Pane,
}
impl PaneMouse {
pub(crate) const fn new(pane: Pane) -> Self {
Self { pane }
}
pub async fn move_to(&self, row: u16, col: u16) -> Result<()> {
self.pane
.send_text(sgr_mouse_sequence(35, row, col, true))
.await
}
pub async fn click(&self, row: u16, col: u16) -> Result<()> {
self.pane
.send_text(sgr_mouse_sequence(0, row, col, true))
.await?;
self.pane
.send_text(sgr_mouse_sequence(0, row, col, false))
.await
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum FillStrategy {
ControlU,
Backspace(usize),
None,
}
impl Locator {
pub async fn click(self) -> Result<()> {
let (_snapshot, item) = self.resolve_strict_with_wait().await?;
self.pane()
.mouse()
.click(item.text_match.start_row, item.text_match.start_col)
.await
}
pub async fn hover(self) -> Result<()> {
let (_snapshot, item) = self.resolve_strict_with_wait().await?;
self.pane()
.mouse()
.move_to(item.text_match.start_row, item.text_match.start_col)
.await
}
pub async fn fill(self, text: impl AsRef<str>) -> Result<()> {
self.fill_with(text, FillStrategy::ControlU).await
}
pub async fn fill_with(self, text: impl AsRef<str>, strategy: FillStrategy) -> Result<()> {
let pane = self.pane().clone();
let (_snapshot, _item) = self.resolve_strict_with_wait().await?;
let keyboard = pane.keyboard();
match strategy {
FillStrategy::ControlU => keyboard.press("C-u").await?,
FillStrategy::Backspace(count) => {
for _ in 0..count {
keyboard.press("Backspace").await?;
}
}
FillStrategy::None => {}
}
keyboard.type_text(text.as_ref()).await
}
}
fn normalize_key_token(key: &str) -> String {
let Some((modifiers, key_name)) = key.rsplit_once('+') else {
return key.to_owned();
};
if key_name.is_empty() {
return key.to_owned();
}
let mut normalized = Vec::new();
for modifier in modifiers.split('+') {
match modifier.to_ascii_lowercase().as_str() {
"control" | "ctrl" => normalized.push("C"),
"alt" | "meta" | "option" => normalized.push("M"),
"shift" => normalized.push("S"),
_ => return key.to_owned(),
}
}
if normalized.is_empty() {
return key.to_owned();
}
let has_shift = normalized.contains(&"S");
let control_only = normalized.len() == 1 && normalized[0] == "C";
let key_name = if control_only || (normalized.contains(&"C") && !has_shift) {
key_name.to_ascii_lowercase()
} else {
key_name.to_owned()
};
format!("{}-{key_name}", normalized.join("-"))
}
#[cfg(test)]
fn control_key(rest: &str) -> String {
let lowered = rest.to_ascii_lowercase();
format!("C-{lowered}")
}
fn sgr_mouse_sequence(button: u16, row: u16, col: u16, press: bool) -> String {
let suffix = if press { 'M' } else { 'm' };
let row = row.saturating_add(1);
let col = col.saturating_add(1);
format!("\x1b[<{button};{col};{row}{suffix}")
}
#[cfg(test)]
mod tests {
use super::{control_key, normalize_key_token, sgr_mouse_sequence};
#[test]
fn keyboard_tokens_preserve_plain_keys_and_normalize_control_spellings() {
assert_eq!(normalize_key_token("Enter"), "Enter");
assert_eq!(normalize_key_token("Backspace"), "Backspace");
assert_eq!(normalize_key_token("Control+C"), "C-c");
assert_eq!(normalize_key_token("Control+["), "C-[");
assert_eq!(normalize_key_token("Ctrl+Z"), "C-z");
assert_eq!(normalize_key_token("ctrl+c"), "C-c");
assert_eq!(normalize_key_token("Alt+x"), "M-x");
assert_eq!(normalize_key_token("Meta+x"), "M-x");
assert_eq!(normalize_key_token("Option+x"), "M-x");
assert_eq!(normalize_key_token("Shift+Tab"), "S-Tab");
assert_eq!(normalize_key_token("Control+Shift+T"), "C-S-T");
assert_eq!(normalize_key_token("Hyper+X"), "Hyper+X");
}
#[test]
fn control_key_lowercases_only_the_control_suffix() {
assert_eq!(control_key("C"), "C-c");
assert_eq!(control_key("Break"), "C-break");
}
#[test]
fn mouse_sequences_use_zero_based_input_and_sgr_coordinates() {
assert_eq!(sgr_mouse_sequence(35, 0, 0, true), "\x1b[<35;1;1M");
assert_eq!(sgr_mouse_sequence(0, 2, 4, true), "\x1b[<0;5;3M");
assert_eq!(sgr_mouse_sequence(0, 2, 4, false), "\x1b[<0;5;3m");
}
#[test]
fn mouse_sequences_saturate_at_terminal_protocol_bounds() {
assert_eq!(
sgr_mouse_sequence(0, u16::MAX, u16::MAX, true),
"\x1b[<0;65535;65535M"
);
}
}