use sim_kernel::{Diagnostic, Error, Expr, Result, Severity, Symbol};
use sim_lib_intent::{Origin, intent, validate_intent};
use sim_lib_scene::node;
use sim_value::build::{list, map, sym, text};
use crate::contract::Draft;
pub const FOCUS_KEY: &str = "focus";
pub const A11Y_KEY: &str = "a11y";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FocusDir {
Next,
Prev,
}
pub fn with_focus(scene: Expr, focused_id: &str) -> Expr {
sim_value::access::set(&scene, FOCUS_KEY, Expr::Symbol(Symbol::new(focused_id)))
}
pub fn focused_id(scene: &Expr) -> Option<Symbol> {
sim_value::access::field_sym(scene, FOCUS_KEY)
}
pub fn move_focus(scene: &Expr, ids_in_order: &[&str], dir: FocusDir) -> Expr {
let len = ids_in_order.len();
if len == 0 {
return scene.clone();
}
let current = focused_id(scene);
let here = current
.as_ref()
.and_then(|symbol| ids_in_order.iter().position(|id| *id == &*symbol.name));
let next = match (here, dir) {
(Some(index), FocusDir::Next) => (index + 1) % len,
(Some(index), FocusDir::Prev) => (index + len - 1) % len,
(None, FocusDir::Next) => 0,
(None, FocusDir::Prev) => len - 1,
};
with_focus(scene.clone(), ids_in_order[next])
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommandKind {
Invoke,
Ask,
Open,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Command {
pub id: Symbol,
pub label: String,
pub kind: CommandKind,
}
pub fn filter_commands<'a>(commands: &'a [Command], filter: &str) -> Vec<&'a Command> {
let needle = filter.to_lowercase();
commands
.iter()
.filter(|command| command.label.to_lowercase().contains(&needle))
.collect()
}
pub fn palette_scene(commands: &[Command], filter: &str) -> Expr {
let items = filter_commands(commands, filter)
.into_iter()
.map(|command| {
node(
"button",
vec![
("label", text(command.label.clone())),
("command", Expr::Symbol(command.id.clone())),
],
)
})
.collect();
node(
"overlay",
vec![
("role", sym("command-palette")),
("filter", text(filter.to_owned())),
("children", list(items)),
],
)
}
pub fn palette_intent(command: &Command, pane: &str, tick: u64) -> Result<Expr> {
let origin = Origin::human(tick);
let built = match command.kind {
CommandKind::Invoke => intent(
"invoke",
origin,
vec![
("target", text(pane.to_owned())),
("op", Expr::Symbol(command.id.clone())),
("args", list(Vec::new())),
],
),
CommandKind::Ask => intent(
"ask",
origin,
vec![
("mission", text(pane.to_owned())),
("question", text(command.label.clone())),
],
),
CommandKind::Open => intent(
"open",
origin,
vec![
("value", Expr::Symbol(command.id.clone())),
("pane", text(pane.to_owned())),
],
),
};
validate_intent(&built).map_err(|error| {
Error::HostError(format!("palette produced an invalid intent: {error}"))
})?;
Ok(built)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct A11y {
pub role: String,
pub label: String,
pub description: String,
pub urgency: String,
}
pub fn with_a11y(node: Expr, role: &str, label: &str, description: &str, urgency: &str) -> Expr {
let record = map(vec![
("role", text(role.to_owned())),
("label", text(label.to_owned())),
("description", text(description.to_owned())),
("urgency", text(urgency.to_owned())),
]);
sim_value::access::set(&node, A11Y_KEY, record)
}
pub fn a11y_of(node: &Expr) -> Option<A11y> {
let record = sim_value::access::field(node, A11Y_KEY)?;
Some(A11y {
role: sim_value::access::field_str(record, "role")?.to_owned(),
label: sim_value::access::field_str(record, "label")?.to_owned(),
description: sim_value::access::field_str(record, "description")?.to_owned(),
urgency: sim_value::access::field_str(record, "urgency")?.to_owned(),
})
}
pub fn diagnostics_scene(draft: &Draft) -> Expr {
if draft.committable && draft.diagnostics.is_empty() {
return node(
"overlay",
vec![
("role", sym("diagnostics")),
("status", sym("ok")),
("children", list(vec![text_line("no diagnostics")])),
],
);
}
let lines = draft.diagnostics.iter().map(diagnostic_node).collect();
node(
"overlay",
vec![
("role", sym("diagnostics")),
("status", sym("rejected")),
("children", list(lines)),
],
)
}
fn diagnostic_node(diagnostic: &Diagnostic) -> Expr {
let mut entries = vec![
("status", sym(severity_token(diagnostic.severity))),
("label", text(diagnostic.message.clone())),
];
if let Some(code) = &diagnostic.code {
entries.push(("code", Expr::Symbol(code.clone())));
}
node("badge", entries)
}
fn text_line(content: &str) -> Expr {
node("text", vec![("text", text(content.to_owned()))])
}
fn severity_token(severity: Severity) -> &'static str {
match severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
Severity::Note => "note",
}
}
#[cfg(test)]
mod tests {
use super::*;
use sim_lib_intent::intent_kind_of;
use sim_lib_scene::{build::text_node, validate_scene};
fn commands() -> Vec<Command> {
vec![
Command {
id: Symbol::new("run"),
label: "Run validation".to_owned(),
kind: CommandKind::Invoke,
},
Command {
id: Symbol::new("ask-status"),
label: "Ask mission status".to_owned(),
kind: CommandKind::Ask,
},
Command {
id: Symbol::new("open-readme"),
label: "Open README".to_owned(),
kind: CommandKind::Open,
},
]
}
#[test]
fn focus_next_prev_wraps_deterministically() {
let ids = ["a", "b", "c"];
let scene = with_focus(text_node("x"), "a");
assert_eq!(focused_id(&scene).unwrap().name.as_ref(), "a");
let b = move_focus(&scene, &ids, FocusDir::Next);
assert_eq!(focused_id(&b).unwrap().name.as_ref(), "b");
let c = move_focus(&b, &ids, FocusDir::Next);
let wrap = move_focus(&c, &ids, FocusDir::Next);
assert_eq!(focused_id(&wrap).unwrap().name.as_ref(), "a", "next wraps");
let prev_wrap = move_focus(&scene, &ids, FocusDir::Prev);
assert_eq!(
focused_id(&prev_wrap).unwrap().name.as_ref(),
"c",
"prev from first wraps to last"
);
assert_eq!(move_focus(&scene, &ids, FocusDir::Next), b);
}
#[test]
fn move_focus_seeds_and_tolerates_empty() {
let ids = ["a", "b"];
let bare = text_node("x");
assert_eq!(
focused_id(&move_focus(&bare, &ids, FocusDir::Next))
.unwrap()
.name
.as_ref(),
"a"
);
assert_eq!(
focused_id(&move_focus(&bare, &ids, FocusDir::Prev))
.unwrap()
.name
.as_ref(),
"b"
);
assert_eq!(move_focus(&bare, &[], FocusDir::Next), bare);
}
#[test]
fn palette_filters_and_orders_deterministically() {
let commands = commands();
let scene = palette_scene(&commands, "");
validate_scene(&scene).expect("palette overlay validates");
assert_eq!(
button_labels(&scene),
commands.iter().map(|c| c.label.clone()).collect::<Vec<_>>()
);
let filtered = palette_scene(&commands, "OPEN");
assert_eq!(button_labels(&filtered), vec!["Open README".to_owned()]);
let many = palette_scene(&commands, "i");
assert_eq!(
button_labels(&many),
vec!["Run validation".to_owned(), "Ask mission status".to_owned()],
"'i' matches 'Run validation' and 'Ask mission status' in order"
);
assert_eq!(palette_scene(&commands, "i"), many);
}
#[test]
fn every_command_intent_validates() {
for command in commands() {
let produced = palette_intent(&command, "main", 9).expect("command reduces");
validate_intent(&produced).expect("produced intent validates");
let kind = intent_kind_of(&produced).unwrap();
let expected = match command.kind {
CommandKind::Invoke => "invoke",
CommandKind::Ask => "ask",
CommandKind::Open => "open",
};
assert_eq!(kind.name.as_ref(), expected);
}
}
#[test]
fn a11y_round_trips() {
let node = with_a11y(
text_node("Run"),
"button",
"Run validation",
"Runs the mission validation suite",
"polite",
);
validate_scene(&node).expect("a11y-annotated node validates");
let back = a11y_of(&node).expect("a11y reads back");
assert_eq!(
back,
A11y {
role: "button".to_owned(),
label: "Run validation".to_owned(),
description: "Runs the mission validation suite".to_owned(),
urgency: "polite".to_owned(),
}
);
assert!(a11y_of(&text_node("plain")).is_none());
}
#[test]
fn diagnostics_scene_renders_rejected_messages() {
let base = Expr::String("x".to_owned());
let mut draft = Draft::rejected(base.clone(), Diagnostic::error("name is required"));
draft
.diagnostics
.push(Diagnostic::info("value will be truncated"));
let scene = diagnostics_scene(&draft);
validate_scene(&scene).expect("diagnostics overlay validates");
let labels = badge_labels(&scene);
assert_eq!(
labels,
vec![
"name is required".to_owned(),
"value will be truncated".to_owned()
],
"diagnostics render in order"
);
assert_eq!(overlay_status(&scene), Some("rejected".to_owned()));
let clean = Draft::clean(base.clone(), base);
let ok = diagnostics_scene(&clean);
validate_scene(&ok).expect("affirmative overlay validates");
assert_eq!(overlay_status(&ok), Some("ok".to_owned()));
}
fn children(scene: &Expr) -> Vec<Expr> {
match sim_value::access::field(scene, "children") {
Some(Expr::List(items)) => items.clone(),
_ => Vec::new(),
}
}
fn button_labels(scene: &Expr) -> Vec<String> {
children(scene)
.iter()
.filter_map(|child| sim_value::access::field_str(child, "label").map(str::to_owned))
.collect()
}
fn badge_labels(scene: &Expr) -> Vec<String> {
children(scene)
.iter()
.filter_map(|child| sim_value::access::field_str(child, "label").map(str::to_owned))
.collect()
}
fn overlay_status(scene: &Expr) -> Option<String> {
sim_value::access::field_sym(scene, "status").map(|symbol| symbol.name.to_string())
}
}