1use 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#[derive(Debug, Clone, PartialEq)]
20pub struct PlannedStep {
21 pub index: usize,
22 pub summary: String,
23 pub step: Step,
24 pub points: Vec<ResolvedPoint>,
27}
28
29#[derive(Debug, Clone, PartialEq)]
32pub struct Plan {
33 pub steps: Vec<PlannedStep>,
34}
35
36#[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
49pub 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
70const NO_DRIFT: &dyn Fn(usize) -> Option<(f64, Delta)> = &|_| None;
73
74fn 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 _ => 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 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 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 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 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 #[test]
338 fn a_point_outside_every_monitor_is_refused_rather_than_guessed() {
339 let mut broken = session();
340 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 #[test]
358 fn an_odd_physical_coordinate_rounds_rather_than_truncating() {
359 let mut odd = session();
360 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 assert!((point.y - 218.0).abs() < f64::EPSILON, "{}", point.y);
374 assert!((point.x - 425.0).abs() < f64::EPSILON, "{}", point.x);
375 }
376}