Skip to main content

kui_core/
spec.rs

1//! Node configuration: plain data, trivially constructible from any language.
2
3use crate::anim::{Easing, Repeat, Transition};
4use crate::color::Color;
5use crate::cursor::CursorShape;
6use crate::enter::Enter;
7use crate::geom::Edges;
8use crate::input::Buttons;
9use crate::keyframes::Keyframe;
10use crate::value::Value;
11use crate::window::{WindowButton, WindowRole};
12
13pub use crate::access::{Label, Live, Role};
14
15#[derive(Clone, Copy, Debug, Default, PartialEq)]
16pub enum Sizing {
17    /// Size to content.
18    #[default]
19    Fit,
20    /// Share leftover space, weighted by factor.
21    Grow(f32),
22    /// Absolute logical pixels.
23    Fixed(f32),
24    /// Fraction of the parent's content box (0.0..=1.0).
25    Percent(f32),
26    /// A size expression resolved against the parent's content box, the
27    /// box a `Percent` takes its cut of: `"clamp(400px, 80%, 1000px)"`
28    /// (backlog F109, [`crate::calc`]). Layout treats it as it treats a
29    /// `Percent` — nothing in the fit pass, its size once the parent's is
30    /// known — and a parent that overflows shrinks it as it would one.
31    Calc(crate::calc::Calc),
32}
33
34/// What a `minWidth` / `maxWidth` / `minHeight` / `maxHeight` declares:
35/// px, the node's own fit size (a min only), or a size expression that
36/// layout resolves against the parent's content box when it sizes the
37/// node (backlog F109). Until then a calc bound clamps like none, as a
38/// percentage clamp does in CSS's intrinsic sizing.
39///
40/// A calc clamp costs the spec nothing: it rides in the clamp's own
41/// `f32` as a negative, the way [`Min::FIT`] does — `min_w` below −1
42/// ([`Min::calc`]), `max_w` below 0 ([`max_calc`]) — and layout writes
43/// the resolved px over it, so a reader after layout reads a number and
44/// one before it goes through [`LayoutSpec::max_w_px`].
45#[derive(Clone, Copy, Debug, PartialEq)]
46pub enum Bound {
47    Px(f32),
48    Fit,
49    Calc(crate::calc::Calc),
50}
51
52/// A ceiling that is a size expression, as `max_w` / `max_h` hold it
53/// until layout resolves it: −1 − its number.
54pub fn max_of_calc(c: crate::calc::Calc) -> f32 {
55    -1.0 - c.id() as f32
56}
57
58/// A px ceiling as `max_w` / `max_h` hold it: never below zero, so a
59/// negative there is only ever [`max_of_calc`]'s. Every door a px
60/// ceiling enters by — `NodeSpec::max_width` / `max_height` in Rust, the
61/// schema's `maxWidth` / `maxHeight` rows Node and Lua apply through
62/// (their numbers, their `$name` lengths), C's `max_w` / `max_h` and
63/// `*_size` clamps — reaches the field through those two builders, so
64/// this is the one place it is decided. Before size expressions a
65/// negative ceiling clamped a node to its floor (`clamp_w` took the
66/// larger of the two), and `NaN` did the same; 0 keeps both. Without it
67/// `-1.0` read as the expression interned first — a node held to half
68/// its row by a number that meant "nothing" (backlog RG78).
69pub fn px_ceiling(px: f32) -> f32 {
70    // `f32::max` takes the other operand over a `NaN`.
71    px.max(0.0)
72}
73
74/// The size expression a `max_w` / `max_h` waits on, if it is one.
75pub fn max_calc(v: f32) -> Option<crate::calc::Calc> {
76    (v < 0.0)
77        .then(|| crate::calc::Calc::from_id((-v - 1.0) as u32))
78        .flatten()
79}
80
81impl From<f32> for Bound {
82    fn from(px: f32) -> Self {
83        Bound::Px(px)
84    }
85}
86
87impl From<Min> for Bound {
88    fn from(m: Min) -> Self {
89        if m.is_fit() {
90            Bound::Fit
91        } else if let Some(c) = m.as_calc() {
92            Bound::Calc(c)
93        } else if m.is_auto() {
94            // Undeclared stays undeclared through `min_width` (RG92).
95            Bound::Px(-0.0)
96        } else {
97            Bound::Px(m.resolved())
98        }
99    }
100}
101
102/// A lower clamp on one axis: a number of logical px, or the node's own
103/// fit size on that axis (`minWidth: "fit"`, [`Min::FIT`]). `FIT` is what
104/// lets a `Grow` child keep a content floor — CSS's `flex: 1 0 auto`: the
105/// tabs of an i3-style bar split the bar evenly while they fit and sit at
106/// their label's width, scrolling, once they do not. Layout resolves it
107/// to a number in the fit pass of its axis (`layout::fit_widths` /
108/// `fit_heights`), so every later clamp reads one; until then it clamps
109/// like no floor at all.
110///
111/// Undeclared is [`Min::AUTO`], which clamps as 0 everywhere but one
112/// place: a child giving in an overflowing row that holds a share of the
113/// room, where it is CSS's `min-width: auto` — the child's min-content,
114/// the widest thing in it that cannot wrap (backlog RG92). A declared 0
115/// (`minWidth: 0`, `Min::px(0.0)`) is no floor there either, as CSS's
116/// `min-width: 0` is how a flex item is let go below its content.
117///
118/// One `f32`, with `FIT` as a negative — the form `KuiSpec.min_w` takes
119/// too (`KUI_MIN_FIT`) — rather than an enum with a tag: `LayoutSpec` is
120/// copied per node per frame, and a tagged pair for two axes is eight
121/// bytes on every node for a floor almost none declares (C15). A negative
122/// floor never meant anything, so the slot was free.
123#[derive(Clone, Copy, Debug, PartialEq)]
124pub struct Min(f32);
125
126impl Default for Min {
127    fn default() -> Self {
128        Min::AUTO
129    }
130}
131
132impl Min {
133    /// The node's own fit size on this axis.
134    pub const FIT: Min = Min(-1.0);
135
136    /// No floor declared: the node's min-content where a share of the room
137    /// gives in CSS's way (backlog RG92) and down a column of fit
138    /// children (F114); 0 across a run of fit children alone, across a
139    /// column, where a fit node is held to the column's box (F116), and
140    /// for a node that scrolls or clips.
141    /// Negative zero, so it is 0 to every clamp and to `==`, and told
142    /// apart from a declared 0 by its sign alone ([`Min::is_auto`]).
143    pub const AUTO: Min = Min(-0.0);
144
145    /// A floor of `v` logical px; a negative (or `NaN`) is a floor of 0,
146    /// declared — no automatic one either.
147    pub fn px(v: f32) -> Min {
148        Min(if v > 0.0 { v } else { 0.0 })
149    }
150
151    /// Whether no floor was declared ([`Min::AUTO`]).
152    pub fn is_auto(self) -> bool {
153        self.0 == 0.0 && self.0.is_sign_negative()
154    }
155
156    /// Whether this is the unresolved fit floor.
157    pub fn is_fit(self) -> bool {
158        self.0 == Min::FIT.0
159    }
160
161    /// A floor that is a size expression, until layout resolves it
162    /// (backlog F109): −2 − its number, below `FIT`'s −1.
163    pub fn calc(c: crate::calc::Calc) -> Min {
164        Min(-2.0 - c.id() as f32)
165    }
166
167    /// The size expression this floor waits on, if it is one.
168    pub fn as_calc(self) -> Option<crate::calc::Calc> {
169        (self.0 <= -2.0)
170            .then(|| crate::calc::Calc::from_id((-self.0 - 2.0) as u32))
171            .flatten()
172    }
173
174    /// Whether layout has yet to resolve this floor — a `FIT` or a calc,
175    /// the negatives — without asking the table which calc it is.
176    pub(crate) fn deferred(self) -> bool {
177        self.0 < 0.0
178    }
179
180    /// The clamp as a number: the px it holds, or 0 for a `FIT` or a calc
181    /// layout has not resolved yet (nothing to floor at).
182    pub fn resolved(self) -> f32 {
183        if self.0 > 0.0 { self.0 } else { 0.0 }
184    }
185}
186
187impl From<f32> for Min {
188    fn from(v: f32) -> Self {
189        Min::px(v)
190    }
191}
192
193/// A number is that many logical px, so `.width(120.0)` is
194/// `.width(Sizing::Fixed(120.0))`, the way `min_width(120.0)` already read.
195impl From<f32> for Sizing {
196    fn from(px: f32) -> Self {
197        Sizing::Fixed(px)
198    }
199}
200
201impl Sizing {
202    /// `Grow(1.0)`: an equal share of the leftover space, the weight
203    /// nearly every grow declares.
204    pub const GROW: Sizing = Sizing::Grow(1.0);
205}
206
207impl Sizing {
208    /// The sizing the way a spec spells it — `fit`, `grow(1)`, `120px`,
209    /// `50%` — for a reader: the devtools' inspector and a `nodes()` row.
210    pub fn describe(self) -> String {
211        match self {
212            Sizing::Fit => "fit".into(),
213            Sizing::Grow(w) => format!("grow({w})"),
214            Sizing::Fixed(px) => format!("{px}px"),
215            Sizing::Percent(p) => format!("{}%", p * 100.0),
216            Sizing::Calc(c) => c.describe(),
217        }
218    }
219
220    /// The animatable number inside: a grow factor, a px size, a fraction.
221    /// None for `Fit` and a `Calc`, which have nothing to ease.
222    pub fn amount(self) -> Option<f32> {
223        match self {
224            Sizing::Fit | Sizing::Calc(_) => None,
225            Sizing::Grow(v) | Sizing::Fixed(v) | Sizing::Percent(v) => Some(v),
226        }
227    }
228
229    /// The same form with a different amount (`Fit` and a `Calc` stay).
230    pub fn with_amount(self, v: f32) -> Self {
231        match self {
232            Sizing::Fit => Sizing::Fit,
233            Sizing::Calc(c) => Sizing::Calc(c),
234            Sizing::Grow(_) => Sizing::Grow(v),
235            Sizing::Fixed(_) => Sizing::Fixed(v),
236            Sizing::Percent(_) => Sizing::Percent(v),
237        }
238    }
239}
240
241#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
242pub enum Dir {
243    Row,
244    #[default]
245    Column,
246}
247
248impl Dir {
249    /// The `dir` row's spelling.
250    pub fn name(self) -> &'static str {
251        match self {
252            Dir::Row => "row",
253            Dir::Column => "column",
254        }
255    }
256}
257
258/// Where children sit along an axis, and where a float attaches.
259///
260/// The first three are every axis's. The rest were appended (backlog C13,
261/// in `schema::ALIGNS` order, so the wire indices and `KUI_ALIGN_*` of the
262/// first three did not move) and each means something on one axis only:
263/// the three spreads on `main_align`, `Baseline` on a row's
264/// `cross_align`. Anywhere else one lays out as `Start`
265/// (`SpaceAround`/`SpaceEvenly` as `Center`), with a warning
266/// (`diag::ALIGN_IGNORED`).
267#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
268pub enum Align {
269    #[default]
270    Start,
271    Center,
272    End,
273    /// Main axis: the free space goes between the children, none at the
274    /// ends — CSS's `space-between`. One child sits at the start.
275    SpaceBetween,
276    /// Main axis: each child gets an equal share of the free space, half
277    /// on either side, so the ends get half what a gap does — CSS's
278    /// `space-around`. One child is centred.
279    SpaceAround,
280    /// Main axis: the free space splits into equal gaps between the
281    /// children and at both ends — CSS's `space-evenly`. One child is
282    /// centred.
283    SpaceEvenly,
284    /// A row's cross axis: the first baselines of the children's text line
285    /// up, so a label and a larger value on one row read as one line. A
286    /// child with no text inside it aligns by its bottom edge; a `grow` or
287    /// percent height fills the line and sits at its top.
288    Baseline,
289}
290
291impl Align {
292    /// The `align` rows' spelling (`schema::ALIGNS`).
293    pub fn name(self) -> &'static str {
294        crate::schema::ALIGNS[self as usize]
295    }
296}
297
298/// What a floating node is positioned against.
299#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
300pub enum FloatAnchor {
301    /// The parent node's border box.
302    #[default]
303    Parent,
304    /// The whole viewport.
305    Viewport,
306    /// The border box of the node `Key` names, wherever it is in the
307    /// tree — even after this one in preorder, which the five passes
308    /// cannot serve, so a float anchored this way is laid out again in
309    /// a sixth, once its anchor is placed (`layout::anchored`). What a
310    /// devtools tab's content is: built in the app's part of the tree,
311    /// shown over the panel's tab body (ADR 0032, decision 2). No
312    /// binding spells it; the core builds it.
313    Node(crate::key::Key),
314}
315
316/// Takes a node out of flex flow: it doesn't consume space in its parent,
317/// sizes Grow/Percent against its anchor, is positioned by attach points,
318/// and escapes ancestor clips unless [`FloatConfig::clip`] keeps it in its
319/// parent's. It paints as a layer of its own — above the
320/// in-flow tree and every float that opened before it, under every one
321/// that opened after — and takes input in the same order
322/// (`docs/adr/0023-layers-stack-in-the-order-they-open.md`).
323#[derive(Clone, Copy, Debug, PartialEq)]
324pub struct FloatConfig {
325    pub anchor: FloatAnchor,
326    /// Attach point on the anchor rect (horizontal, vertical).
327    pub anchor_point: (Align, Align),
328    /// Attach point on the floating node itself.
329    pub self_point: (Align, Align),
330    /// Extra offset applied after attaching, logical px.
331    pub offset: Vec2Offset,
332    /// Keep the float on screen: if the attached placement leaves the
333    /// viewport on an axis, mirror the attachment across the anchor on that
334    /// axis (below ↔ above, after ↔ before) when that fits better, then
335    /// clamp whatever still overflows. Tooltips/menus want this.
336    ///
337    /// This is the *in-window* approximation of an OS popup, and it is the
338    /// one to reach for first: a float costs one tree, one hit list and one
339    /// draw call. What it cannot do is leave the window — a dropdown taller
340    /// than the viewport, or a menu with nowhere in-window to go, gets
341    /// clamped rather than placed. Those want a popup window — which this
342    /// release does not have: it is `docs/adr/0004-multi-window.md`'s step
343    /// 4, and the ADR's Consequences say so. So `fit` plus a `modal` float
344    /// (`docs/adr/0003-modal-surfaces.md`) is not merely the first thing to
345    /// reach for today, it is the only thing.
346    pub fit: bool,
347    /// Take the parent's clip, as a child does, instead of escaping every
348    /// ancestor's: a node on a `clip` canvas panned past the canvas's edge
349    /// is cut there, and its hit region with it, rather than drawn over
350    /// and clicked through the toolbar beside it (backlog F90). Only a
351    /// [`FloatAnchor::Parent`] float reads it — a viewport or node anchor
352    /// is placed against something other than the parent, and escapes
353    /// with it set or not. Paint order is unchanged: the float is still a
354    /// layer of its own above its in-flow siblings, only cut. A `line` or
355    /// `polygon` anchored in its parent's box has it set by the core (ADR
356    /// 0010, decision 5, as amended).
357    pub clip: bool,
358}
359
360/// Plain offset pair (kept separate from geometry to stay `Copy` + FFI-flat).
361#[derive(Clone, Copy, Debug, Default, PartialEq)]
362pub struct Vec2Offset {
363    pub x: f32,
364    pub y: f32,
365}
366
367impl Default for FloatConfig {
368    fn default() -> Self {
369        Self {
370            anchor: FloatAnchor::Parent,
371            anchor_point: (Align::Start, Align::Start),
372            self_point: (Align::Start, Align::Start),
373            offset: Vec2Offset::default(),
374            fit: false,
375            clip: false,
376        }
377    }
378}
379
380impl FloatConfig {
381    pub fn parent() -> Self {
382        Self::default()
383    }
384
385    pub fn viewport() -> Self {
386        Self {
387            anchor: FloatAnchor::Viewport,
388            ..Self::default()
389        }
390    }
391
392    /// Tooltip-style: hang below the parent, centered.
393    pub fn below() -> Self {
394        Self {
395            anchor: FloatAnchor::Parent,
396            anchor_point: (Align::Center, Align::End),
397            self_point: (Align::Center, Align::Start),
398            offset: Vec2Offset { x: 0.0, y: 6.0 },
399            ..Self::default()
400        }
401    }
402
403    /// Tooltip-style: hover above the parent, centered.
404    pub fn above() -> Self {
405        Self {
406            anchor: FloatAnchor::Parent,
407            anchor_point: (Align::Center, Align::Start),
408            self_point: (Align::Center, Align::End),
409            offset: Vec2Offset { x: 0.0, y: -6.0 },
410            ..Self::default()
411        }
412    }
413
414    pub fn at(mut self, x: Align, y: Align) -> Self {
415        self.anchor_point = (x, y);
416        self
417    }
418
419    pub fn self_at(mut self, x: Align, y: Align) -> Self {
420        self.self_point = (x, y);
421        self
422    }
423
424    /// [`Self::at`] and [`Self::self_at`] at the same point: the float sits
425    /// inside the anchor against that edge or corner — `(End, End)` is a
426    /// toast in the viewport's bottom-right, `(Center, Center)` a dialog.
427    #[inline]
428    pub fn inside(self, x: Align, y: Align) -> Self {
429        self.at(x, y).self_at(x, y)
430    }
431
432    pub fn offset(mut self, x: f32, y: f32) -> Self {
433        self.offset = Vec2Offset { x, y };
434        self
435    }
436
437    /// Flip across the anchor / clamp as needed to stay in the viewport;
438    /// see [`FloatConfig::fit`] for where that stops being enough.
439    pub fn fit(mut self) -> Self {
440        self.fit = true;
441        self
442    }
443
444    /// Cut by the parent's clip instead of escaping it; see
445    /// [`FloatConfig::clip`] for which anchors read it.
446    pub fn clipped(mut self) -> Self {
447        self.clip = true;
448        self
449    }
450
451    /// Whether this float takes its parent's clip: [`FloatConfig::clip`]
452    /// declared on a parent-anchored float. The one reading both paint
453    /// passes and the hit regions share.
454    pub fn clipped_by_parent(&self) -> bool {
455        self.clip && self.anchor == FloatAnchor::Parent
456    }
457
458    /// A preset by its wire index — its position in [`FLOAT_PRESETS`], which
459    /// is what the binary protocol carries and what `KUI_FLOAT_*` counts
460    /// from. `None` for an index no preset claims.
461    pub fn preset_at(i: usize) -> Option<Self> {
462        Some(match i {
463            0 => Self::parent(),
464            1 => Self::viewport(),
465            2 => Self::below(),
466            3 => Self::above(),
467            _ => return None,
468        })
469    }
470
471    /// A preset by name. Every binding that spells a float as a word —
472    /// `float="below"`, `float = "above"`, `kui_spec_float_preset` — resolves
473    /// it here, so "below" cannot mean one thing in JS and another in Lua.
474    pub fn preset(name: &str) -> Option<Self> {
475        Self::preset_at(FLOAT_PRESETS.iter().position(|p| *p == name)?)
476    }
477
478    /// One config from the pieces a binding can extract without deciding
479    /// anything: a `base` preset (from [`FloatConfig::preset`] or
480    /// [`FloatConfig::preset_at`]) and the overrides that were actually
481    /// declared. `None` leaves the base's own value — that is what lets
482    /// `float="below"` keep its 6px gap while `{ anchor: "below", dx: 2 }`
483    /// moves it sideways without flattening the gap to zero. `fit` and
484    /// `clip` are ORed in: no preset sets either.
485    pub fn build(
486        base: FloatConfig,
487        anchor_at: Option<(Align, Align)>,
488        self_at: Option<(Align, Align)>,
489        dx: Option<f32>,
490        dy: Option<f32>,
491        fit: bool,
492        clip: bool,
493    ) -> Self {
494        let mut cfg = base;
495        if let Some((x, y)) = anchor_at {
496            cfg.anchor_point = (x, y);
497        }
498        if let Some((x, y)) = self_at {
499            cfg.self_point = (x, y);
500        }
501        if let Some(x) = dx {
502            cfg.offset.x = x;
503        }
504        if let Some(y) = dy {
505            cfg.offset.y = y;
506        }
507        cfg.fit |= fit;
508        cfg.clip |= clip;
509        cfg
510    }
511}
512
513/// The float preset names, in wire order: the index of a name here is what
514/// the binary protocol writes for it and what `KUI_FLOAT_*` counts from.
515pub const FLOAT_PRESETS: &[&str] = &["parent", "viewport", "below", "above"];
516
517/// `overflow` as bits: the C struct's field, the binary wire's payload and
518/// what the `clip` / `scrollX` / `scrollY` booleans OR together. One set of
519/// values so a binding cannot invent its own numbering.
520pub const OVERFLOW_CLIP: u32 = 1 << 0;
521pub const OVERFLOW_SCROLL_X: u32 = 1 << 1;
522pub const OVERFLOW_SCROLL_Y: u32 = 1 << 2;
523
524/// The `pad` shorthand family as declared — any subset of the seven names,
525/// each `None` when the frontend did not see it. [`PadShorthand::resolve`]
526/// decides what a missing edge falls back to; a binding only reports what
527/// it found.
528#[derive(Clone, Copy, Debug, Default, PartialEq)]
529pub struct PadShorthand {
530    /// `pad`: all four edges.
531    pub all: Option<f32>,
532    /// `padX`: left and right.
533    pub x: Option<f32>,
534    /// `padY`: top and bottom.
535    pub y: Option<f32>,
536    pub l: Option<f32>,
537    pub r: Option<f32>,
538    pub t: Option<f32>,
539    pub b: Option<f32>,
540}
541
542impl PadShorthand {
543    /// True when the frontend saw any of the seven names.
544    pub fn declared(self) -> bool {
545        [self.all, self.x, self.y, self.l, self.r, self.t, self.b]
546            .iter()
547            .any(Option::is_some)
548    }
549
550    /// Four edges: an edge falls back to its axis, an axis to the all-round
551    /// `pad`, and `pad` to zero. The specific value always wins.
552    pub fn resolve(self) -> Edges {
553        let all = self.all.unwrap_or(0.0);
554        let (x, y) = (self.x.unwrap_or(all), self.y.unwrap_or(all));
555        Edges {
556            l: self.l.unwrap_or(x),
557            r: self.r.unwrap_or(x),
558            t: self.t.unwrap_or(y),
559            b: self.b.unwrap_or(y),
560        }
561    }
562}
563
564#[derive(Clone, Copy, Debug, PartialEq)]
565pub struct LayoutSpec {
566    pub width: Sizing,
567    pub height: Sizing,
568    /// Clamps applied after `width`/`height` resolve (Fit, Grow, Percent and
569    /// Fixed alike), so "grow but at most N" and "fit but at least N" work.
570    /// A min may also be [`Min::FIT`]: "grow but never below my content".
571    pub min_w: Min,
572    /// A ceiling in px, or — as a negative — a size expression layout has
573    /// yet to resolve ([`max_of_calc`]); set it through
574    /// [`NodeSpec::max_width`], which keeps a px one at or above zero
575    /// ([`px_ceiling`], backlog RG78). `max_h` is the same.
576    pub max_w: f32,
577    pub min_h: Min,
578    pub max_h: f32,
579    pub dir: Dir,
580    pub padding: Edges,
581    pub gap: f32,
582    /// Row children that don't fit the main-axis content box start a new
583    /// line instead of overflowing (or shrinking). Rows only: breaking
584    /// needs a definite main size, and the pass order gives a row one —
585    /// its width is final before its height is measured — where a column
586    /// would need its height first. Ignored on a column and on a
587    /// `scroll_x` row, both with a warning (`diag::WRAP_IGNORED`).
588    pub wrap: bool,
589    /// A column whose rows' children line up in columns (ADR 0033): the
590    /// nth in-flow child of every in-flow row is a cell of column n, and
591    /// a column is as wide as its widest cell — its cells' own `width`s
592    /// say how the column sizes (a `Fixed` or `Fit` cell is content that
593    /// sets the column's fit width, a `Grow` cell makes the column grow,
594    /// a `Percent` one takes its cut of the row) and their `minWidth` /
595    /// `maxWidth` clamp it. The rows are ordinary rows — a row's `gap` is
596    /// the space between its cells, its `padding` its own, and it takes
597    /// its background, its click and its hover as any row does — except
598    /// that a row of a table never wraps (`diag::WRAP_IGNORED`). Set by
599    /// [`NodeSpec::table`], which is a column; `dir` stays `Column`, and
600    /// the flag is read on a column only ([`LayoutSpec::is_table`]): a
601    /// `Row` carrying it — a shape only these public fields can build —
602    /// is the row it says it is. The rows are the in-flow `Row` children;
603    /// a column, a table or a leaf straight under the table is a child
604    /// with its own width and no cells.
605    pub table: bool,
606    /// Space between wrap lines, across the main axis. `gap` is still the
607    /// space between children along it.
608    pub cross_gap: f32,
609    /// Alignment of children along the main axis, the spreads
610    /// (`SpaceBetween` / `SpaceAround` / `SpaceEvenly`) included.
611    pub main_align: Align,
612    /// Alignment of children across the main axis; `Baseline` on a row.
613    pub cross_align: Align,
614    /// Width over height (backlog C14, CSS's `aspect-ratio`); 0 = none.
615    /// It sizes the axis whose sizing is `Fit`: a fit height is the final
616    /// width over the ratio, and a fit width under a `Fixed` height is
617    /// that height times it. With both axes declared it has nothing to set.
618    /// The derived axis is neither shrunk nor fitted to the children,
619    /// which overflow it — `min_h: Min::FIT` floors it at them.
620    pub aspect: f32,
621    /// Clip children to this node's rect.
622    pub clip: bool,
623    /// Overflowing content scrolls (implies clipping). Offsets are retained
624    /// across frames in the core, keyed by this node's `Key`.
625    pub scroll_x: bool,
626    pub scroll_y: bool,
627    /// Scroll anchoring (backlog C26 step 3, CSS's `overflow-anchor`): the
628    /// first child in view keeps its place on screen when the content
629    /// before it changes size — a chat that prepends history, a log that
630    /// inserts above the viewport, a row whose estimate was corrected.
631    /// On the scroll axis that is the container's main axis only.
632    pub anchor: bool,
633    /// Out-of-flow positioning; see [`FloatConfig`].
634    pub float: Option<FloatConfig>,
635}
636
637impl Default for LayoutSpec {
638    fn default() -> Self {
639        Self {
640            width: Sizing::Fit,
641            height: Sizing::Fit,
642            min_w: Min::AUTO,
643            max_w: f32::INFINITY,
644            min_h: Min::AUTO,
645            max_h: f32::INFINITY,
646            dir: Dir::Column,
647            padding: Edges::default(),
648            gap: 0.0,
649            wrap: false,
650            table: false,
651            cross_gap: 0.0,
652            main_align: Align::Start,
653            cross_align: Align::Start,
654            aspect: 0.0,
655            clip: false,
656            scroll_x: false,
657            scroll_y: false,
658            anchor: false,
659            float: None,
660        }
661    }
662}
663
664impl LayoutSpec {
665    /// Whether this node clips its children.
666    pub fn clips(&self) -> bool {
667        self.clip || self.scroll_x || self.scroll_y
668    }
669
670    /// Whether this node is a table (ADR 0033): the flag, on a column.
671    /// Every reader — the solver, the diagnostics, `NodeInfo`, the
672    /// corpus — asks this and not the field, so a `Row` with the field
673    /// set is a row everywhere.
674    pub fn is_table(&self) -> bool {
675        self.table && self.dir == Dir::Column
676    }
677}
678
679impl LayoutSpec {
680    /// The width a declared aspect ratio gives a `Fit` width: its `Fixed`
681    /// height times the ratio. `None` when the ratio has no say over the
682    /// width.
683    pub(crate) fn aspect_width(&self) -> Option<f32> {
684        match (self.width, self.height) {
685            (Sizing::Fit, Sizing::Fixed(h)) if self.aspect > 0.0 => {
686                Some(self.clamp_h(h) * self.aspect)
687            }
688            _ => None,
689        }
690    }
691
692    /// Whether a declared aspect ratio sizes the height: a `Fit` height,
693    /// read off the final width. `aspect_width`'s pair.
694    pub(crate) fn aspect_height(&self) -> bool {
695        self.aspect > 0.0 && self.height == Sizing::Fit
696    }
697
698    pub(crate) fn clamp_w(&self, w: f32) -> f32 {
699        let min = self.min_w.resolved();
700        w.clamp(min, self.max_w_px().max(min))
701    }
702
703    pub(crate) fn clamp_h(&self, h: f32) -> f32 {
704        let min = self.min_h.resolved();
705        h.clamp(min, self.max_h_px().max(min))
706    }
707
708    /// `max_w` as px: a calc layout has not resolved yet is no ceiling.
709    pub fn max_w_px(&self) -> f32 {
710        if self.max_w < 0.0 {
711            f32::INFINITY
712        } else {
713            self.max_w
714        }
715    }
716
717    /// `max_h` as px, as [`Self::max_w_px`].
718    pub fn max_h_px(&self) -> f32 {
719        if self.max_h < 0.0 {
720            f32::INFINITY
721        } else {
722            self.max_h
723        }
724    }
725}
726
727#[derive(Clone, Copy, Debug, PartialEq)]
728pub struct VisualStyle {
729    pub bg: Color,
730    pub border_color: Color,
731    pub border_w: f32,
732    /// Corner radii (logical px), clockwise from the top-left:
733    /// `[tl, tr, br, bl]`. `NodeSpec::radius` sets all four; the per-corner
734    /// builders (`radius_tl`, ...) override one — later calls win, like CSS
735    /// `border-radius` followed by `border-top-left-radius`.
736    pub radius: [f32; 4],
737    /// Group opacity, 0..=1 and 1 by default: multiplied into the alpha of
738    /// every quad this node and its subtree emit, and into every
739    /// descendant's own opacity. This is the *cheap* group opacity — a
740    /// per-quad alpha multiply, not an offscreen composite — so a subtree
741    /// whose own pieces overlap shows its seams through the fade where a
742    /// compositing implementation would not. Nothing else changes: an
743    /// invisible subtree still lays out, still takes clicks and is still
744    /// read by assistive technology, exactly like CSS `opacity: 0`.
745    pub opacity: f32,
746    /// The drop shadow cast behind this node (see [`Shadow`]).
747    pub shadow: Shadow,
748    /// Paint the background, border, shadow and fragment with each edge on
749    /// a whole physical pixel (`pixelSnap`). Off by default: a box is drawn where
750    /// layout put it, so a gap between two boxes is there at any scale.
751    /// On, its edges land where a text's backgrounds' do and where every
752    /// other snapped box's do, so snapped boxes that share an edge in
753    /// layout, and a snapped box beside a text's background, meet without
754    /// a seam. Layout, hit-testing and the children are untouched.
755    pub pixel_snap: bool,
756}
757
758impl Default for VisualStyle {
759    fn default() -> Self {
760        Self {
761            bg: Color::TRANSPARENT,
762            border_color: Color::TRANSPARENT,
763            border_w: 0.0,
764            radius: [0.0; 4],
765            opacity: 1.0,
766            shadow: Shadow::default(),
767            pixel_snap: false,
768        }
769    }
770}
771
772/// One outer drop shadow: the node's rounded rect, moved by `dx`/`dy`,
773/// grown by `spread` and its edge blurred over `blur`, painted in `color`
774/// behind the node. CSS's `box-shadow` without the inset and multi-shadow
775/// forms.
776///
777/// It draws whenever `color` is visible, so `shadowColor` alone is a hard
778/// shadow sitting exactly behind the node. The shape is not knocked out of
779/// the middle the way CSS knocks it out, so a translucent background shows
780/// the shadow through itself.
781#[derive(Clone, Copy, Debug, Default, PartialEq)]
782pub struct Shadow {
783    pub color: Color,
784    /// Offset (logical px); positive `dy` casts downward.
785    pub dx: f32,
786    pub dy: f32,
787    /// Blur radius (logical px): the edge ramps over this distance and the
788    /// shadow reaches this far past its shape. 0 = a hard edge.
789    pub blur: f32,
790    /// Grows (or, negative, shrinks) the shape before blurring.
791    pub spread: f32,
792}
793
794impl Shadow {
795    /// Whether anything would be painted.
796    pub fn is_visible(&self) -> bool {
797        self.color.is_visible()
798    }
799}
800
801/// Corner indices into `VisualStyle::radius` / `Quad::radius`.
802pub mod corner {
803    pub const TL: usize = 0;
804    pub const TR: usize = 1;
805    pub const BR: usize = 2;
806    pub const BL: usize = 3;
807}
808
809/// Full per-node configuration. This — not any Rust trait — is the contract
810/// every frontend (Rust builders, Lua, serialized UI) lowers into.
811#[derive(Clone, Debug, Default)]
812pub struct NodeSpec {
813    pub layout: LayoutSpec,
814    pub style: VisualStyle,
815    /// Track pointer hover for this node (`Ui::is_hovered`) without making
816    /// it clickable — tooltips on passive badges. Nodes with `on_click` /
817    /// `on_key` / `window` are always hover-tracked; clicks on a merely
818    /// hoverable node emit nothing.
819    pub hoverable: bool,
820    /// Ask the driver for another frame after this one, every frame this
821    /// node is declared (`animate`). What a `fragment` reading `time`
822    /// needs; opt-in, because it takes the loop off input-driven.
823    pub animate: bool,
824    /// Paint this node's background in the theme's accent
825    /// (`docs/adr/0019-a-theme-derived-from-appearance-and-accent.md`) —
826    /// the OS's where the host reported one, the app's where it pinned
827    /// one, kui's blue otherwise — keeping the declared `bg` only as what
828    /// a binding that never sets this row still gets.
829    ///
830    /// A question, not a colour: a view says *that* this node is the
831    /// accented one and the palette says which colour that is. The stock
832    /// button takes it further and repaints its hover, its pressed shade
833    /// and its label from the same accent, so a light accent still reads
834    /// (`crate::widgets::button_with`).
835    pub accent: bool,
836    /// Window-chrome role (drag handle / window button). A chrome node's
837    /// interactions become `WindowCommand`s for the frame driver instead of
838    /// `UiEvent`s; `on_click` is ignored on such nodes.
839    pub window: Option<WindowRole>,
840    /// Eases this node's sizing amounts, colors and radius toward what the
841    /// view declares instead of snapping (see [`crate::anim`]). Keyed by
842    /// node identity, so the node needs a stable key across frames.
843    pub transition: Option<Transition>,
844    /// With `transition`: also ease this node's laid-out *position*, moving
845    /// its whole subtree — reordered siblings slide into their new slots.
846    /// Opt-in because a node whose position follows an already-easing
847    /// sibling (a split's second half) would lag twice.
848    pub slide: bool,
849    /// Reachable by Tab, and focused by a click or an assistive-technology
850    /// request, without a click payload or a control role — a list row
851    /// that opens on Enter, a card. Controls (editors, key sinks,
852    /// `on_click` boxes, the control roles) are focusable already; see
853    /// `docs/adr/0002-keyboard-focus-as-data.md`.
854    pub focusable: bool,
855    /// Where focus lands when the `modal` scope containing this node is
856    /// entered: the first node in the modal's Tab ring declaring it,
857    /// instead of simply the ring's first — a destructive confirm opening
858    /// on its Cancel rather than on whichever control is declared first.
859    /// Read on entry only, so a Tab press afterwards stands; a node the
860    /// ring skips (disabled, decoration, not focusable) is not a
861    /// candidate, and with no candidate the entry is the ring's first
862    /// node as before. See `docs/adr/0003-modal-surfaces.md`.
863    pub initial_focus: bool,
864    /// Inert: keeps its hit region (so a tooltip can say why) and loses
865    /// everything else — no click, drag or key sink, no hover / pressed /
866    /// focus background, no place in the Tab ring; the access tree
867    /// reports it disabled.
868    pub disabled: bool,
869    /// The pointer shape over this node (see [`crate::cursor`]). Unset,
870    /// the pointer is the I-beam over an editor or a selection scope and
871    /// the plain arrow over everything else — a clickable or draggable
872    /// node included — so a hand over a button, a grab over a handle
873    /// (`grabbing` while its drag runs), a splitter's resize arrows and a
874    /// disabled control's `notAllowed` are all the view's to declare. The
875    /// stock button declares `Pointer` itself.
876    pub cursor: Option<CursorShape>,
877
878    /// See [`EventSpec`]. `None` when the node declares none of it.
879    pub events: Option<Box<EventSpec>>,
880
881    /// See [`AnimSpec`]. `None` when the node declares none of it.
882    pub anim: Option<Box<AnimSpec>>,
883
884    /// See [`AccessSpec`]. `None` when the node declares none of it.
885    pub access: Option<Box<AccessSpec>>,
886
887    /// See [`InteractSpec`]. `None` when the node declares none of it.
888    pub interact: Option<Box<InteractSpec>>,
889}
890
891/// Event payloads a node declares. Boxed on `NodeSpec` because most
892/// nodes declare none, and seven `Option<Value>` inline cost 224 bytes
893/// on every node built (see C15 in `docs/backlog/closed-2026-09.md`).
894#[derive(Clone, Debug, Default, PartialEq)]
895pub struct EventSpec {
896    /// Payload emitted as a `UiEvent` when this node is clicked.
897    pub on_click: Option<Value>,
898    /// Makes this node draggable: pressing it starts a pointer-captured
899    /// drag, and cursor motion until release emits `UiEvent`s of the form
900    /// `{kind="drag", phase="start"|"move"|"end", x, y, dx, dy, tag}` with
901    /// this payload merged in under `tag`. A drag past the click slop
902    /// suppresses the node's `on_click`, so both can coexist.
903    pub on_drag: Option<Value>,
904    /// Marks this node as a key sink: while it holds key focus, key
905    /// presses arrive as `UiEvent`s on it as `{kind="key", phase="down",
906    /// code, ...}`, with this payload merged in under `tag`. Clicking the
907    /// node takes key focus. Releases are not delivered unless the sink
908    /// also declares [`key_up`](Self::key_up): a keymap is the common
909    /// case, and a keymap that heard both halves would run every binding
910    /// twice.
911    pub on_key: Option<Value>,
912    /// With `on_key`: the sink hears releases too, as the same payload
913    /// with `phase="up"` (`text` null, `repeat` false). For a held-key
914    /// interaction — WASD, press-and-hold to preview, a key that arms a
915    /// mode while it is down. A key only comes up where it went down: a
916    /// release whose press the sink never got is dropped, and focus
917    /// leaving while a key is held delivers the `up` first, so nothing is
918    /// left stuck down. Without it a sink hears presses only, which is
919    /// what a keymap wants.
920    pub key_up: bool,
921    /// With `on_key`: the modifier and lock keys themselves arrive too
922    /// — Shift, Ctrl, Alt, Super, Caps Lock, Num Lock, Scroll Lock, as
923    /// codes of their own with their side in `location` (backlog F108).
924    /// Without it a modifier is only ever held — in the next key's
925    /// `shift` / `ctrl` / … and the `modifiers` event — so a keymap
926    /// mid-sequence does not read Shift as a key between two others. A
927    /// terminal speaking kitty's keyboard protocol asks, and so does a
928    /// game that binds a lone Shift.
929    pub modifier_keys: bool,
930    /// Asks for a context menu: a secondary-button press over this node
931    /// emits `{kind="contextmenu", x, y, tag}` on it with this payload
932    /// under `tag`, and does nothing else — the press moves no focus,
933    /// places no caret and produces no click, so right-clicking a
934    /// selection leaves it selected. `x`/`y` are the press in logical
935    /// viewport coordinates, which is where the menu goes. Asked of the
936    /// topmost node under the pointer; when that node offers no menu the
937    /// press reaches the nearest enclosing node that does, the way an
938    /// unclaimed key reaches the enclosing sink (ADR 0011, backlog T1):
939    /// the event carries the owner's key and tag, a nested declaration
940    /// wins over its ancestor's, a disabled node's own is skipped, and
941    /// the walk stops at the modal boundary. Null = the behaviour without
942    /// a tag.
943    pub on_context_menu: Option<Value>,
944    /// Force-click events: a press that deepened past the second stage of
945    /// a Force Touch trackpad over this node emits `{kind="forceclick",
946    /// x, y, tag}` with this payload under `tag`. Routed as a secondary
947    /// press is — no focus moved, no caret, no click — but asked of the
948    /// topmost node only, with no walk to an enclosing declaration — and
949    /// the ordinary click the press produces still follows,
950    /// which is what macOS does (ADR 0017, decision 6).
951    ///
952    /// Text does not need this: a force click over an editor or a
953    /// `selectable` scope selects the word and asks the host to look it
954    /// up, which is what the gesture means on that platform. This is for
955    /// what the core cannot guess — a force click on a chart, a map, a
956    /// timeline.
957    pub on_force_click: Option<Value>,
958    /// The non-primary buttons as events (backlog F105): a press of a
959    /// button in [`buttons`](Self::buttons) over this node emits
960    /// `{kind="button", phase="press", button, x, y, clicks, tag}` on it
961    /// with this payload under `tag`, and the button is then captured by
962    /// the node — every pointer move while it is held arrives as
963    /// `phase="move"` and its release as `phase="release"`, on this node
964    /// wherever the pointer is. `button` is `"secondary"`, `"middle"` or,
965    /// for a button past those, its [`crate::MouseButton::code`]; `x`/`y`
966    /// are logical viewport coordinates, and on a `cells` grid the events
967    /// carry `cell: {row, col}` as a click does. Several buttons may be
968    /// held at once, each its own capture; a primary drag is untouched.
969    ///
970    /// Asked of the topmost node under the pointer, and when that node
971    /// claims no such button the press reaches the nearest enclosing node
972    /// that does, as a context menu's does: a disabled node's own is
973    /// skipped and the walk stops at the modal boundary. A claimed
974    /// secondary press is this event *instead of* a `contextmenu` event
975    /// and the stock menu — a nearer `on_context_menu` still wins, being
976    /// the nested declaration. Like every non-primary press it moves no
977    /// focus, places no caret and touches no selection or scrollbar. What
978    /// it is for: a terminal's middle-click paste, and the mouse reports a
979    /// program in it asked for. Null = the behaviour without a tag.
980    pub on_button: Option<Value>,
981    /// Which non-primary buttons [`on_button`](Self::on_button) claims:
982    /// all three kinds unless the node says otherwise. A pane that wants
983    /// the middle button and leaves the secondary one to its context menu
984    /// says [`Buttons::MIDDLE`].
985    pub buttons: Buttons,
986    /// Scroll events: the wheel over this node emits `{kind="scroll", x,
987    /// y, dx, dy, lines, tag}` with this payload under `tag` — the delta
988    /// in logical px as the driver reported it (positive `dy` is the wheel
989    /// rolling up, toward earlier content), the pointer's position, and
990    /// on a `cells` grid the whole lines the delta covers (`null` on any
991    /// other node), the fraction carried to the next notch. The node
992    /// *takes* the wheel on the axes [`scroll_axes`](Self::scroll_axes)
993    /// names: a scroll gesture that starts over it is its own, and stays
994    /// its own until it ends wherever the pointer goes (backlog F107) —
995    /// except on an axis it also scrolls as a container (`scroll_x`,
996    /// `scroll_y`, the offset the app sets from what it hears), where it
997    /// is answered by its room as a container is: at its edge, a gesture
998    /// that way passes to the scroller around it (backlog F118); it
999    /// reaches no scroll container above it, and a container inside it
1000    /// still takes the axes it scrolls while it can move that way,
1001    /// passing this node the rest: the other axis, and a gesture that
1002    /// begins with the container at its limit (unless it says
1003    /// [`Overscroll::Contain`]). The core moves nothing — a grid's
1004    /// `origin_line` and a canvas's zoom are the app's to change. A
1005    /// drag-select held past a
1006    /// grid's top or bottom edge arrives here too, as the lines the frame
1007    /// scrolled by (ADR 0029, decision 4).
1008    pub on_scroll: Option<Value>,
1009    /// Which axes [`on_scroll`](Self::on_scroll) takes (backlog F107):
1010    /// both unless the node says otherwise. A scroll gesture on an axis
1011    /// the node does not take passes it by, to the scroller around it —
1012    /// a terminal that scrolls its history on `y` says
1013    /// [`ScrollAxes::Y`], and a sideways swipe that meets it goes on
1014    /// moving the strip it sits in. Meaningless without `on_scroll`.
1015    pub scroll_axes: ScrollAxes,
1016    /// Hover events: the pointer entering or leaving this node emits
1017    /// `{kind="hover", phase="enter"|"leave", tag}` with this payload under
1018    /// `tag` — for hover-dependent *layout* (a close button that appears)
1019    /// where a color swap isn't enough. Implies hover tracking.
1020    pub on_hover: Option<Value>,
1021    /// Drop-zone events (`docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md`):
1022    /// files dragged in from the OS over this node emit
1023    /// `{kind="drop", phase="enter"|"move"|"leave"|"drop", paths, x, y,
1024    /// tag}` with this payload under `tag` — `paths` the OS paths as
1025    /// strings, `x`/`y` the pointer in viewport coordinates (absent on
1026    /// `leave`). The zone under the files is the topmost *zone* by paint
1027    /// order: a node inside a zone resolves to it, and a node that is no
1028    /// zone and has none enclosing it is looked past, so an overlay shown
1029    /// on `enter` cannot make the zone lose the files. No `leave` follows
1030    /// a `drop`. Implies hover tracking.
1031    pub on_drop: Option<Value>,
1032    /// Layout events: the rect layout gave this node arrives as
1033    /// `{kind="layout", x, y, w, h, parent: {x, y, w, h}, tag}` (logical px,
1034    /// viewport coordinates, after scrolling and position easing) — on the
1035    /// node's first frame and again whenever the rect changes, never on a
1036    /// frame that left it alone. The view reads the numbers layout already
1037    /// produced instead of re-deriving them; a transition that moves the
1038    /// node reports every frame it moves. Needs a stable key across frames.
1039    pub on_layout: Option<Value>,
1040    /// Slider changes (`docs/adr/0034-stock-controls-over-the-roles.md`,
1041    /// decision 4): on a node whose role is `Slider`, the core turns a
1042    /// press into the value under the pointer, a drag into the value under
1043    /// it, the arrows and assistive technology's Increment / Decrement
1044    /// into one `value_step`, PageUp / PageDown into ten, Home / End into
1045    /// the range's ends — clamped to `value_min..value_max`, snapped to the
1046    /// step — and emits `{kind="change", value, phase="move"|"end", tag}`
1047    /// with this payload under `tag`. The value is proposed, never
1048    /// applied: nothing moves until the view declares it as `value_now`.
1049    /// Without it a slider's keys reach the app as the `access` nudge
1050    /// (ADR 0007, decision 13). Ignored on any other role.
1051    pub on_change: Option<Value>,
1052    /// Modal: while this node is declared, the Tab ring is its subtree,
1053    /// everything outside it is inert to the pointer, the wheel and
1054    /// assistive technology, and Escape or a press outside emits
1055    /// `{kind="dismiss", reason, tag}` on it with this payload under
1056    /// `tag`. The last node declaring it in tree order is the one in
1057    /// effect (a confirm inside a dialog); see
1058    /// `docs/adr/0003-modal-surfaces.md`. Null = modal without a tag.
1059    pub modal: Option<Value>,
1060    /// A press on this node, or anywhere inside it, leaves keyboard focus
1061    /// where it was (`keepFocus`): a toolbar button, a tab, a divider
1062    /// that acts without taking the keyboard from the editor beside it
1063    /// (backlog DX10). Its click, drag and hover are unchanged, and Tab
1064    /// and assistive technology still reach a focusable node in it — it
1065    /// is the pointer's press alone that stops moving focus.
1066    pub keep_focus: bool,
1067    /// Keyboard focus entering or leaving this node's subtree — the node
1068    /// itself or anything focused inside it — emits `{kind="focus",
1069    /// phase="in"|"out", by, tag}` with this payload under `tag`, where
1070    /// `by` is `"pointer"`, `"keyboard"`, `"assistive"` or `"program"`:
1071    /// what moved it (backlog DX18). Reported once the move has settled —
1072    /// after the input that made it, or at the end of the frame that
1073    /// declared it — so a view reads a change instead of diffing
1074    /// `key_focus` every frame. Declares nothing interactive.
1075    pub on_focus: Option<Value>,
1076}
1077
1078impl EventSpec {
1079    /// The group as a node that declares none of it — what
1080    /// `NodeSpec`'s accessor hands back when the box is `None`.
1081    pub const EMPTY: Self = Self {
1082        on_click: None,
1083        on_drag: None,
1084        on_key: None,
1085        key_up: false,
1086        modifier_keys: false,
1087        on_context_menu: None,
1088        on_force_click: None,
1089        on_button: None,
1090        buttons: Buttons::ALL,
1091        on_scroll: None,
1092        scroll_axes: ScrollAxes::Both,
1093        on_hover: None,
1094        on_drop: None,
1095        on_layout: None,
1096        on_change: None,
1097        modal: None,
1098        keep_focus: false,
1099        on_focus: None,
1100    };
1101}
1102
1103/// Per-node animation declarations. Boxed on `NodeSpec`: `enter` and
1104/// `exit` are 60 bytes each and almost every node has neither.
1105#[derive(Clone, Debug, Default, PartialEq)]
1106pub struct AnimSpec {
1107    /// CSS-style stops for the animatable slots (see [`crate::keyframes`]):
1108    /// with a `transition`, the slots a stop names cycle through the stops
1109    /// over the transition's duration, in its `repeat` direction, offset by
1110    /// its `delay_ms` — forever, and without the view redrawing. Slots no
1111    /// stop names still tween toward what the view declares. Empty = none.
1112    pub keyframes: Vec<Keyframe>,
1113    /// Where this node's slots start the first frame it is seen (see
1114    /// [`crate::enter`]): with a `transition`, the slots it names ease in
1115    /// from there instead of snapping — `dx`/`dy` slide the node in from
1116    /// that far away, `bg` fades it in. A node that vanishes and returns
1117    /// enters again. None = first sight snaps.
1118    pub enter: Option<Enter>,
1119    /// Where this node's slots *end* the frame after the view stops
1120    /// declaring it (see [`crate::depart`]). An exit is an [`Enter`] read
1121    /// the other way: with a `transition`, the departing subtree is copied
1122    /// out of the last frame that had it and replayed — frozen where
1123    /// layout left it, on top, inert — while the slots this names ease
1124    /// from where they were toward what it declares. Needs a stable key
1125    /// across frames, and a `transition` with a duration; without both, a
1126    /// removed node vanishes at once as it always did.
1127    pub exit: Option<Enter>,
1128}
1129
1130impl AnimSpec {
1131    /// The group as a node that declares none of it — what
1132    /// `NodeSpec`'s accessor hands back when the box is `None`.
1133    pub const EMPTY: Self = Self {
1134        keyframes: Vec::new(),
1135        enter: None,
1136        exit: None,
1137    };
1138}
1139
1140/// Accessibility properties a view states outright, as opposed to the
1141/// ones the core derives (see [`crate::access`]). Boxed on `NodeSpec`:
1142/// read only while an access tree is being built, and unset on nearly
1143/// every node.
1144#[derive(Clone, Debug, Default, PartialEq)]
1145pub struct AccessSpec {
1146    /// What this node is to assistive technology (see [`crate::access`]).
1147    /// Unset, the core derives one: a node with `on_click` is a button,
1148    /// an editor a text input, a scrolling container a scroll view, and
1149    /// a plain box is structure that leaves no trace. `Role::None` hides
1150    /// the node and its subtree from the access tree (decoration).
1151    pub role: Option<Role>,
1152    /// The accessible name. Without one a button, link, tab or heading is
1153    /// named by the text inside it; an image or an icon button has no
1154    /// name at all, and the core says so (`control-without-name`,
1155    /// `image-without-label` warnings).
1156    pub label: Option<Label>,
1157    /// The accessible description, read after the name — what the
1158    /// `description` prop sets, and what the `tooltip` prop sets on the way
1159    /// to drawing the same string.
1160    pub description: Option<Label>,
1161    /// For checkbox / radio / switch roles: the on state.
1162    pub checked: bool,
1163    /// For a checkbox: neither on nor off — the select-all box over a
1164    /// list some of whose rows are selected (ADR 0034, decision 3). Wins
1165    /// over `checked`, which it leaves as it was.
1166    pub mixed: bool,
1167    /// The current one of a set: a `Role::Tab`, a picked `Role::ListItem`,
1168    /// the `Role::Link` for the page you are on. A tab reports the state
1169    /// either way; a row or a link reports it only where it is set (see
1170    /// [`crate::access`]).
1171    pub selected: bool,
1172    /// For a node that shows and hides something: expanded, or collapsed.
1173    /// None = it does not expand, and says nothing about it.
1174    pub expanded: Option<bool>,
1175    /// For a slider role: the current value and its range, so assistive
1176    /// technology can read the position (the drawing stays the view's).
1177    pub value_now: Option<f32>,
1178    pub value_min: Option<f32>,
1179    pub value_max: Option<f32>,
1180    /// For a slider role: what the position *reads as* (ARIA's
1181    /// `aria-valuetext`). Without one a reader has only the three numbers
1182    /// above and says a percentage — 25 in [5..60] is "36 percent" — so a
1183    /// value whose unit matters says it here: "25 minutes". It replaces
1184    /// the number rather than joining it (see [`crate::access`]), and a
1185    /// nudge announces the new text, not the new number
1186    /// (`docs/adr/0008-live-regions-and-announcements.md`).
1187    pub value_text: Option<Label>,
1188    /// For a slider role: how far one arrow key moves it, and the grid a
1189    /// value set by the pointer snaps to (ADR 0034, decision 4). None =
1190    /// a hundredth of the range.
1191    pub value_step: Option<f32>,
1192    /// On a `Role::Line` of a custom editor: the caret's byte offset into
1193    /// the line's text, and the byte offset of the selection's other end
1194    /// (see [`crate::access`]).
1195    pub caret: Option<u32>,
1196    pub selection_anchor: Option<u32>,
1197    /// With `caret`: the caret this line declares does not blink — a
1198    /// block caret in a modal editor's normal mode — so a driver's blink
1199    /// clock is not armed on it (`Core::has_caret` leaves it out) while
1200    /// the offset still anchors the IME and reads to assistive
1201    /// technology. Without it a declared caret is a caret to blink.
1202    pub caret_solid: bool,
1203    /// Float `description` below the node while it is hovered — the
1204    /// `tooltip` prop's third effect, set by [`NodeSpec::tooltip`]. The core
1205    /// reads it as the node opens (`Core::hint`); a binding that lowers the
1206    /// prop floats the hint itself and leaves this off.
1207    pub tooltip: bool,
1208    /// When the text inside this node changes, a reader reads the change
1209    /// without being asked (ARIA's `aria-live`). Off by default; a node
1210    /// that declares it is semantic, so a plain box marked live is not
1211    /// elided (see `docs/adr/0008-live-regions-and-announcements.md`).
1212    pub live: Live,
1213}
1214
1215impl AccessSpec {
1216    /// The group as a node that declares none of it — what
1217    /// `NodeSpec`'s accessor hands back when the box is `None`.
1218    pub const EMPTY: Self = Self {
1219        role: None,
1220        label: None,
1221        description: None,
1222        checked: false,
1223        mixed: false,
1224        selected: false,
1225        expanded: None,
1226        value_now: None,
1227        value_min: None,
1228        value_max: None,
1229        value_text: None,
1230        value_step: None,
1231        caret: None,
1232        selection_anchor: None,
1233        caret_solid: false,
1234        tooltip: false,
1235        live: Live::Off,
1236    };
1237}
1238
1239/// Hover / pressed / focus styling and the sounds that go with them.
1240/// Boxed on `NodeSpec` so the common node — which declares none of it —
1241/// costs one null check in `resolve_hover_style` instead of reading
1242/// several `Option`s spread across the struct.
1243#[derive(Clone, Debug, Default, PartialEq)]
1244pub struct InteractSpec {
1245    /// Background while the pointer hovers this node (or any node sharing
1246    /// its `hover_group`). Resolved by the core when the node opens, so a
1247    /// data-only view gets hover styling without querying `is_hovered` —
1248    /// and with `transition` the swap eases. Implies hover tracking.
1249    pub hover_bg: Option<Color>,
1250    /// Background while this node (or its group) is pressed. Implies hover
1251    /// tracking. Without a `hover_bg`, hover keeps the plain `bg`. An
1252    /// `on_drag` node holds this state for the whole captured drag, even
1253    /// while the cursor is off it.
1254    pub pressed_bg: Option<Color>,
1255    /// Hover group: nodes sharing an id count as one for `hover_bg` /
1256    /// `pressed_bg` — a two-piece elbow, a split button, a row whose cells
1257    /// highlight together. The id is a hash of a name (`hover_group`).
1258    /// Implies hover tracking.
1259    pub hover_group: Option<u64>,
1260    /// A registered sound played when this node is clicked (see
1261    /// [`crate::audio`]); the click itself still emits `on_click` if one is
1262    /// declared. Implies hover tracking.
1263    pub click_sound: Option<crate::resources::SoundId>,
1264    /// A registered sound played when the pointer enters this node.
1265    /// Implies hover tracking.
1266    pub hover_sound: Option<crate::resources::SoundId>,
1267    /// Background while this node holds keyboard-visible focus (focus
1268    /// moved by Tab or by assistive technology, not by a click).
1269    /// Declaring one replaces the ring the core draws by default. Pressed
1270    /// wins over focus wins over hover; eases with `transition`.
1271    pub focus_bg: Option<Color>,
1272    /// Background while files dragged in from the OS are over this node
1273    /// (ADR 0031, decision 3). Wins over pressed, focus and hover — a press
1274    /// cannot be held while the OS holds a drag — and eases with
1275    /// `transition`; clears when the files leave, land or the drag is
1276    /// cancelled. Implies hover tracking.
1277    pub drop_bg: Option<Color>,
1278    /// Makes this node a *selection scope*: the text of every node inside
1279    /// it is one selectable run of text, in tree order, and a press-drag
1280    /// inside it selects across all of them (see
1281    /// `docs/adr/0017-selection-as-a-scope.md`). Declared on the container
1282    /// rather than on each label, because what a reader selects is a
1283    /// paragraph or a card, not one run of it.
1284    ///
1285    /// Scopes do not nest: the innermost one containing a run owns it, and
1286    /// an outer one is warned about (`nested-selection-scope`). An `edit`
1287    /// is already its own scope and ignores this.
1288    pub selectable: bool,
1289    /// Makes this node's subtree a *focus region*: a Tab ring of its own
1290    /// that the ring outside never enters, and that never leaves — a
1291    /// devtools dock, an inspector beside the app (see
1292    /// `docs/adr/0022-focus-regions.md`). Entered on purpose:
1293    /// `Ui::focus_region`, a press inside it, or an explicit focus on a
1294    /// node in it. Nothing else about the node changes — it lays out,
1295    /// paints, takes the pointer and appears in the access tree as before,
1296    /// and keys bubble through it to the sink above.
1297    pub focus_region: bool,
1298    /// How this node's scrollbars look, when it scrolls (see
1299    /// [`Scrollbar`]). Cold: read once per scroller when its layer's
1300    /// chrome is emitted, which is why it lives in this box rather than
1301    /// beside `scroll_y` on every node.
1302    pub scrollbar: Scrollbar,
1303    /// Whether a scroll gesture that starts over this scroller while it
1304    /// is at its limit that way goes on to the scroller around it (see
1305    /// [`Overscroll`], backlog F107). Cold like `scrollbar`: read once
1306    /// per scroller a gesture starts over.
1307    pub overscroll: Overscroll,
1308    /// On a table (ADR 0033): lines of this colour between its columns and
1309    /// between its rows, drawn with the table's own box, under its cells,
1310    /// down the middle of each gap — so a table with a `gap` of at least
1311    /// `rule_w` gets a grid with no rule cells (backlog DX21). The
1312    /// columns come from the row with the most cells; the lines run the
1313    /// table's content box. Ignored on anything that is not a table.
1314    pub rules: Option<Color>,
1315    /// The rules' width in logical px; 0 is 1.
1316    pub rule_w: f32,
1317}
1318
1319/// A scrolling node's bars, per node. Every field's default is the stock
1320/// bar — the theme's `scrollbar` / `scrollbar_active` colours, 4 px at
1321/// rest and 6 px under the pointer, always drawn while the content
1322/// overflows — so a binding that sets none of the four rows gets exactly
1323/// what it always had. The bars are overlays and take no layout space
1324/// whatever their width; the grabbable track is at least as wide as the
1325/// active thumb plus its inset.
1326#[derive(Clone, Copy, Debug, Default, PartialEq)]
1327pub struct Scrollbar {
1328    /// Whether the bars are drawn at all, and when (see [`ScrollbarMode`]).
1329    pub mode: ScrollbarMode,
1330    /// The thumb's width at rest, logical px; under the pointer or dragged
1331    /// it is 2 px wider. `None` is the stock 4.
1332    pub width: Option<f32>,
1333    /// The thumb at rest; `None` is `theme.scrollbar`.
1334    pub color: Option<Color>,
1335    /// The thumb under the pointer or dragged; `None` is
1336    /// `theme.scrollbar_active`.
1337    pub active_color: Option<Color>,
1338}
1339
1340impl Scrollbar {
1341    pub const DEFAULT: Self = Self {
1342        mode: ScrollbarMode::Visible,
1343        width: None,
1344        color: None,
1345        active_color: None,
1346    };
1347}
1348
1349/// When a scrolling node's bars are drawn. Spelled by the `scrollbar` row
1350/// (`crate::schema::SCROLLBARS`, in this order).
1351#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1352pub enum ScrollbarMode {
1353    /// The stock bar: drawn whenever the content overflows.
1354    #[default]
1355    Visible,
1356    /// No thumb is drawn and no track takes the press. The wheel, the
1357    /// keyboard, `reveal` and the caret still scroll the node — a list
1358    /// drawing its own indicator, or one whose bar would sit on a border.
1359    Hidden,
1360    /// The bar shows while the scroll state is changing — the offset or
1361    /// the content's extent moved since the last frame, the pointer is on
1362    /// its track, or a thumb is being dragged — and for a second after,
1363    /// then fades out over a quarter of one. A node first seen shows its
1364    /// bar the same second. What an overlay bar does on macOS. Needs the
1365    /// driver's clock (`Core::set_time`); without one it is `Visible`,
1366    /// since a fade with no clock could never end.
1367    Auto,
1368}
1369
1370impl ScrollbarMode {
1371    /// Every mode, in the `scrollbar` row's order: what a binding that
1372    /// spells modes as numbers indexes (C's `KUI_SCROLLBAR_*` are these
1373    /// plus one).
1374    pub const ALL: [ScrollbarMode; 3] = [
1375        ScrollbarMode::Visible,
1376        ScrollbarMode::Hidden,
1377        ScrollbarMode::Auto,
1378    ];
1379}
1380
1381/// What a scroll gesture that starts over a scroller already at its limit
1382/// does (backlog F107) — CSS's `overscroll-behavior`, spelled by the
1383/// `overscroll` row (`crate::schema::OVERSCROLLS`, in this order).
1384///
1385/// A gesture picks its target when it starts: the innermost scroller
1386/// under the pointer that can still move the way it goes. One at its
1387/// limit is passed by, to the scroller around it — *chaining* — and
1388/// `Contain` stops that: the gesture is this scroller's, and moves
1389/// nothing until it turns back. Only on the axes the node scrolls, so a
1390/// `scroll_y` list that contains still passes a sideways swipe to the
1391/// strip it sits in (DX13), where CSS would stop that too. Decided at
1392/// the start only: a gesture that reaches a limit midway stops there
1393/// whatever this says, as a browser's does.
1394#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1395pub enum Overscroll {
1396    /// The gesture goes on to the scroller around this one when this one
1397    /// is at its limit that way.
1398    #[default]
1399    Auto,
1400    /// A gesture that starts here stays here: a panel, a popup's list or
1401    /// a sheet whose scrolling must never move the page behind it.
1402    Contain,
1403}
1404
1405impl Overscroll {
1406    /// Every value, in the `overscroll` row's order (C's
1407    /// `KUI_OVERSCROLL_*` are these plus one).
1408    pub const ALL: [Overscroll; 2] = [Overscroll::Auto, Overscroll::Contain];
1409}
1410
1411/// Which axes an `on_scroll` node takes (backlog F107), spelled by the
1412/// `scrollAxes` row (`crate::schema::SCROLL_AXES`, in this order).
1413#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1414pub enum ScrollAxes {
1415    /// Both: the node hears every scroll gesture that starts over it.
1416    #[default]
1417    Both,
1418    /// Only sideways; a vertical gesture passes it by.
1419    X,
1420    /// Only vertical; a sideways gesture passes it by — a terminal's
1421    /// history, a log.
1422    Y,
1423}
1424
1425impl ScrollAxes {
1426    /// Every value, in the `scrollAxes` row's order (C's
1427    /// `KUI_SCROLL_AXES_*` are these plus one).
1428    pub const ALL: [ScrollAxes; 3] = [ScrollAxes::Both, ScrollAxes::X, ScrollAxes::Y];
1429
1430    /// Whether the node takes `x` (true) or `y` (false).
1431    pub fn takes(self, x: bool) -> bool {
1432        match self {
1433            ScrollAxes::Both => true,
1434            ScrollAxes::X => x,
1435            ScrollAxes::Y => !x,
1436        }
1437    }
1438}
1439
1440impl InteractSpec {
1441    /// The group as a node that declares none of it — what
1442    /// `NodeSpec`'s accessor hands back when the box is `None`.
1443    pub const EMPTY: Self = Self {
1444        hover_bg: None,
1445        pressed_bg: None,
1446        hover_group: None,
1447        click_sound: None,
1448        hover_sound: None,
1449        focus_bg: None,
1450        drop_bg: None,
1451        selectable: false,
1452        focus_region: false,
1453        scrollbar: Scrollbar::DEFAULT,
1454        overscroll: Overscroll::Auto,
1455        rules: None,
1456        rule_w: 0.0,
1457    };
1458}
1459
1460impl NodeSpec {
1461    // -- Boxed groups ------------------------------------------------------
1462    // The four cold groups are behind a pointer each (see C15): a node that
1463    // declares none of a group pays 8 bytes for it rather than its full
1464    // width. Reads go through the `&` accessor, which hands back a shared
1465    // empty group instead of allocating, so a caller reads
1466    // `spec.events().on_click` exactly as it used to read `spec.on_click`.
1467    // Writes go through the `_mut` accessor, which allocates on first use.
1468
1469    /// Event payloads this node declares, empty if it declares none.
1470    #[inline]
1471    pub fn events(&self) -> &EventSpec {
1472        static EMPTY: EventSpec = EventSpec::EMPTY;
1473        self.events.as_deref().unwrap_or(&EMPTY)
1474    }
1475
1476    /// Allocates the group on first write.
1477    #[inline]
1478    pub fn events_mut(&mut self) -> &mut EventSpec {
1479        self.events.get_or_insert_with(Box::default)
1480    }
1481
1482    /// Animation declarations, empty if this node has none.
1483    #[inline]
1484    pub fn anim(&self) -> &AnimSpec {
1485        static EMPTY: AnimSpec = AnimSpec::EMPTY;
1486        self.anim.as_deref().unwrap_or(&EMPTY)
1487    }
1488
1489    /// Allocates the group on first write.
1490    #[inline]
1491    pub fn anim_mut(&mut self) -> &mut AnimSpec {
1492        self.anim.get_or_insert_with(Box::default)
1493    }
1494
1495    /// Declared accessibility properties, empty if this node declares none.
1496    #[inline]
1497    pub fn access(&self) -> &AccessSpec {
1498        static EMPTY: AccessSpec = AccessSpec::EMPTY;
1499        self.access.as_deref().unwrap_or(&EMPTY)
1500    }
1501
1502    /// Allocates the group on first write.
1503    #[inline]
1504    pub fn access_mut(&mut self) -> &mut AccessSpec {
1505        self.access.get_or_insert_with(Box::default)
1506    }
1507
1508    /// Hover / pressed / focus styling, empty if this node declares none.
1509    #[inline]
1510    pub fn interact(&self) -> &InteractSpec {
1511        static EMPTY: InteractSpec = InteractSpec::EMPTY;
1512        self.interact.as_deref().unwrap_or(&EMPTY)
1513    }
1514
1515    /// Allocates the group on first write.
1516    #[inline]
1517    pub fn interact_mut(&mut self) -> &mut InteractSpec {
1518        self.interact.get_or_insert_with(Box::default)
1519    }
1520
1521    /// Whether the core registers a hit region for this node (any of the
1522    /// interaction props, or an explicit `hoverable`).
1523    pub fn hover_tracked(&self) -> bool {
1524        // Asked of every node at emission; the inline flags first, then
1525        // each boxed group once — a node that declares neither group is
1526        // answered by two null checks rather than a read per field (C15).
1527        self.hoverable
1528            || self.focusable
1529            || self.window.is_some()
1530            // A `cursor` override has to be found under the pointer to be
1531            // read, even on an otherwise inert box.
1532            || self.cursor.is_some()
1533            || self.events.as_deref().is_some_and(|e| {
1534                // A modal's own background is not "outside" it: a press
1535                // there must find a region (see
1536                // `docs/adr/0003-modal-surfaces.md`).
1537                e.modal.is_some()
1538                    || e.on_click.is_some()
1539                    || e.on_drag.is_some()
1540                    || e.on_key.is_some()
1541                    || e.on_context_menu.is_some()
1542                    || e.on_force_click.is_some()
1543                    || e.on_button.is_some()
1544                    || e.on_hover.is_some()
1545                    || e.on_drop.is_some()
1546                    || e.on_change.is_some()
1547            })
1548            || self.interact.as_deref().is_some_and(|i| {
1549                i.hover_bg.is_some()
1550                    || i.pressed_bg.is_some()
1551                    || i.drop_bg.is_some()
1552                    || i.hover_group.is_some()
1553                    || i.click_sound.is_some()
1554                    || i.hover_sound.is_some()
1555                    // A selection scope has to be found under the pointer:
1556                    // the press that starts a drag-select lands on it.
1557                    || i.selectable
1558                    // So does a focus region: a press on its dead space
1559                    // settles the ring there (`docs/adr/0022`, decision 3).
1560                    || i.focus_region
1561            })
1562    }
1563
1564    /// The group id `hover_group(name)` assigns — reproducible from any
1565    /// binding (the same FNV mix as `Key`).
1566    pub fn hover_group_id(name: &str) -> u64 {
1567        crate::key::Key::ROOT.str(name).0
1568    }
1569
1570    pub fn row() -> Self {
1571        Self {
1572            layout: LayoutSpec {
1573                dir: Dir::Row,
1574                ..Default::default()
1575            },
1576            ..Default::default()
1577        }
1578    }
1579
1580    pub fn column() -> Self {
1581        Self {
1582            layout: LayoutSpec {
1583                dir: Dir::Column,
1584                ..Default::default()
1585            },
1586            ..Default::default()
1587        }
1588    }
1589
1590    /// A table (ADR 0033): a column whose rows' children line up in
1591    /// columns. Its in-flow children are the rows and each row's in-flow
1592    /// children its cells; the nth cell of every row is column n, and a
1593    /// column is as wide as its widest cell — so a label column sits at
1594    /// its longest label with nothing measured and no width picked by
1595    /// hand. A cell's `width` says how its column sizes: `Fit` (the
1596    /// default) and `Fixed` are content the column's fit width is the
1597    /// max of, `Grow` makes the whole column grow with the table, and a
1598    /// column's `minWidth` / `maxWidth` are the strictest its cells
1599    /// declared. The rows are the `Row` children — give them `width:
1600    /// grow` for the columns to grow into; a `Fit` row sits at the
1601    /// columns' fit width — with their own `gap` between cells, their own
1602    /// padding, background, click and hover; a row of a table never
1603    /// wraps. A bare text is a cell too, kept at its column's width, so
1604    /// `ui.text` straight inside a row is a column; an image straight in
1605    /// a row is a cell the same way, its box the column wide and its own
1606    /// aspect tall. A text, a column or a table straight under the table
1607    /// is a child with its own width and no cells. The table's own `Fit`
1608    /// width is its columns', whatever the rows' sizing, and a `scroll_x`
1609    /// table's rows are at least as wide as its columns. Everything else
1610    /// is a column's: `gap` is the space between rows, `scrollY` scrolls
1611    /// them.
1612    pub fn table() -> Self {
1613        Self {
1614            layout: LayoutSpec {
1615                dir: Dir::Column,
1616                table: true,
1617                ..Default::default()
1618            },
1619            ..Default::default()
1620        }
1621    }
1622
1623    /// A [`Sizing`], or a number of px (`.width(120.0)`).
1624    #[inline]
1625    pub fn width(mut self, s: impl Into<Sizing>) -> Self {
1626        self.layout.width = s.into();
1627        self
1628    }
1629
1630    /// A [`Sizing`], or a number of px (`.height(24.0)`).
1631    #[inline]
1632    pub fn height(mut self, s: impl Into<Sizing>) -> Self {
1633        self.layout.height = s.into();
1634        self
1635    }
1636
1637    /// Both axes at once: `.size(20.0, 20.0)` for a fixed box,
1638    /// `.size(Sizing::GROW, 24.0)` for a strip.
1639    #[inline]
1640    pub fn size(self, w: impl Into<Sizing>, h: impl Into<Sizing>) -> Self {
1641        self.width(w).height(h)
1642    }
1643
1644    /// An equal share of the leftover width: `.width(Sizing::GROW)`.
1645    #[inline]
1646    pub fn grow_width(self) -> Self {
1647        self.width(Sizing::GROW)
1648    }
1649
1650    /// An equal share of the leftover height: `.height(Sizing::GROW)`.
1651    #[inline]
1652    pub fn grow_height(self) -> Self {
1653        self.height(Sizing::GROW)
1654    }
1655
1656    /// Grow along both axes.
1657    #[inline]
1658    pub fn fill(self) -> Self {
1659        self.grow_width().grow_height()
1660    }
1661
1662    /// A number of px, [`Min::FIT`] for the node's own fit width, or a
1663    /// [`Bound::Calc`] (backlog F109).
1664    pub fn min_width(mut self, v: impl Into<Bound>) -> Self {
1665        self.layout.min_w = match v.into() {
1666            Bound::Px(px) if px == 0.0 && px.is_sign_negative() => Min::AUTO,
1667            Bound::Px(px) => Min::px(px),
1668            Bound::Fit => Min::FIT,
1669            Bound::Calc(c) => Min::calc(c),
1670        };
1671        self
1672    }
1673
1674    /// A number of px, or a [`Bound::Calc`]; a max has no fit size, so
1675    /// [`Bound::Fit`] is no clamp. A px ceiling below zero, or `NaN`, is
1676    /// a ceiling of 0 ([`px_ceiling`]).
1677    pub fn max_width(mut self, v: impl Into<Bound>) -> Self {
1678        self.layout.max_w = match v.into() {
1679            Bound::Px(px) => px_ceiling(px),
1680            Bound::Fit => f32::INFINITY,
1681            Bound::Calc(c) => max_of_calc(c),
1682        };
1683        self
1684    }
1685
1686    /// A number of px, [`Min::FIT`] for the node's own fit height, or a
1687    /// [`Bound::Calc`].
1688    pub fn min_height(mut self, v: impl Into<Bound>) -> Self {
1689        self.layout.min_h = match v.into() {
1690            Bound::Px(px) if px == 0.0 && px.is_sign_negative() => Min::AUTO,
1691            Bound::Px(px) => Min::px(px),
1692            Bound::Fit => Min::FIT,
1693            Bound::Calc(c) => Min::calc(c),
1694        };
1695        self
1696    }
1697
1698    /// The four clamps where each is given, a transport's shape (C's
1699    /// `*_size` fields): `None` leaves that clamp as it is.
1700    pub fn with_bounds(
1701        self,
1702        min_w: Option<Bound>,
1703        max_w: Option<Bound>,
1704        min_h: Option<Bound>,
1705        max_h: Option<Bound>,
1706    ) -> Self {
1707        let mut s = self;
1708        if let Some(b) = min_w {
1709            s = s.min_width(b);
1710        }
1711        if let Some(b) = max_w {
1712            s = s.max_width(b);
1713        }
1714        if let Some(b) = min_h {
1715            s = s.min_height(b);
1716        }
1717        if let Some(b) = max_h {
1718            s = s.max_height(b);
1719        }
1720        s
1721    }
1722
1723    /// Clip children to this node's rect without scrolling. A `radius` on
1724    /// the same node rounds the clip, so children stay inside its corners
1725    /// (see `display::Clip`).
1726    pub fn clip(mut self) -> Self {
1727        self.layout.clip = true;
1728        self
1729    }
1730
1731    /// Vertical scrolling (and clipping) for overflowing content.
1732    pub fn scroll_y(mut self) -> Self {
1733        self.layout.scroll_y = true;
1734        self
1735    }
1736
1737    /// Horizontal scrolling (and clipping) for overflowing content.
1738    pub fn scroll_x(mut self) -> Self {
1739        self.layout.scroll_x = true;
1740        self
1741    }
1742
1743    /// Scroll anchoring on this container (see [`LayoutSpec::anchor`]):
1744    /// the first child in view stays where it is on screen when the
1745    /// content before it changes size. Needs `scroll_y` on a column or
1746    /// `scroll_x` on a row.
1747    pub fn anchor(mut self) -> Self {
1748        self.layout.anchor = true;
1749        self
1750    }
1751
1752    /// Take this node out of flex flow; see [`FloatConfig`].
1753    pub fn float(mut self, cfg: FloatConfig) -> Self {
1754        self.layout.float = Some(cfg);
1755        self
1756    }
1757
1758    /// Apply the overflow bits ([`OVERFLOW_CLIP`] and friends). What a bit
1759    /// means is decided here: a binding ORs together whatever its own
1760    /// surface spells (`clip`, `scrollX`, `overflow`) and hands the number
1761    /// over, rather than each one re-deciding that scrolling clips too.
1762    pub fn overflow_bits(mut self, bits: u32) -> Self {
1763        if bits & OVERFLOW_CLIP != 0 {
1764            self = self.clip();
1765        }
1766        if bits & OVERFLOW_SCROLL_X != 0 {
1767            self = self.scroll_x();
1768        }
1769        if bits & OVERFLOW_SCROLL_Y != 0 {
1770            self = self.scroll_y();
1771        }
1772        self
1773    }
1774
1775    /// The `tooltip` prop whole, for a Rust view: the node tracks hover,
1776    /// the hint is its accessible description, and the core floats the
1777    /// hint below it while it is hovered (`widgets::hover_hint`, the float
1778    /// every binding's tooltip is). What `tooltip="…"` is in JSX and Lua
1779    /// and `KuiSpec.tooltip` in C. The float is the node's last child, so
1780    /// it is drawn for a box or a fragment; on a leaf — an image, an
1781    /// editor, a cells grid — the hint is tracked and spoken, not drawn:
1782    /// put the tooltip on a box around the leaf (backlog RG75).
1783    #[inline]
1784    pub fn tooltip(self, hint: &str) -> Self {
1785        let mut spec = self.apply_tooltip(hint);
1786        spec.access_mut().tooltip = true;
1787        spec
1788    }
1789
1790    /// The spec half of the `tooltip` prop: the hint is hover-gated, so the
1791    /// node tracks hover, and it is what assistive technology should say, so
1792    /// it is the accessible description too — and nothing floats. For a
1793    /// caller that floats the hint itself: [`crate::schema::PropsOut::apply_tooltip`]
1794    /// for the parsers, `kui_close` for C, a widget that draws its own. A
1795    /// Rust view wants [`Self::tooltip`], which is all three.
1796    pub fn apply_tooltip(self, hint: &str) -> Self {
1797        self.hoverable().description(hint)
1798    }
1799
1800    /// A number of px, or a [`Bound::Calc`]; [`Bound::Fit`] is no clamp.
1801    /// A px ceiling below zero, or `NaN`, is a ceiling of 0.
1802    pub fn max_height(mut self, v: impl Into<Bound>) -> Self {
1803        self.layout.max_h = match v.into() {
1804            Bound::Px(px) => px_ceiling(px),
1805            Bound::Fit => f32::INFINITY,
1806            Bound::Calc(c) => max_of_calc(c),
1807        };
1808        self
1809    }
1810
1811    pub fn pad(mut self, v: f32) -> Self {
1812        self.layout.padding = Edges::all(v);
1813        self
1814    }
1815
1816    pub fn pad_xy(mut self, x: f32, y: f32) -> Self {
1817        self.layout.padding = Edges::xy(x, y);
1818        self
1819    }
1820
1821    pub fn padding(mut self, e: Edges) -> Self {
1822        self.layout.padding = e;
1823        self
1824    }
1825
1826    pub fn gap(mut self, v: f32) -> Self {
1827        self.layout.gap = v;
1828        self
1829    }
1830
1831    /// Wrap overflowing children onto more lines; see [`LayoutSpec::wrap`].
1832    pub fn wrap(mut self) -> Self {
1833        self.layout.wrap = true;
1834        self
1835    }
1836
1837    /// Space between wrap lines (across the main axis).
1838    pub fn cross_gap(mut self, v: f32) -> Self {
1839        self.layout.cross_gap = v;
1840        self
1841    }
1842
1843    pub fn main_align(mut self, a: Align) -> Self {
1844        self.layout.main_align = a;
1845        self
1846    }
1847
1848    pub fn cross_align(mut self, a: Align) -> Self {
1849        self.layout.cross_align = a;
1850        self
1851    }
1852
1853    /// Width over height (CSS's `aspect-ratio`): see
1854    /// [`LayoutSpec::aspect`]. `16.0 / 9.0` for a video, `1.0` for a
1855    /// square. A ratio that is not positive and finite clears it.
1856    pub fn aspect_ratio(mut self, ratio: f32) -> Self {
1857        self.layout.aspect = if ratio.is_finite() && ratio > 0.0 {
1858            ratio
1859        } else {
1860            0.0
1861        };
1862        self
1863    }
1864
1865    /// Center children on both axes.
1866    pub fn center(self) -> Self {
1867        self.main_align(Align::Center).cross_align(Align::Center)
1868    }
1869
1870    pub fn bg(mut self, c: Color) -> Self {
1871        self.style.bg = c;
1872        self
1873    }
1874
1875    /// Rounds all four corners by `r`. On a node that also clips or
1876    /// scrolls it rounds the clip too, so its children are cut to the same
1877    /// corners rather than poking square out of them.
1878    pub fn radius(mut self, r: f32) -> Self {
1879        self.style.radius = [r; 4];
1880        self
1881    }
1882
1883    /// Per-corner radii, clockwise from the top-left.
1884    pub fn radii(mut self, tl: f32, tr: f32, br: f32, bl: f32) -> Self {
1885        self.style.radius = [tl, tr, br, bl];
1886        self
1887    }
1888
1889    pub fn radius_tl(mut self, r: f32) -> Self {
1890        self.style.radius[corner::TL] = r;
1891        self
1892    }
1893
1894    pub fn radius_tr(mut self, r: f32) -> Self {
1895        self.style.radius[corner::TR] = r;
1896        self
1897    }
1898
1899    pub fn radius_br(mut self, r: f32) -> Self {
1900        self.style.radius[corner::BR] = r;
1901        self
1902    }
1903
1904    pub fn radius_bl(mut self, r: f32) -> Self {
1905        self.style.radius[corner::BL] = r;
1906        self
1907    }
1908
1909    /// Rounds the two top corners (tabs, headers).
1910    pub fn radius_top(self, r: f32) -> Self {
1911        self.radius_tl(r).radius_tr(r)
1912    }
1913
1914    /// Rounds the two bottom corners.
1915    pub fn radius_bottom(self, r: f32) -> Self {
1916        self.radius_br(r).radius_bl(r)
1917    }
1918
1919    pub fn border(mut self, w: f32, c: Color) -> Self {
1920        self.style.border_w = w;
1921        self.style.border_color = c;
1922        self
1923    }
1924
1925    /// Paints this node's background, border, shadow and fragment on whole pixels
1926    /// (see `VisualStyle::pixel_snap`).
1927    pub fn pixel_snap(mut self) -> Self {
1928        self.style.pixel_snap = true;
1929        self
1930    }
1931
1932    /// Fades this node and everything under it (see `VisualStyle::opacity`);
1933    /// clamped to 0..=1.
1934    pub fn opacity(mut self, o: f32) -> Self {
1935        self.style.opacity = o.clamp(0.0, 1.0);
1936        self
1937    }
1938
1939    /// The whole drop shadow at once: color, offset, blur, spread.
1940    pub fn shadow(mut self, shadow: Shadow) -> Self {
1941        self.style.shadow = shadow;
1942        self
1943    }
1944
1945    /// The shadow's color — on its own, a hard shadow exactly behind the
1946    /// node. Nothing else about a shadow draws without it.
1947    pub fn shadow_color(mut self, c: Color) -> Self {
1948        self.style.shadow.color = c;
1949        self
1950    }
1951
1952    pub fn shadow_blur(mut self, blur: f32) -> Self {
1953        self.style.shadow.blur = blur;
1954        self
1955    }
1956
1957    pub fn shadow_offset(mut self, dx: f32, dy: f32) -> Self {
1958        self.style.shadow.dx = dx;
1959        self.style.shadow.dy = dy;
1960        self
1961    }
1962
1963    pub fn shadow_x(mut self, dx: f32) -> Self {
1964        self.style.shadow.dx = dx;
1965        self
1966    }
1967
1968    pub fn shadow_y(mut self, dy: f32) -> Self {
1969        self.style.shadow.dy = dy;
1970        self
1971    }
1972
1973    pub fn shadow_spread(mut self, spread: f32) -> Self {
1974        self.style.shadow.spread = spread;
1975        self
1976    }
1977
1978    /// Hover-track this node without making it clickable; see the
1979    /// `hoverable` field.
1980    pub fn hoverable(mut self) -> Self {
1981        self.hoverable = true;
1982        self
1983    }
1984
1985    /// Ask for another frame after this one, for as long as this node is
1986    /// declared. See the `animate` prop.
1987    pub fn animate(mut self) -> Self {
1988        self.animate = true;
1989        self
1990    }
1991
1992    /// Background from the OS accent colour where the host knows it; see
1993    /// the field.
1994    pub fn accent(mut self) -> Self {
1995        self.accent = true;
1996        self
1997    }
1998
1999    pub fn on_click(mut self, payload: impl Into<Value>) -> Self {
2000        self.events_mut().on_click = Some(payload.into());
2001        self
2002    }
2003
2004    /// Background while hovered (see the `hover_bg` field).
2005    pub fn hover_bg(mut self, c: Color) -> Self {
2006        self.interact_mut().hover_bg = Some(c);
2007        self
2008    }
2009
2010    /// Background while dragged files are over this node (see the
2011    /// `drop_bg` field, ADR 0031).
2012    pub fn drop_bg(mut self, c: Color) -> Self {
2013        self.interact_mut().drop_bg = Some(c);
2014        self
2015    }
2016
2017    /// A table's grid rules in `c` (see the `rules` field).
2018    pub fn rules(mut self, c: Color) -> Self {
2019        self.interact_mut().rules = Some(c);
2020        self
2021    }
2022
2023    /// The rules' width in logical px; 1 when unset (see `rules`).
2024    pub fn rule_width(mut self, w: f32) -> Self {
2025        self.interact_mut().rule_w = w;
2026        self
2027    }
2028
2029    /// Makes this node a selection scope (see the `selectable` field):
2030    /// the text inside it becomes one selectable run.
2031    pub fn selectable(mut self) -> Self {
2032        self.interact_mut().selectable = true;
2033        self
2034    }
2035
2036    /// Makes this node's subtree a focus region (see the `focus_region`
2037    /// field): a Tab ring of its own, entered on purpose.
2038    pub fn focus_region(mut self) -> Self {
2039        self.interact_mut().focus_region = true;
2040        self
2041    }
2042
2043    /// Whether a scroll gesture starting over this scroller at its limit
2044    /// goes on to the one around it (see [`Overscroll`]):
2045    /// `Overscroll::Contain` keeps it here.
2046    pub fn overscroll(mut self, o: Overscroll) -> Self {
2047        self.interact_mut().overscroll = o;
2048        self
2049    }
2050
2051    /// When this node's scrollbars are drawn (see [`ScrollbarMode`]).
2052    /// Scrolling itself is unchanged whatever the mode.
2053    pub fn scrollbar(mut self, mode: ScrollbarMode) -> Self {
2054        self.interact_mut().scrollbar.mode = mode;
2055        self
2056    }
2057
2058    /// The thumb's width at rest, logical px (see [`Scrollbar::width`]).
2059    pub fn scrollbar_width(mut self, w: f32) -> Self {
2060        self.interact_mut().scrollbar.width = Some(w);
2061        self
2062    }
2063
2064    /// The thumb's colour at rest (see [`Scrollbar::color`]).
2065    pub fn scrollbar_color(mut self, c: Color) -> Self {
2066        self.interact_mut().scrollbar.color = Some(c);
2067        self
2068    }
2069
2070    /// The thumb's colour under the pointer or dragged (see
2071    /// [`Scrollbar::active_color`]).
2072    pub fn scrollbar_active_color(mut self, c: Color) -> Self {
2073        self.interact_mut().scrollbar.active_color = Some(c);
2074        self
2075    }
2076
2077    /// Background while pressed (see the `pressed_bg` field).
2078    pub fn pressed_bg(mut self, c: Color) -> Self {
2079        self.interact_mut().pressed_bg = Some(c);
2080        self
2081    }
2082
2083    /// Background while keyboard-visibly focused (see the `focus_bg`
2084    /// field); replaces the default focus ring.
2085    pub fn focus_bg(mut self, c: Color) -> Self {
2086        self.interact_mut().focus_bg = Some(c);
2087        self
2088    }
2089
2090    /// Puts this node in the Tab ring (see the `focusable` field).
2091    pub fn focusable(mut self) -> Self {
2092        self.focusable = true;
2093        self
2094    }
2095
2096    /// Hears focus entering and leaving this subtree (see the `on_focus`
2097    /// field).
2098    pub fn on_focus(mut self, tag: impl Into<Value>) -> Self {
2099        self.events_mut().on_focus = Some(tag.into());
2100        self
2101    }
2102
2103    /// A press here leaves keyboard focus where it was (see the
2104    /// `keep_focus` field).
2105    pub fn keep_focus(mut self) -> Self {
2106        self.events_mut().keep_focus = true;
2107        self
2108    }
2109
2110    /// Makes this node the modal scope's entry focus (see the
2111    /// `initial_focus` field).
2112    pub fn initial_focus(mut self) -> Self {
2113        self.initial_focus = true;
2114        self
2115    }
2116
2117    /// Makes this node inert (see the `disabled` field).
2118    pub fn disabled(mut self, disabled: bool) -> Self {
2119        self.disabled = disabled;
2120        self
2121    }
2122
2123    /// Makes this node the frame's modal surface (see the `modal` field).
2124    #[inline]
2125    pub fn modal(mut self, tag: impl Into<Value>) -> Self {
2126        self.events_mut().modal = Some(tag.into());
2127        self
2128    }
2129
2130    /// Joins the hover group `name` (see the `hover_group` field).
2131    pub fn hover_group(mut self, name: &str) -> Self {
2132        self.interact_mut().hover_group = Some(Self::hover_group_id(name));
2133        self
2134    }
2135
2136    /// Emits enter/leave events for this node (see the `on_hover` field).
2137    /// Pass a tag the handler can match on; `Value::Null` if the node key
2138    /// is identification enough.
2139    pub fn on_hover(mut self, tag: impl Into<Value>) -> Self {
2140        self.events_mut().on_hover = Some(tag.into());
2141        self
2142    }
2143
2144    /// Makes this node a drop zone for files dragged in from the OS (see
2145    /// the `on_drop` field, ADR 0031).
2146    pub fn on_drop(mut self, tag: impl Into<Value>) -> Self {
2147        self.events_mut().on_drop = Some(tag.into());
2148        self
2149    }
2150
2151    /// Plays a registered sound when this node is clicked (see the
2152    /// `click_sound` field).
2153    pub fn click_sound(mut self, sound: crate::resources::SoundId) -> Self {
2154        self.interact_mut().click_sound = Some(sound);
2155        self
2156    }
2157
2158    /// Plays a registered sound when the pointer enters this node (see the
2159    /// `hover_sound` field).
2160    pub fn hover_sound(mut self, sound: crate::resources::SoundId) -> Self {
2161        self.interact_mut().hover_sound = Some(sound);
2162        self
2163    }
2164
2165    /// Makes this node draggable (see the `on_drag` field). Pass a tag the
2166    /// handler can match on; `Value::Null` if the node key is enough.
2167    pub fn on_drag(mut self, tag: impl Into<Value>) -> Self {
2168        self.events_mut().on_drag = Some(tag.into());
2169        self
2170    }
2171
2172    /// Reports this node's laid-out rect as an event whenever it changes
2173    /// (see the `on_layout` field). Pass a tag the handler can match on;
2174    /// `Value::Null` if the node key is identification enough.
2175    pub fn on_layout(mut self, tag: impl Into<Value>) -> Self {
2176        self.events_mut().on_layout = Some(tag.into());
2177        self
2178    }
2179
2180    /// What this node is to assistive technology (see the `role` field).
2181    pub fn role(mut self, role: Role) -> Self {
2182        self.access_mut().role = Some(role);
2183        self
2184    }
2185
2186    /// The accessible name (see the `label` field). Takes an `Arc<str>`
2187    /// as well as a `&str`, so a view can keep one and hand it out every
2188    /// frame without allocating.
2189    pub fn label(mut self, label: impl Into<Label>) -> Self {
2190        self.access_mut().label = Some(label.into());
2191        self
2192    }
2193
2194    /// The accessible description (see the `description` field).
2195    pub fn description(mut self, description: impl Into<Label>) -> Self {
2196        self.access_mut().description = Some(description.into());
2197        self
2198    }
2199
2200    /// The on state for a checkbox / radio / switch role.
2201    pub fn checked(mut self, checked: bool) -> Self {
2202        self.access_mut().checked = checked;
2203        self
2204    }
2205
2206    /// A checkbox that is neither on nor off (see the `mixed` field).
2207    pub fn mixed(mut self, mixed: bool) -> Self {
2208        self.access_mut().mixed = mixed;
2209        self
2210    }
2211
2212    /// The current one of a set (see the `selected` field).
2213    pub fn selected(mut self, selected: bool) -> Self {
2214        self.access_mut().selected = selected;
2215        self
2216    }
2217
2218    /// A disclosure's state (see the `expanded` field).
2219    pub fn expanded(mut self, expanded: bool) -> Self {
2220        self.access_mut().expanded = Some(expanded);
2221        self
2222    }
2223
2224    /// Marks this node a live region (see the `live` field).
2225    pub fn live(mut self, live: Live) -> Self {
2226        self.access_mut().live = live;
2227        self
2228    }
2229
2230    /// A slider role's current value.
2231    pub fn value_now(mut self, v: f32) -> Self {
2232        self.access_mut().value_now = Some(v);
2233        self
2234    }
2235
2236    pub fn value_min(mut self, v: f32) -> Self {
2237        self.access_mut().value_min = Some(v);
2238        self
2239    }
2240
2241    pub fn value_max(mut self, v: f32) -> Self {
2242        self.access_mut().value_max = Some(v);
2243        self
2244    }
2245
2246    /// How far one arrow key moves a slider (see the `value_step` field).
2247    /// A step that is not positive and finite clears it.
2248    pub fn value_step(mut self, v: f32) -> Self {
2249        self.access_mut().value_step = (v.is_finite() && v > 0.0).then_some(v);
2250        self
2251    }
2252
2253    /// Asks the core for a slider's changes (see the `on_change` field).
2254    pub fn on_change(mut self, tag: impl Into<Value>) -> Self {
2255        self.events_mut().on_change = Some(tag.into());
2256        self
2257    }
2258
2259    /// What a slider's position reads as (see the `value_text` field).
2260    pub fn value_text(mut self, text: impl Into<Label>) -> Self {
2261        self.access_mut().value_text = Some(text.into());
2262        self
2263    }
2264
2265    /// On a `Role::Line` of a custom editor: the caret's byte offset into
2266    /// this line's text (see the `caret` field).
2267    pub fn caret(mut self, offset: u32) -> Self {
2268        self.access_mut().caret = Some(offset);
2269        self
2270    }
2271
2272    /// On a `Role::Line` of a custom editor: the byte offset where the
2273    /// selection's other end sits (see the `selection_anchor` field).
2274    pub fn selection_anchor(mut self, offset: u32) -> Self {
2275        self.access_mut().selection_anchor = Some(offset);
2276        self
2277    }
2278
2279    /// On a `Role::Line` declaring `caret`: the caret is solid, not a
2280    /// caret to blink (see the `caret_solid` field). A block caret in a
2281    /// modal editor's normal mode — the one thing that otherwise asks an
2282    /// idle app for a frame twice a second.
2283    pub fn caret_solid(mut self) -> Self {
2284        self.access_mut().caret_solid = true;
2285        self
2286    }
2287
2288    /// Makes this node a key sink (see `NodeSpec::on_key` field). Pass a
2289    /// tag the handler can match on; `Value::Null` if the node key is
2290    /// identification enough.
2291    pub fn on_key(mut self, tag: impl Into<Value>) -> Self {
2292        self.events_mut().on_key = Some(tag.into());
2293        self
2294    }
2295
2296    /// A key sink with no tag — `on_key(Value::Null)`, for a handler that
2297    /// knows the node by its key.
2298    #[inline]
2299    pub fn key_sink(self) -> Self {
2300        self.on_key(Value::Null)
2301    }
2302
2303    /// Delivers releases to this key sink as well as presses (see the
2304    /// `key_up` field). Meaningless without `on_key`.
2305    pub fn key_up(mut self) -> Self {
2306        self.events_mut().key_up = true;
2307        self
2308    }
2309
2310    /// Delivers the modifier and lock keys to this key sink as keys of
2311    /// their own (see the `modifier_keys` field). Meaningless without
2312    /// `on_key`.
2313    pub fn modifier_keys(mut self) -> Self {
2314        self.events_mut().modifier_keys = true;
2315        self
2316    }
2317
2318    /// Asks for force-click events on this node (see the
2319    /// `on_force_click` field).
2320    pub fn on_force_click(mut self, tag: impl Into<Value>) -> Self {
2321        self.events_mut().on_force_click = Some(tag.into());
2322        self
2323    }
2324
2325    /// Asks for the non-primary buttons pressed over this node as events,
2326    /// each captured by it until its release (see the `on_button` field):
2327    /// `{kind="button", phase, button, x, y, tag}`. Every such button
2328    /// unless [`NodeSpec::buttons`] narrows it.
2329    pub fn on_button(mut self, tag: impl Into<Value>) -> Self {
2330        self.events_mut().on_button = Some(tag.into());
2331        self
2332    }
2333
2334    /// Which non-primary buttons `on_button` claims (see the `buttons`
2335    /// field): `Buttons::MIDDLE`, `Buttons::SECONDARY | Buttons::MIDDLE`.
2336    /// Meaningless without `on_button`.
2337    pub fn buttons(mut self, buttons: Buttons) -> Self {
2338        self.events_mut().buttons = buttons;
2339        self
2340    }
2341
2342    /// Asks for the wheel over this node as events (see the `on_scroll`
2343    /// field): `{kind="scroll", x, y, dx, dy, lines, tag}`.
2344    pub fn on_scroll(mut self, tag: impl Into<Value>) -> Self {
2345        self.events_mut().on_scroll = Some(tag.into());
2346        self
2347    }
2348
2349    /// Which axes `on_scroll` takes (see the `scroll_axes` field):
2350    /// `ScrollAxes::Y` for a terminal's history, so a sideways gesture
2351    /// passes it by. Meaningless without `on_scroll`.
2352    pub fn scroll_axes(mut self, axes: ScrollAxes) -> Self {
2353        self.events_mut().scroll_axes = axes;
2354        self
2355    }
2356
2357    /// Asks for a context menu on this node (see the `on_context_menu`
2358    /// field). Pass a tag the handler can match on; `Value::Null` if the
2359    /// node key is identification enough.
2360    pub fn on_context_menu(mut self, tag: impl Into<Value>) -> Self {
2361        self.events_mut().on_context_menu = Some(tag.into());
2362        self
2363    }
2364
2365    /// Animates changes to this node's sizing amounts, colors and radius
2366    /// over `duration_ms` (cubic ease-out); see [`crate::anim`]. Only the
2367    /// duration: an easing or repeat already declared survives, so the
2368    /// transition props compose in any order.
2369    ///
2370    /// On a scroll container it also eases the offset a `reveal` or a
2371    /// `set_scroll` moves it to (backlog F80) — the wheel, the thumb and
2372    /// a drag past the edge still land whole, being the hand's own.
2373    pub fn transition(mut self, duration_ms: f32) -> Self {
2374        let t = self.transition.get_or_insert(Transition::ms(duration_ms));
2375        t.duration_ms = duration_ms;
2376        self
2377    }
2378
2379    pub fn transition_with(mut self, t: Transition) -> Self {
2380        self.transition = Some(t);
2381        self
2382    }
2383
2384    /// Also ease this node's position (see the `slide` field); sets a
2385    /// default 200ms transition if none was declared yet.
2386    pub fn slide(mut self) -> Self {
2387        self.transition.get_or_insert(Transition::ms(200.0));
2388        self.slide = true;
2389        self
2390    }
2391
2392    /// Easing for the node's transition (sets a default 200ms one if none
2393    /// was declared yet).
2394    pub fn easing(mut self, easing: Easing) -> Self {
2395        let t = self.transition.get_or_insert(Transition::ms(200.0));
2396        t.easing = easing;
2397        self
2398    }
2399
2400    /// How far the node's spring overshoots, 0 (glides in) to
2401    /// [`crate::anim::MAX_BOUNCE`] — in place of the spring easing's own
2402    /// bounce, and on a timed easing in place of the curve, making it a
2403    /// spring (see [`Transition::bounce`]). Sets a default 200ms
2404    /// transition if none was declared yet.
2405    pub fn bounce(mut self, bounce: f32) -> Self {
2406        let t = self.transition.get_or_insert(Transition::ms(200.0));
2407        *t = t.bounce(bounce);
2408        self
2409    }
2410
2411    /// How this node's `keyframes` cycle (CSS's `animation-direction`);
2412    /// sets a default 200ms transition if none was declared yet.
2413    pub fn repeat(mut self, repeat: Repeat) -> Self {
2414        let t = self.transition.get_or_insert(Transition::ms(200.0));
2415        t.repeat = repeat;
2416        self
2417    }
2418
2419    /// Holds this node's keyframe cycle back by `delay_ms` (CSS's
2420    /// `animation-delay`), so siblings given different delays run out of
2421    /// phase — a cascade, a chase light. Sets a default 200ms transition if
2422    /// none was declared yet.
2423    pub fn delay(mut self, delay_ms: f32) -> Self {
2424        let t = self.transition.get_or_insert(Transition::ms(200.0));
2425        t.delay_ms = delay_ms;
2426        self
2427    }
2428
2429    /// Cycles the slots these stops name (see the `keyframes` field); sets
2430    /// a default 200ms transition if none was declared yet.
2431    pub fn keyframes(mut self, stops: Vec<Keyframe>) -> Self {
2432        self.transition.get_or_insert(Transition::ms(200.0));
2433        self.anim_mut().keyframes = stops;
2434        self
2435    }
2436
2437    /// Eases this node in from `enter` on its first sight (see the `enter`
2438    /// field); sets a default 200ms transition if none was declared yet.
2439    pub fn enter(mut self, enter: Enter) -> Self {
2440        self.transition.get_or_insert(Transition::ms(200.0));
2441        self.anim_mut().enter = Some(enter);
2442        self
2443    }
2444
2445    /// Eases this node out to `exit` after the view stops declaring it
2446    /// (see the `exit` field); sets a default 200ms transition if none was
2447    /// declared yet.
2448    pub fn exit(mut self, exit: Enter) -> Self {
2449        self.transition.get_or_insert(Transition::ms(200.0));
2450        self.anim_mut().exit = Some(exit);
2451        self
2452    }
2453
2454    /// Pressing this node starts an OS window drag (drivers promote a quick
2455    /// second press to a maximize toggle).
2456    pub fn window_drag(mut self) -> Self {
2457        self.window = Some(WindowRole::Drag);
2458        self
2459    }
2460
2461    /// Clicking this node emits the button's `WindowCommand`.
2462    pub fn window_button(mut self, button: WindowButton) -> Self {
2463        self.window = Some(WindowRole::Button(button));
2464        self
2465    }
2466
2467    /// The pointer shape over this node (see the `cursor` field).
2468    pub fn cursor(mut self, shape: CursorShape) -> Self {
2469        self.cursor = Some(shape);
2470        self
2471    }
2472}
2473
2474#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2475pub enum FontFamily {
2476    #[default]
2477    Sans,
2478    Serif,
2479    Mono,
2480    /// A font registered with the core (`Core::add_font_data` from file
2481    /// bytes, or `Core::add_system_font` by installed family name). A
2482    /// stale handle shapes as sans-serif. Not on the wire as a family:
2483    /// the `font` row carries the handle.
2484    Custom(crate::resources::FontId),
2485}
2486
2487impl FontFamily {
2488    /// The three stock families, in wire order: `schema::FAMILIES` is
2489    /// `ALL` by `name`, and a binding sends the index (backlog AR42).
2490    /// `Custom` is not a family on the wire — the `font` row is.
2491    pub const ALL: &'static [FontFamily] = &[FontFamily::Sans, FontFamily::Serif, FontFamily::Mono];
2492
2493    /// The spelling every binding uses; a registered font has none.
2494    pub fn name(self) -> Option<&'static str> {
2495        match self {
2496            FontFamily::Sans => Some("sans"),
2497            FontFamily::Serif => Some("serif"),
2498            FontFamily::Mono => Some("mono"),
2499            FontFamily::Custom(_) => None,
2500        }
2501    }
2502
2503    /// The variant `schema::FAMILIES` index `i` names; `Sans` for an
2504    /// index this build lacks.
2505    pub fn from_index(i: usize) -> FontFamily {
2506        Self::ALL.get(i).copied().unwrap_or_default()
2507    }
2508}
2509
2510/// How a text node breaks lines at its width.
2511#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2512pub enum TextWrap {
2513    /// Break between words; a word wider than the line breaks by glyph.
2514    #[default]
2515    Word,
2516    /// Break anywhere (URLs, hashes, code).
2517    Glyph,
2518    /// Never break at the width: one line per paragraph, clipped to the
2519    /// node's box (explicit newlines still break).
2520    None,
2521    /// `Word`, but whitespace takes its room like any glyph — CSS's
2522    /// `white-space: break-spaces`: a space that does not fit starts the
2523    /// next row instead of hanging past the edge or vanishing at the
2524    /// break, so every byte has a place inside the box. An editor's
2525    /// wrapped line, whose caret stands on each space. A text with line
2526    /// breaks of its own, a `max_lines` or an `ellipsis` wraps as `Word`.
2527    BreakSpaces,
2528}
2529
2530/// The OpenType features a style asks the shaper for (backlog C23): up to
2531/// [`FontFeatures::MAX`] four-letter tags with a value each — `liga` 0 to
2532/// keep a coding font from joining `->`, `tnum` 1 for tabular figures in a
2533/// gutter, `ss01` 1 for a stylistic set. Plain data and `Copy`, since a
2534/// `TextStyle` is; the spelling every binding shares is
2535/// [`FontFeatures::parse`]'s.
2536#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
2537pub struct FontFeatures {
2538    tags: [[u8; 4]; FontFeatures::MAX],
2539    values: [u32; FontFeatures::MAX],
2540    len: u8,
2541}
2542
2543impl FontFeatures {
2544    /// How many features a style can carry. Eight is more than any one
2545    /// text asks for and keeps the style small.
2546    pub const MAX: usize = 8;
2547
2548    pub const fn new() -> Self {
2549        Self {
2550            tags: [[b' '; 4]; Self::MAX],
2551            values: [0; Self::MAX],
2552            len: 0,
2553        }
2554    }
2555
2556    /// Adds or replaces `tag` (four ASCII characters; shorter is padded
2557    /// with spaces, longer is cut) with `value`. Past `MAX` the feature is
2558    /// dropped rather than the style refused.
2559    pub fn set(mut self, tag: &str, value: u32) -> Self {
2560        let mut t = [b' '; 4];
2561        for (i, b) in tag.bytes().take(4).enumerate() {
2562            t[i] = b;
2563        }
2564        let n = self.len as usize;
2565        if let Some(i) = self.tags[..n].iter().position(|x| *x == t) {
2566            self.values[i] = value;
2567        } else if n < Self::MAX {
2568            self.tags[n] = t;
2569            self.values[n] = value;
2570            self.len += 1;
2571        }
2572        self
2573    }
2574
2575    /// The spelling every binding shares: tags separated by whitespace or
2576    /// commas, each `tag=value`, a bare `tag` meaning 1 and `-tag` meaning
2577    /// 0 — `"liga=0 calt=0 tnum"`, `"-liga,-calt"`. Anything else in the
2578    /// string is skipped.
2579    pub fn parse(s: &str) -> Self {
2580        let mut out = Self::new();
2581        for item in s.split(|c: char| c.is_whitespace() || c == ',') {
2582            if item.is_empty() {
2583                continue;
2584            }
2585            let (tag, value) = if let Some((t, v)) = item.split_once('=') {
2586                (t, v.trim().parse::<u32>().unwrap_or(0))
2587            } else if let Some(t) = item.strip_prefix('-') {
2588                (t, 0)
2589            } else if let Some(t) = item.strip_prefix('+') {
2590                (t, 1)
2591            } else {
2592                (item, 1)
2593            };
2594            let tag = tag.trim();
2595            if !tag.is_empty() {
2596                out = out.set(tag, value);
2597            }
2598        }
2599        out
2600    }
2601
2602    pub fn is_empty(&self) -> bool {
2603        self.len == 0
2604    }
2605
2606    pub fn len(&self) -> usize {
2607        self.len as usize
2608    }
2609
2610    /// Every feature, in the order set, as `(tag, value)`.
2611    pub fn iter(&self) -> impl Iterator<Item = (&[u8; 4], u32)> + '_ {
2612        let n = self.len as usize;
2613        self.tags[..n].iter().zip(self.values[..n].iter().copied())
2614    }
2615
2616    /// What `parse` reads: `tag=value` pairs joined by spaces.
2617    pub fn to_string_spelling(&self) -> String {
2618        self.iter()
2619            .map(|(t, v)| format!("{}={}", String::from_utf8_lossy(t).trim_end(), v))
2620            .collect::<Vec<_>>()
2621            .join(" ")
2622    }
2623}
2624
2625#[derive(Clone, Copy, Debug, PartialEq)]
2626pub struct TextStyle {
2627    pub size: f32,
2628    pub line_height: f32,
2629    /// `None` is "the theme's foreground" — which is what the schema has
2630    /// always said this row means ("default foreground when omitted") and,
2631    /// since ADR 0019, what it does. Filled in as the text enters the
2632    /// tree, so nothing downstream of that ever sees a `None`.
2633    pub color: Option<Color>,
2634    pub family: FontFamily,
2635    pub wrap: TextWrap,
2636    /// At most this many lines are laid out; 0 = unlimited.
2637    pub max_lines: u32,
2638    /// End the last line with "…" when the text was cut off. Alone it
2639    /// means a single line (`max_lines` 1); with `max_lines` it clamps.
2640    pub ellipsis: bool,
2641    /// OpenType features for the shaper; none by default, which is the
2642    /// font's own defaults (ligatures on, where it has them).
2643    pub features: FontFeatures,
2644    /// A line under every glyph, where the face puts its underline (backlog
2645    /// C22). Paint only: not part of what the text is shaped as.
2646    pub underline: bool,
2647    /// The underline's own colour; `None` is the text's (backlog K4).
2648    pub underline_color: Option<Color>,
2649    /// The underline's shape: a line, a wave, dots (backlog K4).
2650    pub underline_style: UnderlineStyle,
2651    /// A line through every glyph, where the face puts its strikeout.
2652    pub strikethrough: bool,
2653}
2654
2655/// The shape of an underline (backlog K4): the face's line, a wave under a
2656/// diagnostic, dots. Where it goes and how thick it is are the face's
2657/// recommendation either way; a wave is three strokes tall around the
2658/// line's centre with a six-stroke period, dots two strokes across and
2659/// four apart (`crate::deco`).
2660#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2661pub enum UnderlineStyle {
2662    #[default]
2663    Solid,
2664    Wavy,
2665    Dotted,
2666}
2667
2668impl UnderlineStyle {
2669    /// The spellings, in discriminant order (`underlineStyle` /
2670    /// `underline_style`; C's `KUI_UNDERLINE_*`).
2671    pub const NAMES: &[&str] = &["solid", "wavy", "dotted"];
2672
2673    pub fn from_index(i: u32) -> Self {
2674        match i {
2675            1 => Self::Wavy,
2676            2 => Self::Dotted,
2677            _ => Self::Solid,
2678        }
2679    }
2680
2681    pub fn name(self) -> &'static str {
2682        Self::NAMES[self as usize]
2683    }
2684}
2685
2686impl Default for TextStyle {
2687    fn default() -> Self {
2688        Self::new(16.0)
2689    }
2690}
2691
2692impl TextStyle {
2693    pub fn new(size: f32) -> Self {
2694        Self {
2695            size,
2696            line_height: (size * 1.35).round(),
2697            color: None,
2698            family: FontFamily::Sans,
2699            wrap: TextWrap::Word,
2700            max_lines: 0,
2701            ellipsis: false,
2702            features: FontFeatures::new(),
2703            underline: false,
2704            underline_color: None,
2705            underline_style: UnderlineStyle::Solid,
2706            strikethrough: false,
2707        }
2708    }
2709
2710    pub fn underline(mut self) -> Self {
2711        self.underline = true;
2712        self
2713    }
2714
2715    /// An underline in its own colour rather than the text's — a
2716    /// diagnostic's red under keyword-coloured text (backlog K4). Turns
2717    /// the underline on.
2718    pub fn underline_color(mut self, c: Color) -> Self {
2719        self.underline = true;
2720        self.underline_color = Some(c);
2721        self
2722    }
2723
2724    /// An underline of this shape (backlog K4). Turns the underline on.
2725    pub fn underline_style(mut self, s: UnderlineStyle) -> Self {
2726        self.underline = true;
2727        self.underline_style = s;
2728        self
2729    }
2730
2731    pub fn strikethrough(mut self) -> Self {
2732        self.strikethrough = true;
2733        self
2734    }
2735
2736    /// OpenType features for the shaper; see [`FontFeatures`].
2737    pub fn features(mut self, f: FontFeatures) -> Self {
2738        self.features = f;
2739        self
2740    }
2741
2742    pub fn family(mut self, f: FontFamily) -> Self {
2743        self.family = f;
2744        self
2745    }
2746
2747    pub fn mono(self) -> Self {
2748        self.family(FontFamily::Mono)
2749    }
2750
2751    /// Shape with a registered font (see `FontFamily::Custom`).
2752    pub fn font(self, id: crate::resources::FontId) -> Self {
2753        self.family(FontFamily::Custom(id))
2754    }
2755
2756    pub fn line_height(mut self, lh: f32) -> Self {
2757        self.line_height = lh;
2758        self
2759    }
2760
2761    pub fn wrap(mut self, wrap: TextWrap) -> Self {
2762        self.wrap = wrap;
2763        self
2764    }
2765
2766    /// One line per paragraph, clipped to the node (`TextWrap::None`).
2767    pub fn nowrap(self) -> Self {
2768        self.wrap(TextWrap::None)
2769    }
2770
2771    /// Lay out at most `n` lines (0 = unlimited).
2772    pub fn max_lines(mut self, n: u32) -> Self {
2773        self.max_lines = n;
2774        self
2775    }
2776
2777    /// Truncate with "…" instead of overflowing: a single line unless
2778    /// `max_lines` says otherwise.
2779    pub fn ellipsis(mut self) -> Self {
2780        self.ellipsis = true;
2781        self
2782    }
2783
2784    pub fn color(mut self, c: Color) -> Self {
2785        self.color = Some(c);
2786        self
2787    }
2788
2789    /// This style with `fg` where it named no colour of its own: what the
2790    /// core stamps on as the text enters the tree, so the shaping caches,
2791    /// the display list and every binding see a resolved colour and never
2792    /// the question mark (ADR 0019).
2793    pub fn or_fg(mut self, fg: Color) -> Self {
2794        self.color = Some(self.color.unwrap_or(fg));
2795        self
2796    }
2797
2798    /// The colour this style paints in, falling back to what kui painted
2799    /// before there were themes. For the handful of places that hold a
2800    /// style the core never stamped.
2801    pub fn color_or_default(&self) -> Color {
2802        self.color.unwrap_or(crate::theme::Theme::DEFAULT_FG)
2803    }
2804}
2805
2806/// Hand-written so that a group whose box was allocated but left at its
2807/// defaults — `.checked(false)` on a fresh spec — equals one whose box was
2808/// never allocated at all. The derived version compared `None` against
2809/// `Some(EMPTY)` and called them different, which is a difference the boxing
2810/// introduced rather than one a caller declared.
2811impl PartialEq for NodeSpec {
2812    fn eq(&self, other: &Self) -> bool {
2813        self.layout == other.layout
2814            && self.style == other.style
2815            && self.hoverable == other.hoverable
2816            && self.animate == other.animate
2817            && self.accent == other.accent
2818            && self.window == other.window
2819            && self.transition == other.transition
2820            && self.slide == other.slide
2821            && self.focusable == other.focusable
2822            && self.initial_focus == other.initial_focus
2823            && self.disabled == other.disabled
2824            && self.cursor == other.cursor
2825            && self.events() == other.events()
2826            && self.anim() == other.anim()
2827            && self.access() == other.access()
2828            && self.interact() == other.interact()
2829    }
2830}
2831
2832#[cfg(test)]
2833mod size_tests {
2834    use super::*;
2835
2836    /// `NodeSpec` is moved by value for every node a frame builds — through
2837    /// the builder chain, `Core::open`, `open_with_key` and into
2838    /// `Vec<NodeSpec>` in `Tree::push` — so its size is a per-node cost that
2839    /// every app pays whether or not it declares the fields. It reached 728
2840    /// bytes one feature at a time and cost ~2.5x on the frame benches before
2841    /// anyone measured it (C15 in `docs/backlog/closed-2026-09.md`).
2842    ///
2843    /// This is the number a review can fail. Adding a prop is fine; adding it
2844    /// *inline* past this bound is the thing to notice. Put cold fields in one
2845    /// of the boxed groups instead, and only raise this if the field is read
2846    /// on every node of every frame.
2847    #[test]
2848    fn node_spec_stays_small() {
2849        const BOUND: usize = 256;
2850        let size = std::mem::size_of::<NodeSpec>();
2851        assert!(
2852            size <= BOUND,
2853            "NodeSpec is {size} bytes, over the {BOUND}-byte bound. It is copied \
2854             per node per frame; put cold fields in EventSpec / AnimSpec / \
2855             AccessSpec / InteractSpec rather than inline. See C15."
2856        );
2857    }
2858
2859    /// The groups exist to be absent: a node declaring none of them carries
2860    /// four null pointers, not four structs.
2861    #[test]
2862    fn an_undeclared_group_costs_a_pointer() {
2863        let spec = NodeSpec::default();
2864        assert!(spec.events.is_none() && spec.anim.is_none());
2865        assert!(spec.access.is_none() && spec.interact.is_none());
2866        // and reads still work, without allocating
2867        assert!(spec.events().on_click.is_none());
2868        assert_eq!(spec.access().role, None);
2869    }
2870
2871    /// An allocated-but-default group is still equal to no group at all.
2872    #[test]
2873    fn an_empty_group_equals_no_group() {
2874        assert_eq!(NodeSpec::default().checked(false), NodeSpec::default());
2875    }
2876}