pixelactions-core 0.9.0

Platform-free core of pixelactions: coordinate-space conversion for macOS CGEvent, Windows SendInput, and Linux XTEST — physical pixels vs logical points, HiDPI and multi-monitor — plus the flow schema, label resolution, and run reports
Documentation
//! `verb:argument` — the chained-argv form of a step.
//!
//! One invocation performing many actions is the cheapest possible
//! programmability: no protocol, no daemon, nothing to install. It is
//! what `cliclick` and `xdotool` have always done, and it collapses N
//! process spawns into one — which matters less for the spawn cost
//! (~3ms) than for doing a single relocation pass instead of N.
//!
//! The verbs deliberately mirror the flow file's actions one-for-one, so
//! learning either teaches the other. That rule is borrowed from tmux's
//! control mode: the protocol's verbs *are* the command set.

use crate::flow::{Axis, Step};

/// Why a chained argument could not be read as a step.
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum VerbError {
    #[error("{0:?} is not verb:argument — try click:submit, type:\"hello\", or wait:done")]
    Malformed(String),
    #[error(
        "unknown verb {0:?} — expected click, double, drag, scroll, hscroll, type, key, \
         verify, changed, wait, gone, or pause"
    )]
    Unknown(String),
    #[error("drag needs from>to, e.g. drag:handle>dropzone (got {0:?})")]
    DragShape(String),
    #[error("pause needs milliseconds, e.g. pause:250 (got {0:?})")]
    PauseValue(String),
    #[error(
        "{0} needs label>amount, e.g. {0}:results>3 to go one way and {0}:results>-3 the \
         other (got {1:?})"
    )]
    ScrollShape(String, String),
    #[error("{0} needs a label, e.g. {0}:submit")]
    EmptyLabel(String),
    #[error(
        "changed takes a label, optionally with a percentage: changed:panel, or \
         changed:panel>2.5 to require more than 2.5% of its pixels to differ (got {0:?})"
    )]
    ChangedShape(String),
}

/// Parse one `verb:argument` argument into a step.
///
/// The argument half is taken verbatim after the first colon, so
/// `type:https://example.com` and `type:a:b` work without escaping — a
/// rule worth keeping, since text is the argument most likely to contain
/// a colon.
pub fn parse(argument: &str) -> Result<Step, VerbError> {
    let Some((verb, rest)) = argument.split_once(':') else {
        return Err(VerbError::Malformed(argument.to_string()));
    };
    let verb = verb.trim();

    let step = match verb {
        "click" => Step::Click {
            target: label(verb, rest)?,
        },
        "double" => Step::DoubleClick {
            target: label(verb, rest)?,
        },
        "verify" => Step::Verify {
            target: label(verb, rest)?,
        },
        "wait" => Step::WaitFor {
            target: label(verb, rest)?,
        },
        "gone" => Step::WaitGone {
            target: label(verb, rest)?,
        },
        "type" => Step::Type {
            text: rest.to_string(),
        },
        "key" => Step::Key {
            chord: label(verb, rest)?,
        },
        "drag" => {
            let Some((from, to)) = rest.split_once('>') else {
                return Err(VerbError::DragShape(rest.to_string()));
            };
            if from.trim().is_empty() || to.trim().is_empty() {
                return Err(VerbError::DragShape(rest.to_string()));
            }
            Step::Drag {
                from: from.trim().to_string(),
                to: to.trim().to_string(),
            }
        }
        "changed" => {
            // `changed:label` or `changed:label>PCT`. The threshold is
            // optional where scroll's amount is required, because zero is
            // a meaningful default here and an unpredictable one there.
            let (target, tolerance) = match rest.split_once('>') {
                None => (rest, 0.0),
                Some((target, pct)) => {
                    let parsed: f64 = pct
                        .trim()
                        .parse()
                        .map_err(|_| VerbError::ChangedShape(rest.to_string()))?;
                    if !parsed.is_finite() || !(0.0..=100.0).contains(&parsed) {
                        return Err(VerbError::ChangedShape(rest.to_string()));
                    }
                    (target, parsed)
                }
            };
            Step::Changed {
                target: label(verb, target)?,
                tolerance,
            }
        }
        "scroll" => scroll(verb, rest, Axis::Vertical)?,
        "hscroll" => scroll(verb, rest, Axis::Horizontal)?,
        "pause" => {
            let ms = rest
                .trim()
                .parse::<u64>()
                .map_err(|_| VerbError::PauseValue(rest.to_string()))?;
            Step::Pause { ms }
        }
        other => return Err(VerbError::Unknown(other.to_string())),
    };
    Ok(step)
}

/// `LABEL>AMOUNT` for a scroll, on the axis the verb chose.
///
/// The amount is required rather than defaulted: it is already the least
/// predictable value in the tool, and silently picking one for the
/// caller would make an unpredictable thing invisible too.
fn scroll(verb: &str, rest: &str, axis: Axis) -> Result<Step, VerbError> {
    let shape = || VerbError::ScrollShape(verb.to_string(), rest.to_string());
    let Some((target, amount)) = rest.split_once('>') else {
        return Err(shape());
    };
    let target = target.trim();
    if target.is_empty() {
        return Err(shape());
    }
    let amount: i32 = amount.trim().parse().map_err(|_| shape())?;
    if amount == 0 {
        return Err(shape());
    }
    Ok(Step::Scroll {
        target: target.to_string(),
        amount,
        axis,
    })
}

/// Parse every argument, or fail on the first bad one.
///
/// All-or-nothing by design: a chain with a typo in step 7 must not
/// perform steps 1 through 6 first.
pub fn parse_all<I, S>(arguments: I) -> Result<Vec<Step>, VerbError>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    arguments.into_iter().map(|a| parse(a.as_ref())).collect()
}

fn label(verb: &str, rest: &str) -> Result<String, VerbError> {
    let trimmed = rest.trim();
    if trimmed.is_empty() {
        return Err(VerbError::EmptyLabel(verb.to_string()));
    }
    Ok(trimmed.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn scroll_chains_as_label_then_amount() {
        assert_eq!(
            parse("scroll:results>-3").expect("valid"),
            Step::Scroll {
                target: "results".into(),
                amount: -3,
                axis: crate::flow::Axis::Vertical,
            }
        );
        assert_eq!(
            parse("hscroll:timeline>5").expect("valid"),
            Step::Scroll {
                target: "timeline".into(),
                amount: 5,
                axis: crate::flow::Axis::Horizontal,
            }
        );
    }

    #[test]
    fn a_scroll_without_an_amount_is_refused() {
        for bad in [
            "scroll:results",
            "scroll:>3",
            "scroll:results>",
            "scroll:results>lots",
        ] {
            assert!(parse(bad).is_err(), "should refuse {bad:?}");
        }
    }

    #[test]
    fn a_scroll_of_zero_is_refused_rather_than_silently_doing_nothing() {
        assert!(parse("scroll:results>0").is_err());
    }

    #[test]
    fn every_verb_maps_to_its_flow_action() {
        assert_eq!(
            parse("click:submit"),
            Ok(Step::Click {
                target: "submit".into()
            })
        );
        assert_eq!(
            parse("double:icon"),
            Ok(Step::DoubleClick {
                target: "icon".into()
            })
        );
        assert_eq!(
            parse("verify:done"),
            Ok(Step::Verify {
                target: "done".into()
            })
        );
        assert_eq!(
            parse("wait:dialog"),
            Ok(Step::WaitFor {
                target: "dialog".into()
            })
        );
        assert_eq!(
            parse("gone:spinner"),
            Ok(Step::WaitGone {
                target: "spinner".into()
            })
        );
        assert_eq!(
            parse("key:cmd+s"),
            Ok(Step::Key {
                chord: "cmd+s".into()
            })
        );
        assert_eq!(parse("pause:250"), Ok(Step::Pause { ms: 250 }));
        assert_eq!(
            parse("drag:handle>zone"),
            Ok(Step::Drag {
                from: "handle".into(),
                to: "zone".into()
            })
        );
    }

    #[test]
    fn type_keeps_everything_after_the_first_colon() {
        // Text is the argument most likely to contain a colon, so it must
        // not need escaping.
        assert_eq!(
            parse("type:https://example.com"),
            Ok(Step::Type {
                text: "https://example.com".into()
            })
        );
        assert_eq!(
            parse("type:a:b:c"),
            Ok(Step::Type {
                text: "a:b:c".into()
            })
        );
    }

    #[test]
    fn type_may_be_deliberately_empty() {
        // Clearing a field by typing nothing is meaningless, but so is
        // rejecting it — an empty string is a valid thing to type.
        assert_eq!(
            parse("type:"),
            Ok(Step::Type {
                text: String::new()
            })
        );
    }

    #[test]
    fn an_argument_without_a_colon_is_malformed() {
        assert!(matches!(parse("click"), Err(VerbError::Malformed(_))));
    }

    #[test]
    fn an_unknown_verb_names_the_real_ones() {
        let error = parse("teleport:home").expect_err("unknown");
        let message = error.to_string();
        assert!(message.contains("click"), "lists the options: {message}");
    }

    #[test]
    fn a_label_verb_without_a_label_is_refused() {
        assert!(matches!(parse("click:"), Err(VerbError::EmptyLabel(_))));
        assert!(matches!(parse("wait:   "), Err(VerbError::EmptyLabel(_))));
    }

    #[test]
    fn drag_requires_both_ends() {
        assert!(matches!(parse("drag:handle"), Err(VerbError::DragShape(_))));
        assert!(matches!(parse("drag:>zone"), Err(VerbError::DragShape(_))));
        assert!(matches!(
            parse("drag:handle>"),
            Err(VerbError::DragShape(_))
        ));
    }

    #[test]
    fn pause_needs_a_number() {
        assert!(matches!(parse("pause:soon"), Err(VerbError::PauseValue(_))));
        assert!(matches!(parse("pause:-5"), Err(VerbError::PauseValue(_))));
    }

    #[test]
    fn whitespace_around_labels_is_tolerated() {
        assert_eq!(
            parse("click: submit "),
            Ok(Step::Click {
                target: "submit".into()
            })
        );
    }

    #[test]
    fn a_chain_fails_whole_rather_than_running_the_good_part() {
        // The important property: a typo in the last argument must not
        // perform the first three actions.
        let result = parse_all(["click:a", "type:hi", "key:cmd+s", "clik:b"]);
        assert!(matches!(result, Err(VerbError::Unknown(_))));
    }

    #[test]
    fn a_whole_chain_parses_in_order() {
        let steps = parse_all(["click:submit", "type:hello", "wait:done"]).expect("valid");
        assert_eq!(steps.len(), 3);
        assert_eq!(
            steps[2],
            Step::WaitFor {
                target: "done".into()
            }
        );
    }

    #[test]
    fn changed_takes_a_bare_label_and_defaults_to_any_pixel() {
        let Step::Changed { target, tolerance } = parse("changed:panel").expect("parses") else {
            panic!("wrong step");
        };
        assert_eq!(target, "panel");
        assert!((tolerance - 0.0).abs() < f64::EPSILON);
    }

    #[test]
    fn changed_takes_a_percentage_after_the_label() {
        let Step::Changed { target, tolerance } = parse("changed:panel>2.5").expect("parses")
        else {
            panic!("wrong step");
        };
        assert_eq!(target, "panel");
        assert!((tolerance - 2.5).abs() < f64::EPSILON);
    }

    #[test]
    fn changed_refuses_a_percentage_that_is_not_one() {
        for bad in [
            "changed:panel>",
            "changed:panel>banana",
            "changed:panel>-1",
            "changed:panel>101",
            "changed:panel>NaN",
        ] {
            assert!(
                matches!(parse(bad), Err(VerbError::ChangedShape(_))),
                "should refuse {bad:?}"
            );
        }
    }

    #[test]
    fn changed_still_needs_a_label() {
        assert!(matches!(parse("changed:"), Err(VerbError::EmptyLabel(_))));
    }
}