Skip to main content

frust_shell_common/
app_tree.rs

1//! [`AppTree`]: the type-erasure that lets a single non-generic native handle
2//! drive any app's `State`/`build`/`View`.
3//!
4//! Every platform shell stores its running app as a `Box<dyn AppTree>` behind an
5//! opaque handle, so the FFI-exported entry points (which can't be generic) stay
6//! non-generic while still driving a concrete app. This is the one seam that
7//! keeps the shell runtime widget-agnostic — apps bring their own view types in
8//! through the shell's app-binding macro.
9
10use std::any::Any;
11
12use frust_core::accesskit;
13use frust_core::anim::FrameTime;
14use frust_core::event::{EditingState, EventOutcome, ImeEvent, ImeState, InputEvent};
15use frust_core::insets::WindowInsets;
16use frust_core::selection_toolbar::SelectionToolbarRequest;
17use frust_core::view::{ChangeFlags, View};
18use frust_core::widget::PlatformViewFrame;
19use frust_core::{PaintOutcome, PaintScene, RenderRoot, SemanticsUpdate};
20use kurbo::Size;
21
22/// Type-erased app tree: the one seam that lets a shell's native handle stay
23/// non-generic while still driving a concrete `State`/`build`/`View`.
24///
25/// Mirrors the desktop facade's erasure approach (a stored generic behind a
26/// non-generic driver): a platform shell's generated `extern` entry points can't
27/// be generic, so the shell's app-binding macro instantiates [`new_boxed_app`]
28/// with the app's types and stores the result as a `Box<dyn AppTree>` inside the
29/// handle.
30pub trait AppTree {
31    /// Re-run the build closure and reconcile the retained tree.
32    fn rebuild(&mut self);
33
34    /// Take (and clear) the layout/paint dirtiness accumulated since the last
35    /// call (delegates to [`RenderRoot::take_change_flags`]).
36    ///
37    /// The frame-gate seam: a shell drives the layout-skip
38    /// decision off this — run [`AppTree::rebuild`], then run [`AppTree::layout`]
39    /// only if the drained flags [`ChangeFlags::needs_layout`] (or it's the
40    /// first frame, or the surface resized), then always [`AppTree::paint`].
41    /// The `set_theme ⇒ LAYOUT|PAINT` contract keeps `Text`'s layout-baked
42    /// theme color correct across a bare theme swap (see
43    /// `crate::frame_gate`'s module docs). Draining is the caller's commitment
44    /// to act on the flags this frame; use
45    /// [`AppTree::has_pending_change_flags`] to peek without draining when
46    /// gathering [`crate::FrameInputs`] for a frame that may be skipped.
47    fn take_change_flags(&mut self) -> ChangeFlags;
48
49    /// Non-draining peek at whether any layout/paint dirtiness is pending
50    /// (delegates to [`RenderRoot::has_pending_change_flags`]).
51    ///
52    /// The frame gate reads this as its `change_flags_pending`
53    /// [`crate::FrameInputs`] entry *before* deciding, so a frame it skips
54    /// leaves the flags intact for the next frame that runs to drain via
55    /// [`AppTree::take_change_flags`].
56    fn has_pending_change_flags(&self) -> bool;
57
58    /// Whether a captured pointer gesture is currently in flight (delegates to
59    /// [`RenderRoot::is_pointer_captured`]).
60    ///
61    /// A frame-gate input: a mid-drag captured widget may
62    /// track/animate the pointer, so the mobile shells feed this into
63    /// [`crate::FrameInputs::pointer_capture_active`] to keep producing frames
64    /// while a gesture is live rather than skipping it.
65    fn is_pointer_captured(&self) -> bool;
66
67    /// Whether some widget in the tree currently holds keyboard/IME focus
68    /// (delegates to [`RenderRoot::is_focus_active`]).
69    ///
70    /// A *level* read, used by a shell that needs the current state (an IME
71    /// reconcile, a caret decision). It is deliberately **not** what the mobile
72    /// frame gate consults any more — a focus session that lasts forces a frame
73    /// forever — see [`AppTree::focus_ime_generation`].
74    fn is_focus_active(&self) -> bool;
75
76    /// The focus/IME session generation, bumped on every actual change of the
77    /// root's focus flag or published IME surface (delegates to
78    /// [`RenderRoot::focus_ime_generation`]).
79    ///
80    /// The frame gate's *edge* input: a shell caches the value it last saw and
81    /// feeds `last != now` into
82    /// [`crate::FrameInputs::focus_or_ime_changed`], so a focus/IME transition
83    /// forces exactly one frame while a steady focus session (a caret blinking
84    /// in an otherwise-idle field) leaves the gate free to skip or pace. The
85    /// same cheap compare-a-generation shape as
86    /// [`AppTree::semantics_generation`].
87    ///
88    /// Deliberately **not** defaulted, unlike [`AppTree::set_insets`] and the
89    /// other additive methods below: any constant default (`0` included) would
90    /// report "nothing ever changed" and silently strand a focus transition,
91    /// against the frame gate's default-to-run rule.
92    ///
93    /// **This is not the session's identity**, and the distinction is the whole
94    /// reason [`AppTree::focus_epoch`] sits beside it — read that one's doc
95    /// before using this counter to decide whether focus is still where it was.
96    fn focus_ime_generation(&self) -> u64;
97
98    /// The live focus session's **identity**, advanced once per honoured focus
99    /// claim and once per session release (delegates to
100    /// [`RenderRoot::focus_epoch`]).
101    ///
102    /// Two adjacent generation counters invite exactly one mistake, so: the
103    /// neighbour above counts *changes to the published surface*, this one
104    /// counts *sessions*, and neither substitutes for the other.
105    ///
106    /// * **Focus moving from one field to another moves this one and can leave
107    ///   the neighbour completely still.** Claiming focus while some field is
108    ///   already focused writes `true` over `true`, and `ImeState` is
109    ///   `{active, editing, caret, content_type}` — it names no widget, so two
110    ///   fields can publish equal surfaces, and a field that takes focus and
111    ///   publishes nothing leaves the *previous* field's surface standing.
112    /// * **An edit, a caret move, or the field being repositioned under the
113    ///   user moves the neighbour and leaves this one still.** The session is
114    ///   the same session throughout.
115    ///
116    /// So a shell that must run a frame or re-sync the platform IME reads the
117    /// neighbour; a shell binding an answer it will receive *later* to the
118    /// session that asked for it — an off-thread clipboard read, say — reads
119    /// this one and compares it again on arrival.
120    ///
121    /// **Never `0`**, whatever a shell's own FFI layer may use `0` to mean: the
122    /// counter is built at `1` and steps past `0` on wrap.
123    ///
124    /// Not defaulted, for a sharper reason than its neighbour's: a constant
125    /// default would report "still the same session" *forever*, so every stale
126    /// answer would compare equal and be accepted. A wrong default here delivers
127    /// text into the wrong field rather than costing a frame.
128    fn focus_epoch(&self) -> u64;
129
130    /// Lay the tree out against a logical (density-independent) size, threading
131    /// the shell-owned `TextContext` down type-erased.
132    fn layout(&mut self, logical: Size, text_ctx: &mut dyn Any);
133    /// Paint the tree into a scene builder at the shell-provided `frame_time`.
134    ///
135    /// `frame_time` is the shell's shared monotonic clock for this frame, threaded
136    /// through to every animating widget as [`frust_core::widget::PaintCtx::frame_time`]
137    /// (time enters from the shell, never `Instant::now()` inside the
138    /// framework).
139    ///
140    /// Returns a [`PaintOutcome`] whose `needs_frame` is set when a widget
141    /// advanced animation state during paint and wants another frame, the
142    /// framework's animation seam. The desktop shell honors it with `window.request_redraw()`;
143    /// the mobile shells' continuous Choreographer/`CADisplayLink` loops already
144    /// produce the next frame and may ignore it. Its `paint_serial` is the
145    /// number the root minted for this paint: the shell carries it with the
146    /// painted scene (`FrameMeta::paint_serial` across the split) and names it
147    /// to the renderer before encoding that scene, so the resident-key ledger
148    /// published afterwards says which paint it follows.
149    fn paint(&mut self, scene: &mut dyn PaintScene, frame_time: FrameTime) -> PaintOutcome;
150    /// Deliver one platform input event to the retained tree.
151    ///
152    /// Delegates to [`RenderRoot::event`], threading the erased `State` the same
153    /// way [`AppTree::rebuild`] does. The returned [`EventOutcome`] carries
154    /// `needs_redraw`, which the shell honours by scheduling a frame: the desktop
155    /// shell calls `window.request_redraw()`, while the mobile shells' continuous
156    /// Choreographer/`CADisplayLink` loops already produce the next frame. The
157    /// event pass itself never rebuilds or repaints (see [`RenderRoot::event`]).
158    fn event(&mut self, event: &InputEvent) -> EventOutcome;
159
160    /// Apply a whole editing state pushed by the platform IME (the mobile
161    /// state-sync path), routed to the focused widget as an
162    /// [`InputEvent::Ime`]`(`[`ImeEvent::ApplyEditingState`]`)`.
163    ///
164    /// `state`'s selection/composing indices are UTF-16 code-unit based (the
165    /// platform-native unit); the focused widget / `frust-text` converts them
166    /// to Rust byte offsets. Returns the same [`EventOutcome`] as [`AppTree::event`].
167    fn ime_apply(&mut self, state: EditingState) -> EventOutcome;
168
169    /// The IME surface the focused widget published, for the shell to drive the
170    /// platform input method. Delegates to [`RenderRoot::ime_state`]; `None` when
171    /// nothing is focused or no IME surface was published.
172    fn ime_state(&self) -> Option<ImeState>;
173
174    /// Take (and clear) the text a widget asked to put on the host clipboard
175    /// (delegates to [`RenderRoot::take_clipboard_write`]).
176    ///
177    /// The shell half of the clipboard channel: a focused editable answers a
178    /// copy/cut by writing its selection into the pass's clipboard slot
179    /// ([`frust_core::EventCtx::write_clipboard`]), and the shell — the only side
180    /// with a host clipboard to talk to — drains it here **immediately after
181    /// every [`AppTree::event`]/[`AppTree::ime_apply`]**, beside
182    /// [`AppTree::ime_state`]. `None` means no widget copied, and a shell with no
183    /// clipboard wired yet may simply not call this.
184    ///
185    /// **Destructive**, unlike the level reads around it: a clipboard write is an
186    /// edge, so a caller that drains and drops the result loses that write.
187    fn take_clipboard_write(&mut self) -> Option<String>;
188
189    /// Take (and clear) whether a widget asked the shell to read the host
190    /// clipboard back to it (delegates to [`RenderRoot::take_paste_request`]).
191    ///
192    /// The inverse direction, drained in the same place: on `true` the shell reads
193    /// its host clipboard and dispatches
194    /// [`InputEvent::EditCommand`]`(`[`EditCommand::Paste`](frust_core::EditCommand::Paste)`(text))`
195    /// back through [`AppTree::event`] — a *new* dispatch, since the read may be
196    /// asynchronous. That answer is focus-routed and therefore self-cancelling: if
197    /// focus moved or was released while the read was in flight it reaches no
198    /// widget and is dropped, so the shell never has to track who asked.
199    ///
200    /// **Destructive**, for [`AppTree::take_clipboard_write`]'s reason.
201    fn take_paste_request(&mut self) -> bool;
202
203    /// The selection-toolbar request the focused field published during the most
204    /// recent [`AppTree::paint`] (delegates to
205    /// [`RenderRoot::selection_toolbar`]); `None` when no field has a selection
206    /// worth a toolbar.
207    ///
208    /// The shell half of the **platform edit-menu** route: on a host with a
209    /// system edit menu (iOS `UIEditMenuInteraction`), a shell reads this beside
210    /// [`AppTree::ime_state`] and presents the host menu at the request's anchor
211    /// rect, converting the logical rect to the platform's own units itself. A
212    /// **level**, not an edge — pair it with
213    /// [`AppTree::selection_toolbar_generation`] to notice changes cheaply.
214    ///
215    /// A field drawing its *own* toolbar publishes this too (one code path for
216    /// both routes), so a shell must gate on
217    /// `frust_core::selection_toolbar::selection_toolbar_policy()`, not on the
218    /// presence of a request.
219    ///
220    /// **`Some` means a field is FOCUSED, not that it has a selection.** The verbs
221    /// are a level a platform responder chain reads at any moment, so they are
222    /// published on every paint of a focused field; `None` means no field is
223    /// focused. Whether a menu should be *presented* is the request's own flag,
224    /// not the presence of the request.
225    ///
226    /// **Defaulted to `None`** like the getters below, so an [`AppTree`] impl
227    /// that predates this channel still compiles and reads an empty one.
228    fn selection_toolbar(&self) -> Option<SelectionToolbarRequest> {
229        None
230    }
231
232    /// A monotonically-increasing generation bumped when the **menu-significant**
233    /// part of [`AppTree::selection_toolbar`] actually changes — the present flag
234    /// and the verb set, its clearing included — and NOT when the anchor alone
235    /// moves (delegates to [`RenderRoot::selection_toolbar_generation`]).
236    ///
237    /// The anchor exclusion is deliberate: the anchor is recomputed every painted
238    /// frame and follows a dragging selection, so moving the generation with it
239    /// would ask the host to re-present its menu on every touch sample. Re-read
240    /// the anchor as a level for the menu's target rect; do not treat it as a
241    /// reason to present.
242    ///
243    /// The [`AppTree::focus_ime_generation`] contract one channel over: a shell
244    /// caches the last value it acted on and re-presents the host menu only when
245    /// it moves, which is what keeps a standing selection — republished every
246    /// frame it stands — from re-presenting the menu on every vsync.
247    ///
248    /// **Defaulted to `0`**, the same additive shape as its neighbour above.
249    fn selection_toolbar_generation(&self) -> u64 {
250        0
251    }
252
253    /// Store the app's active theme, threaded into every subsequent
254    /// layout/paint pass (delegates to [`RenderRoot::set_theme`]).
255    ///
256    /// The theme is **type-erased** (`Box<dyn Any>`) so `frust-shell-common`
257    /// stays free of a `frust-theme` dependency (it compiles everywhere with
258    /// no unsafe/FFI — see `docs/ARCHITECTURE.md`). The concrete `Theme` is
259    /// boxed by the shell that owns the appearance state; each mobile shell
260    /// wires this into its own appearance-change handling. Re-boxing on a
261    /// live appearance change replaces the stored theme.
262    fn set_theme(&mut self, theme: Box<dyn Any>);
263
264    /// Forward `RenderRoot::set_layout_skip` (the `FRUST_NO_LAYOUT_SKIP` kill
265    /// switch a shell resolves via `frame_gate::LayoutKnobs`). Default no-op.
266    fn set_layout_skip(&mut self, _skip: bool) {}
267
268    /// Forward `RenderRoot::set_layout_audit` (`FRUST_LAYOUT_AUDIT`). Default no-op.
269    fn set_layout_audit(&mut self, _audit: bool) {}
270
271    /// Forward `RenderRoot::set_retained_layers` (`FRUST_NO_RETAINED_LAYERS`,
272    /// resolved via `frame_gate::LayoutKnobs`). Default no-op.
273    fn set_retained_layers(&mut self, _enabled: bool) {}
274
275    /// Forward `RenderRoot::set_retained_ledger`: the resident-key ledger the
276    /// render side last published, handed over before each paint (one frame
277    /// stale by design; see [`crate::render_split::latest_retained_ledger`]).
278    /// Default no-op.
279    fn set_retained_ledger(&mut self, _ledger: &frust_core::RetainedLedger) {}
280
281    /// Store the window's insets ([`WindowInsets`]), threaded into every
282    /// subsequent layout/paint pass (delegates to [`RenderRoot::set_insets`]).
283    ///
284    /// A shell reads the platform's per-edge occlusion (Android `WindowInsets`,
285    /// iOS `safeAreaInsets` + keyboard frame), converts device px to **logical**
286    /// px at the FFI boundary (see
287    /// [`crate::ffi_support::logical_insets`](crate::logical_insets)), and pushes
288    /// the result here; a `SafeArea` widget then insets by
289    /// [`WindowInsets::padding`]. `WindowInsets` is a concrete core-owned type
290    /// (only `f64` scalars), so this needs no `Box<dyn Any>` erasure — unlike
291    /// [`AppTree::set_theme`].
292    ///
293    /// Defaulted to a **no-op** so existing [`AppTree`] impls compile unchanged;
294    /// the concrete tree overrides it to forward to [`RenderRoot::set_insets`].
295    /// The `set_insets ⇒ LAYOUT | PAINT` dirty contract keeps a `SafeArea`'s
296    /// layout-time inset resolution correct under the mobile layout-skip gate,
297    /// exactly like `set_theme` (see [`crate::frame_gate`]'s module docs).
298    fn set_insets(&mut self, _insets: WindowInsets) {}
299
300    /// Store the shell's running count of frames the render thread has actually
301    /// presented, threaded into every subsequent paint pass (delegates to
302    /// [`RenderRoot::set_presented_frames`]).
303    ///
304    /// A shell loads the atomic its render side increments (once per presented
305    /// frame) and pushes it here once per UI frame, before [`AppTree::paint`], so
306    /// a widget measuring FPS reports the *presented* rate rather than its own
307    /// paint cadence (which, under the render-thread split, runs faster).
308    ///
309    /// **Unlike [`AppTree::set_theme`]/[`AppTree::set_insets`], this dirties
310    /// nothing** — [`RenderRoot::set_presented_frames`] marks no [`ChangeFlags`],
311    /// so a monotonically ticking counter never forces a relayout and — the
312    /// subtle one — never keeps the mobile [`frame_gate`](crate::frame_gate)'s
313    /// pending-flags input perpetually true, so the menu still idles.
314    /// Defaulted to a **no-op** so existing
315    /// [`AppTree`] impls compile unchanged; the concrete tree overrides it.
316    fn set_presented_frames(&mut self, _presented: u64) {}
317
318    /// Store whether the shell's GPU surface is translucent (alpha-channel,
319    /// "Mode B"), threaded into every subsequent paint pass (delegates to
320    /// [`RenderRoot::set_surface_translucent`]).
321    ///
322    /// A shell pushes the surface's **resolved** translucency here — what
323    /// `frust_render::SurfaceRenderer::surface_resolved_translucent` reports
324    /// after an install, NOT the [`crate::SurfaceModeWatcher`] request latch
325    /// (a translucency request the platform refuses must
326    /// degrade to the opaque Mode A contract, or the punch presents black
327    /// rectangles). Both mobile shells re-read it every frame, so a
328    /// render-thread fallback downgrades within one frame. The platform-view
329    /// hole-punch then clears each slot's rect on a genuinely translucent
330    /// surface so an opaque app backdrop doesn't seal the hole (see
331    /// [`RenderRoot::set_surface_translucent`]). Desktop leaves the default
332    /// (opaque). Defaulted to a **no-op** so existing [`AppTree`] impls compile
333    /// unchanged; the concrete tree overrides it — mirrors
334    /// [`AppTree::set_insets`]'s default-no-op precedent.
335    fn set_surface_translucent(&mut self, _translucent: bool) {}
336
337    /// Collect the accessibility tree for the current frame,
338    /// for a shell to push into its platform `accesskit_*` adapter. Delegates to
339    /// [`RenderRoot::semantics`]; must run **after** [`AppTree::layout`] so node
340    /// bounds are valid.
341    fn semantics(&mut self) -> SemanticsUpdate;
342
343    /// The current semantics generation, bumped whenever a rebuild/theme swap
344    /// could have changed the tree (delegates to
345    /// [`RenderRoot::semantics_generation`]). A shell compares it to skip
346    /// re-pushing an unchanged tree — see [`AppTree::semantics_if_changed`].
347    fn semantics_generation(&self) -> u64;
348
349    /// Pull a fresh [`SemanticsUpdate`] only if the tree may have changed since
350    /// generation `last_seen` (delegates to [`RenderRoot::semantics_if_changed`]),
351    /// so a shell's adapter push runs only when something changed.
352    fn semantics_if_changed(&mut self, last_seen: u64) -> Option<SemanticsUpdate>;
353
354    /// Perform a platform accessibility action delivered by the shell's
355    /// `accesskit_*` adapter (an `ActionRequest`), threading the erased `State`
356    /// the same way [`AppTree::event`] does (delegates to
357    /// [`RenderRoot::perform_accessibility_action`]).
358    ///
359    /// `node_id` is the raw accesskit id the adapter reported; `action` is the
360    /// requested [`accesskit::Action`]. Returns the same [`EventOutcome`] as
361    /// [`AppTree::event`] — its `needs_redraw` tells the shell whether to schedule
362    /// a frame. An unknown node or unmodelled action is a benign no-op.
363    fn perform_accessibility_action(
364        &mut self,
365        node_id: u64,
366        action: accesskit::Action,
367    ) -> EventOutcome;
368
369    /// The [`PlatformViewFrame`]s the tree published during the most recent
370    /// paint pass (delegates to
371    /// [`RenderRoot::platform_view_frames`]). A shell's peek-getter path feeds
372    /// this into a [`crate::platform_view::PlatformViewState`]'s
373    /// [`ingest`](crate::platform_view::PlatformViewState::ingest) after each
374    /// RUN frame's paint (never on a gate-`Skip`, per that method's
375    /// skip-safety contract).
376    ///
377    /// Defaulted to an empty slice so existing [`AppTree`] impls compile
378    /// unchanged; the concrete tree overrides it — mirrors
379    /// [`AppTree::set_insets`]'s default-no-op precedent.
380    fn platform_view_frames(&self) -> &[PlatformViewFrame] {
381        &[]
382    }
383
384    /// The z-shield rects the tree reported during the most recent paint pass
385    /// (delegates to [`RenderRoot::input_shields`]) — the
386    /// second argument of the same
387    /// [`ingest`](crate::platform_view::PlatformViewState::ingest) call
388    /// [`AppTree::platform_view_frames`] feeds.
389    ///
390    /// Defaulted to an empty slice like the frames getter above, so an
391    /// [`AppTree`] impl that predates the shield channel still compiles (and
392    /// simply ships no auto-collected shields).
393    fn input_shields(&self) -> &[kurbo::Rect] {
394        &[]
395    }
396
397    /// Drain the slot ids whose `platform_view` widgets were torn down since the
398    /// last call (delegates to
399    /// [`RenderRoot::take_retired_platform_views`]).
400    ///
401    /// A shell calls this right after [`AppTree::rebuild`] and retires each id
402    /// in its [`PlatformViewState`](crate::platform_view::PlatformViewState), so
403    /// a disposed slot's native view goes away on the next frame instead of
404    /// waiting out the differ's missing-streak heuristic. Draining is
405    /// destructive — an id is reported exactly once.
406    ///
407    /// Defaulted to an empty `Vec`, mirroring the two getters above.
408    fn take_retired_platform_views(&mut self) -> Vec<u64> {
409        Vec::new()
410    }
411
412    /// A read-only, pre-order snapshot of the retained tree (delegates to
413    /// [`RenderRoot::inspect`]) — the one thing the devtools
414    /// [`DevtoolsUi`](crate::devtools::DevtoolsUi) hop needs from a mobile
415    /// shell, whose handle owns its tree only as a `Box<dyn AppTree>`.
416    ///
417    /// Gated on the `devtools` feature: with devtools compiled out there is no
418    /// caller, and the seam should not exist. Defaulted to an empty `Vec`
419    /// like the getters above, so an [`AppTree`] impl outside this crate still
420    /// compiles; the concrete tree overrides it.
421    #[cfg(feature = "devtools")]
422    fn inspect(&self) -> Vec<frust_core::InspectNode> {
423        Vec::new()
424    }
425}
426
427/// Concrete [`AppTree`] holding one app's state, build closure and retained root.
428struct ErasedApp<State: 'static, Build, V: View<State>> {
429    state: State,
430    build: Build,
431    root: RenderRoot<State, V>,
432}
433
434impl<State, Build, V> AppTree for ErasedApp<State, Build, V>
435where
436    State: 'static,
437    V: View<State>,
438    Build: FnMut(&mut State) -> V + 'static,
439{
440    fn rebuild(&mut self) {
441        // The build closure is cheap by construction. The returned flags are
442        // merged into `RenderRoot::pending` and surfaced to the shell's frame
443        // gate via `take_change_flags`/`has_pending_change_flags` below.
444        let _flags = self.root.rebuild(&mut self.build, &mut self.state);
445    }
446
447    fn take_change_flags(&mut self) -> ChangeFlags {
448        self.root.take_change_flags()
449    }
450
451    fn has_pending_change_flags(&self) -> bool {
452        self.root.has_pending_change_flags()
453    }
454
455    fn is_pointer_captured(&self) -> bool {
456        self.root.is_pointer_captured()
457    }
458
459    fn is_focus_active(&self) -> bool {
460        self.root.is_focus_active()
461    }
462
463    fn focus_ime_generation(&self) -> u64 {
464        self.root.focus_ime_generation()
465    }
466
467    fn focus_epoch(&self) -> u64 {
468        self.root.focus_epoch()
469    }
470
471    fn layout(&mut self, logical: Size, text_ctx: &mut dyn Any) {
472        self.root.layout_with_text(logical, text_ctx);
473    }
474
475    fn paint(&mut self, scene: &mut dyn PaintScene, frame_time: FrameTime) -> PaintOutcome {
476        self.root.paint(scene, frame_time)
477    }
478
479    fn event(&mut self, event: &InputEvent) -> EventOutcome {
480        self.root.event(&mut self.state, event)
481    }
482
483    fn ime_apply(&mut self, state: EditingState) -> EventOutcome {
484        self.root.event(
485            &mut self.state,
486            &InputEvent::Ime(ImeEvent::ApplyEditingState(state)),
487        )
488    }
489
490    fn ime_state(&self) -> Option<ImeState> {
491        self.root.ime_state()
492    }
493
494    fn take_clipboard_write(&mut self) -> Option<String> {
495        self.root.take_clipboard_write()
496    }
497
498    fn take_paste_request(&mut self) -> bool {
499        self.root.take_paste_request()
500    }
501
502    fn selection_toolbar(&self) -> Option<SelectionToolbarRequest> {
503        self.root.selection_toolbar()
504    }
505
506    fn selection_toolbar_generation(&self) -> u64 {
507        self.root.selection_toolbar_generation()
508    }
509
510    fn set_layout_skip(&mut self, skip: bool) {
511        self.root.set_layout_skip(skip);
512    }
513
514    fn set_layout_audit(&mut self, audit: bool) {
515        self.root.set_layout_audit(audit);
516    }
517
518    fn set_retained_layers(&mut self, enabled: bool) {
519        self.root.set_retained_layers(enabled);
520    }
521
522    fn set_retained_ledger(&mut self, ledger: &frust_core::RetainedLedger) {
523        self.root.set_retained_ledger(ledger);
524    }
525
526    fn set_theme(&mut self, theme: Box<dyn Any>) {
527        self.root.set_theme(theme);
528    }
529
530    fn set_insets(&mut self, insets: WindowInsets) {
531        self.root.set_insets(insets);
532    }
533
534    fn set_presented_frames(&mut self, presented: u64) {
535        self.root.set_presented_frames(presented);
536    }
537
538    fn set_surface_translucent(&mut self, translucent: bool) {
539        self.root.set_surface_translucent(translucent);
540    }
541
542    fn semantics(&mut self) -> SemanticsUpdate {
543        self.root.semantics()
544    }
545
546    fn semantics_generation(&self) -> u64 {
547        self.root.semantics_generation()
548    }
549
550    fn semantics_if_changed(&mut self, last_seen: u64) -> Option<SemanticsUpdate> {
551        self.root.semantics_if_changed(last_seen)
552    }
553
554    fn perform_accessibility_action(
555        &mut self,
556        node_id: u64,
557        action: accesskit::Action,
558    ) -> EventOutcome {
559        self.root
560            .perform_accessibility_action(&mut self.state, accesskit::NodeId(node_id), action)
561    }
562
563    fn platform_view_frames(&self) -> &[PlatformViewFrame] {
564        self.root.platform_view_frames()
565    }
566
567    fn input_shields(&self) -> &[kurbo::Rect] {
568        self.root.input_shields()
569    }
570
571    fn take_retired_platform_views(&mut self) -> Vec<u64> {
572        self.root.take_retired_platform_views()
573    }
574
575    #[cfg(feature = "devtools")]
576    fn inspect(&self) -> Vec<frust_core::InspectNode> {
577        self.root.inspect()
578    }
579}
580
581/// Erase an app's `State`/`build` into a `Box<dyn AppTree>`, building the
582/// initial `State` from a caller-supplied factory rather than a ready-made
583/// value.
584///
585/// This is the one construction path: [`new_boxed_app`] is a thin wrapper over
586/// this function that hands in a closure returning an already-built `state`.
587/// The factory form lets an entry macro (e.g. a future `Component::init`
588/// binding) construct `State` itself from inside the closure instead of
589/// requiring the caller to build a value up front.
590pub fn new_boxed_app_with<State, Build, V, F>(state_init: F, build: Build) -> Box<dyn AppTree>
591where
592    F: FnOnce() -> State,
593    State: 'static,
594    V: View<State>,
595    Build: FnMut(&mut State) -> V + 'static,
596{
597    Box::new(ErasedApp {
598        state: state_init(),
599        build,
600        root: RenderRoot::new(),
601    })
602}
603
604/// Erase an app's `State`/`build` into a `Box<dyn AppTree>`.
605///
606/// Called by a shell's app-binding macro (e.g. `frust::android_app!`) from its
607/// generated init entry point; kept here (not in the macro) so the erasure and
608/// the trait live together and the macro stays a thin shim.
609pub fn new_boxed_app<State, Build, V>(state: State, build: Build) -> Box<dyn AppTree>
610where
611    State: 'static,
612    V: View<State>,
613    Build: FnMut(&mut State) -> V + 'static,
614{
615    new_boxed_app_with(move || state, build)
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use frust_core::layout::BoxConstraints;
622    use frust_core::view::{BuildCtx, ChangeFlags};
623    use frust_core::widget::{LayoutCtx, PaintCtx, Widget};
624    use kurbo::Size;
625
626    /// A state type with no `Default` impl — the only way it can be
627    /// constructed is through the factory closure passed to
628    /// [`new_boxed_app_with`], proving the seam actually threads the
629    /// factory's output through rather than falling back to some default.
630    struct NonDefaultState {
631        label: &'static str,
632    }
633
634    struct StubWidget;
635    impl Widget for StubWidget {
636        fn layout(&mut self, _ctx: &mut LayoutCtx, _bc: &BoxConstraints) -> Size {
637            Size::ZERO
638        }
639        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
640    }
641
642    struct StubView;
643    impl View<NonDefaultState> for StubView {
644        type Element = StubWidget;
645        fn build(&self, _ctx: &mut BuildCtx<'_>) -> StubWidget {
646            StubWidget
647        }
648        fn rebuild(
649            &self,
650            _prev: &Self,
651            _element: &mut StubWidget,
652            _ctx: &mut BuildCtx<'_>,
653        ) -> ChangeFlags {
654            ChangeFlags::NONE
655        }
656    }
657
658    /// A minimal [`AppTree`] that overrides *nothing* optional — used to prove
659    /// [`AppTree::set_insets`]'s default no-op lets a pre-insets impl compile and
660    /// be driven without panicking. Every other method is unreachable in the test
661    /// (only `set_insets`, the defaulted method, is called), so they're stubbed
662    /// with `unimplemented!()`.
663    struct MinimalTree;
664    impl AppTree for MinimalTree {
665        fn rebuild(&mut self) {
666            unimplemented!()
667        }
668        fn take_change_flags(&mut self) -> ChangeFlags {
669            unimplemented!()
670        }
671        fn has_pending_change_flags(&self) -> bool {
672            unimplemented!()
673        }
674        fn is_pointer_captured(&self) -> bool {
675            unimplemented!()
676        }
677        fn is_focus_active(&self) -> bool {
678            unimplemented!()
679        }
680        fn focus_ime_generation(&self) -> u64 {
681            unimplemented!()
682        }
683        fn focus_epoch(&self) -> u64 {
684            unimplemented!()
685        }
686        fn layout(&mut self, _logical: Size, _text_ctx: &mut dyn Any) {
687            unimplemented!()
688        }
689        fn paint(&mut self, _scene: &mut dyn PaintScene, _frame_time: FrameTime) -> PaintOutcome {
690            unimplemented!()
691        }
692        fn event(&mut self, _event: &InputEvent) -> EventOutcome {
693            unimplemented!()
694        }
695        fn ime_apply(&mut self, _state: EditingState) -> EventOutcome {
696            unimplemented!()
697        }
698        fn ime_state(&self) -> Option<ImeState> {
699            unimplemented!()
700        }
701        // The clipboard drains answer honestly instead of panicking like their
702        // neighbours: "no widget asked" is a real answer a tree can give (it is
703        // what a shell reads on every pass in which nothing copied), so a
704        // hypothetical driver calling them on this double should see the empty
705        // channel rather than a panic.
706        fn take_clipboard_write(&mut self) -> Option<String> {
707            None
708        }
709        fn take_paste_request(&mut self) -> bool {
710            false
711        }
712        fn set_theme(&mut self, _theme: Box<dyn Any>) {
713            unimplemented!()
714        }
715        fn semantics(&mut self) -> SemanticsUpdate {
716            unimplemented!()
717        }
718        fn semantics_generation(&self) -> u64 {
719            unimplemented!()
720        }
721        fn semantics_if_changed(&mut self, _last_seen: u64) -> Option<SemanticsUpdate> {
722            unimplemented!()
723        }
724        fn perform_accessibility_action(
725            &mut self,
726            _node_id: u64,
727            _action: accesskit::Action,
728        ) -> EventOutcome {
729            unimplemented!()
730        }
731        // set_insets deliberately NOT overridden — exercises the default no-op.
732    }
733
734    #[test]
735    fn app_tree_selection_toolbar_defaults_to_absent() {
736        // Compiles (both defaults exist, so a pre-toolbar `AppTree` impl outside
737        // this crate is unaffected) and reads the empty channel: no request, and
738        // a generation a shell can diff from frame zero.
739        let tree = MinimalTree;
740        assert!(tree.selection_toolbar().is_none());
741        assert_eq!(tree.selection_toolbar_generation(), 0);
742    }
743
744    #[test]
745    fn app_tree_platform_view_frames_defaults_to_empty_slice() {
746        // Compiles (the default impl exists) and is a benign empty read — a
747        // pre-platform-views `AppTree` impl is unaffected.
748        let tree = MinimalTree;
749        assert!(tree.platform_view_frames().is_empty());
750    }
751
752    #[test]
753    fn app_tree_shield_and_retire_channels_default_to_empty() {
754        // Same additive contract for the two channels: an `AppTree` impl
755        // that predates them compiles and reports nothing.
756        let mut tree = MinimalTree;
757        assert!(tree.input_shields().is_empty());
758        assert!(tree.take_retired_platform_views().is_empty());
759    }
760
761    #[test]
762    fn app_tree_set_insets_defaults_to_no_op() {
763        use frust_core::insets::EdgeInsets;
764        let mut tree = MinimalTree;
765        // Compiles (the default impl exists) and is a benign no-op — a pre-insets
766        // `AppTree` impl is unaffected.
767        tree.set_insets(WindowInsets::default());
768        tree.set_insets(WindowInsets::new(
769            EdgeInsets::new(0.0, 24.0, 0.0, 34.0),
770            EdgeInsets::ZERO,
771        ));
772    }
773
774    /// A leaf that answers a clipboard verb the way a real editable does: it
775    /// writes its "selection" on a copy and asks for the host clipboard on a
776    /// paste command it cannot satisfy itself.
777    struct ClipboardWidget;
778    impl Widget for ClipboardWidget {
779        fn layout(&mut self, _ctx: &mut LayoutCtx, _bc: &BoxConstraints) -> Size {
780            Size::ZERO
781        }
782        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
783        fn event(
784            &mut self,
785            ctx: &mut frust_core::EventCtx,
786            event: &InputEvent,
787        ) -> frust_core::EventResult {
788            match event {
789                InputEvent::EditCommand(frust_core::EditCommand::Copy) => {
790                    ctx.write_clipboard("selection".to_string());
791                    frust_core::EventResult::Handled
792                }
793                InputEvent::EditCommand(frust_core::EditCommand::SelectAll) => {
794                    ctx.request_paste();
795                    frust_core::EventResult::Handled
796                }
797                _ => frust_core::EventResult::Ignored,
798            }
799        }
800    }
801
802    struct ClipboardView;
803    impl View<NonDefaultState> for ClipboardView {
804        type Element = ClipboardWidget;
805        fn build(&self, _ctx: &mut BuildCtx<'_>) -> ClipboardWidget {
806            ClipboardWidget
807        }
808        fn rebuild(
809            &self,
810            _prev: &Self,
811            _element: &mut ClipboardWidget,
812            _ctx: &mut BuildCtx<'_>,
813        ) -> ChangeFlags {
814            ChangeFlags::NONE
815        }
816    }
817
818    #[test]
819    fn erased_app_delegates_the_clipboard_drains_to_the_root() {
820        // The shell-facing half of the clipboard channel: a shell drains these
821        // through `AppTree` and never touches `RenderRoot` directly.
822        let mut app = new_boxed_app_with(
823            || NonDefaultState { label: "clip" },
824            |_state: &mut NonDefaultState| ClipboardView,
825        );
826        app.rebuild();
827        app.layout(Size::new(10.0, 10.0), &mut () as &mut dyn Any);
828
829        assert!(app.take_clipboard_write().is_none(), "nothing copied yet");
830        assert!(!app.take_paste_request());
831
832        app.event(&InputEvent::EditCommand(frust_core::EditCommand::Copy));
833        assert_eq!(app.take_clipboard_write().as_deref(), Some("selection"));
834        assert!(
835            app.take_clipboard_write().is_none(),
836            "the drain is one-shot through the erasure too"
837        );
838
839        app.event(&InputEvent::EditCommand(frust_core::EditCommand::SelectAll));
840        assert!(app.take_paste_request());
841        assert!(!app.take_paste_request());
842    }
843
844    #[test]
845    fn app_tree_clipboard_drains_report_an_empty_channel_on_the_minimal_tree() {
846        // Unlike its `unimplemented!()` neighbours these answer, because "no
847        // widget asked" is a real answer a tree with no clipboard can give.
848        let mut tree = MinimalTree;
849        assert!(tree.take_clipboard_write().is_none());
850        assert!(!tree.take_paste_request());
851    }
852
853    #[test]
854    fn new_boxed_app_with_uses_factory_produced_state() {
855        let mut app = new_boxed_app_with(
856            || NonDefaultState {
857                label: "from-factory",
858            },
859            |state: &mut NonDefaultState| {
860                assert_eq!(state.label, "from-factory");
861                StubView
862            },
863        );
864        // Drive one rebuild so `build` actually observes the factory-built
865        // state (it's a closure param above, but this also exercises the
866        // AppTree seam end-to-end rather than just constructing the box).
867        app.rebuild();
868    }
869
870    // --- Layout-skip contract (mobile frame gate) --------------------------
871    //
872    // The seam the mobile shells drive: rebuild -> (layout iff needs_layout / first
873    // frame / resize) -> paint. This proves the `set_theme => LAYOUT|PAINT`
874    // correctness anchor: a widget that BAKES its themed value at LAYOUT time
875    // (like `Text`'s glyph color) relayouts on a bare theme swap, while a
876    // no-change frame skips layout yet still paints the last-baked value.
877
878    use std::cell::Cell;
879
880    use frust_scene::{Command, Scene, SceneBuilder};
881
882    /// A type-erased "theme" carrying one scalar the baking widget reads at
883    /// layout time — stands in for `frust_theme::Theme` (the seam needs no
884    /// concrete theme type to be exercised).
885    struct BakeTheme {
886        value: f64,
887    }
888
889    /// Bakes the threaded theme's scalar into `baked` at LAYOUT time and
890    /// merely replays it at PAINT time (the `Text` layout-baked-color model).
891    /// `layouts` counts how many times layout actually ran.
892    struct BakeWidget {
893        layouts: Rc<Cell<u32>>,
894        baked: f64,
895    }
896    impl Widget for BakeWidget {
897        fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
898            self.layouts.set(self.layouts.get() + 1);
899            // Bake the themed value now; paint only replays it.
900            self.baked = ctx.theme_as::<BakeTheme>().map(|t| t.value).unwrap_or(0.0);
901            bc.constrain(Size::new(10.0, 10.0))
902        }
903        fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
904            // Encode the baked scalar in the painted rect's width so a
905            // recording `Scene` reads it back without naming a color type;
906            // the color itself comes from a theme constructor (type inferred,
907            // so this crate needs no `peniko` dependency).
908            let color = frust_theme::ColorScheme::neutral_light().primary;
909            scene.fill_rect(ctx.origin(), Size::new(self.baked, 1.0), color);
910        }
911    }
912
913    struct BakeView {
914        layouts: Rc<Cell<u32>>,
915    }
916    impl View<()> for BakeView {
917        type Element = BakeWidget;
918        fn build(&self, _ctx: &mut BuildCtx<'_>) -> BakeWidget {
919            BakeWidget {
920                layouts: self.layouts.clone(),
921                baked: 0.0,
922            }
923        }
924        fn rebuild(
925            &self,
926            _prev: &Self,
927            _element: &mut BakeWidget,
928            _ctx: &mut BuildCtx<'_>,
929        ) -> ChangeFlags {
930            ChangeFlags::NONE
931        }
932    }
933
934    /// Read back the width of the single `FillRect` the `BakeWidget` painted —
935    /// the baked scalar the recording `Scene` captured.
936    fn painted_baked(scene: &Scene) -> f64 {
937        scene
938            .commands()
939            .iter()
940            .find_map(|c| match c {
941                Command::FillRect { rect, .. } => Some(rect.width()),
942                _ => None,
943            })
944            .expect("BakeWidget paints exactly one FillRect")
945    }
946
947    /// Drive one shell-style frame through the `AppTree` seam: rebuild, then
948    /// layout ONLY when the drained flags need it (or `force_layout` for the
949    /// first frame / a resize), then always paint into a fresh `Scene`.
950    /// Returns the painted baked value.
951    fn drive_frame(app: &mut dyn AppTree, force_layout: bool) -> f64 {
952        app.rebuild();
953        let flags = app.take_change_flags();
954        if force_layout || flags.needs_layout() {
955            app.layout(Size::new(100.0, 100.0), &mut ());
956        }
957        let mut scene = Scene::new();
958        let mut builder = SceneBuilder::new(&mut scene);
959        app.paint(&mut builder, FrameTime::ZERO);
960        painted_baked(&scene)
961    }
962
963    #[test]
964    fn layout_skips_without_flags_but_relayouts_on_theme_swap() {
965        let layouts = Rc::new(Cell::new(0u32));
966        let lc = layouts.clone();
967        let mut app: Box<dyn AppTree> = new_boxed_app_with(
968            || (),
969            move |_s: &mut ()| BakeView {
970                layouts: lc.clone(),
971            },
972        );
973
974        // Frame 1 (first frame): theme set, layout must run and bake value 5.
975        app.set_theme(Box::new(BakeTheme { value: 5.0 }));
976        let painted = drive_frame(&mut *app, true);
977        assert_eq!(layouts.get(), 1, "first frame lays out");
978        assert_eq!(painted, 5.0, "baked the theme value at layout");
979
980        // Frame 2 (steady, no change): rebuild yields no flags -> layout
981        // SKIPPED, but paint still replays the last-baked value correctly.
982        let painted = drive_frame(&mut *app, false);
983        assert_eq!(layouts.get(), 1, "an unchanged frame skips layout");
984        assert_eq!(painted, 5.0, "paint-only frame still correct");
985
986        // Frame 3: a bare theme swap marks LAYOUT|PAINT pending, so layout
987        // RUNS again and re-bakes — the Text-color correctness anchor.
988        app.set_theme(Box::new(BakeTheme { value: 9.0 }));
989        let painted = drive_frame(&mut *app, false);
990        assert_eq!(
991            layouts.get(),
992            2,
993            "a theme swap forces relayout even with no view change"
994        );
995        assert_eq!(painted, 9.0, "re-baked the new theme value");
996
997        // Frame 4 (steady again): layout skipped, paints the new baked value.
998        let painted = drive_frame(&mut *app, false);
999        assert_eq!(
1000            layouts.get(),
1001            2,
1002            "unchanged frame after the swap skips layout"
1003        );
1004        assert_eq!(painted, 9.0, "still correct with the swapped value");
1005    }
1006
1007    // --- Root-owner regression test -----------------------------------------
1008    //
1009    // A root component's `init`/`build` run under the shell's ROOT `Owner`
1010    // (`Owner::with`, the way `create_handle`/`frust::run` now wrap
1011    // `new_boxed_app_with` + the initial rebuild). Without an ambient owner,
1012    // `provide_context` in the root's `init` silently no-ops and a nested
1013    // component's `use_context` returns `None`. This drives that exact path and
1014    // asserts the context resolves through the owner chain.
1015
1016    use std::cell::RefCell;
1017    use std::rc::Rc;
1018
1019    use frust_core::component::{Component, component};
1020    use frust_core::view::{AnyView, any};
1021    use reactive_graph::owner::{Owner, provide_context, use_context};
1022
1023    /// A `use_context` sink shared with the nested component's `build`.
1024    type Sink = Rc<RefCell<Option<u32>>>;
1025
1026    /// The context value the root provides in `init` and the nested component
1027    /// reads back in `build`.
1028    #[derive(Clone, Copy)]
1029    struct ProvidedCtx(u32);
1030
1031    /// A view leaf usable under any state — the nested component's `build`
1032    /// result once it has recorded the resolved context.
1033    struct StubLeaf;
1034    impl<S: 'static> View<S> for StubLeaf {
1035        type Element = StubWidget;
1036        fn build(&self, _ctx: &mut BuildCtx<'_>) -> StubWidget {
1037            StubWidget
1038        }
1039        fn rebuild(
1040            &self,
1041            _prev: &Self,
1042            _element: &mut StubWidget,
1043            _ctx: &mut BuildCtx<'_>,
1044        ) -> ChangeFlags {
1045            ChangeFlags::NONE
1046        }
1047    }
1048
1049    /// Root component: `provide_context`s a value in `init`, mounts a nested
1050    /// component in `build`.
1051    #[derive(Clone)]
1052    struct RootComp {
1053        sink: Sink,
1054    }
1055    impl Component for RootComp {
1056        type State = ();
1057        fn init(&self) {
1058            provide_context(ProvidedCtx(42));
1059        }
1060        fn build(&self, _state: &mut ()) -> impl View<()> {
1061            any(component(ChildComp {
1062                sink: self.sink.clone(),
1063            }))
1064        }
1065    }
1066
1067    /// Nested component: reads the root-provided context in `build` and records
1068    /// what it resolved to.
1069    #[derive(Clone)]
1070    struct ChildComp {
1071        sink: Sink,
1072    }
1073    impl Component for ChildComp {
1074        type State = ();
1075        fn init(&self) {}
1076        fn build(&self, _state: &mut ()) -> impl View<()> {
1077            let resolved = use_context::<ProvidedCtx>().map(|c| c.0);
1078            *self.sink.borrow_mut() = resolved;
1079            any(StubLeaf)
1080        }
1081    }
1082
1083    #[test]
1084    fn root_provided_context_resolves_in_nested_component() {
1085        let sink: Sink = Rc::new(RefCell::new(None));
1086        let root = RootComp { sink: sink.clone() };
1087        let root_for_build = root.clone();
1088
1089        // Mirror the shells: run BOTH the state factory (`Component::init`) and
1090        // the initial `rebuild()` under one root `Owner`, so the context the
1091        // root provides in `init` is visible to the nested component whose own
1092        // owner is created as a child of this one during the rebuild.
1093        let owner = Owner::new();
1094        owner.with(|| {
1095            let mut app = new_boxed_app_with(
1096                move || root.init(),
1097                move |state: &mut ()| AnyView::new(root_for_build.build(state)),
1098            );
1099            app.rebuild();
1100        });
1101
1102        assert_eq!(
1103            *sink.borrow(),
1104            Some(42),
1105            "root-provided context must resolve in the nested component through \
1106             the owner-wrapped new_boxed_app_with + rebuild cycle"
1107        );
1108    }
1109
1110    #[test]
1111    fn root_context_does_not_resolve_without_ambient_owner() {
1112        // The negative control: the same tree with NO ambient owner. This is the
1113        // pre-fix behavior — `provide_context` no-ops and `use_context` returns
1114        // `None` — pinned so a regression that drops the `with_owner` wrap is
1115        // caught by the positive test above rather than passing silently.
1116        let sink: Sink = Rc::new(RefCell::new(None));
1117        let root = RootComp { sink: sink.clone() };
1118        let root_for_build = root.clone();
1119
1120        let mut app = new_boxed_app_with(
1121            move || root.init(),
1122            move |state: &mut ()| AnyView::new(root_for_build.build(state)),
1123        );
1124        app.rebuild();
1125
1126        assert_eq!(
1127            *sink.borrow(),
1128            None,
1129            "without an ambient owner, provide_context no-ops and use_context \
1130             resolves to None (the F1 bug this task closes)"
1131        );
1132    }
1133
1134    // --- WindowMetrics delivery + rebuild-cost regression test ---------------
1135    //
1136    // All three shells publish `WindowMetrics` the same way: poll a
1137    // `WindowMetricsPublisher` from the entry points where the window's shape
1138    // actually moves (surface create/resize, insets), and `provide_context` the
1139    // result under the reactive root owner ONLY when the poll reports a change.
1140    // Their own publish methods are unreachable from the host (both mobile `app`
1141    // modules are target-gated; the desktop one needs a live winit window), so
1142    // this drives that exact loop over the shared `AppTree` seam — the same
1143    // reason the tracked-rebuild test below lives here.
1144    //
1145    // Two things are proven together, because they trade off against each other:
1146    //   1. `use_context::<WindowMetrics>()` resolves inside `Component::build`
1147    //      (the delivery contract), and
1148    //   2. a static window re-provides NOTHING across a long run of frames (the
1149    //      cost contract). `provide_context` notifies nothing on its own — an
1150    //      unconditional per-frame re-provide would still cost a lock write
1151    //      plus an allocation every frame at the FFI boundary for no
1152    //      observable benefit, which is what this guards against.
1153
1154    use frust_core::{Orientation, WindowMetrics};
1155
1156    use crate::WindowMetricsPublisher;
1157
1158    /// Records what the nested component's `build` resolved for
1159    /// `use_context::<WindowMetrics>()` on its most recent run.
1160    type MetricsSink = Rc<RefCell<Option<WindowMetrics>>>;
1161
1162    /// Reads the shell-provided window shape in `build`, like a real component
1163    /// laying itself out around size/orientation would.
1164    #[derive(Clone)]
1165    struct MetricsComp {
1166        sink: MetricsSink,
1167    }
1168    impl Component for MetricsComp {
1169        type State = ();
1170        fn init(&self) {}
1171        fn build(&self, _state: &mut ()) -> impl View<()> {
1172            *self.sink.borrow_mut() = use_context::<WindowMetrics>();
1173            any(StubLeaf)
1174        }
1175    }
1176
1177    #[test]
1178    fn window_metrics_reaches_component_build_and_re_provides_only_on_change() {
1179        let sink: MetricsSink = Rc::new(RefCell::new(None));
1180        let comp = MetricsComp { sink: sink.clone() };
1181
1182        // Counts actual `provide_context` calls — the app-wide invalidation a
1183        // re-provide represents. This is the number the cost contract is about.
1184        let provides = Rc::new(Cell::new(0u32));
1185
1186        // The shells' shared publish body: poll, and publish only on `Some`.
1187        // (Each shell wraps this in `ReactiveRuntime::with_owner`/`Owner::with`;
1188        // here the whole run is inside one `owner.with` below, matching.)
1189        let mut publisher = WindowMetricsPublisher::new();
1190        let provides_for_publish = provides.clone();
1191        let publish = move |publisher: &mut WindowMetricsPublisher,
1192                            physical: (u32, u32),
1193                            scale: f64,
1194                            insets: WindowInsets| {
1195            if let Some(metrics) = publisher.poll(physical, scale, insets) {
1196                provide_context(metrics);
1197                provides_for_publish.set(provides_for_publish.get() + 1);
1198            }
1199        };
1200
1201        let owner = Owner::new();
1202        owner.with(|| {
1203            // Seed before the first rebuild, exactly as each shell does (the
1204            // mobile shells inside `new`, desktop inside `resumed`) — a 1080x2400
1205            // @3x portrait phone surface, no insets reported yet.
1206            publish(&mut publisher, (1080, 2400), 3.0, WindowInsets::default());
1207
1208            let mut app =
1209                new_boxed_app_with(|| (), move |state: &mut ()| AnyView::new(comp.build(state)));
1210            app.rebuild();
1211
1212            // (1) Delivery: it resolved inside `Component::build`, in LOGICAL px.
1213            let seen = sink
1214                .borrow()
1215                .expect("WindowMetrics must reach Component::build");
1216            assert_eq!(seen.size, Size::new(360.0, 800.0), "logical, not physical");
1217            assert_eq!(seen.scale, 3.0);
1218            assert_eq!(seen.orientation, Orientation::Portrait);
1219            assert_eq!(seen.insets, WindowInsets::default());
1220            assert_eq!(provides.get(), 1, "the seed publishes exactly once");
1221
1222            // (2) Cost: a static window over a long run of frames. Every frame
1223            // re-runs the publish body with the values the shell has stored
1224            // (nothing moved), then rebuilds. Not one re-provide may happen.
1225            for _ in 0..120 {
1226                publish(&mut publisher, (1080, 2400), 3.0, WindowInsets::default());
1227                app.rebuild();
1228            }
1229            assert_eq!(
1230                provides.get(),
1231                1,
1232                "a static window must never re-provide WindowMetrics — an \
1233                 unconditional per-frame re-provide would pay a lock write \
1234                 plus an allocation every frame at the FFI boundary for \
1235                 nothing observable"
1236            );
1237
1238            // (3) A real rotation publishes once and flips the derived
1239            // orientation the next rebuild reads...
1240            publish(&mut publisher, (2400, 1080), 3.0, WindowInsets::default());
1241            app.rebuild();
1242            assert_eq!(provides.get(), 2, "a rotation is a real change");
1243            let seen = sink.borrow().expect("still delivered after the rotation");
1244            assert_eq!(seen.size, Size::new(800.0, 360.0));
1245            assert_eq!(seen.orientation, Orientation::Landscape);
1246
1247            // ...and then goes quiet again at the new shape.
1248            for _ in 0..120 {
1249                publish(&mut publisher, (2400, 1080), 3.0, WindowInsets::default());
1250                app.rebuild();
1251            }
1252            assert_eq!(provides.get(), 2, "settled again after the rotation");
1253
1254            // (4) The insets copy moves independently (the IME coming up at an
1255            // unchanged size/scale) — one more publish, then quiet.
1256            let ime_up = frust_core::insets::WindowInsets::new(
1257                frust_core::insets::EdgeInsets::new(0.0, 24.0, 0.0, 34.0),
1258                frust_core::insets::EdgeInsets::new(0.0, 0.0, 0.0, 340.0),
1259            );
1260            publish(&mut publisher, (2400, 1080), 3.0, ime_up);
1261            app.rebuild();
1262            publish(&mut publisher, (2400, 1080), 3.0, ime_up);
1263            app.rebuild();
1264            assert_eq!(provides.get(), 3, "an insets change publishes exactly once");
1265            assert_eq!(
1266                sink.borrow().expect("delivered").insets,
1267                ime_up,
1268                "the metrics carry a copy of the shell's already-logical insets"
1269            );
1270        });
1271    }
1272
1273    // --- Mobile tracked-rebuild regression test ----
1274    //
1275    // The mobile shells (`frust-shell-android`/`-ios`) now wrap their
1276    // per-frame rebuild as `rt.with_owner(|| scope.track(|| app.rebuild()))` —
1277    // the exact shape this test drives over the shared `AppTree` seam (the
1278    // shells' own `app` modules are target-gated and never host-compiled, so this
1279    // is where the wrap gets host coverage). It proves the wake mechanism device
1280    // screens depend on: a signal read during a `scope.track`-wrapped rebuild
1281    // subscribes the frame scope, so a later write trips the process-wide
1282    // `signals_dirty` flag the mobile frame gate drains (`take_signals_dirty` →
1283    // `FrameInputs::signals_dirty`). The negative control is the exact bug this
1284    // test guards against: a bare `with_owner(|| app.rebuild())` (no `scope.track`) installs
1285    // the reactive Owner but NOT the Observer, so the same post-rebuild write
1286    // subscribes nothing and trips nothing — the gate then skips the frame that
1287    // would paint the loaded content until a touch forces a Run. Both halves run
1288    // in ONE `#[test]` because `signals_dirty` is process-global: no other
1289    // shell-common test touches it, so a single serial test needs no cross-test
1290    // lock.
1291
1292    use frust_reactive::{ReactiveRuntime, TrackedScope};
1293    use reactive_graph::signal::RwSignal;
1294    use reactive_graph::traits::{Get, Set};
1295
1296    #[test]
1297    fn scope_tracked_rebuild_trips_signals_dirty_but_bare_rebuild_does_not() {
1298        // A no-op waker: this test asserts on the drained `signals_dirty` flag
1299        // (which `TrackedScope::notify_dirty` sets on every tracked write), not
1300        // on wake calls, so the waker itself need do nothing.
1301        let rt = ReactiveRuntime::init(std::sync::Arc::new(|| {}));
1302
1303        // --- Positive: the shells' new wrap subscribes the scope. ---
1304        let tracked_signal = rt.with_owner(|| RwSignal::new(0u32));
1305        let mut tracked_app: Box<dyn AppTree> = new_boxed_app_with(
1306            || (),
1307            move |_s: &mut ()| {
1308                // A tracked read during rebuild — under `scope.track` it
1309                // subscribes the scope, exactly as a real screen's
1310                // `controller.loading.get()` does inside a mobile rebuild.
1311                let _ = tracked_signal.get();
1312                StubLeaf
1313            },
1314        );
1315        let scope = TrackedScope::new();
1316        {
1317            // Disjoint borrows, mirroring the shells' `scope.track(|| app.rebuild())`.
1318            let s = &scope;
1319            let a = &mut tracked_app;
1320            rt.with_owner(|| s.track(|| a.rebuild()));
1321        }
1322        // Drain anything construction/rebuild left set so the assertion observes
1323        // only the post-rebuild write below.
1324        rt.take_signals_dirty();
1325        tracked_signal.set(1);
1326        assert!(
1327            rt.take_signals_dirty(),
1328            "a write to a signal read inside the scope-tracked rebuild must trip \
1329             signals_dirty — the wake the mobile frame gate drains"
1330        );
1331
1332        // --- Negative control: a bare, untracked rebuild. ---
1333        let untracked_signal = rt.with_owner(|| RwSignal::new(0u32));
1334        let mut untracked_app: Box<dyn AppTree> = new_boxed_app_with(
1335            || (),
1336            move |_s: &mut ()| {
1337                let _ = untracked_signal.get();
1338                StubLeaf
1339            },
1340        );
1341        // The pre-fix mobile shape: `with_owner` installs the Owner but NOT the
1342        // reactive Observer, so the read subscribes nothing.
1343        rt.with_owner(|| untracked_app.rebuild());
1344        rt.take_signals_dirty();
1345        untracked_signal.set(1);
1346        assert!(
1347            !rt.take_signals_dirty(),
1348            "without the scope.track wrap the read subscribes nothing, so the \
1349             write trips no signals_dirty — the device-only stuck-on-loading stall"
1350        );
1351    }
1352}