Skip to main content

pixelactions_core/
plan.rs

1//! Resolution: turn a flow plus a session into a concrete plan, or
2//! refuse.
3//!
4//! Everything that can be known before touching the screen is decided
5//! here — which labels exist, which point each step aims at, what space
6//! that point is in. A flow that references a missing label fails during
7//! planning, before a single event is injected. Half-executed flows are
8//! the worst failure mode this tool could have.
9
10use pixelcoords_core::locate::Delta;
11use pixelcoords_core::resolve::{ResolveError, resolve};
12use pixelcoords_core::session::SessionFile;
13use pixelcoords_core::space::{Origin, Resolved};
14
15use crate::convert::{ResolvedPoint, Space, to_space};
16use crate::flow::{Flow, Step};
17
18/// One step, resolved to the points it will act on.
19#[derive(Debug, Clone, PartialEq)]
20pub struct PlannedStep {
21    pub index: usize,
22    pub summary: String,
23    pub step: Step,
24    /// Resolved points, in the order the step's targets appear. Empty for
25    /// keyboard steps.
26    pub points: Vec<ResolvedPoint>,
27}
28
29/// A whole flow, resolved. Holding one means every label existed and
30/// every point converted.
31#[derive(Debug, Clone, PartialEq)]
32pub struct Plan {
33    pub steps: Vec<PlannedStep>,
34}
35
36/// Why a flow could not be planned. Each variant names the fix.
37#[derive(Debug, thiserror::Error, PartialEq)]
38pub enum PlanError {
39    #[error("no selection labeled {label:?} in this session — it has: {available}")]
40    UnknownLabel { label: String, available: String },
41    #[error("selection {label:?} sits on monitor {monitor}, which this session does not describe")]
42    UnknownMonitor { label: String, monitor: usize },
43    #[error(
44        "selection {label:?} resolves to ({x}, {y}), which is outside every monitor in this session"
45    )]
46    PointOffscreen { label: String, x: i32, y: i32 },
47    #[error(
48        "{count} selections are labeled {label:?} — rename them in pixelcoords so this step names one region, \
49         or the click lands on whichever happens to be first"
50    )]
51    AmbiguousLabel { label: String, count: usize },
52}
53
54/// Resolve a flow against a session.
55///
56/// `space` overrides the flow's own setting when a caller needs a
57/// specific space (dry-run reporting, tests).
58pub fn plan(flow: &Flow, session: &SessionFile, space: Space) -> Result<Plan, PlanError> {
59    let mut steps = Vec::with_capacity(flow.steps.len());
60    for (index, step) in flow.steps.iter().enumerate() {
61        let mut points = Vec::new();
62        for label in step.targets() {
63            points.push(resolve_label(session, label, space)?);
64        }
65        steps.push(PlannedStep {
66            index,
67            summary: step.summary(),
68            step: step.clone(),
69            points,
70        });
71    }
72    Ok(Plan { steps })
73}
74
75/// Nothing has relocated when a plan is built — `plan` never captures.
76/// `run` corrects for drift afterwards, against a fresh `find`.
77const NO_DRIFT: &dyn Fn(usize) -> Option<(f64, Delta)> = &|_| None;
78
79/// The point a labeled region will be acted on.
80///
81/// The label lookup, the monitor lookup, the interior click point and the
82/// hop from monitor-local to global coordinates are all
83/// `pixelcoords_core::resolve`'s. `design/08` calls that the seam, and
84/// says why: reassembling it here means this tool can get DPI wrong in a
85/// way the crate that owns the geometry cannot.
86///
87/// **What stays ours is the refusal.** `resolve` answers in the monitor a
88/// selection *claims*; a point that lands in a gap between monitors, or
89/// past the edge of every one, is still something to refuse rather than
90/// guess at — so the containing-monitor check runs on the physical answer
91/// before any conversion, and `to_space` converts against the monitor
92/// that actually holds the point.
93fn resolve_label(
94    session: &SessionFile,
95    label: &str,
96    space: Space,
97) -> Result<ResolvedPoint, PlanError> {
98    let resolved = resolve(
99        session,
100        Some(label),
101        Origin::Global,
102        Resolved::Physical,
103        NO_DRIFT,
104    )
105    .map_err(|error| match error {
106        ResolveError::UnknownMonitor { monitor, .. } => PlanError::UnknownMonitor {
107            label: label.to_string(),
108            monitor,
109        },
110        // NoSelections, UnknownLabel, and the two window-space errors all
111        // mean the same thing to a caller here: that label is not
112        // actionable. Ours names the alternatives; `Origin::Global` never
113        // reaches the window-space pair.
114        _ => PlanError::UnknownLabel {
115            label: label.to_string(),
116            available: available_labels(session),
117        },
118    })?;
119
120    // More than one selection can carry a label -- alt-dragging a shape in
121    // the overlay clones it, label and all -- and `resolve` answers with
122    // every one of them. Taking the first would act on whichever the file
123    // happened to list first, silently, which is the same "acting blind"
124    // that pixelcoords refuses when a match is ambiguous. Refuse instead.
125    if resolved.len() > 1 {
126        return Err(PlanError::AmbiguousLabel {
127            label: label.to_string(),
128            count: resolved.len(),
129        });
130    }
131    let point = resolved
132        .first()
133        .ok_or_else(|| PlanError::UnknownLabel {
134            label: label.to_string(),
135            available: available_labels(session),
136        })?
137        .point;
138
139    to_space(&session.monitors, point.x, point.y, space).ok_or(PlanError::PointOffscreen {
140        label: label.to_string(),
141        x: point.x,
142        y: point.y,
143    })
144}
145
146/// `n steps`, or `1 step`.
147///
148/// Small, and worth having in one place: the count appears in the refusal
149/// a human reads before consenting to have their mouse moved, and in what
150/// the MCP tools tell a model. "1 steps" in a safety prompt reads as a
151/// tool that is not paying attention, which is not the impression to give
152/// immediately before asking for consent.
153#[must_use]
154pub fn steps_phrase(count: usize) -> String {
155    if count == 1 {
156        "1 step".to_string()
157    } else {
158        format!("{count} steps")
159    }
160}
161
162fn available_labels(session: &SessionFile) -> String {
163    let labels: Vec<&str> = session
164        .selections
165        .iter()
166        .map(|s| s.label.as_str())
167        .filter(|l| !l.is_empty())
168        .collect();
169    if labels.is_empty() {
170        return "no labeled selections".to_string();
171    }
172    labels.join(", ")
173}
174
175#[cfg(test)]
176mod tests {
177    use pixelcoords_core::geometry::{Point, Rect, Shape, Size, ToolKind};
178    use pixelcoords_core::session::{MonitorRecord, SelectionRecord};
179
180    use super::*;
181    use crate::flow::Flow;
182
183    fn session() -> SessionFile {
184        SessionFile {
185            schema: 1,
186            app: pixelcoords_core::session::AppInfo {
187                name: "pixelcoords".into(),
188                version: "0.1.1".into(),
189            },
190            created_utc: "2026-07-28T00:00:00Z".into(),
191            platform: Some("macos".into()),
192            capture: None,
193            name: None,
194            monitors: vec![
195                MonitorRecord {
196                    index: 0,
197                    name: "built-in".into(),
198                    primary: true,
199                    origin_px: Point::new(0, 0),
200                    size_px: Size::new(3024, 1964),
201                    scale: 2.0,
202                },
203                MonitorRecord {
204                    index: 1,
205                    name: "external".into(),
206                    primary: false,
207                    origin_px: Point::new(3024, 0),
208                    size_px: Size::new(1920, 1080),
209                    scale: 1.0,
210                },
211            ],
212            target: None,
213            selections: vec![
214                SelectionRecord {
215                    shape: ToolKind::Rect,
216                    label: "submit".into(),
217                    monitor: 0,
218                    px: Shape::Rect(Rect::new(800, 400, 100, 80)),
219                    global_px: Shape::Rect(Rect::new(800, 400, 100, 80)),
220                    rot_deg: None,
221                    window_px: None,
222                    crop: "crop-0-submit.png".into(),
223                    color: None,
224                },
225                SelectionRecord {
226                    shape: ToolKind::Rect,
227                    label: "far".into(),
228                    monitor: 1,
229                    px: Shape::Rect(Rect::new(100, 100, 40, 40)),
230                    global_px: Shape::Rect(Rect::new(3124, 100, 40, 40)),
231                    rot_deg: None,
232                    window_px: None,
233                    crop: "crop-1-far.png".into(),
234                    color: None,
235                },
236            ],
237            // Rulers are pixelcoords 0.5.0's; nothing here acts on one.
238            measures: Vec::new(),
239        }
240    }
241
242    fn flow(body: &str) -> Flow {
243        Flow::parse(&format!("session = \"s\"\n{body}")).expect("valid flow")
244    }
245
246    #[test]
247    fn a_click_resolves_to_the_regions_click_point_in_logical_points() {
248        let plan = plan(
249            &flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
250            &session(),
251            Space::Logical,
252        )
253        .expect("planned");
254        let point = plan.steps[0].points[0];
255        // Rect 800,400 100x80 centers at 850,440 physical; /2 on a Retina
256        // monitor.
257        assert!((point.x - 425.0).abs() < f64::EPSILON);
258        assert!((point.y - 220.0).abs() < f64::EPSILON);
259        assert_eq!(point.monitor, 0);
260    }
261
262    #[test]
263    fn a_selection_on_a_second_monitor_uses_that_monitors_scale() {
264        let plan = plan(
265            &flow("[[step]]\naction = \"click\"\ntarget = \"far\"\n"),
266            &session(),
267            Space::Logical,
268        )
269        .expect("planned");
270        let point = plan.steps[0].points[0];
271        // Monitor 1 is 1x: global 3144,120 stays put.
272        assert_eq!(point.monitor, 1);
273        assert!((point.x - 3144.0).abs() < f64::EPSILON);
274    }
275
276    #[test]
277    fn labels_match_case_insensitively_like_the_sister_tool() {
278        assert!(
279            plan(
280                &flow("[[step]]\naction = \"click\"\ntarget = \"SUBMIT\"\n"),
281                &session(),
282                Space::Physical,
283            )
284            .is_ok()
285        );
286    }
287
288    #[test]
289    fn an_unknown_label_fails_planning_and_lists_the_real_ones() {
290        let error = plan(
291            &flow("[[step]]\naction = \"click\"\ntarget = \"nope\"\n"),
292            &session(),
293            Space::Auto,
294        )
295        .expect_err("should refuse");
296        let PlanError::UnknownLabel { available, .. } = &error else {
297            panic!("wrong error: {error}");
298        };
299        assert!(
300            available.contains("submit"),
301            "names the options: {available}"
302        );
303    }
304
305    #[test]
306    fn planning_fails_before_any_step_when_a_later_label_is_missing() {
307        // The first step is fine; the second is not. Planning must refuse
308        // the whole flow rather than half-execute it.
309        let result = plan(
310            &flow(
311                "[[step]]\naction = \"click\"\ntarget = \"submit\"\n\n[[step]]\naction = \"click\"\ntarget = \"ghost\"\n",
312            ),
313            &session(),
314            Space::Auto,
315        );
316        assert!(matches!(result, Err(PlanError::UnknownLabel { .. })));
317    }
318
319    #[test]
320    fn a_drag_resolves_both_ends() {
321        let plan = plan(
322            &flow("[[step]]\naction = \"drag\"\nfrom = \"submit\"\nto = \"far\"\n"),
323            &session(),
324            Space::Physical,
325        )
326        .expect("planned");
327        assert_eq!(plan.steps[0].points.len(), 2);
328        assert_eq!(plan.steps[0].points[0].monitor, 0);
329        assert_eq!(plan.steps[0].points[1].monitor, 1);
330    }
331
332    #[test]
333    fn keyboard_steps_resolve_to_no_points() {
334        let plan = plan(
335            &flow("[[step]]\naction = \"type\"\ntext = \"hi\"\n"),
336            &session(),
337            Space::Auto,
338        )
339        .expect("planned");
340        assert!(plan.steps[0].points.is_empty());
341    }
342
343    #[test]
344    fn a_selection_on_an_undescribed_monitor_is_an_error() {
345        let mut broken = session();
346        broken.selections[0].monitor = 9;
347        let error = plan(
348            &flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
349            &broken,
350            Space::Auto,
351        )
352        .expect_err("should refuse");
353        assert!(matches!(
354            error,
355            PlanError::UnknownMonitor { monitor: 9, .. }
356        ));
357    }
358
359    /// Both `px` and `global_px` move, because a session that pixelcoords
360    /// wrote keeps them in step — `global_px` is the monitor-local shape
361    /// already translated, not an independent field.
362    ///
363    /// That distinction is new. Global answers now come from `global_px`
364    /// via `pixelcoords_core::resolve` instead of being re-derived here as
365    /// `monitor.origin_px + px.click_point()`, so moving only `px` no
366    /// longer moves the answer. Re-deriving what the session already
367    /// states was exactly the reassembly `design/08` wanted gone.
368    #[test]
369    fn a_point_outside_every_monitor_is_refused_rather_than_guessed() {
370        let mut broken = session();
371        // Past the right edge of every described monitor.
372        broken.selections[0].px = Shape::Rect(Rect::new(99_000, 400, 10, 10));
373        broken.selections[0].global_px = Shape::Rect(Rect::new(99_000, 400, 10, 10));
374        let error = plan(
375            &flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
376            &broken,
377            Space::Auto,
378        )
379        .expect_err("should refuse");
380        assert!(matches!(error, PlanError::PointOffscreen { .. }));
381    }
382
383    /// The rounding rule, pinned. A physical coordinate that does not
384    /// divide evenly rounds to the nearest logical point rather than
385    /// truncating toward zero, which is both what
386    /// `pixelcoords resolve --units auto` answers and the more accurate
387    /// of the two once an injector converts to an integer anyway.
388    #[test]
389    fn an_odd_physical_coordinate_rounds_rather_than_truncating() {
390        let mut odd = session();
391        // Height 70 puts the click point at y = 400 + 35 = 435 physical —
392        // odd, so scale 2.0 cannot divide it evenly. x stays even, so only
393        // one axis is under test.
394        odd.selections[0].px = Shape::Rect(Rect::new(800, 400, 100, 70));
395        odd.selections[0].global_px = Shape::Rect(Rect::new(800, 400, 100, 70));
396        let plan = plan(
397            &flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
398            &odd,
399            Space::Logical,
400        )
401        .expect("planned");
402        let point = plan.steps[0].points[0];
403        // 435 / 2.0 = 217.5. Rounds to 218; truncating gave 217.
404        assert!((point.y - 218.0).abs() < f64::EPSILON, "{}", point.y);
405        assert!((point.x - 425.0).abs() < f64::EPSILON, "{}", point.x);
406    }
407
408    /// The refusal read "about to perform 1 steps", and the MCP tools
409    /// answered "1 step(s)" -- one ungrammatical, one a form-letter.
410    #[test]
411    fn one_step_is_singular_and_the_rest_are_not() {
412        assert_eq!(super::steps_phrase(1), "1 step");
413        assert_eq!(super::steps_phrase(0), "0 steps");
414        assert_eq!(super::steps_phrase(2), "2 steps");
415    }
416
417    /// Two selections can carry the same label -- alt-dragging a shape in
418    /// the overlay clones it, label and all. `plan` used to resolve such a
419    /// step to whichever the file listed first and act on it silently,
420    /// which is the "acting blind" pixelcoords refuses when a match is
421    /// ambiguous.
422    #[test]
423    fn a_label_on_two_selections_is_refused_rather_than_guessed() {
424        let mut session = session();
425        let mut twin = session.selections[0].clone();
426        let elsewhere = Shape::Rect(Rect::new(500, 500, 40, 20));
427        twin.px = elsewhere.clone();
428        twin.global_px = elsewhere;
429        session.selections.push(twin);
430
431        let flow = Flow {
432            session: String::new(),
433            settings: crate::flow::Settings::default(),
434            steps: vec![Step::Click {
435                target: "submit".into(),
436            }],
437        };
438        let error = plan(&flow, &session, Space::Auto).expect_err("ambiguous");
439        assert_eq!(
440            error,
441            PlanError::AmbiguousLabel {
442                label: "submit".into(),
443                count: 2,
444            },
445            "{error}"
446        );
447        // The message has to say what to do about it, not just that it
448        // happened -- the fix lives in the other tool.
449        assert!(error.to_string().contains("rename"), "{error}");
450    }
451}