1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum PropKind {
11 Id,
13 Keyword(&'static [&'static str]),
15 DisplayText,
17 LocalizedText,
19 Icon,
21 ImageResource,
24 SvgResource,
26 Number,
28 Shortcut,
34 Length,
37 FileKinds,
40 FileField,
43 PositionField,
46 Flag,
48 NumberValue,
50 Bool,
52 TextValue,
54 TextField,
56 BoolField,
58 NumberField,
60 PickerField,
62 SelectionField,
64 SelectionValue,
66 Items,
68 KeyField,
70 PathField,
72 KeysField,
75 Route,
77 WindowId,
79 TypeName,
81 SlotName,
83 StyleVariant,
85 Transition,
87 MotionToken,
89 HeroName,
91 KeyValue,
93 Watched,
96 ColorRole,
98 NumberList,
100 PatternName,
102 MediaSource,
105 Player,
107 Query,
110 Feature,
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub struct PropSpec {
116 pub name: &'static str,
117 pub kind: PropKind,
118 pub required: bool,
119}
120
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub enum Children {
124 None,
125 Any,
127 One,
129 Only(&'static [&'static str]),
131}
132
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub enum ActionRule {
135 None,
136 Required,
137 Optional,
138}
139
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub enum Placement {
143 Anywhere,
144 Root,
146 Within(&'static [&'static str]),
148}
149
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct ComponentSpec {
152 pub name: &'static str,
153 pub props: &'static [PropSpec],
154 pub children: Children,
155 pub template: bool,
156 pub action: ActionRule,
157 pub placement: Placement,
158 pub interactive: bool,
160 pub summary: &'static str,
161}
162
163impl ComponentSpec {
164 pub fn prop(&self, name: &str) -> Option<&'static PropSpec> {
165 self.props
166 .iter()
167 .find(|prop| prop.name == name)
168 .or_else(|| {
169 self.moves()
170 .then(|| {
171 MOTION_PROPS
172 .iter()
173 .chain(PLACEMENT_PROPS)
174 .find(|prop| prop.name == name)
175 })
176 .flatten()
177 })
178 }
179
180 pub fn moves(&self) -> bool {
183 !matches!(
184 self.name,
185 "WindowSet"
186 | "Window"
187 | "Layout"
188 | "Slot"
189 | "Pages"
190 | "PageContent"
191 | "Destination"
192 | "Detail"
193 | "Tab"
194 | "MenuItem"
195 | "SidebarItem"
196 | "Section"
197 | "MenuBar"
198 | "StatusItem"
199 | "CommandMenu"
200 | "CommandItem"
201 | "TableColumn"
202 | "TableMenu"
203 | "ContextMenu"
204 )
205 }
206}
207
208pub use bern_protocol::shortcut::{SHORTCUT_KEYS, parse_shortcut};
209
210pub const FILE_KINDS: &[&str] = &["any", "video", "audio", "picture", "folder"];
212
213pub const FILE_KIND_EXTENSIONS: &[(&str, &[&str])] = &[
217 (
218 "video",
219 &[
220 "mp4", "m4v", "mov", "mkv", "webm", "flv", "f4v", "avi", "wmv", "rmvb", "rm", "ts",
221 "m2ts", "mts", "ogv", "3gp", "vob", "mpg", "mpeg",
222 ],
223 ),
224 (
225 "audio",
226 &[
227 "mp3", "m4a", "aac", "flac", "ape", "opus", "ogg", "oga", "wav", "aiff", "wma", "dsf",
228 "dff",
229 ],
230 ),
231 (
232 "picture",
233 &[
234 "png", "jpg", "jpeg", "heic", "heif", "webp", "avif", "gif", "bmp", "tif", "tiff",
235 "jxl",
236 ],
237 ),
238];
239
240pub fn file_kind_extensions(kind: &str) -> &'static [&'static str] {
242 FILE_KIND_EXTENSIONS
243 .iter()
244 .find(|(name, _)| *name == kind)
245 .map_or(&[], |(_, extensions)| extensions)
246}
247
248pub fn parse_file_kinds(text: &str) -> Result<String, String> {
252 let mut kinds: Vec<String> = Vec::new();
253 for word in text
254 .split(|c: char| c.is_whitespace() || c == ',')
255 .filter(|word| !word.is_empty())
256 {
257 let word = word.to_lowercase();
258 let known = match word.strip_prefix('.') {
259 Some(ext) => {
260 !ext.is_empty()
261 && ext.len() <= 16
262 && ext
263 .chars()
264 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
265 }
266 None => FILE_KINDS.contains(&word.as_str()),
267 };
268 if !known {
269 return Err(format!(
270 "`{word}` is neither a kind ({}) nor an extension such as `.srt`",
271 FILE_KINDS.join(", ")
272 ));
273 }
274 if kinds.contains(&word) {
275 return Err(format!("`{word}` is written twice"));
276 }
277 kinds.push(word);
278 }
279 if kinds.is_empty() {
280 return Err("name at least one kind, such as \"video\"".into());
281 }
282 if kinds.len() > 1 && kinds.iter().any(|kind| kind == "any") {
283 return Err("`any` already takes every file".into());
284 }
285 Ok(kinds.join(" "))
286}
287
288pub const DYNAMIC_ITEMS: &[&str] = &["Sidebar", "Tabs", "Toolbar", "CommandMenu", "StatusItem"];
293
294pub const MENU_ROLES: &[&str] = &["app", "about", "file", "edit", "view", "window", "help"];
298
299pub const DRAWING: &[&str] = &["Canvas", "Shape", "Series", "Pattern"];
301
302pub const MOTION_PROPS: &[PropSpec] = &[
303 opt("appear", PropKind::Transition),
304 opt("appear_delay", PropKind::Number),
305 opt("disappear", PropKind::Transition),
306 opt("reflow", PropKind::MotionToken),
307 opt("hero", PropKind::HeroName),
308 opt("hero_key", PropKind::KeyValue),
309 opt("reveal", PropKind::Keyword(&["hover"])),
310 opt("animate", PropKind::MotionToken),
311 opt("appearance", PropKind::Keyword(&["light", "dark"])),
312 opt("tint", PropKind::ColorRole),
313 opt("scroll_scale", PropKind::Number),
314 opt("scroll_opacity", PropKind::Number),
315 opt("scroll_origin_x", PropKind::Number),
316 opt("scroll_origin_y", PropKind::Number),
317 opt("nudge", PropKind::Bool),
318 opt("nudge_key", PropKind::KeyValue),
319 opt("nudge_scale", PropKind::Number),
320 opt("reduce_motion", PropKind::Bool),
321 opt("spin", PropKind::Bool),
326 opt("spin_period", PropKind::Number),
327 opt("glow", PropKind::Number),
333 opt("glow_opacity", PropKind::Number),
334 opt("blur", PropKind::Length),
338 opt("stagger", PropKind::Number),
339 opt("idle_area", PropKind::Number),
343];
344
345pub const PLACEMENT_PROPS: &[PropSpec] = &[
348 opt("at", PropKind::NumberValue),
349 opt("from", PropKind::NumberValue),
350 opt("to", PropKind::NumberValue),
351];
352
353pub const STAGGERING: &[&str] = &["Column", "Row", "Grid", "Stack", "ForEach", "List"];
355
356const fn req(name: &'static str, kind: PropKind) -> PropSpec {
357 PropSpec {
358 name,
359 kind,
360 required: true,
361 }
362}
363
364const fn opt(name: &'static str, kind: PropKind) -> PropSpec {
365 PropSpec {
366 name,
367 kind,
368 required: false,
369 }
370}
371
372pub const TEXT_STYLES: &[&str] = &[
373 "large_title",
374 "title",
375 "title2",
376 "title3",
377 "headline",
378 "subheadline",
379 "body",
380 "callout",
381 "footnote",
382 "caption",
383 "caption2",
384 "control",
387 "code",
390];
391
392pub const EMPHASIS: &[&str] = &["primary", "secondary", "tertiary"];
393
394pub const LAYOUT_SIZES: &[&str] = &["compact", "medium", "expanded", "large", "tv"];
398
399const FEATURE: PropSpec = opt("feature", PropKind::Feature);
401
402pub const VIDEO_FIT: &[&str] = &["contain", "cover", "fill"];
405
406pub const STACK_ALIGN: &[&str] = &[
408 "top_leading",
409 "top",
410 "top_trailing",
411 "leading",
412 "center",
413 "trailing",
414 "bottom_leading",
415 "bottom",
416 "bottom_trailing",
417];
418
419pub const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg"];
421
422const fn spec(
423 name: &'static str,
424 props: &'static [PropSpec],
425 children: Children,
426 summary: &'static str,
427) -> ComponentSpec {
428 ComponentSpec {
429 name,
430 props,
431 children,
432 template: false,
433 action: ActionRule::None,
434 placement: Placement::Anywhere,
435 interactive: false,
436 summary,
437 }
438}
439
440pub const COMPONENTS: &[ComponentSpec] = &[
441 ComponentSpec {
442 placement: Placement::Root,
443 ..spec(
444 "WindowSet",
445 &[
446 req("handle", PropKind::TypeName),
447 req("primary", PropKind::WindowId),
448 opt("accent", PropKind::KeyValue),
449 opt("palette", PropKind::KeyValue),
450 opt("appearance", PropKind::KeyValue),
451 opt("icon", PropKind::KeyValue),
452 ],
453 Children::Only(&["Window", "MenuBar", "StatusItem"]),
454 "Window catalog of a session root, with the application's `MenuBar` and `StatusItem`",
455 )
456 },
457 ComponentSpec {
458 placement: Placement::Within(&["WindowSet"]),
459 ..spec(
460 "MenuBar",
461 &[],
462 Children::Only(&["CommandMenu"]),
463 "The application's menus in the system menu bar, where the platform has one (macOS; `window.menu_bar` holds there): each `CommandMenu` is a menu of its own, or with `role` adds its items to the system's menu of that name. Its items run their commands from any window; the drafts they read and write are the primary window's. Elsewhere it shows nothing",
464 )
465 },
466 ComponentSpec {
467 placement: Placement::Within(&["WindowSet"]),
468 ..spec(
469 "StatusItem",
470 &[
471 req("label", PropKind::LocalizedText),
472 opt("icon", PropKind::Icon),
473 opt("image", PropKind::SvgResource),
474 opt("login_item", PropKind::Flag),
475 ],
476 Children::Only(&["CommandItem", "CommandMenu", "Divider"]),
477 "The application's item in the system's status area, where the platform has one. With `icon` the macOS menu bar shows that symbol and the notification area of the Windows taskbar and the status icons of Linux desktops show the application icon; with `image`, a declared one-colour SVG, all three show its shape in the colour of their bar (white on a dark one, black on a light one), as status icons are. `label` is its tooltip and accessible name. Its menu opens the primary window, then has its children, then quits the application; a click on Windows and Linux opens the primary window. With it, closing the last window leaves the application running. `login_item: true` adds “Open at Login” before quitting: while it is checked the system starts the application when the user logs in, with no window, only this item. Its items run without a window: they read engine state and run commands, not drafts. Elsewhere it shows nothing",
478 )
479 },
480 ComponentSpec {
481 placement: Placement::Within(&["MenuBar", "CommandMenu", "StatusItem"]),
482 ..spec(
483 "CommandMenu",
484 &[
485 opt("label", PropKind::LocalizedText),
486 opt("role", PropKind::Keyword(MENU_ROLES)),
487 ],
488 Children::Only(&["CommandItem", "CommandMenu", "Divider"]),
489 "A menu of the `MenuBar` titled `label`, or with `role` the system's menu of that name (`app`, `file`, `edit`, `view`, `window`, `help`) taking its items; `about` puts its items in place of the application menu's About item, as `help` puts them in place of the help menu's help item; inside another `CommandMenu` or a `StatusItem` a submenu that opens beside its item; `Divider`s separate groups of items",
490 )
491 },
492 ComponentSpec {
493 placement: Placement::Within(&["CommandMenu", "StatusItem"]),
494 action: ActionRule::Required,
495 interactive: true,
496 ..spec(
497 "CommandItem",
498 &[
499 req("id", PropKind::Id),
500 req("label", PropKind::DisplayText),
501 opt("shortcut", PropKind::Shortcut),
502 opt("enabled", PropKind::Bool),
503 opt("checked", PropKind::Bool),
504 opt("pick", PropKind::FileField),
505 opt("files", PropKind::FileKinds),
506 opt("paste", PropKind::TextField),
507 ],
508 Children::None,
509 "A command of a `CommandMenu` or `StatusItem`: the menu shows its `shortcut`, greys it out while `enabled` is false and checks it while `checked` holds; `pick` asks for a file and `paste` reads the clipboard as `Button` does",
510 )
511 },
512 ComponentSpec {
513 placement: Placement::Within(&["WindowSet"]),
514 action: ActionRule::Optional,
515 ..spec(
516 "Window",
517 &[
518 req("id", PropKind::WindowId),
519 req("title", PropKind::LocalizedText),
520 opt("initial", PropKind::Keyword(&["open", "closed"])),
521 opt(
522 "presentation",
523 PropKind::Keyword(&["auto", "virtual", "floating", "native_required"]),
524 ),
525 opt(
526 "role",
527 PropKind::Keyword(&["standard", "settings", "dialog"]),
528 ),
529 opt("width", PropKind::Number),
530 opt("height", PropKind::Number),
531 opt("min_width", PropKind::Number),
532 opt("min_height", PropKind::Number),
533 opt("confirm_close", PropKind::Bool),
534 opt("close_message", PropKind::LocalizedText),
535 opt("chrome", PropKind::Keyword(&["standard", "unified"])),
536 opt("immersive", PropKind::Bool),
537 opt("landscape", PropKind::Bool),
538 opt("idle_after", PropKind::Number),
539 opt("idle_enabled", PropKind::Bool),
540 ],
541 Children::One,
542 "Logical window with its own identity and state; `confirm_close` asks before closing, and the action saves",
543 )
544 },
545 spec(
546 "FocusRegion",
547 &[],
548 Children::One,
549 "An editing region around one content view: while a text field inside is editing, the first pointer press outside ends editing without submitting or clearing and consumes the entire press; controls inside and subsequent outside presses work normally",
550 ),
551 spec(
552 "Column",
553 &[
554 opt("gap", PropKind::Length),
555 opt("padding", PropKind::Length),
556 opt("padding_x", PropKind::Length),
557 opt("padding_y", PropKind::Length),
558 opt("width", PropKind::Length),
559 opt("height", PropKind::Length),
560 opt(
561 "align",
562 PropKind::Keyword(&["leading", "center", "trailing"]),
563 ),
564 opt("variant", PropKind::StyleVariant),
565 ],
566 Children::Any,
567 "Vertical arrangement; a `style!` variant may set its lengths per platform",
568 ),
569 spec(
570 "Row",
571 &[
572 opt("gap", PropKind::Length),
573 opt("padding", PropKind::Length),
574 opt("padding_x", PropKind::Length),
575 opt("padding_y", PropKind::Length),
576 opt("width", PropKind::Length),
577 opt("height", PropKind::Length),
578 opt(
579 "align",
580 PropKind::Keyword(&["top", "center", "bottom", "baseline"]),
581 ),
582 opt("variant", PropKind::StyleVariant),
583 ],
584 Children::Any,
585 "Horizontal arrangement; `align: baseline` lines the texts up on the baseline of their first line; a `style!` variant may set its lengths per platform",
586 ),
587 spec(
588 "Accordion",
589 &[
590 req("count", PropKind::NumberValue),
591 req("selected", PropKind::NumberValue),
592 req("collapsed_width", PropKind::Length),
593 opt("height", PropKind::Length),
594 opt("gap", PropKind::Length),
595 opt("corner", PropKind::Length),
596 ],
597 Children::Any,
598 "Horizontal accordion: one column expands into the remaining width; without height it fills the available height; animated widths share a layout and fixed gaps",
599 ),
600 spec(
601 "Grid",
602 &[
603 req("min_width", PropKind::Length),
604 opt("gap", PropKind::Length),
605 opt("row_gap", PropKind::Length),
606 opt("padding", PropKind::Length),
607 opt("padding_x", PropKind::Length),
608 opt("padding_y", PropKind::Length),
609 opt("cell_ratio", PropKind::Number),
610 opt("cell_extra", PropKind::Length),
611 opt("masonry", PropKind::Flag),
612 ],
613 Children::Any,
614 "Equal columns, as many as fit with each at least `min_width` wide; children fill them row by row; with `cell_ratio` every row is the column width ÷ `cell_ratio` + `cell_extra` tall, its children at the top, and taller content runs into the gap below; with `masonry` each child is as tall as it is and stands under the column that ends highest so far, the leftmost of those that tie",
615 ),
616 spec(
617 "Stack",
618 &[
619 opt("variant", PropKind::StyleVariant),
620 opt("enabled", PropKind::Bool),
621 opt("align", PropKind::Keyword(STACK_ALIGN)),
622 opt("width", PropKind::Length),
623 opt("height", PropKind::Length),
624 opt("under_bars", PropKind::Flag),
625 ],
626 Children::Any,
627 "Layers on top of each other, later children above earlier ones; as large as the largest unless `width` or `height` says. With `under_bars: true` it fills the window to its edges while it is shown: under the title bar and toolbar of a desktop window, under a phone's status bar and home indicator, as a scene drawn in window coordinates does",
628 ),
629 spec(
630 "Marquee",
631 &[
632 req("duration", PropKind::Length),
633 opt("phase", PropKind::Number),
634 opt("paused", PropKind::Bool),
635 opt("opacity", PropKind::NumberValue),
636 ],
637 Children::Any,
638 "A clipped horizontal scrolling line; the renderer owns its clock and pause state",
639 ),
640 spec(
641 "ScaleBox",
642 &[
643 req("width", PropKind::Length),
644 req("height", PropKind::Length),
645 ],
646 Children::Any,
647 "A fixed logical coordinate space, uniformly fitted and centered in the available size",
648 ),
649 spec(
650 "Positioned",
651 &[
652 req("x", PropKind::Length),
653 req("y", PropKind::Length),
654 req("width", PropKind::Length),
655 req("height", PropKind::Length),
656 opt("bleed", PropKind::Bool),
657 ],
658 Children::Any,
659 "A layer positioned from the top leading corner of its containing coordinate space; with `bleed`, a layer of a `ScaleBox` scene continues past each scene edge it reaches, to the edge of the box",
660 ),
661 spec(
662 "Modal",
663 &[
664 opt("width", PropKind::Length),
665 opt("height", PropKind::Length),
666 opt("cover", PropKind::Keyword(&["compact"])),
667 ],
668 Children::Any,
669 "Centered modal content, constrained to the hosting window, above a dimmed input-blocking backdrop; without `height` it is as tall as its content, a scroll view in it as tall as what it scrolls, until the window is too short; with `cover: compact` it covers the whole window where the window is compact, as a phone's full-screen flow",
670 ),
671 spec(
672 "Flow",
673 &[
674 opt("gap", PropKind::Length),
675 opt(
676 "align",
677 PropKind::Keyword(&["leading", "center", "trailing"]),
678 ),
679 ],
680 Children::Any,
681 "Items wrapping onto new rows at the available width, each row at the leading edge or at `align`; each row centres its items in its height, and a `Spacer` takes the room left at the end of its row",
682 ),
683 ComponentSpec {
684 action: ActionRule::Required,
685 ..spec(
686 "Timer",
687 &[
688 req("interval", PropKind::Number),
689 opt("enabled", PropKind::Bool),
690 ],
691 Children::None,
692 "Invoke a command at an interval in seconds while mounted and enabled; automatically cancels on removal",
693 )
694 },
695 ComponentSpec {
696 action: ActionRule::Required,
697 ..spec(
698 "Watch",
699 &[req("value", PropKind::Watched)],
700 Children::None,
701 "Invoke a command when mounted and whenever the value changes, such as telling the engine which rows are selected",
702 )
703 },
704 ComponentSpec {
705 action: ActionRule::Required,
706 ..spec(
707 "Shortcut",
708 &[
709 req("key", PropKind::Shortcut),
710 opt("enabled", PropKind::Bool),
711 ],
712 Children::None,
713 "A keyboard shortcut without a control of its own: the key runs the action while the shortcut is mounted and enabled; on a television, the remote's button that stands for the key",
714 )
715 },
716 ComponentSpec {
717 placement: Placement::Within(&["Picker"]),
718 ..spec(
719 "PickerOption",
720 &[
721 req("value", PropKind::KeyValue),
722 req("label", PropKind::DisplayText),
723 ],
724 Children::None,
725 "An explicitly labelled picker option, including live counts",
726 )
727 },
728 spec(
729 "Rail",
730 &[
731 opt("axis", PropKind::Keyword(&["horizontal", "vertical"])),
732 opt("anchor", PropKind::Keyword(&["start", "center", "end"])),
733 opt("overhang", PropKind::Length),
734 ],
735 Children::Any,
736 "A strip along `axis` that places each child at the fraction `at` of its length, or between the fractions `from` and `to`; the fractions may follow state; with `overhang` the scale runs that far past both ends, fraction 0 standing before the rail's start",
737 ),
738 spec("Spacer", &[], Children::None, "Flexible space"),
739 spec(
740 "WindowButtons",
741 &[],
742 Children::None,
743 "The room the window's own buttons take at the leading end of content that runs under the title bar: the traffic lights on macOS; nothing, and no gap, where the window has no buttons there",
744 ),
745 spec("Divider", &[], Children::None, "Separator line"),
746 spec(
747 "ScrollView",
748 &[
749 opt("axis", PropKind::Keyword(&["vertical", "horizontal"])),
750 opt("snap", PropKind::Keyword(&["start", "center"])),
751 opt("position", PropKind::PositionField),
752 opt("anchor", PropKind::Number),
753 opt("browsing", PropKind::BoolField),
754 opt("arrival", PropKind::Keyword(&["nudge"])),
755 opt(
756 "indicators",
757 PropKind::Keyword(&["automatic", "hidden", "mouse"]),
758 ),
759 opt("open_at", PropKind::Number),
760 opt("open_center", PropKind::NumberValue),
761 ],
762 Children::Any,
763 "Scrolling container; `open_at` is where it opens: the point at that fraction of the content at the same fraction of the view, 0 the start, 0.5 the middle at the middle, 1 the end; `open_center` opens with the point at that fraction of the content in the middle of the view, or as near the middle as the content reaches, so the point is always in view; it may be bound, and is read once as the view opens; with `snap`, scrolling comes to rest with an item at the start or center; `position` holds the key of the item there, and setting it scrolls to that item, which with `arrival: nudge` gives a nudge when it arrives; `anchor` is where along the view, as a fraction from 0 at its start to 1 at its end, the item of `position` stands when nothing snaps, as near as the content reaches; `browsing` is a session flag that is true while the person scrolls by hand and false once the scrolling stops, not when setting `position` scrolls; `indicators: hidden` scrolls without showing the scroll bar, for a shelf that has its own paging buttons; `indicators: mouse` shows it only while a mouse is in use, which cannot drag the shelf as a finger does, and hides it for touch",
764 ),
765 spec(
766 "SplitView",
767 &[],
768 Children::Only(&["Sidebar", "Detail"]),
769 "Sidebar and detail columns",
770 ),
771 ComponentSpec {
772 placement: Placement::Within(&["SplitView"]),
773 ..spec(
774 "Sidebar",
775 &[
776 req("selection", PropKind::SelectionField),
777 opt("width", PropKind::Length),
778 opt("id", PropKind::Id),
779 opt("fallback", PropKind::SelectionValue),
780 ],
781 Children::Only(&[
782 "Search",
783 "Section",
784 "SidebarItem",
785 "Pages",
786 "SidebarHeader",
787 "SidebarFooter",
788 ]),
789 "Sidebar list selecting a destination",
790 )
791 },
792 ComponentSpec {
793 placement: Placement::Within(&["Sidebar"]),
794 ..spec(
795 "SidebarHeader",
796 &[opt("role", PropKind::Keyword(&["content", "title"]))],
797 Children::Any,
798 "Content pinned to the top of the sidebar, above its list, such as the application's name and icon; `role: title` says it only names the window with the application's icon and name, so platforms whose title bar already shows the application's name (Windows, Linux) leave it out",
799 )
800 },
801 ComponentSpec {
802 placement: Placement::Within(&["Sidebar"]),
803 ..spec(
804 "SidebarFooter",
805 &[],
806 Children::Any,
807 "Content pinned to the bottom of the sidebar, below its list, top to bottom",
808 )
809 },
810 ComponentSpec {
811 placement: Placement::Within(&["SplitView"]),
812 ..spec(
813 "Detail",
814 &[opt("titled", PropKind::Bool)],
815 Children::Any,
816 "Detail column; `titled: false` keeps the selected destination's name out of its title bar, for pages that show their own title",
817 )
818 },
819 ComponentSpec {
820 placement: Placement::Within(&["Sidebar", "Section"]),
821 ..spec(
822 "SidebarItem",
823 &[
824 req("value", PropKind::SelectionValue),
825 req("label", PropKind::DisplayText),
826 opt("icon", PropKind::Icon),
827 opt("badge", PropKind::NumberValue),
828 opt("variant", PropKind::StyleVariant),
829 ],
830 Children::None,
831 "Selectable sidebar destination",
832 )
833 },
834 spec(
835 "Section",
836 &[
837 opt("title", PropKind::DisplayText),
838 opt("footer", PropKind::DisplayText),
839 opt("style", PropKind::Keyword(&["grouped", "plain"])),
840 opt("padding_x", PropKind::Length),
841 opt("padding_y", PropKind::Length),
842 ],
843 Children::Any,
844 "Titled group inside a form, list or sidebar; `style: plain` sets its rows on the page itself, without the group's background, margins or separators, as a heading that scrolls with the form, and the form's row insets do not reach them; `padding_x` and `padding_y` are its rows' insets",
845 ),
846 spec(
847 "Slot",
848 &[req("id", PropKind::SlotName)],
849 Children::None,
850 "Where the application's modules contribute components, in the order Bern.toml lists the modules",
851 ),
852 ComponentSpec {
853 placement: Placement::Within(&["Sidebar", "Section", "Tabs"]),
854 ..spec(
855 "Pages",
856 &[req("slot", PropKind::SlotName)],
857 Children::None,
858 "One sidebar item, or in `Tabs` one tab showing the page, for every page the modules contribute to a page slot",
859 )
860 },
861 spec(
862 "PageContent",
863 &[
864 req("slot", PropKind::SlotName),
865 req("selected", PropKind::SelectionField),
866 ],
867 Children::None,
868 "The contributed page of a page slot that `selected` names; nothing for the application's own pages",
869 ),
870 spec(
871 "Form",
872 &[
873 opt("title", PropKind::DisplayText),
874 opt("variant", PropKind::StyleVariant),
875 opt("gap", PropKind::Length),
876 opt("row_height", PropKind::Length),
877 opt("padding_x", PropKind::Length),
878 opt("padding_y", PropKind::Length),
879 ],
880 Children::Any,
881 "Grouped form of labelled controls, optionally titling its page; `gap` is the room between its sections, `row_height` the least height of a row, and `padding_x` / `padding_y` the rows' insets in every section; a form opening with a plain section starts at the top of its page, also when a slot, a component or a condition gives that section",
882 ),
883 spec(
884 "Navigation",
885 &[req("path", PropKind::PathField), opt("id", PropKind::Id)],
886 Children::Any,
887 "Push navigation bound to a typed route path",
888 ),
889 ComponentSpec {
890 template: true,
891 placement: Placement::Within(&["Navigation"]),
892 ..spec(
893 "Destination",
894 &[],
895 Children::Any,
896 "Content shown for a pushed route",
897 )
898 },
899 ComponentSpec {
900 interactive: true,
901 ..spec(
902 "NavLink",
903 &[req("id", PropKind::Id), req("route", PropKind::Route)],
904 Children::Any,
905 "Row that pushes a route",
906 )
907 },
908 spec(
909 "ControlGroup",
910 &[
911 opt("label", PropKind::DisplayText),
912 opt("height", PropKind::Length),
913 ],
914 Children::Any,
915 "Related native controls sharing one capsule surface and equal-height hit targets",
916 ),
917 spec(
918 "Tabs",
919 &[
920 req("selection", PropKind::SelectionField),
921 opt("id", PropKind::Id),
922 opt("fallback", PropKind::SelectionValue),
923 opt("bar", PropKind::Keyword(&["standard", "floating"])),
924 ],
925 Children::Only(&["Tab", "Pages", "TabAccessory"]),
926 "Sibling pages switched by tabs; `bar: floating` floats the tab bar on phones as a capsule clear of the screen's edges",
927 ),
928 ComponentSpec {
929 placement: Placement::Within(&["Tabs"]),
930 ..spec(
931 "TabAccessory",
932 &[opt("shown", PropKind::Bool)],
933 Children::Any,
934 "A bar kept with the tabs over every page, such as what is playing",
935 )
936 },
937 ComponentSpec {
938 placement: Placement::Within(&["Tabs"]),
939 ..spec(
940 "Tab",
941 &[
942 req("value", PropKind::SelectionValue),
943 req("label", PropKind::DisplayText),
944 opt("icon", PropKind::Icon),
945 opt("display", PropKind::Keyword(&["title_and_icon", "icon"])),
946 ],
947 Children::Any,
948 "One tab page; `display: icon` shows only its icon in the tab bar, the label staying its accessible name",
949 )
950 },
951 ComponentSpec {
952 action: ActionRule::Optional,
953 interactive: true,
954 ..spec(
955 "SplitButton",
956 &[
957 req("id", PropKind::Id),
958 req("label", PropKind::DisplayText),
959 opt("value", PropKind::BoolField),
960 opt("checked", PropKind::Bool),
961 opt("icon", PropKind::Icon),
962 opt("shape", PropKind::Keyword(&["capsule"])),
963 opt("height", PropKind::Length),
964 opt("more_label", PropKind::DisplayText),
965 opt("shortcut", PropKind::Shortcut),
966 ],
967 Children::Any,
968 "A toggle with a second part beside it that shows its children in a popover, such as the settings of what it turns on; like a `Switch`, it toggles a session field (`value`) or shows engine state (`checked`) whose change runs the action",
969 )
970 },
971 ComponentSpec {
972 interactive: true,
973 action: ActionRule::Optional,
974 ..spec(
975 "PopoverButton",
976 &[
977 opt(
978 "panel_appearance",
979 PropKind::Keyword(&["inherit", "window", "surface"]),
980 ),
981 req("id", PropKind::Id),
982 req("label", PropKind::DisplayText),
983 opt("icon", PropKind::Icon),
984 opt("font_size", PropKind::Number),
985 opt("prominence", PropKind::Keyword(&["standard", "plain"])),
986 opt("presented", PropKind::BoolField),
987 opt("open", PropKind::Bool),
988 opt("shortcut", PropKind::Shortcut),
989 ],
990 Children::Any,
991 "A button that shows its children in a popover; `presented` binds a session field, or `open` follows engine state and commits opening and dismissal through its action; `prominence: plain` draws no background and `font_size` sizes the icon, as on an `IconButton`",
992 )
993 },
994 spec(
995 "Inspector",
996 &[
997 req("presented", PropKind::BoolField),
998 opt("width", PropKind::Number),
999 ],
1000 Children::Any,
1001 "A side panel at the trailing edge of the container it stands in, shown while `presented` is true; the container's other content makes room",
1002 ),
1003 spec(
1004 "Toast",
1005 &[
1006 req("content", PropKind::DisplayText),
1007 opt("icon", PropKind::Icon),
1008 opt("key", PropKind::KeyValue),
1009 opt("duration", PropKind::Number),
1010 ],
1011 Children::None,
1012 "A short notice that shows when it appears or its `key` (else its content) changes, and hides itself after `duration` seconds (3 unless given)",
1013 ),
1014 ComponentSpec {
1015 action: ActionRule::Optional,
1016 interactive: true,
1017 ..spec(
1018 "Pinch",
1019 &[
1020 req("id", PropKind::Id),
1021 req("label", PropKind::DisplayText),
1022 req("value", PropKind::NumberField),
1023 req("min", PropKind::Number),
1024 req("max", PropKind::Number),
1025 FEATURE,
1026 ],
1027 Children::One,
1028 "Pinching its content scales `value` within `min` and `max`; the action runs when the fingers lift",
1029 )
1030 },
1031 ComponentSpec {
1032 action: ActionRule::Optional,
1033 interactive: true,
1034 ..spec(
1035 "Swipe",
1036 &[
1037 req("id", PropKind::Id),
1038 req("label", PropKind::DisplayText),
1039 req("value", PropKind::NumberField),
1040 req("min", PropKind::Number),
1041 req("max", PropKind::Number),
1042 opt("axis", PropKind::Keyword(&["vertical", "horizontal"])),
1043 opt("distance", PropKind::Number),
1044 FEATURE,
1045 ],
1046 Children::One,
1047 "Dragging across its content moves `value` within `min` and `max`: up or right raises it, and `distance` points (300 unless given) cross the whole range; the action runs when the finger lifts",
1048 )
1049 },
1050 ComponentSpec {
1051 action: ActionRule::Required,
1052 interactive: true,
1053 ..spec(
1054 "Drop",
1055 &[
1056 req("id", PropKind::Id),
1057 req("label", PropKind::DisplayText),
1058 req("value", PropKind::FileField),
1059 opt("files", PropKind::FileKinds),
1060 opt("over", PropKind::BoolField),
1061 opt("opens", PropKind::Flag),
1062 FEATURE,
1063 ],
1064 Children::One,
1065 "Its content accepts a file of `files` dragged onto it: `over` is true while one is held above it, and on dropping the file goes into `value` and the action runs. With `opens: true` it also takes the files the system opens with the application, which then registers for those kinds",
1066 )
1067 },
1068 spec(
1069 "Toolbar",
1070 &[],
1071 Children::Only(&[
1072 "Button",
1073 "IconButton",
1074 "Picker",
1075 "Menu",
1076 "Search",
1077 "Switch",
1078 "SplitButton",
1079 "PopoverButton",
1080 "Divider",
1081 "Spacer",
1082 "Text",
1083 ]),
1084 "Window or page level actions, and at most one search field; items between `Divider`s form one group. Items before a `Spacer` stand at the leading end, where a page's way back is, and the rest at the trailing end",
1085 ),
1086 spec(
1087 "Adaptive",
1088 &[opt("id", PropKind::Id)],
1089 Children::Only(&["Layout"]),
1090 "Chooses one `Layout` per size by the available width, or the `tv` layout on a television; state is kept across sizes",
1091 ),
1092 ComponentSpec {
1093 placement: Placement::Within(&["Adaptive"]),
1094 ..spec(
1095 "Layout",
1096 &[req("size", PropKind::Keyword(LAYOUT_SIZES))],
1097 Children::Any,
1098 "Content of one layout size",
1099 )
1100 },
1101 ComponentSpec {
1102 interactive: true,
1103 ..spec(
1104 "Menu",
1105 &[
1106 req("id", PropKind::Id),
1107 req("label", PropKind::DisplayText),
1108 opt("hide_disabled", PropKind::Flag),
1109 opt("icon", PropKind::Icon),
1110 opt("style", PropKind::Keyword(&["button", "icon"])),
1111 opt("shape", PropKind::Keyword(&["capsule"])),
1112 opt("height", PropKind::Length),
1113 ],
1114 Children::Only(&["MenuItem", "Divider"]),
1115 "Button opening a menu of commands; `style: icon` shows only the icon, as an `IconButton`, with `label` as its accessible name, and with `height` is a round button of that diameter",
1116 )
1117 },
1118 ComponentSpec {
1119 placement: Placement::Within(&["Menu", "TableMenu", "ContextMenu"]),
1120 action: ActionRule::Required,
1121 interactive: true,
1122 ..spec(
1123 "MenuItem",
1124 &[
1125 req("id", PropKind::Id),
1126 req("label", PropKind::DisplayText),
1127 opt("icon", PropKind::Icon),
1128 opt("role", PropKind::Keyword(&["normal", "destructive"])),
1129 opt("enabled", PropKind::Bool),
1130 FEATURE,
1131 opt("shortcut", PropKind::Shortcut),
1132 opt("pick", PropKind::FileField),
1133 opt("files", PropKind::FileKinds),
1134 opt("paste", PropKind::TextField),
1135 ],
1136 Children::None,
1137 "Menu command bound to a registered command; `pick` asks for a file as `Button` does; `paste` reads the clipboard as `Button` does",
1138 )
1139 },
1140 ComponentSpec {
1141 interactive: true,
1142 ..spec(
1143 "LanguagePicker",
1144 &[
1145 req("id", PropKind::Id),
1146 req("label", PropKind::DisplayText),
1147 opt("label_hidden", PropKind::Flag),
1148 ],
1149 Children::None,
1150 "Switches the interface language among the supported languages at run time; `label_hidden: true` keeps the label as its accessible name only, as on a `Picker`",
1151 )
1152 },
1153 ComponentSpec {
1154 template: true,
1155 ..spec(
1156 "List",
1157 &[
1158 opt("items", PropKind::Items),
1159 opt("key", PropKind::KeyField),
1160 ],
1161 Children::Any,
1162 "List of rows, static or keyed by data",
1163 )
1164 },
1165 ComponentSpec {
1166 template: true,
1167 action: ActionRule::Optional,
1168 interactive: true,
1169 ..spec(
1170 "Table",
1171 &[
1172 req("id", PropKind::Id),
1173 req("label", PropKind::DisplayText),
1174 req("items", PropKind::Items),
1175 req("key", PropKind::KeyField),
1176 opt("selection", PropKind::KeysField),
1177 ],
1178 Children::Only(&["TableColumn", "TableMenu"]),
1179 "Rows of data in columns, one row per item, keyed by `key`; `selection` holds the keys of the chosen rows (click, shift and the platform's toggle modifier choose several); the action is the primary action of the chosen rows: a double click or Enter, a tap on touch screens",
1180 )
1181 },
1182 ComponentSpec {
1183 placement: Placement::Within(&["Table"]),
1184 action: ActionRule::Optional,
1185 ..spec(
1186 "TableColumn",
1187 &[
1188 req("title", PropKind::DisplayText),
1189 opt("width", PropKind::Number),
1190 opt("sorted", PropKind::Bool),
1191 opt("descending", PropKind::Bool),
1192 ],
1193 Children::One,
1194 "A column of a `Table`: `title` heads it and its child is the cell of each row, written with the table's row variable; the action runs when the user clicks the heading to sort by it, and `sorted` and `descending` show the order the rows are in",
1195 )
1196 },
1197 ComponentSpec {
1198 placement: Placement::Within(&["Button"]),
1199 ..spec(
1200 "ContextMenu",
1201 &[],
1202 Children::Only(&["MenuItem", "Divider"]),
1203 "The menu its `Button` opens with a secondary click or a long press, such as what can be done with a song in a list; it is not part of the button's content",
1204 )
1205 },
1206 ComponentSpec {
1207 placement: Placement::Within(&["Table"]),
1208 ..spec(
1209 "TableMenu",
1210 &[],
1211 Children::Only(&["MenuItem", "Divider"]),
1212 "The menu a row of a `Table` opens with a secondary click or a long press; a row outside the selection becomes the selection first, so the items act on `selection`",
1213 )
1214 },
1215 ComponentSpec {
1216 template: true,
1217 ..spec(
1218 "PreviewList",
1219 &[
1220 req("items", PropKind::Items),
1221 req("key", PropKind::KeyField),
1222 req("item_height", PropKind::Number),
1223 opt("gap", PropKind::Number),
1224 opt("more_height", PropKind::Number),
1225 ],
1226 Children::Any,
1227 "A bounded vertical preview: creates only complete rows fitting its height, reserving a +N badge for omitted items; does not scroll",
1228 )
1229 },
1230 ComponentSpec {
1231 template: true,
1232 ..spec(
1233 "ForEach",
1234 &[
1235 req("items", PropKind::Items),
1236 req("key", PropKind::KeyField),
1237 ],
1238 Children::Any,
1239 "Keyed repetition inside any container",
1240 )
1241 },
1242 spec(
1243 "Text",
1244 &[
1245 req("content", PropKind::DisplayText),
1246 opt("style", PropKind::Keyword(TEXT_STYLES)),
1247 opt("emphasis", PropKind::Keyword(EMPHASIS)),
1248 opt("color", PropKind::ColorRole),
1249 opt("mark", PropKind::ColorRole),
1250 opt("lines", PropKind::Number),
1251 opt("font_size", PropKind::Length),
1252 opt("line_height", PropKind::Length),
1253 opt("markdown", PropKind::Flag),
1254 opt("bold", PropKind::Bool),
1255 ],
1256 Children::None,
1257 "Text with a semantic style; `color` draws it in the accent or a status color (`status.success`, `status.warning`, `status.error`); `bold` sets it bold while it holds, changing with `animate`; `mark` draws a band of the color under each line; `lines` shows at most that many lines, ending the last with an ellipsis; `line_height` sets the height of each line, the extra split above and below it as in CSS; with `markdown` the content's inline Markdown — `**strong**`, `*emphasis*`, `` `code` ``, `~~struck~~` and `[links](address)`, a backslash taking the next mark literally — shows as the platform styles it",
1258 ),
1259 spec(
1260 "Canvas",
1261 &[
1262 opt("width", PropKind::Length),
1263 opt("height", PropKind::Length),
1264 opt("label", PropKind::DisplayText),
1265 ],
1266 Children::Only(&["Shape", "Series", "Pattern"]),
1267 "A drawing of shapes, data series and patterns in order; `label` describes what it shows, without one it is decorative",
1268 ),
1269 ComponentSpec {
1270 placement: Placement::Within(&["Canvas"]),
1271 ..spec(
1272 "Shape",
1273 &[
1274 req(
1275 "kind",
1276 PropKind::Keyword(&["rect", "rounded", "capsule", "ellipse"]),
1277 ),
1278 opt("x", PropKind::Number),
1279 opt("y", PropKind::Number),
1280 opt("width", PropKind::Number),
1281 opt("height", PropKind::Number),
1282 opt("corner", PropKind::Number),
1283 opt("fill", PropKind::ColorRole),
1284 opt("fill_from", PropKind::ImageResource),
1285 opt("fade", PropKind::Keyword(&["up", "down", "left", "right"])),
1286 opt("stroke", PropKind::ColorRole),
1287 opt("stroke_width", PropKind::Number),
1288 opt("dash", PropKind::Number),
1289 opt("dash_gap", PropKind::Number),
1290 opt("opacity", PropKind::Number),
1291 ],
1292 Children::None,
1293 "A shape in canvas units; without a size it fills the canvas. `fill_from` fills it with the average color of a picture; `fade` lets the fill fade out toward an edge; `dash` dashes the stroke, dashes that long with gaps of `dash_gap`, by default as long",
1294 )
1295 },
1296 ComponentSpec {
1297 placement: Placement::Within(&["Canvas"]),
1298 ..spec(
1299 "Series",
1300 &[
1301 req("values", PropKind::NumberList),
1302 opt("kind", PropKind::Keyword(&["area", "line"])),
1303 opt("max", PropKind::NumberValue),
1304 opt("fill", PropKind::ColorRole),
1305 opt("stroke", PropKind::ColorRole),
1306 opt("stroke_width", PropKind::Number),
1307 opt("opacity", PropKind::Number),
1308 ],
1309 Children::None,
1310 "Numbers drawn across the canvas width as an area or a line, scaled to `max` (their largest without it)",
1311 )
1312 },
1313 ComponentSpec {
1314 placement: Placement::Within(&["Canvas"]),
1315 ..spec(
1316 "Pattern",
1317 &[
1318 req("name", PropKind::PatternName),
1319 opt("opacity", PropKind::Number),
1320 ],
1321 Children::None,
1322 "A pattern from `style!` tiled over the canvas",
1323 )
1324 },
1325 spec(
1326 "Label",
1327 &[
1328 req("title", PropKind::DisplayText),
1329 req("icon", PropKind::Icon),
1330 opt("style", PropKind::Keyword(TEXT_STYLES)),
1331 opt("font_size", PropKind::Number),
1332 opt("display", PropKind::Keyword(&["title_and_icon", "icon"])),
1333 ],
1334 Children::None,
1335 "Icon and title; `display: icon` hides the title visually while preserving its accessible name",
1336 ),
1337 spec(
1338 "Svg",
1339 &[
1340 req("source", PropKind::SvgResource),
1341 opt("static_source", PropKind::SvgResource),
1342 req("width", PropKind::Length),
1343 req("height", PropKind::Length),
1344 opt("label", PropKind::DisplayText),
1345 opt("fit", PropKind::Keyword(VIDEO_FIT)),
1346 opt("playing", PropKind::Bool),
1347 opt("speed", PropKind::NumberValue),
1348 opt("restart", PropKind::NumberValue),
1349 opt("interactive", PropKind::Flag),
1350 opt("data", PropKind::DisplayText),
1351 opt("external_links", PropKind::Flag),
1352 ],
1353 Children::None,
1354 "Bundled SVG document with CSS, SMIL and script animation; explicit viewport, playing/speed controls and restart revision; document-local interaction is enabled by default; a document that neither moves nor runs is drawn as a picture when the app is built, with no document engine",
1355 ),
1356 spec(
1357 "Image",
1358 &[
1359 req("source", PropKind::ImageResource),
1360 opt("label", PropKind::DisplayText),
1361 opt("width", PropKind::Length),
1362 opt("height", PropKind::Length),
1363 opt("fit", PropKind::Keyword(VIDEO_FIT)),
1364 opt("corner", PropKind::Length),
1365 opt("rendering", PropKind::Keyword(&["original", "template"])),
1366 opt("aspect", PropKind::Number),
1367 ],
1368 Children::None,
1369 "Picture from a declared resource, sized by `width` and/or `height`, its corners rounded by `corner`; `rendering: template` uses its alpha as a mask for the inherited text color, otherwise it keeps its colors; `label` describes it, without one it is decorative",
1370 ),
1371 spec(
1372 "Progress",
1373 &[
1374 opt("value", PropKind::NumberValue),
1375 opt("total", PropKind::Number),
1376 opt("label", PropKind::DisplayText),
1377 opt("style", PropKind::Keyword(&["bar", "ring"])),
1378 opt("width", PropKind::Length),
1379 opt("stroke_width", PropKind::Number),
1380 ],
1381 Children::None,
1382 "Progress of `value` out of `total` (1 unless given) as a bar; without `value`, an indeterminate spinner; `style: ring` draws it as an arc in the accent color from the top, clockwise, with round ends, `width` across and `stroke_width` thick, laid over what it rings",
1383 ),
1384 ComponentSpec {
1385 action: ActionRule::Required,
1386 interactive: true,
1387 ..spec(
1388 "Button",
1389 &[
1390 req("id", PropKind::Id),
1391 req("label", PropKind::DisplayText),
1392 opt("icon", PropKind::Icon),
1393 opt("shape", PropKind::Keyword(&["capsule", "chip"])),
1394 opt("height", PropKind::Length),
1395 opt("width", PropKind::Keyword(&["fill"])),
1396 opt(
1397 "role",
1398 PropKind::Keyword(&["normal", "destructive", "cancel"]),
1399 ),
1400 opt(
1401 "prominence",
1402 PropKind::Keyword(&["standard", "prominent", "plain"]),
1403 ),
1404 opt("enabled", PropKind::Bool),
1405 opt("variant", PropKind::StyleVariant),
1406 FEATURE,
1407 opt("shortcut", PropKind::Shortcut),
1408 opt("pick", PropKind::FileField),
1409 opt("files", PropKind::FileKinds),
1410 opt("paste", PropKind::TextField),
1411 opt("default_focus", PropKind::Flag),
1412 opt("clears", PropKind::TextField),
1413 ],
1414 Children::Any,
1415 "Button bound to a registered command; with one child, the child is what it shows and `label` its accessible name; `width: fill` stretches the system button across its container; with `pick`, it first asks for a file of `files` and runs the action once one is chosen; with `paste: draft.text`, it first puts the text on the clipboard into that draft and runs the action when there was some; `default_focus: true` starts the focus on it when it appears (a television's remote, or a keyboard); `clears: draft.text` empties that draft after the action and gives the keyboard back to the window, as a composer's send button does",
1416 )
1417 },
1418 ComponentSpec {
1419 action: ActionRule::Required,
1420 interactive: true,
1421 ..spec(
1422 "IconButton",
1423 &[
1424 req("id", PropKind::Id),
1425 req("icon", PropKind::Icon),
1426 opt("font_size", PropKind::Number),
1427 opt("height", PropKind::Length),
1428 req("label", PropKind::DisplayText),
1429 opt(
1430 "prominence",
1431 PropKind::Keyword(&["standard", "prominent", "plain"]),
1432 ),
1433 opt("enabled", PropKind::Bool),
1434 opt("variant", PropKind::StyleVariant),
1435 FEATURE,
1436 opt("shortcut", PropKind::Shortcut),
1437 opt("pick", PropKind::FileField),
1438 opt("files", PropKind::FileKinds),
1439 opt("paste", PropKind::TextField),
1440 opt("default_focus", PropKind::Flag),
1441 ],
1442 Children::None,
1443 "Icon-only button with an accessible name; `prominence: plain` draws no background, as the icons at the foot of a sidebar; `height` makes it a round system button of that diameter; `pick` asks for a file and `paste` reads the clipboard as `Button` does; `default_focus: true` starts the focus on it as `Button` does",
1444 )
1445 },
1446 ComponentSpec {
1447 action: ActionRule::Optional,
1448 interactive: true,
1449 ..spec(
1450 "TextInput",
1451 &[
1452 req("id", PropKind::Id),
1453 req("label", PropKind::DisplayText),
1454 opt("label_hidden", PropKind::Flag),
1455 opt("value", PropKind::TextField),
1456 opt("current", PropKind::TextValue),
1457 opt("prompt", PropKind::DisplayText),
1458 opt("kind", PropKind::Keyword(&["plain", "secure", "multiline"])),
1459 opt("submit", PropKind::Keyword(&["clear"])),
1460 opt("clear_key", PropKind::NumberValue),
1461 FEATURE,
1462 ],
1463 Children::None,
1464 "Text editing bound to a session draft (`value`) or showing engine state (`current`); with `value` the action runs on submit, and with `submit: clear` the draft then empties and the field gives up the keyboard, as a message composer does; with `current` each edit runs the action, in which the same expression is the text typed, and the engine's text does not replace the user's while the field is being edited",
1465 )
1466 },
1467 ComponentSpec {
1468 action: ActionRule::Optional,
1469 interactive: true,
1470 ..spec(
1471 "Search",
1472 &[
1473 req("id", PropKind::Id),
1474 req("value", PropKind::TextField),
1475 opt("prompt", PropKind::DisplayText),
1476 FEATURE,
1477 opt("shortcut", PropKind::Shortcut),
1478 opt("style", PropKind::Keyword(&["automatic", "palette"])),
1479 opt("panel_prompt", PropKind::DisplayText),
1480 opt("width", PropKind::Number),
1481 opt("height", PropKind::Number),
1482 opt("font_size", PropKind::Number),
1483 opt("suggest", PropKind::Flag),
1484 ],
1485 Children::Any,
1486 "Search text bound to a session draft; the action runs on submit; children are the results shown below the field while it is in use; with `suggest: true` they show before anything is typed too (on a television, whose search page types with the remote, always); a palette's `width` and `height` size its panel, a field in the page's its field, and `font_size` its text",
1487 )
1488 },
1489 ComponentSpec {
1490 action: ActionRule::Required,
1491 interactive: true,
1492 ..spec(
1493 "SearchItem",
1494 &[
1495 req("id", PropKind::Id),
1496 req("label", PropKind::DisplayText),
1497 opt("icon", PropKind::Icon),
1498 opt("detail", PropKind::DisplayText),
1499 opt("hint", PropKind::DisplayText),
1500 opt("navigation", PropKind::Bool),
1501 opt("pick", PropKind::FileField),
1502 opt("files", PropKind::FileKinds),
1503 FEATURE,
1504 ],
1505 Children::Any,
1506 "A palette result; activates and dismisses the search; optional content replaces the row; navigation false excludes it from arrow navigation",
1507 )
1508 },
1509 ComponentSpec {
1510 action: ActionRule::Optional,
1511 interactive: true,
1512 ..spec(
1513 "Switch",
1514 &[
1515 req("id", PropKind::Id),
1516 req("label", PropKind::DisplayText),
1517 opt("value", PropKind::BoolField),
1518 opt("checked", PropKind::Bool),
1519 opt("label_hidden", PropKind::Flag),
1520 opt("shape", PropKind::Keyword(&["capsule", "chip"])),
1521 opt("height", PropKind::Length),
1522 opt(
1523 "style",
1524 PropKind::Keyword(&["switch", "checkbox", "button"]),
1525 ),
1526 opt("icon", PropKind::Icon),
1527 opt("shortcut", PropKind::Shortcut),
1528 opt("login_item", PropKind::Flag),
1529 ],
1530 Children::None,
1531 "Boolean state; `style: button` is a button that stays pressed while on; `login_item: true` is whether the system opens the application at login, left out where there is no such thing",
1532 )
1533 },
1534 ComponentSpec {
1535 action: ActionRule::Required,
1536 interactive: true,
1537 ..spec(
1538 "ShortcutInput",
1539 &[
1540 req("id", PropKind::Id),
1541 req("label", PropKind::DisplayText),
1542 opt("label_hidden", PropKind::Flag),
1543 req("current", PropKind::Shortcut),
1544 opt("prompt", PropKind::DisplayText),
1545 opt("empty", PropKind::DisplayText),
1546 opt("enabled", PropKind::Bool),
1547 FEATURE,
1548 ],
1549 Children::None,
1550 "A keyboard shortcut chosen by pressing it: a button showing `current` as the platform writes shortcuts (`empty` while there is none); pressed, it shows `prompt` and takes the next key with its modifiers, Escape leaving it as it was and Delete alone clearing it; the action receives the new shortcut in place of `current`",
1551 )
1552 },
1553 ComponentSpec {
1554 action: ActionRule::Optional,
1555 interactive: true,
1556 ..spec(
1557 "Slider",
1558 &[
1559 req("id", PropKind::Id),
1560 req("label", PropKind::DisplayText),
1561 opt("label_hidden", PropKind::Flag),
1562 opt("value", PropKind::NumberField),
1563 opt("current", PropKind::NumberValue),
1564 opt("continuous", PropKind::Flag),
1565 req("min", PropKind::Number),
1566 req("max", PropKind::Number),
1567 opt("step", PropKind::Number),
1568 opt("min_icon", PropKind::Icon),
1569 opt("max_icon", PropKind::Icon),
1570 FEATURE,
1571 ],
1572 Children::None,
1573 "Range value previewed in the session; the action runs on commit",
1574 )
1575 },
1576 ComponentSpec {
1577 interactive: true,
1578 ..spec(
1579 "Video",
1580 &[
1581 req("id", PropKind::Id),
1582 opt("source", PropKind::MediaSource),
1583 opt("player", PropKind::Player),
1584 opt("label", PropKind::DisplayText),
1585 opt("controls", PropKind::Keyword(&["standard", "none"])),
1586 opt("fit", PropKind::Keyword(VIDEO_FIT)),
1587 opt("sizing", PropKind::Keyword(&["standard", "fill"])),
1588 opt("corner", PropKind::Length),
1589 ],
1590 Children::None,
1591 "Video of a player created from `source` or attached with `player`; `sizing: fill` fills a bounded container independently of the picture's `fit`; `corner` rounds the picture",
1592 )
1593 },
1594 ComponentSpec {
1595 template: true,
1596 interactive: true,
1597 ..spec(
1598 "Query",
1599 &[
1600 req("id", PropKind::Id),
1601 req("query", PropKind::Query),
1602 opt("loading", PropKind::DisplayText),
1603 opt("empty", PropKind::DisplayText),
1604 opt("failed", PropKind::DisplayText),
1605 opt("icon", PropKind::Icon),
1606 ],
1607 Children::Any,
1608 "Result of an asynchronous query: loading, ready, refreshing, failed and empty states; the template shows the data",
1609 )
1610 },
1611 ComponentSpec {
1612 action: ActionRule::Optional,
1613 interactive: true,
1614 ..spec(
1615 "Picker",
1616 &[
1617 req("id", PropKind::Id),
1618 req("label", PropKind::DisplayText),
1619 opt("value", PropKind::PickerField),
1620 opt("selected", PropKind::KeyValue),
1621 opt("label_hidden", PropKind::Flag),
1622 opt("shape", PropKind::Keyword(&["capsule"])),
1623 opt("height", PropKind::Length),
1624 opt(
1625 "style",
1626 PropKind::Keyword(&[
1627 "automatic",
1628 "menu",
1629 "segmented",
1630 "radio",
1631 "inline",
1632 "chips",
1633 ]),
1634 ),
1635 ],
1636 Children::Only(&["PickerOption"]),
1637 "Single choice among the labelled variants of an enum, with optional explicit labels; `style: chips` shows a chip per option, which in a `Flow` are items of the flow",
1638 )
1639 },
1640];
1641
1642pub fn component(name: &str) -> Option<&'static ComponentSpec> {
1643 COMPONENTS.iter().find(|spec| spec.name == name)
1644}
1645
1646#[derive(Clone, Debug, PartialEq, Eq)]
1648pub struct ShapeError {
1649 pub prop: Option<String>,
1651 pub message: String,
1652}
1653
1654pub struct ElementShape<'a> {
1656 pub name: &'a str,
1657 pub props: Vec<&'a str>,
1658 pub keywords: Vec<(&'a str, &'a str)>,
1660 pub has_template: bool,
1661 pub children: Option<Vec<ChildShape<'a>>>,
1662 pub has_action: bool,
1663 pub parent: Option<&'a str>,
1664 pub is_root: bool,
1665}
1666
1667pub enum ChildShape<'a> {
1668 Element(&'a str),
1669 Other,
1670}
1671
1672pub fn check_shape(shape: &ElementShape<'_>) -> Vec<ShapeError> {
1675 let mut errors = Vec::new();
1676 let Some(spec) = component(shape.name) else {
1677 let known: Vec<_> = COMPONENTS.iter().map(|spec| spec.name).collect();
1678 errors.push(ShapeError {
1679 prop: None,
1680 message: format!(
1681 "unknown component `{}`; built-in components are: {}",
1682 shape.name,
1683 known.join(", ")
1684 ),
1685 });
1686 return errors;
1687 };
1688 for prop in &shape.props {
1689 if spec.prop(prop).is_none() {
1690 let known: Vec<_> = spec.props.iter().map(|prop| prop.name).collect();
1691 errors.push(ShapeError {
1692 prop: Some(prop.to_string()),
1693 message: if known.is_empty() {
1694 format!("`{}` takes no properties", spec.name)
1695 } else {
1696 format!(
1697 "`{}` has no property `{prop}`; expected one of: {}",
1698 spec.name,
1699 known.join(", ")
1700 )
1701 },
1702 });
1703 }
1704 }
1705 for prop in spec.props.iter().filter(|prop| prop.required) {
1706 if !shape.props.contains(&prop.name) {
1707 errors.push(ShapeError {
1708 prop: None,
1709 message: format!("`{}` requires the property `{}`", spec.name, prop.name),
1710 });
1711 }
1712 }
1713 let list_needs_template = spec.name == "List" && shape.props.contains(&"items");
1714 if shape.has_template && !spec.template {
1715 errors.push(ShapeError {
1716 prop: None,
1717 message: format!("`{}` does not take a row template `|item|`", spec.name),
1718 });
1719 }
1720 if spec.template && !shape.has_template && (spec.name != "List" || list_needs_template) {
1721 errors.push(ShapeError {
1722 prop: None,
1723 message: format!("`{}` needs a template variable such as `|item|`", spec.name),
1724 });
1725 }
1726 if spec.name == "List" && shape.has_template && !list_needs_template {
1727 errors.push(ShapeError {
1728 prop: None,
1729 message: "a static `List` has no row template; add `items:` and `key:`".into(),
1730 });
1731 }
1732 if spec.name == "List" && shape.props.contains(&"items") != shape.props.contains(&"key") {
1733 errors.push(ShapeError {
1734 prop: None,
1735 message: "`List` needs both `items:` and `key:` for data rows".into(),
1736 });
1737 }
1738 if spec.name == "Image"
1741 && !shape.props.contains(&"width")
1742 && !shape.props.contains(&"height")
1743 && !shape.props.contains(&"aspect")
1744 && !shape.keywords.contains(&("fit", "cover"))
1745 {
1746 errors.push(ShapeError {
1747 prop: None,
1748 message: "`Image` needs `width:`, `height:` or `aspect:`, with one the other following the picture's aspect ratio; or `fit: cover` to cover the room its container gives".into(),
1749 });
1750 }
1751 let content = shape.children.as_deref().map(|children| {
1753 children
1754 .iter()
1755 .filter(|child| !matches!(child, ChildShape::Element("ContextMenu")))
1756 .count()
1757 });
1758 if spec.name == "Button" && content.is_some_and(|count| count > 0) {
1759 if content != Some(1) {
1760 errors.push(ShapeError {
1761 prop: None,
1762 message: "a `Button` shows one child; put several in a `Column`, `Row` or `Stack`"
1763 .into(),
1764 });
1765 }
1766 for prop in ["icon", "prominence"] {
1767 if shape.props.contains(&prop) {
1768 errors.push(ShapeError {
1769 prop: Some(prop.into()),
1770 message: format!(
1771 "a `Button` with content draws no label of its own, so `{prop}:` has no effect; show it in the content"
1772 ),
1773 });
1774 }
1775 }
1776 }
1777 if shape.props.contains(&"files")
1778 && !shape.props.contains(&"pick")
1779 && [
1780 "Button",
1781 "IconButton",
1782 "MenuItem",
1783 "SearchItem",
1784 "CommandItem",
1785 ]
1786 .contains(&spec.name)
1787 {
1788 errors.push(ShapeError {
1789 prop: Some("files".into()),
1790 message: format!(
1791 "`files:` says which files `pick:` asks for; add `pick: draft.file` to the `{}`",
1792 spec.name
1793 ),
1794 });
1795 }
1796 if shape.props.contains(&"paste") && shape.props.contains(&"pick") {
1797 errors.push(ShapeError {
1798 prop: Some("paste".into()),
1799 message: format!(
1800 "a `{}` either asks for a file (`pick:`) or reads the clipboard (`paste:`), not both",
1801 spec.name
1802 ),
1803 });
1804 }
1805 if spec.name == "StatusItem" && shape.props.contains(&"icon") == shape.props.contains(&"image")
1806 {
1807 errors.push(ShapeError {
1808 prop: None,
1809 message: "a `StatusItem` shows a symbol (`icon:`) or its own one-colour SVG (`image:`); write one of them".into(),
1810 });
1811 }
1812 if spec.name == "TextInput" {
1813 let current = shape.props.contains(&"current");
1814 if current == shape.props.contains(&"value") {
1815 errors.push(ShapeError {
1816 prop: None,
1817 message: "a `TextInput` edits a draft (`value: draft.text`) or shows engine state (`current: app.text`); write one of them".into(),
1818 });
1819 }
1820 if current && !shape.has_action {
1821 errors.push(ShapeError {
1822 prop: Some("current".into()),
1823 message: "`current` needs an action to hand the edited text to, such as `=> app.rename(app.text)`".into(),
1824 });
1825 }
1826 for prop in ["submit", "clear_key"] {
1827 if current && shape.props.contains(&prop) {
1828 errors.push(ShapeError {
1829 prop: Some(prop.into()),
1830 message: format!(
1831 "`{prop}` empties a draft; a field showing `current` has none"
1832 ),
1833 });
1834 }
1835 }
1836 }
1837 if spec.name == "CommandMenu" {
1838 let role = shape.props.contains(&"role");
1839 if role == shape.props.contains(&"label") {
1840 errors.push(ShapeError {
1841 prop: None,
1842 message: "a `CommandMenu` takes `label:` for a menu of its own, or `role:` to add its items to the system's menu of that name (`about` in place of its About item)".into(),
1843 });
1844 }
1845 if role && shape.parent != Some("MenuBar") {
1846 errors.push(ShapeError {
1847 prop: Some("role".into()),
1848 message: "`role:` names a menu of the menu bar; a submenu takes `label:`".into(),
1849 });
1850 }
1851 }
1852 if spec.name == "ScrollView"
1853 && shape.props.contains(&"anchor")
1854 && (!shape.props.contains(&"position") || shape.props.contains(&"snap"))
1855 {
1856 errors.push(ShapeError {
1857 prop: Some("anchor".into()),
1858 message: "`anchor` is where the item of `position` stands without `snap`; add `position: draft.x` and leave out `snap`".into(),
1859 });
1860 }
1861 if spec.name == "ScrollView"
1862 && shape.props.contains(&"arrival")
1863 && !shape.props.contains(&"position")
1864 {
1865 errors.push(ShapeError {
1866 prop: Some("arrival".into()),
1867 message: "`arrival` nudges the item that setting `position` scrolls to; add `position: draft.x`".into(),
1868 });
1869 }
1870 if spec.name == "Progress" {
1871 let ring = shape.keywords.contains(&("style", "ring"));
1872 if ring && !shape.props.contains(&"value") {
1873 errors.push(ShapeError {
1874 prop: Some("style".into()),
1875 message: "`style: ring` draws how far `value:` is; add `value:`".into(),
1876 });
1877 }
1878 for prop in ["width", "stroke_width"] {
1879 if !ring && shape.props.contains(&prop) {
1880 errors.push(ShapeError {
1881 prop: Some(prop.into()),
1882 message: format!("`{prop}:` sizes a ring; add `style: ring` or leave it out"),
1883 });
1884 }
1885 }
1886 }
1887 if spec.name == "Progress" && shape.props.contains(&"total") && !shape.props.contains(&"value")
1888 {
1889 errors.push(ShapeError {
1890 prop: Some("total".into()),
1891 message:
1892 "`total:` needs a `value:`; without one, `Progress` is an indeterminate spinner"
1893 .into(),
1894 });
1895 }
1896 if shape.props.contains(&"nudge_scale") && !shape.props.contains(&"nudge_key") {
1897 errors.push(ShapeError {
1898 prop: Some("nudge_scale".into()),
1899 message: "`nudge_scale` needs a `nudge_key` event binding".into(),
1900 });
1901 }
1902 if shape.props.contains(&"nudge") && shape.props.contains(&"nudge_key") {
1903 errors.push(ShapeError {
1904 prop: Some("nudge_key".into()),
1905 message: "choose `nudge` for a condition or `nudge_key` for repeated events".into(),
1906 });
1907 }
1908 if shape.props.contains(&"spin_period") && !shape.props.contains(&"spin") {
1909 errors.push(ShapeError {
1910 prop: Some("spin_period".into()),
1911 message: "`spin_period` needs a `spin` condition".into(),
1912 });
1913 }
1914 if shape.props.contains(&"glow_opacity") && !shape.props.contains(&"glow") {
1915 errors.push(ShapeError {
1916 prop: Some("glow_opacity".into()),
1917 message: "`glow_opacity` needs a `glow` radius".into(),
1918 });
1919 }
1920 if shape.props.contains(&"appear_delay") && !shape.props.contains(&"appear") {
1921 errors.push(ShapeError {
1922 prop: Some("appear_delay".into()),
1923 message: "`appear_delay` needs an `appear` transition".into(),
1924 });
1925 }
1926 if spec.name == "Slider"
1927 && shape.props.contains(&"continuous")
1928 && !shape.props.contains(&"current")
1929 {
1930 errors.push(ShapeError {
1931 prop: Some("continuous".into()),
1932 message: "`continuous` needs a controlled slider (`current`)".into(),
1933 });
1934 }
1935 if spec.name == "PopoverButton" {
1936 if shape.props.contains(&"open") && shape.props.contains(&"presented") {
1937 errors.push(ShapeError {
1938 prop: None,
1939 message: "choose `presented` or `open`, not both".into(),
1940 });
1941 }
1942 if shape.props.contains(&"open") && !shape.has_action {
1943 errors.push(ShapeError {
1944 prop: None,
1945 message: "`open` needs an action to commit the new value".into(),
1946 });
1947 }
1948 if shape.has_action && !shape.props.contains(&"open") {
1949 errors.push(ShapeError {
1950 prop: None,
1951 message: "a `PopoverButton` action needs `open`".into(),
1952 });
1953 }
1954 }
1955 if spec.name == "Text" && shape.props.contains(&"color") && shape.props.contains(&"emphasis") {
1956 errors.push(ShapeError {
1957 prop: Some("color".into()),
1958 message: "a text is drawn in its `color` or with an `emphasis`; write one of them"
1959 .into(),
1960 });
1961 }
1962 let login_item = spec.name == "Switch" && shape.props.contains(&"login_item");
1963 if login_item {
1964 for prop in ["value", "checked"] {
1965 if shape.props.contains(&prop) {
1966 errors.push(ShapeError {
1967 prop: Some(prop.into()),
1968 message: format!(
1969 "a `login_item` switch shows the system's login item; it takes no `{prop}`"
1970 ),
1971 });
1972 }
1973 }
1974 if shape.has_action {
1975 errors.push(ShapeError {
1976 prop: None,
1977 message: "a `login_item` switch changes the system's login item itself; it takes no action".into(),
1978 });
1979 }
1980 }
1981 if matches!(spec.name, "Switch" | "SplitButton" | "Picker" | "Slider") && !login_item {
1982 let controlled = if matches!(spec.name, "Switch" | "SplitButton") {
1983 "checked"
1984 } else if spec.name == "Slider" {
1985 "current"
1986 } else {
1987 "selected"
1988 };
1989 if shape.props.contains(&"value") == shape.props.contains(&controlled) {
1990 errors.push(ShapeError {
1991 prop: None,
1992 message: format!(
1993 "`{}` needs exactly one of `value` or `{controlled}`",
1994 spec.name
1995 ),
1996 });
1997 }
1998 if shape.props.contains(&controlled) && !shape.has_action {
1999 errors.push(ShapeError {
2000 prop: None,
2001 message: format!("`{controlled}` needs an action to commit the new value"),
2002 });
2003 }
2004 }
2005 match (spec.action, shape.has_action) {
2006 (ActionRule::Required, false) => errors.push(ShapeError {
2007 prop: None,
2008 message: format!(
2009 "error[B301]: UI action must bind a registered command; add `=> client.command()` to `{}`",
2010 spec.name
2011 ),
2012 }),
2013 (ActionRule::None, true) => errors.push(ShapeError {
2014 prop: None,
2015 message: format!("`{}` does not take an action", spec.name),
2016 }),
2017 _ => {}
2018 }
2019 let children = shape.children.as_deref().unwrap_or(&[]);
2020 match spec.children {
2021 Children::None if shape.children.is_some() => errors.push(ShapeError {
2022 prop: None,
2023 message: format!("`{}` does not take children", spec.name),
2024 }),
2025 Children::One if children.len() != 1 => errors.push(ShapeError {
2026 prop: None,
2027 message: format!("`{}` takes exactly one child", spec.name),
2028 }),
2029 Children::Only(allowed) => {
2030 for child in children {
2031 match child {
2032 ChildShape::Element(name) if allowed.contains(name) => {}
2033 ChildShape::Element(name) => errors.push(ShapeError {
2034 prop: None,
2035 message: format!(
2036 "`{}` cannot contain `{name}`; allowed children: {}",
2037 spec.name,
2038 allowed.join(", ")
2039 ),
2040 }),
2041 ChildShape::Other => errors.push(ShapeError {
2042 prop: None,
2043 message: format!(
2044 "`{}` only contains {} elements directly",
2045 spec.name,
2046 allowed.join(", ")
2047 ),
2048 }),
2049 }
2050 }
2051 }
2052 _ => {}
2053 }
2054 let count = |kind: &str| {
2055 children
2056 .iter()
2057 .filter(|child| matches!(child, ChildShape::Element(name) if *name == kind))
2058 .count()
2059 };
2060 if spec.name == "Sidebar" && count("SidebarFooter") > 1 {
2061 errors.push(ShapeError {
2062 prop: None,
2063 message: "a `Sidebar` has at most one `SidebarFooter`".into(),
2064 });
2065 }
2066 if spec.name == "Sidebar" && count("SidebarHeader") > 1 {
2067 errors.push(ShapeError {
2068 prop: None,
2069 message: "a `Sidebar` has at most one `SidebarHeader`".into(),
2070 });
2071 }
2072 if spec.name == "Table" && count("TableColumn") == 0 {
2073 errors.push(ShapeError {
2074 prop: None,
2075 message: "a `Table` needs at least one `TableColumn`".into(),
2076 });
2077 }
2078 if count("ContextMenu") > 1 {
2079 errors.push(ShapeError {
2080 prop: None,
2081 message: "a `Button` has at most one `ContextMenu`".into(),
2082 });
2083 }
2084 if spec.name == "Table" && count("TableMenu") > 1 {
2085 errors.push(ShapeError {
2086 prop: None,
2087 message: "a `Table` has at most one `TableMenu`".into(),
2088 });
2089 }
2090 if spec.name == "TableColumn"
2091 && shape.props.contains(&"descending")
2092 && !shape.props.contains(&"sorted")
2093 {
2094 errors.push(ShapeError {
2095 prop: Some("descending".into()),
2096 message: "`descending` says which way a column sorts; add `sorted:` for when it does"
2097 .into(),
2098 });
2099 }
2100 if spec.name == "Toolbar" && count("Search") > 1 {
2101 errors.push(ShapeError {
2102 prop: None,
2103 message: "a `Toolbar` has at most one `Search`".into(),
2104 });
2105 }
2106 if spec.name == "SplitView" {
2107 let names: Vec<_> = children
2108 .iter()
2109 .map(|child| match child {
2110 ChildShape::Element(name) => *name,
2111 ChildShape::Other => "",
2112 })
2113 .collect();
2114 if names != ["Sidebar", "Detail"] {
2115 errors.push(ShapeError {
2116 prop: None,
2117 message: "`SplitView` contains exactly `Sidebar { … }` followed by `Detail { … }`"
2118 .into(),
2119 });
2120 }
2121 }
2122 if spec.name == "Video" && shape.props.contains(&"source") == shape.props.contains(&"player") {
2123 errors.push(ShapeError {
2124 prop: None,
2125 message:
2126 "`Video` takes exactly one of `source:` (a player created for this declaration) \
2127 or `player:` (an application player)"
2128 .into(),
2129 });
2130 }
2131 if spec.name == "Query" && shape.props.contains(&"icon") && !shape.props.contains(&"empty") {
2132 errors.push(ShapeError {
2133 prop: Some("icon".into()),
2134 message: "`icon:` decorates the `empty:` state; declare `empty:` too".into(),
2135 });
2136 }
2137 if spec.name == "Window" {
2138 let check = shape.props.contains(&"confirm_close");
2139 if check != shape.props.contains(&"close_message") {
2140 errors.push(ShapeError {
2141 prop: None,
2142 message: "`confirm_close:` and `close_message:` are declared together".into(),
2143 });
2144 }
2145 if shape.has_action && !check {
2146 errors.push(ShapeError {
2147 prop: None,
2148 message: "a `Window` action is its save command, which needs `confirm_close:`"
2149 .into(),
2150 });
2151 }
2152 }
2153 if spec.name == "Navigation" {
2154 let destinations = children
2155 .iter()
2156 .filter(|child| matches!(child, ChildShape::Element("Destination")))
2157 .count();
2158 if destinations != 1 {
2159 errors.push(ShapeError {
2160 prop: None,
2161 message: "`Navigation` contains exactly one `Destination |route| { … }`".into(),
2162 });
2163 }
2164 }
2165 match spec.placement {
2166 Placement::Root if !shape.is_root => errors.push(ShapeError {
2167 prop: None,
2168 message: format!("`{}` must be the root of a component", spec.name),
2169 }),
2170 Placement::Within(parents) if !shape.parent.is_some_and(|p| parents.contains(&p)) => errors
2171 .push(ShapeError {
2172 prop: None,
2173 message: format!(
2174 "`{}` may only appear directly inside {}",
2175 spec.name,
2176 parents.join(" or ")
2177 ),
2178 }),
2179 _ => {}
2180 }
2181 errors
2182}
2183
2184#[cfg(test)]
2185mod tests {
2186 use super::*;
2187
2188 fn shape<'a>(name: &'a str, props: Vec<&'a str>, has_action: bool) -> ElementShape<'a> {
2189 ElementShape {
2190 name,
2191 props,
2192 keywords: Vec::new(),
2193 has_template: false,
2194 children: None,
2195 has_action,
2196 parent: Some("Column"),
2197 is_root: false,
2198 }
2199 }
2200
2201 #[test]
2202 fn adaptive_image_ratio_supplies_its_size() {
2203 assert!(check_shape(&shape("Image", vec!["source", "aspect"], false)).is_empty());
2204 assert!(!check_shape(&shape("Image", vec!["source"], false)).is_empty());
2205 assert!(check_shape(&shape("Grid", vec!["min_width", "gap", "row_gap"], false)).is_empty());
2206 }
2207
2208 #[test]
2209 fn a_covering_image_needs_no_size() {
2210 let cover = ElementShape {
2211 keywords: vec![("fit", "cover")],
2212 ..shape("Image", vec!["source", "fit"], false)
2213 };
2214 assert!(check_shape(&cover).is_empty());
2215 let contain = ElementShape {
2216 keywords: vec![("fit", "contain")],
2217 ..shape("Image", vec!["source", "fit"], false)
2218 };
2219 assert!(!check_shape(&contain).is_empty());
2220 }
2221
2222 #[test]
2223 fn buttons_without_actions_fail_with_b301() {
2224 let errors = check_shape(&shape("Button", vec!["id", "label"], false));
2225 assert!(errors.iter().any(|error| error.message.contains("B301")));
2226 assert!(check_shape(&shape("Button", vec!["id", "label"], true)).is_empty());
2227 }
2228
2229 #[test]
2230 fn unknown_components_and_props_are_reported() {
2231 assert!(!check_shape(&shape("Marquee", vec![], false)).is_empty());
2232 let errors = check_shape(&shape("Text", vec!["content", "colour"], false));
2233 assert_eq!(errors[0].prop.as_deref(), Some("colour"));
2234 }
2235
2236 #[test]
2237 fn interactive_components_require_ids() {
2238 for spec in COMPONENTS.iter().filter(|spec| spec.interactive) {
2239 assert!(
2240 spec.prop("id").is_some_and(|prop| prop.required),
2241 "{} needs a required id",
2242 spec.name
2243 );
2244 }
2245 }
2246
2247 #[test]
2248 fn video_takes_a_source_or_a_player() {
2249 for props in [vec!["id"], vec!["id", "source", "player"]] {
2250 let errors = check_shape(&shape("Video", props, false));
2251 assert!(
2252 errors
2253 .iter()
2254 .any(|error| error.message.contains("exactly one"))
2255 );
2256 }
2257 assert!(check_shape(&shape("Video", vec!["id", "source"], false)).is_empty());
2258 assert!(check_shape(&shape("Video", vec!["id", "player"], false)).is_empty());
2259 }
2260
2261 #[test]
2262 fn queries_need_a_call_an_id_and_a_template() {
2263 let mut query = shape("Query", vec!["id", "query"], false);
2264 assert!(
2265 check_shape(&query)
2266 .iter()
2267 .any(|error| error.message.contains("template"))
2268 );
2269 query.has_template = true;
2270 assert!(check_shape(&query).is_empty());
2271 query.props.push("icon");
2272 assert!(!check_shape(&query).is_empty());
2273 query.props.push("empty");
2274 assert!(check_shape(&query).is_empty());
2275 assert!(!check_shape(&shape("Query", vec!["query"], false)).is_empty());
2276 }
2277
2278 #[test]
2279 fn placement_rules_apply() {
2280 let mut item = shape("SidebarItem", vec!["value", "label"], false);
2281 assert!(!check_shape(&item).is_empty());
2282 item.parent = Some("Section");
2283 assert!(check_shape(&item).is_empty());
2284 }
2285}