1use std::sync::LazyLock;
33
34use rustc_hash::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;
174
175pub const ALIGNS: &[&str] = &[
180 "start",
181 "center",
182 "end",
183 "spaceBetween",
184 "spaceAround",
185 "spaceEvenly",
186 "baseline",
187];
188pub const WINDOW_ROLES: &[&str] = &["drag", "close", "minimize", "maximize"];
189pub const SCROLLBARS: &[&str] = &["visible", "hidden", "auto"];
194pub const OVERSCROLLS: &[&str] = &["auto", "contain"];
199pub const SCROLL_AXES: &[&str] = &["both", "x", "y"];
203pub const FAMILIES: &[&str] = &["sans", "serif", "mono"];
206pub const CURSORS: &[&str] = &[
209 "default",
210 "text",
211 "pointer",
212 "grab",
213 "grabbing",
214 "notAllowed",
215 "ewResize",
216 "nsResize",
217 "nwseResize",
218 "neswResize",
219];
220
221pub fn cursor_idx(i: usize) -> CursorShape {
222 CURSORS
223 .get(i)
224 .and_then(|n| CursorShape::parse(n))
225 .unwrap_or(CursorShape::Default)
226}
227pub const WRAPS: &[&str] = &["word", "glyph", "none", "break-spaces"];
228pub const UNDERLINE_STYLES: &[&str] = UnderlineStyle::NAMES;
230pub const EXPANDED: &[&str] = &["collapsed", "expanded"];
235pub const LIVE: &[&str] = &["off", "polite", "assertive"];
239pub const ROLES: &[&str] = &[
245 "none",
246 "button",
247 "checkbox",
248 "radio",
249 "switch",
250 "slider",
251 "tab",
252 "tabList",
253 "link",
254 "heading",
255 "list",
256 "listItem",
257 "image",
258 "dialog",
259 "group",
260 "textInput",
261 "multilineTextInput",
262 "line",
263 "radioGroup",
267 "menu",
268 "menuItem",
269 "terminal",
271];
272
273pub const ORIENTATIONS: &[&str] = &["horizontal", "vertical"];
279
280pub const APPEARANCES: &[&str] = &["unknown", "light", "dark"];
286
287pub const MOTIONS: &[&str] = &["unknown", "full", "reduced"];
290
291pub const ASSISTIVE: &[&str] = &["unknown", "none", "listening"];
295
296pub const AUDIO_DEVICES: &[&str] = &["closed", "opening", "open", "failed"];
300
301pub const DERIVED_ONLY: &[(&str, &str)] = &[
308 (
309 "window",
310 "the root node of a frame, named by the window title",
311 ),
312 ("titleBar", "a `windowDrag` node"),
313 ("staticText", "a text node"),
314 ("scrollView", "a `scrollX` / `scrollY` node"),
315];
316
317pub fn role_idx(i: usize) -> Role {
340 ROLES
341 .get(i)
342 .and_then(|n| Role::parse(n))
343 .unwrap_or(Role::Group)
344}
345pub const EASINGS: &[&str] = &[
348 "easeOut",
349 "linear",
350 "easeIn",
351 "easeInOut",
352 "spring",
353 "bouncy",
354 "smooth",
355 "snappy",
356];
357
358pub const REPEATS: &[&str] = &["normal", "reverse", "alternate", "alternateReverse"];
360
361pub fn repeat_idx(i: usize) -> Repeat {
362 Repeat::from_index(i)
363}
364
365pub fn easing_idx(i: usize) -> Easing {
366 Easing::from_index(i)
367}
368
369pub enum Kind {
371 F32,
373 Color,
376 Flag,
378 Enum(&'static [&'static str]),
380 Sizing,
386 Min,
391 Max,
395 Msg,
397 Tag,
404 Str,
406 Family,
410 Resource,
414 Keyframes,
418 Enter,
421}
422
423pub enum Apply {
425 SpecF32(fn(NodeSpec, f32) -> NodeSpec),
426 SpecColor(fn(NodeSpec, Color) -> NodeSpec),
427 SpecFlag(fn(NodeSpec) -> NodeSpec),
428 SpecEnum(fn(NodeSpec, usize) -> NodeSpec),
429 SpecSizing(fn(NodeSpec, Sizing) -> NodeSpec),
430 SpecBound(fn(NodeSpec, Bound) -> NodeSpec),
431 SpecMsg(fn(NodeSpec, Value) -> NodeSpec),
432 SpecStr(fn(NodeSpec, &str) -> NodeSpec),
433 SpecKeyframes(fn(NodeSpec, Vec<Keyframe>) -> NodeSpec),
434 SpecEnter(fn(NodeSpec, Enter) -> NodeSpec),
435 SpecResource(fn(NodeSpec, u64) -> NodeSpec),
436 StyleF32(fn(TextStyle, f32) -> TextStyle),
437 StyleColor(fn(TextStyle, Color) -> TextStyle),
438 StyleEnum(fn(TextStyle, usize) -> TextStyle),
439 StyleFlag(fn(TextStyle) -> TextStyle),
440 StyleResource(fn(TextStyle, u64) -> TextStyle),
441 StyleStr(fn(TextStyle, &str) -> TextStyle),
442 StyleFamily(fn(TextStyle, FontFamily) -> TextStyle),
443}
444
445#[derive(Clone, Copy, Debug, PartialEq, Eq)]
446pub enum Target {
447 Spec,
449 Style,
451}
452
453pub struct PropDef {
454 pub name: &'static str,
456 pub id: u32,
457 pub kind: Kind,
458 pub apply: Apply,
459 pub doc: &'static str,
460}
461
462impl PropDef {
463 pub fn target(&self) -> Target {
464 match self.apply {
465 Apply::StyleF32(_)
466 | Apply::StyleColor(_)
467 | Apply::StyleEnum(_)
468 | Apply::StyleFlag(_)
469 | Apply::StyleResource(_)
470 | Apply::StyleStr(_)
471 | Apply::StyleFamily(_) => Target::Style,
472 _ => Target::Spec,
473 }
474 }
475
476 pub fn snake_name(&self) -> &'static str {
478 SNAKE_NAMES[self.index()]
479 }
480
481 fn index(&self) -> usize {
482 PROPS
485 .iter()
486 .position(|d| d.name == self.name)
487 .expect("PropDef not from PROPS")
488 }
489}
490
491pub fn align_idx(i: usize) -> Align {
492 match i {
493 1 => Align::Center,
494 2 => Align::End,
495 3 => Align::SpaceBetween,
496 4 => Align::SpaceAround,
497 5 => Align::SpaceEvenly,
498 6 => Align::Baseline,
499 _ => Align::Start,
500 }
501}
502
503pub fn min_num(mode: u32, value: f64) -> Bound {
507 match mode {
508 1 => Bound::Fit,
509 _ => Bound::Px(value as f32),
510 }
511}
512
513pub fn sizing_num(mode: u32, value: f64) -> Sizing {
515 match mode {
516 1 => Sizing::Fit,
517 2 => Sizing::Grow(value as f32),
518 3 => Sizing::Percent(value as f32),
519 _ => Sizing::Fixed(value as f32),
520 }
521}
522
523pub const PROPS: &[PropDef] = &[
524 PropDef {
525 name: "width",
526 id: P_WIDTH,
527 kind: Kind::Sizing,
528 apply: Apply::SpecSizing(|s, v| s.width(v)),
529 doc: "Horizontal size: px | \"fit\" | \"grow\" | \"N%\" | a size expression — \
530 `\"clamp(400px, 80%, 1000px)\"`, `\"min(720px, 100%)\"`, `\"max(50%, 300)\"`, \
531 nested — which layout resolves against the parent's content box, the box a \
532 percentage takes its cut of (backlog F109). An expression with no percentage \
533 in it is a length; a calc does not ease under `transition`. A percentage \
534 or an expression gives, with the fit children, when its parent overflows \
535 — two `\"50%\"` children and a gap fit their row (backlog F110) — where a \
536 px size keeps its own. A row holding one gives as CSS's flex items do: \
537 every child that can give gives in proportion to its size, and stops at \
538 its content — the widest thing in it that cannot wrap, a label's longest \
539 word — unless `minWidth` says otherwise (backlog RG92). The process keeps \
540 65 536 distinct expressions and never lets one go: past that a new one leaves \
541 its prop at its default, with a `size-expressions-full` warning, so declare \
542 one per layout — a px size for the part that moves each frame, a splitter's \
543 drag — not one per frame (backlog RG93).",
544 },
545 PropDef {
546 name: "height",
547 id: P_HEIGHT,
548 kind: Kind::Sizing,
549 apply: Apply::SpecSizing(|s, v| s.height(v)),
550 doc: "Vertical size: px | \"fit\" | \"grow\" | \"N%\" | a size expression (see `width`).",
551 },
552 PropDef {
553 name: "minWidth",
554 id: P_MIN_W,
555 kind: Kind::Min,
556 apply: Apply::SpecBound(|s, v| s.min_width(v)),
557 doc: "Lower width clamp: logical px, a size expression (see `width`; a percentage \
558 clamp is none until the parent's width is known, as in CSS), or \"fit\" for \
559 the node's own fit width. \
560 \"fit\" under `width=\"grow\"` is a content floor — CSS's `flex: 1 0 auto` — \
561 which is what an i3-style tab bar is: tabs that split the bar evenly \
562 while they fit and sit at their label's width, scrolling, once they do \
563 not. Opt-in, because a fit width is the unwrapped one: a paragraph in a \
564 grow column would stop wrapping under it. Left out, a child giving in an \
565 overflowing row that holds a percentage or a size expression stops at its \
566 content, CSS's `min-width: auto`; `0` lets it go below, CSS's \
567 `min-width: 0` (backlog RG92). A fit node across a column is no wider \
568 than the column's box — CSS's `fit-content` — down to this floor, 0 left \
569 out, unless the column scrolls x, so a text one wrapper deep in a capped \
570 card wraps there; \"fit\" keeps its content's width and runs past \
571 (backlog F116).",
572 },
573 PropDef {
574 name: "maxWidth",
575 id: P_MAX_W,
576 kind: Kind::Max,
577 apply: Apply::SpecBound(|s, v| s.max_width(v)),
578 doc: "Upper width clamp: logical px or a size expression (see `width`); \
579 grow+maxWidth is the responsive-width pattern.",
580 },
581 PropDef {
582 name: "minHeight",
583 id: P_MIN_H,
584 kind: Kind::Min,
585 apply: Apply::SpecBound(|s, v| s.min_height(v)),
586 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`).",
587 },
588 PropDef {
589 name: "maxHeight",
590 id: P_MAX_H,
591 kind: Kind::Max,
592 apply: Apply::SpecBound(|s, v| s.max_height(v)),
593 doc: "Upper height clamp: logical px or a size expression (see `width`).",
594 },
595 PropDef {
596 name: "gap",
597 id: P_GAP,
598 kind: Kind::F32,
599 apply: Apply::SpecF32(|s, v| s.gap(v)),
600 doc: "Space between children along the main axis.",
601 },
602 PropDef {
606 name: "wrapChildren",
607 id: P_WRAP_CHILDREN,
608 kind: Kind::Flag,
609 apply: Apply::SpecFlag(NodeSpec::wrap),
610 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.",
611 },
612 PropDef {
613 name: "crossGap",
614 id: P_CROSS_GAP,
615 kind: Kind::F32,
616 apply: Apply::SpecF32(|s, v| s.cross_gap(v)),
617 doc: "Space between wrap lines, across the main axis (`gap` stays the space along it).",
618 },
619 PropDef {
620 name: "mainAlign",
621 id: P_MAIN_ALIGN,
622 kind: Kind::Enum(ALIGNS),
623 apply: Apply::SpecEnum(|s, i| s.main_align(align_idx(i))),
624 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.",
625 },
626 PropDef {
627 name: "crossAlign",
628 id: P_CROSS_ALIGN,
629 kind: Kind::Enum(ALIGNS),
630 apply: Apply::SpecEnum(|s, i| s.cross_align(align_idx(i))),
631 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.",
632 },
633 PropDef {
634 name: "aspectRatio",
635 id: P_ASPECT_RATIO,
636 kind: Kind::F32,
637 apply: Apply::SpecF32(|s, v| s.aspect_ratio(v)),
638 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.",
639 },
640 PropDef {
641 name: "bg",
642 id: P_BG,
643 kind: Kind::Color,
644 apply: Apply::SpecColor(|s, c| s.bg(c)),
645 doc: "Background fill.",
646 },
647 PropDef {
648 name: "radius",
649 id: P_RADIUS,
650 kind: Kind::F32,
651 apply: Apply::SpecF32(|s, v| s.radius(v)),
652 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.",
653 },
654 PropDef {
655 name: "opacity",
656 id: P_OPACITY,
657 kind: Kind::F32,
658 apply: Apply::SpecF32(|s, v| s.opacity(v)),
659 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.",
660 },
661 PropDef {
662 name: "pixelSnap",
663 id: P_PIXEL_SNAP,
664 kind: Kind::Flag,
665 apply: Apply::SpecFlag(|s| s.pixel_snap()),
666 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.",
667 },
668 PropDef {
669 name: "shadowColor",
670 id: P_SHADOW_COLOR,
671 kind: Kind::Color,
672 apply: Apply::SpecColor(|s, c| s.shadow_color(c)),
673 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.",
674 },
675 PropDef {
676 name: "shadowBlur",
677 id: P_SHADOW_BLUR,
678 kind: Kind::F32,
679 apply: Apply::SpecF32(|s, v| s.shadow_blur(v)),
680 doc: "Drop-shadow blur radius (logical px): the edge ramps over this distance and reaches this far past the shape. 0 = a hard edge.",
681 },
682 PropDef {
683 name: "shadowX",
684 id: P_SHADOW_X,
685 kind: Kind::F32,
686 apply: Apply::SpecF32(|s, v| s.shadow_x(v)),
687 doc: "Drop-shadow horizontal offset (logical px).",
688 },
689 PropDef {
690 name: "shadowY",
691 id: P_SHADOW_Y,
692 kind: Kind::F32,
693 apply: Apply::SpecF32(|s, v| s.shadow_y(v)),
694 doc: "Drop-shadow vertical offset (logical px); positive casts downward.",
695 },
696 PropDef {
697 name: "shadowSpread",
698 id: P_SHADOW_SPREAD,
699 kind: Kind::F32,
700 apply: Apply::SpecF32(|s, v| s.shadow_spread(v)),
701 doc: "Grows (or, negative, shrinks) the drop shadow's shape before blurring (logical px).",
702 },
703 PropDef {
704 name: "radiusTL",
705 id: P_RADIUS_TL,
706 kind: Kind::F32,
707 apply: Apply::SpecF32(|s, v| s.radius_tl(v)),
708 doc: "Top-left corner radius (logical px).",
709 },
710 PropDef {
711 name: "radiusTR",
712 id: P_RADIUS_TR,
713 kind: Kind::F32,
714 apply: Apply::SpecF32(|s, v| s.radius_tr(v)),
715 doc: "Top-right corner radius (logical px).",
716 },
717 PropDef {
718 name: "radiusBR",
719 id: P_RADIUS_BR,
720 kind: Kind::F32,
721 apply: Apply::SpecF32(|s, v| s.radius_br(v)),
722 doc: "Bottom-right corner radius (logical px).",
723 },
724 PropDef {
725 name: "radiusBL",
726 id: P_RADIUS_BL,
727 kind: Kind::F32,
728 apply: Apply::SpecF32(|s, v| s.radius_bl(v)),
729 doc: "Bottom-left corner radius (logical px).",
730 },
731 PropDef {
732 name: "center",
733 id: P_CENTER,
734 kind: Kind::Flag,
735 apply: Apply::SpecFlag(|s| s.center()),
736 doc: "Center children on both axes.",
737 },
738 PropDef {
739 name: "hoverable",
740 id: P_HOVERABLE,
741 kind: Kind::Flag,
742 apply: Apply::SpecFlag(|s| s.hoverable()),
743 doc: "Hover-track without a click payload (for isHovered-driven styling).",
744 },
745 PropDef {
746 name: "animate",
747 id: P_ANIMATE,
748 kind: Kind::Flag,
749 apply: Apply::SpecFlag(|s| s.animate()),
750 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.",
751 },
752 PropDef {
753 name: "accent",
754 id: P_ACCENT,
755 kind: Kind::Flag,
756 apply: Apply::SpecFlag(|s| s.accent()),
757 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.",
758 },
759 PropDef {
760 name: "selectable",
761 id: P_SELECTABLE,
762 kind: Kind::Flag,
763 apply: Apply::SpecFlag(|s| s.selectable()),
764 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.",
765 },
766 PropDef {
767 name: "focusable",
768 id: P_FOCUSABLE,
769 kind: Kind::Flag,
770 apply: Apply::SpecFlag(|s| s.focusable()),
771 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.",
772 },
773 PropDef {
774 name: "keepFocus",
775 id: P_KEEP_FOCUS,
776 kind: Kind::Flag,
777 apply: Apply::SpecFlag(|s| s.keep_focus()),
778 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.",
779 },
780 PropDef {
781 name: "focusRegion",
782 id: P_FOCUS_REGION,
783 kind: Kind::Flag,
784 apply: Apply::SpecFlag(|s| s.focus_region()),
785 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.",
786 },
787 PropDef {
788 name: "scrollbar",
789 id: P_SCROLLBAR,
790 kind: Kind::Enum(SCROLLBARS),
791 apply: Apply::SpecEnum(|s, i| s.scrollbar(crate::spec::ScrollbarMode::ALL[i])),
792 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.",
793 },
794 PropDef {
795 name: "anchor",
796 id: P_ANCHOR,
797 kind: Kind::Flag,
798 apply: Apply::SpecFlag(|s| s.anchor()),
799 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.",
800 },
801 PropDef {
802 name: "scrollbarWidth",
803 id: P_SCROLLBAR_WIDTH,
804 kind: Kind::F32,
805 apply: Apply::SpecF32(|s, v| s.scrollbar_width(v)),
806 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.",
807 },
808 PropDef {
809 name: "scrollbarColor",
810 id: P_SCROLLBAR_COLOR,
811 kind: Kind::Color,
812 apply: Apply::SpecColor(|s, c| s.scrollbar_color(c)),
813 doc: "The thumb at rest; the default is the theme's `scrollbar` role, a translucent wash over whatever it sits on.",
814 },
815 PropDef {
816 name: "scrollbarActiveColor",
817 id: P_SCROLLBAR_ACTIVE_COLOR,
818 kind: Kind::Color,
819 apply: Apply::SpecColor(|s, c| s.scrollbar_active_color(c)),
820 doc: "The thumb under the pointer or while dragged; the default is the theme's `scrollbar_active` role.",
821 },
822 PropDef {
823 name: "overscroll",
824 id: P_OVERSCROLL,
825 kind: Kind::Enum(OVERSCROLLS),
826 apply: Apply::SpecEnum(|s, i| s.overscroll(crate::spec::Overscroll::ALL[i])),
827 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.",
828 },
829 PropDef {
830 name: "disabled",
831 id: P_DISABLED,
832 kind: Kind::Flag,
833 apply: Apply::SpecFlag(|s| s.disabled(true)),
834 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.",
835 },
836 PropDef {
837 name: "hoverBg",
838 id: P_HOVER_BG,
839 kind: Kind::Color,
840 apply: Apply::SpecColor(|s, c| s.hover_bg(c)),
841 doc: "Background while hovered (or while any node in its hoverGroup is); implies hover tracking, eases with `transition`.",
842 },
843 PropDef {
844 name: "rules",
845 id: P_RULES,
846 kind: Kind::Color,
847 apply: Apply::SpecColor(|s, c| s.rules(c)),
848 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.",
849 },
850 PropDef {
851 name: "ruleWidth",
852 id: P_RULE_WIDTH,
853 kind: Kind::F32,
854 apply: Apply::SpecF32(|s, v| s.rule_width(v)),
855 doc: "The width of a table's `rules` in logical px; 1 when unset.",
856 },
857 PropDef {
858 name: "dropBg",
859 id: P_DROP_BG,
860 kind: Kind::Color,
861 apply: Apply::SpecColor(|s, c| s.drop_bg(c)),
862 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`.",
863 },
864 PropDef {
865 name: "pressedBg",
866 id: P_PRESSED_BG,
867 kind: Kind::Color,
868 apply: Apply::SpecColor(|s, c| s.pressed_bg(c)),
869 doc: "Background while pressed (or while its hoverGroup is); implies hover tracking.",
870 },
871 PropDef {
872 name: "focusBg",
873 id: P_FOCUS_BG,
874 kind: Kind::Color,
875 apply: Apply::SpecColor(|s, c| s.focus_bg(c)),
876 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`.",
877 },
878 PropDef {
879 name: "modal",
880 id: P_MODAL,
881 kind: Kind::Tag,
882 apply: Apply::SpecMsg(|s, v| s.modal(v)),
883 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.",
884 },
885 PropDef {
886 name: "initialFocus",
887 id: P_INITIAL_FOCUS,
888 kind: Kind::Flag,
889 apply: Apply::SpecFlag(|s| s.initial_focus()),
890 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.",
891 },
892 PropDef {
893 name: "hoverGroup",
894 id: P_HOVER_GROUP,
895 kind: Kind::Str,
896 apply: Apply::SpecStr(|s, name| s.hover_group(name)),
897 doc: "Nodes sharing a group name show hoverBg/pressedBg together (a split button, a multi-piece shape).",
898 },
899 PropDef {
900 name: "onHover",
901 id: P_ON_HOVER,
902 kind: Kind::Tag,
903 apply: Apply::SpecMsg(|s, v| s.on_hover(v)),
904 doc: "Hover tag: the pointer entering/leaving emits {kind:\"hover\", phase:\"enter\"|\"leave\", tag} events.",
905 },
906 PropDef {
907 name: "onDrop",
908 id: P_ON_DROP,
909 kind: Kind::Tag,
910 apply: Apply::SpecMsg(|s, v| s.on_drop(v)),
911 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.",
912 },
913 PropDef {
914 name: "onLayout",
915 id: P_ON_LAYOUT,
916 kind: Kind::Tag,
917 apply: Apply::SpecMsg(|s, v| s.on_layout(v)),
918 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).",
919 },
920 PropDef {
921 name: "onClick",
922 id: P_ON_CLICK,
923 kind: Kind::Msg,
924 apply: Apply::SpecMsg(|s, v| s.on_click(v)),
925 doc: "Message emitted when clicked (data, not a callback).",
926 },
927 PropDef {
928 name: "onDrag",
929 id: P_ON_DRAG,
930 kind: Kind::Tag,
931 apply: Apply::SpecMsg(|s, v| s.on_drag(v)),
932 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.",
933 },
934 PropDef {
935 name: "onKey",
936 id: P_ON_KEY,
937 kind: Kind::Tag,
938 apply: Apply::SpecMsg(|s, v| s.on_key(v)),
939 doc: "Key-sink tag: with key focus held, presses arrive as {kind:\"key\", phase:\"down\", code, ...} events. Releases only with `keyUp` beside it.",
940 },
941 PropDef {
942 name: "keyUp",
943 id: P_KEY_UP,
944 kind: Kind::Flag,
945 apply: Apply::SpecFlag(|s| s.key_up()),
946 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.",
947 },
948 PropDef {
949 name: "modifierKeys",
950 id: P_MODIFIER_KEYS,
951 kind: Kind::Flag,
952 apply: Apply::SpecFlag(|s| s.modifier_keys()),
953 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.",
954 },
955 PropDef {
956 name: "onContextMenu",
957 id: P_ON_CONTEXT_MENU,
958 kind: Kind::Tag,
959 apply: Apply::SpecMsg(|s, v| s.on_context_menu(v)),
960 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.",
961 },
962 PropDef {
963 name: "onForceClick",
964 id: P_ON_FORCE_CLICK,
965 kind: Kind::Tag,
966 apply: Apply::SpecMsg(|s, v| s.on_force_click(v)),
967 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.",
968 },
969 PropDef {
970 name: "onButton",
971 id: P_ON_BUTTON,
972 kind: Kind::Tag,
973 apply: Apply::SpecMsg(|s, v| s.on_button(v)),
974 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.",
975 },
976 PropDef {
977 name: "buttons",
978 id: P_BUTTONS,
979 kind: Kind::Str,
980 apply: Apply::SpecStr(|s, v| s.buttons(crate::input::Buttons::parse(v))),
981 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`.",
982 },
983 PropDef {
984 name: "onScroll",
985 id: P_ON_SCROLL,
986 kind: Kind::Tag,
987 apply: Apply::SpecMsg(|s, v| s.on_scroll(v)),
988 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`).",
989 },
990 PropDef {
991 name: "scrollAxes",
992 id: P_SCROLL_AXES,
993 kind: Kind::Enum(SCROLL_AXES),
994 apply: Apply::SpecEnum(|s, i| s.scroll_axes(crate::spec::ScrollAxes::ALL[i])),
995 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`.",
996 },
997 PropDef {
998 name: "window",
999 id: P_WINDOW,
1000 kind: Kind::Enum(WINDOW_ROLES),
1001 apply: Apply::SpecEnum(|s, i| match i {
1002 0 => s.window_drag(),
1003 1 => s.window_button(WindowButton::Close),
1004 2 => s.window_button(WindowButton::Minimize),
1005 3 => s.window_button(WindowButton::Maximize),
1006 _ => s,
1007 }),
1008 doc: "Window-chrome role: interactions become window commands, not events.",
1009 },
1010 PropDef {
1011 name: "cursor",
1012 id: P_CURSOR,
1013 kind: Kind::Enum(CURSORS),
1014 apply: Apply::SpecEnum(|s, i| s.cursor(cursor_idx(i))),
1015 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.",
1016 },
1017 PropDef {
1018 name: "transition",
1019 id: P_TRANSITION,
1020 kind: Kind::F32,
1021 apply: Apply::SpecF32(|s, v| s.transition(v)),
1022 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).",
1023 },
1024 PropDef {
1025 name: "easing",
1026 id: P_EASING,
1027 kind: Kind::Enum(EASINGS),
1028 apply: Apply::SpecEnum(|s, i| s.easing(easing_idx(i))),
1029 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.",
1030 },
1031 PropDef {
1032 name: "bounce",
1033 id: P_BOUNCE,
1034 kind: Kind::F32,
1035 apply: Apply::SpecF32(|s, v| s.bounce(v)),
1036 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.",
1037 },
1038 PropDef {
1039 name: "slide",
1040 id: P_SLIDE,
1041 kind: Kind::Flag,
1042 apply: Apply::SpecFlag(|s| s.slide()),
1043 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.",
1044 },
1045 PropDef {
1046 name: "keyframes",
1047 id: P_KEYFRAMES,
1048 kind: Kind::Keyframes,
1049 apply: Apply::SpecKeyframes(|s, k| s.keyframes(k)),
1050 doc: "CSS-style stops `[{ at?, width?, height?, bg?, radius?, opacity? }, …]`: the slots they name cycle through them over `transition` ms, forever, without the view redrawing; `at` is 0..1 and spreads evenly when omitted.",
1051 },
1052 PropDef {
1053 name: "enter",
1054 id: P_ENTER,
1055 kind: Kind::Enter,
1056 apply: Apply::SpecEnter(|s, e| s.enter(e)),
1057 doc: "Where the node starts the first frame it is seen `{ dx?, dy?, width?, height?, bg?, radius?, opacity? }`: those slots ease in from there over `transition` ms instead of snapping (`dx`/`dy` slide it in from that far away, `opacity: 0` fades the whole subtree in).",
1058 },
1059 PropDef {
1060 name: "exit",
1061 id: P_EXIT,
1062 kind: Kind::Enter,
1063 apply: Apply::SpecEnter(|s, e| s.exit(e)),
1064 doc: "Where the node ends the frame after the view stops declaring it `{ dx?, dy?, width?, height?, bg?, radius?, opacity? }` — an `enter` read the other way. It plays when the node itself is removed, its parent still declared; a node that goes because an ancestor went — a tab switched away, a panel closed around it — goes at once with it, unless that ancestor has an `exit` of its own, whose picture carries it (backlog DX19; React's `AnimatePresence` rule). With a `transition`, the departing subtree is copied out of the last frame that had it and replayed frozen, in its place (the pass it painted in, just under the node that painted after it — a panel under a HUD leaves under it) and inert (no clicks, no Tab stop, no access row) while those slots ease from where they were, then dropped; without one it vanishes at once as it always did. `width`/`height` resize the departing node's own box only — the subtree inside it is a picture and is not laid out again. Needs a stable key across frames.",
1065 },
1066 PropDef {
1067 name: "repeat",
1068 id: P_REPEAT,
1069 kind: Kind::Enum(REPEATS),
1070 apply: Apply::SpecEnum(|s, i| s.repeat(repeat_idx(i))),
1071 doc: "How `keyframes` cycle (CSS `animation-direction`, default normal). Lua: `direction`, since `repeat` is a keyword.",
1072 },
1073 PropDef {
1074 name: "delay",
1075 id: P_DELAY,
1076 kind: Kind::F32,
1077 apply: Apply::SpecF32(|s, v| s.delay(v)),
1078 doc: "Holds the `keyframes` cycle back by this many ms (CSS `animation-delay`); siblings with different delays run out of phase.",
1079 },
1080 PropDef {
1081 name: "clickSound",
1082 id: P_CLICK_SOUND,
1083 kind: Kind::Resource,
1084 apply: Apply::SpecResource(|s, id| s.click_sound(crate::resources::SoundId::from_ffi(id))),
1085 doc: "A registered sound (addSound) played when the node is clicked; implies hover tracking.",
1086 },
1087 PropDef {
1088 name: "hoverSound",
1089 id: P_HOVER_SOUND,
1090 kind: Kind::Resource,
1091 apply: Apply::SpecResource(|s, id| s.hover_sound(crate::resources::SoundId::from_ffi(id))),
1092 doc: "A registered sound (addSound) played when the pointer enters the node; implies hover tracking.",
1093 },
1094 PropDef {
1095 name: "lineHeight",
1096 id: P_LINE_HEIGHT,
1097 kind: Kind::F32,
1098 apply: Apply::StyleF32(|t, v| t.line_height(v)),
1099 doc: "Line height (logical px); default size * 1.35.",
1100 },
1101 PropDef {
1102 name: "color",
1103 id: P_COLOR,
1104 kind: Kind::Color,
1105 apply: Apply::StyleColor(|t, c| t.color(c)),
1106 doc: "Text color; default foreground when omitted.",
1107 },
1108 PropDef {
1109 name: "family",
1110 id: P_FAMILY,
1111 kind: Kind::Family,
1112 apply: Apply::StyleFamily(|t, f| t.family(f)),
1113 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.",
1114 },
1115 PropDef {
1116 name: "font",
1117 id: P_FONT,
1118 kind: Kind::Resource,
1119 apply: Apply::StyleResource(|t, id| t.font(crate::resources::FontId::from_ffi(id))),
1120 doc: "A registered font handle (addFont / addSystemFont); overrides `family`.",
1121 },
1122 PropDef {
1123 name: "wrap",
1124 id: P_WRAP,
1125 kind: Kind::Enum(WRAPS),
1126 apply: Apply::StyleEnum(|t, i| match i {
1127 1 => t.wrap(TextWrap::Glyph),
1128 2 => t.wrap(TextWrap::None),
1129 3 => t.wrap(TextWrap::BreakSpaces),
1130 _ => t.wrap(TextWrap::Word),
1131 }),
1132 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`).",
1133 },
1134 PropDef {
1135 name: "maxLines",
1136 id: P_MAX_LINES,
1137 kind: Kind::F32,
1138 apply: Apply::StyleF32(|t, v| t.max_lines(v.max(0.0) as u32)),
1139 doc: "Lay out at most this many lines (0 = unlimited); with `ellipsis`, a line clamp.",
1140 },
1141 PropDef {
1142 name: "ellipsis",
1143 id: P_ELLIPSIS,
1144 kind: Kind::Flag,
1145 apply: Apply::StyleFlag(|t| t.ellipsis()),
1146 doc: "End the last line with an ellipsis when the text is cut off: a single line unless `maxLines` says otherwise.",
1147 },
1148 PropDef {
1149 name: "underline",
1150 id: P_UNDERLINE,
1151 kind: Kind::Flag,
1152 apply: Apply::StyleFlag(|t| t.underline()),
1153 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.",
1154 },
1155 PropDef {
1156 name: "underlineColor",
1157 id: P_UNDERLINE_COLOR,
1158 kind: Kind::Color,
1159 apply: Apply::StyleColor(|t, c| t.underline_color(c)),
1160 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.",
1161 },
1162 PropDef {
1163 name: "underlineStyle",
1164 id: P_UNDERLINE_STYLE,
1165 kind: Kind::Enum(UNDERLINE_STYLES),
1166 apply: Apply::StyleEnum(|t, i| t.underline_style(UnderlineStyle::from_index(i as u32))),
1167 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.",
1168 },
1169 PropDef {
1170 name: "strikethrough",
1171 id: P_STRIKETHROUGH,
1172 kind: Kind::Flag,
1173 apply: Apply::StyleFlag(|t| t.strikethrough()),
1174 doc: "A line through the text, where the face puts its strikeout. Paint only; on a `<span>` the span alone, per line.",
1175 },
1176 PropDef {
1177 name: "features",
1178 id: P_FEATURES,
1179 kind: Kind::Str,
1180 apply: Apply::StyleStr(|t, s| t.features(crate::spec::FontFeatures::parse(s))),
1181 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.",
1182 },
1183 PropDef {
1184 name: "role",
1185 id: P_ROLE,
1186 kind: Kind::Enum(ROLES),
1187 apply: Apply::SpecEnum(|s, i| s.role(role_idx(i))),
1188 doc: "What the node is to assistive technology. Unset, the core derives one (an `onClick` node is a button, an editor a text input, a scrolling box a scroll view, a plain box nothing); `none` hides the node and its subtree from the access tree. A `radio` belongs inside a `radioGroup` and a `tab` inside a `tabList`, labelled with what the choice is: the pair is a composite (`docs/adr/0007-composite-keyboard-patterns.md`) — one Tab stop for the set, the arrows, Home and End moving the choice inside it (each step is the item's click, so the choice follows focus), and a screen reader reading \"2 of 3\". A `radio` or `tab` with no container above it is a Tab stop of its own that no arrow moves, and the core warns (`item-outside-container`). `menu` holds `menuItem`s and `list` holds `listItem`s the same way.",
1189 },
1190 PropDef {
1191 name: "label",
1192 id: P_LABEL,
1193 kind: Kind::Str,
1194 apply: Apply::SpecStr(|s, v| s.label(v)),
1195 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`).",
1196 },
1197 PropDef {
1198 name: "description",
1199 id: P_DESCRIPTION,
1200 kind: Kind::Str,
1201 apply: Apply::SpecStr(|s, v| s.description(v)),
1202 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.",
1203 },
1204 PropDef {
1205 name: "checked",
1206 id: P_CHECKED,
1207 kind: Kind::Flag,
1208 apply: Apply::SpecFlag(|s| s.checked(true)),
1209 doc: "The on state of a `checkbox` / `radio` / `switch` role.",
1210 },
1211 PropDef {
1212 name: "mixed",
1213 id: P_MIXED,
1214 kind: Kind::Flag,
1215 apply: Apply::SpecFlag(|s| s.mixed(true)),
1216 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.",
1217 },
1218 PropDef {
1219 name: "selected",
1220 id: P_SELECTED,
1221 kind: Kind::Flag,
1222 apply: Apply::SpecFlag(|s| s.selected(true)),
1223 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.",
1224 },
1225 PropDef {
1226 name: "expanded",
1227 id: P_EXPANDED,
1228 kind: Kind::Enum(EXPANDED),
1229 apply: Apply::SpecEnum(|s, i| s.expanded(i == 1)),
1230 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.",
1231 },
1232 PropDef {
1233 name: "live",
1234 id: P_LIVE,
1235 kind: Kind::Enum(LIVE),
1236 apply: Apply::SpecEnum(|s, i| s.live(crate::access::Live::from_index(i))),
1237 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.",
1238 },
1239 PropDef {
1240 name: "valueNow",
1241 id: P_VALUE_NOW,
1242 kind: Kind::F32,
1243 apply: Apply::SpecF32(|s, v| s.value_now(v)),
1244 doc: "A `slider` role's current value (the drawing stays yours; this is what assistive technology reads).",
1245 },
1246 PropDef {
1247 name: "valueMin",
1248 id: P_VALUE_MIN,
1249 kind: Kind::F32,
1250 apply: Apply::SpecF32(|s, v| s.value_min(v)),
1251 doc: "A `slider` role's minimum.",
1252 },
1253 PropDef {
1254 name: "valueMax",
1255 id: P_VALUE_MAX,
1256 kind: Kind::F32,
1257 apply: Apply::SpecF32(|s, v| s.value_max(v)),
1258 doc: "A `slider` role's maximum.",
1259 },
1260 PropDef {
1261 name: "valueText",
1262 id: P_VALUE_TEXT,
1263 kind: Kind::Str,
1264 apply: Apply::SpecStr(|s, v| s.value_text(v)),
1265 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.",
1266 },
1267 PropDef {
1268 name: "valueStep",
1269 id: P_VALUE_STEP,
1270 kind: Kind::F32,
1271 apply: Apply::SpecF32(|s, v| s.value_step(v)),
1272 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.",
1273 },
1274 PropDef {
1275 name: "onFocus",
1276 id: P_ON_FOCUS,
1277 kind: Kind::Tag,
1278 apply: Apply::SpecMsg(|s, v| s.on_focus(v)),
1279 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.",
1280 },
1281 PropDef {
1282 name: "onChange",
1283 id: P_ON_CHANGE,
1284 kind: Kind::Tag,
1285 apply: Apply::SpecMsg(|s, v| s.on_change(v)),
1286 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.",
1287 },
1288 PropDef {
1289 name: "caret",
1290 id: P_CARET,
1291 kind: Kind::F32,
1292 apply: Apply::SpecF32(|s, v| s.caret(v.max(0.0) as u32)),
1293 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.",
1294 },
1295 PropDef {
1296 name: "selectionAnchor",
1297 id: P_SELECTION_ANCHOR,
1298 kind: Kind::F32,
1299 apply: Apply::SpecF32(|s, v| s.selection_anchor(v.max(0.0) as u32)),
1300 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).",
1301 },
1302 PropDef {
1303 name: "caretSolid",
1304 id: P_CARET_SOLID,
1305 kind: Kind::Flag,
1306 apply: Apply::SpecFlag(|s| s.caret_solid()),
1307 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.",
1308 },
1309];
1310
1311pub struct CustomProp {
1315 pub name: &'static str,
1316 pub id: u32,
1317 pub jsx_names: &'static [&'static str],
1324 pub lua_names: &'static [&'static str],
1326 pub jsx: &'static str,
1328 pub lua: &'static str,
1330 pub c: &'static str,
1332 pub doc: &'static str,
1333}
1334
1335pub const CUSTOM: &[CustomProp] = &[
1336 CustomProp {
1337 name: "dir",
1338 id: P_DIR,
1339 jsx_names: &["dir"],
1340 lua_names: &[],
1341 jsx: "`dir=\"row\" | \"column\" | \"table\"`",
1342 lua: "`row { }` / `column { }` / `grid { }`",
1343 c: "`dir` (`KUI_ROW` / `KUI_COLUMN` / `KUI_TABLE`)",
1344 doc: "Main axis; column is the default. `table` is a column whose rows' children line up in columns (the `table` element).",
1345 },
1346 CustomProp {
1347 name: "size",
1348 id: P_SIZE,
1349 jsx_names: &["size"],
1350 lua_names: &["size"],
1351 jsx: "`size` (text)",
1352 lua: "`size`",
1353 c: "`KuiTextStyle.size`",
1354 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.",
1355 },
1356 CustomProp {
1357 name: "pad",
1358 id: P_PAD,
1359 jsx_names: &["pad", "padX", "padY", "padL", "padR", "padT", "padB"],
1360 lua_names: &["pad"],
1361 jsx: "`pad`, `padX`, `padY`, `padL`, `padR`, `padT`, `padB`",
1362 lua: "`pad = n` or `pad = { all=, x=, y=, l=, r=, t=, b= }`",
1363 c: "`pad_l`, `pad_r`, `pad_t`, `pad_b`",
1364 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.",
1365 },
1366 CustomProp {
1367 name: "border",
1368 id: P_BORDER,
1369 jsx_names: &["borderW", "borderColor"],
1370 lua_names: &["border"],
1371 jsx: "`borderW`, `borderColor`",
1372 lua: "`border = { w=, color= }`",
1373 c: "`border_w`, `border_color`",
1374 doc: "Border width and color (drawn inside the rect).",
1375 },
1376 CustomProp {
1377 name: "overflow",
1378 id: P_OVERFLOW,
1379 jsx_names: &["clip", "scrollX", "scrollY"],
1380 lua_names: &["clip", "scroll", "scroll_x", "scroll_y"],
1381 jsx: "`clip`, `scrollX`, `scrollY`",
1382 lua: "`clip`, `scroll_x`, `scroll_y` (`scroll` = `scroll_y`)",
1383 c: "`overflow` bits `KUI_CLIP` | `KUI_SCROLL_X` | `KUI_SCROLL_Y`",
1384 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).",
1385 },
1386 CustomProp {
1387 name: "float",
1388 id: P_FLOAT,
1389 jsx_names: &["float"],
1390 lua_names: &["float"],
1391 jsx: "`float=\"below\" | \"above\" | \"parent\" | \"viewport\"` or `{ anchor, at, self, dx, dy, fit, clip }`",
1392 lua: "`float = \"below\"` or `float = { anchor=, at=, self=, dx=, dy=, fit=, clip= }`",
1393 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",
1394 doc: "Out-of-flow positioning against the parent or the viewport; `fit` flips/clamps to stay on screen. A float escapes every ancestor's clip — a tooltip is not cut by the scroller it hangs from — unless it declares `clip` and is anchored to its parent (`parent`, `below`, `above`): then the parent's clip holds it as it holds a child, so a node on a `clip` canvas panned past the canvas's edge is cut there and cannot be hit past it; it still paints as a layer over its in-flow siblings. A `line` or `polygon` in its parent's box is always clipped this way. The four preset names resolve in `FloatConfig::preset`, and `anchor` takes any of them — an override left out keeps the preset's own value, so `{ anchor: \"below\", dx: 4 }` still hangs below with its 6px gap. A float is a layer of its own: above the in-flow tree and every float that opened before it, under every float that opened after, and hit-tested in the same order — so a tooltip that appears over an open menu is over it, and a popover over a scroller's bar takes the press there. A scroller's bars and the focus ring belong to the layer that owns them. There is no z-index; a float declared under a fresh key reopens on top (`docs/adr/0023-layers-stack-in-the-order-they-open.md`).",
1395 },
1396 CustomProp {
1397 name: "keyFocus",
1398 id: P_KEY_FOCUS,
1399 jsx_names: &["keyFocus"],
1400 lua_names: &["key_focus"],
1401 jsx: "`keyFocus`",
1402 lua: "`key_focus`",
1403 c: "`kui_set_key_focus`",
1404 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`).",
1405 },
1406 CustomProp {
1407 name: "key",
1408 id: P_KEY,
1409 jsx_names: &["key"],
1410 lua_names: &["key"],
1411 jsx: "`key`",
1412 lua: "`key`",
1413 c: "`kui_open_keyed` label",
1414 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).",
1415 },
1416 CustomProp {
1417 name: "index",
1418 id: P_INDEX,
1419 jsx_names: &["index"],
1420 lua_names: &["index"],
1421 jsx: "`index`",
1422 lua: "`index`",
1423 c: "`kui_open_indexed`",
1424 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.",
1425 },
1426 CustomProp {
1427 name: "rowCount",
1428 id: P_ROW_COUNT,
1429 jsx_names: &["rowCount"],
1430 lua_names: &["row_count"],
1431 jsx: "`rowCount`",
1432 lua: "`row_count`",
1433 c: "`kui_row_count`",
1434 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.",
1435 },
1436 CustomProp {
1437 name: "title",
1438 id: P_TITLE,
1439 jsx_names: &["title"],
1440 lua_names: &["window_title"],
1441 jsx: "`title` (root box only)",
1442 lua: "`window_title` (root table)",
1443 c: "`kui_window_title`",
1444 doc: "Declares the window title for this frame; the driver diffs and applies.",
1445 },
1446 CustomProp {
1447 name: "alwaysOnTop",
1448 id: P_ALWAYS_ON_TOP,
1449 jsx_names: &["alwaysOnTop"],
1450 lua_names: &["always_on_top"],
1451 jsx: "`alwaysOnTop` (root box only)",
1452 lua: "`always_on_top = true` (root table)",
1453 c: "`kui_set_always_on_top`",
1454 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.",
1455 },
1456 CustomProp {
1457 name: "secureInput",
1458 id: P_SECURE_INPUT,
1459 jsx_names: &["secureInput"],
1460 lua_names: &["secure_input"],
1461 jsx: "`secureInput` (root box only)",
1462 lua: "`secure_input = true` (root table)",
1463 c: "`kui_set_secure_input`",
1464 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.",
1465 },
1466 CustomProp {
1467 name: "optionAsAlt",
1468 id: P_OPTION_AS_ALT,
1469 jsx_names: &["optionAsAlt"],
1470 lua_names: &["option_as_alt"],
1471 jsx: "`optionAsAlt=\"left\"` — `\"none\"`, `\"left\"`, `\"right\"`, `\"both\"` (root box only)",
1472 lua: "`option_as_alt = \"left\"` (root table)",
1473 c: "`kui_set_option_as_alt` (`KUI_OPTION_AS_ALT_*`)",
1474 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.",
1475 },
1476 CustomProp {
1477 name: "windows",
1478 id: P_WINDOWS,
1479 jsx_names: &["windows"],
1480 lua_names: &["windows"],
1481 jsx: "`windows={[{ name, kind?, anchor?, width?, height?, activates? }]}` (root box only; `windows: (model) => [...]` in the loop config)",
1482 lua: "`windows = { { name=, kind=, anchor=, width=, height=, activates= } }` (root table)",
1483 c: "`kui_window_declare`",
1484 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.",
1485 },
1486 CustomProp {
1487 name: "tooltip",
1488 id: P_TOOLTIP,
1489 jsx_names: &["tooltip"],
1490 lua_names: &["tooltip"],
1491 jsx: "`tooltip=\"hint\"`",
1492 lua: "`tooltip = \"hint\"`",
1493 c: "`KuiSpec.tooltip` (`kui_tooltip` / `kui_tooltip_with` draw a hint that is not hover-gated)",
1494 doc: "Floats a hint below the node while hovered. All three effects — hover tracking, the accessible description, and the float itself — come from `PropsOut::apply_tooltip`, so no frontend can implement two of them; a Rust view has all three in `NodeSpec::tooltip` (`NodeSpec::apply_tooltip` is the spec half, for a caller that floats the hint itself). The `description` row is that middle effect on its own, for a hint that is spoken and never drawn. The float is the node's last child, so it is drawn for a box or a `fragment`; on a leaf that holds no children — an `image`, an `edit`, a `cells` grid — the hint is tracked and spoken but not drawn, so put the tooltip on a box around it (backlog RG75).",
1495 },
1496];
1497
1498pub const C_FIELDS: &[(&str, &str)] = &[
1501 ("width", "`width` (KuiSizing)"),
1502 ("height", "`height` (KuiSizing)"),
1503 ("center", "`main_align` + `cross_align` = `KUI_CENTER`"),
1504 ("window", "`window_role` (`KUI_WINDOW_*`)"),
1505 ("transition", "`transition_ms`"),
1506 ("easing", "`easing` (`KUI_EASE_*`)"),
1507 (
1508 "keyframes",
1509 "`keyframes` + `keyframes_len` (`KuiKeyframe[]`)",
1510 ),
1511 ("repeat", "`repeat` (`KUI_REPEAT_*`)"),
1512 ("delay", "`delay_ms`"),
1513 ("enter", "`enter` (`KuiEnter`, with `set` bits)"),
1514 ("exit", "`exit` (`KuiEnter`, with `set` bits)"),
1515 ("opacity", "`opacity` with `opacity_set`"),
1516 ("radiusTL", "`radius_tl` with `per_corner`"),
1517 ("radiusTR", "`radius_tr` with `per_corner`"),
1518 ("radiusBR", "`radius_br` with `per_corner`"),
1519 ("radiusBL", "`radius_bl` with `per_corner`"),
1520 ("hoverGroup", "`hover_group` (KuiStr)"),
1521 ("role", "`role` (`KUI_ROLE_*`)"),
1522 ("expanded", "`expanded` (`KUI_EXPANDED_*`)"),
1523 ("live", "`live` (`KUI_LIVE_*`)"),
1524 ("cursor", "`cursor` (`KUI_CURSOR_*`)"),
1525 ("label", "`label` (KuiStr)"),
1526 (
1527 "valueNow",
1528 "`value_now` with `KUI_VALUE_NOW` in `value_set`",
1529 ),
1530 (
1531 "valueMin",
1532 "`value_min` with `KUI_VALUE_MIN` in `value_set`",
1533 ),
1534 (
1535 "valueMax",
1536 "`value_max` with `KUI_VALUE_MAX` in `value_set`",
1537 ),
1538 ("valueText", "`value_text` (KuiStr)"),
1539 ("caret", "`caret` with `KUI_VALUE_CARET` in `value_set`"),
1540 (
1541 "selectionAnchor",
1542 "`selection_anchor` with `KUI_VALUE_ANCHOR` in `value_set`",
1543 ),
1544 (
1545 "onClick",
1546 "`on_click` argument of `kui_open` / `kui_open_with`",
1547 ),
1548 (
1549 "onDrag",
1550 "`on_drag` argument of `kui_open_draggable` / `kui_open_with`",
1551 ),
1552 ("onKey", "`on_key` argument of `kui_open_with`"),
1553 (
1554 "onContextMenu",
1555 "`on_context_menu` (a borrowed `KuiValue*`, cloned while the node opens)",
1556 ),
1557 (
1558 "onButton",
1559 "`on_button` (a borrowed `KuiValue*`, cloned while the node opens)",
1560 ),
1561 (
1562 "buttons",
1563 "`buttons` (`KUI_BUTTONS_*` bits; zeroed, all three)",
1564 ),
1565 (
1566 "modal",
1567 "`modal` (a borrowed `KuiValue*`, cloned while the node opens)",
1568 ),
1569 (
1570 "onScroll",
1571 "`on_scroll` (a borrowed `KuiValue*`, cloned while the node opens)",
1572 ),
1573 ("onHover", "`on_hover` argument of `kui_open_with`"),
1574 (
1575 "onFocus",
1576 "`on_focus` (a borrowed `KuiValue*`, cloned while the node opens)",
1577 ),
1578 ("ruleWidth", "`rule_w`"),
1579 (
1580 "overscroll",
1581 "`overscroll` (`KUI_OVERSCROLL_*`; zeroed, auto)",
1582 ),
1583 (
1584 "scrollAxes",
1585 "`scroll_axes` (`KUI_SCROLL_AXES_*`; zeroed, both)",
1586 ),
1587 (
1588 "onDrop",
1589 "`on_drop` (a borrowed `KuiValue*`, cloned while the node opens)",
1590 ),
1591 (
1592 "onLayout",
1593 "`on_layout` (a borrowed `KuiValue*`, cloned while the node opens)",
1594 ),
1595 ("family", "`KuiTextStyle.family` (`KUI_FONT_*`)"),
1596 ("font", "`KuiTextStyle.font` (from `kui_font_add*`)"),
1597 ("lineHeight", "`KuiTextStyle.line_height`"),
1598 ("wrap", "`KuiTextStyle.wrap` (`KUI_WRAP_*`)"),
1599 ("maxLines", "`KuiTextStyle.max_lines`"),
1600 ("ellipsis", "`KuiTextStyle.ellipsis`"),
1601 (
1602 "features",
1603 "`KuiTextStyle.features` (a `KuiStr`, the same spelling)",
1604 ),
1605 (
1606 "underline",
1607 "`KuiTextStyle.decoration` (`KUI_DECO_UNDERLINE`); `KuiSpan.flags` (`KUI_SPAN_UNDERLINE`)",
1608 ),
1609 (
1610 "strikethrough",
1611 "`KuiTextStyle.decoration` (`KUI_DECO_STRIKETHROUGH`); `KuiSpan.flags` (`KUI_SPAN_STRIKETHROUGH`)",
1612 ),
1613 (
1614 "underlineColor",
1615 "`KuiTextStyle.underline_color`; `KuiSpan.underline_color`",
1616 ),
1617 (
1618 "underlineStyle",
1619 "`KuiTextStyle.underline_style` (`KUI_UNDERLINE_*`); `KuiSpan.underline_style`",
1620 ),
1621 ("color", "`KuiTextStyle.color`"),
1622];
1623
1624pub fn c_field(def: &PropDef) -> String {
1626 C_FIELDS
1627 .iter()
1628 .find(|(n, _)| *n == def.name)
1629 .map(|(_, c)| (*c).to_string())
1630 .unwrap_or_else(|| format!("`{}`", def.snake_name()))
1631}
1632
1633pub struct ElementDef {
1638 pub name: &'static str,
1639 pub jsx_own: &'static [&'static str],
1645 pub lua_own: &'static [&'static str],
1647 pub jsx_rows: Option<&'static [&'static str]>,
1655 pub lua_rows: Option<&'static [&'static str]>,
1656 pub jsx: &'static str,
1657 pub lua: &'static str,
1658 pub c: &'static str,
1659 pub doc: &'static str,
1660}
1661
1662pub const TEXT_ROWS_JSX: &[&str] = &[
1673 "size",
1674 "lineHeight",
1675 "color",
1676 "family",
1677 "font",
1678 "wrap",
1679 "maxLines",
1680 "ellipsis",
1681 "underline",
1682 "underlineColor",
1683 "underlineStyle",
1684 "strikethrough",
1685 "features",
1686];
1687pub const TEXT_ROWS_LUA: &[&str] = &[
1688 "size",
1689 "line_height",
1690 "color",
1691 "family",
1692 "font",
1693 "wrap",
1694 "max_lines",
1695 "ellipsis",
1696 "underline",
1697 "underline_color",
1698 "underline_style",
1699 "strikethrough",
1700 "features",
1701];
1702
1703pub const BUTTON_ROWS_JSX: &[&str] = &[
1711 "onClick",
1712 "key",
1713 "index",
1714 "label",
1715 "description",
1716 "tooltip",
1717 "disabled",
1718 "accent",
1719];
1720pub const BUTTON_ROWS_LUA: &[&str] = &[
1721 "on_click",
1722 "key",
1723 "index",
1724 "label",
1725 "description",
1726 "tooltip",
1727 "disabled",
1728 "accent",
1729];
1730
1731pub const TOGGLE_ROWS_JSX: &[&str] = &[
1736 "onClick",
1737 "key",
1738 "label",
1739 "description",
1740 "tooltip",
1741 "disabled",
1742 "checked",
1743 "mixed",
1744];
1745pub const TOGGLE_ROWS_LUA: &[&str] = &[
1746 "on_click",
1747 "key",
1748 "label",
1749 "description",
1750 "tooltip",
1751 "disabled",
1752 "checked",
1753 "mixed",
1754];
1755pub const SLIDER_ROWS_JSX: &[&str] = &[
1759 "key",
1760 "label",
1761 "description",
1762 "tooltip",
1763 "disabled",
1764 "valueNow",
1765 "valueMin",
1766 "valueMax",
1767 "valueStep",
1768 "valueText",
1769 "onChange",
1770 "width",
1771 "minWidth",
1772 "maxWidth",
1773];
1774pub const SLIDER_ROWS_LUA: &[&str] = &[
1775 "key",
1776 "label",
1777 "description",
1778 "tooltip",
1779 "disabled",
1780 "value_now",
1781 "value_min",
1782 "value_max",
1783 "value_step",
1784 "value_text",
1785 "on_change",
1786 "width",
1787 "min_width",
1788 "max_width",
1789];
1790
1791pub const ELEMENTS: &[ElementDef] = &[
1792 ElementDef {
1793 name: "box",
1794 jsx_own: &[],
1795 lua_own: &[],
1796 jsx_rows: None,
1797 lua_rows: None,
1798 jsx: "`<box>`",
1799 lua: "`row { }`, `column { }`",
1800 c: "`kui_open*` … `kui_close`",
1801 doc: "A container: every container prop applies.",
1802 },
1803 ElementDef {
1804 name: "table",
1805 jsx_own: &[],
1806 lua_own: &[],
1807 jsx_rows: None,
1808 lua_rows: None,
1809 jsx: "`<box dir=\"table\">`",
1810 lua: "`grid { }`",
1811 c: "`kui_open*` with `dir = KUI_TABLE`",
1812 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.",
1813 },
1814 ElementDef {
1815 name: "text",
1816 jsx_own: &["bold", "italic", "bg", "bgRadius"],
1817 lua_own: &["value", "spans"],
1818 jsx_rows: Some(TEXT_ROWS_JSX),
1819 lua_rows: Some(TEXT_ROWS_LUA),
1820 jsx: "`<text>` with `<span bold italic underline strikethrough bg bgRadius color>` children",
1821 lua: "`text(\"s\", {…})`, `text({ \"a\", { \"b\", bold = true, underline = true, bg = 0x.., bg_radius = 4 } })`",
1822 c: "`kui_text`, `kui_rich_text`",
1823 doc: "Plain or rich text; spans shape as one paragraph, so wrapping crosses style boundaries. A text is content plus a style and no box of its own, so the rows it reads are the style rows (`size`, `lineHeight`, `color`, `family`, `font`, `wrap`, `maxLines`, `ellipsis`, `underline`, `strikethrough`, `features`) and nothing else: a container row, an access row (`label`, `role`, `live`), `key` or `onClick` on a text is dropped with an `unknown-prop` warning naming the rows it does take — put them on the box around it. `wrap`, `maxLines` and `ellipsis` control line breaking. A span's `bg` is a background behind its glyphs alone, one rect per line it spans, so it follows the span across a wrap the way a box around a run cannot. With `bgRadius` (`bg_radius` in Lua, `KuiSpan.bg_radius` in C, `Span::bg_radius` in Rust; logical px) the background is rounded and joined into one shape with every rounded background of the same colour and radius it meets: a piece whose edge touches it exactly on the line above or below and overlaps it sideways, or that meets it end to end on its own line, in this text or another. Its corners are then convex where a line reaches past its neighbour, a fillet where it falls short, and round where nothing meets it — a selection over many rows, or over the wrapped lines of a paragraph, is one rounded outline, joined after every text of the frame is laid out and painted, so it is never a frame behind. Nothing names the shape: two that touch are one; `underline` and `strikethrough` on a span or on the whole text are lines where the face puts them. A text with no line breaks that is 4096 bytes or longer (and no `maxLines` or `ellipsis`), plain or spans alike, is shaped in ~1 KB chunks as they come on screen, so a minified bundle or a log line with a blob in it costs the screenful it shows and a keystroke into it — or a span moving along it, an editor's caret — costs the chunk it lands in; wrapped, the rows are broken from the chunks' positions, so a 100k-character paragraph costs the rows it shows. Its size is estimated from the first chunk until the rest shape (exact under monospace), and the access tree carries its value without its runs.",
1824 },
1825 ElementDef {
1826 name: "button",
1827 jsx_own: &[],
1828 lua_own: &["text"],
1829 jsx_rows: Some(BUTTON_ROWS_JSX),
1830 lua_rows: Some(BUTTON_ROWS_LUA),
1831 jsx: "`<button onClick key|index label description tooltip disabled accent>`",
1832 lua: "`button { label=, on_click=, key= | index=, text=, description=, tooltip=, disabled=, accent= }`",
1833 c: "`kui_button`, `kui_button_with`",
1834 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.",
1835 },
1836 ElementDef {
1837 name: "edit",
1838 jsx_own: &["id", "initial", "multiline", "autofocus"],
1839 lua_own: &["initial", "multiline", "autofocus"],
1840 jsx_rows: None,
1841 lua_rows: None,
1842 jsx: "`<edit key initial multiline autofocus>`, `<input label initial>`",
1843 lua: "`edit { key=, initial=, … }`, `input { label=, initial= }`",
1844 c: "`kui_text_edit`, `kui_text_input`",
1845 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.",
1846 },
1847 ElementDef {
1848 name: "select",
1849 jsx_own: &["label", "options", "current"],
1850 lua_own: &["label", "options", "current"],
1851 jsx_rows: Some(&[]),
1854 lua_rows: Some(&[]),
1855 jsx: "`<select label options={[…]} current>`",
1856 lua: "`dropdown { label=, options={…}, current= }`",
1857 c: "`kui_select`",
1858 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.",
1859 },
1860 ElementDef {
1861 name: "checkbox",
1862 jsx_own: &[],
1863 lua_own: &["text"],
1864 jsx_rows: Some(TOGGLE_ROWS_JSX),
1865 lua_rows: Some(TOGGLE_ROWS_LUA),
1866 jsx: "`<checkbox checked mixed onClick key label description tooltip disabled>text</checkbox>`",
1867 lua: "`checkbox { label=, checked=, mixed=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
1868 c: "`kui_checkbox`",
1869 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.",
1870 },
1871 ElementDef {
1872 name: "radio",
1873 jsx_own: &[],
1874 lua_own: &["text"],
1875 jsx_rows: Some(TOGGLE_ROWS_JSX),
1876 lua_rows: Some(TOGGLE_ROWS_LUA),
1877 jsx: "`<radio checked onClick key label description tooltip disabled>text</radio>`",
1878 lua: "`radio { label=, checked=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
1879 c: "`kui_radio`",
1880 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.",
1881 },
1882 ElementDef {
1883 name: "radioGroup",
1884 jsx_own: &[],
1885 lua_own: &[],
1886 jsx_rows: None,
1887 lua_rows: None,
1888 jsx: "`<radioGroup label>…radios…</radioGroup>`",
1889 lua: "`radio_group { label=, … }`",
1890 c: "`kui_radio_group_open` … `kui_close`",
1891 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.",
1892 },
1893 ElementDef {
1894 name: "switch",
1895 jsx_own: &[],
1896 lua_own: &["text"],
1897 jsx_rows: Some(TOGGLE_ROWS_JSX),
1898 lua_rows: Some(TOGGLE_ROWS_LUA),
1899 jsx: "`<switch checked onClick key label description tooltip disabled>text</switch>`",
1900 lua: "`switch { label=, checked=, on_click=, key=, text=, description=, tooltip=, disabled= }`",
1901 c: "`kui_switch`",
1902 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.",
1903 },
1904 ElementDef {
1905 name: "slider",
1906 jsx_own: &[],
1907 lua_own: &[],
1908 jsx_rows: Some(SLIDER_ROWS_JSX),
1909 lua_rows: Some(SLIDER_ROWS_LUA),
1910 jsx: "`<slider label valueNow valueMin valueMax valueStep valueText onChange width description tooltip disabled/>`",
1911 lua: "`slider { label=, value_now=, value_min=, value_max=, value_step=, value_text=, on_change=, width=, … }`",
1912 c: "`kui_slider`",
1913 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.",
1914 },
1915 ElementDef {
1916 name: "image",
1917 jsx_own: &["src", "sampling", "fit"],
1918 lua_own: &["id", "sampling", "fit"],
1919 jsx_rows: None,
1920 lua_rows: None,
1921 jsx: "`<image src={id} sampling fit>`",
1922 lua: "`image { id=, sampling=, fit= }`",
1923 c: "`kui_image`, `kui_image_with`",
1924 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.",
1925 },
1926 ElementDef {
1927 name: "polygon",
1928 jsx_own: &["points"],
1930 lua_own: &["points"],
1931 jsx_rows: None,
1932 lua_rows: None,
1933 jsx: "`<polygon points={[[x,y],…]} bg/>`",
1934 lua: "`polygon { points={{x,y},…}, bg= }`",
1935 c: "`kui_polygon`",
1936 doc: "A filled polygon through up to eight `points`, the fill in `bg` (`docs/adr/0025-the-image-is-the-canvas.md`, decision 6): an arrowhead, a pie slice, the area under a curve. Placed as a `line` is — always a float in its parent's box space (`float=\"viewport\"` for viewport space), sized to its own bounding box a pixel out on each side, so it takes no room in a row or column. A polygon in its parent's box space is held by the parent's clip as a child is, its hit region with it, so it is cut at a scroller's edge with the row it is drawn in; a declared float is held that way only when it declares `clip` with a parent anchor, and a `float=\"viewport\"` polygon escapes (backlog F78, `docs/adr/0010-a-segment-primitive.md` decision 5). `transition` eases the fill and, with `slide`, its position. The outline may be concave; a self-intersecting one fills even-odd, its overlaps unfilled. Hit by its outline (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press inside the outline hits it and one in its box past the outline falls through, so a pie's wedges need no hit boxes; with none it takes no input and has no access row, and with input it derives one as a box would (a clickable wedge is a button), so name it. A ninth point and later are dropped with `polygon-points-truncated`; fewer than three draw nothing; no `bg`, no fill. On the wire it is one `fragment` quad painted by a WGSL function the core registers itself, so a host that draws the list gets its source from `kui_fragment_source` like any other; what it costs is that quad and one pipeline switch per run of polygons. A stroked outline is a closed `line` over it.",
1937 },
1938 ElementDef {
1939 name: "fragment",
1940 jsx_own: &["src", "image", "params", "animate"],
1941 lua_own: &["id", "image", "params", "animate"],
1942 jsx_rows: None,
1943 lua_rows: None,
1944 jsx: "`<fragment src={id} image={id} params={[…]} animate>`",
1945 lua: "`fragment { id=, image=, params={…}, animate= }`",
1946 c: "`kui_fragment`, `kui_fragment_with`",
1947 doc: "A box a registered WGSL function paints (`docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md`): gradients, rings, noise, shimmer — anything the paint vocabulary has no prop for. An ordinary node otherwise — it lays out, rounds, clips, fades, takes input and holds children, which paint over it — but with **no intrinsic size**, so give it a `width`/`height` or `fill` or it is zero by zero. `src` is a handle from `add_fragment`, which validates the source and warns rather than minting one that cannot compile. `params` is up to sixteen numbers the shader reads as four `vec4<f32>`; more are dropped with a warning. `image` is a registered image the function reads — `kui_sample(uv)` (bilinear) and `kui_sample_nearest(uv)` return its texels at `uv` in `[0,1]²`, and `in.image` is its texel rect, `zw` the size — which is what makes a replaced image a waveform, a heatmap, a 50k-point line or an image effect from one quad (`docs/adr/0025-the-image-is-the-canvas.md`, decision 7); the core binds the atlas or the image's own texture, whichever holds it, and a fragment whose image is not live draws nothing, as one whose `src` is not does. `animate` asks for a frame every frame, which is what a fragment that reads `time` needs and what a still one must not declare.",
1948 },
1949 ElementDef {
1950 name: "cells",
1951 jsx_own: &[
1952 "rows",
1953 "cols",
1954 "cells",
1955 "cursorAt",
1956 "cursorShape",
1957 "cursorColor",
1958 "originLine",
1959 ],
1960 lua_own: &[
1961 "rows",
1962 "cols",
1963 "lines",
1964 "runs",
1965 "cursor_at",
1966 "cursor_shape",
1967 "cursor_color",
1968 "origin_line",
1969 ],
1970 jsx_rows: None,
1971 lua_rows: None,
1972 jsx: "`<cells rows cols cells={Uint32Array} cursorAt={[row, col]} cursorShape cursorColor size family lineHeight/>`",
1973 lua: "`cells { rows=, cols=, lines={\"row text\", …}, runs={{row, col, len, fg, bg, flags}, …}, cursor_at={row, col}, cursor_shape=, cursor_color=, size=, family= }`",
1974 c: "`kui_cells`",
1975 doc: "A terminal's screen as one node (backlog C20): `rows × cols` cells, each a character, a foreground and background as `0xRRGGBBAA` (0 = no background), and attribute bits — 1 bold, 2 italic, 4 underline, 8 strikethrough, 16 wide (the glyph spans this cell and the next, which the app leaves blank), 32 the underline is a wave (a terminal's undercurl, SGR 4:3) and 64 dotted (SGR 4:4), either implying it — plus, optionally, the underline's own colour (SGR 58), 0 for the foreground (backlog K4). A glyph is shaped once per character and style variant and thereafter placed at `col × cell_w` without shaping, so a screen whose every cell is new each frame costs what a still one costs (~60 µs for 200 × 50). The cell width is `M`'s advance in the style's font snapped to whole pixels, the height its `lineHeight`; a cell is a cell, so ligatures never form. Box drawing and block elements (U+2500–U+259F) and the Powerline separators (U+E0B0–U+E0BF: the arrows, and the Powerline Extra half circles and wedges) are not shaped at all but drawn from the cell box — a font's are its own line box tall, a cell is `lineHeight` tall, and through the font every `│` was a dash with a gap under it (backlog F66) and a rounded cap a fallback font's squiggle (F112) — so a TUI's frames and rounded rows are seamless in any font, and bold does not thicken a light line (the set has its heavy variants). JSX passes the cells as a `Uint32Array` (or number array) of four entries per cell — codepoint, fg, bg, flags — or five, with the underline colour, in row-major order; Lua a string per row in `lines` plus `runs` of `{row, col, len, fg, bg, flags, ul}` over them (a run's fg, bg or ul of 0 keeps the default: the style's colour, no background, the foreground); C a `KuiCell` array with `ul`. `cursorAt` (`cursor_at`) names a cell to paint under its glyph in `cursorColor` as a `block` (default), `bar` or `underline` — its own name, since `cursor` is the pointer shape. `originLine` (`origin_line`) is the absolute line number of row 0: a grid is one screenful of the app's own history, so a row number means a different line after every scroll, and stamping where the screen sits is what lets a selection keep its ends across one (`docs/adr/0017-selection-as-a-scope.md`). Saying nothing is 0, and a selection then holds only while the screen does not move. The node's own rows apply — an `onKey` makes it the terminal's sink, an `onClick` or `onDrag` carries `cell: {row, col}` on its events — and its access row is `terminal`, the rows joined as its value.",
1976 },
1977 ElementDef {
1978 name: "line",
1979 jsx_own: &["from", "to", "points", "curve"],
1982 lua_own: &["from", "to", "points", "curve"],
1983 jsx_rows: None,
1984 lua_rows: None,
1985 jsx: "`<line from={[x,y]} to={[x,y]} width color/>`, `<line points={[[x,y],…]} curve/>`",
1986 lua: "`line { from={x,y}, to={x,y}, width=, color= }`, `line { points={{x,y},…}, curve=true }`",
1987 c: "`kui_line`, `kui_polyline`",
1988 doc: "A round-capped stroke: one segment, a polyline through `points`, or a smooth curve through them with `curve`. Always a float in its parent's box space (`float=\"viewport\"` for viewport space), sized to its own bounding box, so it takes no room in a row or column. A stroke in its parent's box space is held by the parent's clip as a child is, its hit region with it, so it is cut at a scroller's edge with the row it is drawn in; a declared float is held that way only when it declares `clip` with a parent anchor, and a `float=\"viewport\"` stroke escapes (backlog F78, `docs/adr/0010-a-segment-primitive.md` decision 5). `width` is the stroke width (default 1) and `color` the stroke colour; `transition` eases the colour, and with `slide` beside it the stroke's position too — the points ride its box, so a stroke whose ends all move together slides with them, while one whose ends move apart resizes at once (a canvas of floats eases everything or nothing, connectors included). Hit by its shape (`docs/adr/0026-hit-testing-by-shape.md`): with `onClick`, `onDrag`, `onHover` or `hoverable` a press within half the width of any piece hits it — at least 4 px of grab, so a hairline is a target — and a press elsewhere in its bounding box falls through to what is under; with none it takes no input and has no access row, and with input it derives one as a box would (a clickable connector is a button), so name it. What it costs: one quad per segment, and a curve is flattened in the core at one piece per 6 logical px of chord (at most 32 per span) — fixed rather than tolerance-driven so every binding gets the same pieces and the corpus can pin them — so a nine-point curve over ~50 px spans is ~60 quads, and a `quadCount` budget should expect it.",
1989 },
1990 ElementDef {
1991 name: "titlebar",
1992 jsx_own: &[],
1993 lua_own: &["title"],
1996 jsx_rows: None,
1997 lua_rows: None,
1998 jsx: "`<titlebar title>` or `<titlebar>…</titlebar>`",
1999 lua: "`titlebar { title= }` / `titlebar { … }`",
2000 c: "`kui_titlebar`, `kui_titlebar_with`",
2001 doc: "Adaptive titlebar for custom chrome: drag strip, native-control inset, window buttons.",
2002 },
2003 ElementDef {
2004 name: "menuBar",
2005 jsx_own: &["menu"],
2006 lua_own: &["menu"],
2007 jsx_rows: None,
2008 lua_rows: None,
2009 jsx: "`<menuBar menu={[{ label, items: [{ label, id?, role?, accel?, enabled?, checked? }] }]}/>`",
2010 lua: "`menu_bar { menu = { { label=, items= { { label=, id=, role=, accel=, enabled=, checked= } } } } }`",
2011 c: "`kui_menu_bar`",
2012 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.",
2013 },
2014 ElementDef {
2015 name: "windowButtons",
2016 jsx_own: &[],
2017 lua_own: &[],
2018 jsx_rows: None,
2019 lua_rows: None,
2020 jsx: "`<windowButtons/>`",
2021 lua: "`window_buttons()`",
2022 c: "`kui_window_buttons`",
2023 doc: "Just the min/max/close buttons, for fully custom titlebars.",
2024 },
2025 ElementDef {
2026 name: "tooltip",
2027 jsx_own: &["value"],
2028 lua_own: &["value"],
2029 jsx_rows: None,
2030 lua_rows: None,
2031 jsx: "`<tooltip value=\"hint\"/>` / `<tooltip>…</tooltip>` nodes, or the `tooltip=\"hint\"` prop (see composites)",
2032 lua: "`tooltip(\"hint\")` / `tooltip { … }` nodes, or the prop",
2033 c: "`kui_tooltip`, `kui_tooltip_with`",
2034 doc: "A float hanging below the parent; the node form always draws, the prop form is hover-gated.",
2035 },
2036 ElementDef {
2037 name: "latencyGraph",
2038 jsx_own: &["at"],
2039 lua_own: &["at"],
2040 jsx_rows: None,
2041 lua_rows: None,
2042 jsx: "`<latencyGraph/>`, `<latencyHud at/>`",
2043 lua: "`latency_graph()`, `latency_hud { at= }`",
2044 c: "`kui_latency_graph`, `kui_latency_hud`",
2045 doc: "Per-phase frame timing (windowed drivers fill it; headless shows the chrome empty).",
2046 },
2047 ElementDef {
2048 name: "audio",
2049 jsx_own: &["src", "loop", "volume", "paused", "finish", "tag"],
2050 lua_own: &["src", "loop", "volume", "paused", "finish", "tag"],
2051 jsx_rows: None,
2052 lua_rows: None,
2053 jsx: "`<audio src={id} loop volume paused finish tag/>`",
2054 lua: "`audio { src=, loop=, volume=, paused=, finish=, tag= }`",
2055 c: "`kui_audio`",
2056 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.",
2057 },
2058];
2059
2060pub struct EventDef {
2062 pub kind: &'static str,
2063 pub payload: &'static str,
2064 pub doc: &'static str,
2065}
2066
2067pub const EVENTS: &[EventDef] = &[
2068 EventDef {
2069 kind: "click",
2070 payload: "the `onClick` payload as-is",
2071 doc: "A press and release on the node (suppressed when a drag moved past the slop). A map payload gains fields where the node can say more: `cell: {row, col}` on a `cells` grid, and inside an `onKey` sink that draws `role=\"line\"` rows, `line`, `byte` and `clicks` as a `drag` inside one carries them.",
2072 },
2073 EventDef {
2074 kind: "drag",
2075 payload: "`{ kind: \"drag\", phase: \"start\" | \"move\" | \"end\", x, y, dx, dy, parent: { x, y, w, h }, tag }`",
2076 doc: "A pointer-captured drag on an `onDrag` node. `x`/`y` are where the pointer is; `dx`/`dy` are its displacement **from the press point**, in every phase — `start` carries zero, a `move` how far the pointer is from where it pressed, `end` the whole distance — so a handler sets `value = start + dx` rather than summing deltas, and can commit from `end` alone. Nothing is dropped under the click slop (3 px, measured from the press): the first `move` already carries the whole distance. `parent` is the container rect, so fractions need no geometry query. On a `cells` grid every phase also carries `cell: {row, col}`. Inside an `onKey` sink that draws `role=\"line\"` rows — an editor the app owns — every phase carries `line` (the ordinal among the sink's lines, the numbering its `access` events use), `byte` (where the point falls in that line's text, what `textHit` would answer) and `clicks` (the press's count), so click-to-caret, drag-select and double-click-word are arithmetic on the event with no query and no frame of lag; a point above the first line is the first, below the last the last, and one in a `role=\"none\"` gutter is the line beside it. Nothing is added where the sink draws no lines.",
2077 },
2078 EventDef {
2079 kind: "key",
2080 payload: "`{ kind: \"key\", phase: \"down\" | \"up\", code, physical, shift, ctrl, alt, super, text, repeat, location, caps_lock, num_lock, tag }`",
2081 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`.",
2082 },
2083 EventDef {
2084 kind: "text",
2085 payload: "`{ kind: \"text\", text, pasted?: true, concealed?: true, transient?: true, tag }`",
2086 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.",
2087 },
2088 EventDef {
2089 kind: "preedit",
2090 payload: "`{ kind: \"preedit\", text, cursor: [start, end] | null, tag }`",
2091 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.",
2092 },
2093 EventDef {
2094 kind: "selectionrange",
2095 payload: "`{ kind: \"selectionrange\", from: { index, byte }, to: { index, byte } }` on the scope",
2096 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.",
2097 },
2098 EventDef {
2099 kind: "contextmenu",
2100 payload: "`{ kind: \"contextmenu\", x, y, tag }`",
2101 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`.",
2102 },
2103 EventDef {
2104 kind: "menu",
2105 payload: "`{ kind: \"menu\", role, item }`",
2106 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).",
2107 },
2108 EventDef {
2109 kind: "forceclick",
2110 payload: "`{ kind: \"forceclick\", x, y, tag }`",
2111 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.",
2112 },
2113 EventDef {
2114 kind: "button",
2115 payload: "`{ kind: \"button\", phase: \"press\" | \"move\" | \"release\", button: \"secondary\" | \"middle\" | number, x, y, clicks, cell?: { row, col }, line?, byte?, tag }`",
2116 doc: "A non-primary button on an `onButton` node that claims it (`buttons`), backlog F105: `press` where it went down — with the driver's click count, `clicks`, which the native runner keeps for the primary button alone and so always reports as 1 here — then `move` for every pointer move while it is held and `release` where it came up, both on the same node wherever the pointer went, since the press captured the button. `button` is the button's name, or for one past the middle button its number (`3 + n`, as `kui_input_mouse_button` takes it; Node's `ctx.mouse` takes the three names only); `x`/`y` are logical viewport coordinates. On a `cells` grid each carries `cell: {row, col}`, clamped to the grid, and inside an `onKey` sink that draws `role=\"line\"` rows `line` and `byte` as a drag does. A claimed secondary press is this event instead of `contextmenu`; the press moves no focus, caret, selection or scrollbar.",
2117 },
2118 EventDef {
2119 kind: "scroll",
2120 payload: "`{ kind: \"scroll\", x, y, dx, dy, lines, tag }`",
2121 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`).",
2122 },
2123 EventDef {
2124 kind: "focus",
2125 payload: "`{ kind: \"focus\", phase: \"in\" | \"out\", by: \"pointer\" | \"keyboard\" | \"assistive\" | \"program\", tag }`",
2126 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.",
2127 },
2128 EventDef {
2129 kind: "hover",
2130 payload: "`{ kind: \"hover\", phase: \"enter\" | \"leave\", by: \"pointer\" | \"content\", tag }`",
2131 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.",
2132 },
2133 EventDef {
2134 kind: "drop",
2135 payload: "`{ kind: \"drop\", phase: \"enter\" | \"move\" | \"leave\" | \"drop\", paths: string[], x, y, tag }`",
2136 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.",
2137 },
2138 EventDef {
2139 kind: "files",
2140 payload: "`{ kind: \"files\", paths: string[], tag }`",
2141 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.",
2142 },
2143 EventDef {
2144 kind: "layout",
2145 payload: "`{ kind: \"layout\", x, y, w, h, parent: { x, y, w, h }, scale, tag }`",
2146 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).",
2147 },
2148 EventDef {
2149 kind: "resize",
2150 payload: "`{ kind: \"resize\", width, height, scale }`",
2151 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.",
2152 },
2153 EventDef {
2154 kind: "window",
2155 payload: "`{ kind: \"window\", phase: \"opened\" | \"closed\" | \"focused\" | \"blurred\", name, id }`",
2156 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.",
2157 },
2158 EventDef {
2159 kind: "system",
2160 payload: "`{ kind: \"system\", appearance, accent, motion, locale, assistive }`",
2161 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.",
2162 },
2163 EventDef {
2164 kind: "fonts",
2165 payload: "`{ kind: \"fonts\" }`",
2166 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.",
2167 },
2168 EventDef {
2169 kind: "modifiers",
2170 payload: "`{ kind: \"modifiers\", shift, ctrl, alt, super }`",
2171 doc: "The physical modifier state changed (delivered to the host on the root).",
2172 },
2173 EventDef {
2174 kind: "changed",
2175 payload: "`{ kind: \"changed\" }`, with the editor's key on the event",
2176 doc: "An editor's text changed.",
2177 },
2178 EventDef {
2179 kind: "submit",
2180 payload: "`{ kind: \"submit\" }`, with the editor's key on the event",
2181 doc: "Enter in a single-line editor.",
2182 },
2183 EventDef {
2184 kind: "sound",
2185 payload: "`{ kind: \"sound\", phase: \"ended\" | \"refused\", playback, tag }`",
2186 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.",
2187 },
2188 EventDef {
2189 kind: "dismiss",
2190 payload: "`{ kind: \"dismiss\", reason: \"escape\" | \"outside\", tag }` on a node; `{ kind: \"dismiss\", reason, name, id }` on the root for a popup window",
2191 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.",
2192 },
2193 EventDef {
2194 kind: "access",
2195 payload: "`{ kind: \"access\", action, tag, text?, value?, anchor?: { line, offset }, focus?: { line, offset } }`",
2196 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.",
2197 },
2198 EventDef {
2199 kind: "change",
2200 payload: "`{ kind: \"change\", value, phase: \"move\" | \"end\", tag }`",
2201 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.",
2202 },
2203];
2204
2205pub struct ResourceDef {
2207 pub what: &'static str,
2208 pub node: &'static str,
2209 pub lua: &'static str,
2210 pub c: &'static str,
2211}
2212
2213pub const RESOURCES: &[ResourceDef] = &[
2214 ResourceDef {
2215 what: "image",
2216 node: "`ctx.addImage(w, h, rgba)` → id for `<image src>`",
2217 lua: "the host registers; `image { id }`",
2218 c: "`kui_image_add` → `kui_image`",
2219 },
2220 ResourceDef {
2221 what: "fragment (WGSL)",
2222 node: "`ctx.addFragment(src)` → id for `<fragment src>`",
2223 lua: "the host registers; `fragment { id }`",
2224 c: "`kui_fragment_add` → `kui_fragment`",
2225 },
2226 ResourceDef {
2227 what: "font from bytes",
2228 node: "`ctx.addFont(buffer)` → id for `font`",
2229 lua: "the host registers; `font = id`",
2230 c: "`kui_font_add` → `KuiTextStyle.font`",
2231 },
2232 ResourceDef {
2233 what: "font file by path",
2234 node: "`ctx.loadFontFile(\"fonts/Antonio.ttf\")` → id for `font`",
2235 lua: "the host registers; `font = id`",
2236 c: "`kui_font_load_file`",
2237 },
2238 ResourceDef {
2239 what: "a folder of fonts",
2240 node: "`ctx.loadFontsDir(\"fonts\")`, then pick by name",
2241 lua: "the host loads",
2242 c: "`kui_font_load_dir`",
2243 },
2244 ResourceDef {
2245 what: "font by family name (installed or loaded)",
2246 node: "`ctx.addSystemFont(\"Antonio\")` (see `systemFontFamilies()`)",
2247 lua: "the host registers; `font = id`",
2248 c: "`kui_font_add_system` (see `kui_font_families`)",
2249 },
2250 ResourceDef {
2251 what: "sound (wav / ogg / mp3 / flac bytes)",
2252 node: "`ctx.addSound(buffer)` → id for `<audio src>`, `clickSound`, `play(id)`",
2253 lua: "the host registers; `audio { src = id }`, `click_sound = id`",
2254 c: "`kui_sound_add` → `kui_audio`, `KuiSpec.click_sound`, `kui_play`",
2255 },
2256];
2257
2258pub struct EnvField {
2278 pub name: &'static str,
2280 pub from: &'static str,
2283 pub node: &'static [&'static str],
2287 pub lua: &'static [&'static str],
2290 pub c: &'static str,
2294 pub doc: &'static str,
2295 pub get: fn(&EnvFacts) -> Value,
2303}
2304
2305#[derive(Clone, Copy, Debug)]
2308pub struct EnvFacts {
2309 pub env: crate::env::Env,
2310 pub viewport: crate::geom::Size,
2311 pub scale: f32,
2312 pub focus: Option<crate::key::Key>,
2313 pub focus_visible: bool,
2314 pub region: Option<crate::key::Key>,
2315 pub caret_visible: bool,
2316}
2317
2318fn key_value(k: Option<crate::key::Key>) -> Value {
2319 k.map_or(Value::Null, |k| Value::Int(k.0 as i64))
2320}
2321
2322fn rect_value(r: crate::geom::Rect) -> Value {
2323 Value::map([
2324 ("x", Value::Float(r.x as f64)),
2325 ("y", Value::Float(r.y as f64)),
2326 ("w", Value::Float(r.w as f64)),
2327 ("h", Value::Float(r.h as f64)),
2328 ])
2329}
2330
2331pub const ENV_FIELDS: &[EnvField] = &[
2332 EnvField {
2333 name: "refresh_hz",
2334 get: |f| {
2335 f.env
2336 .refresh_hz
2337 .map_or(Value::Null, |hz| Value::Float(hz as f64))
2338 },
2339 from: "`Env::refresh_hz`",
2340 node: &["refreshHz"],
2341 lua: &["refresh_hz"],
2342 c: "`kui_env_set(refresh_hz)`",
2343 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.",
2344 },
2345 EnvField {
2346 name: "frame_budget_ms",
2347 get: |f| Value::Float(f.env.frame_budget_ms() as f64),
2348 from: "`Env::frame_budget_ms()`, derived",
2349 node: &["frameBudgetMs"],
2350 lua: &["frame_budget_ms"],
2351 c: "—",
2352 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.",
2353 },
2354 EnvField {
2355 name: "focused",
2356 get: |f| Value::Bool(f.env.focused),
2357 from: "`Env::focused`",
2358 node: &["focused"],
2359 lua: &["focused"],
2360 c: "`kui_env_set(focused)`",
2361 doc: "Whether the *window* has the keyboard at all. Not the focused node — that is the `focus` row.",
2362 },
2363 EnvField {
2364 name: "system.appearance",
2365 get: |f| Value::str(f.env.system.appearance.name()),
2366 from: "`SystemEnv::appearance`",
2367 node: &["system.appearance"],
2368 lua: &["system.appearance"],
2369 c: "`kui_env_set_system(appearance)`",
2370 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.",
2371 },
2372 EnvField {
2373 name: "system.accent",
2374 get: |f| {
2375 f.env
2376 .system
2377 .accent
2378 .map_or(Value::Null, |c| Value::Int(c.to_hex() as i64))
2379 },
2380 from: "`SystemEnv::accent`",
2381 node: &["system.accent"],
2382 lua: &["system.accent"],
2383 c: "`kui_env_set_system(accent)`",
2384 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.",
2385 },
2386 EnvField {
2387 name: "system.motion",
2388 get: |f| Value::str(f.env.system.motion.name()),
2389 from: "`SystemEnv::motion`",
2390 node: &["system.motion"],
2391 lua: &["system.motion"],
2392 c: "`kui_env_set_system(motion)`",
2393 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.",
2394 },
2395 EnvField {
2396 name: "system.locale",
2397 get: |f| {
2398 f.env
2399 .system
2400 .locale
2401 .map_or(Value::Null, |l| Value::str(l.as_str()))
2402 },
2403 from: "`SystemEnv::locale`",
2404 node: &["system.locale"],
2405 lua: &["system.locale"],
2406 c: "`kui_env_set_system(locale)`",
2407 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.",
2408 },
2409 EnvField {
2410 name: "system.assistive",
2411 get: |f| Value::str(f.env.system.assistive.name()),
2412 from: "`SystemEnv::assistive`",
2413 node: &["system.assistive"],
2414 lua: &["system.assistive"],
2415 c: "`kui_env_set_assistive(assistive)`",
2416 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.",
2417 },
2418 EnvField {
2419 name: "window.id",
2420 get: |f| Value::Int(f.env.window.id.0 as i64),
2421 from: "`WindowEnv::id`",
2422 node: &["window.id"],
2423 lua: &["window.id"],
2424 c: "`kui_env_set_window(window)`, read back by `kui_ctx_window`",
2425 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.",
2426 },
2427 EnvField {
2428 name: "window.custom_chrome",
2429 get: |f| Value::Bool(f.env.window.custom_chrome),
2430 from: "`WindowEnv::custom_chrome`",
2431 node: &["window.customChrome"],
2432 lua: &["window.custom_chrome"],
2433 c: "`kui_env_set_window(custom_chrome)`",
2434 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.",
2435 },
2436 EnvField {
2437 name: "window.maximized",
2438 get: |f| Value::Bool(f.env.window.maximized),
2439 from: "`WindowEnv::maximized`",
2440 node: &["window.maximized"],
2441 lua: &["window.maximized"],
2442 c: "`kui_env_set_window(maximized)`",
2443 doc: "The window is maximized — what picks the restore glyph over the maximize one.",
2444 },
2445 EnvField {
2446 name: "window.fullscreen",
2447 get: |f| Value::Bool(f.env.window.fullscreen),
2448 from: "`WindowEnv::fullscreen`",
2449 node: &["window.fullscreen"],
2450 lua: &["window.fullscreen"],
2451 c: "`kui_env_set_window(fullscreen)`",
2452 doc: "The window is fullscreen.",
2453 },
2454 EnvField {
2455 name: "window.always_on_top",
2456 get: |f| Value::Bool(f.env.window.always_on_top),
2457 from: "`WindowEnv::always_on_top`",
2458 node: &["window.alwaysOnTop"],
2459 lua: &["window.always_on_top"],
2460 c: "`kui_env_set_always_on_top(always_on_top)`",
2461 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.",
2462 },
2463 EnvField {
2464 name: "window.native_controls",
2465 get: |f| f.env.window.native_controls.map_or(Value::Null, rect_value),
2466 from: "`WindowEnv::native_controls`",
2467 node: &["window.nativeControls"],
2468 lua: &["window.controls_w", "window.controls_h"],
2469 c: "`kui_env_set_window(controls_w, controls_h)`",
2470 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.",
2471 },
2472 EnvField {
2473 name: "audio.device",
2474 get: |f| Value::str(f.env.audio.device.name()),
2475 from: "`AudioEnv::device`",
2476 node: &["audio.device"],
2477 lua: &["audio.device"],
2478 c: "`kui_env_set_audio(device)`",
2479 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.",
2480 },
2481 EnvField {
2482 name: "audio.live",
2483 get: |f| Value::Int(f.env.audio.live as i64),
2484 from: "`AudioEnv::live`",
2485 node: &["audio.live"],
2486 lua: &["audio.live"],
2487 c: "`kui_env_set_audio(live)`",
2488 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).",
2489 },
2490 EnvField {
2491 name: "viewport.w",
2492 get: |f| Value::Float(f.viewport.w as f64),
2493 from: "`Core::viewport()`, the frame's",
2494 node: &["viewport.width"],
2495 lua: &["viewport_w"],
2496 c: "`kui_frame_begin(w)`",
2497 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.",
2498 },
2499 EnvField {
2500 name: "viewport.h",
2501 get: |f| Value::Float(f.viewport.h as f64),
2502 from: "`Core::viewport()`, the frame's",
2503 node: &["viewport.height"],
2504 lua: &["viewport_h"],
2505 c: "`kui_frame_begin(h)`",
2506 doc: "Its logical height.",
2507 },
2508 EnvField {
2509 name: "scale",
2510 get: |f| Value::Float(f.scale as f64),
2511 from: "`Core::scale()`, the frame's",
2512 node: &["viewport.scale"],
2513 lua: &[],
2514 c: "`kui_frame_begin(scale)`",
2515 doc: "Device pixels per logical px. Lua has no reading: a script sees logical px only.",
2516 },
2517 EnvField {
2518 name: "focus",
2519 get: |f| key_value(f.focus),
2520 from: "`Core::focus()`, the frame's",
2521 node: &[],
2522 lua: &["focus"],
2523 c: "`kui_focused()`",
2524 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`.",
2525 },
2526 EnvField {
2527 name: "focus_visible",
2528 get: |f| Value::Bool(f.focus_visible),
2529 from: "`Core::focus_visible()`, the frame's",
2530 node: &[],
2531 lua: &["focus_visible"],
2532 c: "`kui_focus_visible()`",
2533 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.",
2534 },
2535 EnvField {
2536 name: "caret_visible",
2537 get: |f| Value::Bool(f.caret_visible),
2538 from: "`Core::caret_visible()`, the frame's",
2539 node: &[],
2540 lua: &["caret_visible"],
2541 c: "`kui_caret_visible()`",
2542 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).",
2543 },
2544 EnvField {
2545 name: "region",
2546 get: |f| key_value(f.region),
2547 from: "`Core::region()`, the frame's",
2548 node: &[],
2549 lua: &["region"],
2550 c: "`kui_region()`",
2551 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`.",
2552 },
2553];
2554
2555pub struct ThemeRole {
2566 pub name: &'static str,
2568 pub node: &'static str,
2570 pub doc: &'static str,
2571 pub get: fn(&crate::theme::Theme) -> crate::color::Color,
2573 pub set: fn(&mut crate::theme::Theme, crate::color::Color),
2576}
2577
2578pub const THEME_ROLES: &[ThemeRole] = &[
2579 ThemeRole {
2580 name: "bg",
2581 node: "bg",
2582 get: |t| t.bg,
2583 set: |t, c| t.bg = c,
2584 doc: "The window behind everything.",
2585 },
2586 ThemeRole {
2587 name: "surface",
2588 node: "surface",
2589 get: |t| t.surface,
2590 set: |t, c| t.surface = c,
2591 doc: "A card, panel or list sitting on `bg`.",
2592 },
2593 ThemeRole {
2594 name: "raised",
2595 node: "raised",
2596 get: |t| t.raised,
2597 set: |t, c| t.raised = c,
2598 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.",
2599 },
2600 ThemeRole {
2601 name: "sunken",
2602 node: "sunken",
2603 get: |t| t.sunken,
2604 set: |t, c| t.sunken = c,
2605 doc: "A well cut into a surface: a text field, a code block, a track.",
2606 },
2607 ThemeRole {
2608 name: "border",
2609 node: "border",
2610 get: |t| t.border,
2611 set: |t, c| t.border = c,
2612 doc: "The hairline between two surfaces.",
2613 },
2614 ThemeRole {
2615 name: "border_strong",
2616 node: "borderStrong",
2617 get: |t| t.border_strong,
2618 set: |t, c| t.border_strong = c,
2619 doc: "A border that has to be seen — a float's edge, a focused field.",
2620 },
2621 ThemeRole {
2622 name: "fg",
2623 node: "fg",
2624 get: |t| t.fg,
2625 set: |t, c| t.fg = c,
2626 doc: "Body text, and what a `color`-less text run resolves to.",
2627 },
2628 ThemeRole {
2629 name: "muted",
2630 node: "muted",
2631 get: |t| t.muted,
2632 set: |t, c| t.muted = c,
2633 doc: "Secondary text: captions, hints, an accelerator beside a label.",
2634 },
2635 ThemeRole {
2636 name: "faint",
2637 node: "faint",
2638 get: |t| t.faint,
2639 set: |t, c| t.faint = c,
2640 doc: "Text that is barely there: a placeholder, a gutter number.",
2641 },
2642 ThemeRole {
2643 name: "accent",
2644 node: "accent",
2645 get: |t| t.accent,
2646 set: |t, c| t.accent = c,
2647 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.",
2648 },
2649 ThemeRole {
2650 name: "accent_hover",
2651 node: "accentHover",
2652 get: |t| t.accent_hover,
2653 set: |t, c| t.accent_hover = c,
2654 doc: "`accent` under a pointer.",
2655 },
2656 ThemeRole {
2657 name: "accent_pressed",
2658 node: "accentPressed",
2659 get: |t| t.accent_pressed,
2660 set: |t, c| t.accent_pressed = c,
2661 doc: "`accent` under a press.",
2662 },
2663 ThemeRole {
2664 name: "on_accent",
2665 node: "onAccent",
2666 get: |t| t.on_accent,
2667 set: |t, c| t.on_accent = c,
2668 doc: "Black or white — whichever a reader can see on `accent`. What a button's label is.",
2669 },
2670 ThemeRole {
2671 name: "accent_soft",
2672 node: "accentSoft",
2673 get: |t| t.accent_soft,
2674 set: |t, c| t.accent_soft = c,
2675 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.",
2676 },
2677 ThemeRole {
2678 name: "selection",
2679 node: "selection",
2680 get: |t| t.selection,
2681 set: |t, c| t.selection = c,
2682 doc: "What a text selection is painted under, in an editor and over a `selectable` scope alike.",
2683 },
2684 ThemeRole {
2685 name: "focus_ring",
2686 node: "focusRing",
2687 get: |t| t.focus_ring,
2688 set: |t, c| t.focus_ring = c,
2689 doc: "The default keyboard focus ring (ADR 0002).",
2690 },
2691 ThemeRole {
2692 name: "hover",
2693 node: "hover",
2694 get: |t| t.hover,
2695 set: |t, c| t.hover = c,
2696 doc: "A translucent wash over a hovered neutral control. An overlay, not a fill, so one value works on every surface.",
2697 },
2698 ThemeRole {
2699 name: "pressed",
2700 node: "pressed",
2701 get: |t| t.pressed,
2702 set: |t, c| t.pressed = c,
2703 doc: "The same over a pressed one, and the firmer of the two on both bases.",
2704 },
2705 ThemeRole {
2706 name: "success",
2707 node: "success",
2708 get: |t| t.success,
2709 set: |t, c| t.success = c,
2710 doc: "A good outcome. Readable on `surface` on both bases, which is why it is not one colour for both.",
2711 },
2712 ThemeRole {
2713 name: "warning",
2714 node: "warning",
2715 get: |t| t.warning,
2716 set: |t, c| t.warning = c,
2717 doc: "Something that wants attention.",
2718 },
2719 ThemeRole {
2720 name: "danger",
2721 node: "danger",
2722 get: |t| t.danger,
2723 set: |t, c| t.danger = c,
2724 doc: "A destructive action or a failure. The close button's hover, too.",
2725 },
2726 ThemeRole {
2727 name: "scrollbar",
2728 node: "scrollbar",
2729 get: |t| t.scrollbar,
2730 set: |t, c| t.scrollbar = c,
2731 doc: "The scrollbar thumb at rest.",
2732 },
2733 ThemeRole {
2734 name: "scrollbar_active",
2735 node: "scrollbarActive",
2736 get: |t| t.scrollbar_active,
2737 set: |t, c| t.scrollbar_active = c,
2738 doc: "The thumb while hovered or dragged.",
2739 },
2740];
2741
2742pub struct MetricRole {
2747 pub name: &'static str,
2749 pub node: &'static str,
2751 pub doc: &'static str,
2752 pub get: fn(&crate::metrics::Metrics) -> f32,
2753 pub set: fn(&mut crate::metrics::Metrics, f32),
2754 pub platform: Option<PlatformValue>,
2761}
2762
2763#[derive(Clone, Copy, Debug, PartialEq)]
2765pub struct PlatformValue {
2766 pub windows: f32,
2767 pub elsewhere: f32,
2768}
2769
2770impl PlatformValue {
2771 pub const fn here(self) -> f32 {
2773 if cfg!(target_os = "windows") {
2774 self.windows
2775 } else {
2776 self.elsewhere
2777 }
2778 }
2779}
2780
2781macro_rules! metric_role {
2782 ($field:ident, $node:literal, $doc:literal) => {
2783 MetricRole {
2784 name: stringify!($field),
2785 node: $node,
2786 get: |m| m.$field,
2787 set: |m, v| m.$field = v,
2788 doc: $doc,
2789 platform: None,
2790 }
2791 };
2792 ($field:ident, $node:literal, $doc:literal, windows $w:expr, elsewhere $e:expr) => {
2793 MetricRole {
2794 name: stringify!($field),
2795 node: $node,
2796 get: |m| m.$field,
2797 set: |m, v| m.$field = v,
2798 doc: $doc,
2799 platform: Some(PlatformValue {
2800 windows: $w,
2801 elsewhere: $e,
2802 }),
2803 }
2804 };
2805}
2806
2807pub const METRIC_ROLES: &[MetricRole] = &[
2808 metric_role!(
2809 control_text,
2810 "controlText",
2811 "A stock control's label: the button's text size."
2812 ),
2813 metric_role!(
2814 chrome_text,
2815 "chromeText",
2816 "The chrome's text: a menu row, a menu-bar title, the titlebar's title."
2817 ),
2818 metric_role!(hint_text, "hintText", "A tooltip's text."),
2819 metric_role!(
2820 radius,
2821 "radius",
2822 "The corner of every stock surface: a button, a field, a menu, a tooltip."
2823 ),
2824 metric_role!(
2825 radius_inner,
2826 "radiusInner",
2827 "The corner of a row inside one: a menu row, a menu-bar title."
2828 ),
2829 metric_role!(
2830 control_pad_x,
2831 "controlPadX",
2832 "A button's horizontal padding."
2833 ),
2834 metric_role!(control_pad_y, "controlPadY", "A button's vertical padding."),
2835 metric_role!(
2836 field_pad_x,
2837 "fieldPadX",
2838 "A text field's horizontal padding."
2839 ),
2840 metric_role!(field_pad_y, "fieldPadY", "A text field's vertical padding."),
2841 metric_role!(hint_pad_x, "hintPadX", "A tooltip's horizontal padding."),
2842 metric_role!(hint_pad_y, "hintPadY", "A tooltip's vertical padding."),
2843 metric_role!(
2844 menu_pad_x,
2845 "menuPadX",
2846 "A menu row's horizontal padding, and a menu-bar title's."
2847 ),
2848 metric_role!(
2849 menu_pad_y,
2850 "menuPadY",
2851 "A menu row's vertical padding; a menu-bar title's is two px less."
2852 ),
2853 metric_role!(menu_width, "menuWidth", "A menu panel's width."),
2854 metric_role!(menu_bar_h, "menuBarH", "The drawn menu bar's height."),
2855 metric_role!(
2856 titlebar_h,
2857 "titlebarH",
2858 "The titlebar's height where the strip is the app's alone: the platform's caption height, 32 on Windows and 34 elsewhere. Under macOS custom chrome the strip is the OS's own titlebar, as tall as `window.native_controls` measures it (32 on macOS 27, 28 before), and this row is not read (`widgets::titlebar_height`).",
2859 windows crate::metrics::TITLEBAR_H_WINDOWS,
2860 elsewhere crate::metrics::TITLEBAR_H_ELSEWHERE
2861 ),
2862];
2863
2864static SNAKE_NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
2865 PROPS
2866 .iter()
2867 .map(|d| Box::leak(snake_case(d.name).into_boxed_str()) as &'static str)
2868 .collect()
2869});
2870
2871pub fn snake_case(name: &str) -> String {
2874 let mut out = String::with_capacity(name.len() + 2);
2875 let mut prev_upper = false;
2876 for c in name.chars() {
2877 if c.is_ascii_uppercase() {
2878 if !prev_upper {
2879 out.push('_');
2880 }
2881 out.push(c.to_ascii_lowercase());
2882 prev_upper = true;
2883 } else {
2884 out.push(c);
2885 prev_upper = false;
2886 }
2887 }
2888 out
2889}
2890
2891pub fn by_name(name: &str) -> Option<&'static PropDef> {
2892 PROPS.iter().find(|d| d.name == name)
2893}
2894
2895pub fn by_snake_name(name: &str) -> Option<&'static PropDef> {
2896 SNAKE_NAMES
2897 .iter()
2898 .position(|n| *n == name)
2899 .map(|i| &PROPS[i])
2900}
2901
2902pub fn by_id(id: u32) -> Option<&'static PropDef> {
2903 PROPS.iter().find(|d| d.id == id)
2904}
2905
2906#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2911pub enum Spelling {
2912 Camel,
2913 Snake,
2914}
2915
2916pub const LUA_ALIASES: &[(&str, &str)] = &[("direction", "repeat")];
2921
2922pub fn lua_alias(name: &str) -> Option<&'static str> {
2925 LUA_ALIASES
2926 .iter()
2927 .find(|(alias, _)| *alias == name)
2928 .map(|(_, real)| *real)
2929}
2930
2931static SHARED_NAMES: LazyLock<[FxHashSet<&'static str>; 2]> = LazyLock::new(|| {
2934 let mut camel: FxHashSet<&'static str> = PROPS.iter().map(|d| d.name).collect();
2935 let mut snake: FxHashSet<&'static str> = PROPS.iter().map(|d| d.snake_name()).collect();
2936 for c in CUSTOM {
2937 camel.extend(c.jsx_names);
2938 snake.extend(c.lua_names);
2939 }
2940 snake.extend(LUA_ALIASES.iter().map(|(alias, _)| *alias));
2941 [camel, snake]
2942});
2943
2944fn shared_names(spelling: Spelling) -> &'static FxHashSet<&'static str> {
2945 &SHARED_NAMES[(spelling == Spelling::Snake) as usize]
2946}
2947
2948pub fn element_own(element: &str, spelling: Spelling) -> &'static [&'static str] {
2951 match ELEMENTS.iter().find(|e| e.name == element) {
2952 Some(e) if spelling == Spelling::Camel => e.jsx_own,
2953 Some(e) => e.lua_own,
2954 None => &[],
2955 }
2956}
2957
2958pub fn element_rows(element: &str, spelling: Spelling) -> Option<&'static [&'static str]> {
2962 let e = ELEMENTS.iter().find(|e| e.name == element)?;
2963 if spelling == Spelling::Camel {
2964 e.jsx_rows
2965 } else {
2966 e.lua_rows
2967 }
2968}
2969
2970pub fn shared_prop(name: &str, spelling: Spelling) -> bool {
2976 shared_names(spelling).contains(name)
2977}
2978
2979pub fn known_prop(element: &str, name: &str, spelling: Spelling) -> bool {
2984 if element_own(element, spelling).contains(&name) {
2985 return true;
2986 }
2987 match element_rows(element, spelling) {
2988 Some(rows) => rows.contains(&name),
2989 None => shared_names(spelling).contains(name),
2990 }
2991}
2992
2993pub fn suggest(element: &str, name: &str, spelling: Spelling) -> Option<&'static str> {
2998 let squash = |s: &str| s.replace('_', "").to_ascii_lowercase();
2999 let want = squash(name);
3000 let near = |c: &&&str| squash(c) == want;
3001 let own = element_own(element, spelling).iter();
3002 match element_rows(element, spelling) {
3004 Some(rows) => rows.iter().chain(own).find(near).copied(),
3005 None => shared_names(spelling).iter().chain(own).find(near).copied(),
3006 }
3007}
3008
3009pub enum Parsed {
3011 F32(f32),
3012 Color(Color),
3013 Flag,
3014 Enum(usize),
3015 Sizing(Sizing),
3016 Bound(Bound),
3018 Msg(Value),
3019 Str(String),
3020 Resource(u64),
3021 Keyframes(Vec<Keyframe>),
3022 Enter(Enter),
3023 Family(FontFamily),
3025}
3026
3027#[derive(Clone, Debug, PartialEq)]
3029pub struct PropsOut {
3030 pub spec: NodeSpec,
3031 pub style: TextStyle,
3032 pub key: Option<String>,
3033 pub index: Option<u64>,
3036 pub row_count: Option<u64>,
3039 pub title: Option<String>,
3040 pub always_on_top: bool,
3043 pub secure_input: bool,
3046 pub option_as_alt: crate::OptionAsAlt,
3049 pub key_focus: bool,
3050 pub tooltip: Option<String>,
3053 pub windows: Vec<(String, WindowConfig)>,
3055 pub wrap: bool,
3060}
3061
3062#[derive(Clone, Copy, Debug, PartialEq)]
3066pub enum Identity<'a> {
3067 Auto,
3068 Label(&'a str),
3069 Index(u64),
3070}
3071
3072impl PropsOut {
3073 pub fn identity(&self) -> Identity<'_> {
3075 match (self.index, &self.key) {
3076 (Some(i), _) => Identity::Index(i),
3077 (None, Some(label)) => Identity::Label(label),
3078 (None, None) => Identity::Auto,
3079 }
3080 }
3081
3082 pub fn new() -> Self {
3083 PropsOut {
3084 spec: NodeSpec::column(),
3085 style: TextStyle::new(16.0),
3086 key: None,
3087 index: None,
3088 row_count: None,
3089 title: None,
3090 always_on_top: false,
3091 secure_input: false,
3092 option_as_alt: crate::OptionAsAlt::None,
3093 key_focus: false,
3094 tooltip: None,
3095 windows: Vec::new(),
3096 wrap: false,
3097 }
3098 }
3099
3100 pub fn with_spec(&mut self, f: impl FnOnce(NodeSpec) -> NodeSpec) {
3102 self.spec = f(std::mem::take(&mut self.spec));
3103 }
3104
3105 pub fn apply_tooltip(&mut self, hint: impl Into<String>) {
3110 let hint = hint.into();
3111 self.with_spec(|s| s.apply_tooltip(&hint));
3112 self.tooltip = Some(hint);
3113 }
3114
3115 pub fn apply_pad(&mut self, pad: PadShorthand) {
3119 if pad.declared() {
3120 let edges = pad.resolve();
3121 self.with_spec(|s| s.padding(edges));
3122 }
3123 }
3124}
3125
3126impl Default for PropsOut {
3127 fn default() -> Self {
3128 Self::new()
3129 }
3130}
3131
3132pub fn apply(def: &PropDef, value: Parsed, out: &mut PropsOut) -> Result<(), String> {
3136 let spec = std::mem::take(&mut out.spec);
3137 let style = out.style;
3138 out.wrap |= def.id == P_WRAP;
3141 match (&def.apply, value) {
3142 (Apply::SpecF32(f), Parsed::F32(v)) => out.spec = f(spec, v),
3143 (Apply::SpecColor(f), Parsed::Color(v)) => out.spec = f(spec, v),
3144 (Apply::SpecFlag(f), Parsed::Flag) => out.spec = f(spec),
3145 (Apply::SpecEnum(f), Parsed::Enum(v)) => out.spec = f(spec, v),
3146 (Apply::SpecSizing(f), Parsed::Sizing(v)) => out.spec = f(spec, v),
3147 (Apply::SpecBound(f), Parsed::Bound(v)) => out.spec = f(spec, v),
3148 (Apply::SpecMsg(f), Parsed::Msg(v)) => out.spec = f(spec, v),
3149 (Apply::SpecStr(f), Parsed::Str(v)) => out.spec = f(spec, &v),
3150 (Apply::SpecKeyframes(f), Parsed::Keyframes(v)) => out.spec = f(spec, v),
3151 (Apply::SpecEnter(f), Parsed::Enter(v)) => out.spec = f(spec, v),
3152 (Apply::SpecResource(f), Parsed::Resource(v)) => out.spec = f(spec, v),
3153 (Apply::StyleF32(f), Parsed::F32(v)) => {
3154 out.spec = spec;
3155 out.style = f(style, v);
3156 }
3157 (Apply::StyleColor(f), Parsed::Color(v)) => {
3158 out.spec = spec;
3159 out.style = f(style, v);
3160 }
3161 (Apply::StyleEnum(f), Parsed::Enum(v)) => {
3162 out.spec = spec;
3163 out.style = f(style, v);
3164 }
3165 (Apply::StyleFlag(f), Parsed::Flag) => {
3166 out.spec = spec;
3167 out.style = f(style);
3168 }
3169 (Apply::StyleResource(f), Parsed::Resource(v)) => {
3170 out.spec = spec;
3171 out.style = f(style, v);
3172 }
3173 (Apply::StyleStr(f), Parsed::Str(v)) => {
3174 out.spec = spec;
3175 out.style = f(style, &v);
3176 }
3177 (Apply::StyleFamily(f), Parsed::Family(v)) => {
3178 out.spec = spec;
3179 out.style = f(style, v);
3180 }
3181 _ => {
3182 out.spec = spec;
3183 return Err(format!("prop {} value/kind mismatch", def.name));
3184 }
3185 }
3186 Ok(())
3187}
3188
3189pub fn enum_index(names: &[&str], s: &str) -> Result<usize, String> {
3191 names
3192 .iter()
3193 .position(|n| *n == s)
3194 .ok_or_else(|| format!("bad value {s:?} (one of {names:?})"))
3195}
3196
3197pub fn color_num(hex: u32) -> Color {
3202 if hex == 0 {
3203 Color::TRANSPARENT
3204 } else {
3205 Color::hex(hex)
3206 }
3207}
3208
3209pub fn color_hex_str(s: &str) -> Result<Color, String> {
3211 let bad = || format!("bad color {s:?}");
3212 let hex = s.strip_prefix('#').ok_or_else(bad)?;
3213 let expanded = match hex.len() {
3214 3 => hex
3215 .chars()
3216 .flat_map(|c| [c, c])
3217 .chain("ff".chars())
3218 .collect::<String>(),
3219 6 => format!("{hex}ff"),
3220 8 => hex.to_string(),
3221 _ => return Err(bad()),
3222 };
3223 let n = u32::from_str_radix(&expanded, 16).map_err(|_| bad())?;
3224 Ok(Color::hex(n))
3225}
3226
3227pub const SIZE_MODE_CALC: u32 = 4;
3230
3231pub const SIZE_MODE_TREE: u32 = 5;
3236
3237pub fn min_str(s: &str) -> Result<Bound, String> {
3240 match s {
3241 "fit" => Ok(Bound::Fit),
3242 _ => crate::calc::bound(s)
3243 .map_err(|e| format!("bad min {s:?} (number | \"fit\" | a size expression): {e}")),
3244 }
3245}
3246
3247pub fn max_str(s: &str) -> Result<Bound, String> {
3249 crate::calc::bound(s).map_err(|e| format!("bad max {s:?} (number | a size expression): {e}"))
3250}
3251
3252pub fn sizing_str(s: &str) -> Result<Sizing, String> {
3255 match s {
3256 "fit" => Ok(Sizing::Fit),
3257 "grow" => Ok(Sizing::Grow(1.0)),
3258 _ => crate::calc::sizing(s).map_err(|e| {
3259 format!("bad sizing {s:?} (fit | grow | number | \"N%\" | a size expression): {e}")
3260 }),
3261 }
3262}
3263
3264#[cfg(test)]
3265mod tests {
3266 use super::*;
3267
3268 #[test]
3275 fn every_enum_list_is_its_enum_s_all_by_name() {
3276 fn names(it: &[&'static str]) -> Vec<&'static str> {
3277 it.to_vec()
3278 }
3279 assert_eq!(
3280 Easing::ALL.iter().map(|e| e.name()).collect::<Vec<_>>(),
3281 names(EASINGS)
3282 );
3283 assert_eq!(
3284 Repeat::ALL.iter().map(|r| r.name()).collect::<Vec<_>>(),
3285 names(REPEATS)
3286 );
3287 assert_eq!(
3288 crate::access::Live::ALL
3289 .iter()
3290 .map(|l| l.name())
3291 .collect::<Vec<_>>(),
3292 names(LIVE)
3293 );
3294 assert_eq!(
3295 FontFamily::ALL
3296 .iter()
3297 .map(|f| f.name().expect("a stock family has a name"))
3298 .collect::<Vec<_>>(),
3299 names(FAMILIES)
3300 );
3301 for (i, e) in Easing::ALL.iter().enumerate() {
3303 assert_eq!(easing_idx(i), *e);
3304 }
3305 for (i, r) in Repeat::ALL.iter().enumerate() {
3306 assert_eq!(repeat_idx(i), *r);
3307 }
3308 for (i, l) in crate::access::Live::ALL.iter().enumerate() {
3309 assert_eq!(crate::access::Live::from_index(i), *l);
3310 }
3311 for (i, f) in FontFamily::ALL.iter().enumerate() {
3312 assert_eq!(FontFamily::from_index(i), *f);
3313 }
3314 assert_eq!(easing_idx(99), Easing::default());
3316 assert_eq!(repeat_idx(99), Repeat::default());
3317 assert_eq!(FontFamily::from_index(99), FontFamily::Sans);
3318 }
3319
3320 #[test]
3321 fn ids_and_names_are_unique() {
3322 let all: Vec<(&str, u32)> = PROPS
3323 .iter()
3324 .map(|d| (d.name, d.id))
3325 .chain(CUSTOM.iter().map(|c| (c.name, c.id)))
3326 .collect();
3327 for (i, (name, id)) in all.iter().enumerate() {
3328 for (other_name, other_id) in &all[i + 1..] {
3329 assert_ne!(name, other_name, "duplicate prop name");
3330 assert_ne!(id, other_id, "duplicate wire id for {name} / {other_name}");
3331 }
3332 }
3333 }
3334
3335 fn snake_to_camel(name: &str) -> String {
3337 let mut out = String::new();
3338 let mut up = false;
3339 for ch in name.chars() {
3340 if ch == '_' {
3341 up = true;
3342 } else if up {
3343 out.extend(ch.to_uppercase());
3344 up = false;
3345 } else {
3346 out.push(ch);
3347 }
3348 }
3349 out
3350 }
3351
3352 #[test]
3356 fn metric_roles_restate_the_metrics_exactly() {
3357 use crate::metrics::Metrics;
3358 let m = Metrics::compact();
3359 let Metrics {
3360 control_text,
3361 chrome_text,
3362 hint_text,
3363 radius,
3364 radius_inner,
3365 control_pad_x,
3366 control_pad_y,
3367 field_pad_x,
3368 field_pad_y,
3369 hint_pad_x,
3370 hint_pad_y,
3371 menu_pad_x,
3372 menu_pad_y,
3373 menu_width,
3374 menu_bar_h,
3375 titlebar_h,
3376 } = m;
3377 let fields: &[(&str, f32)] = &[
3378 ("control_text", control_text),
3379 ("chrome_text", chrome_text),
3380 ("hint_text", hint_text),
3381 ("radius", radius),
3382 ("radius_inner", radius_inner),
3383 ("control_pad_x", control_pad_x),
3384 ("control_pad_y", control_pad_y),
3385 ("field_pad_x", field_pad_x),
3386 ("field_pad_y", field_pad_y),
3387 ("hint_pad_x", hint_pad_x),
3388 ("hint_pad_y", hint_pad_y),
3389 ("menu_pad_x", menu_pad_x),
3390 ("menu_pad_y", menu_pad_y),
3391 ("menu_width", menu_width),
3392 ("menu_bar_h", menu_bar_h),
3393 ("titlebar_h", titlebar_h),
3394 ];
3395 assert_eq!(METRIC_ROLES.len(), fields.len(), "a metric has no row");
3396 for (name, value) in fields {
3397 let row = METRIC_ROLES
3398 .iter()
3399 .find(|r| r.name == *name)
3400 .unwrap_or_else(|| panic!("no METRIC_ROLES row for {name}"));
3401 assert_eq!((row.get)(&m), *value, "{name}'s row reads another field");
3402 let mut w = m;
3403 (row.set)(&mut w, 1.0);
3404 assert_eq!((row.get)(&w), 1.0, "{name}'s row writes another field");
3405 }
3406 for row in METRIC_ROLES {
3407 assert!(
3408 fields.iter().any(|(n, _)| *n == row.name),
3409 "METRIC_ROLES names a field Metrics does not have: {}",
3410 row.name
3411 );
3412 let camel = snake_to_camel(row.name);
3413 assert_eq!(row.node, camel, "{}'s Node spelling", row.name);
3414 if let Some(p) = row.platform {
3418 assert_eq!(
3419 (row.get)(&Metrics::default()),
3420 p.here(),
3421 "{}'s stock value",
3422 row.name
3423 );
3424 assert_eq!(
3425 (row.get)(&m),
3426 p.here(),
3427 "{}: compact leaves a platform row alone",
3428 row.name
3429 );
3430 }
3431 }
3432 assert!(
3433 METRIC_ROLES.iter().any(|r| r.platform.is_some()),
3434 "titlebar_h is the platform's; its row says so"
3435 );
3436 }
3437
3438 #[test]
3446 fn theme_roles_restate_the_theme_exactly() {
3447 use crate::theme::Theme;
3448 let t = Theme::dark();
3449 let Theme {
3450 appearance: _,
3451 disabled_opacity: _,
3452 bg,
3453 surface,
3454 raised,
3455 sunken,
3456 border,
3457 border_strong,
3458 fg,
3459 muted,
3460 faint,
3461 accent,
3462 accent_hover,
3463 accent_pressed,
3464 on_accent,
3465 accent_soft,
3466 selection,
3467 focus_ring,
3468 hover,
3469 pressed,
3470 success,
3471 warning,
3472 danger,
3473 scrollbar,
3474 scrollbar_active,
3475 } = t;
3476 let fields: &[(&str, crate::color::Color)] = &[
3477 ("bg", bg),
3478 ("surface", surface),
3479 ("raised", raised),
3480 ("sunken", sunken),
3481 ("border", border),
3482 ("border_strong", border_strong),
3483 ("fg", fg),
3484 ("muted", muted),
3485 ("faint", faint),
3486 ("accent", accent),
3487 ("accent_hover", accent_hover),
3488 ("accent_pressed", accent_pressed),
3489 ("on_accent", on_accent),
3490 ("accent_soft", accent_soft),
3491 ("selection", selection),
3492 ("focus_ring", focus_ring),
3493 ("hover", hover),
3494 ("pressed", pressed),
3495 ("success", success),
3496 ("warning", warning),
3497 ("danger", danger),
3498 ("scrollbar", scrollbar),
3499 ("scrollbar_active", scrollbar_active),
3500 ];
3501 assert_eq!(THEME_ROLES.len(), fields.len(), "a role has no row");
3502 for (name, value) in fields {
3503 let row = THEME_ROLES
3504 .iter()
3505 .find(|r| r.name == *name)
3506 .unwrap_or_else(|| panic!("no THEME_ROLES row for {name}"));
3507 assert_eq!((row.get)(&t), *value, "{name}'s row reads another field");
3508 }
3509 for row in THEME_ROLES {
3510 assert!(
3511 fields.iter().any(|(n, _)| *n == row.name),
3512 "{} names no field",
3513 row.name
3514 );
3515 let camel = {
3517 let mut out = String::new();
3518 let mut up = false;
3519 for ch in row.name.chars() {
3520 if ch == '_' {
3521 up = true;
3522 } else if up {
3523 out.extend(ch.to_uppercase());
3524 up = false;
3525 } else {
3526 out.push(ch);
3527 }
3528 }
3529 out
3530 };
3531 assert_eq!(row.node, camel, "{}'s Node spelling", row.name);
3532 }
3533 }
3534
3535 #[test]
3542 fn env_fields_restate_env_and_window_env_exactly() {
3543 use crate::env::{AudioEnv, Env, SystemEnv};
3544 use crate::window::WindowEnv;
3545 let Env {
3546 refresh_hz: _,
3547 focused: _,
3548 system,
3549 window,
3550 audio,
3551 } = Env::default();
3552 let AudioEnv { device: _, live: _ } = audio;
3553 let SystemEnv {
3554 appearance: _,
3555 accent: _,
3556 motion: _,
3557 locale: _,
3558 assistive: _,
3559 } = system;
3560 let WindowEnv {
3561 id: _,
3562 custom_chrome: _,
3563 maximized: _,
3564 fullscreen: _,
3565 always_on_top: _,
3566 native_controls: _,
3567 } = window;
3568 let stored = [
3569 "refresh_hz",
3570 "focused",
3571 "system.appearance",
3572 "system.accent",
3573 "system.motion",
3574 "system.locale",
3575 "system.assistive",
3576 "window.id",
3577 "window.custom_chrome",
3578 "window.maximized",
3579 "window.fullscreen",
3580 "window.always_on_top",
3581 "window.native_controls",
3582 "audio.device",
3583 "audio.live",
3584 ];
3585 let from_structs: Vec<&str> = ENV_FIELDS
3588 .iter()
3589 .filter(|f| !f.from.contains('('))
3590 .map(|f| {
3591 let (strukt, field) = match f.name.split_once('.') {
3592 Some(("window", field)) => ("WindowEnv", field),
3593 Some(("system", field)) => ("SystemEnv", field),
3594 Some(("audio", field)) => ("AudioEnv", field),
3595 Some((group, _)) => panic!("{}: no struct holds a {group} fact", f.name),
3596 None => ("Env", f.name),
3597 };
3598 assert_eq!(f.from, format!("`{strukt}::{field}`"), "{}", f.name);
3599 f.name
3600 })
3601 .collect();
3602 assert_eq!(from_structs, stored);
3603 for f in ENV_FIELDS.iter().filter(|f| f.from.contains('(')) {
3604 assert!(
3605 f.from.contains("derived") || f.from.contains("the frame's"),
3606 "{}: a call in `from` is derived or the frame's, and says which",
3607 f.name
3608 );
3609 }
3610 }
3611
3612 #[test]
3616 fn env_field_spellings_are_unique_and_complete() {
3617 let mut names: Vec<&str> = ENV_FIELDS.iter().map(|f| f.name).collect();
3618 let mut node: Vec<&str> = ENV_FIELDS
3619 .iter()
3620 .flat_map(|f| f.node.iter().copied())
3621 .collect();
3622 let mut lua: Vec<&str> = ENV_FIELDS
3623 .iter()
3624 .flat_map(|f| f.lua.iter().copied())
3625 .collect();
3626 for list in [&mut names, &mut node, &mut lua] {
3627 let before = list.len();
3628 list.sort_unstable();
3629 list.dedup();
3630 assert_eq!(list.len(), before, "a spelling is used twice");
3631 }
3632 for f in ENV_FIELDS {
3633 assert!(!f.c.is_empty() && !f.doc.is_empty(), "{}", f.name);
3634 if f.node.is_empty() {
3637 assert!(
3638 f.doc.contains("Node"),
3639 "{}: where does Node carry it?",
3640 f.name
3641 );
3642 }
3643 if f.lua.is_empty() {
3644 assert!(
3645 f.doc.contains("Lua"),
3646 "{}: where does Lua carry it?",
3647 f.name
3648 );
3649 }
3650 }
3651 }
3652
3653 #[test]
3657 fn admitted_rows_are_shared_rows_in_both_spellings() {
3658 for e in ELEMENTS {
3659 let (Some(jsx), Some(lua)) = (e.jsx_rows, e.lua_rows) else {
3660 assert!(
3661 e.jsx_rows.is_none() && e.lua_rows.is_none(),
3662 "{}: one spelling only",
3663 e.name
3664 );
3665 continue;
3666 };
3667 assert_eq!(
3668 jsx.len(),
3669 lua.len(),
3670 "{}: the lists differ in length",
3671 e.name
3672 );
3673 for (j, l) in jsx.iter().zip(lua) {
3674 assert!(
3675 shared_prop(j, Spelling::Camel),
3676 "{}: `{j}` is not a row",
3677 e.name
3678 );
3679 assert!(
3680 shared_prop(l, Spelling::Snake),
3681 "{}: `{l}` is not a row",
3682 e.name
3683 );
3684 assert_eq!(
3685 snake_case(j),
3686 *l,
3687 "{}: `{j}` and `{l}` are not one row",
3688 e.name
3689 );
3690 }
3691 if jsx.contains(&"onClick") {
3696 assert!(jsx.contains(&"label"), "{}: `label` missing", e.name);
3697 }
3698 }
3699 assert!(known_prop("button", "description", Spelling::Camel));
3700 assert!(known_prop("button", "on_click", Spelling::Snake));
3701 assert!(known_prop("button", "text", Spelling::Snake));
3702 assert!(!known_prop("button", "radius", Spelling::Camel));
3703 assert!(!known_prop("button", "text", Spelling::Camel));
3704 assert!(known_prop("box", "radius", Spelling::Camel));
3705 assert_eq!(
3708 suggest("button", "on_click", Spelling::Camel),
3709 Some("onClick")
3710 );
3711 assert_eq!(suggest("button", "hover_bg", Spelling::Camel), None);
3712 assert_eq!(suggest("box", "hover_bg", Spelling::Camel), Some("hoverBg"));
3713 }
3714
3715 #[test]
3721 fn text_admits_exactly_the_style_rows() {
3722 let style: Vec<&str> = PROPS
3723 .iter()
3724 .filter(|d| d.target() == Target::Style)
3725 .map(|d| d.name)
3726 .collect();
3727 let mut expect = vec!["size"];
3728 expect.extend(style);
3729 let mut got = TEXT_ROWS_JSX.to_vec();
3730 expect.sort_unstable();
3731 got.sort_unstable();
3732 assert_eq!(got, expect, "TEXT_ROWS_JSX is not the style rows");
3733 for name in ["lineHeight", "color", "wrap", "size"] {
3734 assert!(known_prop("text", name, Spelling::Camel), "{name}");
3735 }
3736 for name in ["live", "role", "label", "onClick", "key", "pad", "bg"] {
3737 assert_eq!(
3738 known_prop("text", name, Spelling::Camel),
3739 name == "bg",
3740 "{name}: `bg` is the element's own (a span's), the rest are not rows it reads"
3741 );
3742 }
3743 assert!(known_prop("text", "line_height", Spelling::Snake));
3744 assert!(known_prop("text", "value", Spelling::Snake));
3745 assert!(!known_prop("text", "live", Spelling::Snake));
3746 assert!(!known_prop("text", "on_click", Spelling::Snake));
3747 assert_eq!(
3750 suggest("text", "max_lines", Spelling::Camel),
3751 Some("maxLines")
3752 );
3753 assert_eq!(suggest("text", "hover_bg", Spelling::Camel), None);
3754 }
3755
3756 #[test]
3757 fn snake_names_round_trip() {
3758 assert_eq!(by_snake_name("min_width").unwrap().name, "minWidth");
3759 assert_eq!(by_snake_name("on_click").unwrap().name, "onClick");
3760 assert_eq!(by_snake_name("bg").unwrap().name, "bg");
3761 assert!(by_snake_name("minWidth").is_none());
3762 assert_eq!(by_name("lineHeight").unwrap().snake_name(), "line_height");
3763 assert_eq!(by_name("radiusTL").unwrap().snake_name(), "radius_tl");
3764 assert_eq!(by_snake_name("radius_bl").unwrap().name, "radiusBL");
3765 }
3766
3767 #[test]
3771 fn the_allow_list_is_per_spelling() {
3772 use Spelling::{Camel, Snake};
3773 assert!(known_prop("box", "hoverBg", Camel));
3774 assert!(known_prop("box", "hover_bg", Snake));
3775 assert!(!known_prop("box", "hover_bg", Camel));
3776 assert!(!known_prop("box", "hoverBg", Snake));
3777 assert!(known_prop("box", "padX", Camel) && !known_prop("box", "padX", Snake));
3779 assert!(known_prop("box", "scroll", Snake) && !known_prop("box", "scroll", Camel));
3780 assert!(known_prop("box", "direction", Snake));
3782 assert_eq!(lua_alias("direction"), Some("repeat"));
3783 assert!(known_prop("edit", "initial", Camel));
3785 assert!(!known_prop("box", "initial", Camel));
3786 assert!(known_prop("edit", "wrap", Camel) && known_prop("edit", "wrap", Snake));
3789 assert!(known_prop("image", "src", Camel) && known_prop("image", "id", Snake));
3790 assert!(!known_prop("box", "colour", Camel));
3792 }
3793
3794 #[test]
3795 fn a_suggestion_is_the_same_word_in_the_right_convention() {
3796 use Spelling::{Camel, Snake};
3797 assert_eq!(suggest("box", "hoverBg", Snake), Some("hover_bg"));
3798 assert_eq!(suggest("box", "onclick", Camel), Some("onClick"));
3799 assert_eq!(suggest("box", "SCROLL_X", Camel), Some("scrollX"));
3800 assert_eq!(suggest("edit", "Initial", Camel), Some("initial"));
3801 assert_eq!(suggest("box", "colour", Camel), None);
3803 }
3804
3805 #[test]
3808 fn every_composite_and_element_prop_is_in_the_allow_list() {
3809 for c in CUSTOM {
3810 for n in c.jsx_names {
3811 assert!(known_prop("box", n, Spelling::Camel), "jsx `{n}`");
3812 }
3813 for n in c.lua_names {
3814 assert!(known_prop("box", n, Spelling::Snake), "lua `{n}`");
3815 }
3816 }
3817 for e in ELEMENTS {
3818 for n in e.jsx_own {
3819 assert!(known_prop(e.name, n, Spelling::Camel), "{}.{n}", e.name);
3820 }
3821 for n in e.lua_own {
3822 assert!(known_prop(e.name, n, Spelling::Snake), "{}.{n}", e.name);
3823 }
3824 }
3825 }
3826
3827 #[test]
3828 fn every_row_applies_a_sample_of_its_kind() {
3829 for def in PROPS {
3830 let f = if def.name == "opacity" { 0.5 } else { 7.0 };
3833 let sample = match def.kind {
3834 Kind::F32 => Parsed::F32(f),
3835 Kind::Color => Parsed::Color(Color::hex(0x11223344)),
3836 Kind::Flag => Parsed::Flag,
3837 Kind::Enum(_) => Parsed::Enum(1),
3838 Kind::Sizing => Parsed::Sizing(Sizing::Percent(0.5)),
3839 Kind::Min => Parsed::Bound(Bound::Fit),
3840 Kind::Max => Parsed::Bound(Bound::Px(10.0)),
3841 Kind::Msg | Kind::Tag => Parsed::Msg(Value::Int(1)),
3842 Kind::Str => Parsed::Str("name".into()),
3843 Kind::Family => Parsed::Family(FontFamily::Mono),
3844 Kind::Resource => Parsed::Resource(7),
3845 Kind::Keyframes => Parsed::Keyframes(vec![Keyframe::default().radius(7.0)]),
3846 Kind::Enter => Parsed::Enter(Enter::from(-7.0, 0.0)),
3847 };
3848 let mut out = PropsOut::new();
3849 apply(def, sample, &mut out).unwrap();
3850 let changed = match def.target() {
3851 Target::Spec => out.spec != NodeSpec::column(),
3852 Target::Style => out.style != TextStyle::new(16.0),
3853 };
3854 assert!(changed, "{} applied a sample but nothing changed", def.name);
3855 }
3856 }
3857
3858 #[test]
3861 fn a_null_tag_still_declares_the_behaviour() {
3862 let mut out = PropsOut::new();
3863 apply(
3864 by_name("onKey").unwrap(),
3865 Parsed::Msg(Value::Null),
3866 &mut out,
3867 )
3868 .unwrap();
3869 assert_eq!(out.spec.events().on_key, Some(Value::Null));
3870 assert!(out.spec.hover_tracked());
3871 let mut out = PropsOut::new();
3872 apply(
3873 by_name("onLayout").unwrap(),
3874 Parsed::Msg(Value::Null),
3875 &mut out,
3876 )
3877 .unwrap();
3878 assert_eq!(out.spec.events().on_layout, Some(Value::Null));
3879 }
3880
3881 #[test]
3891 fn every_declarable_role_name_is_a_real_role() {
3892 for (i, name) in ROLES.iter().enumerate() {
3893 let role = Role::parse(name)
3894 .unwrap_or_else(|| panic!("ROLES[{i}] = {name:?} is not a Role::ALL name"));
3895 assert_eq!(role_idx(i), role, "role index {i} lowers to the wrong role");
3896 assert_eq!(role_idx(i).name(), *name);
3897 }
3898 }
3899
3900 #[test]
3909 fn every_role_is_declarable_or_derived() {
3910 for role in Role::ALL {
3911 let declarable = ROLES.contains(&role.name());
3912 let derived = DERIVED_ONLY.iter().any(|(n, _)| *n == role.name());
3913 assert!(
3914 declarable || derived,
3915 "Role::{role:?} is in Role::ALL but no view can declare it and \
3916 DERIVED_ONLY does not say the core derives it — add {:?} to \
3917 ROLES, or to DERIVED_ONLY with the reason",
3918 role.name()
3919 );
3920 assert!(
3921 !(declarable && derived),
3922 "Role::{role:?} is in ROLES, so it is declarable — drop it from \
3923 DERIVED_ONLY"
3924 );
3925 }
3926 for (name, why) in DERIVED_ONLY {
3927 assert!(!why.is_empty(), "{name:?} is exempted without a reason");
3928 assert!(
3929 Role::parse(name).is_some(),
3930 "DERIVED_ONLY names {name:?}, which is not a Role::ALL name"
3931 );
3932 }
3933 let derived = roles_one_frame_derives();
3934 for (name, _) in DERIVED_ONLY {
3935 assert!(
3936 derived.contains(&Role::parse(name).unwrap()),
3937 "DERIVED_ONLY keeps {name:?} out of ROLES because the core \
3938 derives it, but no frame does any more — either it belongs in \
3939 ROLES now, or the exemption is stale"
3940 );
3941 }
3942 }
3943
3944 fn roles_one_frame_derives() -> Vec<Role> {
3948 let mut core = crate::Core::new();
3949 let mut ui = core.frame(crate::Size::new(200.0, 200.0), 1.0);
3950 ui.window_title("Demo");
3951 ui.configure_root(NodeSpec::column().fill());
3952 ui.leaf_keyed("titlebar", NodeSpec::row().window_drag());
3953 ui.text_in_keyed(
3954 "scroll",
3955 NodeSpec::column().height(40.0).scroll_y(),
3956 "hello",
3957 TextStyle::new(12.0),
3958 );
3959 ui.finish();
3960 core.access_tree().nodes.iter().map(|n| n.role).collect()
3961 }
3962
3963 #[test]
3964 fn colors_and_sizings_parse() {
3965 assert_eq!(color_hex_str("#fff").unwrap(), Color::hex(0xffffffff));
3966 assert_eq!(color_hex_str("#11223344").unwrap(), Color::hex(0x11223344));
3967 assert!(color_hex_str("fff").is_err());
3968 assert_eq!(sizing_str("50%").unwrap(), Sizing::Percent(0.5));
3969 assert_eq!(sizing_str("grow").unwrap(), Sizing::Grow(1.0));
3970 assert!(sizing_str("wide").is_err());
3971 }
3972}