Skip to main content

sim_lib_web_layout/
palette.rs

1//! The command palette: the keyboard spine of the workspace.
2//!
3//! One entry point searches everything an operator can reach -- open resources,
4//! registry exports, browse/help/test cards, lenses, commands, recent objects,
5//! and saved workspaces -- and opens any result into the appropriate lens.
6//! Opening a card targets its value through the dispatcher (the best lens),
7//! never only the generic card renderer.
8
9use sim_kernel::{Expr, Symbol};
10use sim_lib_intent::{Origin, intent};
11use sim_lib_scene::{node, sym};
12
13use crate::value::panes;
14
15/// What a palette entry points at.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum EntryKind {
18    /// A resource open in the workspace.
19    Resource,
20    /// A registry export (class, codec, shape, ...).
21    Export,
22    /// A browse/help/test card.
23    Card,
24    /// A lens.
25    Lens,
26    /// A runnable command.
27    Command,
28    /// A recently touched object.
29    Recent,
30    /// A saved workspace.
31    Workspace,
32}
33
34impl EntryKind {
35    fn token(self) -> &'static str {
36        match self {
37            EntryKind::Resource => "resource",
38            EntryKind::Export => "export",
39            EntryKind::Card => "card",
40            EntryKind::Lens => "lens",
41            EntryKind::Command => "command",
42            EntryKind::Recent => "recent",
43            EntryKind::Workspace => "workspace",
44        }
45    }
46}
47
48/// One searchable palette entry.
49#[derive(Clone, Debug)]
50pub struct PaletteEntry {
51    /// Stable entry id.
52    pub id: Symbol,
53    /// Human-readable label, matched against the query.
54    pub label: String,
55    /// What kind of thing this entry is.
56    pub kind: EntryKind,
57    /// The target value (a resource ref, lens id, card, command, ...).
58    pub target: Expr,
59}
60
61impl PaletteEntry {
62    /// Build an entry.
63    pub fn new(id: &str, label: &str, kind: EntryKind, target: Expr) -> Self {
64        Self {
65            id: Symbol::new(id),
66            label: label.to_owned(),
67            kind,
68            target,
69        }
70    }
71}
72
73/// A searchable command palette.
74#[derive(Default)]
75pub struct Palette {
76    entries: Vec<PaletteEntry>,
77}
78
79impl Palette {
80    /// An empty palette.
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Add an entry.
86    pub fn add(&mut self, entry: PaletteEntry) {
87        self.entries.push(entry);
88    }
89
90    /// Add a resource entry per open pane in a workspace.
91    pub fn add_workspace_resources(&mut self, workspace: &Expr) {
92        for pane in panes(workspace) {
93            if let Some(Expr::Symbol(id)) = crate::value::get(&pane, "id") {
94                let resource = crate::value::get(&pane, "resource")
95                    .cloned()
96                    .unwrap_or(Expr::Nil);
97                self.add(PaletteEntry::new(
98                    &id.name,
99                    &id.name,
100                    EntryKind::Resource,
101                    resource,
102                ));
103            }
104        }
105    }
106
107    /// All entries.
108    pub fn entries(&self) -> &[PaletteEntry] {
109        &self.entries
110    }
111
112    /// Search the palette, returning matching entries best first.
113    pub fn search(&self, query: &str) -> Vec<&PaletteEntry> {
114        let query = query.to_lowercase();
115        let mut scored: Vec<(i32, usize, &PaletteEntry)> = self
116            .entries
117            .iter()
118            .enumerate()
119            .filter_map(|(index, entry)| {
120                match_score(&query, &entry.label.to_lowercase()).map(|score| (score, index, entry))
121            })
122            .collect();
123        scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
124        scored.into_iter().map(|(_, _, entry)| entry).collect()
125    }
126
127    /// Render the palette (query field plus the result list) as a Scene.
128    pub fn scene(&self, query: &str) -> Expr {
129        let results = self
130            .search(query)
131            .into_iter()
132            .map(|entry| {
133                node(
134                    "button",
135                    vec![
136                        ("control", Expr::Symbol(entry.id.clone())),
137                        (
138                            "label",
139                            Expr::String(format!("{}  [{}]", entry.label, entry.kind.token())),
140                        ),
141                        ("target", entry.target.clone()),
142                    ],
143                )
144            })
145            .collect();
146        node(
147            "box",
148            vec![
149                ("role", sym("palette")),
150                (
151                    "children",
152                    Expr::List(vec![
153                        node(
154                            "field",
155                            vec![
156                                ("datatype", sym("search")),
157                                ("value", Expr::String(query.to_owned())),
158                            ],
159                        ),
160                        node(
161                            "stack",
162                            vec![("dir", sym("column")), ("children", Expr::List(results))],
163                        ),
164                    ]),
165                ),
166            ],
167        )
168    }
169
170    /// Build the Intent that opens an entry into a pane: resources, cards, and
171    /// recents open the value (the dispatcher picks the best lens); a lens entry
172    /// switches the active lens; a command invokes.
173    pub fn open_entry(&self, entry: &PaletteEntry, pane: &str) -> Expr {
174        match entry.kind {
175            EntryKind::Lens => intent(
176                "set-lens",
177                Origin::human(0),
178                vec![("pane", sym(pane)), ("lens", entry.target.clone())],
179            ),
180            EntryKind::Command => intent(
181                "invoke",
182                Origin::human(0),
183                vec![
184                    ("target", entry.target.clone()),
185                    ("op", Expr::Symbol(entry.id.clone())),
186                    ("args", Expr::Map(Vec::new())),
187                ],
188            ),
189            _ => intent(
190                "open",
191                Origin::human(0),
192                vec![("value", entry.target.clone()), ("pane", sym(pane))],
193            ),
194        }
195    }
196}
197
198/// The target value a browse/help/test card points at, if any.
199pub fn card_target(card: &Expr) -> Option<Expr> {
200    for name in ["target", "subject", "ref", "value"] {
201        if let Some(value) = crate::value::get(card, name) {
202            return Some(value.clone());
203        }
204    }
205    None
206}
207
208/// Open a card into the best lens for its target: an `intent/open` of the card's
209/// target, dispatched to the right lens rather than the generic card renderer.
210pub fn open_card(card: &Expr, pane: &str) -> Option<Expr> {
211    let target = card_target(card)?;
212    Some(intent(
213        "open",
214        Origin::human(0),
215        vec![("value", target), ("pane", sym(pane))],
216    ))
217}
218
219/// Score a query against a label: higher is better, `None` excludes.
220fn match_score(query: &str, label: &str) -> Option<i32> {
221    if query.is_empty() {
222        return Some(0);
223    }
224    if let Some(index) = label.find(query) {
225        // Contiguous substring: best, earlier is better.
226        return Some(1000 - index as i32);
227    }
228    // Subsequence fallback.
229    if is_subsequence(query, label) {
230        Some(100)
231    } else {
232        None
233    }
234}
235
236fn is_subsequence(query: &str, label: &str) -> bool {
237    let mut chars = label.chars();
238    query.chars().all(|needle| chars.any(|c| c == needle))
239}