1use sim_kernel::{Diagnostic, Error, Expr, Result, Severity, Symbol};
46use sim_lib_intent::{Origin, intent, validate_intent};
47use sim_lib_scene::node;
48use sim_value::build::{list, map, sym, text};
49
50use crate::contract::Draft;
51
52pub const FOCUS_KEY: &str = "focus";
54
55pub const A11Y_KEY: &str = "a11y";
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum FocusDir {
61 Next,
63 Prev,
65}
66
67pub fn with_focus(scene: Expr, focused_id: &str) -> Expr {
74 sim_value::access::set(&scene, FOCUS_KEY, Expr::Symbol(Symbol::new(focused_id)))
75}
76
77pub fn focused_id(scene: &Expr) -> Option<Symbol> {
79 sim_value::access::field_sym(scene, FOCUS_KEY)
80}
81
82pub fn move_focus(scene: &Expr, ids_in_order: &[&str], dir: FocusDir) -> Expr {
92 let len = ids_in_order.len();
93 if len == 0 {
94 return scene.clone();
95 }
96 let current = focused_id(scene);
97 let here = current
98 .as_ref()
99 .and_then(|symbol| ids_in_order.iter().position(|id| *id == &*symbol.name));
100 let next = match (here, dir) {
101 (Some(index), FocusDir::Next) => (index + 1) % len,
102 (Some(index), FocusDir::Prev) => (index + len - 1) % len,
103 (None, FocusDir::Next) => 0,
104 (None, FocusDir::Prev) => len - 1,
105 };
106 with_focus(scene.clone(), ids_in_order[next])
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum CommandKind {
112 Invoke,
114 Ask,
116 Open,
118}
119
120#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct Command {
124 pub id: Symbol,
126 pub label: String,
128 pub kind: CommandKind,
130}
131
132pub fn filter_commands<'a>(commands: &'a [Command], filter: &str) -> Vec<&'a Command> {
139 let needle = filter.to_lowercase();
140 commands
141 .iter()
142 .filter(|command| command.label.to_lowercase().contains(&needle))
143 .collect()
144}
145
146pub fn palette_scene(commands: &[Command], filter: &str) -> Expr {
154 let items = filter_commands(commands, filter)
155 .into_iter()
156 .map(|command| {
157 node(
158 "button",
159 vec![
160 ("label", text(command.label.clone())),
161 ("command", Expr::Symbol(command.id.clone())),
162 ],
163 )
164 })
165 .collect();
166 node(
167 "overlay",
168 vec![
169 ("role", sym("command-palette")),
170 ("filter", text(filter.to_owned())),
171 ("children", list(items)),
172 ],
173 )
174}
175
176pub fn palette_intent(command: &Command, pane: &str, tick: u64) -> Result<Expr> {
186 let origin = Origin::human(tick);
187 let built = match command.kind {
188 CommandKind::Invoke => intent(
189 "invoke",
190 origin,
191 vec![
192 ("target", text(pane.to_owned())),
193 ("op", Expr::Symbol(command.id.clone())),
194 ("args", list(Vec::new())),
195 ],
196 ),
197 CommandKind::Ask => intent(
198 "ask",
199 origin,
200 vec![
201 ("mission", text(pane.to_owned())),
202 ("question", text(command.label.clone())),
203 ],
204 ),
205 CommandKind::Open => intent(
206 "open",
207 origin,
208 vec![
209 ("value", Expr::Symbol(command.id.clone())),
210 ("pane", text(pane.to_owned())),
211 ],
212 ),
213 };
214 validate_intent(&built).map_err(|error| {
215 Error::HostError(format!("palette produced an invalid intent: {error}"))
216 })?;
217 Ok(built)
218}
219
220#[derive(Clone, Debug, PartialEq, Eq)]
228pub struct A11y {
229 pub role: String,
231 pub label: String,
233 pub description: String,
235 pub urgency: String,
237}
238
239pub fn with_a11y(node: Expr, role: &str, label: &str, description: &str, urgency: &str) -> Expr {
246 let record = map(vec![
247 ("role", text(role.to_owned())),
248 ("label", text(label.to_owned())),
249 ("description", text(description.to_owned())),
250 ("urgency", text(urgency.to_owned())),
251 ]);
252 sim_value::access::set(&node, A11Y_KEY, record)
253}
254
255pub fn a11y_of(node: &Expr) -> Option<A11y> {
261 let record = sim_value::access::field(node, A11Y_KEY)?;
262 Some(A11y {
263 role: sim_value::access::field_str(record, "role")?.to_owned(),
264 label: sim_value::access::field_str(record, "label")?.to_owned(),
265 description: sim_value::access::field_str(record, "description")?.to_owned(),
266 urgency: sim_value::access::field_str(record, "urgency")?.to_owned(),
267 })
268}
269
270pub fn diagnostics_scene(draft: &Draft) -> Expr {
279 if draft.committable && draft.diagnostics.is_empty() {
280 return node(
281 "overlay",
282 vec![
283 ("role", sym("diagnostics")),
284 ("status", sym("ok")),
285 ("children", list(vec![text_line("no diagnostics")])),
286 ],
287 );
288 }
289 let lines = draft.diagnostics.iter().map(diagnostic_node).collect();
290 node(
291 "overlay",
292 vec![
293 ("role", sym("diagnostics")),
294 ("status", sym("rejected")),
295 ("children", list(lines)),
296 ],
297 )
298}
299
300fn diagnostic_node(diagnostic: &Diagnostic) -> Expr {
303 let mut entries = vec![
304 ("status", sym(severity_token(diagnostic.severity))),
305 ("label", text(diagnostic.message.clone())),
306 ];
307 if let Some(code) = &diagnostic.code {
308 entries.push(("code", Expr::Symbol(code.clone())));
309 }
310 node("badge", entries)
311}
312
313fn text_line(content: &str) -> Expr {
315 node("text", vec![("text", text(content.to_owned()))])
316}
317
318fn severity_token(severity: Severity) -> &'static str {
320 match severity {
321 Severity::Error => "error",
322 Severity::Warning => "warning",
323 Severity::Info => "info",
324 Severity::Note => "note",
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use sim_lib_intent::intent_kind_of;
332 use sim_lib_scene::{build::text_node, validate_scene};
333
334 fn commands() -> Vec<Command> {
335 vec![
336 Command {
337 id: Symbol::new("run"),
338 label: "Run validation".to_owned(),
339 kind: CommandKind::Invoke,
340 },
341 Command {
342 id: Symbol::new("ask-status"),
343 label: "Ask mission status".to_owned(),
344 kind: CommandKind::Ask,
345 },
346 Command {
347 id: Symbol::new("open-readme"),
348 label: "Open README".to_owned(),
349 kind: CommandKind::Open,
350 },
351 ]
352 }
353
354 #[test]
355 fn focus_next_prev_wraps_deterministically() {
356 let ids = ["a", "b", "c"];
357 let scene = with_focus(text_node("x"), "a");
358 assert_eq!(focused_id(&scene).unwrap().name.as_ref(), "a");
359
360 let b = move_focus(&scene, &ids, FocusDir::Next);
361 assert_eq!(focused_id(&b).unwrap().name.as_ref(), "b");
362 let c = move_focus(&b, &ids, FocusDir::Next);
363 let wrap = move_focus(&c, &ids, FocusDir::Next);
364 assert_eq!(focused_id(&wrap).unwrap().name.as_ref(), "a", "next wraps");
365
366 let prev_wrap = move_focus(&scene, &ids, FocusDir::Prev);
367 assert_eq!(
368 focused_id(&prev_wrap).unwrap().name.as_ref(),
369 "c",
370 "prev from first wraps to last"
371 );
372
373 assert_eq!(move_focus(&scene, &ids, FocusDir::Next), b);
375 }
376
377 #[test]
378 fn move_focus_seeds_and_tolerates_empty() {
379 let ids = ["a", "b"];
380 let bare = text_node("x");
381 assert_eq!(
382 focused_id(&move_focus(&bare, &ids, FocusDir::Next))
383 .unwrap()
384 .name
385 .as_ref(),
386 "a"
387 );
388 assert_eq!(
389 focused_id(&move_focus(&bare, &ids, FocusDir::Prev))
390 .unwrap()
391 .name
392 .as_ref(),
393 "b"
394 );
395 assert_eq!(move_focus(&bare, &[], FocusDir::Next), bare);
397 }
398
399 #[test]
400 fn palette_filters_and_orders_deterministically() {
401 let commands = commands();
402 let scene = palette_scene(&commands, "");
403 validate_scene(&scene).expect("palette overlay validates");
404 assert_eq!(
405 button_labels(&scene),
406 commands.iter().map(|c| c.label.clone()).collect::<Vec<_>>()
407 );
408
409 let filtered = palette_scene(&commands, "OPEN");
411 assert_eq!(button_labels(&filtered), vec!["Open README".to_owned()]);
412
413 let many = palette_scene(&commands, "i");
414 assert_eq!(
415 button_labels(&many),
416 vec!["Run validation".to_owned(), "Ask mission status".to_owned()],
417 "'i' matches 'Run validation' and 'Ask mission status' in order"
418 );
419
420 assert_eq!(palette_scene(&commands, "i"), many);
422 }
423
424 #[test]
425 fn every_command_intent_validates() {
426 for command in commands() {
427 let produced = palette_intent(&command, "main", 9).expect("command reduces");
428 validate_intent(&produced).expect("produced intent validates");
429 let kind = intent_kind_of(&produced).unwrap();
430 let expected = match command.kind {
431 CommandKind::Invoke => "invoke",
432 CommandKind::Ask => "ask",
433 CommandKind::Open => "open",
434 };
435 assert_eq!(kind.name.as_ref(), expected);
436 }
437 }
438
439 #[test]
440 fn a11y_round_trips() {
441 let node = with_a11y(
442 text_node("Run"),
443 "button",
444 "Run validation",
445 "Runs the mission validation suite",
446 "polite",
447 );
448 validate_scene(&node).expect("a11y-annotated node validates");
449 let back = a11y_of(&node).expect("a11y reads back");
450 assert_eq!(
451 back,
452 A11y {
453 role: "button".to_owned(),
454 label: "Run validation".to_owned(),
455 description: "Runs the mission validation suite".to_owned(),
456 urgency: "polite".to_owned(),
457 }
458 );
459 assert!(a11y_of(&text_node("plain")).is_none());
461 }
462
463 #[test]
464 fn diagnostics_scene_renders_rejected_messages() {
465 let base = Expr::String("x".to_owned());
466 let mut draft = Draft::rejected(base.clone(), Diagnostic::error("name is required"));
467 draft
468 .diagnostics
469 .push(Diagnostic::info("value will be truncated"));
470 let scene = diagnostics_scene(&draft);
471 validate_scene(&scene).expect("diagnostics overlay validates");
472 let labels = badge_labels(&scene);
473 assert_eq!(
474 labels,
475 vec![
476 "name is required".to_owned(),
477 "value will be truncated".to_owned()
478 ],
479 "diagnostics render in order"
480 );
481 assert_eq!(overlay_status(&scene), Some("rejected".to_owned()));
482
483 let clean = Draft::clean(base.clone(), base);
485 let ok = diagnostics_scene(&clean);
486 validate_scene(&ok).expect("affirmative overlay validates");
487 assert_eq!(overlay_status(&ok), Some("ok".to_owned()));
488 }
489
490 fn children(scene: &Expr) -> Vec<Expr> {
491 match sim_value::access::field(scene, "children") {
492 Some(Expr::List(items)) => items.clone(),
493 _ => Vec::new(),
494 }
495 }
496
497 fn button_labels(scene: &Expr) -> Vec<String> {
498 children(scene)
499 .iter()
500 .filter_map(|child| sim_value::access::field_str(child, "label").map(str::to_owned))
501 .collect()
502 }
503
504 fn badge_labels(scene: &Expr) -> Vec<String> {
505 children(scene)
506 .iter()
507 .filter_map(|child| sim_value::access::field_str(child, "label").map(str::to_owned))
508 .collect()
509 }
510
511 fn overlay_status(scene: &Expr) -> Option<String> {
512 sim_value::access::field_sym(scene, "status").map(|symbol| symbol.name.to_string())
513 }
514}