Skip to main content

zeyn/
command.rs

1//! Commands: things a user runs from the command palette, a menu bar or a keyboard shortcut.
2//!
3//! A command is declared once in Rust with its label, its shortcut and what it does. Register it
4//! on a feature with [`crate::Feature::command`] so the palette lists it and its shortcut works on
5//! every page, and place the same command in a [`crate::ui::MenuBar`]. Commands the user may not
6//! run never reach the HTML.
7//!
8//! ```
9//! use zeyn::prelude::*;
10//!
11//! #[params]
12//! pub struct OrderParams {
13//!     pub id: Option<i64>,
14//! }
15//!
16//! #[screen(permission = "sales.order.write")]
17//! pub fn order_form(p: OrderParams) -> Screen {
18//!     Screen::window("Order")
19//! }
20//!
21//! fn new_order() -> Command {
22//!     Command::open("sales.new_order", "New order", order_form, OrderParams { id: None }).shortcut("Ctrl+Alt+N")
23//! }
24//!
25//! let feature = Feature::new("sales").screen(order_form).command(new_order());
26//! assert!(feature.check().is_ok());
27//! ```
28//!
29//! Params of the wrong type do not compile:
30//!
31//! ```compile_fail
32//! use zeyn::prelude::*;
33//!
34//! #[params]
35//! pub struct OrderParams {
36//!     pub id: Option<i64>,
37//! }
38//!
39//! #[screen(permission = "sales.order.write")]
40//! pub fn order_form(p: OrderParams) -> Screen {
41//!     Screen::window("Order")
42//! }
43//!
44//! fn new_order() -> Command {
45//!     Command::open("sales.new_order", "New order", order_form, 7)
46//! }
47//! ```
48//!
49//! An action command whose action does not return `zeyn::Result<T>` does not compile:
50//!
51//! ```compile_fail
52//! use zeyn::prelude::*;
53//!
54//! #[action(permission = "sales.order.write")]
55//! pub async fn count_orders(_: ()) -> usize { 0 }
56//!
57//! fn count() -> Command {
58//!     Command::action("sales.count", "Count orders", count_orders, ())
59//! }
60//! ```
61
62use crate::action::{Access, Action, ActionMeta};
63use crate::ctx::{BoxFuture, Ctx, Outcome};
64use crate::i18n::Text;
65use crate::render::{Render, attr, escape};
66use crate::screen::{Effect, Icon, RowCall, ScreenDef, Window};
67use serde_json::{Value as Json, json};
68use std::sync::Arc;
69
70/// The default shortcut of the command palette. It is free in every common browser; apps can
71/// choose another with `App::palette_shortcut`.
72pub const PALETTE_SHORTCUT: &str = "Alt+Shift+P";
73
74/// Shortcuts browsers keep for themselves and never pass to a page: new window, new tab, close
75/// tab, switch tab, quit. A command or button with one of them would never run.
76pub const BROWSER_RESERVED: &[&str] = &[
77    "Control+N",
78    "Control+Shift+N",
79    "Control+T",
80    "Control+Shift+T",
81    "Control+W",
82    "Control+Shift+W",
83    "Control+Tab",
84    "Control+Shift+Tab",
85    "Alt+F4",
86    "Meta+N",
87    "Meta+Shift+N",
88    "Meta+T",
89    "Meta+Shift+T",
90    "Meta+W",
91    "Meta+Shift+W",
92    "Meta+Q",
93];
94
95/// Shortcuts a page can take over, but that people expect to do their browser's or the text
96/// field's job: print, save, find, address bar, history, downloads, reload, zoom, developer
97/// tools, copy and paste. Zeyn never binds them unless a command or button opts in with
98/// `override_browser()`.
99pub const BROWSER_DEFAULTS: &[&str] = &[
100    "Control+A",
101    "Control+C",
102    "Control+D",
103    "Control+E",
104    "Control+F",
105    "Control+G",
106    "Control+H",
107    "Control+J",
108    "Control+K",
109    "Control+L",
110    "Control+O",
111    "Control+P",
112    "Control+R",
113    "Control+S",
114    "Control+U",
115    "Control+V",
116    "Control+X",
117    "Control+Y",
118    "Control+Z",
119    "Control+Shift+A",
120    "Control+Shift+B",
121    "Control+Shift+C",
122    "Control+Shift+Delete",
123    "Control+Shift+E",
124    "Control+Shift+G",
125    "Control+Shift+I",
126    "Control+Shift+J",
127    "Control+Shift+K",
128    "Control+Shift+M",
129    "Control+Shift+O",
130    "Control+Shift+P",
131    "Control+Shift+R",
132    "Control+Shift+Z",
133    "Control+=",
134    "Control+-",
135    "Control+0",
136    "Control+1",
137    "Control+2",
138    "Control+3",
139    "Control+4",
140    "Control+5",
141    "Control+6",
142    "Control+7",
143    "Control+8",
144    "Control+9",
145    "Alt+D",
146    "Alt+Home",
147    "Alt+ArrowLeft",
148    "Alt+ArrowRight",
149    "F1",
150    "F3",
151    "F5",
152    "F6",
153    "F7",
154    "F10",
155    "F11",
156    "F12",
157    "Shift+F5",
158    "Control+F5",
159];
160
161/// How a shortcut collides with the browser.
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub enum BrowserShortcut {
164    /// The browser never passes it to the page.
165    Reserved,
166    /// The page can take it over, but only on purpose.
167    Default,
168}
169
170/// Whether a normalized shortcut (see [`normalize_shortcut`]) belongs to the browser.
171///
172/// ```
173/// use zeyn::{BrowserShortcut, browser_shortcut};
174/// assert_eq!(browser_shortcut("Control+N"), Some(BrowserShortcut::Reserved));
175/// assert_eq!(browser_shortcut("Control+P"), Some(BrowserShortcut::Default));
176/// assert_eq!(browser_shortcut("Alt+Shift+P"), None);
177/// ```
178pub fn browser_shortcut(keys: &str) -> Option<BrowserShortcut> {
179    if BROWSER_RESERVED.contains(&keys) {
180        Some(BrowserShortcut::Reserved)
181    } else if BROWSER_DEFAULTS.contains(&keys) {
182        Some(BrowserShortcut::Default)
183    } else {
184        None
185    }
186}
187
188/// The shortcut to bind, or `None` with the reason in the log when the browser keeps it.
189pub(crate) fn usable_shortcut(raw: &str, override_browser: bool) -> Option<String> {
190    let keys = normalize_shortcut(raw)?;
191    match browser_shortcut(&keys) {
192        Some(BrowserShortcut::Reserved) => {
193            tracing::warn!(shortcut = %keys, "the browser keeps this shortcut and never passes it to the page; it is not bound");
194            None
195        }
196        Some(BrowserShortcut::Default) if !override_browser => {
197            tracing::warn!(shortcut = %keys, "this shortcut belongs to the browser; call override_browser() to take it over");
198            None
199        }
200        _ => Some(keys),
201    }
202}
203
204type Run = Arc<dyn Fn(Ctx) -> BoxFuture<crate::action::Result<()>> + Send + Sync>;
205
206#[derive(Clone)]
207enum Kind {
208    Effect(Effect),
209    Action { meta: ActionMeta, run: Run },
210}
211
212/// A command the user can run from the palette, a menu bar or its keyboard shortcut.
213///
214/// `id` names the command in the HTML and in `POST /_zeyn/command`. Use lower case letters,
215/// digits, dots, dashes and underscores, such as `sales.new_order`. It must be unique in the app.
216#[derive(Clone)]
217pub struct Command {
218    id: &'static str,
219    label: Text,
220    shortcut: Option<String>,
221    override_browser: bool,
222    icon: Option<Icon>,
223    access: Access,
224    kind: Kind,
225}
226
227impl Command {
228    /// Opens a screen as a window. Only users who may open the screen see the command.
229    pub fn open<S: ScreenDef>(
230        id: &'static str,
231        label: impl Into<Text>,
232        screen: S,
233        params: S::Params,
234    ) -> Self {
235        Self::with(
236            id,
237            label.into(),
238            S::META.access,
239            Kind::Effect(Window::open(screen, params)),
240        )
241    }
242
243    /// Goes to a screen. Only users who may open the screen see the command.
244    pub fn navigate<S: ScreenDef>(
245        id: &'static str,
246        label: impl Into<Text>,
247        _screen: S,
248        params: S::Params,
249    ) -> Self {
250        let params = serde_json::to_value(&params).expect("screen params must serialize to JSON");
251        Self::with(
252            id,
253            label.into(),
254            S::META.access,
255            Kind::Effect(Effect::Navigate {
256                screen: S::META.name,
257                params,
258            }),
259        )
260    }
261
262    /// Runs an effect in the browser, such as a toast. Every signed in user of the feature sees it.
263    pub fn effect(id: &'static str, label: impl Into<Text>, effect: Effect) -> Self {
264        Self::with(id, label.into(), Access::Public, Kind::Effect(effect))
265    }
266
267    /// Runs `action` with `input` on the server. The input never travels to the browser: the
268    /// browser names the command and the server runs it after checking the action's permission,
269    /// the feature's guards and the interceptors.
270    pub fn action<A>(id: &'static str, label: impl Into<Text>, _action: A, input: A::Input) -> Self
271    where
272        A: Action,
273        A::Input: Clone + Sync,
274        A::Output: Outcome,
275    {
276        let run: Run = Arc::new(move |ctx| {
277            let input = input.clone();
278            Box::pin(async move {
279                let output = A::call(ctx, input).await?;
280                output.failure().map_or(Ok(()), Err)
281            })
282        });
283        Self::with(
284            id,
285            label.into(),
286            A::META.access,
287            Kind::Action { meta: A::META, run },
288        )
289    }
290
291    fn with(id: &'static str, label: Text, access: Access, kind: Kind) -> Self {
292        Self {
293            id,
294            label,
295            shortcut: None,
296            override_browser: false,
297            icon: None,
298            access,
299            kind,
300        }
301    }
302
303    /// Keyboard shortcut such as `"Ctrl+Shift+O"` or `"Alt+F2"`. `App::check` reports shortcuts
304    /// that cannot be read, that two commands share or that the palette keeps for itself.
305    pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
306        self.shortcut = Some(keys.into());
307        self
308    }
309
310    /// Lets the shortcut take over one the browser uses (see [`BROWSER_DEFAULTS`]), such as
311    /// `Ctrl+P`. Shortcuts the browser never passes on ([`BROWSER_RESERVED`]) stay refused.
312    pub fn override_browser(mut self) -> Self {
313        self.override_browser = true;
314        self
315    }
316
317    pub fn icon(mut self, icon: Icon) -> Self {
318        self.icon = Some(icon);
319        self
320    }
321
322    pub fn id(&self) -> &'static str {
323        self.id
324    }
325
326    pub fn label(&self) -> &Text {
327        &self.label
328    }
329
330    pub fn icon_of(&self) -> Option<Icon> {
331        self.icon
332    }
333
334    /// The shortcut as declared.
335    pub fn keys(&self) -> Option<&str> {
336        self.shortcut.as_deref()
337    }
338
339    /// Who may run the command: the screen's access for `open` and `navigate`, the action's for
340    /// `action`.
341    pub fn access(&self) -> Access {
342        self.access
343    }
344
345    /// The call of an action command, ready to run; `None` for effect commands.
346    pub fn call(&self) -> Option<RowCall> {
347        match &self.kind {
348            Kind::Action { meta, run } => {
349                let run = run.clone();
350                Some(RowCall {
351                    meta: *meta,
352                    run: Box::new(move |ctx| run(ctx)),
353                })
354            }
355            Kind::Effect(_) => None,
356        }
357    }
358
359    /// Attributes that make an element run the command: `data-zeyn-effect` or
360    /// `data-zeyn-command-run`, and `aria-keyshortcuts` with the normalized shortcut.
361    pub(crate) fn attrs(&self, r: &Render) -> String {
362        let mut out = format!(r#" data-zeyn-command="{}""#, escape(self.id));
363        match &self.kind {
364            Kind::Effect(e) => {
365                out.push_str(&format!(r#" data-zeyn-effect="{}""#, attr(&e.to_json(r))))
366            }
367            Kind::Action { .. } => {
368                out.push_str(&format!(r#" data-zeyn-command-run="{}""#, escape(self.id)))
369            }
370        }
371        if let Some(keys) = self
372            .shortcut
373            .as_deref()
374            .and_then(|s| usable_shortcut(s, self.override_browser))
375        {
376            out.push_str(&format!(r#" aria-keyshortcuts="{}""#, escape(&keys)));
377        }
378        out
379    }
380
381    /// The shortcut as shown to users, such as `Ctrl+Shift+O`.
382    pub(crate) fn hint(&self) -> Option<String> {
383        self.shortcut
384            .as_deref()
385            .and_then(normalize_shortcut)
386            .map(|k| k.replace("Control", "Ctrl"))
387    }
388
389    /// A hidden button that runs the command, placed in the page shell for the palette and for
390    /// the shortcut.
391    pub(crate) fn button(&self, r: &Render) -> String {
392        let hint = self
393            .hint()
394            .map(|h| format!(r#" data-hint="{}""#, escape(&h)))
395            .unwrap_or_default();
396        let icon = self
397            .icon
398            .and_then(|i| serde_json::to_value(i).ok())
399            .and_then(|v| {
400                v.as_str()
401                    .map(|s| format!(r#" data-icon="{s}""#))
402            })
403            .unwrap_or_default();
404        format!(
405            r#"<button type="button" tabindex="-1"{}{hint}{icon}>{}</button>"#,
406            self.attrs(r),
407            r.text(&self.label)
408        )
409    }
410
411    pub(crate) fn schema(&self) -> Json {
412        json!({
413            "id": self.id,
414            "label": self.label,
415            "shortcut": self.shortcut,
416            "access": self.access,
417            "kind": match &self.kind {
418                Kind::Effect(e) => json!({ "effect": e }),
419                Kind::Action { meta, .. } => json!({ "action": meta.name }),
420            },
421        })
422    }
423}
424
425/// Reads a shortcut such as `"ctrl+shift+p"` into the form `aria-keyshortcuts` uses:
426/// `Control+Shift+P`. Modifiers come in the order Control, Alt, Shift, Meta. `None` when it
427/// cannot be read.
428///
429/// ```
430/// use zeyn::normalize_shortcut;
431/// assert_eq!(normalize_shortcut("ctrl+shift+p").as_deref(), Some("Control+Shift+P"));
432/// assert_eq!(normalize_shortcut("Shift + Alt + f2").as_deref(), Some("Alt+Shift+F2"));
433/// assert_eq!(normalize_shortcut("Cmd+K").as_deref(), Some("Meta+K"));
434/// assert_eq!(normalize_shortcut("Ctrl+Ctrl+S"), None);
435/// assert_eq!(normalize_shortcut("Ctrl+"), None);
436/// assert_eq!(normalize_shortcut("Hyper+S"), None);
437/// ```
438pub fn normalize_shortcut(keys: &str) -> Option<String> {
439    let parts: Vec<&str> = keys.split('+').map(str::trim).collect();
440    let (key, mods) = parts.split_last()?;
441    let mut flags = [false; 4];
442    for m in mods {
443        let i = match m.to_ascii_lowercase().as_str() {
444            "ctrl" | "control" => 0,
445            "alt" | "option" => 1,
446            "shift" => 2,
447            "meta" | "cmd" | "command" | "super" => 3,
448            _ => return None,
449        };
450        if flags[i] {
451            return None;
452        }
453        flags[i] = true;
454    }
455    let key = key_name(key)?;
456    let mut out: Vec<&str> = ["Control", "Alt", "Shift", "Meta"]
457        .iter()
458        .zip(flags)
459        .filter(|(_, on)| *on)
460        .map(|(n, _)| *n)
461        .collect();
462    out.push(&key);
463    Some(out.join("+"))
464}
465
466const NAMED_KEYS: [&str; 14] = [
467    "Enter",
468    "Escape",
469    "Delete",
470    "Backspace",
471    "Insert",
472    "Home",
473    "End",
474    "PageUp",
475    "PageDown",
476    "ArrowUp",
477    "ArrowDown",
478    "ArrowLeft",
479    "ArrowRight",
480    "Tab",
481];
482
483fn key_name(key: &str) -> Option<String> {
484    let mut chars = key.chars();
485    match (chars.next(), chars.next()) {
486        (Some(c), None) if c.is_ascii_graphic() => return Some(c.to_ascii_uppercase().to_string()),
487        (None, _) => return None,
488        _ => {}
489    }
490    if let Some(n) = key
491        .strip_prefix(['F', 'f'])
492        .and_then(|n| n.parse::<u8>().ok())
493    {
494        return (1..=24)
495            .contains(&n)
496            .then(|| format!("F{n}"));
497    }
498    NAMED_KEYS
499        .iter()
500        .find(|k| k.eq_ignore_ascii_case(key))
501        .map(|k| k.to_string())
502}
503
504/// Whether a normalized shortcut is a character key with no Control, Alt or Meta. WCAG 2.2
505/// (2.1.4) does not allow those, because they fire while people who use speech input talk.
506fn character_only(keys: &str) -> bool {
507    let parts: Vec<&str> = keys.split('+').collect();
508    let key = parts.last().copied().unwrap_or("");
509    key.chars().count() == 1
510        && parts[..parts.len() - 1]
511            .iter()
512            .all(|m| *m == "Shift")
513}
514
515/// Problems of commands that belong together: ids that are not usable, ids or shortcuts used
516/// twice, shortcuts that cannot be read, single character shortcuts and shortcuts the palette
517/// keeps for itself. `owner` names where each command was registered, for the message.
518pub(crate) fn problems(
519    commands: &[(&str, &Command)],
520    check_each: bool,
521    palette: &str,
522) -> Vec<String> {
523    let mut out = Vec::new();
524    let mut seen: Vec<(String, &str, &str)> = Vec::new();
525    for (i, (owner, c)) in commands.iter().enumerate() {
526        if check_each
527            && (c.id.is_empty()
528                || !c
529                    .id
530                    .bytes()
531                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b)))
532        {
533            out.push(format!(
534                    "command `{}` has an id that is not usable in HTML\n  = help: use lower case letters, digits, dots, dashes and underscores, such as `sales.new_order`",
535                    c.id
536                ));
537        }
538        if let Some((other, _)) = commands[..i]
539            .iter()
540            .find(|(o, d)| d.id == c.id && (check_each || o != owner))
541        {
542            out.push(format!("command id `{}` is registered twice (in `{other}` and `{owner}`)\n  = help: give each command its own id", c.id));
543        }
544        let Some(raw) = c.shortcut.as_deref() else {
545            continue;
546        };
547        let Some(keys) = normalize_shortcut(raw) else {
548            if check_each {
549                out.push(format!(
550                    "command `{}` has the shortcut `{raw}`, which cannot be read\n  = help: write modifiers and one key joined by `+`, such as `Ctrl+Shift+O` or `Alt+F2`",
551                    c.id
552                ));
553            }
554            continue;
555        };
556        if check_each {
557            if keys == palette {
558                out.push(format!(
559                    "command `{}` uses `{keys}`, which opens the command palette\n  = help: pick another shortcut; the palette lists the command anyway",
560                    c.id
561                ));
562            }
563            match browser_shortcut(&keys) {
564                Some(BrowserShortcut::Reserved) => out.push(format!(
565                    "command `{}` uses `{keys}`, which the browser keeps and never passes to the page\n  = help: pick a free shortcut such as `Alt+Shift+{}`",
566                    c.id,
567                    keys.rsplit('+').next().unwrap_or("")
568                )),
569                Some(BrowserShortcut::Default) if !c.override_browser => out.push(format!(
570                    "command `{}` uses `{keys}`, which the browser uses (print, find, reload and the like)\n  = help: pick a free shortcut such as `Alt+Shift+{}`, or call `.override_browser()` to take it over on purpose",
571                    c.id,
572                    keys.rsplit('+').next().unwrap_or("")
573                )),
574                _ => {}
575            }
576            if character_only(&keys) {
577                out.push(format!(
578                    "command `{}` uses `{keys}`, a single character key; WCAG 2.2 (2.1.4) does not allow it\n  = help: add Ctrl or Alt, such as `Ctrl+{}`",
579                    c.id,
580                    keys.rsplit('+').next().unwrap_or("")
581                ));
582            }
583        }
584        if let Some((_, other, other_owner)) = seen
585            .iter()
586            .find(|(k, _, o)| *k == keys && (check_each || o != owner))
587        {
588            out.push(format!(
589                "commands `{other}` and `{}` both use the shortcut `{keys}` (in `{other_owner}` and `{owner}`)\n  = help: give one of them another shortcut",
590                c.id
591            ));
592        }
593        seen.push((keys, c.id, owner));
594    }
595    out
596}
597
598/// The `aria-keyshortcuts` attribute and the `<kbd>` hint of a link with the shortcut `raw`.
599/// Both are empty when the shortcut cannot be read or belongs to the browser (logged).
600pub(crate) fn link_shortcut(raw: Option<&str>) -> (String, String) {
601    let Some(keys) = raw.and_then(|s| usable_shortcut(s, false)) else {
602        return (String::new(), String::new());
603    };
604    (
605        format!(r#" aria-keyshortcuts="{}""#, escape(&keys)),
606        format!(
607            r#"<kbd class="z-kbd" aria-hidden="true">{}</kbd>"#,
608            escape(&keys.replace("Control", "Ctrl"))
609        ),
610    )
611}
612
613/// Problems of link shortcuts (`Chips::link`, `PrevNext`): shortcuts that cannot be read, that
614/// the browser keeps or uses, single character ones, and ones another link of the same set, a
615/// command or the palette already uses. `links` holds where each link is and its shortcut.
616pub(crate) fn link_problems(
617    links: &[(String, &str)],
618    commands: &[(&str, &Command)],
619    palette: &str,
620) -> Vec<String> {
621    let mut out = Vec::new();
622    let mut seen: Vec<(String, String)> = commands
623        .iter()
624        .filter_map(|(_, c)| {
625            c.shortcut
626                .as_deref()
627                .and_then(normalize_shortcut)
628                .map(|k| (k, format!("command `{}`", c.id)))
629        })
630        .collect();
631    if let Some(k) = normalize_shortcut(palette) {
632        seen.push((k, "the command palette".into()));
633    }
634    for (place, raw) in links {
635        let Some(keys) = normalize_shortcut(raw) else {
636            out.push(format!(
637                "the link shortcut `{raw}` {place} cannot be read\n  = help: write modifiers and one key joined by `+`, such as `Alt+Shift+N`"
638            ));
639            continue;
640        };
641        let key = keys.rsplit('+').next().unwrap_or("");
642        match browser_shortcut(&keys) {
643            Some(BrowserShortcut::Reserved) => out.push(format!(
644                "the link shortcut `{keys}` {place} is kept by the browser and never reaches the page\n  = help: pick a free shortcut such as `Alt+Shift+{key}`"
645            )),
646            Some(BrowserShortcut::Default) => out.push(format!(
647                "the link shortcut `{keys}` {place} belongs to the browser (print, find, reload and the like)\n  = help: pick a free shortcut such as `Alt+Shift+{key}`"
648            )),
649            None => {}
650        }
651        if character_only(&keys) {
652            out.push(format!(
653                "the link shortcut `{keys}` {place} is a single character key; WCAG 2.2 (2.1.4) does not allow it\n  = help: add Alt or Ctrl, such as `Alt+Shift+{key}`"
654            ));
655        }
656        if let Some((_, other)) = seen.iter().find(|(k, _)| *k == keys) {
657            out.push(format!(
658                "the link shortcut `{keys}` {place} is also used by {other}\n  = help: give one of them another shortcut"
659            ));
660        }
661        seen.push((keys, format!("the link {place}")));
662    }
663    out
664}
665
666/// Longest palette search query, in characters. Longer queries are cut.
667pub const MAX_SEARCH_QUERY: usize = 100;
668
669/// Most results one palette search shows, over all features.
670pub const MAX_SEARCH_HITS: usize = 20;
671
672/// One result of a palette search: what it shows and the app path it opens, such as
673/// `/guides/theming#density`. The path is relative to the app's base path.
674#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
675pub struct SearchHit {
676    pub label: String,
677    /// Shown next to the label, such as the page a section belongs to.
678    pub group: String,
679    pub url: String,
680}
681
682impl SearchHit {
683    pub fn new(label: impl Into<String>, group: impl Into<String>, url: impl Into<String>) -> Self {
684        SearchHit {
685            label: label.into(),
686            group: group.into(),
687            url: url.into(),
688        }
689    }
690
691    /// Whether the path stays in the app: it starts with one `/` and has no backslash or control
692    /// character. Hits that do not are dropped before they reach the browser.
693    pub fn is_local(&self) -> bool {
694        self.url.starts_with('/')
695            && !self.url.starts_with("//")
696            && !self
697                .url
698                .chars()
699                .any(|c| c == '\\' || c.is_control())
700    }
701}
702
703type Find =
704    Arc<dyn Fn(Ctx, String) -> BoxFuture<crate::action::Result<Vec<SearchHit>>> + Send + Sync>;
705
706/// A server search the command palette runs as the user types, registered with
707/// [`crate::Feature::search`]. The action takes the query and returns hits.
708///
709/// ```
710/// use zeyn::prelude::*;
711/// use zeyn::SearchHit;
712///
713/// #[action(public)]
714/// pub async fn search_help(query: String) -> Result<Vec<SearchHit>> {
715///     Ok(vec![SearchHit::new(format!("Help for {query}"), "Help", "/help")])
716/// }
717///
718/// let feature = Feature::new("help").search(search_help);
719/// assert_eq!(feature.searches().len(), 1);
720/// ```
721#[derive(Clone)]
722pub struct Search {
723    meta: ActionMeta,
724    find: Find,
725}
726
727impl Search {
728    pub fn of<A>(_action: A) -> Self
729    where
730        A: Action<Input = String, Output = crate::action::Result<Vec<SearchHit>>>,
731    {
732        let find: Find = Arc::new(|ctx, query| Box::pin(async move { A::call(ctx, query).await? }));
733        Search {
734            meta: A::META,
735            find,
736        }
737    }
738
739    pub fn meta(&self) -> &ActionMeta {
740        &self.meta
741    }
742
743    /// Runs the search. Access is checked by the caller.
744    pub fn run(&self, ctx: Ctx, query: String) -> BoxFuture<crate::action::Result<Vec<SearchHit>>> {
745        (self.find)(ctx, query)
746    }
747}