1use 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
70pub const PALETTE_SHORTCUT: &str = "Alt+Shift+P";
73
74pub 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
95pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub enum BrowserShortcut {
164 Reserved,
166 Default,
168}
169
170pub 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
188pub(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#[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 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 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(¶ms).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 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 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 pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
306 self.shortcut = Some(keys.into());
307 self
308 }
309
310 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 pub fn keys(&self) -> Option<&str> {
336 self.shortcut.as_deref()
337 }
338
339 pub fn access(&self) -> Access {
342 self.access
343 }
344
345 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 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 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 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
425pub 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
504fn 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
515pub(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
598pub(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
613pub(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
666pub const MAX_SEARCH_QUERY: usize = 100;
668
669pub const MAX_SEARCH_HITS: usize = 20;
671
672#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
675pub struct SearchHit {
676 pub label: String,
677 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 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#[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 pub fn run(&self, ctx: Ctx, query: String) -> BoxFuture<crate::action::Result<Vec<SearchHit>>> {
745 (self.find)(ctx, query)
746 }
747}