Skip to main content

bern_ir/
spec.rs

1//! Built-in component specifications.
2//!
3//! This table is the single definition of which components exist, which
4//! properties they accept and how their children and actions are shaped.
5//! The macros, the compiler and every target read it, so a component is
6//! described once and never inferred from one backend's behaviour.
7
8/// How a property value is written and checked.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum PropKind {
11    /// Stable control identity: an identifier or a text literal.
12    Id,
13    /// One of a fixed set of keywords.
14    Keyword(&'static [&'static str]),
15    /// `tr!` text or a displayable binding (text, int, float, labelled enum).
16    DisplayText,
17    /// Only `tr!` text; used where data would make no sense (window titles).
18    LocalizedText,
19    /// Icon name literal from the Bern icon set (SF Symbols naming).
20    Icon,
21    /// Name literal of a declared image resource (PNG or JPEG), such as
22    /// `"images/cover.png"`.
23    ImageResource,
24    /// A bundled SVG document, with CSS/SMIL and document-local scripts.
25    SvgResource,
26    /// Numeric literal in logical units.
27    Number,
28    /// A keyboard shortcut: a literal such as `"cmd+k"`, `"space"` or
29    /// `"cmd+shift+p"`, checked when the application is built (see
30    /// [`parse_shortcut`]), or a text the shortcut follows while the
31    /// application runs, such as `app.keys.open`, read by the same rules;
32    /// a text that is empty or no shortcut gives none.
33    Shortcut,
34    /// A length in logical units: a literal, or a numeric expression the
35    /// length follows, such as `if window.idle { 0 } else { 24 }`.
36    Length,
37    /// The kinds of file a control accepts, a literal such as
38    /// `"video .ass .srt"`; see [`parse_file_kinds`].
39    FileKinds,
40    /// Writable session field holding a `MediaSource` (or an optional
41    /// one): the file the user chose or dropped.
42    FileField,
43    /// Writable session `i64` or `String` field holding the key of the
44    /// item a scroll view stands at.
45    PositionField,
46    /// A literal `true` or `false`, fixed when the application is built.
47    Flag,
48    /// Numeric restricted expression (int or float), read only.
49    NumberValue,
50    /// Boolean restricted expression.
51    Bool,
52    /// Text restricted expression, read only, such as `app.name`.
53    TextValue,
54    /// Writable session text field.
55    TextField,
56    /// Writable session bool field.
57    BoolField,
58    /// Writable session float field.
59    NumberField,
60    /// Writable session enum field whose variants all carry labels.
61    PickerField,
62    /// Writable session enum field selecting among child items.
63    SelectionField,
64    /// Value matching the enclosing selection field's enum.
65    SelectionValue,
66    /// List binding of records feeding a row template.
67    Items,
68    /// Field name of the row record used as stable key.
69    KeyField,
70    /// Writable session list of a route enum.
71    PathField,
72    /// Writable session list of `String` or `i64` keys: the rows of a
73    /// `Table` that are chosen.
74    KeysField,
75    /// Route value for the enclosing navigation path.
76    Route,
77    /// Window kind identifier.
78    WindowId,
79    /// Name of the generated window handle type.
80    TypeName,
81    /// Name of a slot that modules contribute to.
82    SlotName,
83    /// Name of a style variant the application declares in `style!`.
84    StyleVariant,
85    /// Name of a transition from `style!`, such as `pop` or `fade`.
86    Transition,
87    /// Name of a motion token, such as `smooth`.
88    MotionToken,
89    /// Name pairing two elements as one shared element.
90    HeroName,
91    /// A text, number or enum value identifying something, read only.
92    KeyValue,
93    /// A value an element follows, read only: a bool, number, text or
94    /// enum, or an optional or a list of them, such as `draft.selected`.
95    Watched,
96    /// A semantic color role, such as `accent` or `fill.subtle`.
97    ColorRole,
98    /// A list of numbers, read only, such as the values a `Series` draws.
99    NumberList,
100    /// Name of a pattern from `style!`.
101    PatternName,
102    /// Binding of a `MediaSource`, optional or not, followed by the player
103    /// the declaration creates.
104    MediaSource,
105    /// An application player from the `#[bern::players]` catalog.
106    Player,
107    /// A typed query call such as `app.search(draft.text)`, whose result
108    /// feeds the element's template.
109    Query,
110    /// A feature of the enclosing contract, such as `MediaFeature::Clip`.
111    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/// What kind of children a component accepts.
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub enum Children {
124    None,
125    /// Any nodes.
126    Any,
127    /// Exactly one node.
128    One,
129    /// Only the listed components (conditional nodes are not allowed).
130    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/// Where a component may appear.
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub enum Placement {
143    Anywhere,
144    /// Only as the root of a component body.
145    Root,
146    /// Only directly inside one of the listed parents.
147    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    /// Whether the component is an operable control that needs an `id`.
159    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    /// Whether the element takes the motion properties: everything shown
181    /// in a layout, not window, slot or navigation structure.
182    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
210/// File categories a control may accept besides `.ext` extensions.
211pub const FILE_KINDS: &[&str] = &["any", "video", "audio", "picture", "folder"];
212
213/// Extensions each file category takes besides the system's own types,
214/// since a system may not know some, such as Matroska. Every target uses
215/// this table.
216pub 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
240/// The extensions of a file category from [`FILE_KIND_EXTENSIONS`].
241pub 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
248/// Normalize the kinds of file a control accepts: categories from
249/// [`FILE_KINDS`] and extensions such as `.ass`, separated by spaces or
250/// commas, each once.
251pub 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
288/// Properties every element shown in a layout takes: how it appears and
289/// disappears, how its children make room, and which shared element it is.
290/// Navigation containers whose items may sit in `if`, `if let`, `match`
291/// and `ForEach`; each branch and template holds only allowed items.
292pub const DYNAMIC_ITEMS: &[&str] = &["Sidebar", "Tabs", "Toolbar", "CommandMenu", "StatusItem"];
293
294/// Roles of a `CommandMenu` in the menu bar: the system's menu of that
295/// name takes the items, and the menu shows no title of its own. `about`
296/// is the application menu's About item: the items stand in its place.
297pub const MENU_ROLES: &[&str] = &["app", "about", "file", "edit", "view", "window", "help"];
298
299/// Drawing components: a target without a canvas lists none of them.
300pub 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    // While the condition holds the element turns about its center, one
322    // turn every `spin_period` seconds (1 unless given), clockwise, or
323    // counterclockwise below zero; it stops where it is, and stays still
324    // with reduced motion.
325    opt("spin", PropKind::Bool),
326    opt("spin_period", PropKind::Number),
327    // A white light that follows the pointer (a finger while it presses)
328    // over the element, even where other elements lie on top of it: its
329    // radius is the element's shorter side times `glow`, its centre as
330    // opaque as `glow_opacity` (0.4 unless given); it adds to what is
331    // drawn and stays inside the element's bounds and corners.
332    opt("glow", PropKind::Number),
333    opt("glow_opacity", PropKind::Number),
334    // The element's picture blurred with this radius in points, kept
335    // inside its bounds; it may follow state, with `animate` changing it
336    // with that motion.
337    opt("blur", PropKind::Length),
338    opt("stagger", PropKind::Number),
339    // Seconds a mouse rests over the element, such as a player's picture,
340    // before its window goes idle; with such areas on screen a mouse
341    // anywhere else keeps the window awake.
342    opt("idle_area", PropKind::Number),
343];
344
345/// Where an element sits on the `Rail` it stands in: at a fraction of its
346/// length, or between two.
347pub const PLACEMENT_PROPS: &[PropSpec] = &[
348    opt("at", PropKind::NumberValue),
349    opt("from", PropKind::NumberValue),
350    opt("to", PropKind::NumberValue),
351];
352
353/// Containers that may stagger the appearing of their children.
354pub 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    // The platform's control font, as a button's title has it: the text
385    // of a capsule button's content matches the platform's own buttons.
386    "control",
387    // The platform's monospaced font, a little smaller than body: code,
388    // commands, file contents.
389    "code",
390];
391
392pub const EMPHASIS: &[&str] = &["primary", "secondary", "tertiary"];
393
394/// Layout sizes of `Adaptive`, as written in `Layout(size: …)`: the four
395/// sizes of the available width, and `tv`, the layout a television shows
396/// whatever its width.
397pub const LAYOUT_SIZES: &[&str] = &["compact", "medium", "expanded", "large", "tv"];
398
399/// The feature an entry with a business action reaches.
400const FEATURE: PropSpec = opt("feature", PropKind::Feature);
401
402/// How video fills its frame: keep the aspect ratio and letterbox, keep it
403/// and crop, or stretch.
404pub const VIDEO_FIT: &[&str] = &["contain", "cover", "fill"];
405
406/// Where the layers of a `Stack` sit when they are smaller than it.
407pub 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
419/// File extensions an `Image` resource may have.
420pub 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/// One structural problem found by [`check_shape`].
1647#[derive(Clone, Debug, PartialEq, Eq)]
1648pub struct ShapeError {
1649    /// Property the problem refers to, if any.
1650    pub prop: Option<String>,
1651    pub message: String,
1652}
1653
1654/// The syntactic outline of an element, as seen by any parser.
1655pub struct ElementShape<'a> {
1656    pub name: &'a str,
1657    pub props: Vec<&'a str>,
1658    /// The properties written as a lone keyword, as `("fit", "cover")`.
1659    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
1672/// Validate an element against its specification. Shared by the macros
1673/// (for early, span-accurate errors) and the compiler.
1674pub 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    // Without a size, a picture that covers takes the room its container
1739    // gives.
1740    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    // A `ContextMenu` is not part of what a button shows.
1752    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}