1use std::sync::LazyLock;
46
47use rustc_hash::FxHashSet;
48
49use crate::access::Role;
50use crate::anim::{Easing, Repeat};
51use crate::color::Color;
52use crate::cursor::CursorShape;
53use crate::enter::Enter;
54use crate::keyframes::Keyframe;
55use crate::spec::{
56 Align, Bound, FontFamily, NodeSpec, PadShorthand, Sizing, TextStyle, TextWrap, UnderlineStyle,
57};
58use crate::value::Value;
59use crate::window::{WindowButton, WindowConfig};
60
61mod doors;
62pub use doors::{Cell, DOORS, Door, GUEST};
63
64pub const P_DIR: u32 = 1;
66pub const P_WIDTH: u32 = 2;
67pub const P_HEIGHT: u32 = 3;
68pub const P_MIN_W: u32 = 4;
69pub const P_MAX_W: u32 = 5;
70pub const P_MIN_H: u32 = 6;
71pub const P_MAX_H: u32 = 7;
72pub const P_PAD: u32 = 8;
73pub const P_GAP: u32 = 9;
74pub const P_MAIN_ALIGN: u32 = 10;
75pub const P_CROSS_ALIGN: u32 = 11;
76pub const P_BG: u32 = 12;
77pub const P_BORDER: u32 = 13;
78pub const P_RADIUS: u32 = 14;
79pub const P_OVERFLOW: u32 = 15;
80pub const P_FLOAT: u32 = 16;
81pub const P_HOVERABLE: u32 = 17;
82pub const P_ON_CLICK: u32 = 18;
83pub const P_ON_DRAG: u32 = 19;
84pub const P_ON_KEY: u32 = 20;
85pub const P_WINDOW: u32 = 21;
86pub const P_KEY_FOCUS: u32 = 22;
87pub const P_CENTER: u32 = 23;
88pub const P_SIZE: u32 = 24;
89pub const P_LINE_HEIGHT: u32 = 25;
90pub const P_COLOR: u32 = 26;
91pub const P_FAMILY: u32 = 27;
92pub const P_KEY: u32 = 28;
93pub const P_TITLE: u32 = 29;
94pub const P_TOOLTIP: u32 = 30;
95pub const P_TRANSITION: u32 = 31;
96pub const P_EASING: u32 = 32;
97pub const P_SLIDE: u32 = 33;
98pub const P_HOVER_BG: u32 = 34;
99pub const P_PRESSED_BG: u32 = 35;
100pub const P_HOVER_GROUP: u32 = 36;
101pub const P_ON_HOVER: u32 = 37;
102pub const P_RADIUS_TL: u32 = 38;
103pub const P_RADIUS_TR: u32 = 39;
104pub const P_RADIUS_BR: u32 = 40;
105pub const P_RADIUS_BL: u32 = 41;
106pub const P_FONT: u32 = 42;
107pub const P_KEYFRAMES: u32 = 43;
108pub const P_REPEAT: u32 = 44;
109pub const P_DELAY: u32 = 45;
110pub const P_WRAP: u32 = 46;
111pub const P_MAX_LINES: u32 = 47;
112pub const P_ELLIPSIS: u32 = 48;
113pub const P_ENTER: u32 = 49;
114pub const P_CLICK_SOUND: u32 = 50;
115pub const P_HOVER_SOUND: u32 = 51;
116pub const P_ON_LAYOUT: u32 = 52;
117pub const P_ROLE: u32 = 53;
118pub const P_LABEL: u32 = 54;
119pub const P_CHECKED: u32 = 55;
120pub const P_VALUE_NOW: u32 = 56;
121pub const P_VALUE_MIN: u32 = 57;
122pub const P_VALUE_MAX: u32 = 58;
123pub const P_CARET: u32 = 59;
124pub const P_SELECTION_ANCHOR: u32 = 60;
125pub const P_FOCUSABLE: u32 = 61;
126pub const P_DISABLED: u32 = 62;
127pub const P_FOCUS_BG: u32 = 63;
128pub const P_MODAL: u32 = 64;
129pub const P_ON_CONTEXT_MENU: u32 = 65;
130pub const P_CURSOR: u32 = 66;
131pub const P_SELECTED: u32 = 67;
132pub const P_EXPANDED: u32 = 68;
133pub const P_OPACITY: u32 = 69;
134pub const P_SHADOW_COLOR: u32 = 70;
135pub const P_SHADOW_BLUR: u32 = 71;
136pub const P_SHADOW_X: u32 = 72;
137pub const P_SHADOW_Y: u32 = 73;
138pub const P_SHADOW_SPREAD: u32 = 74;
139pub const P_WRAP_CHILDREN: u32 = 75;
140pub const P_CROSS_GAP: u32 = 76;
141pub const P_INITIAL_FOCUS: u32 = 77;
142pub const P_EXIT: u32 = 78;
143pub const P_WINDOWS: u32 = 79;
144pub const P_LIVE: u32 = 80;
145pub const P_KEY_UP: u32 = 81;
146pub const P_VALUE_TEXT: u32 = 82;
147pub const P_DESCRIPTION: u32 = 83;
148pub const P_FEATURES: u32 = 84;
149pub const P_UNDERLINE: u32 = 85;
150pub const P_STRIKETHROUGH: u32 = 86;
151pub const P_ANIMATE: u32 = 87;
152pub const P_ACCENT: u32 = 88;
153pub const P_INDEX: u32 = 89;
154pub const P_SELECTABLE: u32 = 90;
155pub const P_ON_FORCE_CLICK: u32 = 91;
156pub const P_FOCUS_REGION: u32 = 92;
157pub const P_SCROLLBAR: u32 = 93;
158pub const P_SCROLLBAR_WIDTH: u32 = 94;
159pub const P_SCROLLBAR_COLOR: u32 = 95;
160pub const P_SCROLLBAR_ACTIVE_COLOR: u32 = 96;
161pub const P_ANCHOR: u32 = 97;
162pub const P_ALWAYS_ON_TOP: u32 = 98;
163pub const P_ON_SCROLL: u32 = 99;
164pub const P_ROW_COUNT: u32 = 100;
165pub const P_UNDERLINE_COLOR: u32 = 101;
166pub const P_UNDERLINE_STYLE: u32 = 102;
167pub const P_ON_DROP: u32 = 103;
168pub const P_DROP_BG: u32 = 104;
169pub const P_CARET_SOLID: u32 = 105;
170pub const P_SECURE_INPUT: u32 = 106;
171pub const P_ASPECT_RATIO: u32 = 107;
172pub const P_MIXED: u32 = 108;
173pub const P_VALUE_STEP: u32 = 109;
174pub const P_ON_CHANGE: u32 = 110;
175pub const P_PIXEL_SNAP: u32 = 111;
176pub const P_KEEP_FOCUS: u32 = 112;
177pub const P_ON_FOCUS: u32 = 113;
178pub const P_RULES: u32 = 114;
179pub const P_RULE_WIDTH: u32 = 115;
180pub const P_ON_BUTTON: u32 = 116;
181pub const P_BUTTONS: u32 = 117;
182pub const P_OVERSCROLL: u32 = 118;
183pub const P_SCROLL_AXES: u32 = 119;
184pub const P_MODIFIER_KEYS: u32 = 120;
185pub const P_OPTION_AS_ALT: u32 = 121;
186pub const P_BOUNCE: u32 = 122;
187
188pub const ALIGNS: &[&str] = &[
193 "start",
194 "center",
195 "end",
196 "spaceBetween",
197 "spaceAround",
198 "spaceEvenly",
199 "baseline",
200];
201pub const WINDOW_ROLES: &[&str] = &["drag", "close", "minimize", "maximize"];
202pub const SCROLLBARS: &[&str] = &["visible", "hidden", "auto"];
207pub const OVERSCROLLS: &[&str] = &["auto", "contain"];
212pub const SCROLL_AXES: &[&str] = &["both", "x", "y"];
216pub const FAMILIES: &[&str] = &["sans", "serif", "mono"];
219pub const CURSORS: &[&str] = &[
222 "default",
223 "text",
224 "pointer",
225 "grab",
226 "grabbing",
227 "notAllowed",
228 "ewResize",
229 "nsResize",
230 "nwseResize",
231 "neswResize",
232];
233
234pub fn cursor_idx(i: usize) -> CursorShape {
235 CURSORS
236 .get(i)
237 .and_then(|n| CursorShape::parse(n))
238 .unwrap_or(CursorShape::Default)
239}
240pub const WRAPS: &[&str] = &["word", "glyph", "none", "break-spaces"];
241pub const UNDERLINE_STYLES: &[&str] = UnderlineStyle::NAMES;
243pub const EXPANDED: &[&str] = &["collapsed", "expanded"];
248pub const LIVE: &[&str] = &["off", "polite", "assertive"];
252pub const ROLES: &[&str] = &[
258 "none",
259 "button",
260 "checkbox",
261 "radio",
262 "switch",
263 "slider",
264 "tab",
265 "tabList",
266 "link",
267 "heading",
268 "list",
269 "listItem",
270 "image",
271 "dialog",
272 "group",
273 "textInput",
274 "multilineTextInput",
275 "line",
276 "radioGroup",
280 "menu",
281 "menuItem",
282 "terminal",
284];
285
286pub const ORIENTATIONS: &[&str] = &["horizontal", "vertical"];
292
293pub const APPEARANCES: &[&str] = &["unknown", "light", "dark"];
299
300pub const MOTIONS: &[&str] = &["unknown", "full", "reduced"];
303
304pub const ASSISTIVE: &[&str] = &["unknown", "none", "listening"];
308
309pub const AUDIO_DEVICES: &[&str] = &["closed", "opening", "open", "failed"];
313
314pub const DERIVED_ONLY: &[(&str, &str)] = &[
321 (
322 "window",
323 "the root node of a frame, named by the window title",
324 ),
325 ("titleBar", "a `windowDrag` node"),
326 ("staticText", "a text node"),
327 ("scrollView", "a `scrollX` / `scrollY` node"),
328];
329
330pub fn role_idx(i: usize) -> Role {
353 ROLES
354 .get(i)
355 .and_then(|n| Role::parse(n))
356 .unwrap_or(Role::Group)
357}
358pub const EASINGS: &[&str] = &[
361 "easeOut",
362 "linear",
363 "easeIn",
364 "easeInOut",
365 "spring",
366 "bouncy",
367 "smooth",
368 "snappy",
369];
370
371pub const REPEATS: &[&str] = &["normal", "reverse", "alternate", "alternateReverse"];
373
374pub fn repeat_idx(i: usize) -> Repeat {
375 Repeat::from_index(i)
376}
377
378pub fn easing_idx(i: usize) -> Easing {
379 Easing::from_index(i)
380}
381
382pub enum Kind {
384 F32,
386 Color,
389 Flag,
391 Enum(&'static [&'static str]),
393 Sizing,
399 Min,
404 Max,
408 Msg,
410 Tag,
417 Str,
419 Family,
423 Resource,
427 Keyframes,
431 Enter,
434}
435
436pub enum Apply {
438 SpecF32(fn(NodeSpec, f32) -> NodeSpec),
439 SpecColor(fn(NodeSpec, Color) -> NodeSpec),
440 SpecFlag(fn(NodeSpec) -> NodeSpec),
441 SpecEnum(fn(NodeSpec, usize) -> NodeSpec),
442 SpecSizing(fn(NodeSpec, Sizing) -> NodeSpec),
443 SpecBound(fn(NodeSpec, Bound) -> NodeSpec),
444 SpecMsg(fn(NodeSpec, Value) -> NodeSpec),
445 SpecStr(fn(NodeSpec, &str) -> NodeSpec),
446 SpecKeyframes(fn(NodeSpec, Vec<Keyframe>) -> NodeSpec),
447 SpecEnter(fn(NodeSpec, Enter) -> NodeSpec),
448 SpecResource(fn(NodeSpec, u64) -> NodeSpec),
449 StyleF32(fn(TextStyle, f32) -> TextStyle),
450 StyleColor(fn(TextStyle, Color) -> TextStyle),
451 StyleEnum(fn(TextStyle, usize) -> TextStyle),
452 StyleFlag(fn(TextStyle) -> TextStyle),
453 StyleResource(fn(TextStyle, u64) -> TextStyle),
454 StyleStr(fn(TextStyle, &str) -> TextStyle),
455 StyleFamily(fn(TextStyle, FontFamily) -> TextStyle),
456}
457
458#[derive(Clone, Copy, Debug, PartialEq, Eq)]
459pub enum Target {
460 Spec,
462 Style,
464}
465
466pub struct PropDef {
467 pub name: &'static str,
469 pub id: u32,
470 pub kind: Kind,
471 pub apply: Apply,
472 pub doc: &'static str,
473}
474
475impl PropDef {
476 pub fn target(&self) -> Target {
477 match self.apply {
478 Apply::StyleF32(_)
479 | Apply::StyleColor(_)
480 | Apply::StyleEnum(_)
481 | Apply::StyleFlag(_)
482 | Apply::StyleResource(_)
483 | Apply::StyleStr(_)
484 | Apply::StyleFamily(_) => Target::Style,
485 _ => Target::Spec,
486 }
487 }
488
489 pub fn snake_name(&self) -> &'static str {
491 SNAKE_NAMES[self.index()]
492 }
493
494 fn index(&self) -> usize {
495 PROPS
498 .iter()
499 .position(|d| d.name == self.name)
500 .expect("PropDef not from PROPS")
501 }
502}
503
504pub fn align_idx(i: usize) -> Align {
505 match i {
506 1 => Align::Center,
507 2 => Align::End,
508 3 => Align::SpaceBetween,
509 4 => Align::SpaceAround,
510 5 => Align::SpaceEvenly,
511 6 => Align::Baseline,
512 _ => Align::Start,
513 }
514}
515
516pub fn min_num(mode: u32, value: f64) -> Bound {
520 match mode {
521 1 => Bound::Fit,
522 _ => Bound::Px(value as f32),
523 }
524}
525
526pub fn sizing_num(mode: u32, value: f64) -> Sizing {
528 match mode {
529 1 => Sizing::Fit,
530 2 => Sizing::Grow(value as f32),
531 3 => Sizing::Percent(value as f32),
532 _ => Sizing::Fixed(value as f32),
533 }
534}
535
536pub const PROPS: &[PropDef] = &[
537 PropDef {
538 name: "width",
539 id: P_WIDTH,
540 kind: Kind::Sizing,
541 apply: Apply::SpecSizing(|s, v| s.width(v)),
542 doc: "Horizontal size: px | \"fit\" | \"grow\" | \"N%\" | a size expression — \
543 `\"clamp(400px, 80%, 1000px)\"`, `\"min(720px, 100%)\"`, `\"max(50%, 300)\"`, \
544 nested — which layout resolves against the parent's content box, the box a \
545 percentage takes its cut of (backlog F109). An expression with no percentage \
546 in it is a length; a calc does not ease under `transition`. A percentage \
547 or an expression gives, with the fit children, when its parent overflows \
548 — two `\"50%\"` children and a gap fit their row (backlog F110) — where a \
549 px size keeps its own. A row holding one gives as CSS's flex items do: \
550 every child that can give gives in proportion to its size, and stops at \
551 its content — the widest thing in it that cannot wrap, a label's longest \
552 word — unless `minWidth` says otherwise (backlog RG92). The process keeps \
553 65 536 distinct expressions and never lets one go: past that a new one leaves \
554 its prop at its default, with a `size-expressions-full` warning, so declare \
555 one per layout — a px size for the part that moves each frame, a splitter's \
556 drag — not one per frame (backlog RG93).",
557 },
558 PropDef {
559 name: "height",
560 id: P_HEIGHT,
561 kind: Kind::Sizing,
562 apply: Apply::SpecSizing(|s, v| s.height(v)),
563 doc: "Vertical size: px | \"fit\" | \"grow\" | \"N%\" | a size expression (see `width`).",
564 },
565 PropDef {
566 name: "minWidth",
567 id: P_MIN_W,
568 kind: Kind::Min,
569 apply: Apply::SpecBound(|s, v| s.min_width(v)),
570 doc: "Lower width clamp: logical px, a size expression (see `width`; a percentage \
571 clamp is none until the parent's width is known, as in CSS), or \"fit\" for \
572 the node's own fit width. \
573 \"fit\" under `width=\"grow\"` is a content floor — CSS's `flex: 1 0 auto` — \
574 which is what an i3-style tab bar is: tabs that split the bar evenly \
575 while they fit and sit at their label's width, scrolling, once they do \
576 not. Opt-in, because a fit width is the unwrapped one: a paragraph in a \
577 grow column would stop wrapping under it. Left out, a child giving in an \
578 overflowing row that holds a percentage or a size expression stops at its \
579 content, CSS's `min-width: auto`; `0` lets it go below, CSS's \
580 `min-width: 0` (backlog RG92). A fit node across a column is no wider \
581 than the column's box — CSS's `fit-content` — down to this floor, 0 left \
582 out, unless the column scrolls x, so a text one wrapper deep in a capped \
583 card wraps there; \"fit\" keeps its content's width and runs past \
584 (backlog F116).",
585 },
586 PropDef {
587 name: "maxWidth",
588 id: P_MAX_W,
589 kind: Kind::Max,
590 apply: Apply::SpecBound(|s, v| s.max_width(v)),
591 doc: "Upper width clamp: logical px or a size expression (see `width`); \
592 grow+maxWidth is the responsive-width pattern.",
593 },
594 PropDef {
595 name: "minHeight",
596 id: P_MIN_H,
597 kind: Kind::Min,
598 apply: Apply::SpecBound(|s, v| s.min_height(v)),
599 doc: "Lower height clamp: logical px, a size expression, or \"fit\" for the node's own fit height. Undeclared, it is the node's content where its column overflows — CSS's `min-height: auto`, none for a node that scrolls or clips — so a row keeps the height of its text; `0` asks for the squeeze back (see `minWidth`).",
600 },
601 PropDef {
602 name: "maxHeight",
603 id: P_MAX_H,
604 kind: Kind::Max,
605 apply: Apply::SpecBound(|s, v| s.max_height(v)),
606 doc: "Upper height clamp: logical px or a size expression (see `width`).",
607 },
608 PropDef {
609 name: "gap",
610 id: P_GAP,
611 kind: Kind::F32,
612 apply: Apply::SpecF32(|s, v| s.gap(v)),
613 doc: "Space between children along the main axis.",
614 },
615 PropDef {
619 name: "wrapChildren",
620 id: P_WRAP_CHILDREN,
621 kind: Kind::Flag,
622 apply: Apply::SpecFlag(NodeSpec::wrap),
623 doc: "Children that don't fit the main axis start a new line instead of overflowing or shrinking. Rows only (a column is ignored, with a warning), and never on a scrollX row.",
624 },
625 PropDef {
626 name: "crossGap",
627 id: P_CROSS_GAP,
628 kind: Kind::F32,
629 apply: Apply::SpecF32(|s, v| s.cross_gap(v)),
630 doc: "Space between wrap lines, across the main axis (`gap` stays the space along it).",
631 },
632 PropDef {
633 name: "mainAlign",
634 id: P_MAIN_ALIGN,
635 kind: Kind::Enum(ALIGNS),
636 apply: Apply::SpecEnum(|s, i| s.main_align(align_idx(i))),
637 doc: "Child alignment along the main axis. `start`, `center` and `end` put the children together; `spaceBetween` deals the free space out between them (none at the ends), `spaceAround` gives each child an equal share split to its two sides, and `spaceEvenly` makes every gap and both ends equal — CSS's `justify-content`. The spread is added to `gap`, and there is none when nothing is free: a `grow` child takes it all, and an overflowing run keeps its gaps. `baseline` means nothing here and lays out as `start`, with a warning.",
638 },
639 PropDef {
640 name: "crossAlign",
641 id: P_CROSS_ALIGN,
642 kind: Kind::Enum(ALIGNS),
643 apply: Apply::SpecEnum(|s, i| s.cross_align(align_idx(i))),
644 doc: "Child alignment across the main axis. On a row, `baseline` lines up the first baselines of the children's text, so a label and a larger value read as one line; a child with no text aligns by its bottom edge, a `grow` or percent height fills the line from its top, and a fit-height row grows to hold the aligned children. A column lays `baseline` out as `start` (as CSS does), and the three spreads mean nothing across an axis — each with a warning.",
645 },
646 PropDef {
647 name: "aspectRatio",
648 id: P_ASPECT_RATIO,
649 kind: Kind::F32,
650 apply: Apply::SpecF32(|s, v| s.aspect_ratio(v)),
651 doc: "Width over height — `16/9`, `1` for a square — CSS's `aspect-ratio`. It sizes the axis left `fit`: a fit height is the final width over the ratio (so `width: grow` and a ratio is a box that keeps its shape as the window resizes), and a fit width under a fixed height is that height times it. With both axes declared, or a fit width under a `grow` or percent height, it has nothing it can set and warns. The derived axis is neither shrunk nor fitted to the children, which overflow it; `minHeight: 'fit'` floors it at them. On an image it wins over the pixels' own aspect.",
652 },
653 PropDef {
654 name: "bg",
655 id: P_BG,
656 kind: Kind::Color,
657 apply: Apply::SpecColor(|s, c| s.bg(c)),
658 doc: "Background fill.",
659 },
660 PropDef {
661 name: "radius",
662 id: P_RADIUS,
663 kind: Kind::F32,
664 apply: Apply::SpecF32(|s, v| s.radius(v)),
665 doc: "Corner radius for all four corners (logical px); the per-corner props override it when listed after it. On a node that also clips or scrolls it rounds the clip as well, so children stay inside the corners.",
666 },
667 PropDef {
668 name: "opacity",
669 id: P_OPACITY,
670 kind: Kind::F32,
671 apply: Apply::SpecF32(|s, v| s.opacity(v)),
672 doc: "Group opacity 0..1 (default 1): fades this node and its whole subtree. A per-quad alpha multiply rather than an offscreen composite, so overlapping pieces of one subtree show their seams through the fade. Layout, hit-testing and the access tree are untouched; eases with `transition`, and `enter: { opacity: 0 }` fades a panel in.",
673 },
674 PropDef {
675 name: "pixelSnap",
676 id: P_PIXEL_SNAP,
677 kind: Kind::Flag,
678 apply: Apply::SpecFlag(|s| s.pixel_snap()),
679 doc: "Paint this node's background, border, shadow and fragment with each edge on a whole physical pixel: `x` and `x + width` rounded on their own, from where layout put them, as a text's span backgrounds are. Off by default, and a box is drawn where layout put it, so a 1 px `gap` between boxes is there at any scale. On, boxes that share an edge in layout meet on one pixel line, where a join inside a pixel was drawn by halves and left a seam — rows of a band stacked at a pitch that is not whole pixels, or a box that continues a text's selection. Layout, hit-testing, the clip and the children are untouched. A snapped box can draw up to half a pixel from its layout edge and its size can differ by a pixel, so a snapped hairline is 1 or 2 px thick by where it sits.",
680 },
681 PropDef {
682 name: "shadowColor",
683 id: P_SHADOW_COLOR,
684 kind: Kind::Color,
685 apply: Apply::SpecColor(|s, c| s.shadow_color(c)),
686 doc: "Drop-shadow color; nothing else about a shadow draws without it. On its own it is a hard shadow exactly behind the node — add `shadowBlur` / `shadowY` to lift it. Outer shadows only, and the shape is not knocked out of the middle, so a translucent background shows it through.",
687 },
688 PropDef {
689 name: "shadowBlur",
690 id: P_SHADOW_BLUR,
691 kind: Kind::F32,
692 apply: Apply::SpecF32(|s, v| s.shadow_blur(v)),
693 doc: "Drop-shadow blur radius (logical px): the edge ramps over this distance and reaches this far past the shape. 0 = a hard edge.",
694 },
695 PropDef {
696 name: "shadowX",
697 id: P_SHADOW_X,
698 kind: Kind::F32,
699 apply: Apply::SpecF32(|s, v| s.shadow_x(v)),
700 doc: "Drop-shadow horizontal offset (logical px).",
701 },
702 PropDef {
703 name: "shadowY",
704 id: P_SHADOW_Y,
705 kind: Kind::F32,
706 apply: Apply::SpecF32(|s, v| s.shadow_y(v)),
707 doc: "Drop-shadow vertical offset (logical px); positive casts downward.",
708 },
709 PropDef {
710 name: "shadowSpread",
711 id: P_SHADOW_SPREAD,
712 kind: Kind::F32,
713 apply: Apply::SpecF32(|s, v| s.shadow_spread(v)),
714 doc: "Grows (or, negative, shrinks) the drop shadow's shape before blurring (logical px).",
715 },
716 PropDef {
717 name: "radiusTL",
718 id: P_RADIUS_TL,
719 kind: Kind::F32,
720 apply: Apply::SpecF32(|s, v| s.radius_tl(v)),
721 doc: "Top-left corner radius (logical px).",
722 },
723 PropDef {
724 name: "radiusTR",
725 id: P_RADIUS_TR,
726 kind: Kind::F32,
727 apply: Apply::SpecF32(|s, v| s.radius_tr(v)),
728 doc: "Top-right corner radius (logical px).",
729 },
730 PropDef {
731 name: "radiusBR",
732 id: P_RADIUS_BR,
733 kind: Kind::F32,
734 apply: Apply::SpecF32(|s, v| s.radius_br(v)),
735 doc: "Bottom-right corner radius (logical px).",
736 },
737 PropDef {
738 name: "radiusBL",
739 id: P_RADIUS_BL,
740 kind: Kind::F32,
741 apply: Apply::SpecF32(|s, v| s.radius_bl(v)),
742 doc: "Bottom-left corner radius (logical px).",
743 },
744 PropDef {
745 name: "center",
746 id: P_CENTER,
747 kind: Kind::Flag,
748 apply: Apply::SpecFlag(|s| s.center()),
749 doc: "Center children on both axes.",
750 },
751 PropDef {
752 name: "hoverable",
753 id: P_HOVERABLE,
754 kind: Kind::Flag,
755 apply: Apply::SpecFlag(|s| s.hoverable()),
756 doc: "Hover-track without a click payload (for isHovered-driven styling).",
757 },
758 PropDef {
759 name: "animate",
760 id: P_ANIMATE,
761 kind: Kind::Flag,
762 apply: Apply::SpecFlag(|s| s.animate()),
763 doc: "Ask for another frame after this one, every frame this node is declared. What a `fragment` that reads `time` needs, and what anything driving itself off the clock rather than off input needs. Opt-in like `exit`, and for the same reason: it takes the loop off input-driven and onto the display's cadence for as long as it is declared, so a still node must not carry it. One node asking is enough for the whole window.",
764 },
765 PropDef {
766 name: "accent",
767 id: P_ACCENT,
768 kind: Kind::Flag,
769 apply: Apply::SpecFlag(|s| s.accent()),
770 doc: "Paint this node's background in the OS accent colour — `env.system.accent` — keeping the declared `bg` on a host that cannot tell what it is. The one prop whose paint depends on the environment, which is why it is opt-in: the same tree is a different colour on two machines, and that is the point here and a surprise anywhere else. On the stock button it does the whole job — the hover and pressed shades are derived from the accent, and the label goes black or white by its luminance, so a yellow accent is still readable — which is what `<button accent>` is for.",
771 },
772 PropDef {
773 name: "selectable",
774 id: P_SELECTABLE,
775 kind: Kind::Flag,
776 apply: Apply::SpecFlag(|s| s.selectable()),
777 doc: "Makes this node a selection scope: the text of every node inside it is one selectable run, in tree order, and a press-drag across them selects the lot — as do Shift with Left / Right / Home / End on a focused node inside it, a character or a word at a time, a scope with nothing selected anchoring at its start (`docs/adr/0017-selection-as-a-scope.md`). Declared on the container and not on each label, because what a reader selects is a paragraph or a card rather than one run of it — three labels in a column under one `selectable` select as three lines of one text. The selection is the window's: starting one anywhere clears the last, an editor's included. Scopes do not nest; an outer one around an inner one is warned about (`nested-selection-scope`) and the innermost owns the text. Text scrolled out of view inside the scope is still part of it — selection and copy reach it, hit-testing does not. On a `cells` grid the scope selects in cells rather than in bytes: a drag takes lines (with a modifier, a rectangle), a double click the word under the pointer and a triple click the whole row, its ends are absolute lines so a scroll does not move them, and a copy trims each line's trailing blanks.",
778 },
779 PropDef {
780 name: "focusable",
781 id: P_FOCUSABLE,
782 kind: Kind::Flag,
783 apply: Apply::SpecFlag(|s| s.focusable()),
784 doc: "Reachable by Tab (and focused by a click) without a click payload or a control role — a row that opens on Enter. Editors, key sinks, `onClick` boxes and the control roles are focusable already.",
785 },
786 PropDef {
787 name: "keepFocus",
788 id: P_KEEP_FOCUS,
789 kind: Kind::Flag,
790 apply: Apply::SpecFlag(|s| s.keep_focus()),
791 doc: "A press on this node, or anywhere inside it, leaves keyboard focus where it was: a toolbar button, a tab or a divider that acts without taking the keyboard from the editor or key sink that had it. Without it a press on an `onClick` node focuses the node, and the app's keys stop reaching the sink until it takes focus back. The press also leaves a text or cell selection and the Tab ring where they were, so a Copy button copies what was selected. An `<edit>` inside still takes its caret and focus, as the keyboard's own owner. The click, drag and hover are unchanged, and Tab and assistive technology still reach the node.",
792 },
793 PropDef {
794 name: "focusRegion",
795 id: P_FOCUS_REGION,
796 kind: Kind::Flag,
797 apply: Apply::SpecFlag(|s| s.focus_region()),
798 doc: "Makes this node's subtree a focus region: a Tab ring of its own that the ring outside never enters and that never leaves — a devtools dock, an inspector beside the app (`docs/adr/0022-focus-regions.md`). Entered on purpose: `focusRegion(name)` (`Ui::focus_region`, `env.focus_region`, `kui_focus_region`) moves focus in — to the focus the region last held, else its `initialFocus`, else its first stop — and `focusRegion(null)` moves it back to the main ring the same way; a press inside the region, or an explicit focus on a node in it, enters it too. Tab then walks that ring alone, wrapping inside it; with nothing focused, Tab enters the ring of the region in effect (`region()`). A region that stops being declared hands focus back to what the main ring last held. Only the ring is scoped: keys still bubble through the boundary to the sink above (a region that wants its own keymap is an `onKey` sink), the pointer and assistive technology see a plain node, and a `modal` in effect is the ring wherever it sits. Nested regions are skipped by the outer ring the way the main ring skips them.",
799 },
800 PropDef {
801 name: "scrollbar",
802 id: P_SCROLLBAR,
803 kind: Kind::Enum(SCROLLBARS),
804 apply: Apply::SpecEnum(|s, i| s.scrollbar(crate::spec::ScrollbarMode::ALL[i])),
805 doc: "When a scrolling node draws its bars: `visible` (the default — the stock overlay thumb, drawn while the content overflows), `hidden` (no thumb, no track to press; the wheel, the keyboard, `reveal` and the caret still scroll it — for a list that draws its own indicator, or a pane whose bar would sit on a border), or `auto` (shown while the scroll state is changing — the offset or the content's extent moved, the pointer is on the track, a thumb is dragged — and for a second after, then faded out over a quarter of one; a node first seen shows it the same second; what an overlay bar does on macOS). `auto` needs the driver's clock and is `visible` without one. The bars are overlays and take no layout space in any mode. From the last change until it has faded — a second and a quarter — an `auto` bar asks for frames the way a transition of that length would (nothing else could wake the core when the hold ends); while the pointer holds it, it asks for none.",
806 },
807 PropDef {
808 name: "anchor",
809 id: P_ANCHOR,
810 kind: Kind::Flag,
811 apply: Apply::SpecFlag(|s| s.anchor()),
812 doc: "Scroll anchoring on a scrolling node (backlog C26, CSS's `overflow-anchor`): the first child in view keeps its place on screen when the content before it changes size — a chat that prepends history, a log that inserts rows above the viewport, a list whose row heights are corrected as they are measured — with no `setScroll` and no arithmetic in the view. The core remembers which child was first in view and where its edge was, and moves the offset by however far that edge moved in the next layout, before the offset is clamped; a wheel notch or a `setScroll` between the frames is kept and the correction added to it. The child is found by key, so give the rows stable keys (a `key` or an `index`); a child that is gone anchors nothing that frame. On the scroll axis that is the node's main axis only — `scrollY` on a column, `scrollX` on a row — and content appended *after* the anchor moves nothing, so a log that is tailing still asks for the end itself.",
813 },
814 PropDef {
815 name: "scrollbarWidth",
816 id: P_SCROLLBAR_WIDTH,
817 kind: Kind::F32,
818 apply: Apply::SpecF32(|s, v| s.scrollbar_width(v)),
819 doc: "The thumb's width at rest, logical px (default 4); under the pointer or dragged it is 2 px wider. The grabbable track grows to fit a wide thumb.",
820 },
821 PropDef {
822 name: "scrollbarColor",
823 id: P_SCROLLBAR_COLOR,
824 kind: Kind::Color,
825 apply: Apply::SpecColor(|s, c| s.scrollbar_color(c)),
826 doc: "The thumb at rest; the default is the theme's `scrollbar` role, a translucent wash over whatever it sits on.",
827 },
828 PropDef {
829 name: "scrollbarActiveColor",
830 id: P_SCROLLBAR_ACTIVE_COLOR,
831 kind: Kind::Color,
832 apply: Apply::SpecColor(|s, c| s.scrollbar_active_color(c)),
833 doc: "The thumb under the pointer or while dragged; the default is the theme's `scrollbar_active` role.",
834 },
835 PropDef {
836 name: "overscroll",
837 id: P_OVERSCROLL,
838 kind: Kind::Enum(OVERSCROLLS),
839 apply: Apply::SpecEnum(|s, i| s.overscroll(crate::spec::Overscroll::ALL[i])),
840 doc: "What a scroll gesture that starts over this scroller does when it is already at its limit that way (backlog F107, CSS's `overscroll-behavior`): `auto` (the default) passes the gesture on to the scroller around it, `contain` keeps it here, moving nothing until it turns back. A gesture picks its target once, when it starts — the innermost scroller under the pointer that can still move the way it goes — and keeps it until it ends, wherever the pointer or the content has gone; one that reaches a limit midway stops there, whatever this says. Only on the axes the node scrolls: a `scrollY` list that contains still passes a sideways swipe to the strip around it. For a panel or a popup's list whose scrolling must never move what is behind it.",
841 },
842 PropDef {
843 name: "disabled",
844 id: P_DISABLED,
845 kind: Kind::Flag,
846 apply: Apply::SpecFlag(|s| s.disabled(true)),
847 doc: "Inert: no click, drag or key sink, no hover / pressed / focus background, skipped by Tab, reported disabled to assistive technology; hover tracking stays so a `tooltip` can say why.",
848 },
849 PropDef {
850 name: "hoverBg",
851 id: P_HOVER_BG,
852 kind: Kind::Color,
853 apply: Apply::SpecColor(|s, c| s.hover_bg(c)),
854 doc: "Background while hovered (or while any node in its hoverGroup is); implies hover tracking, eases with `transition`.",
855 },
856 PropDef {
857 name: "rules",
858 id: P_RULES,
859 kind: Kind::Color,
860 apply: Apply::SpecColor(|s, c| s.rules(c)),
861 doc: "On a table (`dir=\"table\"`, ADR 0033): grid lines of this colour between its columns and between its rows (backlog DX21) — down the middle of each gap between the columns of its widest row, from the first row's top to the last row's bottom, and across the middle of each gap between rows, the content box wide. Drawn with the table's box, under its cells and on whole pixels, so give the table and its rows a `gap` at least `ruleWidth` for the lines to show between cells; the outer edge is the table's `border`. Ignored on anything but a table.",
862 },
863 PropDef {
864 name: "ruleWidth",
865 id: P_RULE_WIDTH,
866 kind: Kind::F32,
867 apply: Apply::SpecF32(|s, v| s.rule_width(v)),
868 doc: "The width of a table's `rules` in logical px; 1 when unset.",
869 },
870 PropDef {
871 name: "dropBg",
872 id: P_DROP_BG,
873 kind: Kind::Color,
874 apply: Apply::SpecColor(|s, c| s.drop_bg(c)),
875 doc: "Background while files dragged in from the OS are over this node (ADR 0031); wins over pressedBg, focusBg and hoverBg, clears when they leave, land or the drag is cancelled. Implies hover tracking, eases with `transition`.",
876 },
877 PropDef {
878 name: "pressedBg",
879 id: P_PRESSED_BG,
880 kind: Kind::Color,
881 apply: Apply::SpecColor(|s, c| s.pressed_bg(c)),
882 doc: "Background while pressed (or while its hoverGroup is); implies hover tracking.",
883 },
884 PropDef {
885 name: "focusBg",
886 id: P_FOCUS_BG,
887 kind: Kind::Color,
888 apply: Apply::SpecColor(|s, c| s.focus_bg(c)),
889 doc: "Background while the node holds keyboard-visible focus (moved there by Tab or assistive technology, not a click); replaces the default focus ring. Pressed wins over focus wins over hover; eases with `transition`.",
890 },
891 PropDef {
892 name: "modal",
893 id: P_MODAL,
894 kind: Kind::Tag,
895 apply: Apply::SpecMsg(|s, v| s.modal(v)),
896 doc: "Modal surface: the Tab ring becomes this node's subtree, everything outside it is inert to the pointer, the wheel and assistive technology, and Escape or a press outside emits {kind:\"dismiss\", reason:\"escape\"|\"outside\", tag} on it — the app stops declaring the node. The last one declared in tree order is the one in effect (a confirm inside a dialog); a modal that must cover the app is a float. The access tree is not pruned to the modal: it keeps every node of the frame and marks the one in effect `modal` (`docs/adr/0003-modal-surfaces.md`, decision 7), which is what assistive technology acts on.",
897 },
898 PropDef {
899 name: "initialFocus",
900 id: P_INITIAL_FOCUS,
901 kind: Kind::Flag,
902 apply: Apply::SpecFlag(|s| s.initial_focus()),
903 doc: "Where focus lands when the enclosing `modal` scope is entered: the first node in the modal\'s Tab ring declaring it, so a destructive confirm opens on its Cancel rather than on whichever control is declared first. Read on entry only — a Tab press afterwards stands, and the scope re-entered (a nested confirm closing) leaves focus where it was. Declared on nothing, or only on nodes the ring skips (disabled, `role=\"none\"`, not focusable), entry stays the ring\'s first node.",
904 },
905 PropDef {
906 name: "hoverGroup",
907 id: P_HOVER_GROUP,
908 kind: Kind::Str,
909 apply: Apply::SpecStr(|s, name| s.hover_group(name)),
910 doc: "Nodes sharing a group name show hoverBg/pressedBg together (a split button, a multi-piece shape).",
911 },
912 PropDef {
913 name: "onHover",
914 id: P_ON_HOVER,
915 kind: Kind::Tag,
916 apply: Apply::SpecMsg(|s, v| s.on_hover(v)),
917 doc: "Hover tag: the pointer entering/leaving emits {kind:\"hover\", phase:\"enter\"|\"leave\", tag} events.",
918 },
919 PropDef {
920 name: "onDrop",
921 id: P_ON_DROP,
922 kind: Kind::Tag,
923 apply: Apply::SpecMsg(|s, v| s.on_drop(v)),
924 doc: "Drop-zone tag (`docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md`): files dragged in from the OS over this node emit {kind:\"drop\", phase:\"enter\"|\"move\"|\"leave\"|\"drop\", paths, x, y, tag} — `paths` the OS paths as strings, `x`/`y` the pointer in logical viewport coordinates (absent on `leave`). The zone under the files is the topmost zone by paint order: a node inside a zone is the zone's (a button in it, a field in it), and a node that is no zone and has none enclosing it is looked past, so an overlay shown on `enter` cannot make the zone lose the files. No `leave` follows a `drop`; a drop off every zone is refused by the driver. Implies hover tracking. No access row — a screen-reader user's way in is a button beside the zone. On Windows and Linux the position is the OS cursor at enter and release only, so `move` never fires there.",
925 },
926 PropDef {
927 name: "onLayout",
928 id: P_ON_LAYOUT,
929 kind: Kind::Tag,
930 apply: Apply::SpecMsg(|s, v| s.on_layout(v)),
931 doc: "Layout tag: the node's laid-out rect arrives as {kind:\"layout\", x, y, w, h, parent, tag} on its first frame and whenever it changes (needs a stable key).",
932 },
933 PropDef {
934 name: "onClick",
935 id: P_ON_CLICK,
936 kind: Kind::Msg,
937 apply: Apply::SpecMsg(|s, v| s.on_click(v)),
938 doc: "Message emitted when clicked (data, not a callback).",
939 },
940 PropDef {
941 name: "onDrag",
942 id: P_ON_DRAG,
943 kind: Kind::Tag,
944 apply: Apply::SpecMsg(|s, v| s.on_drag(v)),
945 doc: "Drag tag: emits {kind:\"drag\", phase, x, y, dx, dy, parent, tag} events, `dx`/`dy` measured from the press point in every phase. On a `cells` grid the events also carry `cell: {row, col}`; inside an `onKey` sink that draws `role=\"line\"` rows they carry `line`, `byte` and `clicks` — see the events table.",
946 },
947 PropDef {
948 name: "onKey",
949 id: P_ON_KEY,
950 kind: Kind::Tag,
951 apply: Apply::SpecMsg(|s, v| s.on_key(v)),
952 doc: "Key-sink tag: with key focus held, presses arrive as {kind:\"key\", phase:\"down\", code, ...} events. Releases only with `keyUp` beside it.",
953 },
954 PropDef {
955 name: "keyUp",
956 id: P_KEY_UP,
957 kind: Kind::Flag,
958 apply: Apply::SpecFlag(|s| s.key_up()),
959 doc: "With `onKey`: releases arrive too, as the same payload with phase:\"up\" (`text` null, `repeat` false) — for a held-key interaction (WASD, press-and-hold, a key that arms a mode while it is down). A key only comes up where it went down: a release whose press the sink never got is dropped, and focus leaving while a key is held delivers the `up` first, so nothing is left stuck down. Without it a sink hears presses only, which is what a keymap wants — one that heard both halves would run every binding twice.",
960 },
961 PropDef {
962 name: "modifierKeys",
963 id: P_MODIFIER_KEYS,
964 kind: Kind::Flag,
965 apply: Apply::SpecFlag(|s| s.modifier_keys()),
966 doc: "With `onKey`: the modifier and lock keys arrive as keys of their own (backlog F108) — `code` \"shift\", \"ctrl\", \"alt\", \"super\", \"capslock\", \"numlock\", \"scrolllock\", which side in `location` (\"left\" / \"right\"), releases too with `keyUp`. Without it a modifier is only ever held — the next key's `shift`, `ctrl`, … and the `modifiers` event — so a keymap mid-sequence never reads a Shift as a key between two others. For a terminal speaking kitty's keyboard protocol, or a game that binds a lone Shift.",
967 },
968 PropDef {
969 name: "onContextMenu",
970 id: P_ON_CONTEXT_MENU,
971 kind: Kind::Tag,
972 apply: Apply::SpecMsg(|s, v| s.on_context_menu(v)),
973 doc: "Context-menu tag: a secondary-button (right) press emits {kind:\"contextmenu\", x, y, tag} on the node, at the logical viewport point to open the menu at. The press moves no focus, places no caret and produces no click, so right-clicking a selection keeps it. Asked of the topmost node under the pointer, and when that node offers no menu the press reaches the nearest enclosing node that does — a container declaring a menu for everything inside it is the common case — the way an unclaimed key reaches the enclosing sink (`docs/adr/0011`): the event carries the *owner's* key and tag, a nested declaration wins over its ancestor's, a disabled node's own is skipped, and the walk stops at the modal boundary.",
974 },
975 PropDef {
976 name: "onForceClick",
977 id: P_ON_FORCE_CLICK,
978 kind: Kind::Tag,
979 apply: Apply::SpecMsg(|s, v| s.on_force_click(v)),
980 doc: "Force-click tag: a press that deepens past the second stage of a Force Touch trackpad emits {kind:\"forceclick\", x, y, tag} on the node, at the logical viewport point it happened at (`docs/adr/0017-selection-as-a-scope.md`). Routed as a secondary press is — no focus moved, no caret placed, no click — but asked of the topmost node only, with no walk to an enclosing declaration — and the ordinary click the press is still producing arrives afterwards, as it does on macOS. Text needs none of this: a force click over an `edit` or a `selectable` scope selects the word under it and asks the host for its Look Up panel. macOS-only in practice, and there the user can switch the gesture off, so nothing may declare itself the only way to reach something.",
981 },
982 PropDef {
983 name: "onButton",
984 id: P_ON_BUTTON,
985 kind: Kind::Tag,
986 apply: Apply::SpecMsg(|s, v| s.on_button(v)),
987 doc: "Button tag (backlog F105): a press of a non-primary button — middle, secondary, or one past those — emits {kind:\"button\", phase:\"press\", button, x, y, clicks, tag} on the node, and the button is then captured by it: every pointer move while it is held arrives as phase:\"move\" and its release as phase:\"release\", on this node wherever the pointer is. `button` is `\"secondary\"`, `\"middle\"` or a further button's number (3 and up); `x`/`y` are logical viewport coordinates, and on a `cells` grid each event carries `cell: {row, col}` as a click does. Several buttons can be held at once, each its own capture, and a primary drag is untouched. `buttons` says which buttons it claims — all of them unless it narrows them. Asked of the topmost node under the pointer, and when that node claims no such button the press reaches the nearest enclosing node that does, the way a context menu's does: a disabled node's own is skipped and the walk stops at the modal boundary. A claimed secondary press is this event *instead of* a `contextmenu` event and the stock menu (a nearer `onContextMenu` still wins, being the nested declaration). Like every non-primary press it moves no focus, places no caret and touches no selection or scrollbar. For a terminal's middle-click paste, and the mouse reports a program in it asked for.",
988 },
989 PropDef {
990 name: "buttons",
991 id: P_BUTTONS,
992 kind: Kind::Str,
993 apply: Apply::SpecStr(|s, v| s.buttons(crate::input::Buttons::parse(v))),
994 doc: "Which non-primary buttons `onButton` claims (backlog F105): `\"secondary\"`, `\"middle\"` and `\"other\"` (every button past those), separated by spaces or commas — `\"middle\"`, `\"secondary middle\"`. Unset, all three: a node that wants the middle button and leaves the secondary one to its context menu says `\"middle\"`. A word that is none of the three is skipped, so a string of none of them claims nothing, and a typo never takes the secondary button from a context menu. Meaningless without `onButton`.",
995 },
996 PropDef {
997 name: "onScroll",
998 id: P_ON_SCROLL,
999 kind: Kind::Tag,
1000 apply: Apply::SpecMsg(|s, v| s.on_scroll(v)),
1001 doc: "Scroll tag: the wheel over this node emits {kind:\"scroll\", x, y, dx, dy, lines, tag} on it instead of scrolling anything — `dx`/`dy` the delta in logical px as the driver reported it (positive `dy` is the wheel rolling up, toward earlier content), `x`/`y` the pointer, and `lines` on a `cells` grid the whole lines the delta covers (positive = later history, the sign `originLine` grows in; the fraction is carried to the next notch so a trackpad's small steps add up) and null on any other node. The node takes the wheel on the axes `scrollAxes` names (both unless it narrows them): a gesture that starts over it is its own whether or not it has anywhere to go — except on an axis the node also scrolls (`scrollX`/`scrollY`, its offset the app's to set), where it is answered by its room as a container is, so at its edge a gesture that way passes to the scroller around it (backlog F118) — and stays its own until it ends, wherever the pointer goes (backlog F107); it reaches no scroll container above it, and a scroller inside it still takes the axes it scrolls while it can move that way, passing this node the rest — the other axis, and a gesture that begins with that scroller at its limit (`overscroll: \"contain\"` on the scroller keeps it there). The core moves nothing — a grid re-declares `originLine`, a canvas zooms. A drag-select held past a `cells` grid's top or bottom edge arrives here too, once a frame with the lines that frame scrolled by (`docs/adr/0029-a-selection-follows-the-pointer-past-the-edge.md`).",
1002 },
1003 PropDef {
1004 name: "scrollAxes",
1005 id: P_SCROLL_AXES,
1006 kind: Kind::Enum(SCROLL_AXES),
1007 apply: Apply::SpecEnum(|s, i| s.scroll_axes(crate::spec::ScrollAxes::ALL[i])),
1008 doc: "Which axes `onScroll` takes (backlog F107): `both` (the default), `x` or `y`. A scroll gesture on an axis the node does not take passes it by, to the scroller around it, and hears nothing here: a terminal that scrolls its history says `y`, and a sideways swipe that starts over it moves the strip it sits in. (A swipe that started elsewhere is not the node's either way: a gesture keeps the target it started with.) Meaningless without `onScroll`.",
1009 },
1010 PropDef {
1011 name: "window",
1012 id: P_WINDOW,
1013 kind: Kind::Enum(WINDOW_ROLES),
1014 apply: Apply::SpecEnum(|s, i| match i {
1015 0 => s.window_drag(),
1016 1 => s.window_button(WindowButton::Close),
1017 2 => s.window_button(WindowButton::Minimize),
1018 3 => s.window_button(WindowButton::Maximize),
1019 _ => s,
1020 }),
1021 doc: "Window-chrome role: interactions become window commands, not events.",
1022 },
1023 PropDef {
1024 name: "cursor",
1025 id: P_CURSOR,
1026 kind: Kind::Enum(CURSORS),
1027 apply: Apply::SpecEnum(|s, i| s.cursor(cursor_idx(i))),
1028 doc: "The pointer shape over this node. Unset, the pointer is `text` over an editor or a `selectable` scope and `default` over everything else — an `onClick`, `focusable` or `onDrag` node included, as a native button is — so a hand (`pointer`) over a control, a `grab` over a handle (and `grabbing` while its drag runs, which the view declares as its drag state changes), a splitter's `ewResize` / `nsResize` and a `disabled` control's `notAllowed` are all declared. The stock `button` declares `pointer` itself. A captured drag keeps the dragged node's shape wherever the pointer goes.",
1029 },
1030 PropDef {
1031 name: "transition",
1032 id: P_TRANSITION,
1033 kind: Kind::F32,
1034 apply: Apply::SpecF32(|s, v| s.transition(v)),
1035 doc: "Animate sizing/colors/radius changes over this many ms — and, on a scroll container, the offset a reveal or a set_scroll moves it to (needs a stable key).",
1036 },
1037 PropDef {
1038 name: "easing",
1039 id: P_EASING,
1040 kind: Kind::Enum(EASINGS),
1041 apply: Apply::SpecEnum(|s, i| s.easing(easing_idx(i))),
1042 doc: "Easing for `transition` (default easeOut). The springs — `smooth` (no overshoot), `snappy`, `spring` and `bouncy` (the most), each a `bounce` of its own — integrate with momentum, so a value retargeted mid-flight keeps moving the way it was; `transition` is then about how long one takes to get there.",
1043 },
1044 PropDef {
1045 name: "bounce",
1046 id: P_BOUNCE,
1047 kind: Kind::F32,
1048 apply: Apply::SpecF32(|s, v| s.bounce(v)),
1049 doc: "How far a spring overshoots its target: 0 glides in with none, 0.5 bounces visibly, and values past 0.9 are held there (a spring at 1 would never settle). It replaces a spring `easing`'s own bounce (`smooth` 0, `snappy` 0.15, `spring` 0.25, `bouncy` 0.5), and on a timed easing makes the transition a spring — so `transition` plus `bounce` is a spring of that length and bounce.",
1050 },
1051 PropDef {
1052 name: "slide",
1053 id: P_SLIDE,
1054 kind: Kind::Flag,
1055 apply: Apply::SpecFlag(|s| s.slide()),
1056 doc: "With transition: also ease the node's position (reordered siblings slide). While it eases, the node is drawn between where it was and where this frame put it — not at the declared `dx`/`dy`, or its slot in the row — so anything else positioned from those numbers drifts for the transition's length: a canvas of floats eases everything or nothing.",
1057 },
1058 PropDef {
1059 name: "keyframes",
1060 id: P_KEYFRAMES,
1061 kind: Kind::Keyframes,
1062 apply: Apply::SpecKeyframes(|s, k| s.keyframes(k)),
1063 doc: "CSS-style stops `[{ at?, width?, height?, bg?, radius?, opacity? }, …]`: the slots they name cycle through them over `transition` ms, forever, without the view redrawing; `at` is 0..1 and spreads evenly when omitted.",
1064 },
1065 PropDef {
1066 name: "enter",
1067 id: P_ENTER,
1068 kind: Kind::Enter,
1069 apply: Apply::SpecEnter(|s, e| s.enter(e)),
1070 doc: "Where the node starts the first frame it is seen `{ dx?, dy?, width?, height?, bg?, radius?, opacity? }`: those slots ease in from there over `transition` ms instead of snapping (`dx`/`dy` slide it in from that far away, `opacity: 0` fades the whole subtree in).",
1071 },
1072 PropDef {
1073 name: "exit",
1074 id: P_EXIT,
1075 kind: Kind::Enter,
1076 apply: Apply::SpecEnter(|s, e| s.exit(e)),
1077 doc: "Where the node ends the frame after the view stops declaring it `{ dx?, dy?, width?, height?, bg?, radius?, opacity? }` — an `enter` read the other way. It plays when the node itself is removed, its parent still declared; a node that goes because an ancestor went — a tab switched away, a panel closed around it — goes at once with it, unless that ancestor has an `exit` of its own, whose picture carries it (backlog DX19; React's `AnimatePresence` rule). With a `transition`, the departing subtree is copied out of the last frame that had it and replayed frozen, in its place (the pass it painted in, just under the node that painted after it — a panel under a HUD leaves under it) and inert (no clicks, no Tab stop, no access row) while those slots ease from where they were, then dropped; without one it vanishes at once as it always did. `width`/`height` resize the departing node's own box only — the subtree inside it is a picture and is not laid out again. Needs a stable key across frames.",
1078 },
1079 PropDef {
1080 name: "repeat",
1081 id: P_REPEAT,
1082 kind: Kind::Enum(REPEATS),
1083 apply: Apply::SpecEnum(|s, i| s.repeat(repeat_idx(i))),
1084 doc: "How `keyframes` cycle (CSS `animation-direction`, default normal). Lua: `direction`, since `repeat` is a keyword.",
1085 },
1086 PropDef {
1087 name: "delay",
1088 id: P_DELAY,
1089 kind: Kind::F32,
1090 apply: Apply::SpecF32(|s, v| s.delay(v)),
1091 doc: "Holds the `keyframes` cycle back by this many ms (CSS `animation-delay`); siblings with different delays run out of phase.",
1092 },
1093 PropDef {
1094 name: "clickSound",
1095 id: P_CLICK_SOUND,
1096 kind: Kind::Resource,
1097 apply: Apply::SpecResource(|s, id| s.click_sound(crate::resources::SoundId::from_ffi(id))),
1098 doc: "A registered sound (addSound) played when the node is clicked; implies hover tracking.",
1099 },
1100 PropDef {
1101 name: "hoverSound",
1102 id: P_HOVER_SOUND,
1103 kind: Kind::Resource,
1104 apply: Apply::SpecResource(|s, id| s.hover_sound(crate::resources::SoundId::from_ffi(id))),
1105 doc: "A registered sound (addSound) played when the pointer enters the node; implies hover tracking.",
1106 },
1107 PropDef {
1108 name: "lineHeight",
1109 id: P_LINE_HEIGHT,
1110 kind: Kind::F32,
1111 apply: Apply::StyleF32(|t, v| t.line_height(v)),
1112 doc: "Line height (logical px); default size * 1.35.",
1113 },
1114 PropDef {
1115 name: "color",
1116 id: P_COLOR,
1117 kind: Kind::Color,
1118 apply: Apply::StyleColor(|t, c| t.color(c)),
1119 doc: "Text color; default foreground when omitted.",
1120 },
1121 PropDef {
1122 name: "family",
1123 id: P_FAMILY,
1124 kind: Kind::Family,
1125 apply: Apply::StyleFamily(|t, f| t.family(f)),
1126 doc: "Font family: `sans`, `serif` or `mono`, kui's own, or the name of an installed family or one loaded with `loadFontsDir` / `loadFontFile` — `\"Berkeley Mono\"` — drawn in its face in the frame that names it (ADR 0037). A name is matched as `addSystemFont` matches it and registered in the session on first sight, exactly as the font database spells it (`\"menlo\"` is not `\"Menlo\"`); the session's first registration of any font maps the installed font files once (~30 ms on a Mac, backlog DX24), which a family named in a view pays in that frame. `systemFonts()` lists the names there are. A name nothing matches shapes as sans and raises `unknown-family`. It and `font` set the same thing, so declare one.",
1127 },
1128 PropDef {
1129 name: "font",
1130 id: P_FONT,
1131 kind: Kind::Resource,
1132 apply: Apply::StyleResource(|t, id| t.font(crate::resources::FontId::from_ffi(id))),
1133 doc: "A registered font handle (addFont / addSystemFont); overrides `family`.",
1134 },
1135 PropDef {
1136 name: "wrap",
1137 id: P_WRAP,
1138 kind: Kind::Enum(WRAPS),
1139 apply: Apply::StyleEnum(|t, i| match i {
1140 1 => t.wrap(TextWrap::Glyph),
1141 2 => t.wrap(TextWrap::None),
1142 3 => t.wrap(TextWrap::BreakSpaces),
1143 _ => t.wrap(TextWrap::Word),
1144 }),
1145 doc: "Line breaking at the node's width: between words (default), anywhere, never (one line per paragraph, clipped to the node), or between words with whitespace taking its room (`break-spaces`: a space that does not fit starts the next row rather than hanging past the edge — an editor's wrapped line). On a single-line `edit` — a field, which otherwise takes one line and scrolls it — declaring it is what makes the field fold to its width like a document, by this mode, while Enter still submits (see `edit`).",
1146 },
1147 PropDef {
1148 name: "maxLines",
1149 id: P_MAX_LINES,
1150 kind: Kind::F32,
1151 apply: Apply::StyleF32(|t, v| t.max_lines(v.max(0.0) as u32)),
1152 doc: "Lay out at most this many lines (0 = unlimited); with `ellipsis`, a line clamp.",
1153 },
1154 PropDef {
1155 name: "ellipsis",
1156 id: P_ELLIPSIS,
1157 kind: Kind::Flag,
1158 apply: Apply::StyleFlag(|t| t.ellipsis()),
1159 doc: "End the last line with an ellipsis when the text is cut off: a single line unless `maxLines` says otherwise.",
1160 },
1161 PropDef {
1162 name: "underline",
1163 id: P_UNDERLINE,
1164 kind: Kind::Flag,
1165 apply: Apply::StyleFlag(|t| t.underline()),
1166 doc: "A line under the text, where the face puts its underline and as thick as it says, in the text colour. Paint only. On a `<span>` it covers the span alone and follows it across a wrap, one rect per line. `underlineColor` gives it a colour of its own and `underlineStyle` a shape; either implies it.",
1167 },
1168 PropDef {
1169 name: "underlineColor",
1170 id: P_UNDERLINE_COLOR,
1171 kind: Kind::Color,
1172 apply: Apply::StyleColor(|t, c| t.underline_color(c)),
1173 doc: "The underline's own colour — a diagnostic's red under keyword-coloured text (backlog K4). Implies `underline`. On a `<span>` the span's; a span with no colour of its own takes the text's.",
1174 },
1175 PropDef {
1176 name: "underlineStyle",
1177 id: P_UNDERLINE_STYLE,
1178 kind: Kind::Enum(UNDERLINE_STYLES),
1179 apply: Apply::StyleEnum(|t, i| t.underline_style(UnderlineStyle::from_index(i as u32))),
1180 doc: "The underline's shape (backlog K4): `solid` (the face's line), `wavy` (three strokes tall around the line, a six-stroke period — a diagnostic's squiggle, a terminal's undercurl) or `dotted` (dots two strokes across, four apart). Implies `underline`. A wave or dots are runs of the segment primitive a `line` draws, so no backend learns a kind; the cost is two quads per period.",
1181 },
1182 PropDef {
1183 name: "strikethrough",
1184 id: P_STRIKETHROUGH,
1185 kind: Kind::Flag,
1186 apply: Apply::StyleFlag(|t| t.strikethrough()),
1187 doc: "A line through the text, where the face puts its strikeout. Paint only; on a `<span>` the span alone, per line.",
1188 },
1189 PropDef {
1190 name: "features",
1191 id: P_FEATURES,
1192 kind: Kind::Str,
1193 apply: Apply::StyleStr(|t, s| t.features(crate::spec::FontFeatures::parse(s))),
1194 doc: "OpenType features for the shaper, as `tag=value` pairs separated by spaces or commas — a bare `tag` is 1, `-tag` is 0: `\"liga=0 calt=0\"` keeps a coding font from joining `->` and `!=` (what a terminal built on runs needs to hold its grid), `\"tnum\"` lines figures up in a gutter, `\"ss01\"` picks a stylistic set. Unset, the font's own defaults apply. At most 8; part of what the text is shaped as, so two texts differing only here are shaped twice.",
1195 },
1196 PropDef {
1197 name: "role",
1198 id: P_ROLE,
1199 kind: Kind::Enum(ROLES),
1200 apply: Apply::SpecEnum(|s, i| s.role(role_idx(i))),
1201 doc: "What the node is to assistive technology. Unset, the core derives one (an `onClick` node is a button, an editor a text input, a scrolling box a scroll view, a plain box nothing); `none` hides the node and its subtree from the access tree. A `radio` belongs inside a `radioGroup` and a `tab` inside a `tabList`, labelled with what the choice is: the pair is a composite (`docs/adr/0007-composite-keyboard-patterns.md`) — one Tab stop for the set, the arrows, Home and End moving the choice inside it (each step is the item's click, so the choice follows focus), and a screen reader reading \"2 of 3\". A `radio` or `tab` with no container above it is a Tab stop of its own that no arrow moves, and the core warns (`item-outside-container`). `menu` holds `menuItem`s and `list` holds `listItem`s the same way.",
1202 },
1203 PropDef {
1204 name: "label",
1205 id: P_LABEL,
1206 kind: Kind::Str,
1207 apply: Apply::SpecStr(|s, v| s.label(v)),
1208 doc: "The accessible name. Without one a button, link, tab or heading is named by the text inside it; an image, an icon-only button and a `modal` dialog have none, and the core warns (`image-without-label`, `control-without-name`, `modal-without-name`).",
1209 },
1210 PropDef {
1211 name: "description",
1212 id: P_DESCRIPTION,
1213 kind: Kind::Str,
1214 apply: Apply::SpecStr(|s, v| s.description(v)),
1215 doc: "The accessible description: the extra sentence a reader says after the name, for what the name cannot say on its own — what a button will do, why a control is disabled, what format a field wants. `tooltip` is the shorthand that also draws the string and hover-tracks the node; this is the description alone, for a hint that is spoken and never drawn. Both write the one slot, so a node declaring both keeps whichever its binding applied last. It reads only on a node that reaches the access tree — a role, a label, a control — since a plain box is elided and takes its description with it.",
1216 },
1217 PropDef {
1218 name: "checked",
1219 id: P_CHECKED,
1220 kind: Kind::Flag,
1221 apply: Apply::SpecFlag(|s| s.checked(true)),
1222 doc: "The on state of a `checkbox` / `radio` / `switch` role.",
1223 },
1224 PropDef {
1225 name: "mixed",
1226 id: P_MIXED,
1227 kind: Kind::Flag,
1228 apply: Apply::SpecFlag(|s| s.mixed(true)),
1229 doc: "A `checkbox` that is neither on nor off — the select-all box over a list some of whose rows are selected (ADR 0034). Read as mixed by assistive technology whatever `checked` says, and drawn as a dash by the stock `<checkbox>`. Meaningful on the checkbox role alone.",
1230 },
1231 PropDef {
1232 name: "selected",
1233 id: P_SELECTED,
1234 kind: Kind::Flag,
1235 apply: Apply::SpecFlag(|s| s.selected(true)),
1236 doc: "The current one of a set: which `tab` a `tabList` shows, which `listItem` a list has picked, which `link` is the page you are on. A `tab` always carries the state — its siblings read as \"not selected\" — while a list row or a link carries it only where it is set, since an ordinary list or navigation bar is not a selection and a reader saying \"not selected\" on every row of it is noise.",
1237 },
1238 PropDef {
1239 name: "expanded",
1240 id: P_EXPANDED,
1241 kind: Kind::Enum(EXPANDED),
1242 apply: Apply::SpecEnum(|s, i| s.expanded(i == 1)),
1243 doc: "A disclosure's state: what a node that shows and hides something (a twisty, an accordion header, a menu button) reads as. Unset, the node does not expand at all — which is why this names its state instead of being a flag.",
1244 },
1245 PropDef {
1246 name: "live",
1247 id: P_LIVE,
1248 kind: Kind::Enum(LIVE),
1249 apply: Apply::SpecEnum(|s, i| s.live(crate::access::Live::from_index(i))),
1250 doc: "Marks this node a live region: when the text inside it changes, a screen reader reads the change without being asked — `polite` at the next pause, `assertive` interrupting. Put it on the smallest node that holds the message, since everything inside a live node is live. For a one-off with no node behind it (\"Saved\") the binding's `announce` verb is the other half.",
1251 },
1252 PropDef {
1253 name: "valueNow",
1254 id: P_VALUE_NOW,
1255 kind: Kind::F32,
1256 apply: Apply::SpecF32(|s, v| s.value_now(v)),
1257 doc: "A `slider` role's current value (the drawing stays yours; this is what assistive technology reads).",
1258 },
1259 PropDef {
1260 name: "valueMin",
1261 id: P_VALUE_MIN,
1262 kind: Kind::F32,
1263 apply: Apply::SpecF32(|s, v| s.value_min(v)),
1264 doc: "A `slider` role's minimum.",
1265 },
1266 PropDef {
1267 name: "valueMax",
1268 id: P_VALUE_MAX,
1269 kind: Kind::F32,
1270 apply: Apply::SpecF32(|s, v| s.value_max(v)),
1271 doc: "A `slider` role's maximum.",
1272 },
1273 PropDef {
1274 name: "valueText",
1275 id: P_VALUE_TEXT,
1276 kind: Kind::Str,
1277 apply: Apply::SpecStr(|s, v| s.value_text(v)),
1278 doc: "What a `slider` role's position reads as (ARIA's `aria-valuetext`). Without one a reader has only `valueNow` and the range and says a percentage — 25 in [5..60] is \"36 percent\" — so a value whose unit carries the meaning says it here: \"25 minutes\". It replaces the number in the reading rather than joining it, and a nudge announces the new text. Meaningful on the slider role alone, like the three numbers; putting the reading in `label` instead renames the control on every nudge, which is the wrong attribute.",
1279 },
1280 PropDef {
1281 name: "valueStep",
1282 id: P_VALUE_STEP,
1283 kind: Kind::F32,
1284 apply: Apply::SpecF32(|s, v| s.value_step(v)),
1285 doc: "How far one arrow key moves a `slider` role, and the grid a value the pointer sets snaps to (ADR 0034). Unset, a hundredth of the range. PageUp / PageDown move ten steps. Read by the core only where the slider declares `onChange`; reported to assistive technology either way.",
1286 },
1287 PropDef {
1288 name: "onFocus",
1289 id: P_ON_FOCUS,
1290 kind: Kind::Tag,
1291 apply: Apply::SpecMsg(|s, v| s.on_focus(v)),
1292 doc: "Keyboard focus entering or leaving this node's subtree — the node itself, or anything focused inside it — emits `{kind:\"focus\", phase:\"in\"|\"out\", by, tag}` (backlog DX18). `by` is what moved it: `pointer` (a press), `keyboard` (Tab, a key a control answered), `assistive` (a screen reader's request) or `program` (the view or the app — `keyFocus`, `setFocus`, a modal's entry). Reported once the move settles, after the input that made it or at the end of the frame that declared it, so an app hears a pane taking the keyboard instead of diffing the focused key every frame. Leaving is reported innermost first, entering outermost first. It makes nothing focusable or interactive.",
1293 },
1294 PropDef {
1295 name: "onChange",
1296 id: P_ON_CHANGE,
1297 kind: Kind::Tag,
1298 apply: Apply::SpecMsg(|s, v| s.on_change(v)),
1299 doc: "A `slider` role's changes (ADR 0034): the core turns a press on the node into the value under the pointer, a drag into the value under it, the arrows and assistive technology's increment / decrement into one `valueStep`, PageUp / PageDown into ten, Home / End into the range's ends — clamped to `valueMin`..`valueMax` (0..100 unset) and snapped to the step — and emits `{kind:\"change\", value, phase, tag}`: `phase` is `\"move\"` while the pointer holds the slider and `\"end\"` when it lets go or a key moved it. The value is proposed and never applied; the slider moves when the view declares it as `valueNow`. A key that lands where the slider already is proposes nothing. The pointer reads the node's content box along its main axis, so a `dir=\"column\"` slider runs bottom to top. Without it a slider's arrows reach the app as `{kind:\"access\", action}`. Ignored on any other role.",
1300 },
1301 PropDef {
1302 name: "caret",
1303 id: P_CARET,
1304 kind: Kind::F32,
1305 apply: Apply::SpecF32(|s, v| s.caret(v.max(0.0) as u32)),
1306 doc: "On a `line` of a custom editor (a `textInput` / `multilineTextInput` role drawn by the app): the caret's byte offset into that line's text.",
1307 },
1308 PropDef {
1309 name: "selectionAnchor",
1310 id: P_SELECTION_ANCHOR,
1311 kind: Kind::F32,
1312 apply: Apply::SpecF32(|s, v| s.selection_anchor(v.max(0.0) as u32)),
1313 doc: "On a `line` of a custom editor: the byte offset where the selection's other end sits (the caret is `caret`, possibly on another line).",
1314 },
1315 PropDef {
1316 name: "caretSolid",
1317 id: P_CARET_SOLID,
1318 kind: Kind::Flag,
1319 apply: Apply::SpecFlag(|s| s.caret_solid()),
1320 doc: "On a `line` declaring `caret`: the caret is solid — a block caret in a modal editor's normal mode — so the driver's blink clock is not armed on it and `caretVisible` stays true, while the offset still anchors the IME and reads to assistive technology. Without it a declared `caret` is a caret to blink, and the one thing that asks an idle app for a frame twice a second; an editor whose caret only blinks while typing declares this on every other mode's line.",
1321 },
1322];
1323
1324pub struct CustomProp {
1328 pub name: &'static str,
1329 pub id: u32,
1330 pub jsx_names: &'static [&'static str],
1337 pub lua_names: &'static [&'static str],
1339 pub jsx: &'static str,
1341 pub lua: &'static str,
1343 pub c: &'static str,
1345 pub doc: &'static str,
1346}
1347
1348pub const CUSTOM: &[CustomProp] = &[
1349 CustomProp {
1350 name: "dir",
1351 id: P_DIR,
1352 jsx_names: &["dir"],
1353 lua_names: &[],
1354 jsx: "`dir=\"row\" | \"column\" | \"table\"`",
1355 lua: "`row { }` / `column { }` / `grid { }`",
1356 c: "`dir` (`KUI_ROW` / `KUI_COLUMN` / `KUI_TABLE`)",
1357 doc: "Main axis; column is the default. `table` is a column whose rows' children line up in columns (the `table` element).",
1358 },
1359 CustomProp {
1360 name: "size",
1361 id: P_SIZE,
1362 jsx_names: &["size"],
1363 lua_names: &["size"],
1364 jsx: "`size` (text)",
1365 lua: "`size`",
1366 c: "`KuiTextStyle.size`",
1367 doc: "Font size in logical px; the text style is constructed from it, so declare it for the other style props to apply at that size.",
1368 },
1369 CustomProp {
1370 name: "pad",
1371 id: P_PAD,
1372 jsx_names: &["pad", "padX", "padY", "padL", "padR", "padT", "padB"],
1373 lua_names: &["pad"],
1374 jsx: "`pad`, `padX`, `padY`, `padL`, `padR`, `padT`, `padB`",
1375 lua: "`pad = n` or `pad = { all=, x=, y=, l=, r=, t=, b= }`",
1376 c: "`pad_l`, `pad_r`, `pad_t`, `pad_b`",
1377 doc: "Padding; a frontend reports the names it saw and `PadShorthand::resolve` turns them into four edges — an edge falls back to its axis, an axis to the all-round `pad`, and the specific one always wins.",
1378 },
1379 CustomProp {
1380 name: "border",
1381 id: P_BORDER,
1382 jsx_names: &["borderW", "borderColor"],
1383 lua_names: &["border"],
1384 jsx: "`borderW`, `borderColor`",
1385 lua: "`border = { w=, color= }`",
1386 c: "`border_w`, `border_color`",
1387 doc: "Border width and color (drawn inside the rect).",
1388 },
1389 CustomProp {
1390 name: "overflow",
1391 id: P_OVERFLOW,
1392 jsx_names: &["clip", "scrollX", "scrollY"],
1393 lua_names: &["clip", "scroll", "scroll_x", "scroll_y"],
1394 jsx: "`clip`, `scrollX`, `scrollY`",
1395 lua: "`clip`, `scroll_x`, `scroll_y` (`scroll` = `scroll_y`)",
1396 c: "`overflow` bits `KUI_CLIP` | `KUI_SCROLL_X` | `KUI_SCROLL_Y`",
1397 doc: "Clip children; scroll (implies clip) with retained offsets and live scrollbars. A `radius` on the same node rounds the clip, so a rounded card does not show square corners poking out of it; nesting two rounded clippers keeps only the corners neither of them moved, and hit-testing stays rectangular. Every frontend ORs the same bits and hands them to `NodeSpec::overflow_bits`. The wheel goes to the scroller under the pointer on the axes it scrolls, and the rest of the notch to the one around it: a `scrollY` list inside a `scrollX` strip moves the strip on a sideways swipe (backlog DX13). A scroll gesture — a swipe and its glide, a wheel spun without a pause — picks that scroller when it starts, skipping one already at its limit that way for the one around it (unless it says `overscroll: contain`), and keeps it until it ends, so content moving under a still pointer does not hand the rest of a swipe to what came under it (backlog F107). An offset is kept while the key is declared; an undeclared one is kept until the budget needs the room (1024 undeclared entries, longest-undeclared evicted first).",
1398 },
1399 CustomProp {
1400 name: "float",
1401 id: P_FLOAT,
1402 jsx_names: &["float"],
1403 lua_names: &["float"],
1404 jsx: "`float=\"below\" | \"above\" | \"parent\" | \"viewport\"` or `{ anchor, at, self, dx, dy, fit, clip }`",
1405 lua: "`float = \"below\"` or `float = { anchor=, at=, self=, dx=, dy=, fit=, clip= }`",
1406 c: "`float_mode`, `float_anchor_x/y`, `float_self_x/y`, `float_dx/dy`, `float_fit`, `float_clip`; `kui_spec_float_preset` fills them from a preset name",
1407 doc: "Out-of-flow positioning against the parent or the viewport; `fit` flips/clamps to stay on screen. A float escapes every ancestor's clip — a tooltip is not cut by the scroller it hangs from — unless it declares `clip` and is anchored to its parent (`parent`, `below`, `above`): then the parent's clip holds it as it holds a child, so a node on a `clip` canvas panned past the canvas's edge is cut there and cannot be hit past it; it still paints as a layer over its in-flow siblings. A `line` or `polygon` in its parent's box is always clipped this way. The four preset names resolve in `FloatConfig::preset`, and `anchor` takes any of them — an override left out keeps the preset's own value, so `{ anchor: \"below\", dx: 4 }` still hangs below with its 6px gap. A float is a layer of its own: above the in-flow tree and every float that opened before it, under every float that opened after, and hit-tested in the same order — so a tooltip that appears over an open menu is over it, and a popover over a scroller's bar takes the press there. A scroller's bars and the focus ring belong to the layer that owns them. There is no z-index; a float declared under a fresh key reopens on top (`docs/adr/0023-layers-stack-in-the-order-they-open.md`).",
1408 },
1409 CustomProp {
1410 name: "keyFocus",
1411 id: P_KEY_FOCUS,
1412 jsx_names: &["keyFocus"],
1413 lua_names: &["key_focus"],
1414 jsx: "`keyFocus`",
1415 lua: "`key_focus`",
1416 c: "`kui_set_key_focus`",
1417 doc: "Focuses this node (an `onKey` sink, an editor, any focusable node) when it starts being declared: declared every frame it takes focus once, so a later Tab press is not clobbered. Declaring it on the frame a `modal` stops being declared is how a view says where focus lands on the way out — the edge stands, and the focus the modal displaced is not handed back over it (`docs/adr/0003-modal-surfaces.md`, decision 4). To move focus at any time call the binding's focus verb (`ctx.focus`, `kui_focus`, `env.set_focus`).",
1418 },
1419 CustomProp {
1420 name: "key",
1421 id: P_KEY,
1422 jsx_names: &["key"],
1423 lua_names: &["key"],
1424 jsx: "`key`",
1425 lua: "`key`",
1426 c: "`kui_open_keyed` label",
1427 doc: "Stable identity for retained state (scroll offsets, editors, transitions; keys are hashes of the path from the root). Retained state outlives the key's absence, under a budget on the states nobody declares (see `<edit>` and the overflow props).",
1428 },
1429 CustomProp {
1430 name: "index",
1431 id: P_INDEX,
1432 jsx_names: &["index"],
1433 lua_names: &["index"],
1434 jsx: "`index`",
1435 lua: "`index`",
1436 c: "`kui_open_indexed`",
1437 doc: "Stable identity by *data* index rather than by name: the key auto-keying would have given this node as the `i`th child, given to it wherever it actually sits. What a virtualised list is for — a view that builds rows 900..930 of ten thousand opens each with its own row number, so the row keeps its hover, focus, edit buffer and tweens as the built range slides over it, and a list that builds every row agrees with one that builds a screenful. Wherever `key` names a node this numbers it (a box, a `line`, a `cells`, a `fragment`); declared beside `key` the index wins. Indices and names are separate namespaces, so a spacer keyed `\"lead\"` cannot collide with row 0 — but two rows on one index do, exactly as two on one name would.",
1438 },
1439 CustomProp {
1440 name: "rowCount",
1441 id: P_ROW_COUNT,
1442 jsx_names: &["rowCount"],
1443 lua_names: &["row_count"],
1444 jsx: "`rowCount`",
1445 lua: "`row_count`",
1446 c: "`kui_row_count`",
1447 doc: "How many `index`ed rows this node's virtual list has, built or not. `uniformList` / `uniform_list` / `widgets::uniform_list` and `widgets::list` declare it on their container; a list composed by hand says it beside `scrollY`. What it buys: Select All (Cmd/Ctrl-A, the menu's row) inside a `selectable` virtual list selects the *data*, rows `0..rowCount`, rather than the rows the frame built, and the copy is a `selectionrange` ask whose `to.byte` is past the last row's length when that row is not built — cut it to the row. Without it Select All is the built rows, which is all the core can see.",
1448 },
1449 CustomProp {
1450 name: "title",
1451 id: P_TITLE,
1452 jsx_names: &["title"],
1453 lua_names: &["window_title"],
1454 jsx: "`title` (root box only)",
1455 lua: "`window_title` (root table)",
1456 c: "`kui_window_title`",
1457 doc: "Declares the window title for this frame; the driver diffs and applies.",
1458 },
1459 CustomProp {
1460 name: "alwaysOnTop",
1461 id: P_ALWAYS_ON_TOP,
1462 jsx_names: &["alwaysOnTop"],
1463 lua_names: &["always_on_top"],
1464 jsx: "`alwaysOnTop` (root box only)",
1465 lua: "`always_on_top = true` (root table)",
1466 c: "`kui_set_always_on_top`",
1467 doc: "Declares that this frame wants the window kept above every other app's — a floating palette, a picture-in-picture player, a timer (backlog C30). Frame state the way `title` is, applied by the driver on change and free on the frames it does not change, but with a default of false rather than \"leave as-is\": a frame that stops declaring it lowers the window again, so a pin button is a toggle on the app's own state and nothing has to remember to undo it. Whether the platform has a level to set is `env.window.always_on_top`, which is what the pin button should draw its state from — Wayland has no call for it at all, so there the window never moves and the reading says so; it is the driver's record of what it set, not a query, so a level the OS dropped afterwards (a fullscreen space, a tiling manager) is not reported. A popup keeps its own level whatever its owner declares.",
1468 },
1469 CustomProp {
1470 name: "secureInput",
1471 id: P_SECURE_INPUT,
1472 jsx_names: &["secureInput"],
1473 lua_names: &["secure_input"],
1474 jsx: "`secureInput` (root box only)",
1475 lua: "`secure_input = true` (root table)",
1476 c: "`kui_set_secure_input`",
1477 doc: "Declares that this frame wants the keyboard to this window kept from every other process while the window has it — macOS's Secure Keyboard Entry, what a terminal turns on at a password prompt (backlog F85). Frame state the way `alwaysOnTop` is, default false: declare it on every frame the prompt is up, and the frame that stops is what turns it off, so nothing has to remember to undo it. The runner owns the platform call and its balance: `EnableSecureEventInput` is process-wide and counted, and the runner holds one count while a window whose frame asked has the keyboard, giving it back when that window loses the keyboard, closes or stops asking, and at exit — Apple's rule, since while it is on no other process can read the keyboard at all (a launcher's hotkey, a text expander, an accessibility tool). Nothing on Windows or Linux, which have no such switch. A C host with its own loop reads the ask with `kui_secure_input_get` and makes the call itself.",
1478 },
1479 CustomProp {
1480 name: "optionAsAlt",
1481 id: P_OPTION_AS_ALT,
1482 jsx_names: &["optionAsAlt"],
1483 lua_names: &["option_as_alt"],
1484 jsx: "`optionAsAlt=\"left\"` — `\"none\"`, `\"left\"`, `\"right\"`, `\"both\"` (root box only)",
1485 lua: "`option_as_alt = \"left\"` (root table)",
1486 c: "`kui_set_option_as_alt` (`KUI_OPTION_AS_ALT_*`)",
1487 doc: "Declares which Option keys act as Alt in this window on macOS (backlog F113). On a Mac, Option composes: ⌥m types `µ`, and ⌥u, ⌥e, ⌥i, ⌥n and ⌥` are dead keys that start an accent and wait for the next key, so the press never reaches the app as a key and a keymap binding `<A-u>` never hears it. An Option key named here is Alt instead: it composes nothing and types nothing, and a key under it arrives as a chord of the key the layout prints unmodified — a terminal's \"Option as Meta\", an editor's Alt bindings. `\"left\"` or `\"right\"` leaves the other side composing, so a user keeps `ü` on one Option; `\"both\"` takes both; `\"none\"`, the default, is the Mac's own behaviour. Frame state the way `alwaysOnTop` is: declare it on every frame, and the frame that stops gives the Option keys back to the layout; the runner applies it to the window on change, never per frame. A popup's keys arrive through its owner, so the owner's declaration is the one they are read under. Nothing on Windows or Linux, whose Alt composes nothing. A C host with its own loop reads the ask with `kui_option_as_alt_get` and applies it itself.",
1488 },
1489 CustomProp {
1490 name: "windows",
1491 id: P_WINDOWS,
1492 jsx_names: &["windows"],
1493 lua_names: &["windows"],
1494 jsx: "`windows={[{ name, kind?, anchor?, width?, height?, activates? }]}` (root box only; `windows: (model) => [...]` in the loop config)",
1495 lua: "`windows = { { name=, kind=, anchor=, width=, height=, activates= } }` (root table)",
1496 c: "`kui_window_declare`",
1497 doc: "Declares which windows exist this frame, by stable name (`docs/adr/0004-multi-window.md`). A window opens on the first frame any window's frame declares it — its config is read then and never again, since the user owns its geometry once it exists — and closes on the first frame none does. The driver drains the `Open` / `Close` that result, and the app sees `{kind:\"window\", phase, name, id}`. A window the user closed does not reopen while it is still declared: stop declaring it, then declare it again. `kind: \"popup\"` makes it a menu surface instead: borderless, off the taskbar, owned by the window that declared it and closed with it, placed in screen coordinates against `anchor` — the `{x, y, w, h}` an `onLayout` node reported — and non-activating unless `activates` says otherwise, so the field that opened it keeps the focus ring while the arrows walk the list. A press outside it or Escape raises `{kind:\"dismiss\", reason, name, id}` and closes nothing, exactly as a `modal` node's does: stop declaring the window. Reach for a popup only for the placements a float cannot make — a list taller than the window, a menu with nowhere in-window to go, a panel beside the app; everything else stays `fit` plus a `modal` float, which costs one tree instead of an OS surface.",
1498 },
1499 CustomProp {
1500 name: "tooltip",
1501 id: P_TOOLTIP,
1502 jsx_names: &["tooltip"],
1503 lua_names: &["tooltip"],
1504 jsx: "`tooltip=\"hint\"`",
1505 lua: "`tooltip = \"hint\"`",
1506 c: "`KuiSpec.tooltip` (`kui_tooltip` / `kui_tooltip_with` draw a hint that is not hover-gated)",
1507 doc: "Floats a hint below the node while hovered. All three effects — hover tracking, the accessible description, and the float itself — come from `PropsOut::apply_tooltip`, so no frontend can implement two of them; a Rust view has all three in `NodeSpec::tooltip` (`NodeSpec::apply_tooltip` is the spec half, for a caller that floats the hint itself). The `description` row is that middle effect on its own, for a hint that is spoken and never drawn. The float is the node's last child, so it is drawn for a box or a `fragment`; on a leaf that holds no children — an `image`, an `edit`, a `cells` grid — the hint is tracked and spoken but not drawn, so put the tooltip on a box around it (backlog RG75).",
1508 },
1509];
1510
1511pub const C_FIELDS: &[(&str, &str)] = &[
1514 ("width", "`width` (KuiSizing)"),
1515 ("height", "`height` (KuiSizing)"),
1516 ("center", "`main_align` + `cross_align` = `KUI_CENTER`"),
1517 ("window", "`window_role` (`KUI_WINDOW_*`)"),
1518 ("transition", "`transition_ms`"),
1519 ("easing", "`easing` (`KUI_EASE_*`)"),
1520 (
1521 "keyframes",
1522 "`keyframes` + `keyframes_len` (`KuiKeyframe[]`)",
1523 ),
1524 ("repeat", "`repeat` (`KUI_REPEAT_*`)"),
1525 ("delay", "`delay_ms`"),
1526 ("enter", "`enter` (`KuiEnter`, with `set` bits)"),
1527 ("exit", "`exit` (`KuiEnter`, with `set` bits)"),
1528 ("opacity", "`opacity` with `opacity_set`"),
1529 ("radiusTL", "`radius_tl` with `per_corner`"),
1530 ("radiusTR", "`radius_tr` with `per_corner`"),
1531 ("radiusBR", "`radius_br` with `per_corner`"),
1532 ("radiusBL", "`radius_bl` with `per_corner`"),
1533 ("hoverGroup", "`hover_group` (KuiStr)"),
1534 ("role", "`role` (`KUI_ROLE_*`)"),
1535 ("expanded", "`expanded` (`KUI_EXPANDED_*`)"),
1536 ("live", "`live` (`KUI_LIVE_*`)"),
1537 ("cursor", "`cursor` (`KUI_CURSOR_*`)"),
1538 ("label", "`label` (KuiStr)"),
1539 (
1540 "valueNow",
1541 "`value_now` with `KUI_VALUE_NOW` in `value_set`",
1542 ),
1543 (
1544 "valueMin",
1545 "`value_min` with `KUI_VALUE_MIN` in `value_set`",
1546 ),
1547 (
1548 "valueMax",
1549 "`value_max` with `KUI_VALUE_MAX` in `value_set`",
1550 ),
1551 ("valueText", "`value_text` (KuiStr)"),
1552 ("caret", "`caret` with `KUI_VALUE_CARET` in `value_set`"),
1553 (
1554 "selectionAnchor",
1555 "`selection_anchor` with `KUI_VALUE_ANCHOR` in `value_set`",
1556 ),
1557 (
1558 "onClick",
1559 "`on_click` argument of `kui_open` / `kui_open_with`",
1560 ),
1561 (
1562 "onDrag",
1563 "`on_drag` argument of `kui_open_draggable` / `kui_open_with`",
1564 ),
1565 ("onKey", "`on_key` argument of `kui_open_with`"),
1566 (
1567 "onContextMenu",
1568 "`on_context_menu` (a borrowed `KuiValue*`, cloned while the node opens)",
1569 ),
1570 (
1571 "onButton",
1572 "`on_button` (a borrowed `KuiValue*`, cloned while the node opens)",
1573 ),
1574 (
1575 "buttons",
1576 "`buttons` (`KUI_BUTTONS_*` bits; zeroed, all three)",
1577 ),
1578 (
1579 "modal",
1580 "`modal` (a borrowed `KuiValue*`, cloned while the node opens)",
1581 ),
1582 (
1583 "onScroll",
1584 "`on_scroll` (a borrowed `KuiValue*`, cloned while the node opens)",
1585 ),
1586 ("onHover", "`on_hover` argument of `kui_open_with`"),
1587 (
1588 "onFocus",
1589 "`on_focus` (a borrowed `KuiValue*`, cloned while the node opens)",
1590 ),
1591 ("ruleWidth", "`rule_w`"),
1592 (
1593 "overscroll",
1594 "`overscroll` (`KUI_OVERSCROLL_*`; zeroed, auto)",
1595 ),
1596 (
1597 "scrollAxes",
1598 "`scroll_axes` (`KUI_SCROLL_AXES_*`; zeroed, both)",
1599 ),
1600 (
1601 "onDrop",
1602 "`on_drop` (a borrowed `KuiValue*`, cloned while the node opens)",
1603 ),
1604 (
1605 "onLayout",
1606 "`on_layout` (a borrowed `KuiValue*`, cloned while the node opens)",
1607 ),
1608 ("family", "`KuiTextStyle.family` (`KUI_FONT_*`)"),
1609 ("font", "`KuiTextStyle.font` (from `kui_font_add*`)"),
1610 ("lineHeight", "`KuiTextStyle.line_height`"),
1611 ("wrap", "`KuiTextStyle.wrap` (`KUI_WRAP_*`)"),
1612 ("maxLines", "`KuiTextStyle.max_lines`"),
1613 ("ellipsis", "`KuiTextStyle.ellipsis`"),
1614 (
1615 "features",
1616 "`KuiTextStyle.features` (a `KuiStr`, the same spelling)",
1617 ),
1618 (
1619 "underline",
1620 "`KuiTextStyle.decoration` (`KUI_DECO_UNDERLINE`); `KuiSpan.flags` (`KUI_SPAN_UNDERLINE`)",
1621 ),
1622 (
1623 "strikethrough",
1624 "`KuiTextStyle.decoration` (`KUI_DECO_STRIKETHROUGH`); `KuiSpan.flags` (`KUI_SPAN_STRIKETHROUGH`)",
1625 ),
1626 (
1627 "underlineColor",
1628 "`KuiTextStyle.underline_color`; `KuiSpan.underline_color`",
1629 ),
1630 (
1631 "underlineStyle",
1632 "`KuiTextStyle.underline_style` (`KUI_UNDERLINE_*`); `KuiSpan.underline_style`",
1633 ),
1634 ("color", "`KuiTextStyle.color`"),
1635];
1636
1637pub fn c_field(def: &PropDef) -> String {
1639 C_FIELDS
1640 .iter()
1641 .find(|(n, _)| *n == def.name)
1642 .map(|(_, c)| (*c).to_string())
1643 .unwrap_or_else(|| format!("`{}`", def.snake_name()))
1644}
1645
1646pub struct ElementDef {
1651 pub name: &'static str,
1652 pub jsx_own: &'static [&'static str],
1658 pub lua_own: &'static [&'static str],
1660 pub jsx_rows: Option<&'static [&'static str]>,
1668 pub lua_rows: Option<&'static [&'static str]>,
1669 pub jsx: &'static str,
1670 pub lua: &'static str,
1671 pub c: &'static str,
1672 pub doc: &'static str,
1673}
1674
1675pub const TEXT_ROWS_JSX: &[&str] = &[
1686 "size",
1687 "lineHeight",
1688 "color",
1689 "family",
1690 "font",
1691 "wrap",
1692 "maxLines",
1693 "ellipsis",
1694 "underline",
1695 "underlineColor",
1696 "underlineStyle",
1697 "strikethrough",
1698 "features",
1699];
1700pub const TEXT_ROWS_LUA: &[&str] = &[
1701 "size",
1702 "line_height",
1703 "color",
1704 "family",
1705 "font",
1706 "wrap",
1707 "max_lines",
1708 "ellipsis",
1709 "underline",
1710 "underline_color",
1711 "underline_style",
1712 "strikethrough",
1713 "features",
1714];
1715
1716pub const BUTTON_ROWS_JSX: &[&str] = &[
1724 "onClick",
1725 "key",
1726 "index",
1727 "label",
1728 "description",
1729 "tooltip",
1730 "disabled",
1731 "accent",
1732];
1733pub const BUTTON_ROWS_LUA: &[&str] = &[
1734 "on_click",
1735 "key",
1736 "index",
1737 "label",
1738 "description",
1739 "tooltip",
1740 "disabled",
1741 "accent",
1742];
1743
1744pub const TOGGLE_ROWS_JSX: &[&str] = &[
1749 "onClick",
1750 "key",
1751 "label",
1752 "description",
1753 "tooltip",
1754 "disabled",
1755 "checked",
1756 "mixed",
1757];
1758pub const TOGGLE_ROWS_LUA: &[&str] = &[
1759 "on_click",
1760 "key",
1761 "label",
1762 "description",
1763 "tooltip",
1764 "disabled",
1765 "checked",
1766 "mixed",
1767];
1768pub const SLIDER_ROWS_JSX: &[&str] = &[
1772 "key",
1773 "label",
1774 "description",
1775 "tooltip",
1776 "disabled",
1777 "valueNow",
1778 "valueMin",
1779 "valueMax",
1780 "valueStep",
1781 "valueText",
1782 "onChange",
1783 "width",
1784 "minWidth",
1785 "maxWidth",
1786];
1787pub const SLIDER_ROWS_LUA: &[&str] = &[
1788 "key",
1789 "label",
1790 "description",
1791 "tooltip",
1792 "disabled",
1793 "value_now",
1794 "value_min",
1795 "value_max",
1796 "value_step",
1797 "value_text",
1798 "on_change",
1799 "width",
1800 "min_width",
1801 "max_width",
1802];
1803
1804pub const ELEMENTS: &[ElementDef] = &[
1805 ElementDef {
1806 name: "box",
1807 jsx_own: &[],
1808 lua_own: &[],
1809 jsx_rows: None,
1810 lua_rows: None,
1811 jsx: "`<box>`",
1812 lua: "`row { }`, `column { }`",
1813 c: "`kui_open*` … `kui_close`",
1814 doc: "A container: every container prop applies.",
1815 },
1816 ElementDef {
1817 name: "table",
1818 jsx_own: &[],
1819 lua_own: &[],
1820 jsx_rows: None,
1821 lua_rows: None,
1822 jsx: "`<box dir=\"table\">`",
1823 lua: "`grid { }`",
1824 c: "`kui_open*` with `dir = KUI_TABLE`",
1825 doc: "A column whose rows' children line up in columns (`docs/adr/0033-a-table-is-a-column-whose-cells-align.md`): its children are the rows, each row's in-flow children its cells, the nth cell of every row column n, and a column as wide as its widest cell — so a label column sits at its longest label with nothing measured and no width picked by hand, in every binding, since the alignment is the layout's and not a widget's. A cell's `width` says how its column sizes: `fit` (the default) and a fixed number are content the column's fit width is the max of; `grow` makes the whole column grow with the table, `grow` factors splitting the room the fit columns leave; a percent takes its cut of the row; and a column's `minWidth` / `maxWidth` are the strictest its cells declared. Fit columns that overflow the row are compressed toward their floors largest first, as a row's children are, unless the table scrolls x; a fixed column never is. A bare text is a cell too, held at its column's width, so a text straight inside a row is a column; an image straight in a row is a cell the same way, its box the column wide and its own aspect tall, the pixels meeting the box by its `fit` row (wrap an icon in a box to keep its own width). The rows are the table's `row` children, ordinary rows — give them `width=\"grow\"` for the columns to grow into (a `fit` row sits at the columns' width) — with their own `gap` between cells, their own padding, background, click, hover and access rows; a row of a table never wraps (`wrap-ignored`). Anything else straight under the table — a text, a `column` section, another table — is a child with its own width and no cells. The table's own `fit` width is its columns', whatever its rows' sizing, so a table with no width is the aligned list; a `scrollX` table's rows are at least as wide as its columns, and it scrolls to them. Everything else is a column's: `gap` is the space between rows, `scrollY` scrolls them, a float in a row is not a cell. Spelled `grid { }` in Lua, since `table` is Lua's own.",
1826 },
1827 ElementDef {
1828 name: "text",
1829 jsx_own: &["bold", "italic", "bg", "bgRadius"],
1830 lua_own: &["value", "spans"],
1831 jsx_rows: Some(TEXT_ROWS_JSX),
1832 lua_rows: Some(TEXT_ROWS_LUA),
1833 jsx: "`<text>` with `<span bold italic underline strikethrough bg bgRadius color>` children",
1834 lua: "`text(\"s\", {…})`, `text({ \"a\", { \"b\", bold = true, underline = true, bg = 0x.., bg_radius = 4 } })`",
1835 c: "`kui_text`, `kui_rich_text`",
1836 doc: "Plain or rich text; spans shape as one paragraph, so wrapping crosses style boundaries. A text is content plus a style and no box of its own, so the rows it reads are the style rows (`size`, `lineHeight`, `color`, `family`, `font`, `wrap`, `maxLines`, `ellipsis`, `underline`, `strikethrough`, `features`) and nothing else: a container row, an access row (`label`, `role`, `live`), `key` or `onClick` on a text is dropped with an `unknown-prop` warning naming the rows it does take — put them on the box around it. `wrap`, `maxLines` and `ellipsis` control line breaking. A span's `bg` is a background behind its glyphs alone, one rect per line it spans, so it follows the span across a wrap the way a box around a run cannot. With `bgRadius` (`bg_radius` in Lua, `KuiSpan.bg_radius` in C, `Span::bg_radius` in Rust; logical px) the background is rounded and joined into one shape with every rounded background of the same colour and radius it meets: a piece whose edge touches it exactly on the line above or below and overlaps it sideways, or that meets it end to end on its own line, in this text or another. Its corners are then convex where a line reaches past its neighbour, a fillet where it falls short, and round where nothing meets it — a selection over many rows, or over the wrapped lines of a paragraph, is one rounded outline, joined after every text of the frame is laid out and painted, so it is never a frame behind. Nothing names the shape: two that touch are one; `underline` and `strikethrough` on a span or on the whole text are lines where the face puts them. A text with no line breaks that is 4096 bytes or longer (and no `maxLines` or `ellipsis`), plain or spans alike, is shaped in ~1 KB chunks as they come on screen, so a minified bundle or a log line with a blob in it costs the screenful it shows and a keystroke into it — or a span moving along it, an editor's caret — costs the chunk it lands in; wrapped, the rows are broken from the chunks' positions, so a 100k-character paragraph costs the rows it shows. Its size is estimated from the first chunk until the rest shape (exact under monospace), and the access tree carries its value without its runs.",
1837 },
1838 ElementDef {
1839 name: "button",
1840 jsx_own: &[],
1841 lua_own: &["text"],
1842 jsx_rows: Some(BUTTON_ROWS_JSX),
1843 lua_rows: Some(BUTTON_ROWS_LUA),
1844 jsx: "`<button onClick key|index label description tooltip disabled accent>`",
1845 lua: "`button { label=, on_click=, key= | index=, text=, description=, tooltip=, disabled=, accent= }`",
1846 c: "`kui_button`, `kui_button_with`",
1847 doc: "The stock button: `widgets::button_spec(&theme, &metrics)` — the theme's accent trio as its three backgrounds, declared on the node and resolved by the core — keyed by its text (`key` overrides). It paints from the palette like every stock widget (backlog AR41): the OS's accent where the host reports one, the app's where it set or pinned one, kui's blue otherwise; the label goes black or white by the background's luminance. Its look is its spec, so the layout and paint rows are closed — declared, they are dropped with an `unknown-prop` warning naming the rows it does read — and those are the access rows: `label` when the text is not the name, `description`, `tooltip`, and `disabled` (inert, and dimmed to half). The one paint row it takes is `accent`, which on a button changes nothing (it is the accent already) and is kept for the box's sake. In Lua `label` is the name and the text both unless `text` says otherwise; in C the rows ride a `KuiSpec` whose other fields `kui_button_with` ignores. A button that needs any other row is a box with `role=\"button\"` and the same rows spelled out.",
1848 },
1849 ElementDef {
1850 name: "edit",
1851 jsx_own: &["id", "initial", "multiline", "autofocus"],
1852 lua_own: &["initial", "multiline", "autofocus"],
1853 jsx_rows: None,
1854 lua_rows: None,
1855 jsx: "`<edit key initial multiline autofocus>`, `<input label initial>`",
1856 lua: "`edit { key=, initial=, … }`, `input { label=, initial= }`",
1857 c: "`kui_text_edit`, `kui_text_input`",
1858 doc: "Retained editor state by key; read it back with `editText(key)` after a `changed` event. `initial` seeds a new editor only — a key declared again keeps the draft the user typed, and `setEditText(name, text)` is what resets one (it leaves the caret at the end). Name it by the label its `key` prop declares — `setEditText(\'note\', text)` — or by the hex key an event carried. It reaches an editor that does not exist yet: the text is held for the frame that declares that name and seeds it there, over `initial`, so the `update` that opens a rename field can fill it in the same turn, which is what the label spelling is for — the hex key comes from an event an editor being opened has not fired. A name nothing declares on that frame drops its text with an `edit-text-without-editor` warning. A single-line editor is a field and a `multiline` one a document, which decides how each is laid out as well as how it reads: a field takes one line whatever its box, sizes to the text it holds when its width is `fit`, and scrolls that line under the caret when it is not, while a document wraps to its box. The one exception is a field with `wrap` declared (`wrap=\"word\"` or `\"glyph\"`): it folds to its width the way a document does and keeps a field\'s keyboard — Enter still submits, a newline is still never admitted, the caret still opens at the end — so a rename field breaks where the label it renames breaks, and with `width=\"fit\"` plus `maxWidth` it sizes to its wrapped draft on the keystroke frame. A single-line editor opens with the caret after its seeded text, as a native field does; a multiline one is a document and opens at its top — a held `setEditText` is the call, not a seed, so it opens at the end either way. State is kept while the key is declared; an undeclared one is kept until the budget needs the room (256 undeclared editors, longest-undeclared evicted first). `autofocus` asks once: the editor takes focus on the frame the flag starts being declared — a new editor, or one whose flag just turned on — and only while nothing holds focus, so a blur afterwards stands and a focused control is never robbed (`docs/adr/0022-focus-regions.md`, decision 9); `focus(key)` is the call for taking it at any other time.",
1859 },
1860 ElementDef {
1861 name: "select",
1862 jsx_own: &["label", "options", "current"],
1863 lua_own: &["label", "options", "current"],
1864 jsx_rows: Some(&[]),
1867 lua_rows: Some(&[]),
1868 jsx: "`<select label options={[…]} current>`",
1869 lua: "`dropdown { label=, options={…}, current= }`",
1870 c: "`kui_select`",
1871 doc: "The stock select (`widgets::select_items`, backlog F72): a field showing the choice in force that, clicked, opens the core's own menu of the options under it with the current one checked — the menu a right-click opens, drawn in the frame or the platform's where the host shows menus itself, dismissed by Escape or a press outside, its rows walked by the arrows and read as a menu. `label` is the key and the accessible name both; `options` is a list whose entries are strings (an option by its label, posting it) or menu-item objects `{ label, id, enabled }` (posting `id`), and a `{ role: \"separator\" }` is a separator; `current` is the index in force, counted from 0 in JSX and C and from 1 in Lua, or none — one past the options or on a separator is none, with a `select-current-ignored` warning on the field; an empty `options` is refused, and a key of an option object no row reads (`disabled`, where the key is `enabled`) is an `unknown-prop` warning. The app holds no open state: the choice arrives as the `menu` event a menu row posts, on the field's key — `{kind: \"menu\", role: \"custom\", item: <the option>}` — and drawing the field again with the new `current` is the whole loop. A reader hears a button named by the field, described by its choice, expanded while the menu is open. Its look is its spec, so it reads no other row: a layout, paint or access row on it is dropped with an `unknown-prop` warning. Lua spells it `dropdown`, since `select` is Lua's own.",
1872 },
1873 ElementDef {
1874 name: "checkbox",
1875 jsx_own: &[],
1876 lua_own: &["text"],
1877 jsx_rows: Some(TOGGLE_ROWS_JSX),
1878 lua_rows: Some(TOGGLE_ROWS_LUA),
1879 jsx: "`<checkbox checked mixed onClick key label description tooltip disabled>text</checkbox>`",
1880 lua: "`checkbox { label=, checked=, mixed=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
1881 c: "`kui_checkbox`",
1882 doc: "The stock checkbox (`widgets::toggle_with`, ADR 0034): a box drawn from the state the view declares — `checked`, or `mixed` for the select-all box over a list some of whose rows are selected, drawn as a dash and read as mixed — and its label beside it, keyed by its text (`key` overrides). The state is the app's: a press by the pointer, Space, Enter or assistive technology posts `onClick`, and the view flips its model and draws it again. Its look is its spec, so the layout and paint rows are closed and dropped with an `unknown-prop` warning; the rows it reads are its state and the access rows. In Lua `label` is the name and the text both unless `text` says otherwise. The box is the metrics' control text plus one (16 px comfortable), so `compact` and `scaled` move it with the stock button.",
1883 },
1884 ElementDef {
1885 name: "radio",
1886 jsx_own: &[],
1887 lua_own: &["text"],
1888 jsx_rows: Some(TOGGLE_ROWS_JSX),
1889 lua_rows: Some(TOGGLE_ROWS_LUA),
1890 jsx: "`<radio checked onClick key label description tooltip disabled>text</radio>`",
1891 lua: "`radio { label=, checked=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
1892 c: "`kui_radio`",
1893 doc: "The stock radio (`widgets::toggle_with`, ADR 0034): a circle drawn from `checked`, and its label, keyed by its text. Put radios in a `radioGroup`, which makes them one Tab stop whose arrows, Home and End move the choice and press the radio they land on (ADR 0007), so radios whose `onClick` each set the choice answer the keyboard with no more code. The state is the app's, as a checkbox's is; the rows are the checkbox's, `mixed` aside.",
1894 },
1895 ElementDef {
1896 name: "radioGroup",
1897 jsx_own: &[],
1898 lua_own: &[],
1899 jsx_rows: None,
1900 lua_rows: None,
1901 jsx: "`<radioGroup label>…radios…</radioGroup>`",
1902 lua: "`radio_group { label=, … }`",
1903 c: "`kui_radio_group_open` … `kui_close`",
1904 doc: "A container of radios (`widgets::radio_group_with`, ADR 0034): the `radioGroup` role, named by its `label`, laid out as a column with the stock gap — a `dir=\"row\"` lays the radios across, and its arrows run across with it. It reads every box row; the role and the name are its own whatever the rows say.",
1905 },
1906 ElementDef {
1907 name: "switch",
1908 jsx_own: &[],
1909 lua_own: &["text"],
1910 jsx_rows: Some(TOGGLE_ROWS_JSX),
1911 lua_rows: Some(TOGGLE_ROWS_LUA),
1912 jsx: "`<switch checked onClick key label description tooltip disabled>text</switch>`",
1913 lua: "`switch { label=, checked=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
1914 c: "`kui_switch`",
1915 doc: "The stock switch (`widgets::toggle_with`, ADR 0034): a track and a knob drawn from `checked`, the knob sliding across when it changes, and its label; read as a switch, on or off. The state is the app's, as a checkbox's is; the rows are the checkbox's, `mixed` aside.",
1916 },
1917 ElementDef {
1918 name: "slider",
1919 jsx_own: &[],
1920 lua_own: &[],
1921 jsx_rows: Some(SLIDER_ROWS_JSX),
1922 lua_rows: Some(SLIDER_ROWS_LUA),
1923 jsx: "`<slider label valueNow valueMin valueMax valueStep valueText onChange width description tooltip disabled/>`",
1924 lua: "`slider { label=, value_now=, value_min=, value_max=, value_step=, value_text=, on_change=, width=, … }`",
1925 c: "`kui_slider`",
1926 doc: "The stock slider (`widgets::slider_with`, ADR 0034): a track, a fill to `valueNow` and a thumb, as wide as a menu (`width` sizes it), keyed by its `label`, which is also its accessible name. With `onChange` the core does the arithmetic: a press proposes the value under the pointer, a drag each new step, the arrows one `valueStep`, PageUp / PageDown ten, Home / End the ends, all clamped to `valueMin`..`valueMax` (0..100 unset) and snapped to the step, as `{kind:\"change\", value, phase:\"move\"|\"end\", tag}`. The value is proposed, never applied: the view stores it and declares it as `valueNow`. Its look is its spec, so the rows it reads are the value rows, the access rows and its width.",
1927 },
1928 ElementDef {
1929 name: "image",
1930 jsx_own: &["src", "sampling", "fit"],
1931 lua_own: &["id", "sampling", "fit"],
1932 jsx_rows: None,
1933 lua_rows: None,
1934 jsx: "`<image src={id} sampling fit>`",
1935 lua: "`image { id=, sampling=, fit= }`",
1936 c: "`kui_image`, `kui_image_with`",
1937 doc: "A registered RGBA image. Sizing: `width=\"fit\"` takes the pixel size, a fit height against a resolved width keeps the aspect, `radius` rounds it. Two rows say how the pixels meet the box (`docs/adr/0025-the-image-is-the-canvas.md`): `sampling` is `linear` (the default) or `nearest` — pixel art, an emulator, a data grid that must stay square under zoom; `fit` is `fill` (the default: the pixels stretch to the box), `contain` (the largest rect of the image's aspect that fits, centred, the rest of the box showing what is behind) or `cover` (the box filled and the pixels that do not fit cropped, centred). The box — its layout, its hit region, its access rect — is the same in every mode. The pixels come from the atlas, or from a texture of the image's own once `updateImage` has replaced them or when no atlas page could hold them; the node cannot tell and need not.",
1938 },
1939 ElementDef {
1940 name: "polygon",
1941 jsx_own: &["points"],
1943 lua_own: &["points"],
1944 jsx_rows: None,
1945 lua_rows: None,
1946 jsx: "`<polygon points={[[x,y],…]} bg/>`",
1947 lua: "`polygon { points={{x,y},…}, bg= }`",
1948 c: "`kui_polygon`",
1949 doc: "A filled polygon through up to eight `points`, the fill in `bg` (`docs/adr/0025-the-image-is-the-canvas.md`, decision 6): an arrowhead, a pie slice, the area under a curve. Placed as a `line` is — always a float in its parent's box space (`float=\"viewport\"` for viewport space), sized to its own bounding box a pixel out on each side, so it takes no room in a row or column. A polygon in its parent's box space is held by the parent's clip as a child is, its hit region with it, so it is cut at a scroller's edge with the row it is drawn in; a declared float is held that way only when it declares `clip` with a parent anchor, and a `float=\"viewport\"` polygon escapes (backlog F78, `docs/adr/0010-a-segment-primitive.md` decision 5). `transition` eases the fill and, with `slide`, its position. The outline may be concave; a self-intersecting one fills even-odd, its overlaps unfilled. Hit by its outline (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press inside the outline hits it and one in its box past the outline falls through, so a pie's wedges need no hit boxes; with none it takes no input and has no access row, and with input it derives one as a box would (a clickable wedge is a button), so name it. A ninth point and later are dropped with `polygon-points-truncated`; fewer than three draw nothing; no `bg`, no fill. On the wire it is one `fragment` quad painted by a WGSL function the core registers itself, so a host that draws the list gets its source from `kui_fragment_source` like any other; what it costs is that quad and one pipeline switch per run of polygons. A stroked outline is a closed `line` over it.",
1950 },
1951 ElementDef {
1952 name: "fragment",
1953 jsx_own: &["src", "image", "params", "animate"],
1954 lua_own: &["id", "image", "params", "animate"],
1955 jsx_rows: None,
1956 lua_rows: None,
1957 jsx: "`<fragment src={id} image={id} params={[…]} animate>`",
1958 lua: "`fragment { id=, image=, params={…}, animate= }`",
1959 c: "`kui_fragment`, `kui_fragment_with`",
1960 doc: "A box a registered WGSL function paints (`docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md`): gradients, rings, noise, shimmer — anything the paint vocabulary has no prop for. An ordinary node otherwise — it lays out, rounds, clips, fades, takes input and holds children, which paint over it — but with **no intrinsic size**, so give it a `width`/`height` or `fill` or it is zero by zero. `src` is a handle from `add_fragment`, which validates the source and warns rather than minting one that cannot compile. `params` is up to sixteen numbers the shader reads as four `vec4<f32>`; more are dropped with a warning. `image` is a registered image the function reads — `kui_sample(uv)` (bilinear) and `kui_sample_nearest(uv)` return its texels at `uv` in `[0,1]²`, and `in.image` is its texel rect, `zw` the size — which is what makes a replaced image a waveform, a heatmap, a 50k-point line or an image effect from one quad (`docs/adr/0025-the-image-is-the-canvas.md`, decision 7); the core binds the atlas or the image's own texture, whichever holds it, and a fragment whose image is not live draws nothing, as one whose `src` is not does. `animate` asks for a frame every frame, which is what a fragment that reads `time` needs and what a still one must not declare.",
1961 },
1962 ElementDef {
1963 name: "cells",
1964 jsx_own: &[
1965 "rows",
1966 "cols",
1967 "cells",
1968 "cursorAt",
1969 "cursorShape",
1970 "cursorColor",
1971 "originLine",
1972 ],
1973 lua_own: &[
1974 "rows",
1975 "cols",
1976 "lines",
1977 "runs",
1978 "cursor_at",
1979 "cursor_shape",
1980 "cursor_color",
1981 "origin_line",
1982 ],
1983 jsx_rows: None,
1984 lua_rows: None,
1985 jsx: "`<cells rows cols cells={Uint32Array} cursorAt={[row, col]} cursorShape cursorColor size family lineHeight/>`",
1986 lua: "`cells { rows=, cols=, lines={\"row text\", …}, runs={{row, col, len, fg, bg, flags}, …}, cursor_at={row, col}, cursor_shape=, cursor_color=, size=, family= }`",
1987 c: "`kui_cells`",
1988 doc: "A terminal's screen as one node (backlog C20): `rows × cols` cells, each a character, a foreground and background as `0xRRGGBBAA` (0 = no background), and attribute bits — 1 bold, 2 italic, 4 underline, 8 strikethrough, 16 wide (the glyph spans this cell and the next, which the app leaves blank), 32 the underline is a wave (a terminal's undercurl, SGR 4:3) and 64 dotted (SGR 4:4), either implying it — plus, optionally, the underline's own colour (SGR 58), 0 for the foreground (backlog K4). A glyph is shaped once per character and style variant and thereafter placed at `col × cell_w` without shaping, so a screen whose every cell is new each frame costs what a still one costs (~60 µs for 200 × 50). The cell width is `M`'s advance in the style's font snapped to whole pixels, the height its `lineHeight`; a cell is a cell, so ligatures never form. Box drawing and block elements (U+2500–U+259F) and the Powerline separators (U+E0B0–U+E0BF: the arrows, and the Powerline Extra half circles and wedges) are not shaped at all but drawn from the cell box — a font's are its own line box tall, a cell is `lineHeight` tall, and through the font every `│` was a dash with a gap under it (backlog F66) and a rounded cap a fallback font's squiggle (F112) — so a TUI's frames and rounded rows are seamless in any font, and bold does not thicken a light line (the set has its heavy variants). JSX passes the cells as a `Uint32Array` (or number array) of four entries per cell — codepoint, fg, bg, flags — or five, with the underline colour, in row-major order; Lua a string per row in `lines` plus `runs` of `{row, col, len, fg, bg, flags, ul}` over them (a run's fg, bg or ul of 0 keeps the default: the style's colour, no background, the foreground); C a `KuiCell` array with `ul`. `cursorAt` (`cursor_at`) names a cell to paint under its glyph in `cursorColor` as a `block` (default), `bar` or `underline` — its own name, since `cursor` is the pointer shape. `originLine` (`origin_line`) is the absolute line number of row 0: a grid is one screenful of the app's own history, so a row number means a different line after every scroll, and stamping where the screen sits is what lets a selection keep its ends across one (`docs/adr/0017-selection-as-a-scope.md`). Saying nothing is 0, and a selection then holds only while the screen does not move. The node's own rows apply — an `onKey` makes it the terminal's sink, an `onClick` or `onDrag` carries `cell: {row, col}` on its events — and its access row is `terminal`, the rows joined as its value.",
1989 },
1990 ElementDef {
1991 name: "line",
1992 jsx_own: &["from", "to", "points", "curve"],
1995 lua_own: &["from", "to", "points", "curve"],
1996 jsx_rows: None,
1997 lua_rows: None,
1998 jsx: "`<line from={[x,y]} to={[x,y]} width color/>`, `<line points={[[x,y],…]} curve/>`",
1999 lua: "`line { from={x,y}, to={x,y}, width=, color= }`, `line { points={{x,y},…}, curve=true }`",
2000 c: "`kui_line`, `kui_polyline`",
2001 doc: "A round-capped stroke: one segment, a polyline through `points`, or a smooth curve through them with `curve`. Always a float in its parent's box space (`float=\"viewport\"` for viewport space), sized to its own bounding box, so it takes no room in a row or column. A stroke in its parent's box space is held by the parent's clip as a child is, its hit region with it, so it is cut at a scroller's edge with the row it is drawn in; a declared float is held that way only when it declares `clip` with a parent anchor, and a `float=\"viewport\"` stroke escapes (backlog F78, `docs/adr/0010-a-segment-primitive.md` decision 5). `width` is the stroke width (default 1) and `color` the stroke colour; `transition` eases the colour, and with `slide` beside it the stroke's position too — the points ride its box, so a stroke whose ends all move together slides with them, while one whose ends move apart resizes at once (a canvas of floats eases everything or nothing, connectors included). Hit by its shape (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press within half the width of any piece hits it — at least 4 px of grab, so a hairline is a target — and a press elsewhere in its bounding box falls through to what is under; with none it takes no input and has no access row, and with input it derives one as a box would (a clickable connector is a button), so name it. What it costs: one quad per segment, and a curve is flattened in the core at one piece per 6 logical px of chord (at most 32 per span) — fixed rather than tolerance-driven so every binding gets the same pieces and the corpus can pin them — so a nine-point curve over ~50 px spans is ~60 quads, and a `quadCount` budget should expect it.",
2002 },
2003 ElementDef {
2004 name: "titlebar",
2005 jsx_own: &[],
2006 lua_own: &["title"],
2009 jsx_rows: None,
2010 lua_rows: None,
2011 jsx: "`<titlebar title>` or `<titlebar>…</titlebar>`",
2012 lua: "`titlebar { title= }` / `titlebar { … }`",
2013 c: "`kui_titlebar`, `kui_titlebar_with`",
2014 doc: "Adaptive titlebar for custom chrome: drag strip, native-control inset, window buttons.",
2015 },
2016 ElementDef {
2017 name: "menuBar",
2018 jsx_own: &["menu"],
2019 lua_own: &["menu"],
2020 jsx_rows: None,
2021 lua_rows: None,
2022 jsx: "`<menuBar menu={[{ label, items: [{ label, id?, role?, accel?, enabled?, checked? }] }]}/>`",
2023 lua: "`menu_bar { menu = { { label=, items= { { label=, id=, role=, accel=, enabled=, checked= } } } } }`",
2024 c: "`kui_menu_bar`",
2025 doc: "The application menu (`docs/adr/0018-a-menu-bar-the-app-declares.md`): `menu` is what it *is*, and where this element sits is where its titles go when they have to be drawn in the window. One call and not two, because declaring the menu and placing the strip are one decision. It draws **nothing** where the platform owns the bar — macOS, where the driver hands the same declaration to the OS — so the frame has still said what the app's menu is and the strip simply is not there; that is the contract `windowButtons` has under native decorations, and it is what makes one view portable. Its rows are the rows a context menu has: the same `role`s the core performs itself (`copy`, `paste`, `selectAll`, `cut`, `lookUp`), the same `id` payload, the same `accel` text, plus `checked` for a setting — and choosing one posts the same `{kind:\"menu\", role, item}` event, so an app handles one thing whichever menu it came from. Declared every frame and diffed: an unchanged menu costs a comparison, and an empty list takes it away. An accelerator kui can parse is rewritten into the platform's spelling, so `\"mod+s\"` reads as `⌘S` on macOS and `Ctrl+S` elsewhere and binds that key in the platform's own bar. On macOS the first menu is the application menu, which the OS titles with the app's own name whatever the label says. While a menu is open the bar is the frame's modal scope, so hovering across the titles moves the open menu, a press on the open title closes it, and Escape or a press in the app below closes it and reaches nothing else.",
2026 },
2027 ElementDef {
2028 name: "windowButtons",
2029 jsx_own: &[],
2030 lua_own: &[],
2031 jsx_rows: None,
2032 lua_rows: None,
2033 jsx: "`<windowButtons/>`",
2034 lua: "`window_buttons()`",
2035 c: "`kui_window_buttons`",
2036 doc: "Just the min/max/close buttons, for fully custom titlebars.",
2037 },
2038 ElementDef {
2039 name: "tooltip",
2040 jsx_own: &["value"],
2041 lua_own: &["value"],
2042 jsx_rows: None,
2043 lua_rows: None,
2044 jsx: "`<tooltip value=\"hint\"/>` / `<tooltip>…</tooltip>` nodes, or the `tooltip=\"hint\"` prop (see composites)",
2045 lua: "`tooltip(\"hint\")` / `tooltip { … }` nodes, or the prop",
2046 c: "`kui_tooltip`, `kui_tooltip_with`",
2047 doc: "A float hanging below the parent; the node form always draws, the prop form is hover-gated.",
2048 },
2049 ElementDef {
2050 name: "latencyGraph",
2051 jsx_own: &["at"],
2052 lua_own: &["at"],
2053 jsx_rows: None,
2054 lua_rows: None,
2055 jsx: "`<latencyGraph/>`, `<latencyHud at/>`",
2056 lua: "`latency_graph()`, `latency_hud { at= }`",
2057 c: "`kui_latency_graph`, `kui_latency_hud`",
2058 doc: "Per-phase frame timing (windowed drivers fill it; headless shows the chrome empty).",
2059 },
2060 ElementDef {
2061 name: "audio",
2062 jsx_own: &["src", "loop", "volume", "paused", "finish", "tag"],
2063 lua_own: &["src", "loop", "volume", "paused", "finish", "tag"],
2064 jsx_rows: None,
2065 lua_rows: None,
2066 jsx: "`<audio src={id} loop volume paused finish tag/>`",
2067 lua: "`audio { src=, loop=, volume=, paused=, finish=, tag= }`",
2068 c: "`kui_audio`",
2069 doc: "A playback retained by node key: present = playing (once, or looped), gone = stopped; `volume` / `paused` apply live, a changed `src` restarts; a `tag` brings back `{kind:\"sound\", phase:\"ended\", tag}`. Draws nothing. `finish` changes what *gone* means: the node's removal releases the playback rather than stopping it, so a one-shot plays to its end and the view need not know the asset's length to declare the node for it (a loop still stops on removal — there is no end to reach — and a paused playback released has nothing to finish). Without it, the way to play a sound whole is to hold the node declared until the `tag`'s `ended` message arrives. A released playback is not free: it holds one of the device's 128 voices until its file ends, and the 129th play is refused — reported as a `playback-refused` warning and, for a `tag`, `phase: \"refused\"` rather than a wait that never returns. In the app's units, voices held = sound length × release rate: a 1.4 s chime released four times a second holds 6 of the 128 at any moment, a 10 s ambience released once a second holds 10, and every playback still declared (a loop included) counts beside them — a `refused` `sound` event is what arriving at 128 sounds like.",
2070 },
2071];
2072
2073pub struct EventDef {
2075 pub kind: &'static str,
2076 pub payload: &'static str,
2077 pub doc: &'static str,
2078}
2079
2080pub const EVENTS: &[EventDef] = &[
2081 EventDef {
2082 kind: "click",
2083 payload: "the `onClick` payload as-is",
2084 doc: "A press and release on the node (suppressed when a drag moved past the slop). A map payload gains fields where the node can say more: `cell: {row, col}` on a `cells` grid, and inside an `onKey` sink that draws `role=\"line\"` rows, `line`, `byte` and `clicks` as a `drag` inside one carries them.",
2085 },
2086 EventDef {
2087 kind: "drag",
2088 payload: "`{ kind: \"drag\", phase: \"start\" | \"move\" | \"end\", x, y, dx, dy, parent: { x, y, w, h }, tag }`",
2089 doc: "A pointer-captured drag on an `onDrag` node. `x`/`y` are where the pointer is; `dx`/`dy` are its displacement **from the press point**, in every phase — `start` carries zero, a `move` how far the pointer is from where it pressed, `end` the whole distance — so a handler sets `value = start + dx` rather than summing deltas, and can commit from `end` alone. Nothing is dropped under the click slop (3 px, measured from the press): the first `move` already carries the whole distance. `parent` is the container rect, so fractions need no geometry query. On a `cells` grid every phase also carries `cell: {row, col}`. Inside an `onKey` sink that draws `role=\"line\"` rows — an editor the app owns — every phase carries `line` (the ordinal among the sink's lines, the numbering its `access` events use), `byte` (where the point falls in that line's text, what `textHit` would answer) and `clicks` (the press's count), so click-to-caret, drag-select and double-click-word are arithmetic on the event with no query and no frame of lag; a point above the first line is the first, below the last the last, and one in a `role=\"none\"` gutter is the line beside it. Nothing is added where the sink draws no lines.",
2090 },
2091 EventDef {
2092 kind: "key",
2093 payload: "`{ kind: \"key\", phase: \"down\" | \"up\", code, physical, shift, ctrl, alt, super, text, repeat, location, caps_lock, num_lock, tag }`",
2094 doc: "A key press or release on the focused `onKey` sink; `code` is a character or a name (`\"left\"`, `\"f5\"`) and is what a keymap binds against. `physical` is the US-QWERTY key at that *position*, spelled the same way — bind it instead when you want the finger rather than the label (WASD stays a square on every layout). `code` follows the layout while the layout speaks ASCII, so a chord lands on the key the user can see (Dvorak's `⌥v` on the key printed V); on a layout that does not (Cyrillic, Greek, Hebrew, Arabic) the position's US key stands in, as Shift prints it (`J`, `:`; unshifted under Alt, as a chord reads it), so a Latin keymap keeps matching instead of matching nothing. A shifted letter arrives as the upper-case letter — `Z` with `shift` set for ⇧⌘Z, `physical` staying `z` — so a keymap that binds letters folds a one-character `code` to lower case under a chord; a headless press is spelled the same way, since no door re-spells it (`\"z\"` with `shift` is a chord no keyboard produces). `repeat` marks a press the OS auto-repeated; `text` is what the press would insert — always the layout's own character — and is null on every release. A key only comes up where it went down: a release whose press the sink never got is dropped, and focus leaving while a key is held delivers the `up` first, so a held-key binding (WASD, press-and-hold) cannot be left stuck down. `location` says which of a key's twins it was (backlog F108): `\"left\"` or `\"right\"` for a modifier, `\"numpad\"` for the keypad's digits, operators, Enter and (Num Lock off) arrows — `code` still `\"1\"`, `\"enter\"` — else `\"standard\"`; `caps_lock` and `num_lock` what the lock keys held. The keys F13–F35, `printscreen`, `pause`, `menu`, `clear` and the media keys (`mediaplaypause`, `volumeup`, …) are keys like any other; the modifier and lock keys themselves (`shift`, `ctrl`, `alt`, `super`, `capslock`, `numlock`, `scrolllock`) reach only a sink that says `modifierKeys`.",
2095 },
2096 EventDef {
2097 kind: "text",
2098 payload: "`{ kind: \"text\", text, pasted?: true, concealed?: true, transient?: true, tag }`",
2099 doc: "Text an IME committed at the end of a composition — or the clipboard's text, when the app asked for a paste with `requestPaste` / `request_paste` / `kui_request_paste` — on the focused `onKey` sink (or the nearest one above the focused control, or the root sink with nothing focused) — the one committed text the platform never reports as a key press carrying `text`, so a custom editor inserts it as it would a key's `text`. Plain typing does not arrive this way: the `key` event already carries what the press would insert, and a sink hearing both would type every character twice. A focused `<edit>` takes the commit itself and reports `changed`. A paste's answer carries `pasted: true` (backlog DX14) — an answer being the host's paste reply, or any commit while a paste is outstanding — so a sink that asked tells the clipboard's text from an IME's commit without keeping its own flag; an IME commit has no `pasted`. It also says what the pasteboard marked it (backlog F84): `concealed: true` for a secret — a password manager's copy, which a view should not show, log or keep — and `transient: true` for text not to keep in a history, after the nspasteboard.org convention (on Windows the clipboard's exclusion formats); a marker that is not set is absent, never false. The runner reads them on macOS and Windows; on Linux, and from a host that answers with a bare commit, a paste arrives unmarked.",
2100 },
2101 EventDef {
2102 kind: "preedit",
2103 payload: "`{ kind: \"preedit\", text, cursor: [start, end] | null, tag }`",
2104 doc: "An in-progress IME composition on the focused `onKey` sink: `text` is the uncommitted string to show inline at the caret, `cursor` the byte range inside it the IME's own caret covers (null when it does not say), and an empty `text` means the composition ended without a commit, so what was shown goes away. The OS candidate window is anchored for you: the `line` carrying `caret` says where. A focused `<edit>` draws the composition itself.",
2105 },
2106 EventDef {
2107 kind: "selectionrange",
2108 payload: "`{ kind: \"selectionrange\", from: { index, byte }, to: { index, byte } }` on the scope",
2109 doc: "A copy reached rows of a `selectable` virtual list that no frame built (`docs/adr/0017-selection-as-a-scope.md`, tier 3): the core cannot read text it never laid out, so it asks the app for the range — `index` is a row's data index (its `index` prop), `byte` an offset into that row's text, past the row's length for \"the whole row\" (a Select All over a list that declared `rowCount` ends that way, on its last row) — and the app answers with `answerSelectionRange(text)` / `answer_selection_range` / `kui_answer_selection_range`, which is what reaches the clipboard as a `setClipboard` action. Raised only while a copy is outstanding (`requestCopy()` answered `asked`, or the runner's Cmd/Ctrl-C did); a late answer changes nothing. `examples/rust/features/clipboard.rs` and its Node twin show the round trip.",
2110 },
2111 EventDef {
2112 kind: "contextmenu",
2113 payload: "`{ kind: \"contextmenu\", x, y, tag }`",
2114 doc: "A secondary-button press on an `onContextMenu` node, on the press rather than the release; `x`/`y` are logical viewport coordinates — where the menu goes. The core opens nothing: the app declares the menu (a `modal` float) and stops declaring it on `dismiss`.",
2115 },
2116 EventDef {
2117 kind: "menu",
2118 payload: "`{ kind: \"menu\", role, item }`",
2119 doc: "A row of the core's own context menu was chosen (`openMenu` / `open_menu` / `kui_open_menu`), on the node the menu was about. `item` is the row's `id`, or its label when it declared none; `role` is the row's standard role or `custom`. Every chosen row posts, the standard ones included: a `cut` or `paste` role is carried out by the core (its clipboard work queued for the host) *and* reported, so an app can hear its editor being cut from and is free to ignore it (`docs/adr/0017-selection-as-a-scope.md`, decision 5).",
2120 },
2121 EventDef {
2122 kind: "forceclick",
2123 payload: "`{ kind: \"forceclick\", x, y, tag }`",
2124 doc: "A press that deepened past the second stage of a Force Touch trackpad, on an `onForceClick` node, at the logical viewport point it happened at. Routed as a secondary press is — no focus moved, no caret placed, no click — but asked of the topmost node only, and the ordinary click the press is still producing arrives afterwards. Text needs none of this: over an `edit` or a `selectable` scope the core selects the word under it and asks the host for its Look Up panel instead. macOS-only in practice.",
2125 },
2126 EventDef {
2127 kind: "button",
2128 payload: "`{ kind: \"button\", phase: \"press\" | \"move\" | \"release\", button: \"secondary\" | \"middle\" | number, x, y, clicks, cell?: { row, col }, line?, byte?, tag }`",
2129 doc: "A non-primary button on an `onButton` node that claims it (`buttons`), backlog F105: `press` where it went down — with the driver's click count, `clicks`, which the native runner keeps for the primary button alone and so always reports as 1 here — then `move` for every pointer move while it is held and `release` where it came up, both on the same node wherever the pointer went, since the press captured the button. `button` is the button's name, or for one past the middle button its number (`3 + n`, as `kui_input_mouse_button` takes it; Node's `ctx.mouse` takes the three names only); `x`/`y` are logical viewport coordinates. On a `cells` grid each carries `cell: {row, col}`, clamped to the grid, and inside an `onKey` sink that draws `role=\"line\"` rows `line` and `byte` as a drag does. A claimed secondary press is this event instead of `contextmenu`; the press moves no focus, caret, selection or scrollbar.",
2130 },
2131 EventDef {
2132 kind: "scroll",
2133 payload: "`{ kind: \"scroll\", x, y, dx, dy, lines, tag }`",
2134 doc: "The wheel over an `onScroll` node, or a drag-select held past a `cells` grid's top or bottom edge: `dx`/`dy` the delta in logical px as the driver reported it (positive `dy` is the wheel rolling up, toward earlier content), `x`/`y` the pointer in logical viewport coordinates, `lines` the whole lines a `cells` grid's `dy` covers — positive is later history, the sign `originLine` grows in, the fraction carried to the next notch — and null on any other node. The core scrolls nothing for it: the app re-declares the grid's `originLine`, or zooms its canvas. From the edge drag it comes once a frame while the pointer is held past the edge, with the lines that frame's step covers, and the selection's absolute lines survive the scroll the app answers with (`docs/adr/0029-a-selection-follows-the-pointer-past-the-edge.md`).",
2135 },
2136 EventDef {
2137 kind: "focus",
2138 payload: "`{ kind: \"focus\", phase: \"in\" | \"out\", by: \"pointer\" | \"keyboard\" | \"assistive\" | \"program\", tag }`",
2139 doc: "Keyboard focus entered or left an `onFocus` node's subtree (backlog DX18). `by` is what moved it — a press, a key, a screen reader's request, or the view and the app — so a pane that follows a click into its sink tells that apart from a move the app made itself.",
2140 },
2141 EventDef {
2142 kind: "hover",
2143 payload: "`{ kind: \"hover\", phase: \"enter\" | \"leave\", by: \"pointer\" | \"content\", tag }`",
2144 doc: "The pointer entered or left an `onHover` node — also when a new frame moved it under a still cursor. `by` says which (backlog DX20): `pointer` when the pointer moved or left the window, `content` when it stayed and what is under it changed — a list scrolled by the wheel or the keys, a row that grew, a float that opened. A picker whose selection follows the pointer ignores `content`, or the rows sliding under a still pointer as the keys scroll the list drag the selection with them.",
2145 },
2146 EventDef {
2147 kind: "drop",
2148 payload: "`{ kind: \"drop\", phase: \"enter\" | \"move\" | \"leave\" | \"drop\", paths: string[], x, y, tag }`",
2149 doc: "Files dragged in from the OS over an `onDrop` node (`docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md`): `enter` when they come over the zone, `move` while they move over it (never twice for one point), `leave` when they go to another zone, to no zone or out of the window, `drop` when they land — and no `leave` after a `drop`. `paths` are the OS paths as strings; `x`/`y` the pointer in logical viewport coordinates, absent on `leave`. The zone is the topmost one under the pointer by paint order; a node inside it is its, and a node that is no zone is looked past (an overlay shown on `enter` does not end the hover). Nothing is re-resolved when a frame lands: only the driver's next report moves the files, so a zone the view stops declaring hears its `leave` then.",
2150 },
2151 EventDef {
2152 kind: "files",
2153 payload: "`{ kind: \"files\", paths: string[], tag }`",
2154 doc: "A file dialog's answer (backlog C51): what an Open, Save or folder dialog asked for with `requestFiles` / `request_files` / `kui_request_files` picked — the OS paths, as a `drop` carries them — or no paths when the user cancelled. `tag` is the dialog's own. It reaches whoever asked: the host, or the extension whose fill asked; one dialog is out at a time.",
2155 },
2156 EventDef {
2157 kind: "layout",
2158 payload: "`{ kind: \"layout\", x, y, w, h, parent: { x, y, w, h }, scale, tag }`",
2159 doc: "The rect layout gave an `onLayout` node (logical px, viewport coords, after scrolling and easing): on its first frame and whenever it changes, never on a frame that left it alone. `scale` is physical px per logical px at the node — `w × scale` by `h × scale` is how many pixels to render for it before `updateImage` (the frame's scale today; where a zoom would compose in).",
2160 },
2161 EventDef {
2162 kind: "resize",
2163 payload: "`{ kind: \"resize\", width, height, scale }`",
2164 doc: "The viewport changed size or DPI (logical px, delivered to the host on the root): the window less the devtools' dock while the panel is docked (`docs/adr/0024`), so a dock coming, going or being dragged is a resize too. `KuiWindow.size()` queries the same numbers, before the first frame as well (backlog F43). The first frame establishes the viewport rather than reporting it, so a dock present at launch posts none.",
2165 },
2166 EventDef {
2167 kind: "window",
2168 payload: "`{ kind: \"window\", phase: \"opened\" | \"closed\" | \"focused\" | \"blurred\", name, id }`",
2169 doc: "A declared window opened (the diff queued its `Open`) or closed — because nothing declares it any more, or because the user closed it, in which case it stays closed while still declared: stop declaring `name`, then declare it again to reopen. `id` is what its events carry; the event itself is on the root of whichever window's frame noticed. `focused` and `blurred` are this window gaining and losing the keyboard (backlog DX18) — `env.focused` changing, as the driver reports it — so an app that saves on blur or re-reads the clipboard on return hears it once instead of diffing `env.focused` every frame.",
2170 },
2171 EventDef {
2172 kind: "system",
2173 payload: "`{ kind: \"system\", appearance, accent, motion, locale, assistive }`",
2174 doc: "An OS setting the user changed while the app was open — the light/dark appearance, the accent colour, reduced motion, or the UI language — or assistive technology starting to listen (delivered to the host on the root, one per window that noticed). The payload is `env.system` as it now reads, in the same spellings and with the same nulls, so a handler can keep the whole reading or take the one field it branches on. The first frame establishes the reading rather than reporting it, the way the viewport does; a host whose view is a function the runner calls every frame can equally re-read `env` and ignore this, but a host that retains the tree it was handed (Node, C, Lua) only re-runs its view for a message, so this is how a palette follows the OS.",
2175 },
2176 EventDef {
2177 kind: "fonts",
2178 payload: "`{ kind: \"fonts\" }`",
2179 doc: "The system's installed fonts changed: a rescan found a face installed or removed since the last one (delivered to the host on the root, one per window that noticed). The winit runner rescans itself when macOS or Windows says the set changed; elsewhere it is the app calling `reloadSystemFonts`. Re-read `systemFonts()` / `systemFontFamilies()` here if the model holds them; a view naming a family by `family` needs nothing, it resolves on the same frame. A font the app loads itself raises none, and the first frame establishes the set rather than reporting it.",
2180 },
2181 EventDef {
2182 kind: "modifiers",
2183 payload: "`{ kind: \"modifiers\", shift, ctrl, alt, super }`",
2184 doc: "The physical modifier state changed (delivered to the host on the root).",
2185 },
2186 EventDef {
2187 kind: "changed",
2188 payload: "`{ kind: \"changed\" }`, with the editor's key on the event",
2189 doc: "An editor's text changed.",
2190 },
2191 EventDef {
2192 kind: "submit",
2193 payload: "`{ kind: \"submit\" }`, with the editor's key on the event",
2194 doc: "Enter in a single-line editor.",
2195 },
2196 EventDef {
2197 kind: "sound",
2198 payload: "`{ kind: \"sound\", phase: \"ended\" | \"refused\", playback, tag }`",
2199 doc: "A tagged playback (`play(id, { tag })` or `<audio tag>`) finished on its own — never when something stopped it. `phase: \"refused\"` instead when the device would not take the play at all (its 128 voices are all held, or the sound did not decode): that playback never starts and so never ends, so this is what arrives in place of the `ended` a view would otherwise wait forever for.",
2200 },
2201 EventDef {
2202 kind: "dismiss",
2203 payload: "`{ kind: \"dismiss\", reason: \"escape\" | \"outside\", tag }` on a node; `{ kind: \"dismiss\", reason, name, id }` on the root for a popup window",
2204 doc: "The user asked for a surface to go away — Escape, or a press that landed outside it. The core closes nothing: the app stops declaring the surface (or asks first). A `modal` node gets one on the node, carrying its tag, and only the modal in effect does; a `kind: \"popup\"` window gets one on the root, carrying the window's `name` and `id`, reported by the driver because a press outside a window and a key sent to a non-activating one are both facts only the OS has. The two are the same event, so a dropdown that graduates from a modal float to a popup window changes its declaration and not its handler.",
2205 },
2206 EventDef {
2207 kind: "access",
2208 payload: "`{ kind: \"access\", action, tag, text?, value?, anchor?: { line, offset }, focus?: { line, offset } }`",
2209 doc: "Assistive technology — or the keyboard — asked for what only the app can do: `increment` / `decrement` on a `slider` role that declared no `onChange` (a reader's nudge, or the arrow keys on the focused slider), and `setValue` there with the number asked for as `value` (Windows' UI Automation sets a slider rather than nudging it); `setValue` / `replaceSelectedText` (with `text`) / `setTextSelection` (with `anchor` and `focus` as line ordinals and byte offsets) on a custom editor. `tag` is the node's `onClick` payload (or its `onDrag` / `onKey` tag). Every other request resolves in the core and arrives as the events a pointer would have produced.",
2210 },
2211 EventDef {
2212 kind: "change",
2213 payload: "`{ kind: \"change\", value, phase: \"move\" | \"end\", tag }`",
2214 doc: "A `slider` that declared `onChange` (the stock `<slider>`, ADR 0034): the core turned a press into the value under the pointer, a drag into each new step, an arrow or assistive technology's increment / decrement into one `valueStep` and its set value into that value snapped, PageUp / PageDown into ten, Home / End into the ends — clamped to `valueMin`..`valueMax` and snapped to the step, the decimal the step names. `phase` is `move` while the pointer holds the slider and `end` when it lets go or a key moved it; a key that lands where the slider is proposes nothing. The value is proposed: declare it as `valueNow`. `tag` is the `onChange` payload.",
2215 },
2216];
2217
2218pub struct ResourceDef {
2220 pub what: &'static str,
2221 pub node: &'static str,
2222 pub lua: &'static str,
2223 pub c: &'static str,
2224}
2225
2226pub const RESOURCES: &[ResourceDef] = &[
2227 ResourceDef {
2228 what: "image",
2229 node: "`ctx.addImage(w, h, rgba)` → id for `<image src>`",
2230 lua: "the host registers; `image { id }`",
2231 c: "`kui_image_add` → `kui_image`",
2232 },
2233 ResourceDef {
2234 what: "fragment (WGSL)",
2235 node: "`ctx.addFragment(src)` → id for `<fragment src>`",
2236 lua: "the host registers; `fragment { id }`",
2237 c: "`kui_fragment_add` → `kui_fragment`",
2238 },
2239 ResourceDef {
2240 what: "font from bytes",
2241 node: "`ctx.addFont(buffer)` → id for `font`",
2242 lua: "the host registers; `font = id`",
2243 c: "`kui_font_add` → `KuiTextStyle.font`",
2244 },
2245 ResourceDef {
2246 what: "font file by path",
2247 node: "`ctx.loadFontFile(\"fonts/Antonio.ttf\")` → id for `font`",
2248 lua: "the host registers; `font = id`",
2249 c: "`kui_font_load_file`",
2250 },
2251 ResourceDef {
2252 what: "a folder of fonts",
2253 node: "`ctx.loadFontsDir(\"fonts\")`, then pick by name",
2254 lua: "the host loads",
2255 c: "`kui_font_load_dir`",
2256 },
2257 ResourceDef {
2258 what: "font by family name (installed or loaded)",
2259 node: "`ctx.addSystemFont(\"Antonio\")` (see `systemFontFamilies()`)",
2260 lua: "the host registers; `font = id`",
2261 c: "`kui_font_add_system` (see `kui_font_families`)",
2262 },
2263 ResourceDef {
2264 what: "sound (wav / ogg / mp3 / flac bytes)",
2265 node: "`ctx.addSound(buffer)` → id for `<audio src>`, `clickSound`, `play(id)`",
2266 lua: "the host registers; `audio { src = id }`, `click_sound = id`",
2267 c: "`kui_sound_add` → `kui_audio`, `KuiSpec.click_sound`, `kui_play`",
2268 },
2269];
2270
2271pub struct EnvField {
2291 pub name: &'static str,
2293 pub from: &'static str,
2296 pub node: &'static [&'static str],
2300 pub lua: &'static [&'static str],
2303 pub c: &'static str,
2307 pub doc: &'static str,
2308 pub get: fn(&EnvFacts) -> Value,
2316}
2317
2318#[derive(Clone, Copy, Debug)]
2321pub struct EnvFacts {
2322 pub env: crate::env::Env,
2323 pub viewport: crate::geom::Size,
2324 pub scale: f32,
2325 pub focus: Option<crate::key::Key>,
2326 pub focus_visible: bool,
2327 pub region: Option<crate::key::Key>,
2328 pub caret_visible: bool,
2329}
2330
2331fn key_value(k: Option<crate::key::Key>) -> Value {
2332 k.map_or(Value::Null, |k| Value::Int(k.0 as i64))
2333}
2334
2335fn rect_value(r: crate::geom::Rect) -> Value {
2336 Value::map([
2337 ("x", Value::Float(r.x as f64)),
2338 ("y", Value::Float(r.y as f64)),
2339 ("w", Value::Float(r.w as f64)),
2340 ("h", Value::Float(r.h as f64)),
2341 ])
2342}
2343
2344pub const ENV_FIELDS: &[EnvField] = &[
2345 EnvField {
2346 name: "refresh_hz",
2347 get: |f| {
2348 f.env
2349 .refresh_hz
2350 .map_or(Value::Null, |hz| Value::Float(hz as f64))
2351 },
2352 from: "`Env::refresh_hz`",
2353 node: &["refreshHz"],
2354 lua: &["refresh_hz"],
2355 c: "`kui_env_set(refresh_hz)`",
2356 doc: "Display refresh rate in Hz. \"The host cannot tell\" is `null` in Node (a stable shape to destructure, typed `number | null`), an absent key in Lua, and a rate at or below zero in C.",
2357 },
2358 EnvField {
2359 name: "frame_budget_ms",
2360 get: |f| Value::Float(f.env.frame_budget_ms() as f64),
2361 from: "`Env::frame_budget_ms()`, derived",
2362 node: &["frameBudgetMs"],
2363 lua: &["frame_budget_ms"],
2364 c: "—",
2365 doc: "One vsync interval at `refresh_hz`, or at 120 Hz when the host cannot tell: the per-frame time budget, and what the latency HUD draws its line at. Derived, and in the reading anyway, so no view restates the fallback. A C host is the one that knows the rate and computes its own.",
2366 },
2367 EnvField {
2368 name: "focused",
2369 get: |f| Value::Bool(f.env.focused),
2370 from: "`Env::focused`",
2371 node: &["focused"],
2372 lua: &["focused"],
2373 c: "`kui_env_set(focused)`",
2374 doc: "Whether the *window* has the keyboard at all. Not the focused node — that is the `focus` row.",
2375 },
2376 EnvField {
2377 name: "system.appearance",
2378 get: |f| Value::str(f.env.system.appearance.name()),
2379 from: "`SystemEnv::appearance`",
2380 node: &["system.appearance"],
2381 lua: &["system.appearance"],
2382 c: "`kui_env_set_system(appearance)`",
2383 doc: "The OS light/dark setting: `\"light\"`, `\"dark\"`, or `\"unknown\"` when the host has no way to ask (`KUI_APPEARANCE_*` in C, where unknown is 0). Unknown is a real answer and the default — a view picks its own palette for it rather than being handed a guess. The core acts on it in one way: the theme is derived from it (ADR 0019), so the stock widgets and a `<text>` with no colour follow the setting — and nothing of the app's own repaints, because only the view knows which of its colours is the background.",
2384 },
2385 EnvField {
2386 name: "system.accent",
2387 get: |f| {
2388 f.env
2389 .system
2390 .accent
2391 .map_or(Value::Null, |c| Value::Int(c.to_hex() as i64))
2392 },
2393 from: "`SystemEnv::accent`",
2394 node: &["system.accent"],
2395 lua: &["system.accent"],
2396 c: "`kui_env_set_system(accent)`",
2397 doc: "The OS accent/highlight colour as `0xRRGGBBAA`, ready to pass straight back as a `bg` or `color`. \"The host cannot tell\" is `null` in Node, an absent key in Lua, and 0 in C — a fully transparent accent is not a colour anyone was given, the way a refresh rate of zero is not a rate. Node's `setEnv` also takes the `\"#rrggbb\"` spelling a prop takes.",
2398 },
2399 EnvField {
2400 name: "system.motion",
2401 get: |f| Value::str(f.env.system.motion.name()),
2402 from: "`SystemEnv::motion`",
2403 node: &["system.motion"],
2404 lua: &["system.motion"],
2405 c: "`kui_env_set_system(motion)`",
2406 doc: "The OS reduce-motion setting: `\"reduced\"` when the user asked for less animation, `\"full\"` when they did not, `\"unknown\"` when nobody asked the OS (`KUI_MOTION_*` in C, unknown 0). Spelled as what the user wants rather than as a `reduceMotion` boolean, because the third reading has no place in a boolean. Nothing in the core shortens an animation for it — a view that honours it does so where it declares one.",
2407 },
2408 EnvField {
2409 name: "system.locale",
2410 get: |f| {
2411 f.env
2412 .system
2413 .locale
2414 .map_or(Value::Null, |l| Value::str(l.as_str()))
2415 },
2416 from: "`SystemEnv::locale`",
2417 node: &["system.locale"],
2418 lua: &["system.locale"],
2419 c: "`kui_env_set_system(locale)`",
2420 doc: "The UI language as a BCP-47 tag (`\"en\"`, `\"en-US\"`, `\"zh-Hant-HK\"`), for whatever the view formats dates and numbers with; kui does not parse it. Carried inline (31 ASCII bytes, `Locale`) so the reading stays `Copy`, and anything that does not fit reads back as unknown: `null` in Node, an absent key in Lua, an empty `KuiStr` in C.",
2421 },
2422 EnvField {
2423 name: "system.assistive",
2424 get: |f| Value::str(f.env.system.assistive.name()),
2425 from: "`SystemEnv::assistive`",
2426 node: &["system.assistive"],
2427 lua: &["system.assistive"],
2428 c: "`kui_env_set_assistive(assistive)`",
2429 doc: "Whether assistive technology is listening: `\"listening\"` once an accessibility client has asked this window for its tree, `\"none\"` while the bridge is up and nobody has, `\"unknown\"` where there is no bridge — a headless `Ctx`, a runner built without `accesskit`, a C host that never called the setter (`KUI_ASSISTIVE_*`, unknown 0). The reading that changes what a view *says* rather than what it draws: an alert that announces when something is listening and blinks when nothing is. Reported through the `system` event when it changes, like the other four. Two limits are the platform's, not kui's. *Any* client counts — a probe, an accessibility inspector, a test driving the AX API and VoiceOver alike all ask for the tree, and nothing tells them apart — so it says something is listening, not that a person is. And it falls back to `\"none\"` only where the adapter reports deactivation, which in the pinned AccessKit is AT-SPI alone (the session's accessibility bus going away); on macOS and Windows nothing reports a client leaving, so once it has risen it stays `\"listening\"` for the window's life.",
2430 },
2431 EnvField {
2432 name: "window.id",
2433 get: |f| Value::Int(f.env.window.id.0 as i64),
2434 from: "`WindowEnv::id`",
2435 node: &["window.id"],
2436 lua: &["window.id"],
2437 c: "`kui_env_set_window(window)`, read back by `kui_ctx_window`",
2438 doc: "Which window this frame draws, assigned by the driver: 0 for the window the app starts in. Every event from it carries the same number.",
2439 },
2440 EnvField {
2441 name: "window.custom_chrome",
2442 get: |f| Value::Bool(f.env.window.custom_chrome),
2443 from: "`WindowEnv::custom_chrome`",
2444 node: &["window.customChrome"],
2445 lua: &["window.custom_chrome"],
2446 c: "`kui_env_set_window(custom_chrome)`",
2447 doc: "The host asked the app to draw its own chrome, so there is no native titlebar to sit under. `<titlebar>` and `<windowButtons>` build nothing when this is false.",
2448 },
2449 EnvField {
2450 name: "window.maximized",
2451 get: |f| Value::Bool(f.env.window.maximized),
2452 from: "`WindowEnv::maximized`",
2453 node: &["window.maximized"],
2454 lua: &["window.maximized"],
2455 c: "`kui_env_set_window(maximized)`",
2456 doc: "The window is maximized — what picks the restore glyph over the maximize one.",
2457 },
2458 EnvField {
2459 name: "window.fullscreen",
2460 get: |f| Value::Bool(f.env.window.fullscreen),
2461 from: "`WindowEnv::fullscreen`",
2462 node: &["window.fullscreen"],
2463 lua: &["window.fullscreen"],
2464 c: "`kui_env_set_window(fullscreen)`",
2465 doc: "The window is fullscreen.",
2466 },
2467 EnvField {
2468 name: "window.always_on_top",
2469 get: |f| Value::Bool(f.env.window.always_on_top),
2470 from: "`WindowEnv::always_on_top`",
2471 node: &["window.alwaysOnTop"],
2472 lua: &["window.always_on_top"],
2473 c: "`kui_env_set_always_on_top(always_on_top)`",
2474 doc: "The window is above every other app's: the level the driver set after the frame asked for it (`alwaysOnTop` / `always_on_top` / `kui_set_always_on_top`, backlog C30), on a platform that has one. On Wayland winit has no call for it, so a driver there reports false however often the app asks — which is why a pin button draws its state from this and not from the app's own flag. It is the driver's record of what it set and not a query (winit has no level getter), so a level the OS dropped afterwards — a fullscreen space, a tiling manager — is not seen here. A C host reports it through its own setter rather than an argument on `kui_env_set_window`, the way `kui_env_set_assistive` is, so an older host that never applies a level has nothing to recompile.",
2475 },
2476 EnvField {
2477 name: "window.native_controls",
2478 get: |f| f.env.window.native_controls.map_or(Value::Null, rect_value),
2479 from: "`WindowEnv::native_controls`",
2480 node: &["window.nativeControls"],
2481 lua: &["window.controls_w", "window.controls_h"],
2482 c: "`kui_env_set_window(controls_w, controls_h)`",
2483 doc: "Area (logical px, window coordinates) covered by controls the OS still draws over our content — the macOS traffic lights under custom chrome. Keep out of it. Node hands back the `Rect` the core holds (`{x, y, w, h}`, or `null` for none); Lua and C flatten it to a width and height anchored at the window origin (absent in Lua, `0` in C, for none), which is the shape C's two numbers can express and where the one real instance sits.",
2484 },
2485 EnvField {
2486 name: "audio.device",
2487 get: |f| Value::str(f.env.audio.device.name()),
2488 from: "`AudioEnv::device`",
2489 node: &["audio.device"],
2490 lua: &["audio.device"],
2491 c: "`kui_env_set_audio(device)`",
2492 doc: "What the driver's output device is doing: `\"closed\"` (the default, and a headless driver's answer), `\"opening\"` (the ~90 ms open, on its own thread), `\"open\"`, or `\"failed\"` (it refused, and commands are dropped) — `KUI_AUDIO_DEVICE_*` in C, closed 0. A fact and not a verb: nothing lets a view close it, the driver does that itself once it has been idle a while. Worth reading because an open stream is a real-time thread whether or not anything plays, which is the whole of an idle app's CPU once a session has held a sound.",
2493 },
2494 EnvField {
2495 name: "audio.live",
2496 get: |f| Value::Int(f.env.audio.live as i64),
2497 from: "`AudioEnv::live`",
2498 node: &["audio.live"],
2499 lua: &["audio.live"],
2500 c: "`kui_env_set_audio(live)`",
2501 doc: "Playbacks started and not yet ended, plus any waiting on the device to open. Zero with the device still `\"open\"` is the idle stream the row above is about. A play that arrives while the device is `\"opening\"` counts here from the frame it was asked, until the open answers: if the device refuses, the play is refused on the next apply — `{kind:\"sound\", phase:\"refused\"}` for a tagged one — and leaves the count with it, so what a machine with no output device shows is `opening`/1 then `failed`/0 with the refusal between (backlog F63).",
2502 },
2503 EnvField {
2504 name: "viewport.w",
2505 get: |f| Value::Float(f.viewport.w as f64),
2506 from: "`Core::viewport()`, the frame's",
2507 node: &["viewport.width"],
2508 lua: &["viewport_w"],
2509 c: "`kui_frame_begin(w)`",
2510 doc: "The logical width of the current (or last) frame's viewport — the window less the devtools' dock while the panel is docked (`docs/adr/0024`), the same number a `resize` reports and Node's `KuiWindow.size()` answers — the other host fact a view wants at the same moment, so it rides in the same reading. Zero before the first frame, since the frame establishes it (backlog F43: this row once read the window instead, so an app under `KUI_DEVTOOLS` sized itself to a viewport it did not have). Node's `viewport` is the `WindowSize` shape `runWindowed` already uses.",
2511 },
2512 EnvField {
2513 name: "viewport.h",
2514 get: |f| Value::Float(f.viewport.h as f64),
2515 from: "`Core::viewport()`, the frame's",
2516 node: &["viewport.height"],
2517 lua: &["viewport_h"],
2518 c: "`kui_frame_begin(h)`",
2519 doc: "Its logical height.",
2520 },
2521 EnvField {
2522 name: "scale",
2523 get: |f| Value::Float(f.scale as f64),
2524 from: "`Core::scale()`, the frame's",
2525 node: &["viewport.scale"],
2526 lua: &[],
2527 c: "`kui_frame_begin(scale)`",
2528 doc: "Device pixels per logical px. Lua has no reading: a script sees logical px only.",
2529 },
2530 EnvField {
2531 name: "focus",
2532 get: |f| key_value(f.focus),
2533 from: "`Core::focus()`, the frame's",
2534 node: &[],
2535 lua: &["focus"],
2536 c: "`kui_focused()`",
2537 doc: "The focused *node*'s key, as events carry it (absent for none). A value the host wrote before the view ran, so it lags a same-frame verb by one frame; `env.is_focused(key)` is the live query. Node spells it as the call `focused()` on the context, and C as `kui_focused`, rather than a key on `env`.",
2538 },
2539 EnvField {
2540 name: "focus_visible",
2541 get: |f| Value::Bool(f.focus_visible),
2542 from: "`Core::focus_visible()`, the frame's",
2543 node: &[],
2544 lua: &["focus_visible"],
2545 c: "`kui_focus_visible()`",
2546 doc: "Whether focus shows — the keyboard or assistive technology put it where it is, or acted on it there; a click alone does not. Node: `focusVisible()` on the context.",
2547 },
2548 EnvField {
2549 name: "caret_visible",
2550 get: |f| Value::Bool(f.caret_visible),
2551 from: "`Core::caret_visible()`, the frame's",
2552 node: &[],
2553 lua: &["caret_visible"],
2554 c: "`kui_caret_visible()`",
2555 doc: "The caret's blink phase — `true` draws it (backlog C35). The driver's clock sets it while there is a caret to blink: a focused `edit`'s, or the `caret` a `line` under a focused `onKey` sink declares; a custom editor draws its caret node on the on phase and skips it on the off, keeping the `caret` row on its `line` either way, so it blinks in step with the stock editor and, in a window without the keyboard, not at all. Always `true` headless. Node: `caretVisible()` on the context (`setCaretVisible` is the driver's half, for a test that drives the phase).",
2556 },
2557 EnvField {
2558 name: "region",
2559 get: |f| key_value(f.region),
2560 from: "`Core::region()`, the frame's",
2561 node: &[],
2562 lua: &["region"],
2563 c: "`kui_region()`",
2564 doc: "The focus region in effect — the key of the `focusRegion` node whose ring Tab walks (absent for the main ring; `docs/adr/0022-focus-regions.md`). What a chord that toggles between a dock and the app reads to know which way it is going. Node spells it as the call `region()` on the context, and C as `kui_region`.",
2565 },
2566];
2567
2568pub struct ThemeRole {
2579 pub name: &'static str,
2581 pub node: &'static str,
2583 pub doc: &'static str,
2584 pub get: fn(&crate::theme::Theme) -> crate::color::Color,
2586 pub set: fn(&mut crate::theme::Theme, crate::color::Color),
2589}
2590
2591pub const THEME_ROLES: &[ThemeRole] = &[
2592 ThemeRole {
2593 name: "bg",
2594 node: "bg",
2595 get: |t| t.bg,
2596 set: |t, c| t.bg = c,
2597 doc: "The window behind everything.",
2598 },
2599 ThemeRole {
2600 name: "surface",
2601 node: "surface",
2602 get: |t| t.surface,
2603 set: |t, c| t.surface = c,
2604 doc: "A card, panel or list sitting on `bg`.",
2605 },
2606 ThemeRole {
2607 name: "raised",
2608 node: "raised",
2609 get: |t| t.raised,
2610 set: |t, c| t.raised = c,
2611 doc: "A surface floating above content: a menu, a tooltip, a popover. Under a light theme it is no lighter than `surface` — a float on a white page separates by its border.",
2612 },
2613 ThemeRole {
2614 name: "sunken",
2615 node: "sunken",
2616 get: |t| t.sunken,
2617 set: |t, c| t.sunken = c,
2618 doc: "A well cut into a surface: a text field, a code block, a track.",
2619 },
2620 ThemeRole {
2621 name: "border",
2622 node: "border",
2623 get: |t| t.border,
2624 set: |t, c| t.border = c,
2625 doc: "The hairline between two surfaces.",
2626 },
2627 ThemeRole {
2628 name: "border_strong",
2629 node: "borderStrong",
2630 get: |t| t.border_strong,
2631 set: |t, c| t.border_strong = c,
2632 doc: "A border that has to be seen — a float's edge, a focused field.",
2633 },
2634 ThemeRole {
2635 name: "fg",
2636 node: "fg",
2637 get: |t| t.fg,
2638 set: |t, c| t.fg = c,
2639 doc: "Body text, and what a `color`-less text run resolves to.",
2640 },
2641 ThemeRole {
2642 name: "muted",
2643 node: "muted",
2644 get: |t| t.muted,
2645 set: |t, c| t.muted = c,
2646 doc: "Secondary text: captions, hints, an accelerator beside a label.",
2647 },
2648 ThemeRole {
2649 name: "faint",
2650 node: "faint",
2651 get: |t| t.faint,
2652 set: |t, c| t.faint = c,
2653 doc: "Text that is barely there: a placeholder, a gutter number.",
2654 },
2655 ThemeRole {
2656 name: "accent",
2657 node: "accent",
2658 get: |t| t.accent,
2659 set: |t, c| t.accent = c,
2660 doc: "The one saturated colour: the OS accent where the host reports one, the app's where it pinned one, kui's blue otherwise. The `accent` prop paints from this.",
2661 },
2662 ThemeRole {
2663 name: "accent_hover",
2664 node: "accentHover",
2665 get: |t| t.accent_hover,
2666 set: |t, c| t.accent_hover = c,
2667 doc: "`accent` under a pointer.",
2668 },
2669 ThemeRole {
2670 name: "accent_pressed",
2671 node: "accentPressed",
2672 get: |t| t.accent_pressed,
2673 set: |t, c| t.accent_pressed = c,
2674 doc: "`accent` under a press.",
2675 },
2676 ThemeRole {
2677 name: "on_accent",
2678 node: "onAccent",
2679 get: |t| t.on_accent,
2680 set: |t, c| t.on_accent = c,
2681 doc: "Black or white — whichever a reader can see on `accent`. What a button's label is.",
2682 },
2683 ThemeRole {
2684 name: "accent_soft",
2685 node: "accentSoft",
2686 get: |t| t.accent_soft,
2687 set: |t, c| t.accent_soft = c,
2688 doc: "The accent as a translucent wash rather than a fill: a selected menu row, a chosen tab, a highlighted list item. Keeps `fg` readable over it on both bases, which a fill does not.",
2689 },
2690 ThemeRole {
2691 name: "selection",
2692 node: "selection",
2693 get: |t| t.selection,
2694 set: |t, c| t.selection = c,
2695 doc: "What a text selection is painted under, in an editor and over a `selectable` scope alike.",
2696 },
2697 ThemeRole {
2698 name: "focus_ring",
2699 node: "focusRing",
2700 get: |t| t.focus_ring,
2701 set: |t, c| t.focus_ring = c,
2702 doc: "The default keyboard focus ring (ADR 0002).",
2703 },
2704 ThemeRole {
2705 name: "hover",
2706 node: "hover",
2707 get: |t| t.hover,
2708 set: |t, c| t.hover = c,
2709 doc: "A translucent wash over a hovered neutral control. An overlay, not a fill, so one value works on every surface.",
2710 },
2711 ThemeRole {
2712 name: "pressed",
2713 node: "pressed",
2714 get: |t| t.pressed,
2715 set: |t, c| t.pressed = c,
2716 doc: "The same over a pressed one, and the firmer of the two on both bases.",
2717 },
2718 ThemeRole {
2719 name: "success",
2720 node: "success",
2721 get: |t| t.success,
2722 set: |t, c| t.success = c,
2723 doc: "A good outcome. Readable on `surface` on both bases, which is why it is not one colour for both.",
2724 },
2725 ThemeRole {
2726 name: "warning",
2727 node: "warning",
2728 get: |t| t.warning,
2729 set: |t, c| t.warning = c,
2730 doc: "Something that wants attention.",
2731 },
2732 ThemeRole {
2733 name: "danger",
2734 node: "danger",
2735 get: |t| t.danger,
2736 set: |t, c| t.danger = c,
2737 doc: "A destructive action or a failure. The close button's hover, too.",
2738 },
2739 ThemeRole {
2740 name: "scrollbar",
2741 node: "scrollbar",
2742 get: |t| t.scrollbar,
2743 set: |t, c| t.scrollbar = c,
2744 doc: "The scrollbar thumb at rest.",
2745 },
2746 ThemeRole {
2747 name: "scrollbar_active",
2748 node: "scrollbarActive",
2749 get: |t| t.scrollbar_active,
2750 set: |t, c| t.scrollbar_active = c,
2751 doc: "The thumb while hovered or dragged.",
2752 },
2753];
2754
2755pub struct MetricRole {
2761 pub name: &'static str,
2763 pub node: &'static str,
2765 pub doc: &'static str,
2766 pub get: fn(&crate::metrics::Metrics) -> f32,
2767 pub set: fn(&mut crate::metrics::Metrics, f32),
2768 pub platform: Option<PlatformValue>,
2775}
2776
2777#[derive(Clone, Copy, Debug, PartialEq)]
2779pub struct PlatformValue {
2780 pub windows: f32,
2781 pub elsewhere: f32,
2782}
2783
2784impl PlatformValue {
2785 pub const fn here(self) -> f32 {
2787 if cfg!(target_os = "windows") {
2788 self.windows
2789 } else {
2790 self.elsewhere
2791 }
2792 }
2793}
2794
2795macro_rules! metric_role {
2796 ($field:ident, $node:literal, $doc:literal) => {
2797 MetricRole {
2798 name: stringify!($field),
2799 node: $node,
2800 get: |m| m.$field,
2801 set: |m, v| m.$field = v,
2802 doc: $doc,
2803 platform: None,
2804 }
2805 };
2806 ($field:ident, $node:literal, $doc:literal, windows $w:expr, elsewhere $e:expr) => {
2807 MetricRole {
2808 name: stringify!($field),
2809 node: $node,
2810 get: |m| m.$field,
2811 set: |m, v| m.$field = v,
2812 doc: $doc,
2813 platform: Some(PlatformValue {
2814 windows: $w,
2815 elsewhere: $e,
2816 }),
2817 }
2818 };
2819}
2820
2821pub const METRIC_ROLES: &[MetricRole] = &[
2822 metric_role!(
2823 control_text,
2824 "controlText",
2825 "A stock control's label: the button's text size."
2826 ),
2827 metric_role!(
2828 chrome_text,
2829 "chromeText",
2830 "The chrome's text: a menu row, a menu-bar title, the titlebar's title."
2831 ),
2832 metric_role!(hint_text, "hintText", "A tooltip's text."),
2833 metric_role!(
2834 radius,
2835 "radius",
2836 "The corner of every stock surface: a button, a field, a menu, a tooltip."
2837 ),
2838 metric_role!(
2839 radius_inner,
2840 "radiusInner",
2841 "The corner of a row inside one: a menu row, a menu-bar title."
2842 ),
2843 metric_role!(
2844 control_pad_x,
2845 "controlPadX",
2846 "A button's horizontal padding."
2847 ),
2848 metric_role!(control_pad_y, "controlPadY", "A button's vertical padding."),
2849 metric_role!(
2850 field_pad_x,
2851 "fieldPadX",
2852 "A text field's horizontal padding."
2853 ),
2854 metric_role!(field_pad_y, "fieldPadY", "A text field's vertical padding."),
2855 metric_role!(hint_pad_x, "hintPadX", "A tooltip's horizontal padding."),
2856 metric_role!(hint_pad_y, "hintPadY", "A tooltip's vertical padding."),
2857 metric_role!(
2858 menu_pad_x,
2859 "menuPadX",
2860 "A menu row's horizontal padding, and a menu-bar title's."
2861 ),
2862 metric_role!(
2863 menu_pad_y,
2864 "menuPadY",
2865 "A menu row's vertical padding; a menu-bar title's is two px less."
2866 ),
2867 metric_role!(menu_width, "menuWidth", "A menu panel's width."),
2868 metric_role!(menu_bar_h, "menuBarH", "The drawn menu bar's height."),
2869 metric_role!(
2870 titlebar_h,
2871 "titlebarH",
2872 "The titlebar's height where the strip is the app's alone: the platform's caption height, 32 on Windows and 34 elsewhere. Under macOS custom chrome the strip is the OS's own titlebar, as tall as `window.native_controls` measures it (32 on macOS 27, 28 before), and this row is not read (`widgets::titlebar_height`).",
2873 windows crate::metrics::TITLEBAR_H_WINDOWS,
2874 elsewhere crate::metrics::TITLEBAR_H_ELSEWHERE
2875 ),
2876];
2877
2878static SNAKE_NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
2879 PROPS
2880 .iter()
2881 .map(|d| Box::leak(snake_case(d.name).into_boxed_str()) as &'static str)
2882 .collect()
2883});
2884
2885pub fn snake_case(name: &str) -> String {
2888 let mut out = String::with_capacity(name.len() + 2);
2889 let mut prev_upper = false;
2890 for c in name.chars() {
2891 if c.is_ascii_uppercase() {
2892 if !prev_upper {
2893 out.push('_');
2894 }
2895 out.push(c.to_ascii_lowercase());
2896 prev_upper = true;
2897 } else {
2898 out.push(c);
2899 prev_upper = false;
2900 }
2901 }
2902 out
2903}
2904
2905pub fn by_name(name: &str) -> Option<&'static PropDef> {
2906 PROPS.iter().find(|d| d.name == name)
2907}
2908
2909pub fn by_snake_name(name: &str) -> Option<&'static PropDef> {
2910 SNAKE_NAMES
2911 .iter()
2912 .position(|n| *n == name)
2913 .map(|i| &PROPS[i])
2914}
2915
2916pub fn by_id(id: u32) -> Option<&'static PropDef> {
2917 PROPS.iter().find(|d| d.id == id)
2918}
2919
2920#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2925pub enum Spelling {
2926 Camel,
2927 Snake,
2928}
2929
2930pub const LUA_ALIASES: &[(&str, &str)] = &[("direction", "repeat")];
2935
2936pub fn lua_alias(name: &str) -> Option<&'static str> {
2939 LUA_ALIASES
2940 .iter()
2941 .find(|(alias, _)| *alias == name)
2942 .map(|(_, real)| *real)
2943}
2944
2945static SHARED_NAMES: LazyLock<[FxHashSet<&'static str>; 2]> = LazyLock::new(|| {
2948 let mut camel: FxHashSet<&'static str> = PROPS.iter().map(|d| d.name).collect();
2949 let mut snake: FxHashSet<&'static str> = PROPS.iter().map(|d| d.snake_name()).collect();
2950 for c in CUSTOM {
2951 camel.extend(c.jsx_names);
2952 snake.extend(c.lua_names);
2953 }
2954 snake.extend(LUA_ALIASES.iter().map(|(alias, _)| *alias));
2955 [camel, snake]
2956});
2957
2958fn shared_names(spelling: Spelling) -> &'static FxHashSet<&'static str> {
2959 &SHARED_NAMES[(spelling == Spelling::Snake) as usize]
2960}
2961
2962pub fn element_own(element: &str, spelling: Spelling) -> &'static [&'static str] {
2965 match ELEMENTS.iter().find(|e| e.name == element) {
2966 Some(e) if spelling == Spelling::Camel => e.jsx_own,
2967 Some(e) => e.lua_own,
2968 None => &[],
2969 }
2970}
2971
2972pub fn element_rows(element: &str, spelling: Spelling) -> Option<&'static [&'static str]> {
2976 let e = ELEMENTS.iter().find(|e| e.name == element)?;
2977 if spelling == Spelling::Camel {
2978 e.jsx_rows
2979 } else {
2980 e.lua_rows
2981 }
2982}
2983
2984pub fn shared_prop(name: &str, spelling: Spelling) -> bool {
2990 shared_names(spelling).contains(name)
2991}
2992
2993pub fn known_prop(element: &str, name: &str, spelling: Spelling) -> bool {
2998 if element_own(element, spelling).contains(&name) {
2999 return true;
3000 }
3001 match element_rows(element, spelling) {
3002 Some(rows) => rows.contains(&name),
3003 None => shared_names(spelling).contains(name),
3004 }
3005}
3006
3007pub fn suggest(element: &str, name: &str, spelling: Spelling) -> Option<&'static str> {
3012 let squash = |s: &str| s.replace('_', "").to_ascii_lowercase();
3013 let want = squash(name);
3014 let near = |c: &&&str| squash(c) == want;
3015 let own = element_own(element, spelling).iter();
3016 match element_rows(element, spelling) {
3018 Some(rows) => rows.iter().chain(own).find(near).copied(),
3019 None => shared_names(spelling).iter().chain(own).find(near).copied(),
3020 }
3021}
3022
3023pub enum Parsed {
3025 F32(f32),
3026 Color(Color),
3027 Flag,
3028 Enum(usize),
3029 Sizing(Sizing),
3030 Bound(Bound),
3032 Msg(Value),
3033 Str(String),
3034 Resource(u64),
3035 Keyframes(Vec<Keyframe>),
3036 Enter(Enter),
3037 Family(FontFamily),
3039}
3040
3041#[derive(Clone, Debug, PartialEq)]
3043pub struct PropsOut {
3044 pub spec: NodeSpec,
3045 pub style: TextStyle,
3046 pub key: Option<String>,
3047 pub index: Option<u64>,
3050 pub row_count: Option<u64>,
3053 pub title: Option<String>,
3054 pub always_on_top: bool,
3057 pub secure_input: bool,
3060 pub option_as_alt: crate::OptionAsAlt,
3063 pub key_focus: bool,
3064 pub tooltip: Option<String>,
3067 pub windows: Vec<(String, WindowConfig)>,
3069 pub wrap: bool,
3074}
3075
3076#[derive(Clone, Copy, Debug, PartialEq)]
3080pub enum Identity<'a> {
3081 Auto,
3082 Label(&'a str),
3083 Index(u64),
3084}
3085
3086impl PropsOut {
3087 pub fn identity(&self) -> Identity<'_> {
3089 match (self.index, &self.key) {
3090 (Some(i), _) => Identity::Index(i),
3091 (None, Some(label)) => Identity::Label(label),
3092 (None, None) => Identity::Auto,
3093 }
3094 }
3095
3096 pub fn new() -> Self {
3097 PropsOut {
3098 spec: NodeSpec::column(),
3099 style: TextStyle::new(16.0),
3100 key: None,
3101 index: None,
3102 row_count: None,
3103 title: None,
3104 always_on_top: false,
3105 secure_input: false,
3106 option_as_alt: crate::OptionAsAlt::None,
3107 key_focus: false,
3108 tooltip: None,
3109 windows: Vec::new(),
3110 wrap: false,
3111 }
3112 }
3113
3114 pub fn with_spec(&mut self, f: impl FnOnce(NodeSpec) -> NodeSpec) {
3116 self.spec = f(std::mem::take(&mut self.spec));
3117 }
3118
3119 pub fn apply_tooltip(&mut self, hint: impl Into<String>) {
3124 let hint = hint.into();
3125 self.with_spec(|s| s.apply_tooltip(&hint));
3126 self.tooltip = Some(hint);
3127 }
3128
3129 pub fn apply_pad(&mut self, pad: PadShorthand) {
3133 if pad.declared() {
3134 let edges = pad.resolve();
3135 self.with_spec(|s| s.padding(edges));
3136 }
3137 }
3138}
3139
3140impl Default for PropsOut {
3141 fn default() -> Self {
3142 Self::new()
3143 }
3144}
3145
3146pub fn apply(def: &PropDef, value: Parsed, out: &mut PropsOut) -> Result<(), String> {
3150 let spec = std::mem::take(&mut out.spec);
3151 let style = out.style;
3152 out.wrap |= def.id == P_WRAP;
3155 match (&def.apply, value) {
3156 (Apply::SpecF32(f), Parsed::F32(v)) => out.spec = f(spec, v),
3157 (Apply::SpecColor(f), Parsed::Color(v)) => out.spec = f(spec, v),
3158 (Apply::SpecFlag(f), Parsed::Flag) => out.spec = f(spec),
3159 (Apply::SpecEnum(f), Parsed::Enum(v)) => out.spec = f(spec, v),
3160 (Apply::SpecSizing(f), Parsed::Sizing(v)) => out.spec = f(spec, v),
3161 (Apply::SpecBound(f), Parsed::Bound(v)) => out.spec = f(spec, v),
3162 (Apply::SpecMsg(f), Parsed::Msg(v)) => out.spec = f(spec, v),
3163 (Apply::SpecStr(f), Parsed::Str(v)) => out.spec = f(spec, &v),
3164 (Apply::SpecKeyframes(f), Parsed::Keyframes(v)) => out.spec = f(spec, v),
3165 (Apply::SpecEnter(f), Parsed::Enter(v)) => out.spec = f(spec, v),
3166 (Apply::SpecResource(f), Parsed::Resource(v)) => out.spec = f(spec, v),
3167 (Apply::StyleF32(f), Parsed::F32(v)) => {
3168 out.spec = spec;
3169 out.style = f(style, v);
3170 }
3171 (Apply::StyleColor(f), Parsed::Color(v)) => {
3172 out.spec = spec;
3173 out.style = f(style, v);
3174 }
3175 (Apply::StyleEnum(f), Parsed::Enum(v)) => {
3176 out.spec = spec;
3177 out.style = f(style, v);
3178 }
3179 (Apply::StyleFlag(f), Parsed::Flag) => {
3180 out.spec = spec;
3181 out.style = f(style);
3182 }
3183 (Apply::StyleResource(f), Parsed::Resource(v)) => {
3184 out.spec = spec;
3185 out.style = f(style, v);
3186 }
3187 (Apply::StyleStr(f), Parsed::Str(v)) => {
3188 out.spec = spec;
3189 out.style = f(style, &v);
3190 }
3191 (Apply::StyleFamily(f), Parsed::Family(v)) => {
3192 out.spec = spec;
3193 out.style = f(style, v);
3194 }
3195 _ => {
3196 out.spec = spec;
3197 return Err(format!("prop {} value/kind mismatch", def.name));
3198 }
3199 }
3200 Ok(())
3201}
3202
3203pub fn enum_index(names: &[&str], s: &str) -> Result<usize, String> {
3205 names
3206 .iter()
3207 .position(|n| *n == s)
3208 .ok_or_else(|| format!("bad value {s:?} (one of {names:?})"))
3209}
3210
3211pub fn color_num(hex: u32) -> Color {
3216 if hex == 0 {
3217 Color::TRANSPARENT
3218 } else {
3219 Color::hex(hex)
3220 }
3221}
3222
3223pub fn color_hex_str(s: &str) -> Result<Color, String> {
3225 let bad = || format!("bad color {s:?}");
3226 let hex = s.strip_prefix('#').ok_or_else(bad)?;
3227 let expanded = match hex.len() {
3228 3 => hex
3229 .chars()
3230 .flat_map(|c| [c, c])
3231 .chain("ff".chars())
3232 .collect::<String>(),
3233 6 => format!("{hex}ff"),
3234 8 => hex.to_string(),
3235 _ => return Err(bad()),
3236 };
3237 let n = u32::from_str_radix(&expanded, 16).map_err(|_| bad())?;
3238 Ok(Color::hex(n))
3239}
3240
3241pub const SIZE_MODE_CALC: u32 = 4;
3244
3245pub const SIZE_MODE_TREE: u32 = 5;
3250
3251pub fn min_str(s: &str) -> Result<Bound, String> {
3254 match s {
3255 "fit" => Ok(Bound::Fit),
3256 _ => crate::calc::bound(s)
3257 .map_err(|e| format!("bad min {s:?} (number | \"fit\" | a size expression): {e}")),
3258 }
3259}
3260
3261pub fn max_str(s: &str) -> Result<Bound, String> {
3263 crate::calc::bound(s).map_err(|e| format!("bad max {s:?} (number | a size expression): {e}"))
3264}
3265
3266pub fn sizing_str(s: &str) -> Result<Sizing, String> {
3269 match s {
3270 "fit" => Ok(Sizing::Fit),
3271 "grow" => Ok(Sizing::Grow(1.0)),
3272 _ => crate::calc::sizing(s).map_err(|e| {
3273 format!("bad sizing {s:?} (fit | grow | number | \"N%\" | a size expression): {e}")
3274 }),
3275 }
3276}
3277
3278#[cfg(test)]
3279mod tests {
3280 use super::*;
3281
3282 #[test]
3290 fn every_enum_list_is_its_enum_s_all_by_name() {
3291 fn names(it: &[&'static str]) -> Vec<&'static str> {
3292 it.to_vec()
3293 }
3294 assert_eq!(
3295 Easing::ALL.iter().map(|e| e.name()).collect::<Vec<_>>(),
3296 names(EASINGS)
3297 );
3298 assert_eq!(
3299 Repeat::ALL.iter().map(|r| r.name()).collect::<Vec<_>>(),
3300 names(REPEATS)
3301 );
3302 assert_eq!(
3303 crate::access::Live::ALL
3304 .iter()
3305 .map(|l| l.name())
3306 .collect::<Vec<_>>(),
3307 names(LIVE)
3308 );
3309 assert_eq!(
3310 FontFamily::ALL
3311 .iter()
3312 .map(|f| f.name().expect("a stock family has a name"))
3313 .collect::<Vec<_>>(),
3314 names(FAMILIES)
3315 );
3316 for (i, e) in Easing::ALL.iter().enumerate() {
3318 assert_eq!(easing_idx(i), *e);
3319 }
3320 for (i, r) in Repeat::ALL.iter().enumerate() {
3321 assert_eq!(repeat_idx(i), *r);
3322 }
3323 for (i, l) in crate::access::Live::ALL.iter().enumerate() {
3324 assert_eq!(crate::access::Live::from_index(i), *l);
3325 }
3326 for (i, f) in FontFamily::ALL.iter().enumerate() {
3327 assert_eq!(FontFamily::from_index(i), *f);
3328 }
3329 assert_eq!(easing_idx(99), Easing::default());
3331 assert_eq!(repeat_idx(99), Repeat::default());
3332 assert_eq!(FontFamily::from_index(99), FontFamily::Sans);
3333 }
3334
3335 #[test]
3336 fn ids_and_names_are_unique() {
3337 let all: Vec<(&str, u32)> = PROPS
3338 .iter()
3339 .map(|d| (d.name, d.id))
3340 .chain(CUSTOM.iter().map(|c| (c.name, c.id)))
3341 .collect();
3342 for (i, (name, id)) in all.iter().enumerate() {
3343 for (other_name, other_id) in &all[i + 1..] {
3344 assert_ne!(name, other_name, "duplicate prop name");
3345 assert_ne!(id, other_id, "duplicate wire id for {name} / {other_name}");
3346 }
3347 }
3348 }
3349
3350 fn snake_to_camel(name: &str) -> String {
3352 let mut out = String::new();
3353 let mut up = false;
3354 for ch in name.chars() {
3355 if ch == '_' {
3356 up = true;
3357 } else if up {
3358 out.extend(ch.to_uppercase());
3359 up = false;
3360 } else {
3361 out.push(ch);
3362 }
3363 }
3364 out
3365 }
3366
3367 #[test]
3371 fn metric_roles_restate_the_metrics_exactly() {
3372 use crate::metrics::Metrics;
3373 let m = Metrics::compact();
3374 let Metrics {
3375 control_text,
3376 chrome_text,
3377 hint_text,
3378 radius,
3379 radius_inner,
3380 control_pad_x,
3381 control_pad_y,
3382 field_pad_x,
3383 field_pad_y,
3384 hint_pad_x,
3385 hint_pad_y,
3386 menu_pad_x,
3387 menu_pad_y,
3388 menu_width,
3389 menu_bar_h,
3390 titlebar_h,
3391 } = m;
3392 let fields: &[(&str, f32)] = &[
3393 ("control_text", control_text),
3394 ("chrome_text", chrome_text),
3395 ("hint_text", hint_text),
3396 ("radius", radius),
3397 ("radius_inner", radius_inner),
3398 ("control_pad_x", control_pad_x),
3399 ("control_pad_y", control_pad_y),
3400 ("field_pad_x", field_pad_x),
3401 ("field_pad_y", field_pad_y),
3402 ("hint_pad_x", hint_pad_x),
3403 ("hint_pad_y", hint_pad_y),
3404 ("menu_pad_x", menu_pad_x),
3405 ("menu_pad_y", menu_pad_y),
3406 ("menu_width", menu_width),
3407 ("menu_bar_h", menu_bar_h),
3408 ("titlebar_h", titlebar_h),
3409 ];
3410 assert_eq!(METRIC_ROLES.len(), fields.len(), "a metric has no row");
3411 for (name, value) in fields {
3412 let row = METRIC_ROLES
3413 .iter()
3414 .find(|r| r.name == *name)
3415 .unwrap_or_else(|| panic!("no METRIC_ROLES row for {name}"));
3416 assert_eq!((row.get)(&m), *value, "{name}'s row reads another field");
3417 let mut w = m;
3418 (row.set)(&mut w, 1.0);
3419 assert_eq!((row.get)(&w), 1.0, "{name}'s row writes another field");
3420 }
3421 for row in METRIC_ROLES {
3422 assert!(
3423 fields.iter().any(|(n, _)| *n == row.name),
3424 "METRIC_ROLES names a field Metrics does not have: {}",
3425 row.name
3426 );
3427 let camel = snake_to_camel(row.name);
3428 assert_eq!(row.node, camel, "{}'s Node spelling", row.name);
3429 if let Some(p) = row.platform {
3433 assert_eq!(
3434 (row.get)(&Metrics::default()),
3435 p.here(),
3436 "{}'s stock value",
3437 row.name
3438 );
3439 assert_eq!(
3440 (row.get)(&m),
3441 p.here(),
3442 "{}: compact leaves a platform row alone",
3443 row.name
3444 );
3445 }
3446 }
3447 assert!(
3448 METRIC_ROLES.iter().any(|r| r.platform.is_some()),
3449 "titlebar_h is the platform's; its row says so"
3450 );
3451 }
3452
3453 #[test]
3461 fn theme_roles_restate_the_theme_exactly() {
3462 use crate::theme::Theme;
3463 let t = Theme::dark();
3464 let Theme {
3465 appearance: _,
3466 disabled_opacity: _,
3467 bg,
3468 surface,
3469 raised,
3470 sunken,
3471 border,
3472 border_strong,
3473 fg,
3474 muted,
3475 faint,
3476 accent,
3477 accent_hover,
3478 accent_pressed,
3479 on_accent,
3480 accent_soft,
3481 selection,
3482 focus_ring,
3483 hover,
3484 pressed,
3485 success,
3486 warning,
3487 danger,
3488 scrollbar,
3489 scrollbar_active,
3490 } = t;
3491 let fields: &[(&str, crate::color::Color)] = &[
3492 ("bg", bg),
3493 ("surface", surface),
3494 ("raised", raised),
3495 ("sunken", sunken),
3496 ("border", border),
3497 ("border_strong", border_strong),
3498 ("fg", fg),
3499 ("muted", muted),
3500 ("faint", faint),
3501 ("accent", accent),
3502 ("accent_hover", accent_hover),
3503 ("accent_pressed", accent_pressed),
3504 ("on_accent", on_accent),
3505 ("accent_soft", accent_soft),
3506 ("selection", selection),
3507 ("focus_ring", focus_ring),
3508 ("hover", hover),
3509 ("pressed", pressed),
3510 ("success", success),
3511 ("warning", warning),
3512 ("danger", danger),
3513 ("scrollbar", scrollbar),
3514 ("scrollbar_active", scrollbar_active),
3515 ];
3516 assert_eq!(THEME_ROLES.len(), fields.len(), "a role has no row");
3517 for (name, value) in fields {
3518 let row = THEME_ROLES
3519 .iter()
3520 .find(|r| r.name == *name)
3521 .unwrap_or_else(|| panic!("no THEME_ROLES row for {name}"));
3522 assert_eq!((row.get)(&t), *value, "{name}'s row reads another field");
3523 }
3524 for row in THEME_ROLES {
3525 assert!(
3526 fields.iter().any(|(n, _)| *n == row.name),
3527 "{} names no field",
3528 row.name
3529 );
3530 let camel = {
3532 let mut out = String::new();
3533 let mut up = false;
3534 for ch in row.name.chars() {
3535 if ch == '_' {
3536 up = true;
3537 } else if up {
3538 out.extend(ch.to_uppercase());
3539 up = false;
3540 } else {
3541 out.push(ch);
3542 }
3543 }
3544 out
3545 };
3546 assert_eq!(row.node, camel, "{}'s Node spelling", row.name);
3547 }
3548 }
3549
3550 #[test]
3557 fn env_fields_restate_env_and_window_env_exactly() {
3558 use crate::env::{AudioEnv, Env, SystemEnv};
3559 use crate::window::WindowEnv;
3560 let Env {
3561 refresh_hz: _,
3562 focused: _,
3563 system,
3564 window,
3565 audio,
3566 } = Env::default();
3567 let AudioEnv { device: _, live: _ } = audio;
3568 let SystemEnv {
3569 appearance: _,
3570 accent: _,
3571 motion: _,
3572 locale: _,
3573 assistive: _,
3574 } = system;
3575 let WindowEnv {
3576 id: _,
3577 custom_chrome: _,
3578 maximized: _,
3579 fullscreen: _,
3580 always_on_top: _,
3581 native_controls: _,
3582 } = window;
3583 let stored = [
3584 "refresh_hz",
3585 "focused",
3586 "system.appearance",
3587 "system.accent",
3588 "system.motion",
3589 "system.locale",
3590 "system.assistive",
3591 "window.id",
3592 "window.custom_chrome",
3593 "window.maximized",
3594 "window.fullscreen",
3595 "window.always_on_top",
3596 "window.native_controls",
3597 "audio.device",
3598 "audio.live",
3599 ];
3600 let from_structs: Vec<&str> = ENV_FIELDS
3603 .iter()
3604 .filter(|f| !f.from.contains('('))
3605 .map(|f| {
3606 let (strukt, field) = match f.name.split_once('.') {
3607 Some(("window", field)) => ("WindowEnv", field),
3608 Some(("system", field)) => ("SystemEnv", field),
3609 Some(("audio", field)) => ("AudioEnv", field),
3610 Some((group, _)) => panic!("{}: no struct holds a {group} fact", f.name),
3611 None => ("Env", f.name),
3612 };
3613 assert_eq!(f.from, format!("`{strukt}::{field}`"), "{}", f.name);
3614 f.name
3615 })
3616 .collect();
3617 assert_eq!(from_structs, stored);
3618 for f in ENV_FIELDS.iter().filter(|f| f.from.contains('(')) {
3619 assert!(
3620 f.from.contains("derived") || f.from.contains("the frame's"),
3621 "{}: a call in `from` is derived or the frame's, and says which",
3622 f.name
3623 );
3624 }
3625 }
3626
3627 #[test]
3631 fn env_field_spellings_are_unique_and_complete() {
3632 let mut names: Vec<&str> = ENV_FIELDS.iter().map(|f| f.name).collect();
3633 let mut node: Vec<&str> = ENV_FIELDS
3634 .iter()
3635 .flat_map(|f| f.node.iter().copied())
3636 .collect();
3637 let mut lua: Vec<&str> = ENV_FIELDS
3638 .iter()
3639 .flat_map(|f| f.lua.iter().copied())
3640 .collect();
3641 for list in [&mut names, &mut node, &mut lua] {
3642 let before = list.len();
3643 list.sort_unstable();
3644 list.dedup();
3645 assert_eq!(list.len(), before, "a spelling is used twice");
3646 }
3647 for f in ENV_FIELDS {
3648 assert!(!f.c.is_empty() && !f.doc.is_empty(), "{}", f.name);
3649 if f.node.is_empty() {
3652 assert!(
3653 f.doc.contains("Node"),
3654 "{}: where does Node carry it?",
3655 f.name
3656 );
3657 }
3658 if f.lua.is_empty() {
3659 assert!(
3660 f.doc.contains("Lua"),
3661 "{}: where does Lua carry it?",
3662 f.name
3663 );
3664 }
3665 }
3666 }
3667
3668 #[test]
3672 fn admitted_rows_are_shared_rows_in_both_spellings() {
3673 for e in ELEMENTS {
3674 let (Some(jsx), Some(lua)) = (e.jsx_rows, e.lua_rows) else {
3675 assert!(
3676 e.jsx_rows.is_none() && e.lua_rows.is_none(),
3677 "{}: one spelling only",
3678 e.name
3679 );
3680 continue;
3681 };
3682 assert_eq!(
3683 jsx.len(),
3684 lua.len(),
3685 "{}: the lists differ in length",
3686 e.name
3687 );
3688 for (j, l) in jsx.iter().zip(lua) {
3689 assert!(
3690 shared_prop(j, Spelling::Camel),
3691 "{}: `{j}` is not a row",
3692 e.name
3693 );
3694 assert!(
3695 shared_prop(l, Spelling::Snake),
3696 "{}: `{l}` is not a row",
3697 e.name
3698 );
3699 assert_eq!(
3700 snake_case(j),
3701 *l,
3702 "{}: `{j}` and `{l}` are not one row",
3703 e.name
3704 );
3705 }
3706 if jsx.contains(&"onClick") {
3711 assert!(jsx.contains(&"label"), "{}: `label` missing", e.name);
3712 }
3713 }
3714 assert!(known_prop("button", "description", Spelling::Camel));
3715 assert!(known_prop("button", "on_click", Spelling::Snake));
3716 assert!(known_prop("button", "text", Spelling::Snake));
3717 assert!(!known_prop("button", "radius", Spelling::Camel));
3718 assert!(!known_prop("button", "text", Spelling::Camel));
3719 assert!(known_prop("box", "radius", Spelling::Camel));
3720 assert_eq!(
3723 suggest("button", "on_click", Spelling::Camel),
3724 Some("onClick")
3725 );
3726 assert_eq!(suggest("button", "hover_bg", Spelling::Camel), None);
3727 assert_eq!(suggest("box", "hover_bg", Spelling::Camel), Some("hoverBg"));
3728 }
3729
3730 #[test]
3736 fn text_admits_exactly_the_style_rows() {
3737 let style: Vec<&str> = PROPS
3738 .iter()
3739 .filter(|d| d.target() == Target::Style)
3740 .map(|d| d.name)
3741 .collect();
3742 let mut expect = vec!["size"];
3743 expect.extend(style);
3744 let mut got = TEXT_ROWS_JSX.to_vec();
3745 expect.sort_unstable();
3746 got.sort_unstable();
3747 assert_eq!(got, expect, "TEXT_ROWS_JSX is not the style rows");
3748 for name in ["lineHeight", "color", "wrap", "size"] {
3749 assert!(known_prop("text", name, Spelling::Camel), "{name}");
3750 }
3751 for name in ["live", "role", "label", "onClick", "key", "pad", "bg"] {
3752 assert_eq!(
3753 known_prop("text", name, Spelling::Camel),
3754 name == "bg",
3755 "{name}: `bg` is the element's own (a span's), the rest are not rows it reads"
3756 );
3757 }
3758 assert!(known_prop("text", "line_height", Spelling::Snake));
3759 assert!(known_prop("text", "value", Spelling::Snake));
3760 assert!(!known_prop("text", "live", Spelling::Snake));
3761 assert!(!known_prop("text", "on_click", Spelling::Snake));
3762 assert_eq!(
3765 suggest("text", "max_lines", Spelling::Camel),
3766 Some("maxLines")
3767 );
3768 assert_eq!(suggest("text", "hover_bg", Spelling::Camel), None);
3769 }
3770
3771 #[test]
3772 fn snake_names_round_trip() {
3773 assert_eq!(by_snake_name("min_width").unwrap().name, "minWidth");
3774 assert_eq!(by_snake_name("on_click").unwrap().name, "onClick");
3775 assert_eq!(by_snake_name("bg").unwrap().name, "bg");
3776 assert!(by_snake_name("minWidth").is_none());
3777 assert_eq!(by_name("lineHeight").unwrap().snake_name(), "line_height");
3778 assert_eq!(by_name("radiusTL").unwrap().snake_name(), "radius_tl");
3779 assert_eq!(by_snake_name("radius_bl").unwrap().name, "radiusBL");
3780 }
3781
3782 #[test]
3786 fn the_allow_list_is_per_spelling() {
3787 use Spelling::{Camel, Snake};
3788 assert!(known_prop("box", "hoverBg", Camel));
3789 assert!(known_prop("box", "hover_bg", Snake));
3790 assert!(!known_prop("box", "hover_bg", Camel));
3791 assert!(!known_prop("box", "hoverBg", Snake));
3792 assert!(known_prop("box", "padX", Camel) && !known_prop("box", "padX", Snake));
3794 assert!(known_prop("box", "scroll", Snake) && !known_prop("box", "scroll", Camel));
3795 assert!(known_prop("box", "direction", Snake));
3797 assert_eq!(lua_alias("direction"), Some("repeat"));
3798 assert!(known_prop("edit", "initial", Camel));
3800 assert!(!known_prop("box", "initial", Camel));
3801 assert!(known_prop("edit", "wrap", Camel) && known_prop("edit", "wrap", Snake));
3804 assert!(known_prop("image", "src", Camel) && known_prop("image", "id", Snake));
3805 assert!(!known_prop("box", "colour", Camel));
3807 }
3808
3809 #[test]
3810 fn a_suggestion_is_the_same_word_in_the_right_convention() {
3811 use Spelling::{Camel, Snake};
3812 assert_eq!(suggest("box", "hoverBg", Snake), Some("hover_bg"));
3813 assert_eq!(suggest("box", "onclick", Camel), Some("onClick"));
3814 assert_eq!(suggest("box", "SCROLL_X", Camel), Some("scrollX"));
3815 assert_eq!(suggest("edit", "Initial", Camel), Some("initial"));
3816 assert_eq!(suggest("box", "colour", Camel), None);
3818 }
3819
3820 #[test]
3823 fn every_composite_and_element_prop_is_in_the_allow_list() {
3824 for c in CUSTOM {
3825 for n in c.jsx_names {
3826 assert!(known_prop("box", n, Spelling::Camel), "jsx `{n}`");
3827 }
3828 for n in c.lua_names {
3829 assert!(known_prop("box", n, Spelling::Snake), "lua `{n}`");
3830 }
3831 }
3832 for e in ELEMENTS {
3833 for n in e.jsx_own {
3834 assert!(known_prop(e.name, n, Spelling::Camel), "{}.{n}", e.name);
3835 }
3836 for n in e.lua_own {
3837 assert!(known_prop(e.name, n, Spelling::Snake), "{}.{n}", e.name);
3838 }
3839 }
3840 }
3841
3842 #[test]
3843 fn every_row_applies_a_sample_of_its_kind() {
3844 for def in PROPS {
3845 let f = if def.name == "opacity" { 0.5 } else { 7.0 };
3848 let sample = match def.kind {
3849 Kind::F32 => Parsed::F32(f),
3850 Kind::Color => Parsed::Color(Color::hex(0x11223344)),
3851 Kind::Flag => Parsed::Flag,
3852 Kind::Enum(_) => Parsed::Enum(1),
3853 Kind::Sizing => Parsed::Sizing(Sizing::Percent(0.5)),
3854 Kind::Min => Parsed::Bound(Bound::Fit),
3855 Kind::Max => Parsed::Bound(Bound::Px(10.0)),
3856 Kind::Msg | Kind::Tag => Parsed::Msg(Value::Int(1)),
3857 Kind::Str => Parsed::Str("name".into()),
3858 Kind::Family => Parsed::Family(FontFamily::Mono),
3859 Kind::Resource => Parsed::Resource(7),
3860 Kind::Keyframes => Parsed::Keyframes(vec![Keyframe::default().radius(7.0)]),
3861 Kind::Enter => Parsed::Enter(Enter::from(-7.0, 0.0)),
3862 };
3863 let mut out = PropsOut::new();
3864 apply(def, sample, &mut out).unwrap();
3865 let changed = match def.target() {
3866 Target::Spec => out.spec != NodeSpec::column(),
3867 Target::Style => out.style != TextStyle::new(16.0),
3868 };
3869 assert!(changed, "{} applied a sample but nothing changed", def.name);
3870 }
3871 }
3872
3873 #[test]
3876 fn a_null_tag_still_declares_the_behaviour() {
3877 let mut out = PropsOut::new();
3878 apply(
3879 by_name("onKey").unwrap(),
3880 Parsed::Msg(Value::Null),
3881 &mut out,
3882 )
3883 .unwrap();
3884 assert_eq!(out.spec.events().on_key, Some(Value::Null));
3885 assert!(out.spec.hover_tracked());
3886 let mut out = PropsOut::new();
3887 apply(
3888 by_name("onLayout").unwrap(),
3889 Parsed::Msg(Value::Null),
3890 &mut out,
3891 )
3892 .unwrap();
3893 assert_eq!(out.spec.events().on_layout, Some(Value::Null));
3894 }
3895
3896 #[test]
3906 fn every_declarable_role_name_is_a_real_role() {
3907 for (i, name) in ROLES.iter().enumerate() {
3908 let role = Role::parse(name)
3909 .unwrap_or_else(|| panic!("ROLES[{i}] = {name:?} is not a Role::ALL name"));
3910 assert_eq!(role_idx(i), role, "role index {i} lowers to the wrong role");
3911 assert_eq!(role_idx(i).name(), *name);
3912 }
3913 }
3914
3915 #[test]
3924 fn every_role_is_declarable_or_derived() {
3925 for role in Role::ALL {
3926 let declarable = ROLES.contains(&role.name());
3927 let derived = DERIVED_ONLY.iter().any(|(n, _)| *n == role.name());
3928 assert!(
3929 declarable || derived,
3930 "Role::{role:?} is in Role::ALL but no view can declare it and \
3931 DERIVED_ONLY does not say the core derives it — add {:?} to \
3932 ROLES, or to DERIVED_ONLY with the reason",
3933 role.name()
3934 );
3935 assert!(
3936 !(declarable && derived),
3937 "Role::{role:?} is in ROLES, so it is declarable — drop it from \
3938 DERIVED_ONLY"
3939 );
3940 }
3941 for (name, why) in DERIVED_ONLY {
3942 assert!(!why.is_empty(), "{name:?} is exempted without a reason");
3943 assert!(
3944 Role::parse(name).is_some(),
3945 "DERIVED_ONLY names {name:?}, which is not a Role::ALL name"
3946 );
3947 }
3948 let derived = roles_one_frame_derives();
3949 for (name, _) in DERIVED_ONLY {
3950 assert!(
3951 derived.contains(&Role::parse(name).unwrap()),
3952 "DERIVED_ONLY keeps {name:?} out of ROLES because the core \
3953 derives it, but no frame does any more — either it belongs in \
3954 ROLES now, or the exemption is stale"
3955 );
3956 }
3957 }
3958
3959 fn roles_one_frame_derives() -> Vec<Role> {
3963 let mut core = crate::Core::new();
3964 let mut ui = core.frame(crate::Size::new(200.0, 200.0), 1.0);
3965 ui.window_title("Demo");
3966 ui.configure_root(NodeSpec::column().fill());
3967 ui.leaf_keyed("titlebar", NodeSpec::row().window_drag());
3968 ui.text_in_keyed(
3969 "scroll",
3970 NodeSpec::column().height(40.0).scroll_y(),
3971 "hello",
3972 TextStyle::new(12.0),
3973 );
3974 ui.finish();
3975 core.access_tree().nodes.iter().map(|n| n.role).collect()
3976 }
3977
3978 #[test]
3979 fn colors_and_sizings_parse() {
3980 assert_eq!(color_hex_str("#fff").unwrap(), Color::hex(0xffffffff));
3981 assert_eq!(color_hex_str("#11223344").unwrap(), Color::hex(0x11223344));
3982 assert!(color_hex_str("fff").is_err());
3983 assert_eq!(sizing_str("50%").unwrap(), Sizing::Percent(0.5));
3984 assert_eq!(sizing_str("grow").unwrap(), Sizing::Grow(1.0));
3985 assert!(sizing_str("wide").is_err());
3986 }
3987}