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