1use pixelcoords_core::session::SessionFile;
11
12use crate::convert::{ResolvedPoint, Space, to_space};
13use crate::flow::{Flow, Step};
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct PlannedStep {
18 pub index: usize,
19 pub summary: String,
20 pub step: Step,
21 pub points: Vec<ResolvedPoint>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
29pub struct Plan {
30 pub steps: Vec<PlannedStep>,
31}
32
33#[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
46pub 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
67fn 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 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 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 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 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 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}