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