1use crate::ScriptValue;
17use crate::{FileDialogKind, ScriptCommand, ScriptFn, ScriptFnCx, ScriptNs, ScriptTy as T};
18
19fn emit<F>(name: &str, doc: &str, params: &[(&str, T)], build: F) -> ScriptFn
21where
22 F: Fn(&ScriptFnCx<'_>) -> ScriptCommand + Send + Sync + 'static,
23{
24 let mut f = ScriptFn::new(name)
25 .ns(ScriptNs::Builtin)
26 .ret(T::Unit)
27 .doc(doc);
28 for (pname, ty) in params {
29 f = f.param(*pname, ty.clone());
30 }
31 f.build(move |cx| {
32 let cmd = build(cx);
33 cx.emit(cmd);
34 Ok(ScriptValue::Unit)
35 })
36}
37
38fn value<F>(name: &str, doc: &str, params: &[(&str, T)], ret: T, body: F) -> ScriptFn
40where
41 F: Fn(&ScriptFnCx<'_>) -> ScriptValue + Send + Sync + 'static,
42{
43 let mut f = ScriptFn::new(name).ns(ScriptNs::Builtin).ret(ret).doc(doc);
44 for (pname, ty) in params {
45 f = f.param(*pname, ty.clone());
46 }
47 f.build(move |cx| Ok(body(cx)))
48}
49
50pub fn builtin_script_fns() -> Vec<ScriptFn> {
56 let mut fns = Vec::new();
57 fns.extend(state_fns());
58 fns.extend(timer_fns());
59 fns.extend(os_fns());
60 fns.extend(dialog_fns());
61 fns.extend(navigation_fns());
62 fns.extend(request_fns());
63 fns.extend(misc_fns());
64 fns.extend(text_fns());
65 fns
66}
67
68fn state_fns() -> Vec<ScriptFn> {
70 vec![
71 emit(
72 "add_clicks",
73 "Add to the demo click counter.",
74 &[("n", T::Int)],
75 |cx| ScriptCommand::AddClicks(cx.int_arg(0) as i32),
76 ),
77 emit(
78 "set_string",
79 "Write a string into the app's key-value state.",
80 &[("key", T::Str), ("value", T::Str)],
81 |cx| ScriptCommand::SetString {
82 key: cx.str_arg(0),
83 value: cx.str_arg(1),
84 },
85 ),
86 emit(
87 "set_text",
88 "Replace the text content of the element with that id.",
89 &[("target_id", T::Str), ("text", T::Str)],
90 |cx| ScriptCommand::SetText {
91 target_id: cx.str_arg(0),
92 text: cx.str_arg(1),
93 },
94 ),
95 emit(
99 "set_src",
100 "Swap the asset path of an <image> at run time.",
101 &[("target_id", T::Str), ("path", T::Str)],
102 |cx| ScriptCommand::SetSrc {
103 target_id: cx.str_arg(0),
104 path: cx.str_arg(1),
105 },
106 ),
107 emit(
110 "set_class",
111 "Replace the class list of the element with that id.",
112 &[("id", T::Str), ("classes", T::Str)],
113 |cx| ScriptCommand::SetClasses {
114 target_id: cx.str_arg(0),
115 classes: cx.str_arg(1),
116 },
117 ),
118 emit(
119 "set_root_class",
120 "Replace the class list of the root element.",
121 &[("classes", T::Str)],
122 |cx| ScriptCommand::SetClasses {
123 target_id: "<root>".to_string(),
124 classes: cx.str_arg(0),
125 },
126 ),
127 emit(
128 "set_color_scheme",
129 "Apply a color scheme: default, force-light, force-dark, \
130 prefer-light, or prefer-dark.",
131 &[("name", T::Str)],
132 |cx| ScriptCommand::SetColorScheme {
133 name: cx.str_arg(0),
134 },
135 ),
136 emit(
139 "open_menu",
140 "Open the menu with that id.",
141 &[("id", T::Str)],
142 |cx| ScriptCommand::SetSignal {
143 name: menu_signal(&cx.str_arg(0)),
144 value: "true".to_string(),
145 },
146 ),
147 emit(
148 "close_menu",
149 "Close the menu with that id.",
150 &[("id", T::Str)],
151 |cx| ScriptCommand::SetSignal {
152 name: menu_signal(&cx.str_arg(0)),
153 value: "false".to_string(),
154 },
155 ),
156 ]
157}
158
159fn menu_signal(id: &str) -> String {
161 format!("__menu_open:{id}")
162}
163
164fn timer_fns() -> Vec<ScriptFn> {
168 vec![
169 emit(
170 "set_timeout",
171 "Fire on_timer(name) once, after that many milliseconds.",
172 &[("name", T::Str), ("ms", T::Int)],
173 |cx| ScriptCommand::SetTimer {
174 name: cx.str_arg(0),
175 millis: cx.int_arg(1).max(0) as u64,
176 repeat: false,
177 },
178 ),
179 emit(
180 "set_interval",
181 "Fire on_timer(name) every that many milliseconds.",
182 &[("name", T::Str), ("ms", T::Int)],
183 |cx| ScriptCommand::SetTimer {
184 name: cx.str_arg(0),
185 millis: cx.int_arg(1).max(0) as u64,
186 repeat: true,
187 },
188 ),
189 emit(
190 "cancel_timer",
191 "Stop the timer registered under that name.",
192 &[("name", T::Str)],
193 |cx| ScriptCommand::CancelTimer {
194 name: cx.str_arg(0),
195 },
196 ),
197 emit(
198 "request_frame",
199 "Ask for one on_frame(dt) call on the next tick.",
200 &[],
201 |_| ScriptCommand::RequestFrame,
202 ),
203 ]
204}
205
206fn os_fns() -> Vec<ScriptFn> {
208 vec![
209 emit(
210 "notify",
211 "Post a desktop notification.",
212 &[("title", T::Str), ("body", T::Str)],
213 |cx| ScriptCommand::Notify {
214 title: cx.str_arg(0),
215 body: cx.str_arg(1),
216 },
217 ),
218 emit(
219 "notify_ex",
220 "Post a desktop notification with an id, options, and actions.",
221 &[
222 ("id", T::Str),
223 ("title", T::Str),
224 ("body", T::Str),
225 ("options", T::Str),
226 ("actions", T::Str),
227 ],
228 |cx| ScriptCommand::NotifyEx {
229 id: cx.str_arg(0),
230 title: cx.str_arg(1),
231 body: cx.str_arg(2),
232 options: cx.str_arg(3),
233 actions: cx.str_arg(4),
234 },
235 ),
236 emit(
237 "clipboard_write",
238 "Put text on the system clipboard.",
239 &[("text", T::Str)],
240 |cx| ScriptCommand::ClipboardWrite {
241 text: cx.str_arg(0),
242 },
243 ),
244 emit(
245 "clipboard_read",
246 "Read the clipboard; delivers to on_clipboard(tag, text).",
247 &[("tag", T::Str)],
248 |cx| ScriptCommand::ClipboardRead { tag: cx.str_arg(0) },
249 ),
250 emit(
251 "copy_image",
252 "Put the image at that path on the clipboard.",
253 &[("path", T::Str)],
254 |cx| ScriptCommand::CopyImageToClipboard {
255 path: cx.str_arg(0),
256 },
257 ),
258 emit(
259 "save_clipboard_image",
260 "Write the clipboard image to that path.",
261 &[("path", T::Str)],
262 |cx| ScriptCommand::SaveClipboardImage {
263 path: cx.str_arg(0),
264 },
265 ),
266 emit(
267 "tray_icon",
268 "Register a tray icon.",
269 &[("id", T::Str), ("icon_path", T::Str), ("tooltip", T::Str)],
270 |cx| ScriptCommand::RegisterTrayIcon {
271 id: cx.str_arg(0),
272 icon_path: cx.str_arg(1),
273 tooltip: non_empty(cx.str_arg(2)),
274 menu: String::new(),
275 template: false,
276 },
277 ),
278 emit(
279 "tray_icon_menu",
280 "Register a tray icon carrying a menu.",
281 &[
282 ("id", T::Str),
283 ("icon_path", T::Str),
284 ("tooltip", T::Str),
285 ("menu", T::Str),
286 ("template", T::Bool),
287 ],
288 |cx| ScriptCommand::RegisterTrayIcon {
289 id: cx.str_arg(0),
290 icon_path: cx.str_arg(1),
291 tooltip: non_empty(cx.str_arg(2)),
292 menu: cx.str_arg(3),
293 template: cx.bool_arg(4),
294 },
295 ),
296 emit(
297 "unregister_tray",
298 "Remove the tray icon with that id.",
299 &[("id", T::Str)],
300 |cx| ScriptCommand::UnregisterTrayIcon { id: cx.str_arg(0) },
301 ),
302 emit(
303 "open_url",
304 "Open a URL in the system browser.",
305 &[("url", T::Str)],
306 |cx| ScriptCommand::OpenUrl { url: cx.str_arg(0) },
307 ),
308 emit(
309 "open_path",
310 "Open a path with its default application.",
311 &[("path", T::Str)],
312 |cx| ScriptCommand::OpenPath {
313 path: cx.str_arg(0),
314 },
315 ),
316 emit(
317 "reveal_path",
318 "Show a path in the system file manager.",
319 &[("path", T::Str)],
320 |cx| ScriptCommand::RevealPath {
321 path: cx.str_arg(0),
322 },
323 ),
324 emit(
325 "keep_awake",
326 "Hold a wake lock under that name until allow_sleep.",
327 &[("name", T::Str), ("reason", T::Str)],
328 |cx| ScriptCommand::KeepAwake {
329 name: cx.str_arg(0),
330 reason: cx.str_arg(1),
331 },
332 ),
333 emit(
334 "allow_sleep",
335 "Release the wake lock held under that name.",
336 &[("name", T::Str)],
337 |cx| ScriptCommand::AllowSleep {
338 name: cx.str_arg(0),
339 },
340 ),
341 emit(
344 "register_hotkey",
345 "Bind an OS-level global accelerator; fires on_hotkey(name).",
346 &[("name", T::Str), ("accelerator", T::Str)],
347 |cx| ScriptCommand::RegisterHotkey {
348 name: cx.str_arg(0),
349 accelerator: cx.str_arg(1),
350 },
351 ),
352 emit(
353 "unregister_hotkey",
354 "Release the global accelerator bound under that name.",
355 &[("name", T::Str)],
356 |cx| ScriptCommand::UnregisterHotkey {
357 name: cx.str_arg(0),
358 },
359 ),
360 emit(
361 "add_recent_file",
362 "Record path as recently opened; empty label derives one from the path.",
363 &[("path", T::Str), ("label", T::Str)],
364 |cx| ScriptCommand::AddRecentFile {
365 path: cx.str_arg(0),
366 label: cx.str_arg(1),
367 },
368 ),
369 emit(
370 "list_recent_files",
371 "Request the recent-files list; delivers to on_recent_files(tag, paths).",
372 &[("tag", T::Str)],
373 |cx| ScriptCommand::ListRecentFiles { tag: cx.str_arg(0) },
374 ),
375 emit(
376 "clear_recent_files",
377 "Remove every entry from the recent-files list.",
378 &[],
379 |_| ScriptCommand::ClearRecentFiles,
380 ),
381 emit(
382 "set_autostart",
383 "Enable or disable launching this app at login.",
384 &[("on", T::Bool)],
385 |cx| ScriptCommand::SetAutostart { on: cx.bool_arg(0) },
386 ),
387 emit(
388 "query_autostart",
389 "Request the autostart state; delivers to on_autostart_enabled(tag) or on_autostart_disabled(tag).",
390 &[("tag", T::Str)],
391 |cx| ScriptCommand::QueryAutostart { tag: cx.str_arg(0) },
392 ),
393 ]
394}
395
396fn non_empty(text: String) -> Option<String> {
398 (!text.is_empty()).then_some(text)
399}
400
401fn dialog_fns() -> Vec<ScriptFn> {
405 let mut fns: Vec<ScriptFn> = [
406 ("pick_file", FileDialogKind::Open, "Pick one file."),
407 (
408 "pick_files",
409 FileDialogKind::OpenMulti,
410 "Pick several files.",
411 ),
412 ("pick_folder", FileDialogKind::PickFolder, "Pick a folder."),
413 ]
414 .into_iter()
415 .map(|(name, kind, doc)| {
416 emit(name, doc, &[("tag", T::Str)], move |cx| {
417 ScriptCommand::OpenFileDialog {
418 kind,
419 tag: cx.str_arg(0),
420 filters: Vec::new(),
421 default_name: None,
422 }
423 })
424 })
425 .collect();
426 fns.push(emit(
427 "save_file",
428 "Ask for a save destination, offering that file name.",
429 &[("tag", T::Str), ("default_name", T::Str)],
430 |cx| ScriptCommand::OpenFileDialog {
431 kind: FileDialogKind::Save,
432 tag: cx.str_arg(0),
433 filters: Vec::new(),
434 default_name: Some(cx.str_arg(1)),
435 },
436 ));
437 fns.push(emit(
438 "pick_file_filtered",
439 "Pick one file, restricted to a filter spec like \
440 \"Images:png,jpg|All:*\".",
441 &[("tag", T::Str), ("spec", T::Str)],
442 |cx| ScriptCommand::OpenFileDialog {
443 kind: FileDialogKind::Open,
444 tag: cx.str_arg(0),
445 filters: parse_dialog_filter_spec(&cx.str_arg(1)),
446 default_name: None,
447 },
448 ));
449 fns
450}
451
452pub fn parse_dialog_filter_spec(spec: &str) -> Vec<(String, Vec<String>)> {
457 let mut out = Vec::new();
458 for group in spec.split('|') {
459 let group = group.trim();
460 if group.is_empty() {
461 continue;
462 }
463 let (label, exts) = match group.split_once(':') {
464 Some((l, e)) => (l.trim().to_string(), e),
465 None => (group.to_string(), ""),
466 };
467 let exts: Vec<String> = exts
468 .split(',')
469 .map(|e| e.trim().to_string())
470 .filter(|e| !e.is_empty() && e != "*")
471 .collect();
472 out.push((label, exts));
473 }
474 out
475}
476
477fn navigation_fns() -> Vec<ScriptFn> {
484 vec![value(
485 "page_current",
486 "The page the app is on.",
487 &[],
488 T::Str,
489 |_| ScriptValue::Str(lumen_core::nav::current()),
490 )]
491}
492
493fn request_fns() -> Vec<ScriptFn> {
501 vec![
502 value(
503 "request_header",
504 "The value of that request header, or an empty string.",
505 &[("name", T::Str)],
506 T::Str,
507 |cx| ScriptValue::Str(lumen_core::request::header(&cx.str_arg(0))),
508 ),
509 value(
510 "request_cookie",
511 "The value of that request cookie, or an empty string.",
512 &[("name", T::Str)],
513 T::Str,
514 |cx| ScriptValue::Str(lumen_core::request::cookie(&cx.str_arg(0))),
515 ),
516 value(
517 "request_body",
518 "The request body, or an empty string.",
519 &[],
520 T::Str,
521 |_| ScriptValue::Str(lumen_core::request::body()),
522 ),
523 emit(
524 "response_status",
525 "Answer with that HTTP status.",
526 &[("status", T::Int)],
527 |cx| ScriptCommand::SetResponseStatus {
528 status: cx.int_arg(0).clamp(100, 599) as u16,
529 },
530 ),
531 emit(
532 "response_header",
533 "Set a header on the response.",
534 &[("name", T::Str), ("value", T::Str)],
535 |cx| ScriptCommand::SetResponseHeader {
536 name: cx.str_arg(0),
537 value: cx.str_arg(1),
538 },
539 ),
540 emit(
541 "redirect",
542 "Answer with a redirect to that location.",
543 &[("location", T::Str)],
544 |cx| ScriptCommand::Redirect {
545 location: cx.str_arg(0),
546 },
547 ),
548 emit(
549 "fetch",
550 "Request a URL; delivers to on_fetch(tag, body).",
551 &[("url", T::Str), ("tag", T::Str)],
552 |cx| ScriptCommand::Fetch {
553 url: cx.str_arg(0),
554 tag: cx.str_arg(1),
555 },
556 ),
557 ]
558}
559
560fn formatted(spec: &str, value: &str) -> String {
566 lumen_core::i18n::format(spec, value).unwrap_or_else(|| value.to_string())
567}
568
569fn misc_fns() -> Vec<ScriptFn> {
571 vec![
572 value(
576 "t",
577 "The active locale's string for that key, or the key itself.",
578 &[("key", T::Str)],
579 T::Str,
580 |cx| ScriptValue::Str(lumen_core::i18n::translate(&cx.str_arg(0))),
581 ),
582 value(
583 "tr",
584 "The active locale's string for that key, or the key itself.",
585 &[("key", T::Str)],
586 T::Str,
587 |cx| ScriptValue::Str(lumen_core::i18n::translate(&cx.str_arg(0))),
588 ),
589 emit(
596 "set_locale",
597 "Switch the app to that BCP-47 locale, such as de-DE.",
598 &[("tag", T::Str)],
599 |cx| ScriptCommand::SetLocale { tag: cx.str_arg(0) },
600 ),
601 value(
602 "locale",
603 "The BCP-47 tag of the locale the app is running in.",
604 &[],
605 T::Str,
606 |_| ScriptValue::Str(lumen_core::i18n::active_locale()),
607 ),
608 value(
615 "format_number",
616 "That number written the way the active locale writes numbers.",
617 &[("n", T::Float)],
618 T::Str,
619 |cx| ScriptValue::Str(formatted("number", &cx.float_arg(0).to_string())),
620 ),
621 value(
622 "format_currency",
623 "That amount as money in an ISO-4217 currency, for the active locale.",
624 &[("amount", T::Float), ("currency", T::Str)],
625 T::Str,
626 |cx| {
627 let spec = format!("currency:{}", cx.str_arg(1));
628 ScriptValue::Str(formatted(&spec, &cx.float_arg(0).to_string()))
629 },
630 ),
631 value(
632 "format_date",
633 "That `YYYY-MM-DD` date written for the active locale.",
634 &[("iso", T::Str)],
635 T::Str,
636 |cx| ScriptValue::Str(formatted("date", &cx.str_arg(0))),
637 ),
638 value(
639 "format_time",
640 "The time of that timestamp, written for the active locale.",
641 &[("iso", T::Str)],
642 T::Str,
643 |cx| ScriptValue::Str(formatted("time", &cx.str_arg(0))),
644 ),
645 value(
646 "format_datetime",
647 "That timestamp written for the active locale, date and time.",
648 &[("iso", T::Str)],
649 T::Str,
650 |cx| ScriptValue::Str(formatted("datetime", &cx.str_arg(0))),
651 ),
652 value(
653 "format_relative",
654 "That many seconds from now, as the active locale says it (past is negative).",
655 &[("seconds", T::Int)],
656 T::Str,
657 |cx| ScriptValue::Str(formatted("relative", &cx.int_arg(0).to_string())),
658 ),
659 value(
664 "local_id",
665 "The id of a sibling inside the same template instance.",
666 &[("source", T::Str), ("suffix", T::Str)],
667 T::Str,
668 |cx| {
669 let source = cx.str_arg(0);
670 let suffix = cx.str_arg(1);
671 ScriptValue::Str(match source.rfind(':') {
672 Some(colon) => format!("{}:{suffix}", &source[..colon]),
673 None => suffix,
674 })
675 },
676 ),
677 value(
678 "dump_tree",
679 "The element tree, rendered for debugging.",
680 &[],
681 T::Str,
682 |_| ScriptValue::Str(crate::introspect::dump_tree()),
683 ),
684 ]
685}
686
687fn text_fns() -> Vec<ScriptFn> {
698 [parse_json_fn(), parse_markdown_fn()]
699 .into_iter()
700 .flatten()
701 .collect()
702}
703
704#[cfg(feature = "json")]
706fn parse_json_fn() -> Option<ScriptFn> {
707 Some(value(
708 "parse_json",
709 "Parse a JSON string into a map, array, or scalar. Null on a parse error.",
710 &[("json", T::Str)],
711 T::Dynamic,
712 |cx| crate::text_parse::parse_json(&cx.str_arg(0)),
713 ))
714}
715
716#[cfg(not(feature = "json"))]
717fn parse_json_fn() -> Option<ScriptFn> {
718 None
719}
720
721#[cfg(feature = "markdown")]
723fn parse_markdown_fn() -> Option<ScriptFn> {
724 Some(value(
725 "parse_markdown",
726 "Parse markdown into a list of block records: id, kind, level, text, lang.",
727 &[("src", T::Str)],
728 T::Dynamic,
729 |cx| crate::text_parse::parse_markdown(&cx.str_arg(0)),
730 ))
731}
732
733#[cfg(not(feature = "markdown"))]
734fn parse_markdown_fn() -> Option<ScriptFn> {
735 None
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741 use std::collections::HashSet;
742
743 #[test]
755 fn every_entry_is_a_documented_builtin() {
756 for f in builtin_script_fns() {
757 assert_eq!(f.ns, ScriptNs::Builtin, "{}", f.name);
758 assert!(!f.sig.doc.is_empty(), "{} has no doc line", f.name);
759 assert!(!f.sig.variadic, "{} must declare its parameters", f.name);
760 for p in &f.sig.params {
761 assert!(
762 !p.ty.is_dynamic(),
763 "{}: parameter `{}` is untyped",
764 f.name,
765 p.name
766 );
767 }
768 assert_ne!(
769 f.sig.ret,
770 T::Any,
771 "{}: return type is undeclared; call .ret(..), or .ret(T::Dynamic) when \
772 the result has no fixed shape",
773 f.name
774 );
775 }
776 }
777
778 #[test]
781 fn every_name_is_registered_once() {
782 let fns = builtin_script_fns();
783 let mut seen: HashSet<&str> = HashSet::new();
784 for f in &fns {
785 assert!(
786 seen.insert(f.name.as_str()),
787 "`{}` is registered twice",
788 f.name
789 );
790 }
791 }
792
793 #[test]
794 fn a_filter_spec_parses_into_labelled_extension_groups() {
795 assert_eq!(
796 parse_dialog_filter_spec("Images:png,jpg|All:*"),
797 vec![
798 (
799 "Images".to_string(),
800 vec!["png".to_string(), "jpg".to_string()]
801 ),
802 ("All".to_string(), Vec::new()),
803 ]
804 );
805 }
806}