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