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 #[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
54pub 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
75const NO_DRIFT: &dyn Fn(usize) -> Option<(f64, Delta)> = &|_| None;
78
79fn 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 _ => PlanError::UnknownLabel {
115 label: label.to_string(),
116 available: available_labels(session),
117 },
118 })?;
119
120 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#[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 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 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 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 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 #[test]
369 fn a_point_outside_every_monitor_is_refused_rather_than_guessed() {
370 let mut broken = session();
371 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 #[test]
389 fn an_odd_physical_coordinate_rounds_rather_than_truncating() {
390 let mut odd = session();
391 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 assert!((point.y - 218.0).abs() < f64::EPSILON, "{}", point.y);
405 assert!((point.x - 425.0).abs() < f64::EPSILON, "{}", point.x);
406 }
407
408 #[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 #[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 assert!(error.to_string().contains("rename"), "{error}");
450 }
451}