1use std::sync::LazyLock;
33
34use rustc_hash::{FxHashMap, FxHashSet};
35
36use crate::access::Role;
37use crate::anim::{Easing, Repeat};
38use crate::color::Color;
39use crate::cursor::CursorShape;
40use crate::enter::Enter;
41use crate::keyframes::Keyframe;
42use crate::spec::{
43 Align, Bound, FontFamily, NodeSpec, PadShorthand, Sizing, TextStyle, TextWrap, UnderlineStyle,
44};
45use crate::value::Value;
46use crate::window::{WindowButton, WindowConfig};
47
48mod doors;
49pub use doors::{Cell, DOORS, Door, GUEST};
50
51pub const P_DIR: u32 = 1;
53pub const P_WIDTH: u32 = 2;
54pub const P_HEIGHT: u32 = 3;
55pub const P_MIN_W: u32 = 4;
56pub const P_MAX_W: u32 = 5;
57pub const P_MIN_H: u32 = 6;
58pub const P_MAX_H: u32 = 7;
59pub const P_PAD: u32 = 8;
60pub const P_GAP: u32 = 9;
61pub const P_MAIN_ALIGN: u32 = 10;
62pub const P_CROSS_ALIGN: u32 = 11;
63pub const P_BG: u32 = 12;
64pub const P_BORDER: u32 = 13;
65pub const P_RADIUS: u32 = 14;
66pub const P_OVERFLOW: u32 = 15;
67pub const P_FLOAT: u32 = 16;
68pub const P_HOVERABLE: u32 = 17;
69pub const P_ON_CLICK: u32 = 18;
70pub const P_ON_DRAG: u32 = 19;
71pub const P_ON_KEY: u32 = 20;
72pub const P_WINDOW: u32 = 21;
73pub const P_KEY_FOCUS: u32 = 22;
74pub const P_CENTER: u32 = 23;
75pub const P_SIZE: u32 = 24;
76pub const P_LINE_HEIGHT: u32 = 25;
77pub const P_COLOR: u32 = 26;
78pub const P_FAMILY: u32 = 27;
79pub const P_KEY: u32 = 28;
80pub const P_TITLE: u32 = 29;
81pub const P_TOOLTIP: u32 = 30;
82pub const P_TRANSITION: u32 = 31;
83pub const P_EASING: u32 = 32;
84pub const P_SLIDE: u32 = 33;
85pub const P_HOVER_BG: u32 = 34;
86pub const P_PRESSED_BG: u32 = 35;
87pub const P_HOVER_GROUP: u32 = 36;
88pub const P_ON_HOVER: u32 = 37;
89pub const P_RADIUS_TL: u32 = 38;
90pub const P_RADIUS_TR: u32 = 39;
91pub const P_RADIUS_BR: u32 = 40;
92pub const P_RADIUS_BL: u32 = 41;
93pub const P_FONT: u32 = 42;
94pub const P_KEYFRAMES: u32 = 43;
95pub const P_REPEAT: u32 = 44;
96pub const P_DELAY: u32 = 45;
97pub const P_WRAP: u32 = 46;
98pub const P_MAX_LINES: u32 = 47;
99pub const P_ELLIPSIS: u32 = 48;
100pub const P_ENTER: u32 = 49;
101pub const P_CLICK_SOUND: u32 = 50;
102pub const P_HOVER_SOUND: u32 = 51;
103pub const P_ON_LAYOUT: u32 = 52;
104pub const P_ROLE: u32 = 53;
105pub const P_LABEL: u32 = 54;
106pub const P_CHECKED: u32 = 55;
107pub const P_VALUE_NOW: u32 = 56;
108pub const P_VALUE_MIN: u32 = 57;
109pub const P_VALUE_MAX: u32 = 58;
110pub const P_CARET: u32 = 59;
111pub const P_SELECTION_ANCHOR: u32 = 60;
112pub const P_FOCUSABLE: u32 = 61;
113pub const P_DISABLED: u32 = 62;
114pub const P_FOCUS_BG: u32 = 63;
115pub const P_MODAL: u32 = 64;
116pub const P_ON_CONTEXT_MENU: u32 = 65;
117pub const P_CURSOR: u32 = 66;
118pub const P_SELECTED: u32 = 67;
119pub const P_EXPANDED: u32 = 68;
120pub const P_OPACITY: u32 = 69;
121pub const P_SHADOW_COLOR: u32 = 70;
122pub const P_SHADOW_BLUR: u32 = 71;
123pub const P_SHADOW_X: u32 = 72;
124pub const P_SHADOW_Y: u32 = 73;
125pub const P_SHADOW_SPREAD: u32 = 74;
126pub const P_WRAP_CHILDREN: u32 = 75;
127pub const P_CROSS_GAP: u32 = 76;
128pub const P_INITIAL_FOCUS: u32 = 77;
129pub const P_EXIT: u32 = 78;
130pub const P_WINDOWS: u32 = 79;
131pub const P_LIVE: u32 = 80;
132pub const P_KEY_UP: u32 = 81;
133pub const P_VALUE_TEXT: u32 = 82;
134pub const P_DESCRIPTION: u32 = 83;
135pub const P_FEATURES: u32 = 84;
136pub const P_UNDERLINE: u32 = 85;
137pub const P_STRIKETHROUGH: u32 = 86;
138pub const P_ANIMATE: u32 = 87;
139pub const P_ACCENT: u32 = 88;
140pub const P_INDEX: u32 = 89;
141pub const P_SELECTABLE: u32 = 90;
142pub const P_ON_FORCE_CLICK: u32 = 91;
143pub const P_FOCUS_REGION: u32 = 92;
144pub const P_SCROLLBAR: u32 = 93;
145pub const P_SCROLLBAR_WIDTH: u32 = 94;
146pub const P_SCROLLBAR_COLOR: u32 = 95;
147pub const P_SCROLLBAR_ACTIVE_COLOR: u32 = 96;
148pub const P_ANCHOR: u32 = 97;
149pub const P_ALWAYS_ON_TOP: u32 = 98;
150pub const P_ON_SCROLL: u32 = 99;
151pub const P_ROW_COUNT: u32 = 100;
152pub const P_UNDERLINE_COLOR: u32 = 101;
153pub const P_UNDERLINE_STYLE: u32 = 102;
154pub const P_ON_DROP: u32 = 103;
155pub const P_DROP_BG: u32 = 104;
156pub const P_CARET_SOLID: u32 = 105;
157pub const P_SECURE_INPUT: u32 = 106;
158pub const P_ASPECT_RATIO: u32 = 107;
159pub const P_MIXED: u32 = 108;
160pub const P_VALUE_STEP: u32 = 109;
161pub const P_ON_CHANGE: u32 = 110;
162pub const P_PIXEL_SNAP: u32 = 111;
163pub const P_KEEP_FOCUS: u32 = 112;
164pub const P_ON_FOCUS: u32 = 113;
165pub const P_RULES: u32 = 114;
166pub const P_RULE_WIDTH: u32 = 115;
167pub const P_ON_BUTTON: u32 = 116;
168pub const P_BUTTONS: u32 = 117;
169pub const P_OVERSCROLL: u32 = 118;
170pub const P_SCROLL_AXES: u32 = 119;
171pub const P_MODIFIER_KEYS: u32 = 120;
172pub const P_OPTION_AS_ALT: u32 = 121;
173pub const P_BOUNCE: u32 = 122;
174pub const P_GRADIENT: u32 = 123;
175pub const P_SCROLL_MODS: u32 = 124;
176pub const P_IME_OFF: u32 = 125;
177pub const P_BACKDROP_BLUR: u32 = 126;
178pub const P_ROTATE: u32 = 127;
179pub const P_SCALE: u32 = 128;
180pub const P_PIVOT_X: u32 = 129;
181pub const P_PIVOT_Y: u32 = 130;
182pub const P_ITERATIONS: u32 = 131;
183pub const P_BOLD: u32 = 132;
184
185pub const ALIGNS: &[&str] = &[
190 "start",
191 "center",
192 "end",
193 "spaceBetween",
194 "spaceAround",
195 "spaceEvenly",
196 "baseline",
197];
198pub const WINDOW_ROLES: &[&str] = &["drag", "close", "minimize", "maximize"];
199pub const SCROLLBARS: &[&str] = &["visible", "hidden", "auto"];
204pub const OVERSCROLLS: &[&str] = &["auto", "contain"];
209pub const SCROLL_AXES: &[&str] = &["both", "x", "y"];
213pub const FAMILIES: &[&str] = &["sans", "serif", "mono"];
216pub const CURSORS: &[&str] = &[
219 "default",
220 "text",
221 "pointer",
222 "grab",
223 "grabbing",
224 "notAllowed",
225 "ewResize",
226 "nsResize",
227 "nwseResize",
228 "neswResize",
229];
230
231pub fn cursor_idx(i: usize) -> CursorShape {
232 CURSORS
233 .get(i)
234 .and_then(|n| CursorShape::parse(n))
235 .unwrap_or(CursorShape::Default)
236}
237pub const WRAPS: &[&str] = &["word", "glyph", "none", "break-spaces"];
238pub const UNDERLINE_STYLES: &[&str] = UnderlineStyle::NAMES;
240pub const EXPANDED: &[&str] = &["collapsed", "expanded"];
245pub const LIVE: &[&str] = &["off", "polite", "assertive"];
249pub const ROLES: &[&str] = &[
255 "none",
256 "button",
257 "checkbox",
258 "radio",
259 "switch",
260 "slider",
261 "tab",
262 "tabList",
263 "link",
264 "heading",
265 "list",
266 "listItem",
267 "image",
268 "dialog",
269 "group",
270 "textInput",
271 "multilineTextInput",
272 "line",
273 "radioGroup",
277 "menu",
278 "menuItem",
279 "terminal",
281];
282
283pub const ORIENTATIONS: &[&str] = &["horizontal", "vertical"];
289
290pub const APPEARANCES: &[&str] = &["unknown", "light", "dark"];
296
297pub const MOTIONS: &[&str] = &["unknown", "full", "reduced"];
300
301pub const ASSISTIVE: &[&str] = &["unknown", "none", "listening"];
305
306pub const AUDIO_DEVICES: &[&str] = &["closed", "opening", "open", "failed"];
310
311pub const BACKDROPS: &[&str] = &["opaque", "transparent", "blur", "tinted"];
315
316pub const DERIVED_ONLY: &[(&str, &str)] = &[
323 (
324 "window",
325 "the root node of a frame, named by the window title",
326 ),
327 ("titleBar", "a `windowDrag` node"),
328 ("staticText", "a text node"),
329 ("scrollView", "a `scrollX` / `scrollY` node"),
330];
331
332pub fn role_idx(i: usize) -> Role {
355 ROLES
356 .get(i)
357 .and_then(|n| Role::parse(n))
358 .unwrap_or(Role::Group)
359}
360pub const EASINGS: &[&str] = &[
363 "easeOut",
364 "linear",
365 "easeIn",
366 "easeInOut",
367 "spring",
368 "bouncy",
369 "smooth",
370 "snappy",
371];
372
373pub const REPEATS: &[&str] = &["normal", "reverse", "alternate", "alternateReverse"];
375
376pub fn repeat_idx(i: usize) -> Repeat {
377 Repeat::from_index(i)
378}
379
380pub fn easing_idx(i: usize) -> Easing {
381 Easing::from_index(i)
382}
383
384pub enum Kind {
386 F32,
388 Color,
391 Flag,
393 Enum(&'static [&'static str]),
395 Sizing,
401 Min,
406 Max,
410 Msg,
412 Tag,
419 Str,
421 Family,
425 Resource,
429 Keyframes,
433 Enter,
436 Gradient,
440}
441
442pub enum Apply {
444 SpecF32(fn(NodeSpec, f32) -> NodeSpec),
445 SpecColor(fn(NodeSpec, Color) -> NodeSpec),
446 SpecFlag(fn(NodeSpec) -> NodeSpec),
447 SpecEnum(fn(NodeSpec, usize) -> NodeSpec),
448 SpecSizing(fn(NodeSpec, Sizing) -> NodeSpec),
449 SpecBound(fn(NodeSpec, Bound) -> NodeSpec),
450 SpecMsg(fn(NodeSpec, Value) -> NodeSpec),
451 SpecStr(fn(NodeSpec, &str) -> NodeSpec),
452 SpecKeyframes(fn(NodeSpec, Vec<Keyframe>) -> NodeSpec),
453 SpecEnter(fn(NodeSpec, Enter) -> NodeSpec),
454 SpecGradient(fn(NodeSpec, crate::gradient::Gradient) -> NodeSpec),
455 SpecResource(fn(NodeSpec, u64) -> NodeSpec),
456 StyleF32(fn(TextStyle, f32) -> TextStyle),
457 StyleColor(fn(TextStyle, Color) -> TextStyle),
458 StyleEnum(fn(TextStyle, usize) -> TextStyle),
459 StyleFlag(fn(TextStyle) -> TextStyle),
460 StyleResource(fn(TextStyle, u64) -> TextStyle),
461 StyleStr(fn(TextStyle, &str) -> TextStyle),
462 StyleFamily(fn(TextStyle, FontFamily) -> TextStyle),
463}
464
465#[derive(Clone, Copy, Debug, PartialEq, Eq)]
466pub enum Target {
467 Spec,
469 Style,
471}
472
473pub struct PropDef {
474 pub name: &'static str,
476 pub id: u32,
477 pub kind: Kind,
478 pub apply: Apply,
479 pub doc: &'static str,
480}
481
482impl PropDef {
483 pub fn target(&self) -> Target {
484 match self.apply {
485 Apply::StyleF32(_)
486 | Apply::StyleColor(_)
487 | Apply::StyleEnum(_)
488 | Apply::StyleFlag(_)
489 | Apply::StyleResource(_)
490 | Apply::StyleStr(_)
491 | Apply::StyleFamily(_) => Target::Style,
492 _ => Target::Spec,
493 }
494 }
495
496 pub fn snake_name(&self) -> &'static str {
498 SNAKE_NAMES[self.index()]
499 }
500
501 fn index(&self) -> usize {
502 PROPS
505 .iter()
506 .position(|d| d.name == self.name)
507 .expect("PropDef not from PROPS")
508 }
509}
510
511pub fn align_idx(i: usize) -> Align {
512 match i {
513 1 => Align::Center,
514 2 => Align::End,
515 3 => Align::SpaceBetween,
516 4 => Align::SpaceAround,
517 5 => Align::SpaceEvenly,
518 6 => Align::Baseline,
519 _ => Align::Start,
520 }
521}
522
523pub fn min_num(mode: u32, value: f64) -> Bound {
527 match mode {
528 1 => Bound::Fit,
529 _ => Bound::Px(value as f32),
530 }
531}
532
533pub fn sizing_num(mode: u32, value: f64) -> Sizing {
535 match mode {
536 1 => Sizing::Fit,
537 2 => Sizing::Grow(value as f32),
538 3 => Sizing::Percent(value as f32),
539 _ => Sizing::Fixed(value as f32),
540 }
541}
542
543pub const PROPS: &[PropDef] = &[
544 PropDef {
545 name: "width",
546 id: P_WIDTH,
547 kind: Kind::Sizing,
548 apply: Apply::SpecSizing(|s, v| s.width(v)),
549 doc: "Horizontal size: px | \"fit\" | \"grow\" | \"N%\" | a size expression — \
550 `\"clamp(400px, 80%, 1000px)\"`, `\"min(720px, 100%)\"`, `\"max(50%, 300)\"`, \
551 nested — which layout resolves against the parent's content box, the box a \
552 percentage takes its cut of (backlog F109). An expression with no percentage \
553 in it is a length; a calc does not ease under `transition`. A percentage \
554 or an expression gives, with the fit children, when its parent overflows \
555 — two `\"50%\"` children and a gap fit their row (backlog F110) — where a \
556 px size keeps its own. A row holding one gives as CSS's flex items do: \
557 every child that can give gives in proportion to its size, and stops at \
558 its content — the widest thing in it that cannot wrap, a label's longest \
559 word — unless `minWidth` says otherwise (backlog RG92). The process keeps \
560 65 536 distinct expressions and never lets one go: past that a new one leaves \
561 its prop at its default, with a `size-expressions-full` warning, so declare \
562 one per layout — a px size for the part that moves each frame, a splitter's \
563 drag — not one per frame (backlog RG93).",
564 },
565 PropDef {
566 name: "height",
567 id: P_HEIGHT,
568 kind: Kind::Sizing,
569 apply: Apply::SpecSizing(|s, v| s.height(v)),
570 doc: "Vertical size: px | \"fit\" | \"grow\" | \"N%\" | a size expression (see `width`).",
571 },
572 PropDef {
573 name: "minWidth",
574 id: P_MIN_W,
575 kind: Kind::Min,
576 apply: Apply::SpecBound(|s, v| s.min_width(v)),
577 doc: "Lower width clamp: logical px, a size expression (see `width`; a percentage \
578 clamp is none until the parent's width is known, as in CSS), or \"fit\" for \
579 the node's own fit width. \
580 \"fit\" under `width=\"grow\"` is a content floor — CSS's `flex: 1 0 auto` — \
581 which is what an i3-style tab bar is: tabs that split the bar evenly \
582 while they fit and sit at their label's width, scrolling, once they do \
583 not. Opt-in, because a fit width is the unwrapped one: a paragraph in a \
584 grow column would stop wrapping under it. Left out, a child giving in an \
585 overflowing row that holds a percentage or a size expression stops at its \
586 content, CSS's `min-width: auto`; `0` lets it go below, CSS's \
587 `min-width: 0` (backlog RG92). A fit node across a column is no wider \
588 than the column's box — CSS's `fit-content` — down to this floor, 0 left \
589 out, unless the column scrolls x, so a text one wrapper deep in a capped \
590 card wraps there; \"fit\" keeps its content's width and runs past \
591 (backlog F116).",
592 },
593 PropDef {
594 name: "maxWidth",
595 id: P_MAX_W,
596 kind: Kind::Max,
597 apply: Apply::SpecBound(|s, v| s.max_width(v)),
598 doc: "Upper width clamp: logical px or a size expression (see `width`); \
599 grow+maxWidth is the responsive-width pattern.",
600 },
601 PropDef {
602 name: "minHeight",
603 id: P_MIN_H,
604 kind: Kind::Min,
605 apply: Apply::SpecBound(|s, v| s.min_height(v)),
606 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`).",
607 },
608 PropDef {
609 name: "maxHeight",
610 id: P_MAX_H,
611 kind: Kind::Max,
612 apply: Apply::SpecBound(|s, v| s.max_height(v)),
613 doc: "Upper height clamp: logical px or a size expression (see `width`).",
614 },
615 PropDef {
616 name: "gap",
617 id: P_GAP,
618 kind: Kind::F32,
619 apply: Apply::SpecF32(|s, v| s.gap(v)),
620 doc: "Space between children along the main axis.",
621 },
622 PropDef {
626 name: "wrapChildren",
627 id: P_WRAP_CHILDREN,
628 kind: Kind::Flag,
629 apply: Apply::SpecFlag(NodeSpec::wrap),
630 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.",
631 },
632 PropDef {
633 name: "crossGap",
634 id: P_CROSS_GAP,
635 kind: Kind::F32,
636 apply: Apply::SpecF32(|s, v| s.cross_gap(v)),
637 doc: "Space between wrap lines, across the main axis (`gap` stays the space along it).",
638 },
639 PropDef {
640 name: "mainAlign",
641 id: P_MAIN_ALIGN,
642 kind: Kind::Enum(ALIGNS),
643 apply: Apply::SpecEnum(|s, i| s.main_align(align_idx(i))),
644 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.",
645 },
646 PropDef {
647 name: "crossAlign",
648 id: P_CROSS_ALIGN,
649 kind: Kind::Enum(ALIGNS),
650 apply: Apply::SpecEnum(|s, i| s.cross_align(align_idx(i))),
651 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.",
652 },
653 PropDef {
654 name: "aspectRatio",
655 id: P_ASPECT_RATIO,
656 kind: Kind::F32,
657 apply: Apply::SpecF32(|s, v| s.aspect_ratio(v)),
658 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.",
659 },
660 PropDef {
661 name: "bg",
662 id: P_BG,
663 kind: Kind::Color,
664 apply: Apply::SpecColor(|s, c| s.bg(c)),
665 doc: "Background fill.",
666 },
667 PropDef {
668 name: "radius",
669 id: P_RADIUS,
670 kind: Kind::F32,
671 apply: Apply::SpecF32(|s, v| s.radius(v)),
672 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.",
673 },
674 PropDef {
675 name: "opacity",
676 id: P_OPACITY,
677 kind: Kind::F32,
678 apply: Apply::SpecF32(|s, v| s.opacity(v)),
679 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.",
680 },
681 PropDef {
682 name: "pixelSnap",
683 id: P_PIXEL_SNAP,
684 kind: Kind::Flag,
685 apply: Apply::SpecFlag(|s| s.pixel_snap()),
686 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.",
687 },
688 PropDef {
689 name: "shadowColor",
690 id: P_SHADOW_COLOR,
691 kind: Kind::Color,
692 apply: Apply::SpecColor(|s, c| s.shadow_color(c)),
693 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.",
694 },
695 PropDef {
696 name: "shadowBlur",
697 id: P_SHADOW_BLUR,
698 kind: Kind::F32,
699 apply: Apply::SpecF32(|s, v| s.shadow_blur(v)),
700 doc: "Drop-shadow blur radius (logical px): the edge ramps over this distance and reaches this far past the shape. 0 = a hard edge.",
701 },
702 PropDef {
703 name: "shadowX",
704 id: P_SHADOW_X,
705 kind: Kind::F32,
706 apply: Apply::SpecF32(|s, v| s.shadow_x(v)),
707 doc: "Drop-shadow horizontal offset (logical px).",
708 },
709 PropDef {
710 name: "shadowY",
711 id: P_SHADOW_Y,
712 kind: Kind::F32,
713 apply: Apply::SpecF32(|s, v| s.shadow_y(v)),
714 doc: "Drop-shadow vertical offset (logical px); positive casts downward.",
715 },
716 PropDef {
717 name: "shadowSpread",
718 id: P_SHADOW_SPREAD,
719 kind: Kind::F32,
720 apply: Apply::SpecF32(|s, v| s.shadow_spread(v)),
721 doc: "Grows (or, negative, shrinks) the drop shadow's shape before blurring (logical px).",
722 },
723 PropDef {
724 name: "radiusTL",
725 id: P_RADIUS_TL,
726 kind: Kind::F32,
727 apply: Apply::SpecF32(|s, v| s.radius_tl(v)),
728 doc: "Top-left corner radius (logical px).",
729 },
730 PropDef {
731 name: "radiusTR",
732 id: P_RADIUS_TR,
733 kind: Kind::F32,
734 apply: Apply::SpecF32(|s, v| s.radius_tr(v)),
735 doc: "Top-right corner radius (logical px).",
736 },
737 PropDef {
738 name: "radiusBR",
739 id: P_RADIUS_BR,
740 kind: Kind::F32,
741 apply: Apply::SpecF32(|s, v| s.radius_br(v)),
742 doc: "Bottom-right corner radius (logical px).",
743 },
744 PropDef {
745 name: "radiusBL",
746 id: P_RADIUS_BL,
747 kind: Kind::F32,
748 apply: Apply::SpecF32(|s, v| s.radius_bl(v)),
749 doc: "Bottom-left corner radius (logical px).",
750 },
751 PropDef {
752 name: "center",
753 id: P_CENTER,
754 kind: Kind::Flag,
755 apply: Apply::SpecFlag(|s| s.center()),
756 doc: "Center children on both axes.",
757 },
758 PropDef {
759 name: "hoverable",
760 id: P_HOVERABLE,
761 kind: Kind::Flag,
762 apply: Apply::SpecFlag(|s| s.hoverable()),
763 doc: "Hover-track without a click payload (for isHovered-driven styling).",
764 },
765 PropDef {
766 name: "animate",
767 id: P_ANIMATE,
768 kind: Kind::Flag,
769 apply: Apply::SpecFlag(|s| s.animate()),
770 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.",
771 },
772 PropDef {
773 name: "accent",
774 id: P_ACCENT,
775 kind: Kind::Flag,
776 apply: Apply::SpecFlag(|s| s.accent()),
777 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.",
778 },
779 PropDef {
780 name: "selectable",
781 id: P_SELECTABLE,
782 kind: Kind::Flag,
783 apply: Apply::SpecFlag(|s| s.selectable()),
784 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.",
785 },
786 PropDef {
787 name: "focusable",
788 id: P_FOCUSABLE,
789 kind: Kind::Flag,
790 apply: Apply::SpecFlag(|s| s.focusable()),
791 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.",
792 },
793 PropDef {
794 name: "keepFocus",
795 id: P_KEEP_FOCUS,
796 kind: Kind::Flag,
797 apply: Apply::SpecFlag(|s| s.keep_focus()),
798 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.",
799 },
800 PropDef {
801 name: "focusRegion",
802 id: P_FOCUS_REGION,
803 kind: Kind::Flag,
804 apply: Apply::SpecFlag(|s| s.focus_region()),
805 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.",
806 },
807 PropDef {
808 name: "scrollbar",
809 id: P_SCROLLBAR,
810 kind: Kind::Enum(SCROLLBARS),
811 apply: Apply::SpecEnum(|s, i| s.scrollbar(crate::spec::ScrollbarMode::ALL[i])),
812 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.",
813 },
814 PropDef {
815 name: "anchor",
816 id: P_ANCHOR,
817 kind: Kind::Flag,
818 apply: Apply::SpecFlag(|s| s.anchor()),
819 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.",
820 },
821 PropDef {
822 name: "scrollbarWidth",
823 id: P_SCROLLBAR_WIDTH,
824 kind: Kind::F32,
825 apply: Apply::SpecF32(|s, v| s.scrollbar_width(v)),
826 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.",
827 },
828 PropDef {
829 name: "scrollbarColor",
830 id: P_SCROLLBAR_COLOR,
831 kind: Kind::Color,
832 apply: Apply::SpecColor(|s, c| s.scrollbar_color(c)),
833 doc: "The thumb at rest; the default is the theme's `scrollbar` role, a translucent wash over whatever it sits on.",
834 },
835 PropDef {
836 name: "scrollbarActiveColor",
837 id: P_SCROLLBAR_ACTIVE_COLOR,
838 kind: Kind::Color,
839 apply: Apply::SpecColor(|s, c| s.scrollbar_active_color(c)),
840 doc: "The thumb under the pointer or while dragged; the default is the theme's `scrollbar_active` role.",
841 },
842 PropDef {
843 name: "overscroll",
844 id: P_OVERSCROLL,
845 kind: Kind::Enum(OVERSCROLLS),
846 apply: Apply::SpecEnum(|s, i| s.overscroll(crate::spec::Overscroll::ALL[i])),
847 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.",
848 },
849 PropDef {
850 name: "disabled",
851 id: P_DISABLED,
852 kind: Kind::Flag,
853 apply: Apply::SpecFlag(|s| s.disabled(true)),
854 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.",
855 },
856 PropDef {
857 name: "hoverBg",
858 id: P_HOVER_BG,
859 kind: Kind::Color,
860 apply: Apply::SpecColor(|s, c| s.hover_bg(c)),
861 doc: "Background while hovered (or while any node in its hoverGroup is); implies hover tracking, eases with `transition`.",
862 },
863 PropDef {
864 name: "gradient",
865 id: P_GRADIENT,
866 kind: Kind::Gradient,
867 apply: Apply::SpecGradient(|s, g| s.gradient(g)),
868 doc: "A gradient painted over the node's `bg` and under its border and its children (`docs/adr/0042-a-gradient-is-an-image-the-core-paints.md`): `{ to: 'bottom', stops: [...] }` towards a side or a corner (`right`, `bottom left`, …; the default is `bottom`), `{ angle: 0.125, stops }` in turns clockwise from east, or `{ radial: true, at: [0.5, 0], stops }` out from a centre (fractions of the box, the middle by default) to its farthest corner. A stop is a colour — a `$token` too — or `[colour, position]` with the position 0 to 1; stops without one are spaced evenly between those with. Two stops at one position are a hard edge. The gradient is defined on the box's unit square and stretched to it, so a side or a corner is CSS's and any other `angle` runs corner to corner at an eighth of a turn whatever the box's aspect, where CSS's pixel-measured `45deg` does not. Stops mix in straight sRGB with the alpha premultiplied, as CSS's do. What it costs is one image quad: the core rasterizes each distinct gradient once into the glyph atlas — a 256-texel strip along an axis, a 128-texel square otherwise, within half an 8-bit level of the gradient computed per pixel for a linear one and 1.2 for a radial — keyed by the gradient and not the box, so a box that resizes and a thousand boxes that share one rasterize nothing, and a host that draws an image draws it; a gradient box costs about 55 ns over a flat one, so ten thousand of them are half a millisecond. A hard edge is as soft as the raster stretched to the box (a 256th of its length along a strip); stripes are boxes. It does not tween — `transition` eases the `bg` under it and `opacity` fades it — and `hoverBg` and the other state backgrounds replace `bg`, not the gradient; one that changes every frame is a raster a frame, and a shimmer is a `fragment`'s. Ignored on a `line`, a `polygon` and a `path`. Fewer than two stops are an error in JSX and Lua, as a malformed `keyframes` is, and draw nothing in Rust and C. A stop whose `$token` misses is not an error: it is raised as `unknown-token` and left out, as a miss leaves any slot unset, and the rest are spaced as if it had not been declared — so a gradient left with fewer than two stops, a two-stop one with a typo, draws nothing over its `bg` (backlog RG118).",
869 },
870 PropDef {
871 name: "backdropBlur",
872 id: P_BACKDROP_BLUR,
873 kind: Kind::F32,
874 apply: Apply::SpecF32(|s, v| s.backdrop_blur(v)),
875 doc: "Blur what was drawn beneath the node, inside its rounded box, by this radius in logical px — CSS's `backdrop-filter: blur()`, the radius its standard deviation (backlog F129). What blurs is everything painted before the node: its ancestors' backgrounds, the siblings under it, content scrolling beneath it, the window's `backdrop` where the window has one. The node's own `bg`, border and children paint over the blur, so a translucent `bg` (`#ffffff40`) makes frosted glass and an opaque one hides it. Clipped as the node is, faded by its `opacity`; 0 is none. The GPU renderer reads back only the box (and a margin of three radii around it) and blurs it at reduced resolution, so it costs a copy and three small passes per blurred node on a frame that has one and nothing on a frame that does not. A renderer that cannot read back what it drew — a host's own, or anything older — leaves the node over an unblurred backdrop; the display list carries it as a `backdrop` quad (`KUI_QUAD_BACKDROP` in C) either way.",
876 },
877 PropDef {
878 name: "rotate",
879 id: P_ROTATE,
880 kind: Kind::F32,
881 apply: Apply::SpecF32(|s, v| s.rotate(v)),
882 doc: "Turns this node and everything under it, in turns clockwise (0.25 is a quarter turn right), about its pivot — the centre unless `pivotX` / `pivotY` say — after layout (`docs/adr/0043-a-node-turns-about-its-pivot.md`). Paint-only: the node takes the room its upright self takes, nothing around it moves, `onLayout` reports the layout rect. Everything the subtree draws turns with it — backgrounds, borders, shadows, text, images, strokes, fragments — and so does what it clips: a child cut by a turned card's rounded corners stays inside them. Hit where drawn: a tilted card is grabbed on its tilted edge and a press in its box past its edge falls through; drag payloads stay in viewport px. The access rect is the bounding box. Nests by composition. Tweens with `transition` as one slot with `scale`, and an entrance, an exit or a keyframe stop may name it (`enter: { rotate: -0.02 }`, `keyframes: [{ rotate: 0 }, { rotate: 1 }]` spins a box). A float anchored to the parent turns with it; a viewport float does not. On a `path` this is the path's own turn (ADR 0041), which does not tween — wrap it in a box for one that does. Text under a turn leaves the pixel grid, as a turned mask does. `backdropBlur` under a turn blurs the upright box.",
883 },
884 PropDef {
885 name: "scale",
886 id: P_SCALE,
887 kind: Kind::F32,
888 apply: Apply::SpecF32(|s, v| s.scale(v)),
889 doc: "Scales this node and everything under it by this factor, uniformly, about its pivot, after layout (`docs/adr/0043-a-node-turns-about-its-pivot.md`); 1 is none. 0 draws nothing in Rust, Node and Lua; in C and Odin, where a zeroed field is unset, 0 is 1. Paint-only, as `rotate` is: layout, the room taken and `onLayout` are the upright node's; what it draws, clips and hits scales. Tweens with `transition` as one slot with `rotate`; `enter: { scale: 0.8 }` settles a chip in, `keyframes: [{ scale: 1.05, at: 0.5 }]` pulses. The edge ramps scale with the box, so a box scaled far up reads soft.",
890 },
891 PropDef {
892 name: "pivotX",
893 id: P_PIVOT_X,
894 kind: Kind::F32,
895 apply: Apply::SpecF32(|s, v| {
896 let fy = s.transform_spec().map_or(0.5, |t| t.pivot.y);
897 s.pivot(v, fy)
898 }),
899 doc: "Where across the box `rotate` and `scale` are about, as a fraction of its width: 0 the left edge, 0.5 (the default) the middle, 1 the right edge; outside 0..1 is a point past the box. C: `pivot_x` with `pivot_set`.",
900 },
901 PropDef {
902 name: "pivotY",
903 id: P_PIVOT_Y,
904 kind: Kind::F32,
905 apply: Apply::SpecF32(|s, v| {
906 let fx = s.transform_spec().map_or(0.5, |t| t.pivot.x);
907 s.pivot(fx, v)
908 }),
909 doc: "Where down the box `rotate` and `scale` are about, as a fraction of its height: 0 the top, 0.5 (the default) the middle, 1 the bottom. C: `pivot_y` with `pivot_set`.",
910 },
911 PropDef {
912 name: "rules",
913 id: P_RULES,
914 kind: Kind::Color,
915 apply: Apply::SpecColor(|s, c| s.rules(c)),
916 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.",
917 },
918 PropDef {
919 name: "ruleWidth",
920 id: P_RULE_WIDTH,
921 kind: Kind::F32,
922 apply: Apply::SpecF32(|s, v| s.rule_width(v)),
923 doc: "The width of a table's `rules` in logical px; 1 when unset.",
924 },
925 PropDef {
926 name: "dropBg",
927 id: P_DROP_BG,
928 kind: Kind::Color,
929 apply: Apply::SpecColor(|s, c| s.drop_bg(c)),
930 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`.",
931 },
932 PropDef {
933 name: "pressedBg",
934 id: P_PRESSED_BG,
935 kind: Kind::Color,
936 apply: Apply::SpecColor(|s, c| s.pressed_bg(c)),
937 doc: "Background while pressed (or while its hoverGroup is); implies hover tracking.",
938 },
939 PropDef {
940 name: "focusBg",
941 id: P_FOCUS_BG,
942 kind: Kind::Color,
943 apply: Apply::SpecColor(|s, c| s.focus_bg(c)),
944 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`.",
945 },
946 PropDef {
947 name: "modal",
948 id: P_MODAL,
949 kind: Kind::Tag,
950 apply: Apply::SpecMsg(|s, v| s.modal(v)),
951 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.",
952 },
953 PropDef {
954 name: "initialFocus",
955 id: P_INITIAL_FOCUS,
956 kind: Kind::Flag,
957 apply: Apply::SpecFlag(|s| s.initial_focus()),
958 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.",
959 },
960 PropDef {
961 name: "hoverGroup",
962 id: P_HOVER_GROUP,
963 kind: Kind::Str,
964 apply: Apply::SpecStr(|s, name| s.hover_group(name)),
965 doc: "Nodes sharing a group name show hoverBg/pressedBg together (a split button, a multi-piece shape).",
966 },
967 PropDef {
968 name: "onHover",
969 id: P_ON_HOVER,
970 kind: Kind::Tag,
971 apply: Apply::SpecMsg(|s, v| s.on_hover(v)),
972 doc: "Hover tag: the pointer entering/leaving emits {kind:\"hover\", phase:\"enter\"|\"leave\", tag} events.",
973 },
974 PropDef {
975 name: "onDrop",
976 id: P_ON_DROP,
977 kind: Kind::Tag,
978 apply: Apply::SpecMsg(|s, v| s.on_drop(v)),
979 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.",
980 },
981 PropDef {
982 name: "onLayout",
983 id: P_ON_LAYOUT,
984 kind: Kind::Tag,
985 apply: Apply::SpecMsg(|s, v| s.on_layout(v)),
986 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).",
987 },
988 PropDef {
989 name: "onClick",
990 id: P_ON_CLICK,
991 kind: Kind::Msg,
992 apply: Apply::SpecMsg(|s, v| s.on_click(v)),
993 doc: "Message emitted when clicked (data, not a callback).",
994 },
995 PropDef {
996 name: "onDrag",
997 id: P_ON_DRAG,
998 kind: Kind::Tag,
999 apply: Apply::SpecMsg(|s, v| s.on_drag(v)),
1000 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.",
1001 },
1002 PropDef {
1003 name: "onKey",
1004 id: P_ON_KEY,
1005 kind: Kind::Tag,
1006 apply: Apply::SpecMsg(|s, v| s.on_key(v)),
1007 doc: "Key-sink tag: with key focus held, presses arrive as {kind:\"key\", phase:\"down\", code, ...} events. Releases only with `keyUp` beside it.",
1008 },
1009 PropDef {
1010 name: "keyUp",
1011 id: P_KEY_UP,
1012 kind: Kind::Flag,
1013 apply: Apply::SpecFlag(|s| s.key_up()),
1014 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.",
1015 },
1016 PropDef {
1017 name: "modifierKeys",
1018 id: P_MODIFIER_KEYS,
1019 kind: Kind::Flag,
1020 apply: Apply::SpecFlag(|s| s.modifier_keys()),
1021 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.",
1022 },
1023 PropDef {
1024 name: "onContextMenu",
1025 id: P_ON_CONTEXT_MENU,
1026 kind: Kind::Tag,
1027 apply: Apply::SpecMsg(|s, v| s.on_context_menu(v)),
1028 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.",
1029 },
1030 PropDef {
1031 name: "onForceClick",
1032 id: P_ON_FORCE_CLICK,
1033 kind: Kind::Tag,
1034 apply: Apply::SpecMsg(|s, v| s.on_force_click(v)),
1035 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.",
1036 },
1037 PropDef {
1038 name: "onButton",
1039 id: P_ON_BUTTON,
1040 kind: Kind::Tag,
1041 apply: Apply::SpecMsg(|s, v| s.on_button(v)),
1042 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.",
1043 },
1044 PropDef {
1045 name: "buttons",
1046 id: P_BUTTONS,
1047 kind: Kind::Str,
1048 apply: Apply::SpecStr(|s, v| s.buttons(crate::input::Buttons::parse(v))),
1049 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`.",
1050 },
1051 PropDef {
1052 name: "onScroll",
1053 id: P_ON_SCROLL,
1054 kind: Kind::Tag,
1055 apply: Apply::SpecMsg(|s, v| s.on_scroll(v)),
1056 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`).",
1057 },
1058 PropDef {
1059 name: "scrollAxes",
1060 id: P_SCROLL_AXES,
1061 kind: Kind::Enum(SCROLL_AXES),
1062 apply: Apply::SpecEnum(|s, i| s.scroll_axes(crate::spec::ScrollAxes::ALL[i])),
1063 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`.",
1064 },
1065 PropDef {
1066 name: "scrollMods",
1067 id: P_SCROLL_MODS,
1068 kind: Kind::Str,
1069 apply: Apply::SpecStr(|s, v| s.scroll_mods(crate::input::KeyMods::parse(v))),
1070 doc: "The modifiers `onScroll` is for (backlog F122): `\"shift\"`, `\"ctrl\"`, `\"alt\"` and `\"super\"` (⌘, the Windows key), separated by spaces or commas — `\"ctrl super\"`. With any named, the node hears only a scroll gesture that began with one of them held, and hears it first: ahead of every scroll container and every `onScroll` that names none, wherever under the pointer the gesture began, the innermost such node winning — so a Ctrl-wheel zoom declared on the window's root is heard over a list, and the list does not scroll. A wheel with none of them held passes the node by, as if it had no `onScroll`: a node that scrolls as well (`overflow`) scrolls for it as any container does. Its `scroll` events carry `mods`, the modifiers held when the gesture began; the gesture stays the node's to the end of its glide, whatever is let go meanwhile, and one begun without them never becomes its. A word that is none of the four is skipped. Unset, a handler like any other. Meaningless without `onScroll`.",
1071 },
1072 PropDef {
1073 name: "window",
1074 id: P_WINDOW,
1075 kind: Kind::Enum(WINDOW_ROLES),
1076 apply: Apply::SpecEnum(|s, i| match i {
1077 0 => s.window_drag(),
1078 1 => s.window_button(WindowButton::Close),
1079 2 => s.window_button(WindowButton::Minimize),
1080 3 => s.window_button(WindowButton::Maximize),
1081 _ => s,
1082 }),
1083 doc: "Window-chrome role: interactions become window commands, not events.",
1084 },
1085 PropDef {
1086 name: "cursor",
1087 id: P_CURSOR,
1088 kind: Kind::Enum(CURSORS),
1089 apply: Apply::SpecEnum(|s, i| s.cursor(cursor_idx(i))),
1090 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.",
1091 },
1092 PropDef {
1093 name: "transition",
1094 id: P_TRANSITION,
1095 kind: Kind::F32,
1096 apply: Apply::SpecF32(|s, v| s.transition(v)),
1097 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).",
1098 },
1099 PropDef {
1100 name: "easing",
1101 id: P_EASING,
1102 kind: Kind::Enum(EASINGS),
1103 apply: Apply::SpecEnum(|s, i| s.easing(easing_idx(i))),
1104 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. Between `keyframes` stops a spring is drawn as `easeOut` (see that row).",
1105 },
1106 PropDef {
1107 name: "bounce",
1108 id: P_BOUNCE,
1109 kind: Kind::F32,
1110 apply: Apply::SpecF32(|s, v| s.bounce(v)),
1111 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. It does nothing between `keyframes` stops, which are sampled off the clock (see that row).",
1112 },
1113 PropDef {
1114 name: "slide",
1115 id: P_SLIDE,
1116 kind: Kind::Flag,
1117 apply: Apply::SpecFlag(|s| s.slide()),
1118 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.",
1119 },
1120 PropDef {
1121 name: "keyframes",
1122 id: P_KEYFRAMES,
1123 kind: Kind::Keyframes,
1124 apply: Apply::SpecKeyframes(|s, k| s.keyframes(k)),
1125 doc: "CSS-style stops `[{ at?, dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }, …]`: the slots they name cycle through them over `transition` ms, for ever unless `iterations` says how many times, without the view redrawing; `at` is 0..1 and spreads evenly when omitted. `dx` / `dy` are logical px from where layout put the node (backlog F132), as an entrance's are: the node and its subtree are drawn and hit that far away at the stop, a lane a stop leaves out is 0, and the offset adds to a `slide`'s, so `[{ dy: 0 }, { dy: -6 }]` with `repeat: 'alternate'` bobs a box and a sparkle drifts up its stops. Paint, hit and access only: layout and the room the node takes are its own place's, and an `onLayout` node reports its layout rect, not the cycle, which would post an event every frame it runs. The `easing` applies to each step between two stops, as CSS applies its timing function per keyframe; a spring easing there is drawn as `easeOut` and `bounce` does nothing, since a cycle is sampled off the clock and a spring has to be integrated (backlog F141) — an overshoot in a cycle is a stop past the target, `[{ scale: 1 }, { scale: 1.15, at: 0.6 }, { scale: 1 }]`.",
1126 },
1127 PropDef {
1128 name: "enter",
1129 id: P_ENTER,
1130 kind: Kind::Enter,
1131 apply: Apply::SpecEnter(|s, e| s.enter(e)),
1132 doc: "Where the node starts the first frame it is seen `{ dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }`: 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, `scale: 0.8` settles it in).",
1133 },
1134 PropDef {
1135 name: "exit",
1136 id: P_EXIT,
1137 kind: Kind::Enter,
1138 apply: Apply::SpecEnter(|s, e| s.exit(e)),
1139 doc: "Where the node ends the frame after the view stops declaring it `{ dx?, dy?, width?, height?, bg?, radius?, opacity?, rotate?, scale? }` — 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. The exit read is the one the last frame that had the node declared, unless `exit_with` named another for the frame it went in: a card a button throws left or right is aimed by the handler that removes it, with no frame drawn first to point it. Needs a stable key across frames.",
1140 },
1141 PropDef {
1142 name: "repeat",
1143 id: P_REPEAT,
1144 kind: Kind::Enum(REPEATS),
1145 apply: Apply::SpecEnum(|s, i| s.repeat(repeat_idx(i))),
1146 doc: "How `keyframes` cycle (CSS `animation-direction`, default normal). Lua: `direction`, since `repeat` is a keyword.",
1147 },
1148 PropDef {
1149 name: "delay",
1150 id: P_DELAY,
1151 kind: Kind::F32,
1152 apply: Apply::SpecF32(|s, v| s.delay(v)),
1153 doc: "Holds the `keyframes` cycle back by this many ms (CSS `animation-delay`); siblings with different delays run out of phase.",
1154 },
1155 PropDef {
1156 name: "iterations",
1157 id: P_ITERATIONS,
1158 kind: Kind::F32,
1159 apply: Apply::SpecF32(|s, v| s.iterations(v)),
1160 doc: "How many times the `keyframes` cycle runs (CSS `animation-iteration-count`, backlog F133); left out, for ever. A finite cycle plays from the first frame the node is declared with it — a node that leaves and comes back plays again — holds its first stop through its `delay`, and rests where its last iteration ended (CSS's fill `both`): `1` plays a burst or a shake once, `2` with `repeat: \"alternate\"` goes out and back and ends where it began, `0.5` stops halfway. Once it is over the node owes no frame, so `animating()` and `owed()` go quiet as a settled transition's do; `delay` plus `iterations: 1` staggers one-shots. A count that is not a positive number is for ever. C: 0 is for ever.",
1161 },
1162 PropDef {
1163 name: "clickSound",
1164 id: P_CLICK_SOUND,
1165 kind: Kind::Resource,
1166 apply: Apply::SpecResource(|s, id| s.click_sound(crate::resources::SoundId::from_ffi(id))),
1167 doc: "A registered sound (addSound) played when the node is clicked; implies hover tracking.",
1168 },
1169 PropDef {
1170 name: "hoverSound",
1171 id: P_HOVER_SOUND,
1172 kind: Kind::Resource,
1173 apply: Apply::SpecResource(|s, id| s.hover_sound(crate::resources::SoundId::from_ffi(id))),
1174 doc: "A registered sound (addSound) played when the pointer enters the node; implies hover tracking.",
1175 },
1176 PropDef {
1177 name: "lineHeight",
1178 id: P_LINE_HEIGHT,
1179 kind: Kind::F32,
1180 apply: Apply::StyleF32(|t, v| t.line_height(v)),
1181 doc: "Line height (logical px); default size * 1.35.",
1182 },
1183 PropDef {
1184 name: "color",
1185 id: P_COLOR,
1186 kind: Kind::Color,
1187 apply: Apply::StyleColor(|t, c| t.color(c)),
1188 doc: "Text color; default foreground when omitted.",
1189 },
1190 PropDef {
1191 name: "family",
1192 id: P_FAMILY,
1193 kind: Kind::Family,
1194 apply: Apply::StyleFamily(|t, f| t.family(f)),
1195 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.",
1196 },
1197 PropDef {
1198 name: "font",
1199 id: P_FONT,
1200 kind: Kind::Resource,
1201 apply: Apply::StyleResource(|t, id| t.font(crate::resources::FontId::from_ffi(id))),
1202 doc: "A registered font handle (addFont / addSystemFont); overrides `family`.",
1203 },
1204 PropDef {
1205 name: "wrap",
1206 id: P_WRAP,
1207 kind: Kind::Enum(WRAPS),
1208 apply: Apply::StyleEnum(|t, i| match i {
1209 1 => t.wrap(TextWrap::Glyph),
1210 2 => t.wrap(TextWrap::None),
1211 3 => t.wrap(TextWrap::BreakSpaces),
1212 _ => t.wrap(TextWrap::Word),
1213 }),
1214 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`).",
1215 },
1216 PropDef {
1217 name: "maxLines",
1218 id: P_MAX_LINES,
1219 kind: Kind::F32,
1220 apply: Apply::StyleF32(|t, v| t.max_lines(v.max(0.0) as u32)),
1221 doc: "Lay out at most this many lines (0 = unlimited); with `ellipsis`, a line clamp.",
1222 },
1223 PropDef {
1224 name: "ellipsis",
1225 id: P_ELLIPSIS,
1226 kind: Kind::Flag,
1227 apply: Apply::StyleFlag(|t| t.ellipsis()),
1228 doc: "End the last line with an ellipsis when the text is cut off: a single line unless `maxLines` says otherwise.",
1229 },
1230 PropDef {
1231 name: "underline",
1232 id: P_UNDERLINE,
1233 kind: Kind::Flag,
1234 apply: Apply::StyleFlag(|t| t.underline()),
1235 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.",
1236 },
1237 PropDef {
1238 name: "underlineColor",
1239 id: P_UNDERLINE_COLOR,
1240 kind: Kind::Color,
1241 apply: Apply::StyleColor(|t, c| t.underline_color(c)),
1242 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.",
1243 },
1244 PropDef {
1245 name: "underlineStyle",
1246 id: P_UNDERLINE_STYLE,
1247 kind: Kind::Enum(UNDERLINE_STYLES),
1248 apply: Apply::StyleEnum(|t, i| t.underline_style(UnderlineStyle::from_index(i as u32))),
1249 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.",
1250 },
1251 PropDef {
1252 name: "strikethrough",
1253 id: P_STRIKETHROUGH,
1254 kind: Kind::Flag,
1255 apply: Apply::StyleFlag(|t| t.strikethrough()),
1256 doc: "A line through the text, where the face puts its strikeout. Paint only; on a `<span>` the span alone, per line.",
1257 },
1258 PropDef {
1259 name: "bold",
1260 id: P_BOLD,
1261 kind: Kind::Flag,
1262 apply: Apply::StyleFlag(|t| t.bold()),
1263 doc: "The family's bold, on a whole text or an editor (backlog F150) — a heading, a table's header, a title field: its bold face, or its regular drawn bold where the family has none, as a `<span bold>` is. A span inside a bold text is bold too.",
1264 },
1265 PropDef {
1266 name: "features",
1267 id: P_FEATURES,
1268 kind: Kind::Str,
1269 apply: Apply::StyleStr(|t, s| t.features(crate::spec::FontFeatures::parse(s))),
1270 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.",
1271 },
1272 PropDef {
1273 name: "role",
1274 id: P_ROLE,
1275 kind: Kind::Enum(ROLES),
1276 apply: Apply::SpecEnum(|s, i| s.role(role_idx(i))),
1277 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 — the decorative door, for what a reader need not hear: an icon beside the text that says the same, or a caption under the button it repeats. A text takes no `role` (it has no box), so `none` goes on the box around it; `ambiguous-name` points at it when a caption and its control share a name (backlog F140). Where the caption is all a control says, a `label` on the control that says what it does is the better answer. 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.",
1278 },
1279 PropDef {
1280 name: "label",
1281 id: P_LABEL,
1282 kind: Kind::Str,
1283 apply: Apply::SpecStr(|s, v| s.label(v)),
1284 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`). Not the key label a `key` prop or `with_keyed` declares, which `key_of` looks up and a reader never hears; `key_named` looks a node up by this name.",
1285 },
1286 PropDef {
1287 name: "description",
1288 id: P_DESCRIPTION,
1289 kind: Kind::Str,
1290 apply: Apply::SpecStr(|s, v| s.description(v)),
1291 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.",
1292 },
1293 PropDef {
1294 name: "checked",
1295 id: P_CHECKED,
1296 kind: Kind::Flag,
1297 apply: Apply::SpecFlag(|s| s.checked(true)),
1298 doc: "The on state of a `checkbox` / `radio` / `switch` role.",
1299 },
1300 PropDef {
1301 name: "mixed",
1302 id: P_MIXED,
1303 kind: Kind::Flag,
1304 apply: Apply::SpecFlag(|s| s.mixed(true)),
1305 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.",
1306 },
1307 PropDef {
1308 name: "selected",
1309 id: P_SELECTED,
1310 kind: Kind::Flag,
1311 apply: Apply::SpecFlag(|s| s.selected(true)),
1312 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.",
1313 },
1314 PropDef {
1315 name: "expanded",
1316 id: P_EXPANDED,
1317 kind: Kind::Enum(EXPANDED),
1318 apply: Apply::SpecEnum(|s, i| s.expanded(i == 1)),
1319 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.",
1320 },
1321 PropDef {
1322 name: "live",
1323 id: P_LIVE,
1324 kind: Kind::Enum(LIVE),
1325 apply: Apply::SpecEnum(|s, i| s.live(crate::access::Live::from_index(i))),
1326 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.",
1327 },
1328 PropDef {
1329 name: "valueNow",
1330 id: P_VALUE_NOW,
1331 kind: Kind::F32,
1332 apply: Apply::SpecF32(|s, v| s.value_now(v)),
1333 doc: "A `slider` role's current value (the drawing stays yours; this is what assistive technology reads).",
1334 },
1335 PropDef {
1336 name: "valueMin",
1337 id: P_VALUE_MIN,
1338 kind: Kind::F32,
1339 apply: Apply::SpecF32(|s, v| s.value_min(v)),
1340 doc: "A `slider` role's minimum.",
1341 },
1342 PropDef {
1343 name: "valueMax",
1344 id: P_VALUE_MAX,
1345 kind: Kind::F32,
1346 apply: Apply::SpecF32(|s, v| s.value_max(v)),
1347 doc: "A `slider` role's maximum.",
1348 },
1349 PropDef {
1350 name: "valueText",
1351 id: P_VALUE_TEXT,
1352 kind: Kind::Str,
1353 apply: Apply::SpecStr(|s, v| s.value_text(v)),
1354 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.",
1355 },
1356 PropDef {
1357 name: "valueStep",
1358 id: P_VALUE_STEP,
1359 kind: Kind::F32,
1360 apply: Apply::SpecF32(|s, v| s.value_step(v)),
1361 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.",
1362 },
1363 PropDef {
1364 name: "onFocus",
1365 id: P_ON_FOCUS,
1366 kind: Kind::Tag,
1367 apply: Apply::SpecMsg(|s, v| s.on_focus(v)),
1368 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.",
1369 },
1370 PropDef {
1371 name: "onChange",
1372 id: P_ON_CHANGE,
1373 kind: Kind::Tag,
1374 apply: Apply::SpecMsg(|s, v| s.on_change(v)),
1375 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.",
1376 },
1377 PropDef {
1378 name: "caret",
1379 id: P_CARET,
1380 kind: Kind::F32,
1381 apply: Apply::SpecF32(|s, v| s.caret(v.max(0.0) as u32)),
1382 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.",
1383 },
1384 PropDef {
1385 name: "selectionAnchor",
1386 id: P_SELECTION_ANCHOR,
1387 kind: Kind::F32,
1388 apply: Apply::SpecF32(|s, v| s.selection_anchor(v.max(0.0) as u32)),
1389 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).",
1390 },
1391 PropDef {
1392 name: "caretSolid",
1393 id: P_CARET_SOLID,
1394 kind: Kind::Flag,
1395 apply: Apply::SpecFlag(|s| s.caret_solid()),
1396 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.",
1397 },
1398];
1399
1400pub struct CustomProp {
1404 pub name: &'static str,
1405 pub id: u32,
1406 pub jsx_names: &'static [&'static str],
1413 pub lua_names: &'static [&'static str],
1415 pub jsx: &'static str,
1417 pub lua: &'static str,
1419 pub c: &'static str,
1421 pub odin: &'static str,
1425 pub doc: &'static str,
1426}
1427
1428pub const CUSTOM: &[CustomProp] = &[
1429 CustomProp {
1430 name: "dir",
1431 id: P_DIR,
1432 jsx_names: &["dir"],
1433 lua_names: &[],
1434 jsx: "`dir=\"row\" | \"column\" | \"table\"`",
1435 lua: "`row { }` / `column { }` / `grid { }`",
1436 c: "`dir` (`KUI_ROW` / `KUI_COLUMN` / `KUI_TABLE`)",
1437 odin: "`Spec.dir` (`.Column` / `.Row` / `.Table`); `kui.row` and `kui.column` set it",
1438 doc: "Main axis; column is the default. `table` is a column whose rows' children line up in columns (the `table` element).",
1439 },
1440 CustomProp {
1441 name: "size",
1442 id: P_SIZE,
1443 jsx_names: &["size"],
1444 lua_names: &["size"],
1445 jsx: "`size` (text)",
1446 lua: "`size`",
1447 c: "`KuiTextStyle.size`; `KuiSpan.size`",
1448 odin: "`Text_Style.size`",
1449 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.",
1450 },
1451 CustomProp {
1452 name: "pad",
1453 id: P_PAD,
1454 jsx_names: &["pad", "padX", "padY", "padL", "padR", "padT", "padB"],
1455 lua_names: &["pad"],
1456 jsx: "`pad`, `padX`, `padY`, `padL`, `padR`, `padT`, `padB`",
1457 lua: "`pad = n` or `pad = { all=, x=, y=, l=, r=, t=, b= }`",
1458 c: "`pad_l`, `pad_r`, `pad_t`, `pad_b`",
1459 odin: "`Spec.pad`: `kui.pad(16)`, `kui.pad(16, 8)`, or `{l = .., r = .., t = .., b = ..}`",
1460 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.",
1461 },
1462 CustomProp {
1463 name: "border",
1464 id: P_BORDER,
1465 jsx_names: &["borderW", "borderColor"],
1466 lua_names: &["border"],
1467 jsx: "`borderW`, `borderColor`",
1468 lua: "`border = { w=, color= }`",
1469 c: "`border_w`, `border_color`",
1470 odin: "`Spec.border_w`, `Spec.border_color`",
1471 doc: "Border width and color (drawn inside the rect).",
1472 },
1473 CustomProp {
1474 name: "overflow",
1475 id: P_OVERFLOW,
1476 jsx_names: &["clip", "scrollX", "scrollY"],
1477 lua_names: &["clip", "scroll", "scroll_x", "scroll_y"],
1478 jsx: "`clip`, `scrollX`, `scrollY`",
1479 lua: "`clip`, `scroll_x`, `scroll_y` (`scroll` = `scroll_y`)",
1480 c: "`overflow` bits `KUI_CLIP` | `KUI_SCROLL_X` | `KUI_SCROLL_Y`",
1481 odin: "`Spec.overflow`: `{.Clip}`, `{.Scroll_X}`, `{.Scroll_Y}`",
1482 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).",
1483 },
1484 CustomProp {
1485 name: "float",
1486 id: P_FLOAT,
1487 jsx_names: &["float"],
1488 lua_names: &["float"],
1489 jsx: "`float=\"below\" | \"above\" | \"parent\" | \"viewport\"` or `{ anchor, at, self, dx, dy, fit, clip }`",
1490 lua: "`float = \"below\"` or `float = { anchor=, at=, self=, dx=, dy=, fit=, clip= }`",
1491 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",
1492 odin: "`Spec.float`, a `Float` (`mode`, `anchor_x/y`, `self_x/y`, `dx/dy`, `fit`, `clip`); `kui.float_preset` fills one from a preset name",
1493 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`, `polygon` or `path` 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`).",
1494 },
1495 CustomProp {
1496 name: "keyFocus",
1497 id: P_KEY_FOCUS,
1498 jsx_names: &["keyFocus"],
1499 lua_names: &["key_focus"],
1500 jsx: "`keyFocus`",
1501 lua: "`key_focus`",
1502 c: "`kui_set_key_focus`",
1503 odin: "`Spec.key_focus`, which calls `kui.set_key_focus` on the node",
1504 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`).",
1505 },
1506 CustomProp {
1507 name: "key",
1508 id: P_KEY,
1509 jsx_names: &["key"],
1510 lua_names: &["key"],
1511 jsx: "`key`",
1512 lua: "`key`",
1513 c: "`kui_open_keyed` label",
1514 odin: "`Spec.key`, which opens the node keyed",
1515 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).",
1516 },
1517 CustomProp {
1518 name: "index",
1519 id: P_INDEX,
1520 jsx_names: &["index"],
1521 lua_names: &["index"],
1522 jsx: "`index`",
1523 lua: "`index`",
1524 c: "`kui_open_indexed`",
1525 odin: "`Spec.index`, a `Maybe(u64)`, which opens the node indexed",
1526 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.",
1527 },
1528 CustomProp {
1529 name: "rowCount",
1530 id: P_ROW_COUNT,
1531 jsx_names: &["rowCount"],
1532 lua_names: &["row_count"],
1533 jsx: "`rowCount`",
1534 lua: "`row_count`",
1535 c: "`kui_row_count`",
1536 odin: "`Spec.row_count`, a `Maybe(u64)`, which calls `kui.row_count` on the node",
1537 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.",
1538 },
1539 CustomProp {
1540 name: "title",
1541 id: P_TITLE,
1542 jsx_names: &["title"],
1543 lua_names: &["window_title"],
1544 jsx: "`title` (root box only)",
1545 lua: "`window_title` (root table)",
1546 c: "`kui_window_title`",
1547 odin: "`kui.window_title`",
1548 doc: "Declares the window title for this frame; the driver diffs and applies.",
1549 },
1550 CustomProp {
1551 name: "alwaysOnTop",
1552 id: P_ALWAYS_ON_TOP,
1553 jsx_names: &["alwaysOnTop"],
1554 lua_names: &["always_on_top"],
1555 jsx: "`alwaysOnTop` (root box only)",
1556 lua: "`always_on_top = true` (root table)",
1557 c: "`kui_set_always_on_top`",
1558 odin: "`kui.set_always_on_top`",
1559 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.",
1560 },
1561 CustomProp {
1562 name: "secureInput",
1563 id: P_SECURE_INPUT,
1564 jsx_names: &["secureInput"],
1565 lua_names: &["secure_input"],
1566 jsx: "`secureInput` (root box only)",
1567 lua: "`secure_input = true` (root table)",
1568 c: "`kui_set_secure_input`",
1569 odin: "`kui.set_secure_input`",
1570 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.",
1571 },
1572 CustomProp {
1573 name: "optionAsAlt",
1574 id: P_OPTION_AS_ALT,
1575 jsx_names: &["optionAsAlt"],
1576 lua_names: &["option_as_alt"],
1577 jsx: "`optionAsAlt=\"left\"` — `\"none\"`, `\"left\"`, `\"right\"`, `\"both\"` (root box only)",
1578 lua: "`option_as_alt = \"left\"` (root table)",
1579 c: "`kui_set_option_as_alt` (`KUI_OPTION_AS_ALT_*`)",
1580 odin: "`kui.set_option_as_alt` (an `Option_As_Alt`)",
1581 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.",
1582 },
1583 CustomProp {
1584 name: "imeOff",
1585 id: P_IME_OFF,
1586 jsx_names: &["imeOff"],
1587 lua_names: &["ime_off"],
1588 jsx: "`imeOff` (root box only)",
1589 lua: "`ime_off = true` (root table)",
1590 c: "`kui_set_ime_off`",
1591 odin: "`kui.set_ime_off`",
1592 doc: "Declares that this window takes the keyboard as keys, with the platform's input method off (backlog F125): no composition and no candidate window, and on a Mac no dead key waiting for the next and no press-and-hold — an input method too, so a held letter repeats instead of opening the accent picker, whatever the user's `ApplePressAndHoldEnabled` says. A `key` event's `text` is still the layout's character; what goes is everything the OS would have composed from it. What a modal editor's normal mode wants — `jjjj` is how one moves, and an IME left on eats the keymap — while its insert mode stops declaring it and gets accents, dead keys and the IME back. Frame state the way `alwaysOnTop` is, default false: declare it on every frame the mode wants it, and the frame that stops gives the input method back; the runner applies it to the window on change, never per frame, and a composition in progress when it turns off ends without a commit, as an empty `preedit`. The window's, not a node's: a stock editor focused under it composes nothing either. A popup's keys arrive through its owner, so the owner's declaration is the one they are read under. On Windows and Linux the window's IME is disabled the same way, and only that: their dead keys are the layout's, and still compose. A C host with its own loop reads the ask with `kui_ime_off_get` and applies it itself.",
1593 },
1594 CustomProp {
1595 name: "windows",
1596 id: P_WINDOWS,
1597 jsx_names: &["windows"],
1598 lua_names: &["windows"],
1599 jsx: "`windows={[{ name, kind?, anchor?, width?, height?, activates? }]}` (root box only; `windows: (model) => [...]` in the loop config)",
1600 lua: "`windows = { { name=, kind=, anchor=, width=, height=, activates= } }` (root table)",
1601 c: "`kui_window_declare`",
1602 odin: "`kui.window_declare`",
1603 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.",
1604 },
1605 CustomProp {
1606 name: "tooltip",
1607 id: P_TOOLTIP,
1608 jsx_names: &["tooltip"],
1609 lua_names: &["tooltip"],
1610 jsx: "`tooltip=\"hint\"`",
1611 lua: "`tooltip = \"hint\"`",
1612 c: "`KuiSpec.tooltip` (`kui_tooltip` / `kui_tooltip_with` draw a hint that is not hover-gated)",
1613 odin: "`Spec.tooltip` (`kui.tooltip` / `kui.tooltip_with` draw a hint that is not hover-gated)",
1614 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. On a box or a `fragment` the float is the node's last child; a leaf holds no children — a `line`, `polygon`, `path`, `cells` grid, `image` or `edit` — and its hint floats beside it instead, anchored to it, and lands below its box the same way, out of every clip and flipping above near the window's bottom (backlog RG113; `PropsOut::for_leaf`). A leaf draws its description, which is the hint unless a `description` applied after it overwrote the slot. A `line`, `polygon` or `path` is hovered by its shape, so its hint shows while the pointer is on the stroke or inside the outline, not anywhere in its box.",
1615 },
1616];
1617
1618pub const C_FIELDS: &[(&str, &str)] = &[
1621 ("width", "`width` (KuiSizing)"),
1622 ("height", "`height` (KuiSizing)"),
1623 ("center", "`main_align` + `cross_align` = `KUI_CENTER`"),
1624 ("window", "`window_role` (`KUI_WINDOW_*`)"),
1625 ("transition", "`transition_ms`"),
1626 ("easing", "`easing` (`KUI_EASE_*`)"),
1627 (
1628 "keyframes",
1629 "`keyframes` + `keyframes_len` (`KuiKeyframe[]`)",
1630 ),
1631 ("gradient", "`gradient` (`const KuiGradient *`)"),
1632 ("repeat", "`repeat` (`KUI_REPEAT_*`)"),
1633 ("delay", "`delay_ms`"),
1634 ("iterations", "`iterations` (0 is for ever)"),
1635 ("enter", "`enter` (`KuiEnter`, with `set` bits)"),
1636 ("exit", "`exit` (`KuiEnter`, with `set` bits)"),
1637 ("opacity", "`opacity` with `opacity_set`"),
1638 ("scale", "`scale` (0 is 1)"),
1639 ("pivotX", "`pivot_x` with `pivot_set`"),
1640 ("pivotY", "`pivot_y` with `pivot_set`"),
1641 ("radiusTL", "`radius_tl` with `per_corner`"),
1642 ("radiusTR", "`radius_tr` with `per_corner`"),
1643 ("radiusBR", "`radius_br` with `per_corner`"),
1644 ("radiusBL", "`radius_bl` with `per_corner`"),
1645 ("hoverGroup", "`hover_group` (KuiStr)"),
1646 ("role", "`role` (`KUI_ROLE_*`)"),
1647 ("expanded", "`expanded` (`KUI_EXPANDED_*`)"),
1648 ("live", "`live` (`KUI_LIVE_*`)"),
1649 ("cursor", "`cursor` (`KUI_CURSOR_*`)"),
1650 ("label", "`label` (KuiStr)"),
1651 (
1652 "valueNow",
1653 "`value_now` with `KUI_VALUE_NOW` in `value_set`",
1654 ),
1655 (
1656 "valueMin",
1657 "`value_min` with `KUI_VALUE_MIN` in `value_set`",
1658 ),
1659 (
1660 "valueMax",
1661 "`value_max` with `KUI_VALUE_MAX` in `value_set`",
1662 ),
1663 ("valueText", "`value_text` (KuiStr)"),
1664 ("caret", "`caret` with `KUI_VALUE_CARET` in `value_set`"),
1665 (
1666 "selectionAnchor",
1667 "`selection_anchor` with `KUI_VALUE_ANCHOR` in `value_set`",
1668 ),
1669 (
1670 "onClick",
1671 "`on_click` argument of `kui_open` / `kui_open_with`",
1672 ),
1673 (
1674 "onDrag",
1675 "`on_drag` argument of `kui_open_draggable` / `kui_open_with`",
1676 ),
1677 ("onKey", "`on_key` argument of `kui_open_with`"),
1678 (
1679 "onContextMenu",
1680 "`on_context_menu` (a borrowed `KuiValue*`, cloned while the node opens)",
1681 ),
1682 (
1683 "onButton",
1684 "`on_button` (a borrowed `KuiValue*`, cloned while the node opens)",
1685 ),
1686 (
1687 "buttons",
1688 "`buttons` (`KUI_BUTTONS_*` bits; zeroed, all three)",
1689 ),
1690 (
1691 "modal",
1692 "`modal` (a borrowed `KuiValue*`, cloned while the node opens)",
1693 ),
1694 (
1695 "onScroll",
1696 "`on_scroll` (a borrowed `KuiValue*`, cloned while the node opens)",
1697 ),
1698 ("onHover", "`on_hover` argument of `kui_open_with`"),
1699 (
1700 "onFocus",
1701 "`on_focus` (a borrowed `KuiValue*`, cloned while the node opens)",
1702 ),
1703 ("ruleWidth", "`rule_w`"),
1704 (
1705 "overscroll",
1706 "`overscroll` (`KUI_OVERSCROLL_*`; zeroed, auto)",
1707 ),
1708 (
1709 "scrollAxes",
1710 "`scroll_axes` (`KUI_SCROLL_AXES_*`; zeroed, both)",
1711 ),
1712 (
1713 "scrollMods",
1714 "`scroll_mods` (`KUI_KMOD_*` bits; zeroed, none)",
1715 ),
1716 (
1717 "onDrop",
1718 "`on_drop` (a borrowed `KuiValue*`, cloned while the node opens)",
1719 ),
1720 (
1721 "onLayout",
1722 "`on_layout` (a borrowed `KuiValue*`, cloned while the node opens)",
1723 ),
1724 (
1725 "family",
1726 "`KuiTextStyle.family` (`KUI_FONT_*`); `KuiSpan.family` (with `KUI_SPAN_FAMILY`)",
1727 ),
1728 (
1729 "font",
1730 "`KuiTextStyle.font` (from `kui_font_add*`); `KuiSpan.font`",
1731 ),
1732 ("lineHeight", "`KuiTextStyle.line_height`"),
1733 ("wrap", "`KuiTextStyle.wrap` (`KUI_WRAP_*`)"),
1734 ("maxLines", "`KuiTextStyle.max_lines`"),
1735 ("ellipsis", "`KuiTextStyle.ellipsis`"),
1736 (
1737 "features",
1738 "`KuiTextStyle.features` (a `KuiStr`, the same spelling)",
1739 ),
1740 (
1741 "underline",
1742 "`KuiTextStyle.decoration` (`KUI_DECO_UNDERLINE`); `KuiSpan.flags` (`KUI_SPAN_UNDERLINE`)",
1743 ),
1744 (
1745 "strikethrough",
1746 "`KuiTextStyle.decoration` (`KUI_DECO_STRIKETHROUGH`); `KuiSpan.flags` (`KUI_SPAN_STRIKETHROUGH`)",
1747 ),
1748 (
1749 "bold",
1750 "`KuiTextStyle.bold`; `KuiSpan.flags` (`KUI_SPAN_BOLD`)",
1751 ),
1752 (
1753 "underlineColor",
1754 "`KuiTextStyle.underline_color`; `KuiSpan.underline_color`",
1755 ),
1756 (
1757 "underlineStyle",
1758 "`KuiTextStyle.underline_style` (`KUI_UNDERLINE_*`); `KuiSpan.underline_style`",
1759 ),
1760 ("color", "`KuiTextStyle.color`"),
1761];
1762
1763pub fn c_field(def: &PropDef) -> String {
1765 C_FIELDS
1766 .iter()
1767 .find(|(n, _)| *n == def.name)
1768 .map(|(_, c)| (*c).to_string())
1769 .unwrap_or_else(|| format!("`{}`", def.snake_name()))
1770}
1771
1772pub fn odin_field(def: &PropDef) -> String {
1775 let record = match def.target() {
1776 Target::Spec => "Spec",
1777 Target::Style => "Text_Style",
1778 };
1779 format!("`{record}.{}`", def.snake_name())
1780}
1781
1782pub fn odin_from_c(c: &str) -> String {
1788 c.replace("kui_", "kui.")
1789 .replace("KuiSpec.", "Spec.")
1790 .replace("KuiTextStyle.", "Text_Style.")
1791}
1792
1793pub struct ElementDef {
1798 pub name: &'static str,
1799 pub jsx_own: &'static [&'static str],
1805 pub lua_own: &'static [&'static str],
1807 pub jsx_rows: Option<&'static [&'static str]>,
1815 pub lua_rows: Option<&'static [&'static str]>,
1816 pub jsx: &'static str,
1817 pub lua: &'static str,
1818 pub c: &'static str,
1819 pub odin: &'static str,
1822 pub doc: &'static str,
1823}
1824
1825pub const TEXT_ROWS_JSX: &[&str] = &[
1836 "size",
1837 "lineHeight",
1838 "color",
1839 "family",
1840 "font",
1841 "wrap",
1842 "maxLines",
1843 "ellipsis",
1844 "underline",
1845 "underlineColor",
1846 "underlineStyle",
1847 "strikethrough",
1848 "bold",
1849 "features",
1850];
1851pub const TEXT_ROWS_LUA: &[&str] = &[
1852 "size",
1853 "line_height",
1854 "color",
1855 "family",
1856 "font",
1857 "wrap",
1858 "max_lines",
1859 "ellipsis",
1860 "underline",
1861 "underline_color",
1862 "underline_style",
1863 "strikethrough",
1864 "bold",
1865 "features",
1866];
1867
1868pub const BUTTON_ROWS_JSX: &[&str] = &[
1876 "onClick",
1877 "key",
1878 "index",
1879 "label",
1880 "description",
1881 "tooltip",
1882 "disabled",
1883 "accent",
1884];
1885pub const BUTTON_ROWS_LUA: &[&str] = &[
1886 "on_click",
1887 "key",
1888 "index",
1889 "label",
1890 "description",
1891 "tooltip",
1892 "disabled",
1893 "accent",
1894];
1895
1896pub const TOGGLE_ROWS_JSX: &[&str] = &[
1901 "onClick",
1902 "key",
1903 "label",
1904 "description",
1905 "tooltip",
1906 "disabled",
1907 "checked",
1908 "mixed",
1909];
1910pub const TOGGLE_ROWS_LUA: &[&str] = &[
1911 "on_click",
1912 "key",
1913 "label",
1914 "description",
1915 "tooltip",
1916 "disabled",
1917 "checked",
1918 "mixed",
1919];
1920pub const SLIDER_ROWS_JSX: &[&str] = &[
1924 "key",
1925 "label",
1926 "description",
1927 "tooltip",
1928 "disabled",
1929 "valueNow",
1930 "valueMin",
1931 "valueMax",
1932 "valueStep",
1933 "valueText",
1934 "onChange",
1935 "width",
1936 "minWidth",
1937 "maxWidth",
1938];
1939pub const SLIDER_ROWS_LUA: &[&str] = &[
1940 "key",
1941 "label",
1942 "description",
1943 "tooltip",
1944 "disabled",
1945 "value_now",
1946 "value_min",
1947 "value_max",
1948 "value_step",
1949 "value_text",
1950 "on_change",
1951 "width",
1952 "min_width",
1953 "max_width",
1954];
1955
1956pub const ELEMENTS: &[ElementDef] = &[
1957 ElementDef {
1958 name: "box",
1959 jsx_own: &[],
1960 lua_own: &[],
1961 jsx_rows: None,
1962 lua_rows: None,
1963 jsx: "`<box>`",
1964 lua: "`row { }`, `column { }`",
1965 c: "`kui_open*` … `kui_close`",
1966 odin: "`kui.box` / `kui.row` / `kui.column` in an `if`, closed at its end; `kui.open` … `kui.close` unscoped",
1967 doc: "A container: every container prop applies.",
1968 },
1969 ElementDef {
1970 name: "table",
1971 jsx_own: &[],
1972 lua_own: &[],
1973 jsx_rows: None,
1974 lua_rows: None,
1975 jsx: "`<box dir=\"table\">`",
1976 lua: "`grid { }`",
1977 c: "`kui_open*` with `dir = KUI_TABLE`",
1978 odin: "`kui.box` with `dir = .Table`",
1979 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.",
1980 },
1981 ElementDef {
1982 name: "text",
1983 jsx_own: &["bold", "italic", "bg", "bgRadius"],
1984 lua_own: &["value", "spans"],
1985 jsx_rows: Some(TEXT_ROWS_JSX),
1986 lua_rows: Some(TEXT_ROWS_LUA),
1987 jsx: "`<text>` with `<span bold italic underline strikethrough bg bgRadius color family font size>` children",
1988 lua: "`text(\"s\", {…})`, `text({ \"a\", { \"b\", bold = true, underline = true, bg = 0x.., bg_radius = 4 }, { \"code\", family = \"mono\", size = 13 } })`",
1989 c: "`kui_text`, `kui_rich_text`",
1990 odin: "`kui.text`, `kui.rich_text`",
1991 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 span takes a face and a size of its own: `family` (a stock name or an installed family's, as the text's) or `font` (a handle, which wins) — inline code in `mono` inside a sans paragraph — and `size` in logical px, its line height scaled at the paragraph's ratio (`Span::family` / `Span::mono` / `Span::size` in Rust, `KuiSpan.family` with `KUI_SPAN_FAMILY`, `.font` and `.size` in C). Its glyphs are shaped in that face, so a caret, a hit, a selection and the measurement read the same glyphs and byte positions stay exact across the change; a line is as tall as its tallest span, and a span's background is its own height around its glyphs. A paragraph with a sized span is shaped whole, never in chunks. 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.",
1992 },
1993 ElementDef {
1994 name: "button",
1995 jsx_own: &[],
1996 lua_own: &["text"],
1997 jsx_rows: Some(BUTTON_ROWS_JSX),
1998 lua_rows: Some(BUTTON_ROWS_LUA),
1999 jsx: "`<button onClick key|index label description tooltip disabled accent>`",
2000 lua: "`button { label=, on_click=, key= | index=, text=, description=, tooltip=, disabled=, accent= }`",
2001 c: "`kui_button`, `kui_button_with`",
2002 odin: "`kui.button`",
2003 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.",
2004 },
2005 ElementDef {
2006 name: "edit",
2007 jsx_own: &[
2008 "id",
2009 "initial",
2010 "multiline",
2011 "autofocus",
2012 "keepTab",
2013 "placeholder",
2014 ],
2015 lua_own: &[
2016 "initial",
2017 "multiline",
2018 "autofocus",
2019 "keep_tab",
2020 "placeholder",
2021 ],
2022 jsx_rows: None,
2023 lua_rows: None,
2024 jsx: "`<edit key initial multiline autofocus keepTab placeholder>`, `<input label initial>`",
2025 lua: "`edit { key=, initial=, keep_tab=, placeholder=, … }`, `input { label=, initial= }`",
2026 c: "`kui_text_edit`, `kui_text_edit_placeholder`, `kui_text_input`",
2027 odin: "`kui.text_edit`, `kui.text_input`",
2028 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. A focused editor keeps the keys it acts on — the editing keys, what it types, the clipboard and undo chords — and a chord it does not (⌘N, Ctrl+K) goes to the nearest `onKey` sink above it, as a chord bubbles from a control (`docs/adr/0011-keys-bubble-to-the-enclosing-sink.md`, amended 2026-10-09). `keepTab` makes a field's Tab the app's: Tab and Shift-Tab do not walk the focus ring from it, and the press goes to the sink above it the same way, so a list or an outline built from fields indents on Tab; a `multiline` editor keeps Tab for indentation either way. An arrow, Backspace or Delete that meets the edge of the text emits `boundary` (see the events). `placeholder` is what an empty editor shows where its text would be, in the theme's `faint` — never part of the value, gone with the first character typed or composed, and read as the field's accessible description when it declares none.",
2029 },
2030 ElementDef {
2031 name: "select",
2032 jsx_own: &["label", "options", "current"],
2033 lua_own: &["label", "options", "current"],
2034 jsx_rows: Some(&[]),
2037 lua_rows: Some(&[]),
2038 jsx: "`<select label options={[…]} current>`",
2039 lua: "`dropdown { label=, options={…}, current= }`",
2040 c: "`kui_select`",
2041 odin: "`kui.select`",
2042 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.",
2043 },
2044 ElementDef {
2045 name: "checkbox",
2046 jsx_own: &[],
2047 lua_own: &["text"],
2048 jsx_rows: Some(TOGGLE_ROWS_JSX),
2049 lua_rows: Some(TOGGLE_ROWS_LUA),
2050 jsx: "`<checkbox checked mixed onClick key label description tooltip disabled>text</checkbox>`",
2051 lua: "`checkbox { label=, checked=, mixed=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
2052 c: "`kui_checkbox`",
2053 odin: "`kui.checkbox`",
2054 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.",
2055 },
2056 ElementDef {
2057 name: "radio",
2058 jsx_own: &[],
2059 lua_own: &["text"],
2060 jsx_rows: Some(TOGGLE_ROWS_JSX),
2061 lua_rows: Some(TOGGLE_ROWS_LUA),
2062 jsx: "`<radio checked onClick key label description tooltip disabled>text</radio>`",
2063 lua: "`radio { label=, checked=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
2064 c: "`kui_radio`",
2065 odin: "`kui.radio`",
2066 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.",
2067 },
2068 ElementDef {
2069 name: "radioGroup",
2070 jsx_own: &[],
2071 lua_own: &[],
2072 jsx_rows: None,
2073 lua_rows: None,
2074 jsx: "`<radioGroup label>…radios…</radioGroup>`",
2075 lua: "`radio_group { label=, … }`",
2076 c: "`kui_radio_group_open` … `kui_close`",
2077 odin: "`kui.radio_group` in an `if`, closed at its end",
2078 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.",
2079 },
2080 ElementDef {
2081 name: "switch",
2082 jsx_own: &[],
2083 lua_own: &["text"],
2084 jsx_rows: Some(TOGGLE_ROWS_JSX),
2085 lua_rows: Some(TOGGLE_ROWS_LUA),
2086 jsx: "`<switch checked onClick key label description tooltip disabled>text</switch>`",
2087 lua: "`switch { label=, checked=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
2088 c: "`kui_switch`",
2089 odin: "`kui.toggle`",
2090 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.",
2091 },
2092 ElementDef {
2093 name: "slider",
2094 jsx_own: &[],
2095 lua_own: &[],
2096 jsx_rows: Some(SLIDER_ROWS_JSX),
2097 lua_rows: Some(SLIDER_ROWS_LUA),
2098 jsx: "`<slider label valueNow valueMin valueMax valueStep valueText onChange width description tooltip disabled/>`",
2099 lua: "`slider { label=, value_now=, value_min=, value_max=, value_step=, value_text=, on_change=, width=, … }`",
2100 c: "`kui_slider`",
2101 odin: "`kui.slider`",
2102 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.",
2103 },
2104 ElementDef {
2105 name: "image",
2106 jsx_own: &["src", "sampling", "fit"],
2107 lua_own: &["id", "sampling", "fit"],
2108 jsx_rows: None,
2109 lua_rows: None,
2110 jsx: "`<image src={id} sampling fit>`",
2111 lua: "`image { id=, sampling=, fit= }`",
2112 c: "`kui_image`, `kui_image_with`",
2113 odin: "`kui.image`, `kui.image_with`",
2114 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. Drawn at less than half its texels a pixel, a `linear` image is drawn from a level the core halved it to (`docs/adr/0044-an-image-drawn-smaller-is-drawn-from-a-level.md`): the deepest level with at least a texel a pixel, the 2×2 means taken in linear light, made once per session at the first draw that wants it and kept in the atlas in place of the whole image — so a photo registered as decoded and shown on a card is neither resampled by the app nor aliased on screen. The display's scale and any `scale` the node is drawn through count. `nearest` and an image ever updated (a stream) draw the whole image.",
2115 },
2116 ElementDef {
2117 name: "polygon",
2118 jsx_own: &["points"],
2120 lua_own: &["points"],
2121 jsx_rows: None,
2122 lua_rows: None,
2123 jsx: "`<polygon points={[[x,y],…]} bg/>`",
2124 lua: "`polygon { points={{x,y},…}, bg= }`",
2125 c: "`kui_polygon`",
2126 odin: "`kui.polygon`, its points a `[][2]f32`",
2127 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, and painted in the parent's layer at its place in the tree, over the siblings before it and under those after (backlog F123). 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.",
2128 },
2129 ElementDef {
2130 name: "path",
2131 jsx_own: &["d", "fillRule", "rotate", "pivot", "dash", "dashOffset"],
2135 lua_own: &[
2136 "d",
2137 "ops",
2138 "fill_rule",
2139 "rotate",
2140 "pivot",
2141 "dash",
2142 "dash_offset",
2143 ],
2144 jsx_rows: None,
2145 lua_rows: None,
2146 jsx: "`<path d=\"M … Z\" bg width color fillRule rotate pivot dash dashOffset/>`",
2147 lua: "`path { d = \"M … Z\", bg=, width=, color=, fill_rule=, rotate=, pivot=, dash=, dash_offset= }`",
2148 c: "`kui_path`",
2149 odin: "`kui.path`, `kui.path_d`",
2150 doc: "Any outline — SVG's `d`, a pie wedge with a round arc, a map's region, an icon — filled with `bg` by `fillRule` (`nonzero`, the default, or `evenodd`) and stroked `width` wide in `color` when `width` is given, the stroke over the fill (`docs/adr/0040-a-path-is-a-mask-in-the-atlas.md`). `d` is SVG path data (`M L H V C S Q T A Z`, absolute or relative), parsed by one parser in the core, so every binding draws the same shape; one that does not parse raises `path-malformed` and draws nothing. JSX also takes `d` as a flat number array of op codes and operands, Lua the same as `ops`, and C only that form (`kui_path_parse` turns a string into it). 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 two pixels out on each side (half the stroke's width further), so it takes no room in a row or column, held by the parent's clip as a child is and painted in the parent's layer at its place in the tree (backlog F123). `transition` eases the fill and, with `slide`, its position; the stroke's colour does not tween, as a box's border does not. Hit by its outline under the fill rule (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press inside hits it and one in its box past the outline falls through, so a pie's wedges need no hit boxes; a stroke with no fill is hit by its stroke as a line is; with input it derives an access row as a box would (a clickable wedge is a button), so name it. On the wire it is one glyph-mask quad per paint, fill and stroke: the outline is rasterized once per shape, scale and quarter-pixel position into the glyph atlas and tinted like a glyph, so a host that draws text draws paths, and nothing is re-rasterized for a colour tween, a hover or a slide. The fill bleeds half a pixel, so two paths sharing an edge meet without the background showing through; a chart that wants separators gaps its own geometry. A mask a quarter of the biggest atlas page or more, or a path whose ops change twice within a few frames, draws from a texture of its own instead (a `texture` quad), and one past 8192 px on a side draws nothing, with `path-too-large`. `rotate` turns the path, in turns clockwise, about `pivot` — a point in the path's own coordinates, the centre of its box without one (`docs/adr/0041-a-mask-turns-about-its-centre.md`): the turn is the quad's and not the mask's, so a path that only turns — a spinner's arc about its circle's centre — is rasterized once and stays in the atlas at every angle. A path with `rotate` or `pivot` is boxed by the square the turn sweeps, its mask centred on the pivot on a whole pixel, and it is hit where it is drawn; `rotate` does not tween. `dash` and `dashOffset` cut the stroke into marks and gaps as a `line`'s do (backlog V2) — lengths as seen, round-capped marks, a gap the dots overlap closed — restarting at every subpath as SVG's do; the pattern is part of the stroke's mask, so a dashed stroke costs what a solid one does, a stroke with no fill is still hit along its gaps, and a `dashOffset` that changes every frame is a shape that changes every frame: the path leaves the atlas for a texture of its own while it marches.",
2151 },
2152 ElementDef {
2153 name: "fragment",
2154 jsx_own: &["src", "image", "params", "animate"],
2155 lua_own: &["id", "image", "params", "animate"],
2156 jsx_rows: None,
2157 lua_rows: None,
2158 jsx: "`<fragment src={id} image={id} params={[…]} animate>`",
2159 lua: "`fragment { id=, image=, params={…}, animate= }`",
2160 c: "`kui_fragment`, `kui_fragment_with`",
2161 odin: "`kui.fragment`, `kui.fragment_with`; `kui.fragment_open` in an `if` holds children",
2162 doc: "A box a registered WGSL function paints (`docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md`): a conic or a moving gradient, 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.",
2163 },
2164 ElementDef {
2165 name: "cells",
2166 jsx_own: &[
2167 "rows",
2168 "cols",
2169 "cells",
2170 "cursorAt",
2171 "cursorShape",
2172 "cursorColor",
2173 "originLine",
2174 ],
2175 lua_own: &[
2176 "rows",
2177 "cols",
2178 "lines",
2179 "runs",
2180 "cursor_at",
2181 "cursor_shape",
2182 "cursor_color",
2183 "origin_line",
2184 ],
2185 jsx_rows: None,
2186 lua_rows: None,
2187 jsx: "`<cells rows cols cells={Uint32Array} cursorAt={[row, col]} cursorShape cursorColor size family lineHeight/>`",
2188 lua: "`cells { rows=, cols=, lines={\"row text\", …}, runs={{row, col, len, fg, bg, flags}, …}, cursor_at={row, col}, cursor_shape=, cursor_color=, size=, family= }`",
2189 c: "`kui_cells`",
2190 odin: "`kui.cells`",
2191 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. A character the family has no glyph for is asked of a monospaced face before the platform's fallback list, shaped smaller where it is still wider than its cells (two under wide), and drawn in their middle (backlog F120); the private use area's icons are left as they fall. 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.",
2192 },
2193 ElementDef {
2194 name: "line",
2195 jsx_own: &["from", "to", "points", "curve", "dash", "dashOffset"],
2198 lua_own: &["from", "to", "points", "curve", "dash", "dash_offset"],
2199 jsx_rows: None,
2200 lua_rows: None,
2201 jsx: "`<line from={[x,y]} to={[x,y]} width color/>`, `<line points={[[x,y],…]} curve dash={[6, 4]} dashOffset/>`",
2202 lua: "`line { from={x,y}, to={x,y}, width=, color= }`, `line { points={{x,y},…}, curve=true, dash={6, 4}, dash_offset= }`",
2203 c: "`kui_line`, `kui_polyline`",
2204 odin: "`kui.line`, `kui.polyline` (points, `[][2]f32`)",
2205 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 — but a float for the room alone: in its parent's box space it paints in the parent's layer at its place in the tree, over the siblings declared before it and under those after, as a child does, and opens no layer of its own (backlog F123; a connector meant to sit under two cards is declared before them). 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, and is a layer of its own (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. `dash` cuts the stroke into marks and gaps (backlog V2): one length (marks and gaps alike), a mark and a gap, or four lengths for a dash-dot, in px **as seen** — every mark is a short stroke with the stroke's round caps, so `dash` 6, 4 is 6 px of ink and 4 px of nothing at any width up to 6 (SVG's `stroke-dasharray` measures the centre line instead, so with round caps its `4 4` at a width of 4 is solid; this pattern is SVG's `mark − width, gap + width`). A mark no longer than the stroke is wide is a dot as wide as the stroke, in the same period, so its gap is that much shorter; where a mark and its gap together come to no more than the width the dots meet and the gap closes — the marks either side of it are one, and a pattern with no gap left, `dash` 2, 2 at a width of 8, draws solid (backlog RG118). The pattern runs along the stroke's whole length, so it keeps its phase round the corners of a polyline and the pieces of a curve, and `dashOffset` starts that far into it — growing it moves the marks towards the first point, a marquee's marching ants; neither tweens. A pattern with no gap, a mark and gap under a physical pixel together, or more than 16384 marks draws solid. A dashed stroke is hit along its whole length, gaps included, and costs a quad per mark per piece the mark lies on.",
2206 },
2207 ElementDef {
2208 name: "titlebar",
2209 jsx_own: &[],
2210 lua_own: &["title"],
2213 jsx_rows: None,
2214 lua_rows: None,
2215 jsx: "`<titlebar title>` or `<titlebar>…</titlebar>`",
2216 lua: "`titlebar { title= }` / `titlebar { … }`",
2217 c: "`kui_titlebar`, `kui_titlebar_with`",
2218 odin: "`kui.titlebar`, `kui.titlebar_with`",
2219 doc: "Adaptive titlebar for custom chrome: drag strip, native-control inset, window buttons.",
2220 },
2221 ElementDef {
2222 name: "menuBar",
2223 jsx_own: &["menu"],
2224 lua_own: &["menu"],
2225 jsx_rows: None,
2226 lua_rows: None,
2227 jsx: "`<menuBar menu={[{ label, items: [{ label, id?, role?, accel?, enabled?, checked?, items? }] }]}/>`",
2228 lua: "`menu_bar { menu = { { label=, items= { { label=, id=, role=, accel=, enabled=, checked=, items= } } } } }`",
2229 c: "`kui_menu_bar`",
2230 odin: "`kui.menu_bar`",
2231 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.",
2232 },
2233 ElementDef {
2234 name: "windowButtons",
2235 jsx_own: &[],
2236 lua_own: &[],
2237 jsx_rows: None,
2238 lua_rows: None,
2239 jsx: "`<windowButtons/>`",
2240 lua: "`window_buttons()`",
2241 c: "`kui_window_buttons`",
2242 odin: "`kui.window_buttons`",
2243 doc: "Just the min/max/close buttons, for fully custom titlebars.",
2244 },
2245 ElementDef {
2246 name: "tooltip",
2247 jsx_own: &["value"],
2248 lua_own: &["value"],
2249 jsx_rows: None,
2250 lua_rows: None,
2251 jsx: "`<tooltip value=\"hint\"/>` / `<tooltip>…</tooltip>` nodes, or the `tooltip=\"hint\"` prop (see composites)",
2252 lua: "`tooltip(\"hint\")` / `tooltip { … }` nodes, or the prop",
2253 c: "`kui_tooltip`, `kui_tooltip_with`",
2254 odin: "`kui.tooltip`, `kui.tooltip_with`",
2255 doc: "A float hanging below the parent; the node form always draws, the prop form is hover-gated.",
2256 },
2257 ElementDef {
2258 name: "latencyGraph",
2259 jsx_own: &["at"],
2260 lua_own: &["at"],
2261 jsx_rows: None,
2262 lua_rows: None,
2263 jsx: "`<latencyGraph/>`, `<latencyHud at/>`",
2264 lua: "`latency_graph()`, `latency_hud { at= }`",
2265 c: "`kui_latency_graph`, `kui_latency_hud`",
2266 odin: "`kui.latency_graph`, `kui.latency_hud`",
2267 doc: "Per-phase frame timing (windowed drivers fill it; headless shows the chrome empty).",
2268 },
2269 ElementDef {
2270 name: "audio",
2271 jsx_own: &["src", "loop", "volume", "paused", "finish", "tag"],
2272 lua_own: &["src", "loop", "volume", "paused", "finish", "tag"],
2273 jsx_rows: None,
2274 lua_rows: None,
2275 jsx: "`<audio src={id} loop volume paused finish tag/>`",
2276 lua: "`audio { src=, loop=, volume=, paused=, finish=, tag= }`",
2277 c: "`kui_audio`",
2278 odin: "`kui.audio`",
2279 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.",
2280 },
2281];
2282
2283pub struct EventDef {
2285 pub kind: &'static str,
2286 pub payload: &'static str,
2287 pub doc: &'static str,
2288}
2289
2290pub const EVENTS: &[EventDef] = &[
2291 EventDef {
2292 kind: "click",
2293 payload: "the `onClick` payload as-is",
2294 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`, `inside` and `clicks` as a `drag` inside one carries them.",
2295 },
2296 EventDef {
2297 kind: "drag",
2298 payload: "`{ kind: \"drag\", phase: \"start\" | \"move\" | \"end\", x, y, dx, dy, parent: { x, y, w, h }, tag }`",
2299 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, and `inside` says whether the point is within that line's own box at all — false above, below and in the margin beside a row, so a margin gesture (block selection) is told from a press on the text without comparing `x` to a rect. Nothing is added where the sink draws no lines.",
2300 },
2301 EventDef {
2302 kind: "key",
2303 payload: "`{ kind: \"key\", phase: \"down\" | \"up\", code, physical, shift, ctrl, alt, super, text, repeat, location, caps_lock, num_lock, tag }`",
2304 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`.",
2305 },
2306 EventDef {
2307 kind: "text",
2308 payload: "`{ kind: \"text\", text, pasted?: true, concealed?: true, transient?: true, tag }`",
2309 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.",
2310 },
2311 EventDef {
2312 kind: "preedit",
2313 payload: "`{ kind: \"preedit\", text, cursor: [start, end] | null, tag }`",
2314 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.",
2315 },
2316 EventDef {
2317 kind: "selectionrange",
2318 payload: "`{ kind: \"selectionrange\", from: { index, byte }, to: { index, byte } }` on the scope",
2319 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.",
2320 },
2321 EventDef {
2322 kind: "contextmenu",
2323 payload: "`{ kind: \"contextmenu\", x, y, tag }`",
2324 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`.",
2325 },
2326 EventDef {
2327 kind: "menu",
2328 payload: "`{ kind: \"menu\", role, item }`",
2329 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).",
2330 },
2331 EventDef {
2332 kind: "forceclick",
2333 payload: "`{ kind: \"forceclick\", x, y, tag }`",
2334 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.",
2335 },
2336 EventDef {
2337 kind: "button",
2338 payload: "`{ kind: \"button\", phase: \"press\" | \"move\" | \"release\", button: \"secondary\" | \"middle\" | number, x, y, clicks, cell?: { row, col }, line?, byte?, inside?, tag }`",
2339 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`, `byte` and `inside` as a drag does. A claimed secondary press is this event instead of `contextmenu`; the press moves no focus, caret, selection or scrollbar.",
2340 },
2341 EventDef {
2342 kind: "scroll",
2343 payload: "`{ kind: \"scroll\", x, y, dx, dy, lines, mods?: { shift, ctrl, alt, super }, tag }`",
2344 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`).",
2345 },
2346 EventDef {
2347 kind: "focus",
2348 payload: "`{ kind: \"focus\", phase: \"in\" | \"out\", by: \"pointer\" | \"keyboard\" | \"assistive\" | \"program\", tag }`",
2349 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.",
2350 },
2351 EventDef {
2352 kind: "hover",
2353 payload: "`{ kind: \"hover\", phase: \"enter\" | \"leave\", by: \"pointer\" | \"content\", tag }`",
2354 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.",
2355 },
2356 EventDef {
2357 kind: "drop",
2358 payload: "`{ kind: \"drop\", phase: \"enter\" | \"move\" | \"leave\" | \"drop\", paths: string[], x, y, tag }`",
2359 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.",
2360 },
2361 EventDef {
2362 kind: "files",
2363 payload: "`{ kind: \"files\", paths: string[], tag }`",
2364 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.",
2365 },
2366 EventDef {
2367 kind: "open",
2368 payload: "`{ kind: \"open\", paths: string[] }` on the root",
2369 doc: "The OS asked the app to open documents (backlog F124): the Finder's Open With, a file dropped on the Dock icon, `open -a App file`, a double-click on a document of a type the app's `Info.plist` declares under `CFBundleDocumentTypes`. Delivered to the host on the root whether or not anything asked — unlike `files`, which answers an ask. `paths` are file-system paths as strings; a URL of a scheme the app registers is not one, and is not carried (room is left for a `urls` beside `paths`). The macOS runner sends it at launch — held until the main window has opened, since AppKit hands the documents over before it exists and puts none in the arguments — and while the app runs; a host driving its own window sends it as input (`openDocuments`, `kui_input_open`, `InputEvent::Open`). Windows and Linux pass documents in the process's arguments instead, and nothing sends it there.",
2370 },
2371 EventDef {
2372 kind: "layout",
2373 payload: "`{ kind: \"layout\", x, y, w, h, parent: { x, y, w, h }, scale, tag }`",
2374 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).",
2375 },
2376 EventDef {
2377 kind: "resize",
2378 payload: "`{ kind: \"resize\", width, height, scale }`",
2379 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.",
2380 },
2381 EventDef {
2382 kind: "window",
2383 payload: "`{ kind: \"window\", phase: \"opened\" | \"closed\" | \"focused\" | \"blurred\", name, id }`",
2384 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.",
2385 },
2386 EventDef {
2387 kind: "system",
2388 payload: "`{ kind: \"system\", appearance, accent, motion, locale, assistive }`",
2389 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.",
2390 },
2391 EventDef {
2392 kind: "fonts",
2393 payload: "`{ kind: \"fonts\" }`",
2394 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.",
2395 },
2396 EventDef {
2397 kind: "modifiers",
2398 payload: "`{ kind: \"modifiers\", shift, ctrl, alt, super }`",
2399 doc: "The physical modifier state changed (delivered to the host on the root).",
2400 },
2401 EventDef {
2402 kind: "changed",
2403 payload: "`{ kind: \"changed\" }`, with the editor's key on the event",
2404 doc: "An editor's text changed.",
2405 },
2406 EventDef {
2407 kind: "submit",
2408 payload: "`{ kind: \"submit\" }`, with the editor's key on the event",
2409 doc: "Enter in a single-line editor.",
2410 },
2411 EventDef {
2412 kind: "cancel",
2413 payload: "`{ kind: \"cancel\" }`, with the editor's key on the event",
2414 doc: "Escape let go of a focused editor: the keyboard leaves it, as it does for a click elsewhere, and this says it was Escape — a draft abandoned, where `submit` is one confirmed and a plain blur is neither. A field that commits on blur cancels here instead.",
2415 },
2416 EventDef {
2417 kind: "boundary",
2418 payload: "`{ kind: \"boundary\", key: \"up\" | \"down\" | \"left\" | \"right\" | \"backspace\" | \"delete\", edge: \"start\" | \"end\", word, doc }`, with the editor's key on the event",
2419 doc: "An editing key that met the edge of an editor's text and did nothing: ↑ on the first line, ← or Backspace at the start (`edge: \"start\"`), ↓ on the last line, → or Delete at the end (`\"end\"`) — from a caret with no selection and without Shift. What a block editor joins blocks and moves between fields by. A field has one line, so its ↑ and ↓ always report. `word` and `doc` are the editing modifiers the key carried (Option or Ctrl, ⌘ or Ctrl — `docs/adr/0002`).",
2420 },
2421 EventDef {
2422 kind: "sound",
2423 payload: "`{ kind: \"sound\", phase: \"ended\" | \"refused\", playback, tag }`",
2424 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.",
2425 },
2426 EventDef {
2427 kind: "dismiss",
2428 payload: "`{ kind: \"dismiss\", reason: \"escape\" | \"outside\", tag }` on a node; `{ kind: \"dismiss\", reason, name, id }` on the root for a popup window",
2429 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.",
2430 },
2431 EventDef {
2432 kind: "access",
2433 payload: "`{ kind: \"access\", action, tag, text?, value?, anchor?: { line, offset }, focus?: { line, offset } }`",
2434 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.",
2435 },
2436 EventDef {
2437 kind: "change",
2438 payload: "`{ kind: \"change\", value, phase: \"move\" | \"end\", tag }`",
2439 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.",
2440 },
2441];
2442
2443pub struct ResourceDef {
2445 pub what: &'static str,
2446 pub node: &'static str,
2447 pub lua: &'static str,
2448 pub c: &'static str,
2449}
2450
2451pub const RESOURCES: &[ResourceDef] = &[
2452 ResourceDef {
2453 what: "image",
2454 node: "`ctx.addImage(w, h, rgba)` → id for `<image src>`",
2455 lua: "the host registers; `image { id }`",
2456 c: "`kui_image_add` → `kui_image`",
2457 },
2458 ResourceDef {
2459 what: "fragment (WGSL)",
2460 node: "`ctx.addFragment(src)` → id for `<fragment src>`",
2461 lua: "the host registers; `fragment { id }`",
2462 c: "`kui_fragment_add` → `kui_fragment`",
2463 },
2464 ResourceDef {
2465 what: "font from bytes",
2466 node: "`ctx.addFont(buffer)` → id for `font`",
2467 lua: "the host registers; `font = id`",
2468 c: "`kui_font_add` → `KuiTextStyle.font`",
2469 },
2470 ResourceDef {
2471 what: "font file by path",
2472 node: "`ctx.loadFontFile(\"fonts/Antonio.ttf\")` → id for `font`",
2473 lua: "the host registers; `font = id`",
2474 c: "`kui_font_load_file`",
2475 },
2476 ResourceDef {
2477 what: "a folder of fonts",
2478 node: "`ctx.loadFontsDir(\"fonts\")`, then pick by name",
2479 lua: "the host loads",
2480 c: "`kui_font_load_dir`",
2481 },
2482 ResourceDef {
2483 what: "font by family name (installed or loaded)",
2484 node: "`ctx.addSystemFont(\"Antonio\")` (see `systemFontFamilies()`)",
2485 lua: "the host registers; `font = id`",
2486 c: "`kui_font_add_system` (see `kui_font_families`)",
2487 },
2488 ResourceDef {
2489 what: "sound (wav / ogg / mp3 / flac bytes)",
2490 node: "`ctx.addSound(buffer)` → id for `<audio src>`, `clickSound`, `play(id)`",
2491 lua: "the host registers; `audio { src = id }`, `click_sound = id`",
2492 c: "`kui_sound_add` → `kui_audio`, `KuiSpec.click_sound`, `kui_play`",
2493 },
2494];
2495
2496pub struct EnvField {
2516 pub name: &'static str,
2518 pub from: &'static str,
2521 pub node: &'static [&'static str],
2525 pub lua: &'static [&'static str],
2528 pub c: &'static str,
2532 pub doc: &'static str,
2533 pub get: fn(&EnvFacts) -> Value,
2541}
2542
2543#[derive(Clone, Copy, Debug)]
2546pub struct EnvFacts {
2547 pub env: crate::env::Env,
2548 pub viewport: crate::geom::Size,
2549 pub scale: f32,
2550 pub focus: Option<crate::key::Key>,
2551 pub focus_visible: bool,
2552 pub region: Option<crate::key::Key>,
2553 pub caret_visible: bool,
2554 pub now: f64,
2556}
2557
2558fn key_value(k: Option<crate::key::Key>) -> Value {
2559 k.map_or(Value::Null, |k| Value::Int(k.0 as i64))
2560}
2561
2562fn rect_value(r: crate::geom::Rect) -> Value {
2563 Value::map([
2564 ("x", Value::Float(r.x as f64)),
2565 ("y", Value::Float(r.y as f64)),
2566 ("w", Value::Float(r.w as f64)),
2567 ("h", Value::Float(r.h as f64)),
2568 ])
2569}
2570
2571pub const ENV_FIELDS: &[EnvField] = &[
2572 EnvField {
2573 name: "refresh_hz",
2574 get: |f| {
2575 f.env
2576 .refresh_hz
2577 .map_or(Value::Null, |hz| Value::Float(hz as f64))
2578 },
2579 from: "`Env::refresh_hz`",
2580 node: &["refreshHz"],
2581 lua: &["refresh_hz"],
2582 c: "`kui_env_set(refresh_hz)`",
2583 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.",
2584 },
2585 EnvField {
2586 name: "frame_budget_ms",
2587 get: |f| Value::Float(f.env.frame_budget_ms() as f64),
2588 from: "`Env::frame_budget_ms()`, derived",
2589 node: &["frameBudgetMs"],
2590 lua: &["frame_budget_ms"],
2591 c: "—",
2592 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.",
2593 },
2594 EnvField {
2595 name: "focused",
2596 get: |f| Value::Bool(f.env.focused),
2597 from: "`Env::focused`",
2598 node: &["focused"],
2599 lua: &["focused"],
2600 c: "`kui_env_set(focused)`",
2601 doc: "Whether the *window* has the keyboard at all. Not the focused node — that is the `focus` row.",
2602 },
2603 EnvField {
2604 name: "system.appearance",
2605 get: |f| Value::str(f.env.system.appearance.name()),
2606 from: "`SystemEnv::appearance`",
2607 node: &["system.appearance"],
2608 lua: &["system.appearance"],
2609 c: "`kui_env_set_system(appearance)`",
2610 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.",
2611 },
2612 EnvField {
2613 name: "system.accent",
2614 get: |f| {
2615 f.env
2616 .system
2617 .accent
2618 .map_or(Value::Null, |c| Value::Int(c.to_hex() as i64))
2619 },
2620 from: "`SystemEnv::accent`",
2621 node: &["system.accent"],
2622 lua: &["system.accent"],
2623 c: "`kui_env_set_system(accent)`",
2624 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.",
2625 },
2626 EnvField {
2627 name: "system.motion",
2628 get: |f| Value::str(f.env.system.motion.name()),
2629 from: "`SystemEnv::motion`",
2630 node: &["system.motion"],
2631 lua: &["system.motion"],
2632 c: "`kui_env_set_system(motion)`",
2633 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.",
2634 },
2635 EnvField {
2636 name: "system.locale",
2637 get: |f| {
2638 f.env
2639 .system
2640 .locale
2641 .map_or(Value::Null, |l| Value::str(l.as_str()))
2642 },
2643 from: "`SystemEnv::locale`",
2644 node: &["system.locale"],
2645 lua: &["system.locale"],
2646 c: "`kui_env_set_system(locale)`",
2647 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.",
2648 },
2649 EnvField {
2650 name: "system.assistive",
2651 get: |f| Value::str(f.env.system.assistive.name()),
2652 from: "`SystemEnv::assistive`",
2653 node: &["system.assistive"],
2654 lua: &["system.assistive"],
2655 c: "`kui_env_set_assistive(assistive)`",
2656 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.",
2657 },
2658 EnvField {
2659 name: "window.id",
2660 get: |f| Value::Int(f.env.window.id.0 as i64),
2661 from: "`WindowEnv::id`",
2662 node: &["window.id"],
2663 lua: &["window.id"],
2664 c: "`kui_env_set_window(window)`, read back by `kui_ctx_window`",
2665 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.",
2666 },
2667 EnvField {
2668 name: "window.custom_chrome",
2669 get: |f| Value::Bool(f.env.window.custom_chrome),
2670 from: "`WindowEnv::custom_chrome`",
2671 node: &["window.customChrome"],
2672 lua: &["window.custom_chrome"],
2673 c: "`kui_env_set_window(custom_chrome)`",
2674 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.",
2675 },
2676 EnvField {
2677 name: "window.maximized",
2678 get: |f| Value::Bool(f.env.window.maximized),
2679 from: "`WindowEnv::maximized`",
2680 node: &["window.maximized"],
2681 lua: &["window.maximized"],
2682 c: "`kui_env_set_window(maximized)`",
2683 doc: "The window is maximized — what picks the restore glyph over the maximize one.",
2684 },
2685 EnvField {
2686 name: "window.fullscreen",
2687 get: |f| Value::Bool(f.env.window.fullscreen),
2688 from: "`WindowEnv::fullscreen`",
2689 node: &["window.fullscreen"],
2690 lua: &["window.fullscreen"],
2691 c: "`kui_env_set_window(fullscreen)`",
2692 doc: "The window is fullscreen.",
2693 },
2694 EnvField {
2695 name: "window.always_on_top",
2696 get: |f| Value::Bool(f.env.window.always_on_top),
2697 from: "`WindowEnv::always_on_top`",
2698 node: &["window.alwaysOnTop"],
2699 lua: &["window.always_on_top"],
2700 c: "`kui_env_set_always_on_top(always_on_top)`",
2701 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.",
2702 },
2703 EnvField {
2704 name: "window.native_controls",
2705 get: |f| f.env.window.native_controls.map_or(Value::Null, rect_value),
2706 from: "`WindowEnv::native_controls`",
2707 node: &["window.nativeControls"],
2708 lua: &["window.controls_w", "window.controls_h"],
2709 c: "`kui_env_set_window(controls_w, controls_h)`",
2710 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.",
2711 },
2712 EnvField {
2713 name: "window.backdrop",
2714 get: |f| Value::str(f.env.window.backdrop.name()),
2715 from: "`WindowEnv::backdrop`",
2716 node: &["window.backdrop"],
2717 lua: &["window.backdrop"],
2718 c: "`kui_env_set_backdrop(backdrop)`; read back with `kui_ctx_backdrop()`",
2719 doc: "What is behind the window's transparent pixels, as the driver got it (backlog F126): `\"opaque\"` (the default, and every headless core's), `\"transparent\"` (the desktop as it is), `\"blur\"` (a live blur of what is behind the window) or `\"tinted\"` (the desktop's colour, not live) — `KUI_BACKDROP_*` in C, opaque 0. The app asks with `Launcher::backdrop` and decides which regions show it by painting them with alpha; this is the answer, which is less where the platform has less — a blur asked of GNOME reads `\"tinted\"`, the wallpaper kui draws itself, or `\"opaque\"` where none could be read — so a view paints its translucent regions opaque when this says so. A C host reports it through its own setter, as `always_on_top` is; a C app asks with `KuiRunConfig.backdrop` and its view reads the answer with `kui_ctx_backdrop`.",
2720 },
2721 EnvField {
2722 name: "audio.device",
2723 get: |f| Value::str(f.env.audio.device.name()),
2724 from: "`AudioEnv::device`",
2725 node: &["audio.device"],
2726 lua: &["audio.device"],
2727 c: "`kui_env_set_audio(device)`",
2728 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.",
2729 },
2730 EnvField {
2731 name: "audio.live",
2732 get: |f| Value::Int(f.env.audio.live as i64),
2733 from: "`AudioEnv::live`",
2734 node: &["audio.live"],
2735 lua: &["audio.live"],
2736 c: "`kui_env_set_audio(live)`",
2737 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).",
2738 },
2739 EnvField {
2740 name: "viewport.w",
2741 get: |f| Value::Float(f.viewport.w as f64),
2742 from: "`Core::viewport()`, the frame's",
2743 node: &["viewport.width"],
2744 lua: &["viewport_w"],
2745 c: "`kui_frame_begin(w)`",
2746 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.",
2747 },
2748 EnvField {
2749 name: "viewport.h",
2750 get: |f| Value::Float(f.viewport.h as f64),
2751 from: "`Core::viewport()`, the frame's",
2752 node: &["viewport.height"],
2753 lua: &["viewport_h"],
2754 c: "`kui_frame_begin(h)`",
2755 doc: "Its logical height.",
2756 },
2757 EnvField {
2758 name: "scale",
2759 get: |f| Value::Float(f.scale as f64),
2760 from: "`Core::scale()`, the frame's",
2761 node: &["viewport.scale"],
2762 lua: &[],
2763 c: "`kui_frame_begin(scale)`",
2764 doc: "Device pixels per logical px. Lua has no reading: a script sees logical px only.",
2765 },
2766 EnvField {
2767 name: "focus",
2768 get: |f| key_value(f.focus),
2769 from: "`Core::focus()`, the frame's",
2770 node: &[],
2771 lua: &["focus"],
2772 c: "`kui_focused()`",
2773 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`.",
2774 },
2775 EnvField {
2776 name: "focus_visible",
2777 get: |f| Value::Bool(f.focus_visible),
2778 from: "`Core::focus_visible()`, the frame's",
2779 node: &[],
2780 lua: &["focus_visible"],
2781 c: "`kui_focus_visible()`",
2782 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.",
2783 },
2784 EnvField {
2785 name: "caret_visible",
2786 get: |f| Value::Bool(f.caret_visible),
2787 from: "`Core::caret_visible()`, the frame's",
2788 node: &[],
2789 lua: &["caret_visible"],
2790 c: "`kui_caret_visible()`",
2791 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).",
2792 },
2793 EnvField {
2794 name: "now",
2795 get: |f| Value::Float(f.now),
2796 from: "`Core::now()`, the frame's",
2797 node: &[],
2798 lua: &["now"],
2799 c: "`kui_now()`",
2800 doc: "The frame clock in seconds (backlog F134): the driver's monotonic clock, any origin, the one `transition` and `keyframes` read this frame — 0 before a driver sets one. Read a deadline off it (a toast's expiry, a sequence's beats) rather than off a clock of the app's own, so a test that moves the frame clock moves both. Node: `now()` on the context.",
2801 },
2802 EnvField {
2803 name: "region",
2804 get: |f| key_value(f.region),
2805 from: "`Core::region()`, the frame's",
2806 node: &[],
2807 lua: &["region"],
2808 c: "`kui_region()`",
2809 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`.",
2810 },
2811];
2812
2813pub struct ThemeRole {
2824 pub name: &'static str,
2826 pub node: &'static str,
2828 pub doc: &'static str,
2829 pub get: fn(&crate::theme::Theme) -> crate::color::Color,
2831 pub set: fn(&mut crate::theme::Theme, crate::color::Color),
2834}
2835
2836pub const THEME_ROLES: &[ThemeRole] = &[
2837 ThemeRole {
2838 name: "bg",
2839 node: "bg",
2840 get: |t| t.bg,
2841 set: |t, c| t.bg = c,
2842 doc: "The window behind everything.",
2843 },
2844 ThemeRole {
2845 name: "surface",
2846 node: "surface",
2847 get: |t| t.surface,
2848 set: |t, c| t.surface = c,
2849 doc: "A card, panel or list sitting on `bg`.",
2850 },
2851 ThemeRole {
2852 name: "raised",
2853 node: "raised",
2854 get: |t| t.raised,
2855 set: |t, c| t.raised = c,
2856 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.",
2857 },
2858 ThemeRole {
2859 name: "sunken",
2860 node: "sunken",
2861 get: |t| t.sunken,
2862 set: |t, c| t.sunken = c,
2863 doc: "A well cut into a surface: a text field, a code block, a track.",
2864 },
2865 ThemeRole {
2866 name: "border",
2867 node: "border",
2868 get: |t| t.border,
2869 set: |t, c| t.border = c,
2870 doc: "The hairline between two surfaces.",
2871 },
2872 ThemeRole {
2873 name: "border_strong",
2874 node: "borderStrong",
2875 get: |t| t.border_strong,
2876 set: |t, c| t.border_strong = c,
2877 doc: "A border that has to be seen — a float's edge, a focused field.",
2878 },
2879 ThemeRole {
2880 name: "fg",
2881 node: "fg",
2882 get: |t| t.fg,
2883 set: |t, c| t.fg = c,
2884 doc: "Body text, and what a `color`-less text run resolves to.",
2885 },
2886 ThemeRole {
2887 name: "muted",
2888 node: "muted",
2889 get: |t| t.muted,
2890 set: |t, c| t.muted = c,
2891 doc: "Secondary text: captions, hints, an accelerator beside a label.",
2892 },
2893 ThemeRole {
2894 name: "faint",
2895 node: "faint",
2896 get: |t| t.faint,
2897 set: |t, c| t.faint = c,
2898 doc: "Text that is barely there: a placeholder, a gutter number.",
2899 },
2900 ThemeRole {
2901 name: "accent",
2902 node: "accent",
2903 get: |t| t.accent,
2904 set: |t, c| t.accent = c,
2905 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.",
2906 },
2907 ThemeRole {
2908 name: "accent_hover",
2909 node: "accentHover",
2910 get: |t| t.accent_hover,
2911 set: |t, c| t.accent_hover = c,
2912 doc: "`accent` under a pointer.",
2913 },
2914 ThemeRole {
2915 name: "accent_pressed",
2916 node: "accentPressed",
2917 get: |t| t.accent_pressed,
2918 set: |t, c| t.accent_pressed = c,
2919 doc: "`accent` under a press.",
2920 },
2921 ThemeRole {
2922 name: "on_accent",
2923 node: "onAccent",
2924 get: |t| t.on_accent,
2925 set: |t, c| t.on_accent = c,
2926 doc: "Black or white — whichever a reader can see on `accent`. What a button's label is.",
2927 },
2928 ThemeRole {
2929 name: "accent_soft",
2930 node: "accentSoft",
2931 get: |t| t.accent_soft,
2932 set: |t, c| t.accent_soft = c,
2933 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.",
2934 },
2935 ThemeRole {
2936 name: "selection",
2937 node: "selection",
2938 get: |t| t.selection,
2939 set: |t, c| t.selection = c,
2940 doc: "What a text selection is painted under, in an editor and over a `selectable` scope alike.",
2941 },
2942 ThemeRole {
2943 name: "focus_ring",
2944 node: "focusRing",
2945 get: |t| t.focus_ring,
2946 set: |t, c| t.focus_ring = c,
2947 doc: "The default keyboard focus ring (ADR 0002).",
2948 },
2949 ThemeRole {
2950 name: "hover",
2951 node: "hover",
2952 get: |t| t.hover,
2953 set: |t, c| t.hover = c,
2954 doc: "A translucent wash over a hovered neutral control. An overlay, not a fill, so one value works on every surface.",
2955 },
2956 ThemeRole {
2957 name: "pressed",
2958 node: "pressed",
2959 get: |t| t.pressed,
2960 set: |t, c| t.pressed = c,
2961 doc: "The same over a pressed one, and the firmer of the two on both bases.",
2962 },
2963 ThemeRole {
2964 name: "success",
2965 node: "success",
2966 get: |t| t.success,
2967 set: |t, c| t.success = c,
2968 doc: "A good outcome. Readable on `surface` on both bases, which is why it is not one colour for both.",
2969 },
2970 ThemeRole {
2971 name: "warning",
2972 node: "warning",
2973 get: |t| t.warning,
2974 set: |t, c| t.warning = c,
2975 doc: "Something that wants attention.",
2976 },
2977 ThemeRole {
2978 name: "danger",
2979 node: "danger",
2980 get: |t| t.danger,
2981 set: |t, c| t.danger = c,
2982 doc: "A destructive action or a failure. The close button's hover, too.",
2983 },
2984 ThemeRole {
2985 name: "scrollbar",
2986 node: "scrollbar",
2987 get: |t| t.scrollbar,
2988 set: |t, c| t.scrollbar = c,
2989 doc: "The scrollbar thumb at rest.",
2990 },
2991 ThemeRole {
2992 name: "scrollbar_active",
2993 node: "scrollbarActive",
2994 get: |t| t.scrollbar_active,
2995 set: |t, c| t.scrollbar_active = c,
2996 doc: "The thumb while hovered or dragged.",
2997 },
2998];
2999
3000pub struct MetricRole {
3005 pub name: &'static str,
3007 pub node: &'static str,
3009 pub doc: &'static str,
3010 pub get: fn(&crate::metrics::Metrics) -> f32,
3011 pub set: fn(&mut crate::metrics::Metrics, f32),
3012 pub platform: Option<PlatformValue>,
3019}
3020
3021#[derive(Clone, Copy, Debug, PartialEq)]
3023pub struct PlatformValue {
3024 pub windows: f32,
3025 pub elsewhere: f32,
3026}
3027
3028impl PlatformValue {
3029 pub const fn here(self) -> f32 {
3031 if cfg!(target_os = "windows") {
3032 self.windows
3033 } else {
3034 self.elsewhere
3035 }
3036 }
3037}
3038
3039macro_rules! metric_role {
3040 ($field:ident, $node:literal, $doc:literal) => {
3041 MetricRole {
3042 name: stringify!($field),
3043 node: $node,
3044 get: |m| m.$field,
3045 set: |m, v| m.$field = v,
3046 doc: $doc,
3047 platform: None,
3048 }
3049 };
3050 ($field:ident, $node:literal, $doc:literal, windows $w:expr, elsewhere $e:expr) => {
3051 MetricRole {
3052 name: stringify!($field),
3053 node: $node,
3054 get: |m| m.$field,
3055 set: |m, v| m.$field = v,
3056 doc: $doc,
3057 platform: Some(PlatformValue {
3058 windows: $w,
3059 elsewhere: $e,
3060 }),
3061 }
3062 };
3063}
3064
3065pub const METRIC_ROLES: &[MetricRole] = &[
3066 metric_role!(
3067 control_text,
3068 "controlText",
3069 "A stock control's label: the button's text size."
3070 ),
3071 metric_role!(
3072 chrome_text,
3073 "chromeText",
3074 "The chrome's text: a menu row, a menu-bar title, the titlebar's title."
3075 ),
3076 metric_role!(hint_text, "hintText", "A tooltip's text."),
3077 metric_role!(
3078 radius,
3079 "radius",
3080 "The corner of every stock surface: a button, a field, a menu, a tooltip."
3081 ),
3082 metric_role!(
3083 radius_inner,
3084 "radiusInner",
3085 "The corner of a row inside one: a menu row, a menu-bar title."
3086 ),
3087 metric_role!(
3088 control_pad_x,
3089 "controlPadX",
3090 "A button's horizontal padding."
3091 ),
3092 metric_role!(control_pad_y, "controlPadY", "A button's vertical padding."),
3093 metric_role!(
3094 field_pad_x,
3095 "fieldPadX",
3096 "A text field's horizontal padding."
3097 ),
3098 metric_role!(field_pad_y, "fieldPadY", "A text field's vertical padding."),
3099 metric_role!(hint_pad_x, "hintPadX", "A tooltip's horizontal padding."),
3100 metric_role!(hint_pad_y, "hintPadY", "A tooltip's vertical padding."),
3101 metric_role!(
3102 menu_pad_x,
3103 "menuPadX",
3104 "A menu row's horizontal padding, and a menu-bar title's."
3105 ),
3106 metric_role!(
3107 menu_pad_y,
3108 "menuPadY",
3109 "A menu row's vertical padding; a menu-bar title's is two px less."
3110 ),
3111 metric_role!(menu_width, "menuWidth", "A menu panel's width."),
3112 metric_role!(menu_bar_h, "menuBarH", "The drawn menu bar's height."),
3113 metric_role!(
3114 titlebar_h,
3115 "titlebarH",
3116 "The titlebar strip's height: the platform's caption height, 32 on Windows and 34 elsewhere, or the window's own where the runner knows it (backlog W22) — 40 and 52 off macOS for a launcher's `medium` or `tall` titlebar under custom chrome, and under macOS custom chrome the OS's own titlebar as `window.native_controls` measures it (32, 40 or 52 on macOS 27). The stock number stands for that window's height, so a set of the app's keeps it unless it names a number of its own. Under macOS custom chrome the strip is drawn at the measured height whatever this row says (`widgets::titlebar_height`).",
3117 windows crate::metrics::TITLEBAR_H_WINDOWS,
3118 elsewhere crate::metrics::TITLEBAR_H_ELSEWHERE
3119 ),
3120];
3121
3122static SNAKE_NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
3123 PROPS
3124 .iter()
3125 .map(|d| Box::leak(snake_case(d.name).into_boxed_str()) as &'static str)
3126 .collect()
3127});
3128
3129pub fn snake_case(name: &str) -> String {
3132 let mut out = String::with_capacity(name.len() + 2);
3133 let mut prev_upper = false;
3134 for c in name.chars() {
3135 if c.is_ascii_uppercase() {
3136 if !prev_upper {
3137 out.push('_');
3138 }
3139 out.push(c.to_ascii_lowercase());
3140 prev_upper = true;
3141 } else {
3142 out.push(c);
3143 prev_upper = false;
3144 }
3145 }
3146 out
3147}
3148
3149pub fn by_name(name: &str) -> Option<&'static PropDef> {
3150 PROPS.iter().find(|d| d.name == name)
3151}
3152
3153static BY_SNAKE: LazyLock<FxHashMap<&'static [u8], usize>> = LazyLock::new(|| {
3157 SNAKE_NAMES
3158 .iter()
3159 .enumerate()
3160 .map(|(i, n)| (n.as_bytes(), i))
3161 .collect()
3162});
3163
3164pub fn by_snake_name(name: &str) -> Option<&'static PropDef> {
3165 by_snake_bytes(name.as_bytes())
3166}
3167
3168pub fn by_snake_bytes(name: &[u8]) -> Option<&'static PropDef> {
3171 BY_SNAKE.get(name).map(|&i| &PROPS[i])
3172}
3173
3174pub fn by_id(id: u32) -> Option<&'static PropDef> {
3175 PROPS.iter().find(|d| d.id == id)
3176}
3177
3178#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3183pub enum Spelling {
3184 Camel,
3185 Snake,
3186}
3187
3188pub const LUA_ALIASES: &[(&str, &str)] = &[("direction", "repeat")];
3193
3194pub fn lua_alias(name: &str) -> Option<&'static str> {
3197 LUA_ALIASES
3198 .iter()
3199 .find(|(alias, _)| *alias == name)
3200 .map(|(_, real)| *real)
3201}
3202
3203static SHARED_NAMES: LazyLock<[FxHashSet<&'static str>; 2]> = LazyLock::new(|| {
3206 let mut camel: FxHashSet<&'static str> = PROPS.iter().map(|d| d.name).collect();
3207 let mut snake: FxHashSet<&'static str> = PROPS.iter().map(|d| d.snake_name()).collect();
3208 for c in CUSTOM {
3209 camel.extend(c.jsx_names);
3210 snake.extend(c.lua_names);
3211 }
3212 snake.extend(LUA_ALIASES.iter().map(|(alias, _)| *alias));
3213 [camel, snake]
3214});
3215
3216fn shared_names(spelling: Spelling) -> &'static FxHashSet<&'static str> {
3217 &SHARED_NAMES[(spelling == Spelling::Snake) as usize]
3218}
3219
3220pub fn element_own(element: &str, spelling: Spelling) -> &'static [&'static str] {
3223 match ELEMENTS.iter().find(|e| e.name == element) {
3224 Some(e) if spelling == Spelling::Camel => e.jsx_own,
3225 Some(e) => e.lua_own,
3226 None => &[],
3227 }
3228}
3229
3230pub fn element_rows(element: &str, spelling: Spelling) -> Option<&'static [&'static str]> {
3234 let e = ELEMENTS.iter().find(|e| e.name == element)?;
3235 if spelling == Spelling::Camel {
3236 e.jsx_rows
3237 } else {
3238 e.lua_rows
3239 }
3240}
3241
3242pub fn shared_prop(name: &str, spelling: Spelling) -> bool {
3248 shared_names(spelling).contains(name)
3249}
3250
3251pub fn known_prop(element: &str, name: &str, spelling: Spelling) -> bool {
3256 if element_own(element, spelling).contains(&name) {
3257 return true;
3258 }
3259 match element_rows(element, spelling) {
3260 Some(rows) => rows.contains(&name),
3261 None => shared_names(spelling).contains(name),
3262 }
3263}
3264
3265pub fn suggest(element: &str, name: &str, spelling: Spelling) -> Option<&'static str> {
3270 let squash = |s: &str| s.replace('_', "").to_ascii_lowercase();
3271 let want = squash(name);
3272 let near = |c: &&&str| squash(c) == want;
3273 let own = element_own(element, spelling).iter();
3274 match element_rows(element, spelling) {
3276 Some(rows) => rows.iter().chain(own).find(near).copied(),
3277 None => shared_names(spelling).iter().chain(own).find(near).copied(),
3278 }
3279}
3280
3281pub enum Parsed {
3283 F32(f32),
3284 Color(Color),
3285 Flag,
3286 Enum(usize),
3287 Sizing(Sizing),
3288 Bound(Bound),
3290 Msg(Value),
3291 Str(String),
3292 Resource(u64),
3293 Keyframes(Vec<Keyframe>),
3294 Enter(Enter),
3295 Gradient(crate::gradient::Gradient),
3296 Family(FontFamily),
3298}
3299
3300#[derive(Clone, Debug, PartialEq)]
3302pub struct PropsOut {
3303 pub spec: NodeSpec,
3304 pub style: TextStyle,
3305 pub key: Option<String>,
3306 pub index: Option<u64>,
3309 pub row_count: Option<u64>,
3312 pub title: Option<String>,
3313 pub always_on_top: bool,
3316 pub secure_input: bool,
3319 pub option_as_alt: crate::OptionAsAlt,
3322 pub ime_off: bool,
3325 pub key_focus: bool,
3326 pub tooltip: Option<String>,
3329 pub windows: Vec<(String, WindowConfig)>,
3331 pub wrap: bool,
3336}
3337
3338#[derive(Clone, Copy, Debug, PartialEq)]
3342pub enum Identity<'a> {
3343 Auto,
3344 Label(&'a str),
3345 Index(u64),
3346}
3347
3348impl PropsOut {
3349 pub fn identity(&self) -> Identity<'_> {
3351 match (self.index, &self.key) {
3352 (Some(i), _) => Identity::Index(i),
3353 (None, Some(label)) => Identity::Label(label),
3354 (None, None) => Identity::Auto,
3355 }
3356 }
3357
3358 pub fn new() -> Self {
3359 PropsOut {
3360 spec: NodeSpec::column(),
3361 style: TextStyle::new(16.0),
3362 key: None,
3363 index: None,
3364 row_count: None,
3365 title: None,
3366 always_on_top: false,
3367 secure_input: false,
3368 option_as_alt: crate::OptionAsAlt::None,
3369 ime_off: false,
3370 key_focus: false,
3371 tooltip: None,
3372 windows: Vec::new(),
3373 wrap: false,
3374 }
3375 }
3376
3377 pub fn with_spec(&mut self, f: impl FnOnce(NodeSpec) -> NodeSpec) {
3379 self.spec = f(std::mem::take(&mut self.spec));
3380 }
3381
3382 pub fn apply_tooltip(&mut self, hint: impl Into<String>) {
3387 let hint = hint.into();
3388 self.with_spec(|s| s.apply_tooltip(&hint));
3389 self.tooltip = Some(hint);
3390 }
3391
3392 pub fn for_leaf(mut self) -> Self {
3401 if self.tooltip.is_some() {
3402 self.spec.access_mut().tooltip = true;
3403 }
3404 self
3405 }
3406
3407 pub fn apply_pad(&mut self, pad: PadShorthand) {
3411 if pad.declared() {
3412 let edges = pad.resolve();
3413 self.with_spec(|s| s.padding(edges));
3414 }
3415 }
3416}
3417
3418impl Default for PropsOut {
3419 fn default() -> Self {
3420 Self::new()
3421 }
3422}
3423
3424pub fn apply(def: &PropDef, value: Parsed, out: &mut PropsOut) -> Result<(), String> {
3428 let spec = std::mem::take(&mut out.spec);
3429 let style = out.style;
3430 out.wrap |= def.id == P_WRAP;
3433 match (&def.apply, value) {
3434 (Apply::SpecF32(f), Parsed::F32(v)) => out.spec = f(spec, v),
3435 (Apply::SpecColor(f), Parsed::Color(v)) => out.spec = f(spec, v),
3436 (Apply::SpecFlag(f), Parsed::Flag) => out.spec = f(spec),
3437 (Apply::SpecEnum(f), Parsed::Enum(v)) => out.spec = f(spec, v),
3438 (Apply::SpecSizing(f), Parsed::Sizing(v)) => out.spec = f(spec, v),
3439 (Apply::SpecBound(f), Parsed::Bound(v)) => out.spec = f(spec, v),
3440 (Apply::SpecMsg(f), Parsed::Msg(v)) => out.spec = f(spec, v),
3441 (Apply::SpecStr(f), Parsed::Str(v)) => out.spec = f(spec, &v),
3442 (Apply::SpecKeyframes(f), Parsed::Keyframes(v)) => out.spec = f(spec, v),
3443 (Apply::SpecEnter(f), Parsed::Enter(v)) => out.spec = f(spec, v),
3444 (Apply::SpecGradient(f), Parsed::Gradient(v)) => out.spec = f(spec, v),
3445 (Apply::SpecResource(f), Parsed::Resource(v)) => out.spec = f(spec, v),
3446 (Apply::StyleF32(f), Parsed::F32(v)) => {
3447 out.spec = spec;
3448 out.style = f(style, v);
3449 }
3450 (Apply::StyleColor(f), Parsed::Color(v)) => {
3451 out.spec = spec;
3452 out.style = f(style, v);
3453 }
3454 (Apply::StyleEnum(f), Parsed::Enum(v)) => {
3455 out.spec = spec;
3456 out.style = f(style, v);
3457 }
3458 (Apply::StyleFlag(f), Parsed::Flag) => {
3459 out.spec = spec;
3460 out.style = f(style);
3461 }
3462 (Apply::StyleResource(f), Parsed::Resource(v)) => {
3463 out.spec = spec;
3464 out.style = f(style, v);
3465 }
3466 (Apply::StyleStr(f), Parsed::Str(v)) => {
3467 out.spec = spec;
3468 out.style = f(style, &v);
3469 }
3470 (Apply::StyleFamily(f), Parsed::Family(v)) => {
3471 out.spec = spec;
3472 out.style = f(style, v);
3473 }
3474 _ => {
3475 out.spec = spec;
3476 return Err(format!("prop {} value/kind mismatch", def.name));
3477 }
3478 }
3479 Ok(())
3480}
3481
3482pub fn enum_index(names: &[&str], s: &str) -> Result<usize, String> {
3484 names
3485 .iter()
3486 .position(|n| *n == s)
3487 .ok_or_else(|| format!("bad value {s:?} (one of {names:?})"))
3488}
3489
3490pub fn color_num(hex: u32) -> Color {
3495 if hex == 0 {
3496 Color::TRANSPARENT
3497 } else {
3498 Color::hex(hex)
3499 }
3500}
3501
3502pub fn color_hex_str(s: &str) -> Result<Color, String> {
3504 let bad = || format!("bad color {s:?}");
3505 let hex = s.strip_prefix('#').ok_or_else(bad)?;
3506 let expanded = match hex.len() {
3507 3 => hex
3508 .chars()
3509 .flat_map(|c| [c, c])
3510 .chain("ff".chars())
3511 .collect::<String>(),
3512 6 => format!("{hex}ff"),
3513 8 => hex.to_string(),
3514 _ => return Err(bad()),
3515 };
3516 let n = u32::from_str_radix(&expanded, 16).map_err(|_| bad())?;
3517 Ok(Color::hex(n))
3518}
3519
3520pub const SIZE_MODE_CALC: u32 = 4;
3523
3524pub const SIZE_MODE_TREE: u32 = 5;
3529
3530pub fn min_str(s: &str) -> Result<Bound, String> {
3533 match s {
3534 "fit" => Ok(Bound::Fit),
3535 _ => crate::calc::bound(s)
3536 .map_err(|e| format!("bad min {s:?} (number | \"fit\" | a size expression): {e}")),
3537 }
3538}
3539
3540pub fn max_str(s: &str) -> Result<Bound, String> {
3542 crate::calc::bound(s).map_err(|e| format!("bad max {s:?} (number | a size expression): {e}"))
3543}
3544
3545pub fn sizing_str(s: &str) -> Result<Sizing, String> {
3548 match s {
3549 "fit" => Ok(Sizing::Fit),
3550 "grow" => Ok(Sizing::Grow(1.0)),
3551 _ => crate::calc::sizing(s).map_err(|e| {
3552 format!("bad sizing {s:?} (fit | grow | number | \"N%\" | a size expression): {e}")
3553 }),
3554 }
3555}
3556
3557#[cfg(test)]
3558mod tests {
3559 use super::*;
3560
3561 #[test]
3568 fn every_enum_list_is_its_enum_s_all_by_name() {
3569 fn names(it: &[&'static str]) -> Vec<&'static str> {
3570 it.to_vec()
3571 }
3572 assert_eq!(
3573 Easing::ALL.iter().map(|e| e.name()).collect::<Vec<_>>(),
3574 names(EASINGS)
3575 );
3576 assert_eq!(
3577 Repeat::ALL.iter().map(|r| r.name()).collect::<Vec<_>>(),
3578 names(REPEATS)
3579 );
3580 assert_eq!(
3581 crate::access::Live::ALL
3582 .iter()
3583 .map(|l| l.name())
3584 .collect::<Vec<_>>(),
3585 names(LIVE)
3586 );
3587 assert_eq!(
3588 FontFamily::ALL
3589 .iter()
3590 .map(|f| f.name().expect("a stock family has a name"))
3591 .collect::<Vec<_>>(),
3592 names(FAMILIES)
3593 );
3594 for (i, e) in Easing::ALL.iter().enumerate() {
3596 assert_eq!(easing_idx(i), *e);
3597 }
3598 for (i, r) in Repeat::ALL.iter().enumerate() {
3599 assert_eq!(repeat_idx(i), *r);
3600 }
3601 for (i, l) in crate::access::Live::ALL.iter().enumerate() {
3602 assert_eq!(crate::access::Live::from_index(i), *l);
3603 }
3604 for (i, f) in FontFamily::ALL.iter().enumerate() {
3605 assert_eq!(FontFamily::from_index(i), *f);
3606 }
3607 assert_eq!(easing_idx(99), Easing::default());
3609 assert_eq!(repeat_idx(99), Repeat::default());
3610 assert_eq!(FontFamily::from_index(99), FontFamily::Sans);
3611 }
3612
3613 #[test]
3614 fn ids_and_names_are_unique() {
3615 let all: Vec<(&str, u32)> = PROPS
3616 .iter()
3617 .map(|d| (d.name, d.id))
3618 .chain(CUSTOM.iter().map(|c| (c.name, c.id)))
3619 .collect();
3620 for (i, (name, id)) in all.iter().enumerate() {
3621 for (other_name, other_id) in &all[i + 1..] {
3622 assert_ne!(name, other_name, "duplicate prop name");
3623 assert_ne!(id, other_id, "duplicate wire id for {name} / {other_name}");
3624 }
3625 }
3626 }
3627
3628 fn snake_to_camel(name: &str) -> String {
3630 let mut out = String::new();
3631 let mut up = false;
3632 for ch in name.chars() {
3633 if ch == '_' {
3634 up = true;
3635 } else if up {
3636 out.extend(ch.to_uppercase());
3637 up = false;
3638 } else {
3639 out.push(ch);
3640 }
3641 }
3642 out
3643 }
3644
3645 #[test]
3649 fn metric_roles_restate_the_metrics_exactly() {
3650 use crate::metrics::Metrics;
3651 let m = Metrics::compact();
3652 let Metrics {
3653 control_text,
3654 chrome_text,
3655 hint_text,
3656 radius,
3657 radius_inner,
3658 control_pad_x,
3659 control_pad_y,
3660 field_pad_x,
3661 field_pad_y,
3662 hint_pad_x,
3663 hint_pad_y,
3664 menu_pad_x,
3665 menu_pad_y,
3666 menu_width,
3667 menu_bar_h,
3668 titlebar_h,
3669 } = m;
3670 let fields: &[(&str, f32)] = &[
3671 ("control_text", control_text),
3672 ("chrome_text", chrome_text),
3673 ("hint_text", hint_text),
3674 ("radius", radius),
3675 ("radius_inner", radius_inner),
3676 ("control_pad_x", control_pad_x),
3677 ("control_pad_y", control_pad_y),
3678 ("field_pad_x", field_pad_x),
3679 ("field_pad_y", field_pad_y),
3680 ("hint_pad_x", hint_pad_x),
3681 ("hint_pad_y", hint_pad_y),
3682 ("menu_pad_x", menu_pad_x),
3683 ("menu_pad_y", menu_pad_y),
3684 ("menu_width", menu_width),
3685 ("menu_bar_h", menu_bar_h),
3686 ("titlebar_h", titlebar_h),
3687 ];
3688 assert_eq!(METRIC_ROLES.len(), fields.len(), "a metric has no row");
3689 for (name, value) in fields {
3690 let row = METRIC_ROLES
3691 .iter()
3692 .find(|r| r.name == *name)
3693 .unwrap_or_else(|| panic!("no METRIC_ROLES row for {name}"));
3694 assert_eq!((row.get)(&m), *value, "{name}'s row reads another field");
3695 let mut w = m;
3696 (row.set)(&mut w, 1.0);
3697 assert_eq!((row.get)(&w), 1.0, "{name}'s row writes another field");
3698 }
3699 for row in METRIC_ROLES {
3700 assert!(
3701 fields.iter().any(|(n, _)| *n == row.name),
3702 "METRIC_ROLES names a field Metrics does not have: {}",
3703 row.name
3704 );
3705 let camel = snake_to_camel(row.name);
3706 assert_eq!(row.node, camel, "{}'s Node spelling", row.name);
3707 if let Some(p) = row.platform {
3711 assert_eq!(
3712 (row.get)(&Metrics::default()),
3713 p.here(),
3714 "{}'s stock value",
3715 row.name
3716 );
3717 assert_eq!(
3718 (row.get)(&m),
3719 p.here(),
3720 "{}: compact leaves a platform row alone",
3721 row.name
3722 );
3723 }
3724 }
3725 assert!(
3726 METRIC_ROLES.iter().any(|r| r.platform.is_some()),
3727 "titlebar_h is the platform's; its row says so"
3728 );
3729 }
3730
3731 #[test]
3739 fn theme_roles_restate_the_theme_exactly() {
3740 use crate::theme::Theme;
3741 let t = Theme::dark();
3742 let Theme {
3743 appearance: _,
3744 disabled_opacity: _,
3745 bg,
3746 surface,
3747 raised,
3748 sunken,
3749 border,
3750 border_strong,
3751 fg,
3752 muted,
3753 faint,
3754 accent,
3755 accent_hover,
3756 accent_pressed,
3757 on_accent,
3758 accent_soft,
3759 selection,
3760 focus_ring,
3761 hover,
3762 pressed,
3763 success,
3764 warning,
3765 danger,
3766 scrollbar,
3767 scrollbar_active,
3768 } = t;
3769 let fields: &[(&str, crate::color::Color)] = &[
3770 ("bg", bg),
3771 ("surface", surface),
3772 ("raised", raised),
3773 ("sunken", sunken),
3774 ("border", border),
3775 ("border_strong", border_strong),
3776 ("fg", fg),
3777 ("muted", muted),
3778 ("faint", faint),
3779 ("accent", accent),
3780 ("accent_hover", accent_hover),
3781 ("accent_pressed", accent_pressed),
3782 ("on_accent", on_accent),
3783 ("accent_soft", accent_soft),
3784 ("selection", selection),
3785 ("focus_ring", focus_ring),
3786 ("hover", hover),
3787 ("pressed", pressed),
3788 ("success", success),
3789 ("warning", warning),
3790 ("danger", danger),
3791 ("scrollbar", scrollbar),
3792 ("scrollbar_active", scrollbar_active),
3793 ];
3794 assert_eq!(THEME_ROLES.len(), fields.len(), "a role has no row");
3795 for (name, value) in fields {
3796 let row = THEME_ROLES
3797 .iter()
3798 .find(|r| r.name == *name)
3799 .unwrap_or_else(|| panic!("no THEME_ROLES row for {name}"));
3800 assert_eq!((row.get)(&t), *value, "{name}'s row reads another field");
3801 }
3802 for row in THEME_ROLES {
3803 assert!(
3804 fields.iter().any(|(n, _)| *n == row.name),
3805 "{} names no field",
3806 row.name
3807 );
3808 let camel = {
3810 let mut out = String::new();
3811 let mut up = false;
3812 for ch in row.name.chars() {
3813 if ch == '_' {
3814 up = true;
3815 } else if up {
3816 out.extend(ch.to_uppercase());
3817 up = false;
3818 } else {
3819 out.push(ch);
3820 }
3821 }
3822 out
3823 };
3824 assert_eq!(row.node, camel, "{}'s Node spelling", row.name);
3825 }
3826 }
3827
3828 #[test]
3835 fn env_fields_restate_env_and_window_env_exactly() {
3836 use crate::env::{AudioEnv, Env, SystemEnv};
3837 use crate::window::WindowEnv;
3838 let Env {
3839 refresh_hz: _,
3840 focused: _,
3841 system,
3842 window,
3843 audio,
3844 } = Env::default();
3845 let AudioEnv { device: _, live: _ } = audio;
3846 let SystemEnv {
3847 appearance: _,
3848 accent: _,
3849 motion: _,
3850 locale: _,
3851 assistive: _,
3852 } = system;
3853 let WindowEnv {
3854 id: _,
3855 custom_chrome: _,
3856 maximized: _,
3857 fullscreen: _,
3858 always_on_top: _,
3859 native_controls: _,
3860 backdrop: _,
3861 } = window;
3862 let stored = [
3863 "refresh_hz",
3864 "focused",
3865 "system.appearance",
3866 "system.accent",
3867 "system.motion",
3868 "system.locale",
3869 "system.assistive",
3870 "window.id",
3871 "window.custom_chrome",
3872 "window.maximized",
3873 "window.fullscreen",
3874 "window.always_on_top",
3875 "window.native_controls",
3876 "window.backdrop",
3877 "audio.device",
3878 "audio.live",
3879 ];
3880 let from_structs: Vec<&str> = ENV_FIELDS
3883 .iter()
3884 .filter(|f| !f.from.contains('('))
3885 .map(|f| {
3886 let (strukt, field) = match f.name.split_once('.') {
3887 Some(("window", field)) => ("WindowEnv", field),
3888 Some(("system", field)) => ("SystemEnv", field),
3889 Some(("audio", field)) => ("AudioEnv", field),
3890 Some((group, _)) => panic!("{}: no struct holds a {group} fact", f.name),
3891 None => ("Env", f.name),
3892 };
3893 assert_eq!(f.from, format!("`{strukt}::{field}`"), "{}", f.name);
3894 f.name
3895 })
3896 .collect();
3897 assert_eq!(from_structs, stored);
3898 for f in ENV_FIELDS.iter().filter(|f| f.from.contains('(')) {
3899 assert!(
3900 f.from.contains("derived") || f.from.contains("the frame's"),
3901 "{}: a call in `from` is derived or the frame's, and says which",
3902 f.name
3903 );
3904 }
3905 }
3906
3907 #[test]
3911 fn env_field_spellings_are_unique_and_complete() {
3912 let mut names: Vec<&str> = ENV_FIELDS.iter().map(|f| f.name).collect();
3913 let mut node: Vec<&str> = ENV_FIELDS
3914 .iter()
3915 .flat_map(|f| f.node.iter().copied())
3916 .collect();
3917 let mut lua: Vec<&str> = ENV_FIELDS
3918 .iter()
3919 .flat_map(|f| f.lua.iter().copied())
3920 .collect();
3921 for list in [&mut names, &mut node, &mut lua] {
3922 let before = list.len();
3923 list.sort_unstable();
3924 list.dedup();
3925 assert_eq!(list.len(), before, "a spelling is used twice");
3926 }
3927 for f in ENV_FIELDS {
3928 assert!(!f.c.is_empty() && !f.doc.is_empty(), "{}", f.name);
3929 if f.node.is_empty() {
3932 assert!(
3933 f.doc.contains("Node"),
3934 "{}: where does Node carry it?",
3935 f.name
3936 );
3937 }
3938 if f.lua.is_empty() {
3939 assert!(
3940 f.doc.contains("Lua"),
3941 "{}: where does Lua carry it?",
3942 f.name
3943 );
3944 }
3945 }
3946 }
3947
3948 #[test]
3952 fn admitted_rows_are_shared_rows_in_both_spellings() {
3953 for e in ELEMENTS {
3954 let (Some(jsx), Some(lua)) = (e.jsx_rows, e.lua_rows) else {
3955 assert!(
3956 e.jsx_rows.is_none() && e.lua_rows.is_none(),
3957 "{}: one spelling only",
3958 e.name
3959 );
3960 continue;
3961 };
3962 assert_eq!(
3963 jsx.len(),
3964 lua.len(),
3965 "{}: the lists differ in length",
3966 e.name
3967 );
3968 for (j, l) in jsx.iter().zip(lua) {
3969 assert!(
3970 shared_prop(j, Spelling::Camel),
3971 "{}: `{j}` is not a row",
3972 e.name
3973 );
3974 assert!(
3975 shared_prop(l, Spelling::Snake),
3976 "{}: `{l}` is not a row",
3977 e.name
3978 );
3979 assert_eq!(
3980 snake_case(j),
3981 *l,
3982 "{}: `{j}` and `{l}` are not one row",
3983 e.name
3984 );
3985 }
3986 if jsx.contains(&"onClick") {
3991 assert!(jsx.contains(&"label"), "{}: `label` missing", e.name);
3992 }
3993 }
3994 assert!(known_prop("button", "description", Spelling::Camel));
3995 assert!(known_prop("button", "on_click", Spelling::Snake));
3996 assert!(known_prop("button", "text", Spelling::Snake));
3997 assert!(!known_prop("button", "radius", Spelling::Camel));
3998 assert!(!known_prop("button", "text", Spelling::Camel));
3999 assert!(known_prop("box", "radius", Spelling::Camel));
4000 assert_eq!(
4003 suggest("button", "on_click", Spelling::Camel),
4004 Some("onClick")
4005 );
4006 assert_eq!(suggest("button", "hover_bg", Spelling::Camel), None);
4007 assert_eq!(suggest("box", "hover_bg", Spelling::Camel), Some("hoverBg"));
4008 }
4009
4010 #[test]
4016 fn text_admits_exactly_the_style_rows() {
4017 let style: Vec<&str> = PROPS
4018 .iter()
4019 .filter(|d| d.target() == Target::Style)
4020 .map(|d| d.name)
4021 .collect();
4022 let mut expect = vec!["size"];
4023 expect.extend(style);
4024 let mut got = TEXT_ROWS_JSX.to_vec();
4025 expect.sort_unstable();
4026 got.sort_unstable();
4027 assert_eq!(got, expect, "TEXT_ROWS_JSX is not the style rows");
4028 for name in ["lineHeight", "color", "wrap", "size"] {
4029 assert!(known_prop("text", name, Spelling::Camel), "{name}");
4030 }
4031 for name in ["live", "role", "label", "onClick", "key", "pad", "bg"] {
4032 assert_eq!(
4033 known_prop("text", name, Spelling::Camel),
4034 name == "bg",
4035 "{name}: `bg` is the element's own (a span's), the rest are not rows it reads"
4036 );
4037 }
4038 assert!(known_prop("text", "line_height", Spelling::Snake));
4039 assert!(known_prop("text", "value", Spelling::Snake));
4040 assert!(!known_prop("text", "live", Spelling::Snake));
4041 assert!(!known_prop("text", "on_click", Spelling::Snake));
4042 assert_eq!(
4045 suggest("text", "max_lines", Spelling::Camel),
4046 Some("maxLines")
4047 );
4048 assert_eq!(suggest("text", "hover_bg", Spelling::Camel), None);
4049 }
4050
4051 #[test]
4052 fn snake_names_round_trip() {
4053 assert_eq!(by_snake_name("min_width").unwrap().name, "minWidth");
4054 assert_eq!(by_snake_name("on_click").unwrap().name, "onClick");
4055 assert_eq!(by_snake_name("bg").unwrap().name, "bg");
4056 assert!(by_snake_name("minWidth").is_none());
4057 assert_eq!(by_name("lineHeight").unwrap().snake_name(), "line_height");
4058 assert_eq!(by_name("radiusTL").unwrap().snake_name(), "radius_tl");
4059 assert_eq!(by_snake_name("radius_bl").unwrap().name, "radiusBL");
4060 }
4061
4062 #[test]
4066 fn the_allow_list_is_per_spelling() {
4067 use Spelling::{Camel, Snake};
4068 assert!(known_prop("box", "hoverBg", Camel));
4069 assert!(known_prop("box", "hover_bg", Snake));
4070 assert!(!known_prop("box", "hover_bg", Camel));
4071 assert!(!known_prop("box", "hoverBg", Snake));
4072 assert!(known_prop("box", "padX", Camel) && !known_prop("box", "padX", Snake));
4074 assert!(known_prop("box", "scroll", Snake) && !known_prop("box", "scroll", Camel));
4075 assert!(known_prop("box", "direction", Snake));
4077 assert_eq!(lua_alias("direction"), Some("repeat"));
4078 assert!(known_prop("edit", "initial", Camel));
4080 assert!(!known_prop("box", "initial", Camel));
4081 assert!(known_prop("edit", "wrap", Camel) && known_prop("edit", "wrap", Snake));
4084 assert!(known_prop("image", "src", Camel) && known_prop("image", "id", Snake));
4085 assert!(!known_prop("box", "colour", Camel));
4087 }
4088
4089 #[test]
4090 fn a_suggestion_is_the_same_word_in_the_right_convention() {
4091 use Spelling::{Camel, Snake};
4092 assert_eq!(suggest("box", "hoverBg", Snake), Some("hover_bg"));
4093 assert_eq!(suggest("box", "onclick", Camel), Some("onClick"));
4094 assert_eq!(suggest("box", "SCROLL_X", Camel), Some("scrollX"));
4095 assert_eq!(suggest("edit", "Initial", Camel), Some("initial"));
4096 assert_eq!(suggest("box", "colour", Camel), None);
4098 }
4099
4100 #[test]
4103 fn every_composite_and_element_prop_is_in_the_allow_list() {
4104 for c in CUSTOM {
4105 for n in c.jsx_names {
4106 assert!(known_prop("box", n, Spelling::Camel), "jsx `{n}`");
4107 }
4108 for n in c.lua_names {
4109 assert!(known_prop("box", n, Spelling::Snake), "lua `{n}`");
4110 }
4111 }
4112 for e in ELEMENTS {
4113 for n in e.jsx_own {
4114 assert!(known_prop(e.name, n, Spelling::Camel), "{}.{n}", e.name);
4115 }
4116 for n in e.lua_own {
4117 assert!(known_prop(e.name, n, Spelling::Snake), "{}.{n}", e.name);
4118 }
4119 }
4120 }
4121
4122 #[test]
4123 fn every_row_applies_a_sample_of_its_kind() {
4124 for def in PROPS {
4125 let f = if def.name == "opacity" { 0.5 } else { 7.0 };
4128 let sample = match def.kind {
4129 Kind::F32 => Parsed::F32(f),
4130 Kind::Color => Parsed::Color(Color::hex(0x11223344)),
4131 Kind::Flag => Parsed::Flag,
4132 Kind::Enum(_) => Parsed::Enum(1),
4133 Kind::Sizing => Parsed::Sizing(Sizing::Percent(0.5)),
4134 Kind::Min => Parsed::Bound(Bound::Fit),
4135 Kind::Max => Parsed::Bound(Bound::Px(10.0)),
4136 Kind::Msg | Kind::Tag => Parsed::Msg(Value::Int(1)),
4137 Kind::Str if def.name == "scrollMods" => Parsed::Str("ctrl".into()),
4140 Kind::Str => Parsed::Str("name".into()),
4141 Kind::Family => Parsed::Family(FontFamily::Mono),
4142 Kind::Resource => Parsed::Resource(7),
4143 Kind::Keyframes => Parsed::Keyframes(vec![Keyframe::default().radius(7.0)]),
4144 Kind::Enter => Parsed::Enter(Enter::from(-7.0, 0.0)),
4145 Kind::Gradient => Parsed::Gradient(crate::gradient::Gradient::to(
4146 crate::gradient::Side::Right,
4147 [Color::hex(0x11223344), Color::WHITE],
4148 )),
4149 };
4150 let mut out = PropsOut::new();
4151 apply(def, sample, &mut out).unwrap();
4152 let changed = match def.target() {
4153 Target::Spec => out.spec != NodeSpec::column(),
4154 Target::Style => out.style != TextStyle::new(16.0),
4155 };
4156 assert!(changed, "{} applied a sample but nothing changed", def.name);
4157 }
4158 }
4159
4160 #[test]
4163 fn a_null_tag_still_declares_the_behaviour() {
4164 let mut out = PropsOut::new();
4165 apply(
4166 by_name("onKey").unwrap(),
4167 Parsed::Msg(Value::Null),
4168 &mut out,
4169 )
4170 .unwrap();
4171 assert_eq!(out.spec.events().on_key, Some(Value::Null));
4172 assert!(out.spec.hover_tracked());
4173 let mut out = PropsOut::new();
4174 apply(
4175 by_name("onLayout").unwrap(),
4176 Parsed::Msg(Value::Null),
4177 &mut out,
4178 )
4179 .unwrap();
4180 assert_eq!(out.spec.events().on_layout, Some(Value::Null));
4181 }
4182
4183 #[test]
4193 fn every_declarable_role_name_is_a_real_role() {
4194 for (i, name) in ROLES.iter().enumerate() {
4195 let role = Role::parse(name)
4196 .unwrap_or_else(|| panic!("ROLES[{i}] = {name:?} is not a Role::ALL name"));
4197 assert_eq!(role_idx(i), role, "role index {i} lowers to the wrong role");
4198 assert_eq!(role_idx(i).name(), *name);
4199 }
4200 }
4201
4202 #[test]
4211 fn every_role_is_declarable_or_derived() {
4212 for role in Role::ALL {
4213 let declarable = ROLES.contains(&role.name());
4214 let derived = DERIVED_ONLY.iter().any(|(n, _)| *n == role.name());
4215 assert!(
4216 declarable || derived,
4217 "Role::{role:?} is in Role::ALL but no view can declare it and \
4218 DERIVED_ONLY does not say the core derives it — add {:?} to \
4219 ROLES, or to DERIVED_ONLY with the reason",
4220 role.name()
4221 );
4222 assert!(
4223 !(declarable && derived),
4224 "Role::{role:?} is in ROLES, so it is declarable — drop it from \
4225 DERIVED_ONLY"
4226 );
4227 }
4228 for (name, why) in DERIVED_ONLY {
4229 assert!(!why.is_empty(), "{name:?} is exempted without a reason");
4230 assert!(
4231 Role::parse(name).is_some(),
4232 "DERIVED_ONLY names {name:?}, which is not a Role::ALL name"
4233 );
4234 }
4235 let derived = roles_one_frame_derives();
4236 for (name, _) in DERIVED_ONLY {
4237 assert!(
4238 derived.contains(&Role::parse(name).unwrap()),
4239 "DERIVED_ONLY keeps {name:?} out of ROLES because the core \
4240 derives it, but no frame does any more — either it belongs in \
4241 ROLES now, or the exemption is stale"
4242 );
4243 }
4244 }
4245
4246 fn roles_one_frame_derives() -> Vec<Role> {
4250 let mut core = crate::Core::new();
4251 let mut ui = core.frame(crate::Size::new(200.0, 200.0), 1.0);
4252 ui.window_title("Demo");
4253 ui.configure_root(NodeSpec::column().fill());
4254 ui.leaf_keyed("titlebar", NodeSpec::row().window_drag());
4255 ui.text_in_keyed(
4256 "scroll",
4257 NodeSpec::column().height(40.0).scroll_y(),
4258 "hello",
4259 TextStyle::new(12.0),
4260 );
4261 ui.finish();
4262 core.access_tree().nodes.iter().map(|n| n.role).collect()
4263 }
4264
4265 #[test]
4266 fn colors_and_sizings_parse() {
4267 assert_eq!(color_hex_str("#fff").unwrap(), Color::hex(0xffffffff));
4268 assert_eq!(color_hex_str("#11223344").unwrap(), Color::hex(0x11223344));
4269 assert!(color_hex_str("fff").is_err());
4270 assert_eq!(sizing_str("50%").unwrap(), Sizing::Percent(0.5));
4271 assert_eq!(sizing_str("grow").unwrap(), Sizing::Grow(1.0));
4272 assert!(sizing_str("wide").is_err());
4273 }
4274}