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}
48
49/// Resolve a flow against a session.
50///
51/// `space` overrides the flow's own setting when a caller needs a
52/// specific space (dry-run reporting, tests).
53pub fn plan(flow: &Flow, session: &SessionFile, space: Space) -> Result<Plan, PlanError> {
54    let mut steps = Vec::with_capacity(flow.steps.len());
55    for (index, step) in flow.steps.iter().enumerate() {
56        let mut points = Vec::new();
57        for label in step.targets() {
58            points.push(resolve_label(session, label, space)?);
59        }
60        steps.push(PlannedStep {
61            index,
62            summary: step.summary(),
63            step: step.clone(),
64            points,
65        });
66    }
67    Ok(Plan { steps })
68}
69
70/// Nothing has relocated when a plan is built — `plan` never captures.
71/// `run` corrects for drift afterwards, against a fresh `find`.
72const NO_DRIFT: &dyn Fn(usize) -> Option<(f64, Delta)> = &|_| None;
73
74/// The point a labeled region will be acted on.
75///
76/// The label lookup, the monitor lookup, the interior click point and the
77/// hop from monitor-local to global coordinates are all
78/// `pixelcoords_core::resolve`'s. `design/08` calls that the seam, and
79/// says why: reassembling it here means this tool can get DPI wrong in a
80/// way the crate that owns the geometry cannot.
81///
82/// **What stays ours is the refusal.** `resolve` answers in the monitor a
83/// selection *claims*; a point that lands in a gap between monitors, or
84/// past the edge of every one, is still something to refuse rather than
85/// guess at — so the containing-monitor check runs on the physical answer
86/// before any conversion, and `to_space` converts against the monitor
87/// that actually holds the point.
88fn resolve_label(
89    session: &SessionFile,
90    label: &str,
91    space: Space,
92) -> Result<ResolvedPoint, PlanError> {
93    let resolved = resolve(
94        session,
95        Some(label),
96        Origin::Global,
97        Resolved::Physical,
98        NO_DRIFT,
99    )
100    .map_err(|error| match error {
101        ResolveError::UnknownMonitor { monitor, .. } => PlanError::UnknownMonitor {
102            label: label.to_string(),
103            monitor,
104        },
105        // NoSelections, UnknownLabel, and the two window-space errors all
106        // mean the same thing to a caller here: that label is not
107        // actionable. Ours names the alternatives; `Origin::Global` never
108        // reaches the window-space pair.
109        _ => PlanError::UnknownLabel {
110            label: label.to_string(),
111            available: available_labels(session),
112        },
113    })?;
114
115    let point = resolved
116        .first()
117        .ok_or_else(|| PlanError::UnknownLabel {
118            label: label.to_string(),
119            available: available_labels(session),
120        })?
121        .point;
122
123    to_space(&session.monitors, point.x, point.y, space).ok_or(PlanError::PointOffscreen {
124        label: label.to_string(),
125        x: point.x,
126        y: point.y,
127    })
128}
129
130fn available_labels(session: &SessionFile) -> String {
131    let labels: Vec<&str> = session
132        .selections
133        .iter()
134        .map(|s| s.label.as_str())
135        .filter(|l| !l.is_empty())
136        .collect();
137    if labels.is_empty() {
138        return "no labeled selections".to_string();
139    }
140    labels.join(", ")
141}
142
143#[cfg(test)]
144mod tests {
145    use pixelcoords_core::geometry::{Point, Size};
146    use pixelcoords_core::geometry::{Rect, Shape, ToolKind};
147    use pixelcoords_core::session::{MonitorRecord, SelectionRecord};
148
149    use super::*;
150    use crate::flow::Flow;
151
152    fn session() -> SessionFile {
153        SessionFile {
154            schema: 1,
155            app: pixelcoords_core::session::AppInfo {
156                name: "pixelcoords".into(),
157                version: "0.1.1".into(),
158            },
159            created_utc: "2026-07-28T00:00:00Z".into(),
160            platform: Some("macos".into()),
161            capture: None,
162            name: None,
163            monitors: vec![
164                MonitorRecord {
165                    index: 0,
166                    name: "built-in".into(),
167                    primary: true,
168                    origin_px: Point::new(0, 0),
169                    size_px: Size::new(3024, 1964),
170                    scale: 2.0,
171                },
172                MonitorRecord {
173                    index: 1,
174                    name: "external".into(),
175                    primary: false,
176                    origin_px: Point::new(3024, 0),
177                    size_px: Size::new(1920, 1080),
178                    scale: 1.0,
179                },
180            ],
181            target: None,
182            selections: vec![
183                SelectionRecord {
184                    shape: ToolKind::Rect,
185                    label: "submit".into(),
186                    monitor: 0,
187                    px: Shape::Rect(Rect::new(800, 400, 100, 80)),
188                    global_px: Shape::Rect(Rect::new(800, 400, 100, 80)),
189                    rot_deg: None,
190                    window_px: None,
191                    crop: "crop-0-submit.png".into(),
192                    color: None,
193                },
194                SelectionRecord {
195                    shape: ToolKind::Rect,
196                    label: "far".into(),
197                    monitor: 1,
198                    px: Shape::Rect(Rect::new(100, 100, 40, 40)),
199                    global_px: Shape::Rect(Rect::new(3124, 100, 40, 40)),
200                    rot_deg: None,
201                    window_px: None,
202                    crop: "crop-1-far.png".into(),
203                    color: None,
204                },
205            ],
206            // Rulers are pixelcoords 0.5.0's; nothing here acts on one.
207            measures: Vec::new(),
208        }
209    }
210
211    fn flow(body: &str) -> Flow {
212        Flow::parse(&format!("session = \"s\"\n{body}")).expect("valid flow")
213    }
214
215    #[test]
216    fn a_click_resolves_to_the_regions_click_point_in_logical_points() {
217        let plan = plan(
218            &flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
219            &session(),
220            Space::Logical,
221        )
222        .expect("planned");
223        let point = plan.steps[0].points[0];
224        // Rect 800,400 100x80 centers at 850,440 physical; /2 on a Retina
225        // monitor.
226        assert!((point.x - 425.0).abs() < f64::EPSILON);
227        assert!((point.y - 220.0).abs() < f64::EPSILON);
228        assert_eq!(point.monitor, 0);
229    }
230
231    #[test]
232    fn a_selection_on_a_second_monitor_uses_that_monitors_scale() {
233        let plan = plan(
234            &flow("[[step]]\naction = \"click\"\ntarget = \"far\"\n"),
235            &session(),
236            Space::Logical,
237        )
238        .expect("planned");
239        let point = plan.steps[0].points[0];
240        // Monitor 1 is 1x: global 3144,120 stays put.
241        assert_eq!(point.monitor, 1);
242        assert!((point.x - 3144.0).abs() < f64::EPSILON);
243    }
244
245    #[test]
246    fn labels_match_case_insensitively_like_the_sister_tool() {
247        assert!(
248            plan(
249                &flow("[[step]]\naction = \"click\"\ntarget = \"SUBMIT\"\n"),
250                &session(),
251                Space::Physical,
252            )
253            .is_ok()
254        );
255    }
256
257    #[test]
258    fn an_unknown_label_fails_planning_and_lists_the_real_ones() {
259        let error = plan(
260            &flow("[[step]]\naction = \"click\"\ntarget = \"nope\"\n"),
261            &session(),
262            Space::Auto,
263        )
264        .expect_err("should refuse");
265        let PlanError::UnknownLabel { available, .. } = &error else {
266            panic!("wrong error: {error}");
267        };
268        assert!(
269            available.contains("submit"),
270            "names the options: {available}"
271        );
272    }
273
274    #[test]
275    fn planning_fails_before_any_step_when_a_later_label_is_missing() {
276        // The first step is fine; the second is not. Planning must refuse
277        // the whole flow rather than half-execute it.
278        let result = plan(
279            &flow(
280                "[[step]]\naction = \"click\"\ntarget = \"submit\"\n\n[[step]]\naction = \"click\"\ntarget = \"ghost\"\n",
281            ),
282            &session(),
283            Space::Auto,
284        );
285        assert!(matches!(result, Err(PlanError::UnknownLabel { .. })));
286    }
287
288    #[test]
289    fn a_drag_resolves_both_ends() {
290        let plan = plan(
291            &flow("[[step]]\naction = \"drag\"\nfrom = \"submit\"\nto = \"far\"\n"),
292            &session(),
293            Space::Physical,
294        )
295        .expect("planned");
296        assert_eq!(plan.steps[0].points.len(), 2);
297        assert_eq!(plan.steps[0].points[0].monitor, 0);
298        assert_eq!(plan.steps[0].points[1].monitor, 1);
299    }
300
301    #[test]
302    fn keyboard_steps_resolve_to_no_points() {
303        let plan = plan(
304            &flow("[[step]]\naction = \"type\"\ntext = \"hi\"\n"),
305            &session(),
306            Space::Auto,
307        )
308        .expect("planned");
309        assert!(plan.steps[0].points.is_empty());
310    }
311
312    #[test]
313    fn a_selection_on_an_undescribed_monitor_is_an_error() {
314        let mut broken = session();
315        broken.selections[0].monitor = 9;
316        let error = plan(
317            &flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
318            &broken,
319            Space::Auto,
320        )
321        .expect_err("should refuse");
322        assert!(matches!(
323            error,
324            PlanError::UnknownMonitor { monitor: 9, .. }
325        ));
326    }
327
328    /// Both `px` and `global_px` move, because a session that pixelcoords
329    /// wrote keeps them in step — `global_px` is the monitor-local shape
330    /// already translated, not an independent field.
331    ///
332    /// That distinction is new. Global answers now come from `global_px`
333    /// via `pixelcoords_core::resolve` instead of being re-derived here as
334    /// `monitor.origin_px + px.click_point()`, so moving only `px` no
335    /// longer moves the answer. Re-deriving what the session already
336    /// states was exactly the reassembly `design/08` wanted gone.
337    #[test]
338    fn a_point_outside_every_monitor_is_refused_rather_than_guessed() {
339        let mut broken = session();
340        // Past the right edge of every described monitor.
341        broken.selections[0].px = Shape::Rect(Rect::new(99_000, 400, 10, 10));
342        broken.selections[0].global_px = Shape::Rect(Rect::new(99_000, 400, 10, 10));
343        let error = plan(
344            &flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
345            &broken,
346            Space::Auto,
347        )
348        .expect_err("should refuse");
349        assert!(matches!(error, PlanError::PointOffscreen { .. }));
350    }
351
352    /// The rounding rule, pinned. A physical coordinate that does not
353    /// divide evenly rounds to the nearest logical point rather than
354    /// truncating toward zero, which is both what
355    /// `pixelcoords resolve --units auto` answers and the more accurate
356    /// of the two once an injector converts to an integer anyway.
357    #[test]
358    fn an_odd_physical_coordinate_rounds_rather_than_truncating() {
359        let mut odd = session();
360        // Height 70 puts the click point at y = 400 + 35 = 435 physical —
361        // odd, so scale 2.0 cannot divide it evenly. x stays even, so only
362        // one axis is under test.
363        odd.selections[0].px = Shape::Rect(Rect::new(800, 400, 100, 70));
364        odd.selections[0].global_px = Shape::Rect(Rect::new(800, 400, 100, 70));
365        let plan = plan(
366            &flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
367            &odd,
368            Space::Logical,
369        )
370        .expect("planned");
371        let point = plan.steps[0].points[0];
372        // 435 / 2.0 = 217.5. Rounds to 218; truncating gave 217.
373        assert!((point.y - 218.0).abs() < f64::EPSILON, "{}", point.y);
374        assert!((point.x - 425.0).abs() < f64::EPSILON, "{}", point.x);
375    }
376}