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::session::SessionFile;
11
12use crate::convert::{ResolvedPoint, Space, to_space};
13use crate::flow::{Flow, Step};
14
15/// One step, resolved to the points it will act on.
16#[derive(Debug, Clone, PartialEq)]
17pub struct PlannedStep {
18    pub index: usize,
19    pub summary: String,
20    pub step: Step,
21    /// Resolved points, in the order the step's targets appear. Empty for
22    /// keyboard steps.
23    pub points: Vec<ResolvedPoint>,
24}
25
26/// A whole flow, resolved. Holding one means every label existed and
27/// every point converted.
28#[derive(Debug, Clone, PartialEq)]
29pub struct Plan {
30    pub steps: Vec<PlannedStep>,
31}
32
33/// Why a flow could not be planned. Each variant names the fix.
34#[derive(Debug, thiserror::Error, PartialEq)]
35pub enum PlanError {
36    #[error("no selection labeled {label:?} in this session — it has: {available}")]
37    UnknownLabel { label: String, available: String },
38    #[error("selection {label:?} sits on monitor {monitor}, which this session does not describe")]
39    UnknownMonitor { label: String, monitor: usize },
40    #[error(
41        "selection {label:?} resolves to ({x}, {y}), which is outside every monitor in this session"
42    )]
43    PointOffscreen { label: String, x: i32, y: i32 },
44}
45
46/// Resolve a flow against a session.
47///
48/// `space` overrides the flow's own setting when a caller needs a
49/// specific space (dry-run reporting, tests).
50pub fn plan(flow: &Flow, session: &SessionFile, space: Space) -> Result<Plan, PlanError> {
51    let mut steps = Vec::with_capacity(flow.steps.len());
52    for (index, step) in flow.steps.iter().enumerate() {
53        let mut points = Vec::new();
54        for label in step.targets() {
55            points.push(resolve_label(session, label, space)?);
56        }
57        steps.push(PlannedStep {
58            index,
59            summary: step.summary(),
60            step: step.clone(),
61            points,
62        });
63    }
64    Ok(Plan { steps })
65}
66
67/// The point a labeled region will be acted on: its click point —
68/// `pixelcoords-core`'s own interior-point logic, never reimplemented
69/// here — translated to global coordinates and converted to `space`.
70fn resolve_label(
71    session: &SessionFile,
72    label: &str,
73    space: Space,
74) -> Result<ResolvedPoint, PlanError> {
75    let selection = session
76        .selections
77        .iter()
78        .find(|s| s.label.eq_ignore_ascii_case(label))
79        .ok_or_else(|| PlanError::UnknownLabel {
80            label: label.to_string(),
81            available: available_labels(session),
82        })?;
83
84    let monitor = session
85        .monitors
86        .iter()
87        .find(|m| m.index == selection.monitor)
88        .ok_or_else(|| PlanError::UnknownMonitor {
89            label: label.to_string(),
90            monitor: selection.monitor,
91        })?;
92
93    // click_point works in the shape's own (monitor-local) space; add the
94    // monitor origin to reach the global desktop grid the conversion and
95    // the input APIs both use.
96    let local = selection.px.click_point();
97    let global_x = monitor.origin_px.x + local.x;
98    let global_y = monitor.origin_px.y + local.y;
99
100    to_space(&session.monitors, global_x, global_y, space).ok_or(PlanError::PointOffscreen {
101        label: label.to_string(),
102        x: global_x,
103        y: global_y,
104    })
105}
106
107fn available_labels(session: &SessionFile) -> String {
108    let labels: Vec<&str> = session
109        .selections
110        .iter()
111        .map(|s| s.label.as_str())
112        .filter(|l| !l.is_empty())
113        .collect();
114    if labels.is_empty() {
115        return "no labeled selections".to_string();
116    }
117    labels.join(", ")
118}
119
120#[cfg(test)]
121mod tests {
122    use pixelcoords_core::geometry::{Point, Size};
123    use pixelcoords_core::geometry::{Rect, Shape, ToolKind};
124    use pixelcoords_core::session::{MonitorRecord, SelectionRecord};
125
126    use super::*;
127    use crate::flow::Flow;
128
129    fn session() -> SessionFile {
130        SessionFile {
131            schema: 1,
132            app: pixelcoords_core::session::AppInfo {
133                name: "pixelcoords".into(),
134                version: "0.1.1".into(),
135            },
136            created_utc: "2026-07-28T00:00:00Z".into(),
137            platform: Some("macos".into()),
138            capture: None,
139            name: None,
140            monitors: vec![
141                MonitorRecord {
142                    index: 0,
143                    name: "built-in".into(),
144                    primary: true,
145                    origin_px: Point::new(0, 0),
146                    size_px: Size::new(3024, 1964),
147                    scale: 2.0,
148                },
149                MonitorRecord {
150                    index: 1,
151                    name: "external".into(),
152                    primary: false,
153                    origin_px: Point::new(3024, 0),
154                    size_px: Size::new(1920, 1080),
155                    scale: 1.0,
156                },
157            ],
158            target: None,
159            selections: vec![
160                SelectionRecord {
161                    shape: ToolKind::Rect,
162                    label: "submit".into(),
163                    monitor: 0,
164                    px: Shape::Rect(Rect::new(800, 400, 100, 80)),
165                    global_px: Shape::Rect(Rect::new(800, 400, 100, 80)),
166                    rot_deg: None,
167                    window_px: None,
168                    crop: "crop-0-submit.png".into(),
169                },
170                SelectionRecord {
171                    shape: ToolKind::Rect,
172                    label: "far".into(),
173                    monitor: 1,
174                    px: Shape::Rect(Rect::new(100, 100, 40, 40)),
175                    global_px: Shape::Rect(Rect::new(3124, 100, 40, 40)),
176                    rot_deg: None,
177                    window_px: None,
178                    crop: "crop-1-far.png".into(),
179                },
180            ],
181        }
182    }
183
184    fn flow(body: &str) -> Flow {
185        Flow::parse(&format!("session = \"s\"\n{body}")).expect("valid flow")
186    }
187
188    #[test]
189    fn a_click_resolves_to_the_regions_click_point_in_logical_points() {
190        let plan = plan(
191            &flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
192            &session(),
193            Space::Logical,
194        )
195        .expect("planned");
196        let point = plan.steps[0].points[0];
197        // Rect 800,400 100x80 centers at 850,440 physical; /2 on a Retina
198        // monitor.
199        assert!((point.x - 425.0).abs() < f64::EPSILON);
200        assert!((point.y - 220.0).abs() < f64::EPSILON);
201        assert_eq!(point.monitor, 0);
202    }
203
204    #[test]
205    fn a_selection_on_a_second_monitor_uses_that_monitors_scale() {
206        let plan = plan(
207            &flow("[[step]]\naction = \"click\"\ntarget = \"far\"\n"),
208            &session(),
209            Space::Logical,
210        )
211        .expect("planned");
212        let point = plan.steps[0].points[0];
213        // Monitor 1 is 1x: global 3144,120 stays put.
214        assert_eq!(point.monitor, 1);
215        assert!((point.x - 3144.0).abs() < f64::EPSILON);
216    }
217
218    #[test]
219    fn labels_match_case_insensitively_like_the_sister_tool() {
220        assert!(
221            plan(
222                &flow("[[step]]\naction = \"click\"\ntarget = \"SUBMIT\"\n"),
223                &session(),
224                Space::Physical,
225            )
226            .is_ok()
227        );
228    }
229
230    #[test]
231    fn an_unknown_label_fails_planning_and_lists_the_real_ones() {
232        let error = plan(
233            &flow("[[step]]\naction = \"click\"\ntarget = \"nope\"\n"),
234            &session(),
235            Space::Auto,
236        )
237        .expect_err("should refuse");
238        let PlanError::UnknownLabel { available, .. } = &error else {
239            panic!("wrong error: {error}");
240        };
241        assert!(
242            available.contains("submit"),
243            "names the options: {available}"
244        );
245    }
246
247    #[test]
248    fn planning_fails_before_any_step_when_a_later_label_is_missing() {
249        // The first step is fine; the second is not. Planning must refuse
250        // the whole flow rather than half-execute it.
251        let result = plan(
252            &flow(
253                "[[step]]\naction = \"click\"\ntarget = \"submit\"\n\n[[step]]\naction = \"click\"\ntarget = \"ghost\"\n",
254            ),
255            &session(),
256            Space::Auto,
257        );
258        assert!(matches!(result, Err(PlanError::UnknownLabel { .. })));
259    }
260
261    #[test]
262    fn a_drag_resolves_both_ends() {
263        let plan = plan(
264            &flow("[[step]]\naction = \"drag\"\nfrom = \"submit\"\nto = \"far\"\n"),
265            &session(),
266            Space::Physical,
267        )
268        .expect("planned");
269        assert_eq!(plan.steps[0].points.len(), 2);
270        assert_eq!(plan.steps[0].points[0].monitor, 0);
271        assert_eq!(plan.steps[0].points[1].monitor, 1);
272    }
273
274    #[test]
275    fn keyboard_steps_resolve_to_no_points() {
276        let plan = plan(
277            &flow("[[step]]\naction = \"type\"\ntext = \"hi\"\n"),
278            &session(),
279            Space::Auto,
280        )
281        .expect("planned");
282        assert!(plan.steps[0].points.is_empty());
283    }
284
285    #[test]
286    fn a_selection_on_an_undescribed_monitor_is_an_error() {
287        let mut broken = session();
288        broken.selections[0].monitor = 9;
289        let error = plan(
290            &flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
291            &broken,
292            Space::Auto,
293        )
294        .expect_err("should refuse");
295        assert!(matches!(
296            error,
297            PlanError::UnknownMonitor { monitor: 9, .. }
298        ));
299    }
300
301    #[test]
302    fn a_point_outside_every_monitor_is_refused_rather_than_guessed() {
303        let mut broken = session();
304        // Move the region past the right edge of every described monitor.
305        broken.selections[0].px = Shape::Rect(Rect::new(99_000, 400, 10, 10));
306        let error = plan(
307            &flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
308            &broken,
309            Space::Auto,
310        )
311        .expect_err("should refuse");
312        assert!(matches!(error, PlanError::PointOffscreen { .. }));
313    }
314}