pixelactions-core 0.9.6

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
# pixelactions-core

<p align="center">
  <a href="https://crates.io/crates/pixelactions-core"><img src="https://img.shields.io/crates/v/pixelactions-core.svg" alt="crates.io" /></a>
  <a href="https://docs.rs/pixelactions-core"><img src="https://img.shields.io/docsrs/pixelactions-core.svg" alt="docs.rs" /></a>
  <img src="https://img.shields.io/badge/rustc-1.88+-93450a.svg" alt="MSRV: Rust 1.88+" />
  <img src="https://img.shields.io/badge/unsafe-forbidden-success.svg" alt="forbid(unsafe_code)" />
  <a href="https://github.com/nolindnaidoo/pixelactions/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT" /></a>
</p>

A coordinate is only worth having if something acts on it. pixelactions is
the second half of that loop: it reads a session a human marked in
[pixelcoords](https://github.com/nolindnaidoo/pixelcoords), resolves the
label to a point, performs the interaction, and confirms it landed.

This is its platform-free core: coordinate conversion, flow files, the
planner, the line protocol, the step vocabulary, and the run report — with
no input backend, no OS calls, and `#![forbid(unsafe_code)]`.

**Want the tool?** `cargo install pixelactions`.
**Want to build on it?** This crate — it is what you use to *generate*
flows, *parse* reports, or drive the protocol from Rust.

```toml
[dependencies]
pixelactions-core = "0.9"
```

## Coordinate spaces: the reason this crate exists

A pixelcoords session records **physical pixels**. Input APIs disagree
about what they want:

| Platform | Input API | Speaks |
|---|---|---|
| macOS | `CGEvent` | **logical points**, global space, origin top-left |
| Windows | `SendInput` | **physical pixels**, normalized across the virtual desktop |
| Linux / X11 | `XTEST` | **physical pixels** on the root window |

The same saved coordinate therefore needs a different conversion per
platform, and getting it wrong does not error — it clicks the wrong
place. `Space::Auto` resolves to whatever the current platform needs,
decided in exactly one place (`native_space()`), so no call site guesses.

```rust
use pixelactions_core::convert::{Space, native_space, to_space};
use pixelcoords_core::geometry::{Point, Size};
use pixelcoords_core::session::MonitorRecord;

let monitors = vec![
    // A Retina built-in display at the origin...
    MonitorRecord {
        index: 0,
        name: "Built-in".into(),
        primary: true,
        origin_px: Point::new(0, 0),
        size_px: Size::new(3600, 2338),
        scale: 2.0,
    },
    // ...and a 1x external panel to its left, at negative coordinates.
    MonitorRecord {
        index: 1,
        name: "External".into(),
        primary: false,
        origin_px: Point::new(-3440, 0),
        size_px: Size::new(3440, 1440),
        scale: 1.0,
    },
];

// Physical (850, 440) on the Retina display is logical (425, 220).
let point = to_space(&monitors, 850, 440, Space::Logical).expect("on a monitor");
assert_eq!((point.x, point.y), (425.0, 220.0));
assert_eq!(point.monitor, 0);

// The same physical coordinate on the 1x panel is unchanged, because
// conversion divides by the *containing* monitor's scale — never a
// global one. Mixed-DPI desktops are the normal case, not an edge case.
let external = to_space(&monitors, -2000, 400, Space::Logical).expect("on a monitor");
assert_eq!((external.x, external.y), (-2000.0, 400.0));
assert_eq!(external.monitor, 1);

// A point in the gap between monitors is refused, never clamped —
// clamping would click somewhere plausible and wrong.
assert!(to_space(&monitors, 99_999, 99_999, Space::Logical).is_none());

// What `Auto` means here, decided once:
assert!(matches!(native_space(), Space::Logical | Space::Physical));
```

`monitor_at` answers containment on its own, and `corners` /
`near_screen_corner` back the kill switch — a cursor found in a screen
corner stops a run, because grabbing the mouse is what a person does
when automation goes wrong.

## Resolving labels to points

`plan` turns a flow plus a session into concrete coordinates, or refuses.
It is **total**: every label resolves before any action runs, because a
half-executed flow is the worst outcome this tool can produce.

```rust
use pixelactions_core::convert::Space;
use pixelactions_core::flow::Flow;
use pixelactions_core::plan::{PlanError, plan};
use pixelcoords_core::session::SessionFile;

# fn main() -> Result<(), Box<dyn std::error::Error>> {
let session: SessionFile = serde_json::from_str(EXAMPLE_SESSION)?;
let flow = Flow::parse("session = \"s\"\n\n[[step]]\naction = \"click\"\ntarget = \"submit\"\n")?;

let resolved = plan(&flow, &session, Space::Logical)?;
assert_eq!(resolved.steps[0].points[0].x, 425.0);
assert_eq!(resolved.steps[0].summary, "click submit");

// A missing label fails planning, and the error names what does exist.
let typo = Flow::parse("session = \"s\"\n\n[[step]]\naction = \"click\"\ntarget = \"submti\"\n")?;
let error = plan(&typo, &session, Space::Logical).expect_err("unknown label");
assert!(matches!(error, PlanError::UnknownLabel { .. }));
assert!(error.to_string().contains("submit"));
# Ok(())
# }
#
# const EXAMPLE_SESSION: &str = r#"{
#   "schema": 1,
#   "app": { "name": "pixelcoords", "version": "0.7.6" },
#   "created_utc": "2026-07-29T00:00:00Z",
#   "monitors": [
#     { "index": 0, "name": "Built-in", "primary": true,
#       "origin_px": { "x": 0, "y": 0 },
#       "size_px": { "w": 3600, "h": 2338 }, "scale": 2.0 }
#   ],
#   "selections": [
#     { "shape": "rect", "label": "submit", "monitor": 0,
#       "px":        { "x": 800, "y": 400, "w": 100, "h": 80 },
#       "global_px": { "x": 800, "y": 400, "w": 100, "h": 80 },
#       "crop": "submit.png" }
#   ]
# }"#;
```

## Flow files

A flow references regions by **label**, never by raw coordinate. That
indirection is the point: a label survives the UI moving, and a diff
shows intent ("click submit") rather than arithmetic.

```rust
use pixelactions_core::flow::{Flow, Step, Verify};

# fn main() -> Result<(), Box<dyn std::error::Error>> {
let flow = Flow::parse(
    r#"
    session = "~/captures/checkout"

    [settings]
    verify = "each"
    timeout_ms = 30000

    [[step]]
    action = "click"
    target = "email"

    [[step]]
    action = "type"
    text = "a@b.com"

    [[step]]
    action = "wait_for"
    target = "confirmation"
    "#,
)?;

assert_eq!(flow.settings.verify, Verify::Each);
assert_eq!(flow.steps[0], Step::Click { target: "email".into() });

// Every label the flow will touch, for resolving up front.
assert_eq!(flow.targets(), vec!["email", "confirmation"]);
# Ok(())
# }
```

Parsing is **strict**: unknown keys are errors, not silent no-ops, so a
typo fails at parse time instead of skipping a step at run time. (Session
parsing, by contrast, is deliberately tolerant — unknown fields from a
newer pixelcoords are ignored, so the two tools release independently.)

## The same verbs, three ways

Chained argv, flow files, and the line protocol all build the same
`Step`, so learning one teaches the others and none can drift.

```rust
use pixelactions_core::flow::Step;
use pixelactions_core::protocol::{RequestBody, parse_request};
use pixelactions_core::verb::parse_all;

# fn main() -> Result<(), Box<dyn std::error::Error>> {
// Chained argv: `pixelactions run --session DIR click:submit type:"hi"`
let steps = parse_all(["click:submit", "type:hi", "scroll:results>-3"])?;
assert_eq!(steps[0], Step::Click { target: "submit".into() });

// The line protocol: `do` names the same action a flow file's `action`
// does, so `{"do":"click"}` is the wire form of `action = "click"`.
let request = parse_request(r#"{"id":1,"do":"click","target":"submit"}"#)
    .expect("a valid request");
assert_eq!(request.id, Some(1));
assert_eq!(
    request.body,
    RequestBody::Step { step: Step::Click { target: "submit".into() } }
);
# Ok(())
# }
```

Parsing a chain is all-or-nothing by design: a typo in step 7 must not
perform steps 1 through 6 first.

## Speaking the line protocol

`protocol` holds both directions of the wire format, so you can write a
Rust client for `pixelactions serve` — or an entirely different server
that speaks the same thing.

```rust
use pixelactions_core::protocol::{PROTOCOL_VERSION, Response, ResponseBody, supported_verbs};

let welcome = Response {
    id: Some(1),
    body: ResponseBody::Welcome {
        version: PROTOCOL_VERSION,
        verbs: supported_verbs(),
        session: "/captures/checkout".into(),
    },
};

// One JSON object per line, newline included — no embedded newlines, in
// either direction.
let line = welcome.to_line();
assert!(line.ends_with('\n'));
assert_eq!(line.matches('\n').count(), 1);
assert!(line.contains("\"result\":\"welcome\""));
```

The framing rules are the ones LSP, esbuild, and MCP all converged on:
one JSON object per line; **stdout carries protocol only and stderr is
logs**; closing stdin is the graceful shutdown; and a version handshake
first, so the protocol can change later without breaking programs written
against it today.

## Reporting what happened

`report` carries the vocabulary the whole tool reports in — and the
distinctions are the point.

```rust
use pixelactions_core::report::{RunReport, StepOutcome, StepReport};

// "The OS accepted the event" is not "the app reacted to it".
assert_eq!(StepOutcome::Verified.name(), "verified");
assert_eq!(StepOutcome::Executed.name(), "executed");

// And "it did not work" is not "I declined to try": a refusal is never
// worth retrying, so it earns its own exit code.
let refused = RunReport {
    schema: RunReport::SCHEMA,
    session: "/captures/checkout".into(),
    executed: true,
    steps: vec![StepReport {
        index: 0,
        summary: "click submit".into(),
        outcome: StepOutcome::Refused,
        points: Vec::new(),
        detail: Some("kill switch: the cursor is in a screen corner".into()),
        elapsed_ms: 14,
    }],
};
assert_eq!(refused.exit_code(), 3);
```

Exit codes are the API: **0** done · **1** a step failed honestly · **2**
malformed question · **3** refused.

## Modules

| Module | What it owns |
|---|---|
| `flow` | the flow file: steps, settings, parsing — strict, unknown keys refused |
| `verb` | the chained-argv grammar (`click:submit`, `scroll:list>3`) |
| `plan` | resolving labels to points, and refusing when it cannot |
| `convert` | physical ↔ logical, and which one this platform's input API wants |
| `protocol` | the line protocol: requests, responses, the advertised verbs |
| `report` | the run report — what executed, what verified, what failed |
| `audit` | the append-only record of what a run did |

Full API, every item:
**[docs.rs/pixelactions-core](https://docs.rs/pixelactions-core)**

## Testing

| Layer | What it covers |
|---|---|
| Unit tests | every module — **90% line coverage floor per module**, enforced in CI |
| Property tests | the conversion, because a bug there means clicking the wrong place |
| Doctests | every example in this file compiles and runs — the README *is* the crate docs |

Examples here are `include_str!`'d into the crate and run on every push, so
one that stopped compiling would fail CI rather than mislead you.

## Relationship to the other crates

`pixelactions` the binary is this crate plus an input backend: `CGEvent`,
`SendInput`, XTEST, or the Wayland portal. Anything decidable without
touching a device lives here.

`pixelcoords-core` owns the geometry and the session schema; this crate
depends on it and **never reimplements it**. Matching, capture and shapes
belong there. What belongs here is everything about *acting*: the space a
platform's input API expects, the vocabulary of steps, and the report.

## Also by nolindnaidoo

**Rust**

- **[pixelcoords]https://github.com/nolindnaidoo/pixelcoords** - Mark pixel-exact coordinates machines can use · [pixelcoords.dev]https://pixelcoords.dev

**VS Code Extensions** — every tool in the family, one page: **[letools.dev](https://letools.dev)**

- **[String-LE]https://marketplace.visualstudio.com/items?itemName=nolindnaidoo.string-le** - Extract string values for i18n from JSON, YAML, CSV, TOML, INI, and .env
- **[Numbers-LE]https://marketplace.visualstudio.com/items?itemName=nolindnaidoo.numbers-le** - Extract numeric values from JSON, YAML, CSV, TOML, INI, and .env
- **[EnvSync-LE]https://marketplace.visualstudio.com/items?itemName=nolindnaidoo.envsync-le** - Spot missing keys across your .env files, with a markdown report
- **[Paths-LE]https://marketplace.visualstudio.com/items?itemName=nolindnaidoo.paths-le** - Extract file paths from JS/TS imports, JSON, HTML, CSS, TOML, CSV, and .env
- **[Secrets-LE]https://marketplace.visualstudio.com/items?itemName=nolindnaidoo.secrets-le** - Detect and sanitize credentials locally, before you commit
- **[Scrape-LE]https://marketplace.visualstudio.com/items?itemName=nolindnaidoo.scrape-le** - Check whether a page is scrapeable before you write the scraper
- **[Colors-LE]https://marketplace.visualstudio.com/items?itemName=nolindnaidoo.colors-le** - Extract and analyze colors from CSS, SCSS, LESS, Stylus, HTML, JS/TS, and SVG
- **[URLs-LE]https://marketplace.visualstudio.com/items?itemName=nolindnaidoo.urls-le** - Extract URLs from documentation, configs, and code
- **[Regex-LE]https://marketplace.visualstudio.com/items?itemName=nolindnaidoo.regex-le** - Find, test, and validate the regex patterns in the current file
- **[Dates-LE]https://marketplace.visualstudio.com/items?itemName=nolindnaidoo.dates-le** - Extract and analyze dates from logs, configs, and code

**Contact Developer** — [GitHub](https://github.com/nolindnaidoo) · [LinkedIn](https://www.linkedin.com/in/nolindnaidoo/)

## License

MIT — see [LICENSE](https://github.com/nolindnaidoo/pixelactions/blob/main/LICENSE).