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