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