Skip to main content

sim_lib_view/
palette.rs

1//! Surface-neutral command palette, focus model, accessibility metadata, and
2//! diagnostics presentation (VIEW4.06).
3//!
4//! These are the interaction semantics SHARED by every view surface -- the TUI
5//! (the `sim-view-tty` consumer) and the Web UI alike. The module is
6//! deliberately surface-neutral: it emits ordinary
7//! [`Scene`](sim_lib_scene) values and [`Intent`](sim_lib_intent) values and
8//! reads them back, but it never spells ANSI, DOM, or ARIA. A terminal renderer
9//! turns a focus annotation into a highlight; a browser turns the same
10//! annotation into a `:focus` ring and the [`A11y`] record into ARIA
11//! attributes. The core Scene carries only open metadata, so neither surface's
12//! vocabulary leaks into the shared model.
13//!
14//! Four facilities live here:
15//!
16//! - a [`with_focus`]/[`focused_id`]/[`move_focus`] focus model stored as a
17//!   `focus` metadata field, not a new scene kind;
18//! - a [`Command`] palette: [`palette_scene`] renders a filtered, deterministic
19//!   overlay and [`palette_intent`] reduces a chosen command to a validated
20//!   [`Intent`](sim_lib_intent);
21//! - an [`A11y`] accessibility record attached as an open `a11y` map via
22//!   [`with_a11y`] and read back with [`a11y_of`];
23//! - [`diagnostics_scene`], which presents a rejected [`Draft`]'s diagnostics as
24//!   a deterministic overlay.
25//!
26//! # Example
27//!
28//! ```
29//! use sim_kernel::Symbol;
30//! use sim_lib_view::palette::{Command, CommandKind, palette_intent, palette_scene};
31//!
32//! let cmd = Command {
33//!     id: Symbol::new("run"),
34//!     label: "Run validation".to_owned(),
35//!     kind: CommandKind::Invoke,
36//! };
37//! // The overlay lists the command and validates as a Scene.
38//! let scene = palette_scene(std::slice::from_ref(&cmd), "run");
39//! assert!(sim_lib_scene::validate_scene(&scene).is_ok());
40//! // The chosen command reduces to a validated Intent.
41//! let intent = palette_intent(&cmd, "main", 7).unwrap();
42//! assert!(sim_lib_intent::validate_intent(&intent).is_ok());
43//! ```
44
45use 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
52/// The map key under which the focus model stores the focused node id.
53pub const FOCUS_KEY: &str = "focus";
54
55/// The map key under which [`with_a11y`] stores the accessibility record.
56pub const A11Y_KEY: &str = "a11y";
57
58/// A direction to advance focus in [`move_focus`].
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum FocusDir {
61    /// Advance to the next id in order, wrapping past the end to the first.
62    Next,
63    /// Advance to the previous id in order, wrapping past the start to the last.
64    Prev,
65}
66
67/// Annotates `scene` with `focused_id` as open `focus` metadata.
68///
69/// Focus is a metadata field on the scene node, never a new scene kind: the id
70/// is stored as a bare symbol under [`FOCUS_KEY`], so a surface that does not
71/// understand focus simply ignores it and the scene still validates. An
72/// existing focus annotation is replaced.
73pub 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
77/// Reads the focused node id annotated by [`with_focus`], if any.
78pub fn focused_id(scene: &Expr) -> Option<Symbol> {
79    sim_value::access::field_sym(scene, FOCUS_KEY)
80}
81
82/// Advances focus deterministically through `ids_in_order`, wrapping at the
83/// ends, and returns the re-annotated scene.
84///
85/// The current focus is located in `ids_in_order` and moved one step in `dir`;
86/// the order wraps, so [`FocusDir::Next`] past the last id lands on the first
87/// and [`FocusDir::Prev`] past the first lands on the last. When the scene has
88/// no focus yet (or its focus is not in the list), [`FocusDir::Next`] seeds the
89/// first id and [`FocusDir::Prev`] the last. An empty `ids_in_order` leaves the
90/// scene unchanged.
91pub 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/// How a [`Command`] reduces to an [`Intent`](sim_lib_intent) when chosen.
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum CommandKind {
112    /// Reduces to an `intent/invoke` acting on the active pane.
113    Invoke,
114    /// Reduces to an `intent/ask` posing the command label as a question.
115    Ask,
116    /// Reduces to an `intent/open` opening the command id in the active pane.
117    Open,
118}
119
120/// One palette command: a stable id, a human label, and the kind of Intent it
121/// reduces to.
122#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct Command {
124    /// Stable command id (for example `run` or `open-source`).
125    pub id: Symbol,
126    /// Human-readable label shown in the palette and matched by the filter.
127    pub label: String,
128    /// The kind of Intent [`palette_intent`] produces for this command.
129    pub kind: CommandKind,
130}
131
132/// Selects the commands whose label contains `filter`, case-insensitively,
133/// preserving input order.
134///
135/// An empty `filter` selects every command. This is the one shared predicate
136/// behind both [`palette_scene`] and any surface-side selection, so the TUI and
137/// Web UI filter identically.
138pub 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
146/// Builds a `scene/overlay` listing the commands matching `filter`.
147///
148/// Order is deterministic (input order, filtered by [`filter_commands`]). Each
149/// command becomes a `scene/button` carrying its label and a `command` field
150/// with its id, so a surface can route a click or keypress back to the chosen
151/// [`Command`]. The overlay carries its `role` and the active `filter` as
152/// metadata.
153pub 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
176/// Reduces a chosen [`Command`] to its matching validated Intent for `pane` at
177/// `tick`.
178///
179/// The required fields of each Intent kind are filled from the command and the
180/// active pane: an [`CommandKind::Invoke`] becomes `intent/invoke`
181/// (`target`/`op`/`args`); [`CommandKind::Ask`] becomes `intent/ask`
182/// (`mission`/`question`); [`CommandKind::Open`] becomes `intent/open`
183/// (`value`/`pane`). The result is re-checked with [`validate_intent`] before
184/// it is returned, so a caller never sees a malformed Intent.
185pub 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/// An accessibility record carried as open metadata on a scene node.
221///
222/// The record names a semantic role, an accessible label and description, and
223/// an urgency token. It is intentionally surface-neutral: a browser maps these
224/// to ARIA attributes and a terminal to its own affordances, but neither
225/// vocabulary is stored in the node. Round-trips through [`with_a11y`] /
226/// [`a11y_of`].
227#[derive(Clone, Debug, PartialEq, Eq)]
228pub struct A11y {
229    /// Semantic role token (for example `button`, `alert`, `list`).
230    pub role: String,
231    /// Accessible label (the concise name of the node).
232    pub label: String,
233    /// Longer accessible description, or empty when there is none.
234    pub description: String,
235    /// Urgency token (for example `polite`, `assertive`, `off`).
236    pub urgency: String,
237}
238
239/// Attaches an `a11y` metadata map (role/label/description/urgency) to `node`.
240///
241/// The four values are stored as plain strings under an open `a11y` field. No
242/// ARIA or terminal name is copied into the node: a surface derives its own
243/// affordances from this record. An existing `a11y` field is replaced. Read it
244/// back with [`a11y_of`].
245pub 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
255/// Reads the accessibility record attached by [`with_a11y`], if present and
256/// well-formed.
257///
258/// Returns `None` when the node carries no `a11y` map or the map is missing one
259/// of the four string fields, so a partial record never reads back as valid.
260pub 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
270/// Presents a [`Draft`]'s diagnostics as a deterministic `scene/overlay`.
271///
272/// A committable draft (no diagnostics) yields an affirmative overlay carrying
273/// an `ok` status and a single confirmation line. A rejected draft yields one
274/// `scene/badge` per diagnostic, in order, each tagged with a severity token
275/// and carrying its message; a diagnostic that has a machine-readable `code` is
276/// anchored to it through a `code` field. The overlay's `status` is `rejected`
277/// when any diagnostic is present.
278pub 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
300/// Builds one diagnostic line: a `scene/badge` tagged with the severity token
301/// and carrying the message, anchored to its `code` when one is present.
302fn 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
313/// A `scene/text` line used for the affirmative diagnostics overlay.
314fn text_line(content: &str) -> Expr {
315    node("text", vec![("text", text(content.to_owned()))])
316}
317
318/// The stable severity token a diagnostic badge carries.
319fn 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        // Determinism: same input, same output.
374        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        // Empty id list leaves the scene untouched.
396        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        // Case-insensitive substring filter, order preserved.
410        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        // Deterministic.
421        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        // A node without an a11y field reads back as None.
460        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        // A committable draft yields an affirmative overlay.
484        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}