Skip to main content

eui_core/surface/
mod.rs

1//! A `Surface` = one A2UI surface: its catalog, its normalized tree, its
2//! data model, its theme and settings, the local UI state the core owns,
3//! and one layout engine (main root + one root per open layer) kept across
4//! passes.
5//!
6//! Round 1 (renderer hardening):
7//! - nodes live in STABLE SLOTS keyed by id: a rebuild (a data write, a tab
8//!   switch, an opened overlay, a scrolled window) reconciles by id, keeps
9//!   every unchanged node's style, visual and measurement, and reports the
10//!   difference as a [`NodeDelta`] (`added` / `removed` / `changed`);
11//! - restyle is PER NODE: a state change restyles that node, a viewport
12//!   change only the nodes with conditions, a theme/mode/settings change
13//!   every node;
14//! - settings: locale + strings, mode `light|dark|system`, density,
15//!   contrast, font scale, safe-area insets, pointer hover, reduced motion
16//!   ([`SurfaceSettings`]).
17//!
18//! The API is COARSE by design (the FFI facade mirrors it one to one):
19//! messages in (`apply`), viewport/state setters, `layout(measure)` out —
20//! never a call per node or per keystroke.
21
22mod events;
23mod pass;
24mod reconcile;
25mod restyle;
26
27pub use pass::{LayoutStep, LeafData};
28
29use std::collections::{BTreeSet, HashMap, HashSet};
30use std::sync::Arc;
31
32use indexmap::IndexMap;
33use serde::{Deserialize, Serialize};
34use serde_json::{Map, Value};
35use taffy::style::Direction;
36
37use crate::catalog::{CatalogView, A2UI_BASIC_CATALOG_ID};
38use crate::data::{get_pointer, set_pointer};
39use crate::engine::Engine;
40use crate::layout_tree::{LNode, LayerClass, LayerSpec, ListSpec, LocalState, NodeKind, ToastSpec};
41use crate::measure::{ControlBox, MeasureMemo, TextStyle};
42use crate::overlay::OverlayPlacement;
43use crate::recipes::RecipeIndex;
44use crate::reducer::{reduce_surface, ReduceOptions, ReduceResult};
45use crate::strings::StringTable;
46use crate::style::Visual;
47use crate::theme::{apply_contrast, apply_density, resolve_mode, Density, Mode, ModeSetting, ResolvedTheme};
48use crate::types::{ExtensionDef, FlatComponent, NestedNode, ReduceIssue, UiNode};
49
50pub use crate::layout_tree::ToastSpec as Toast;
51
52/// How long a hover overlay (Tooltip, `openOn: hover` Popover) waits after
53/// the pointer left its trigger and content before it closes (the
54/// reference's `HOVER_CLOSE_MS`): time to cross the gap into the content.
55pub const HOVER_CLOSE_MS: u32 = 150;
56
57/// Absolute frame in surface coordinates (points/dp; fractional unless
58/// rounding is on).
59#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
60pub struct Frame {
61    pub x: f32,
62    pub y: f32,
63    pub w: f32,
64    pub h: f32,
65}
66
67/// The static description of one layout node (`Surface::nodes`): fetched
68/// once, then patched with [`NodeDelta`]. Indices are STABLE slots; paint
69/// order = accessibility order = the order of `LayoutOutput.frames`.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct PlacedNode {
72    pub index: u32,
73    pub id: String,
74    pub component: String,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub part: Option<String>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub owner: Option<String>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub owner_component: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub catalog_id: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub extension_kind: Option<String>,
85    pub depth: u32,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub parent: Option<u32>,
88    /// Children in paint order.
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub children: Vec<u32>,
91    pub layer: u32,
92    pub kind: NodeKind,
93    pub props: Map<String, Value>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub lines: Option<u32>,
96    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
97    pub pressable: bool,
98    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
99    pub hidden: bool,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub accessibility: Option<Value>,
102    /// The overlay this node opens when pressed (a trigger slot's root).
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub trigger_for: Option<String>,
105    /// The part states the core decided (`selected`, `open`, `checked`,
106    /// `invalid`, `disabled`); the host adds `hover`/`pressed`/`focus`.
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub states: Vec<String>,
109    /// The enclosing Form's id.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub form: Option<String>,
112    /// A live region: `polite | assertive`.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub live: Option<String>,
115    /// A freed slot (the node left the tree; a later node may reuse it).
116    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
117    pub removed: bool,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
121pub struct PlacedFrame {
122    pub index: u32,
123    pub x: f32,
124    pub y: f32,
125    pub w: f32,
126    pub h: f32,
127}
128
129/// One overlay layer in paint order, frames in SURFACE coordinates.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct Layer {
132    pub layer: u32,
133    pub kind: String,
134    pub owner: String,
135    pub root: u32,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub anchor_frame: Option<Frame>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub placement: Option<OverlayPlacement>,
140    /// Drawer: the viewport edge; Dialog: `centered`; anchored: the side it
141    /// landed on; a context menu: `point`; a toast: `toast`.
142    pub position: String,
143    /// `overlay` or `toast` (base < overlay < toast; no z-index).
144    pub class: LayerClass,
145    /// A scrim under it and the focus trapped inside (Dialog, Drawer).
146    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
147    pub modal: bool,
148    /// Escape, a scrim press or a drag closes it (`event(root, "dismiss")`).
149    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
150    pub dismissible: bool,
151    pub frames: Vec<PlacedFrame>,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct ListOutput {
156    pub id: String,
157    pub node: u32,
158    pub content_height: f32,
159    pub start: u32,
160    pub end: u32,
161    pub count: u32,
162    pub windowed: bool,
163    /// Round 2: windows on x (`content_height` is then the content width).
164    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
165    pub horizontal: bool,
166}
167
168/// Round 2 (§2, §5): a node pinned by `position: sticky` (or a List's
169/// pinned section header): paint it (and its subtree) translated by
170/// `(dx, dy)` on top of its frame, inside the same scroll translation as
171/// its siblings. Only nodes that move are listed.
172#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
173pub struct StickyOutput {
174    pub index: u32,
175    pub dx: f32,
176    pub dy: f32,
177}
178
179/// One scroll container (any `overflow: scroll|auto` node, windowed lists,
180/// a Dialog body): its offset (clamped), its viewport (the node's frame) and
181/// its scrollable content size. Frames are reported UNSCROLLED: a painter
182/// translates every descendant by `-offset` and clips to the frame.
183#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
184pub struct ScrollOutput {
185    pub index: u32,
186    pub offset_x: f32,
187    pub offset_y: f32,
188    pub content_width: f32,
189    pub content_height: f32,
190    pub scroll_x: bool,
191    pub scroll_y: bool,
192}
193
194/// What changed in `nodes()` since the previous `layout()`: `added` and
195/// `changed` slots to fetch again (`nodes_at`), `removed` slots to drop.
196/// `renumbered` = every index moved (compaction, a new surface): fetch all.
197#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
198pub struct NodeDelta {
199    pub added: Vec<u32>,
200    pub removed: Vec<u32>,
201    pub changed: Vec<u32>,
202    pub renumbered: bool,
203}
204
205impl NodeDelta {
206    pub fn is_empty(&self) -> bool {
207        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty() && !self.renumbered
208    }
209}
210
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
212pub struct LayoutOutput {
213    /// Main-tree frames, pre-order (= paint order).
214    pub frames: Vec<PlacedFrame>,
215    pub layers: Vec<Layer>,
216    pub lists: Vec<ListOutput>,
217    pub scrolls: Vec<ScrollOutput>,
218    /// Round 2: sticky nodes and pinned section headers at their pinned
219    /// paint offsets this pass.
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub sticky: Vec<StickyOutput>,
222    /// Open toasts the host times (`duration_ms` 0 = sticky); a timeout is
223    /// `event(<toast root>, "dismiss")` / `dismiss_toast(id)`.
224    pub toasts: Vec<ToastSpec>,
225    /// Slots whose resolved visual changed since the last pass.
226    pub visual_changes: Vec<u32>,
227    /// The node changes since the last pass (see [`NodeDelta`]).
228    pub delta: NodeDelta,
229    /// Bumps whenever the node set changed (added/removed/renumbered).
230    pub structure_version: u64,
231    pub surface_width: f32,
232    pub surface_height: f32,
233    /// Content taller than the bounded height (a transcript card may scroll).
234    pub overflow: bool,
235    /// `ltr` | `rtl` (the root's `direction`, else the locale's).
236    pub direction: String,
237    /// The active breakpoint (`None` = base).
238    pub breakpoint: Option<String>,
239    pub measure_rounds: u32,
240    pub upcalls: u32,
241    /// Nodes restyled this pass (a hover = 1; a theme switch = all).
242    pub restyled: u32,
243    /// The layout tree was rebuilt (and reconciled) this pass.
244    pub rebuilt: bool,
245    /// Layout nodes the builder produced this pass: the whole tree on a
246    /// rebuild, only a re-windowed list's subtree on a scroll.
247    pub built_nodes: u32,
248    pub layout_ns: u64,
249}
250
251/// What the host must do after an interaction (`non_exhaustive`: match with
252/// a wildcard arm; new kinds are additive). The wire form is camelCase
253/// throughout, kinds AND fields (`{kind: "action", componentId, …}`,
254/// `{kind: "hoverTimer", owner, delayMs}`), as the host API's.
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
257#[non_exhaustive]
258pub enum OutEvent {
259    /// A server event (A2UI `action`): forward it. `context` = the author's
260    /// context with the event payload merged over it.
261    Action { name: String, component_id: String, event: String, context: Value, #[serde(skip_serializing_if = "Option::is_none")] payload: Option<Value> },
262    /// `openUrl` or a `Link`.
263    OpenUrl { url: String },
264    /// An `on.<event>` `functionCall` naming a HOST function (not one of
265    /// the catalog's built-ins, `FUNCTION_NAMES`): the host looks it up in
266    /// its registry and runs it through the policy gate
267    /// (`host::decide_function`, VAPP-91). `args` = the call's args resolved
268    /// against the data model and scope. Serialized `{kind: "functionCall",
269    /// componentId, name, args}`.
270    FunctionCall { component_id: String, name: String, args: Value },
271    /// A bound value was written through to the data model.
272    DataChanged { path: String, value: Value },
273    /// A host-owned input edit (the host forwards with its revision).
274    Input { component_id: String, name: String, #[serde(skip_serializing_if = "Option::is_none")] path: Option<String>, value: Value, commit: bool },
275    /// Move keyboard focus (a Form's first invalid field, `focus` command).
276    Focus { id: String, index: u32 },
277    /// Speak through the platform's live region.
278    Announce { text: String, live: String },
279    /// Put text on the clipboard (CodeBlock copy).
280    Copy { text: String },
281    /// Open the platform file picker for a FileUpload; the picked files come
282    /// back as `event(<dropzone>, "upload", {files})` (bytes via the host).
283    PickFiles { component_id: String, #[serde(skip_serializing_if = "Option::is_none")] accept: Option<String>, multiple: bool },
284    /// Local UI state changed: lay out again.
285    Relayout,
286    /// Call [`Surface::hover_timeout`]`(owner)` after `delay_ms` (a hover
287    /// overlay whose trigger and content were left; it closes then unless
288    /// the pointer came back). Keep ONE timer per owner: a new `HoverTimer`
289    /// for the same owner restarts it.
290    HoverTimer { owner: String, delay_ms: u32 },
291    /// Round 2: scroll the host viewport of the whole surface to `(x, y)`
292    /// (a `scrollToIndex` on a list the page scrolls), then report it with
293    /// [`Surface::set_surface_scroll`].
294    ScrollSurface { x: f32, y: f32 },
295}
296
297/// What a host may ask of a live surface (`catalog/a11y.json` `commands`).
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300pub enum SurfaceCommand {
301    Focus { id: String },
302    Announce { text: String, #[serde(default)] live: Option<String> },
303    ScrollIntoView { id: String },
304    /// Round 2: `scrollToIndex {id, index, align?}` (`start | center | end |
305    /// nearest`, default nearest).
306    ScrollToIndex {
307        id: String,
308        index: u32,
309        #[serde(default, skip_serializing_if = "Option::is_none")]
310        align: Option<String>,
311    },
312}
313
314/// Safe-area insets (status bar, notch, home indicator) layers keep clear of.
315#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
316pub struct Insets {
317    pub top: f32,
318    pub right: f32,
319    pub bottom: f32,
320    pub left: f32,
321}
322
323/// `normal | high | system` (system = the platform's high-contrast flag).
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
325#[serde(rename_all = "lowercase")]
326pub enum ContrastSetting {
327    Normal,
328    High,
329    #[default]
330    System,
331}
332
333/// What a host sets per surface (docs/round-1-contract.md §4–6).
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase", default)]
336pub struct SurfaceSettings {
337    /// BCP 47; the week start and the text direction. Default `en-US`.
338    pub locale: String,
339    /// Built-in string overrides by id.
340    pub strings: IndexMap<String, String>,
341    pub mode: ModeSetting,
342    /// The platform prefers dark (`mode: system`).
343    pub system_dark: bool,
344    pub density: Density,
345    pub contrast: ContrastSetting,
346    /// The platform asks for more contrast (`contrast: system`).
347    pub system_high_contrast: bool,
348    /// Dynamic Type / font scale, applied to every resolved type size.
349    pub font_scale: f32,
350    /// A hover-capable pointer (`@media (hover: hover)`, hover popovers).
351    pub hover: bool,
352    /// `@media (prefers-reduced-motion: reduce)`; transitions become 0 ms.
353    pub reduced_motion: bool,
354    pub insets: Insets,
355    /// Today as `yyyy-mm-dd` (calendars open on it and mark it).
356    pub today: Option<String>,
357    /// How a hover overlay (Tooltip, `openOn: hover` Popover) closes when
358    /// the pointer left its trigger and its content. `0` (default) = at
359    /// once: the HOST delays forwarding the trigger's un-hover (gpui's
360    /// grace). `> 0` (natives: [`HOVER_CLOSE_MS`]) = the core holds it open
361    /// and raises [`OutEvent::HoverTimer`]; the host calls
362    /// [`Surface::hover_timeout`] when it fires.
363    pub hover_close_ms: u32,
364    /// VAPP-200: the viewer may read, not write (an app at app access
365    /// `read`): every writing control builds as `disabled: true`
366    /// ([`crate::read_only`], `fixtures/read-only.json`).
367    pub read_only: bool,
368}
369
370impl Default for SurfaceSettings {
371    fn default() -> Self {
372        SurfaceSettings {
373            locale: crate::locale::DEFAULT_LOCALE.to_string(),
374            strings: IndexMap::new(),
375            mode: ModeSetting::Light,
376            system_dark: false,
377            density: Density::Default,
378            contrast: ContrastSetting::Normal,
379            system_high_contrast: false,
380            font_scale: 1.0,
381            hover: true,
382            reduced_motion: false,
383            insets: Insets::default(),
384            today: None,
385            hover_close_ms: 0,
386            read_only: false,
387        }
388    }
389}
390
391#[derive(Debug, Clone)]
392pub struct SurfaceOptions {
393    pub catalog_id: String,
394    /// `None` = no theme: geometry mode (no recipes, styles verbatim).
395    pub theme: Option<Arc<ResolvedTheme>>,
396    pub mode: Mode,
397    pub extensions: Vec<ExtensionDef>,
398    pub rounding: bool,
399    /// Expand form controls into their parts (default: when themed). Off =
400    /// geometry mode: every native is ONE measured leaf.
401    pub expand_controls: Option<bool>,
402}
403
404impl Default for SurfaceOptions {
405    fn default() -> Self {
406        SurfaceOptions {
407            catalog_id: crate::catalog::CORE_CATALOG_ID.to_string(),
408            theme: Some(crate::themes::default_theme()),
409            mode: Mode::Light,
410            extensions: Vec::new(),
411            rounding: false,
412            expand_controls: None,
413        }
414    }
415}
416
417#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
418pub struct ApplyOutcome {
419    pub structure_changed: bool,
420    /// The reduce issues of a whole tree (`set_nested`, `set_components`,
421    /// `createSurface`) or this message's own refusal (a data write).
422    /// VAPP-103: a streamed `updateComponents` reduces LAZILY (a surface
423    /// sent one component per message costs linear, not quadratic): it
424    /// reports the issues its OWN components raise (unknown component,
425    /// props, `visible`, children, slots); the tree's (missing ids, cycles,
426    /// limits) are [`Surface::issues`] after it.
427    pub issues: Vec<ReduceIssue>,
428}
429
430/// `%` radii a node asked for (resolved against its laid-out box).
431#[derive(Debug, Clone, Copy, PartialEq, Default)]
432struct RadiusPercent {
433    all: Option<f32>,
434    corners: [Option<f32>; 4],
435}
436
437/// Per-slot layout state kept across passes.
438#[derive(Debug, Clone, Default)]
439struct NodeState {
440    flat: Map<String, Value>,
441    visual: Visual,
442    text: TextStyle,
443    control: ControlBox,
444    /// Bumped when props change: the memo entry is stale.
445    content_version: u32,
446    /// The themed style has `@media` blocks: a viewport change restyles it.
447    conditional: bool,
448    radius_pct: Option<RadiusPercent>,
449    /// The px radii last resolved from `radius_pct` (a box change re-resolves).
450    styled: bool,
451    /// The node's resolved (inherited) direction.
452    direction: Direction,
453    /// Its line height is a px value set here or inherited (else `normal`).
454    line_height_set: bool,
455}
456
457#[derive(Debug, Clone)]
458struct Carried {
459    intrinsics: crate::measure::Intrinsics,
460    heights: Vec<(i64, f32)>,
461    props: Map<String, Value>,
462    lines: Option<u32>,
463}
464
465pub struct Surface {
466    pub id: String,
467    pub catalog_id: String,
468    view: Arc<CatalogView>,
469    recipes: Arc<RecipeIndex>,
470    extensions: Vec<ExtensionDef>,
471    /// The theme as set; `effective` = density + contrast applied.
472    theme: Option<Arc<ResolvedTheme>>,
473    effective: Option<Arc<ResolvedTheme>>,
474    settings: SurfaceSettings,
475    strings: StringTable,
476    mode: Mode,
477    expand_controls: Option<bool>,
478    components: Vec<FlatComponent>,
479    nested: Option<NestedNode>,
480    data: Value,
481    root: Option<UiNode>,
482    /// Every issue: the reduce's, then the last build's (template limits),
483    /// then the styles' (each source kept apart so a rebuild replaces its
484    /// own: an overflow clears when the data shrinks).
485    pub(crate) issues: Vec<ReduceIssue>,
486    reduce_issues: Vec<ReduceIssue>,
487    pub(crate) build_issues: Vec<ReduceIssue>,
488    pub(crate) style_issues: Vec<ReduceIssue>,
489    local: LocalState,
490    states: HashMap<String, Vec<String>>,
491
492    /// Slots (dead ones have `live[i] == false`).
493    nodes: Vec<LNode>,
494    live: Vec<bool>,
495    slot_of: HashMap<String, u32>,
496    free: Vec<u32>,
497    main_root: Option<u32>,
498    layers: Vec<LayerSpec>,
499    lists: Vec<ListSpec>,
500    toasts: Vec<ToastSpec>,
501    node_state: Vec<NodeState>,
502    engine: Engine,
503    memo: MeasureMemo,
504    memo_versions: HashMap<u32, u32>,
505    last_measurer: Option<u64>,
506    viewport: (f32, f32),
507    max_height: Option<f32>,
508    direction: Direction,
509    rounding: bool,
510    structure_version: u64,
511    needs_build: bool,
512    restyle_all: bool,
513    style_dirty: BTreeSet<u32>,
514    visuals_dirty: HashSet<u32>,
515    /// The LIFTED templates of the last reduce (`ReduceResult::templates`,
516    /// round 2), by component id.
517    template_cache: HashMap<String, Option<UiNode>>,
518    /// VAPP-103: `components` changed since the last reduce (a streamed
519    /// `updateComponents` reduces on the next read, never per message).
520    reduce_pending: bool,
521    /// `components` positions by id (an update replaces BY ID in O(1)).
522    component_index: HashMap<String, usize>,
523    /// Last placements by slot, so an event can find anchor frames.
524    last_frames: Vec<Frame>,
525    /// Measurements by node id that outlive a slot (a windowed list's rows
526    /// scrolled out and back in cost nothing twice).
527    archive: HashMap<String, Carried>,
528    responsive: bool,
529    breakpoint: Option<String>,
530    delta_added: BTreeSet<u32>,
531    delta_removed: BTreeSet<u32>,
532    delta_changed: BTreeSet<u32>,
533    renumbered: bool,
534    pending: Vec<OutEvent>,
535    scrolls: Vec<ScrollOutput>,
536    /// Counters for the current pass (`LayoutOutput::restyled`/`rebuilt`).
537    restyled: u32,
538    rebuilt: bool,
539    built_nodes: u32,
540    /// A stepped layout pass in progress (`layout_begin`).
541    step_state: Option<pass::PassState>,
542    /// Nodes whose subtree alone rebuilds next pass (a moved window, local
543    /// tab/accordion/carousel/toggle state).
544    subtree_pending: BTreeSet<String>,
545    /// Hover overlays waiting for their close timer.
546    hover_closing: HashSet<String>,
547    /// What the last build knew that events and subtree rebuilds need: the
548    /// literal Table rows (data scopes) and the Forms' disabled/busy flags.
549    seed: crate::layout_tree::BuildSeed,
550    /// Round 2: the surface formatter (default: the English fallback).
551    formatter: Arc<dyn crate::format::Formatter>,
552    /// A fixed clock for relative times (`None` = the wall clock).
553    clock: Option<f64>,
554    /// Bumped on every write to `data` (template key caches).
555    data_version: u64,
556}
557
558// SAFETY: taffy's `CompactLength` carries a `*const ()` slot for `calc()`
559// expressions, which makes `Style` `!Send`. This crate never builds a calc
560// value (every length is a number/percent/auto), so the slot is always the
561// tag variant and the tree is plain owned data. The FFI keeps the surface
562// behind a `Mutex`.
563unsafe impl Send for Surface {}
564
565impl Surface {
566    pub fn new(id: &str, options: SurfaceOptions) -> Surface {
567        let view = CatalogView::with(&options.extensions);
568        let recipes = if options.extensions.is_empty() { RecipeIndex::core() } else { Arc::new(RecipeIndex::new(&view)) };
569        let mut engine = Engine::new();
570        engine.set_rounding(options.rounding);
571        let settings = SurfaceSettings { mode: if options.mode == Mode::Dark { ModeSetting::Dark } else { ModeSetting::Light }, ..SurfaceSettings::default() };
572        let mut s = Surface {
573            id: id.to_string(),
574            catalog_id: options.catalog_id,
575            view,
576            recipes,
577            extensions: options.extensions,
578            theme: options.theme,
579            effective: None,
580            strings: crate::strings::DEFAULT_STRINGS.clone(),
581            settings,
582            mode: options.mode,
583            expand_controls: options.expand_controls,
584            components: Vec::new(),
585            nested: None,
586            data: Value::Object(Map::new()),
587            root: None,
588            issues: Vec::new(),
589            reduce_issues: Vec::new(),
590            build_issues: Vec::new(),
591            style_issues: Vec::new(),
592            local: LocalState::default(),
593            states: HashMap::new(),
594            nodes: Vec::new(),
595            live: Vec::new(),
596            slot_of: HashMap::new(),
597            free: Vec::new(),
598            main_root: None,
599            layers: Vec::new(),
600            lists: Vec::new(),
601            toasts: Vec::new(),
602            node_state: Vec::new(),
603            engine,
604            memo: MeasureMemo::default(),
605            memo_versions: HashMap::new(),
606            last_measurer: None,
607            viewport: (0.0, 0.0),
608            max_height: None,
609            direction: Direction::Ltr,
610            rounding: options.rounding,
611            structure_version: 0,
612            needs_build: true,
613            restyle_all: true,
614            style_dirty: BTreeSet::new(),
615            visuals_dirty: HashSet::new(),
616            template_cache: HashMap::new(),
617            reduce_pending: false,
618            component_index: HashMap::new(),
619            last_frames: Vec::new(),
620            archive: HashMap::new(),
621            responsive: false,
622            breakpoint: None,
623            delta_added: BTreeSet::new(),
624            delta_removed: BTreeSet::new(),
625            delta_changed: BTreeSet::new(),
626            renumbered: false,
627            pending: Vec::new(),
628            scrolls: Vec::new(),
629            restyled: 0,
630            rebuilt: false,
631            built_nodes: 0,
632            step_state: None,
633            subtree_pending: BTreeSet::new(),
634            hover_closing: HashSet::new(),
635            seed: Default::default(),
636            formatter: Arc::new(crate::format::EnglishFormatter),
637            clock: None,
638            data_version: 0,
639        };
640        s.refresh_theme();
641        s
642    }
643
644    // ------------------------------------------------------------------
645    // Configuration
646    // ------------------------------------------------------------------
647
648    /// Round 2: format numbers, currencies, percents, dates and relative
649    /// times through `formatter` (the host's, built in the surface's locale
650    /// and the host's time zone; or [`crate::format::ZonedEnglishFormatter`]
651    /// at the host's UTC offset). The core has no zone database: the zone
652    /// reaches it only through the formatter. The tree re-binds.
653    pub fn set_formatter(&mut self, formatter: Arc<dyn crate::format::Formatter>) {
654        self.formatter = formatter;
655        self.needs_build = true;
656    }
657
658    /// The formatter in effect.
659    pub fn formatter(&self) -> &Arc<dyn crate::format::Formatter> {
660        &self.formatter
661    }
662
663    /// Pin the clock relative times read (epoch ms; `None` = the wall
664    /// clock). A host re-binds a surface showing `formatRelativeTime`
665    /// without `now` at least once a minute ([`Surface::tick`]).
666    pub fn set_clock(&mut self, now_ms: Option<f64>) {
667        self.clock = now_ms;
668        self.needs_build = true;
669    }
670
671    /// Re-bind (relative times move): the next layout rebuilds.
672    pub fn tick(&mut self) {
673        self.needs_build = true;
674    }
675
676    /// Round 2 (§3): whether the surface shows a time that moves with the
677    /// clock — a `formatRelativeTime` call without `now` in the tree or a
678    /// template, or a Table `relativeTime` column — so the host calls
679    /// [`Surface::tick`] at least once a minute. `false` with a pinned clock
680    /// ([`Surface::set_clock`]). O(nodes): ask after a reduce or layout.
681    pub fn uses_clock(&self) -> bool {
682        fn relative_call(v: &Value) -> bool {
683            match v {
684                Value::Object(o) => (o.get("call").and_then(Value::as_str) == Some("formatRelativeTime") && o.get("args").and_then(|a| a.get("now")).is_none()) || o.values().any(relative_call),
685                Value::Array(a) => a.iter().any(relative_call),
686                _ => false,
687            }
688        }
689        if self.clock.is_some() {
690            return false;
691        }
692        let mut found = false;
693        for tree in self.root.iter().chain(self.template_cache.values().flatten()) {
694            tree.walk(&mut |n| found = found || n.props.values().any(relative_call));
695        }
696        found
697            || self.nodes.iter().zip(&self.live).any(|(n, live)| {
698                *live && n.component == "Table" && n.props.get("columns").and_then(Value::as_array).is_some_and(|cols| cols.iter().any(|c| c.get("type").and_then(Value::as_str) == Some("relativeTime")))
699            })
700    }
701
702    /// Register an extension catalog (validated). The tree is re-reduced.
703    pub fn register_extension(&mut self, ext: ExtensionDef) -> Result<(), String> {
704        let ext = crate::extension::define_extension(ext)?;
705        let mut all = self.extensions.clone();
706        all.retain(|e| e.id != ext.id);
707        all.push(ext);
708        self.view = CatalogView::with(&all);
709        self.recipes = Arc::new(RecipeIndex::new(&self.view));
710        self.extensions = all;
711        self.template_cache.clear();
712        self.reduce();
713        Ok(())
714    }
715
716    /// The theme in effect: the set theme with the surface's density and
717    /// contrast applied (once per change, so every resolver stays agnostic).
718    fn refresh_theme(&mut self) {
719        let contrast = match self.settings.contrast {
720            ContrastSetting::High => true,
721            ContrastSetting::Normal => false,
722            ContrastSetting::System => self.settings.system_high_contrast,
723        };
724        self.effective = self.theme.as_ref().map(|t| {
725            if self.settings.density == Density::Default && !contrast {
726                return t.clone();
727            }
728            let mut out = apply_density(t, self.settings.density);
729            if contrast {
730                out = apply_contrast(&out);
731            }
732            Arc::new(out)
733        });
734        self.restyle_all = true;
735    }
736
737    pub fn set_theme(&mut self, theme: Option<Arc<ResolvedTheme>>) {
738        self.theme = theme;
739        self.refresh_theme();
740        // Gap tokens feed List gaps; expansion depends on a theme.
741        self.needs_build = true;
742        self.invalidate_measures();
743    }
744
745    pub fn theme(&self) -> Option<&Arc<ResolvedTheme>> {
746        self.theme.as_ref()
747    }
748
749    /// The theme with density and contrast applied (what the core resolves
750    /// against; painters reading tokens should use this one).
751    pub fn effective_theme(&self) -> Option<&Arc<ResolvedTheme>> {
752        self.effective.as_ref()
753    }
754
755    pub fn set_mode(&mut self, mode: Mode) {
756        self.settings.mode = if mode == Mode::Dark { ModeSetting::Dark } else { ModeSetting::Light };
757        if self.mode != mode {
758            self.mode = mode;
759            self.restyle_all = true;
760        }
761    }
762
763    pub fn mode(&self) -> Mode {
764        self.mode
765    }
766
767    pub fn settings(&self) -> &SurfaceSettings {
768        &self.settings
769    }
770
771    /// Replace every setting at once; the parts that changed take effect on
772    /// the next layout (a type-size change re-measures).
773    pub fn set_settings(&mut self, settings: SurfaceSettings) {
774        let old = std::mem::replace(&mut self.settings, settings);
775        let s = self.settings.clone();
776        if old.strings != s.strings {
777            self.strings = crate::strings::string_table(&s.strings);
778            self.needs_build = true;
779        }
780        if old.read_only != s.read_only {
781            self.needs_build = true;
782            self.restyle_all = true;
783        }
784        if old.locale != s.locale || old.today != s.today {
785            self.needs_build = true;
786            self.restyle_all = true;
787        }
788        if old.density != s.density || old.contrast != s.contrast || old.system_high_contrast != s.system_high_contrast {
789            self.refresh_theme();
790        }
791        let mode = resolve_mode(s.mode, s.system_dark);
792        if mode != self.mode {
793            self.mode = mode;
794            self.restyle_all = true;
795        }
796        if (old.font_scale - s.font_scale).abs() > f32::EPSILON || old.hover != s.hover || old.reduced_motion != s.reduced_motion {
797            self.restyle_all = true;
798        }
799        if old.insets != s.insets {
800            self.engine.mark_all_dirty();
801        }
802    }
803
804    /// VAPP-200: a read-only surface (every writing control disabled).
805    pub fn set_read_only(&mut self, on: bool) {
806        let mut s = self.settings.clone();
807        s.read_only = on;
808        self.set_settings(s);
809    }
810
811    pub fn set_locale(&mut self, locale: &str) {
812        let mut s = self.settings.clone();
813        s.locale = locale.to_string();
814        self.set_settings(s);
815    }
816
817    pub fn set_strings(&mut self, overrides: IndexMap<String, String>) {
818        let mut s = self.settings.clone();
819        s.strings = overrides;
820        self.set_settings(s);
821    }
822
823    /// `light | dark | system`, with the platform's current preference.
824    pub fn set_mode_setting(&mut self, mode: ModeSetting, system_dark: bool) {
825        let mut s = self.settings.clone();
826        s.mode = mode;
827        s.system_dark = system_dark;
828        self.set_settings(s);
829    }
830
831    pub fn set_density(&mut self, density: Density) {
832        let mut s = self.settings.clone();
833        s.density = density;
834        self.set_settings(s);
835    }
836
837    pub fn set_contrast(&mut self, contrast: ContrastSetting, system_high: bool) {
838        let mut s = self.settings.clone();
839        s.contrast = contrast;
840        s.system_high_contrast = system_high;
841        self.set_settings(s);
842    }
843
844    /// Dynamic Type / font scale (1 = the theme's sizes).
845    pub fn set_font_scale(&mut self, scale: f32) {
846        let mut s = self.settings.clone();
847        s.font_scale = scale.max(0.1);
848        self.set_settings(s);
849    }
850
851    pub fn set_insets(&mut self, insets: Insets) {
852        let mut s = self.settings.clone();
853        s.insets = insets;
854        self.set_settings(s);
855    }
856
857    /// A hover-capable pointer and the reduced-motion preference.
858    pub fn set_pointer(&mut self, hover: bool, reduced_motion: bool) {
859        let mut s = self.settings.clone();
860        s.hover = hover;
861        s.reduced_motion = reduced_motion;
862        self.set_settings(s);
863    }
864
865    /// Android accumulates in dp and rounds once itself; the browser never
866    /// rounds. Off = fractional frames.
867    pub fn set_rounding(&mut self, on: bool) {
868        self.rounding = on;
869        self.engine.set_rounding(on);
870        self.engine.mark_all_dirty();
871    }
872
873    /// Surface size. Height ≤ 0 = as tall as the content (a scrolling host).
874    /// `max_height` bounds a transcript card; `LayoutOutput::overflow` says
875    /// when the content is taller. Returns whether anything changed.
876    pub fn set_viewport(&mut self, width: f32, height: f32, max_height: Option<f32>) -> bool {
877        if self.viewport == (width, height) && self.max_height == max_height {
878            return false;
879        }
880        let height_changed = self.viewport.1 != height || self.max_height != max_height;
881        self.viewport = (width, height);
882        self.max_height = max_height;
883        for (i, s) in self.node_state.iter().enumerate() {
884            if s.conditional && self.live.get(i).copied().unwrap_or(false) {
885                self.style_dirty.insert(i as u32);
886            }
887        }
888        // Windowed lists size their range off the viewport height; a
889        // responsive native follows the breakpoint.
890        if (height_changed && !self.lists.is_empty()) || (self.responsive && self.active_breakpoint() != self.breakpoint) {
891            self.needs_build = true;
892        }
893        true
894    }
895
896    pub fn viewport(&self) -> (f32, f32) {
897        self.viewport
898    }
899
900    /// The breakpoint of the current width (theme tokens, else the defaults).
901    fn active_breakpoint(&self) -> Option<String> {
902        crate::conditions::active_breakpoint(self.viewport.0, &self.breakpoints())
903    }
904
905    fn breakpoints(&self) -> IndexMap<String, f32> {
906        match &self.effective {
907            Some(t) if !t.tokens.breakpoint.is_empty() => t.tokens.breakpoint.iter().map(|(k, v)| (k.clone(), *v as f32)).collect(),
908            _ => crate::conditions::default_breakpoints(),
909        }
910    }
911
912    /// Replace a node's interaction states (hover/pressed/focus/
913    /// focus-visible/disabled…). Restyles THAT node only. A hover or a
914    /// keyboard focus on a Tooltip's or a hover Popover's TRIGGER opens it
915    /// at once (the host owns the open delay); it STAYS open while the
916    /// pointer (or focus) is on the trigger or on ANY node of its layer.
917    /// When both are left it closes at once, or — with
918    /// `settings.hover_close_ms > 0` — raises [`OutEvent::HoverTimer`] and
919    /// closes on [`Self::hover_timeout`] unless the pointer came back
920    /// (crossing the gap from trigger to content). The change events are in
921    /// `take_events`.
922    pub fn set_states(&mut self, id: &str, states: Vec<String>) -> bool {
923        let current = self.states.get(id);
924        if current.is_some_and(|c| *c == states) || (current.is_none() && states.is_empty()) {
925            return false;
926        }
927        if states.is_empty() {
928            self.states.remove(id);
929        } else {
930            self.states.insert(id.to_string(), states);
931        }
932        if let Some(&slot) = self.slot_of.get(id) {
933            self.style_dirty.insert(slot);
934            self.lend_states(slot);
935            for target in self.hover_targets(slot) {
936                let open = self.local.open.get(&target).copied().unwrap_or(false);
937                let active = self.hover_active(&target);
938                if active && !open {
939                    self.hover_closing.remove(&target);
940                    let events = self.set_open(&target, true);
941                    self.pending.extend(events.into_iter().filter(|e| !matches!(e, OutEvent::Relayout)));
942                } else if active {
943                    self.hover_closing.remove(&target);
944                } else if open && self.settings.hover_close_ms == 0 {
945                    let events = self.set_open(&target, false);
946                    self.pending.extend(events.into_iter().filter(|e| !matches!(e, OutEvent::Relayout)));
947                } else if open && self.hover_closing.insert(target.clone()) {
948                    self.pending.push(OutEvent::HoverTimer { owner: target, delay_ms: self.settings.hover_close_ms });
949                }
950            }
951        }
952        true
953    }
954
955    /// A [`OutEvent::HoverTimer`] fired: close the hover overlay `owner`
956    /// unless its trigger or its layer is hovered (or keyboard-focused)
957    /// again. Returns the change events (`Relayout` when it closed).
958    pub fn hover_timeout(&mut self, owner: &str) -> Vec<OutEvent> {
959        if !self.hover_closing.remove(owner) || !self.opens_on_hover(owner) || self.hover_active(owner) {
960            return vec![];
961        }
962        if !self.local.open.get(owner).copied().unwrap_or(false) {
963            return vec![];
964        }
965        self.set_open(owner, false)
966    }
967
968    /// The hover overlays a node keeps open: the one it is the TRIGGER of
969    /// (the node itself, so a host's open delay on the trigger holds), and
970    /// every hover overlay whose LAYER it is in (then on from that overlay's
971    /// own node: a hover card inside a hover card keeps both).
972    fn hover_targets(&self, slot: u32) -> Vec<String> {
973        let mut out = Vec::new();
974        let mut cur = Some(slot);
975        let mut guard = 0;
976        while let Some(c) = cur {
977            guard += 1;
978            if guard > 4096 || !self.live.get(c as usize).copied().unwrap_or(false) {
979                break;
980            }
981            let n = &self.nodes[c as usize];
982            if let Some(t) = n.trigger_for.as_ref().filter(|_| c == slot) {
983                if self.opens_on_hover(t) && !out.contains(t) {
984                    out.push(t.clone());
985                }
986            }
987            cur = match n.parent {
988                Some(p) => Some(p),
989                None => match self.layers.iter().find(|l| l.root == c) {
990                    Some(l) => {
991                        if self.opens_on_hover(&l.owner) && !out.contains(&l.owner) {
992                            out.push(l.owner.clone());
993                        }
994                        self.slot_of.get(&l.owner).copied()
995                    }
996                    None => None,
997                },
998            };
999        }
1000        out
1001    }
1002
1003    /// Is any live node that keeps `target` open hovered or focused?
1004    fn hover_active(&self, target: &str) -> bool {
1005        self.states.iter().any(|(id, st)| {
1006            st.iter().any(|s| s == "hover" || s == "focus-visible" || s == "focus")
1007                && self.slot_of.get(id).is_some_and(|&slot| self.live[slot as usize] && self.hover_targets(slot).iter().any(|t| t == target))
1008                // A plain `focus` keeps it open only inside its layer (focus
1009                // moved into the card); on the trigger it takes focus-visible.
1010                && (st.iter().any(|s| s == "hover" || s == "focus-visible") || self.in_layer_of(self.slot_of[id], target))
1011        })
1012    }
1013
1014    /// Is `slot` inside the layer `owner` opened?
1015    fn in_layer_of(&self, slot: u32, owner: &str) -> bool {
1016        let Some(layer) = self.layers.iter().find(|l| l.owner == owner) else { return false };
1017        let mut cur = Some(slot);
1018        while let Some(c) = cur {
1019            if c == layer.root {
1020                return true;
1021            }
1022            cur = self.nodes[c as usize].parent;
1023        }
1024        false
1025    }
1026
1027    fn opens_on_hover(&self, target: &str) -> bool {
1028        let Some(&slot) = self.slot_of.get(target) else { return false };
1029        let n = &self.nodes[slot as usize];
1030        n.component == "Tooltip" || (n.component == "Popover" && n.props.get("openOn").and_then(Value::as_str) == Some("hover"))
1031    }
1032
1033    /// The pressed set as a whole (the spike's `set_pressed`).
1034    pub fn set_pressed(&mut self, ids: &[String]) -> bool {
1035        self.set_flagged("pressed", ids)
1036    }
1037
1038    /// The hovered set as a whole: `ids` carry the `hover` state (recipes'
1039    /// `hover` / style `:hover` resolve through it), every other node loses
1040    /// it. Same effects as [`Self::set_states`] (a hover overlay opens).
1041    pub fn set_hovered(&mut self, ids: &[String]) -> bool {
1042        self.set_flagged("hover", ids)
1043    }
1044
1045    /// One node's `hover` on or off, its other states kept (a host's pointer
1046    /// enter / leave).
1047    pub fn set_hover(&mut self, id: &str, hovered: bool) -> bool {
1048        let mut s = self.states.get(id).cloned().unwrap_or_default();
1049        let has = s.iter().any(|x| x == "hover");
1050        if has == hovered {
1051            return false;
1052        }
1053        if hovered {
1054            s.push("hover".into());
1055        } else {
1056            s.retain(|x| x != "hover");
1057        }
1058        self.set_states(id, s)
1059    }
1060
1061    /// The interaction states the HOST set on a node (`hover`, `pressed`,
1062    /// `focus`…; [`Self::set_states`]), empty when none.
1063    pub fn host_states(&self, id: &str) -> &[String] {
1064        self.states.get(id).map(Vec::as_slice).unwrap_or(&[])
1065    }
1066
1067    /// Exactly `ids` carry `flag`; the rest of each node's states are kept.
1068    fn set_flagged(&mut self, flag: &str, ids: &[String]) -> bool {
1069        let mut changed = false;
1070        let keep: HashSet<&String> = ids.iter().collect();
1071        let had: Vec<String> = self.states.iter().filter(|(_, s)| s.iter().any(|x| x == flag)).map(|(k, _)| k.clone()).collect();
1072        for id in had {
1073            if !keep.contains(&id) {
1074                let mut s = self.states.get(&id).cloned().unwrap_or_default();
1075                s.retain(|x| x != flag);
1076                changed |= self.set_states(&id, s);
1077            }
1078        }
1079        for id in ids {
1080            let mut s = self.states.get(id).cloned().unwrap_or_default();
1081            if !s.iter().any(|x| x == flag) {
1082                s.push(flag.into());
1083                changed |= self.set_states(id, s);
1084            }
1085        }
1086        changed
1087    }
1088
1089    /// Events the core raised outside a call that returns them (a hover
1090    /// opening a tooltip, a live region announcing).
1091    pub fn take_events(&mut self) -> Vec<OutEvent> {
1092        std::mem::take(&mut self.pending)
1093    }
1094
1095    /// Forget every cached measurement (a font load, Dynamic Type).
1096    pub fn invalidate_measures(&mut self) {
1097        self.memo.clear();
1098        self.memo_versions.clear();
1099        self.archive.clear();
1100        self.engine.mark_all_dirty();
1101    }
1102
1103    /// Forget one node's cached measurement.
1104    pub fn mark_dirty(&mut self, index: u32) -> bool {
1105        if !self.live.get(index as usize).copied().unwrap_or(false) {
1106            return false;
1107        }
1108        self.memo.forget(index);
1109        self.memo_versions.remove(&index);
1110        self.engine.mark_dirty(index);
1111        true
1112    }
1113
1114    // ------------------------------------------------------------------
1115    // Content
1116    // ------------------------------------------------------------------
1117
1118    /// Apply one A2UI server→client message (`createSurface`,
1119    /// `updateComponents`, `updateDataModel`, `deleteSurface`).
1120    pub fn apply(&mut self, message: &Value) -> Result<ApplyOutcome, String> {
1121        let obj = message.as_object().ok_or("message must be an object")?;
1122        if let Some(create) = obj.get("createSurface") {
1123            if let Some(cid) = create.get("catalogId").and_then(Value::as_str) {
1124                self.catalog_id = cid.to_string();
1125            }
1126            if let Some(sid) = create.get("surfaceId").and_then(Value::as_str) {
1127                self.id = sid.to_string();
1128            }
1129            self.set_component_list(Vec::new());
1130            self.nested = None;
1131            self.data = Value::Object(Map::new());
1132            self.data_version += 1;
1133            self.local = LocalState::default();
1134            self.template_cache.clear();
1135            self.reduce();
1136            return Ok(ApplyOutcome { structure_changed: true, issues: self.issues.clone() });
1137        }
1138        if let Some(update) = obj.get("updateComponents") {
1139            let components = update.get("components").ok_or("updateComponents.components is required")?;
1140            let list: Vec<FlatComponent> = serde_json::from_value(components.clone()).map_err(|e| format!("components: {e}"))?;
1141            return Ok(self.update_components(list));
1142        }
1143        self.ensure_reduced();
1144        if let Some(update) = obj.get("updateDataModel") {
1145            let path = update.get("path").and_then(Value::as_str).unwrap_or("");
1146            let value = update.get("value").cloned();
1147            let issues = match self.set_data(path, value) {
1148                Ok(()) => Vec::new(),
1149                Err(message) => vec![ReduceIssue { id: path.to_string(), message }],
1150            };
1151            return Ok(ApplyOutcome { structure_changed: self.needs_build, issues });
1152        }
1153        if obj.get("deleteSurface").is_some() {
1154            self.set_component_list(Vec::new());
1155            self.nested = None;
1156            self.root = None;
1157            self.data = Value::Object(Map::new());
1158            self.data_version += 1;
1159            self.local = LocalState::default();
1160            self.needs_build = true;
1161            return Ok(ApplyOutcome { structure_changed: true, issues: Vec::new() });
1162        }
1163        Err("unknown message: expected createSurface | updateComponents | updateDataModel | deleteSurface".into())
1164    }
1165
1166    /// An `updateComponents` payload. A2UI: a later update replaces
1167    /// components BY ID and keeps the rest (the TS reference's SurfaceStore
1168    /// does the same). VAPP-103: O(update) here, the tree reduces LAZILY on
1169    /// the next read (layout, an event, [`Self::issues`]), so a surface
1170    /// streamed one component per message costs linear, not quadratic.
1171    pub fn update_components(&mut self, list: Vec<FlatComponent>) -> ApplyOutcome {
1172        if self.nested.is_some() {
1173            self.set_component_list(Vec::new());
1174        }
1175        // The previous version of every id this update touches (`None` =
1176        // new), for the local field values.
1177        let mut replaced: HashMap<String, Option<FlatComponent>> = HashMap::new();
1178        let mut order: Vec<String> = Vec::new();
1179        for c in list {
1180            if !replaced.contains_key(&c.id) {
1181                order.push(c.id.clone());
1182            }
1183            match self.component_index.get(&c.id) {
1184                Some(&i) => {
1185                    let old = std::mem::replace(&mut self.components[i], c);
1186                    replaced.entry(old.id.clone()).or_insert(Some(old));
1187                }
1188                None => {
1189                    replaced.entry(c.id.clone()).or_insert(None);
1190                    self.component_index.insert(c.id.clone(), self.components.len());
1191                    self.components.push(c);
1192                }
1193            }
1194        }
1195        self.nested = None;
1196        self.retain_updated_field_values(&replaced);
1197        self.reduce_pending = true;
1198        self.needs_build = true;
1199        // The message's own components' issues now (O(message)); the tree's
1200        // with the next read ([`Self::issues`]).
1201        let options = ReduceOptions::new(&self.catalog_id).with_view(self.view.clone());
1202        let (components, index) = (&self.components, &self.component_index);
1203        let lookup = |id: &str| index.get(id).map(|&i| components[i].clone());
1204        let mut issues = Vec::new();
1205        for id in &order {
1206            if let Some(&i) = index.get(id.as_str()) {
1207                issues.extend(crate::reducer::component_issues(&components[i], &options, &lookup));
1208            }
1209        }
1210        ApplyOutcome { structure_changed: true, issues }
1211    }
1212
1213    /// The nested authoring form (fixtures, MCP templates) instead of a flat
1214    /// component list.
1215    pub fn set_nested(&mut self, tree: NestedNode) -> ApplyOutcome {
1216        self.nested = Some(tree);
1217        self.set_component_list(Vec::new());
1218        self.template_cache.clear();
1219        self.local.field_values.clear();
1220        self.reduce();
1221        ApplyOutcome { structure_changed: true, issues: self.issues.clone() }
1222    }
1223
1224    /// A flat component list (what `updateComponents` carries).
1225    pub fn set_components(&mut self, components: Vec<FlatComponent>) -> ApplyOutcome {
1226        let before = if self.nested.is_some() { Vec::new() } else { std::mem::take(&mut self.components) };
1227        self.set_component_list(components);
1228        self.nested = None;
1229        self.template_cache.clear();
1230        self.retain_field_values(&before);
1231        self.reduce();
1232        ApplyOutcome { structure_changed: true, issues: self.issues.clone() }
1233    }
1234
1235    /// Replace the whole component list (the id index follows).
1236    fn set_component_list(&mut self, components: Vec<FlatComponent>) {
1237        self.component_index = components.iter().enumerate().map(|(i, c)| (c.id.clone(), i)).collect();
1238        self.components = components;
1239    }
1240
1241    /// [`Self::retain_field_values`] after a by-id update: `replaced` = the
1242    /// previous version of each id it touched (`None` = new); every other
1243    /// component is unchanged. O(field values), never O(components).
1244    fn retain_updated_field_values(&mut self, replaced: &HashMap<String, Option<FlatComponent>>) {
1245        if self.local.field_values.is_empty() {
1246            return;
1247        }
1248        let (components, index) = (&self.components, &self.component_index);
1249        self.local.field_values.retain(|key, _| {
1250            let mut candidate = key.as_str();
1251            loop {
1252                if let Some(old) = replaced.get(candidate) {
1253                    let new = index.get(candidate).map(|&i| &components[i]);
1254                    return matches!((old.as_ref(), new), (Some(a), Some(b)) if a == b);
1255                }
1256                if index.contains_key(candidate) {
1257                    return true;
1258                }
1259                match candidate.rfind('.') {
1260                    Some(i) => candidate = &candidate[..i],
1261                    None => return false,
1262                }
1263            }
1264        });
1265    }
1266
1267    /// After a component update, keep an unbound control's local value
1268    /// while the component that owns it is unchanged (React keeps the state
1269    /// of a still-mounted node the same way); drop it when that component
1270    /// was removed or re-sent different. A value's owner = the longest
1271    /// component id equal to the key or prefixing it at a `.` (parts and
1272    /// template instances).
1273    fn retain_field_values(&mut self, before: &[FlatComponent]) {
1274        if self.local.field_values.is_empty() {
1275            return;
1276        }
1277        let old: HashMap<&str, &FlatComponent> = before.iter().map(|c| (c.id.as_str(), c)).collect();
1278        let new: HashMap<&str, &FlatComponent> = self.components.iter().map(|c| (c.id.as_str(), c)).collect();
1279        self.local.field_values.retain(|key, _| {
1280            let mut candidate = key.as_str();
1281            loop {
1282                if old.contains_key(candidate) || new.contains_key(candidate) {
1283                    return matches!((old.get(candidate), new.get(candidate)), (Some(a), Some(b)) if a == b);
1284                }
1285                match candidate.rfind('.') {
1286                    Some(i) => candidate = &candidate[..i],
1287                    None => return false,
1288                }
1289            }
1290        });
1291    }
1292
1293    /// Write `value` at `path` (`None` removes). Templates and bindings
1294    /// re-resolve on the next layout.
1295    /// VAPP-103: a refused write (a pointer past the limits, a non-index
1296    /// token or an index past the end of an array) changes nothing and
1297    /// returns the reason.
1298    pub fn set_data(&mut self, path: &str, value: Option<Value>) -> Result<(), String> {
1299        set_pointer(&mut self.data, path, value)?;
1300        self.data_version += 1;
1301        self.needs_build = true;
1302        Ok(())
1303    }
1304
1305    pub fn data(&self) -> &Value {
1306        &self.data
1307    }
1308
1309    pub fn root(&mut self) -> Option<&UiNode> {
1310        self.ensure_reduced();
1311        self.root.as_ref()
1312    }
1313
1314    /// The reduce issues (plus the build's: styles, template items), after
1315    /// any streamed update is reduced.
1316    pub fn issues(&mut self) -> &[ReduceIssue] {
1317        self.ensure_reduced();
1318        &self.issues
1319    }
1320
1321    /// Reduce a streamed update now (every reader of the tree calls it).
1322    pub(crate) fn ensure_reduced(&mut self) {
1323        if self.reduce_pending {
1324            self.reduce();
1325        }
1326    }
1327
1328    fn reduce(&mut self) {
1329        crate::roomy(|| self.reduce_now())
1330    }
1331
1332    fn reduce_now(&mut self) {
1333        self.template_cache.clear();
1334        self.local.static_keys.clear();
1335        self.local.key_cache.clear();
1336        let options = ReduceOptions::new(&self.catalog_id).with_view(self.view.clone());
1337        let result: Option<ReduceResult> = if let Some(nested) = &self.nested {
1338            Some(crate::reducer::reduce_nested(nested, &options))
1339        } else if !self.components.is_empty() {
1340            Some(reduce_surface(&self.components, &options))
1341        } else {
1342            None
1343        };
1344        match result {
1345            Some(r) => {
1346                self.root = Some(r.root);
1347                self.reduce_issues = r.issues;
1348                // Round 2: template nodes are lifted out of the tree; items
1349                // instantiate them from this table.
1350                self.template_cache = r.templates.unwrap_or_default().into_iter().map(|(id, n)| (id, Some(n))).collect();
1351            }
1352            None => {
1353                self.root = None;
1354                self.reduce_issues.clear();
1355            }
1356        }
1357        self.build_issues.clear();
1358        self.style_issues.clear();
1359        self.refresh_issues();
1360        // Only a reduce that RAN clears the flag (a panic above leaves it
1361        // pending: the next read reduces again, never a stale root).
1362        self.reduce_pending = false;
1363        self.needs_build = true;
1364    }
1365
1366    /// `issues` = the reduce's + the build's + the styles' (once each).
1367    pub(crate) fn refresh_issues(&mut self) {
1368        let mut all: Vec<ReduceIssue> = Vec::with_capacity(self.reduce_issues.len() + self.build_issues.len() + self.style_issues.len());
1369        for issue in self.reduce_issues.iter().chain(&self.build_issues).chain(&self.style_issues) {
1370            if !all.contains(issue) {
1371                all.push(issue.clone());
1372            }
1373        }
1374        self.issues = all;
1375    }
1376
1377    fn gap_px(&self, name: &str) -> f32 {
1378        match &self.effective {
1379            Some(t) => t.tokens.spacing.get(name).copied().unwrap_or(0.0) as f32,
1380            None => match name {
1381                "xxs" => 2.0,
1382                "xs" => 4.0,
1383                "sm" => 8.0,
1384                "md" => 12.0,
1385                "lg" => 16.0,
1386                "xl" => 24.0,
1387                _ => 0.0,
1388            },
1389        }
1390    }
1391
1392    // ------------------------------------------------------------------
1393    // Snapshots
1394    // ------------------------------------------------------------------
1395
1396    pub fn structure_version(&self) -> u64 {
1397        self.structure_version
1398    }
1399
1400    fn placed(&self, i: usize) -> PlacedNode {
1401        let n = &self.nodes[i];
1402        let removed = !self.live[i];
1403        let mut states: Vec<String> = Vec::new();
1404        for q in [&n.part_query, &n.own_query].into_iter().flatten() {
1405            for s in &q.states {
1406                if !states.contains(s) {
1407                    states.push(s.clone());
1408                }
1409            }
1410        }
1411        PlacedNode {
1412            index: i as u32,
1413            id: n.id.clone(),
1414            component: n.component.clone(),
1415            part: n.part.clone(),
1416            owner: n.owner.clone(),
1417            owner_component: n.owner_component.clone(),
1418            catalog_id: n.catalog_id.clone(),
1419            extension_kind: n.extension_kind.clone(),
1420            depth: n.depth,
1421            parent: if removed { None } else { n.parent },
1422            children: if removed { Vec::new() } else { n.children.clone() },
1423            layer: n.layer,
1424            kind: n.kind,
1425            props: if removed { Map::new() } else { n.props.clone() },
1426            lines: n.lines,
1427            pressable: n.pressable && !removed,
1428            hidden: n.hidden || removed,
1429            accessibility: n.accessibility.clone(),
1430            trigger_for: n.trigger_for.clone(),
1431            states,
1432            form: n.form.clone(),
1433            live: n.live.clone(),
1434            removed,
1435        }
1436    }
1437
1438    /// Every slot (removed ones as tombstones, so `nodes()[i].index == i`).
1439    pub fn nodes(&mut self) -> Vec<PlacedNode> {
1440        self.settle();
1441        (0..self.nodes.len()).map(|i| self.placed(i)).collect()
1442    }
1443
1444    /// The given slots only (a delta's `added` + `changed`).
1445    pub fn nodes_at(&mut self, indices: &[u32]) -> Vec<PlacedNode> {
1446        self.settle();
1447        indices.iter().filter(|i| (**i as usize) < self.nodes.len()).map(|i| self.placed(*i as usize)).collect()
1448    }
1449
1450    /// Live nodes.
1451    pub fn node_count(&mut self) -> usize {
1452        self.settle();
1453        self.live.iter().filter(|l| **l).count()
1454    }
1455
1456    /// The resolved visual of one node (after a layout pass).
1457    pub fn visual(&self, index: u32) -> Option<&Visual> {
1458        self.node_state.get(index as usize).map(|s| &s.visual)
1459    }
1460
1461    pub fn visuals(&self) -> Vec<Visual> {
1462        self.node_state.iter().map(|s| s.visual.clone()).collect()
1463    }
1464
1465    pub fn text_style(&self, index: u32) -> Option<&TextStyle> {
1466        self.node_state.get(index as usize).map(|s| &s.text)
1467    }
1468
1469    pub fn layout_node(&self, index: u32) -> Option<&LNode> {
1470        self.nodes.get(index as usize).filter(|_| self.live.get(index as usize).copied().unwrap_or(false))
1471    }
1472
1473    pub fn index_of(&self, id: &str) -> Option<u32> {
1474        self.slot_of.get(id).copied()
1475    }
1476
1477    /// Round 2 (VAPP-100): does the node restyle under the pointer? True
1478    /// when its style has a `:hover` block or its recipe (the native's own
1479    /// root recipe, its part or macro recipe) has a rule on `state: hover`
1480    /// that its props match ([`crate::theme::recipe_reacts_to`]), in the
1481    /// effective theme. A painter tracks the pointer over these nodes (as
1482    /// over pressables, triggers and fields) and reports `hover`. Ask again
1483    /// after a theme switch.
1484    pub fn hover_styled(&self, index: u32) -> bool {
1485        let Some(n) = self.layout_node(index) else { return false };
1486        if n.base_style.contains_key(":hover") {
1487            return true;
1488        }
1489        let Some(theme) = &self.effective else { return false };
1490        [&n.own_query, &n.part_query].into_iter().flatten().any(|q| crate::theme::recipe_reacts_to(theme, q, "hover"))
1491    }
1492
1493    pub fn last_frame(&self, index: u32) -> Option<Frame> {
1494        self.last_frames.get(index as usize).copied()
1495    }
1496
1497    pub fn direction(&self) -> Direction {
1498        self.direction
1499    }
1500
1501    /// Is this surface a basic-catalog one?
1502    pub fn is_basic(&self) -> bool {
1503        self.catalog_id == A2UI_BASIC_CATALOG_ID
1504    }
1505
1506    pub fn get_data(&self, path: &str) -> Option<&Value> {
1507        get_pointer(&self.data, path)
1508    }
1509
1510    /// The built-in string table in effect (defaults + the host's).
1511    pub fn strings(&self) -> &StringTable {
1512        &self.strings
1513    }
1514
1515    /// Apply a pending rebuild / re-window before a snapshot. Not while a
1516    /// stepped pass is running (the FFI measures between the steps without
1517    /// the lock): the pass finishes on the nodes it styled and measured, and
1518    /// the rebuild waits for the next pass.
1519    fn settle(&mut self) {
1520        if self.step_state.is_some() {
1521            return;
1522        }
1523        self.ensure_reduced();
1524        if self.needs_build {
1525            self.subtree_pending.clear();
1526            self.rebuild();
1527        } else {
1528            for id in std::mem::take(&mut self.subtree_pending) {
1529                if !self.rebuild_subtree(&id) {
1530                    self.rebuild();
1531                    self.subtree_pending.clear();
1532                    break;
1533                }
1534            }
1535        }
1536    }
1537
1538    /// EXP-1259: the node whose hover/focus a part borrows: a Tabs `close`
1539    /// glyph shows while its TAB is hovered or focused.
1540    fn state_lender(n: &LNode) -> Option<String> {
1541        let owner = n.owner.as_deref()?;
1542        match (n.owner_component.as_deref(), n.part.as_deref()) {
1543            (Some("Tabs"), Some("close")) => n.id.strip_prefix(&format!("{owner}.close")).map(|i| format!("{owner}.tab{i}")),
1544            _ => None,
1545        }
1546    }
1547
1548    /// A node's states changed: restyle the parts that borrow them, and a
1549    /// hovered search row becomes the highlighted one (a hover selects).
1550    fn lend_states(&mut self, slot: u32) {
1551        let n = &self.nodes[slot as usize];
1552        if let (Some(owner), Some("Tabs"), Some("tab")) = (n.owner.clone(), n.owner_component.as_deref(), n.part.as_deref()) {
1553            if let Some(&close) = n.id.strip_prefix(&format!("{owner}.tab")).and_then(|i| self.slot_of.get(&format!("{owner}.close{i}"))) {
1554                self.style_dirty.insert(close);
1555            }
1556        }
1557        let n = &self.nodes[slot as usize];
1558        let hovered = self.states.get(&n.id).is_some_and(|s| s.iter().any(|x| x == "hover"));
1559        // EXP-1259: a pointer over a top-level Menu row opens ITS submenu
1560        // (or closes another row's), like the web's; keys and presses still
1561        // open one too.
1562        if let (true, Some(owner), Some("Menu"), Some("item")) = (hovered, n.owner.clone(), n.owner_component.as_deref(), n.part.as_deref()) {
1563            let top = n.id.strip_prefix(&format!("{owner}.item.")).filter(|rest| !rest.contains('.')).and_then(|i| i.parse::<usize>().ok());
1564            if let Some(i) = top {
1565                let submenu = n.props.get("kind").and_then(Value::as_str) == Some("submenu");
1566                let open = self.local.submenu.get(&owner).copied();
1567                let next = if submenu { Some(i) } else { None };
1568                if open != next && (submenu || open.is_some()) {
1569                    match next {
1570                        Some(i) => self.local.submenu.insert(owner.clone(), i),
1571                        None => self.local.submenu.remove(&owner),
1572                    };
1573                    self.needs_build = true;
1574                    if !self.pending.iter().any(|e| matches!(e, OutEvent::Relayout)) {
1575                        self.pending.push(OutEvent::Relayout);
1576                    }
1577                }
1578            }
1579        }
1580        let n = &self.nodes[slot as usize];
1581        if let (true, Some(owner), Some(position), Some("item")) = (hovered, n.owner.clone(), n.props.get("position").and_then(Value::as_u64), n.part.as_deref()) {
1582            let key = match n.owner_component.as_deref() {
1583                Some("SearchList") => Some(owner.clone()),
1584                // `<menu>.item.<sub>.<i>` → `<menu>.search.<sub>`
1585                Some("Menu") => n.id.strip_prefix(&format!("{owner}.item")).and_then(|rest| rest.rsplit_once('.')).map(|(sub, _)| format!("{owner}.search{sub}")),
1586                _ => None,
1587            };
1588            if let Some(key) = key {
1589                if self.local.active.get(&key).copied().unwrap_or(0) != position as usize {
1590                    self.local.active.insert(key, position as usize);
1591                    self.needs_build = true;
1592                    if !self.pending.iter().any(|e| matches!(e, OutEvent::Relayout)) {
1593                        self.pending.push(OutEvent::Relayout);
1594                    }
1595                }
1596            }
1597        }
1598    }
1599
1600    fn states_of(&self, n: &LNode) -> Vec<String> {
1601        let mut states = self.states.get(&n.id).cloned().unwrap_or_default();
1602        if let Some(lender) = Self::state_lender(n) {
1603            for s in self.states.get(&lender).into_iter().flatten() {
1604                if matches!(s.as_str(), "hover" | "focus" | "focus-visible") && !states.contains(s) {
1605                    states.push(s.clone());
1606                }
1607            }
1608        }
1609        if n.props.get("disabled").and_then(Value::as_bool) == Some(true) && !states.iter().any(|s| s == "disabled") {
1610            states.push("disabled".into());
1611        }
1612        for q in [&n.part_query, &n.own_query].into_iter().flatten() {
1613            for s in &q.states {
1614                if !states.contains(s) {
1615                    states.push(s.clone());
1616                }
1617            }
1618        }
1619        states
1620    }
1621}