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