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}