Skip to main content

day_core/
ambient.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Ambient state: values a piece reads without being handed them.
5//!
6//! Two families live here. The first is what a backend reports about a window
7//! (docs/size-classes.md): its [`SizeClass`] and its safe-area insets. The second is what an
8//! app provides for its own subtree (docs/state.md): [`with_environment`] / [`environment`],
9//! and the [`Ambient`] trait over them that carries per-window and app-wide state.
10//!
11//! They sit together because they answer the same question from opposite ends, and because the
12//! app half belongs below day-pieces: an app's `*-core` crate holds its view-model and has to be
13//! able to `impl Ambient` for it, which the orphan rule forbids when the trait lives one crate
14//! further up.
15//!
16//! Both are reactive, so a piece that reads one re-runs when the backend reports a new value, and
17//! both are keyed by window root for the same reason [`crate::toolbar`] is: one process can show
18//! two windows at different sizes at once (a narrow window beside a wide one, iPadOS Stage
19//! Manager, Android split-screen), and a single global would lay the second window out for the
20//! first one's size.
21//!
22//! Signals are created in the root reactive scope. Backends report from native callbacks that can
23//! fire while some transient scope is current, and a signal owned by a scope that later disposes
24//! would take the window's state with it.
25
26use std::cell::RefCell;
27
28use day_reactive::{Scope, Signal};
29use day_spec::SizeClass;
30
31use crate::build::Piece;
32use crate::{AnyPiece, piece_fn};
33
34use crate::tree::{RNode, with_tree};
35
36/// The reactive values one window carries.
37#[derive(Clone, Copy)]
38struct WindowAmbient {
39    /// `None` until the backend reports one. The distinction matters: a backend that does not
40    /// participate in size classes yet must not be read as "this window is compact", or every
41    /// host on it would resolve to a stack. Callers treat `None` as "ask the toolkit instead".
42    size_class: Signal<Option<SizeClass>>,
43    /// The last class the BACKEND reported, kept beside the live one so a script that forces a
44    /// class has something true to go back to (`size_class: { width: auto }`).
45    reported_class: Signal<Option<SizeClass>>,
46    /// Whether a script forced [`Self::size_class`]. Reports land in `reported_class` alone
47    /// until the script lets go: a piece that rebuilds on the forced class can change the
48    /// window's content, and the backend's report of that would otherwise undo the force.
49    overridden: Signal<bool>,
50    safe_area: Signal<day_geometry::Insets>,
51}
52
53day_reactive::tls_slots! {
54    ambient;
55    static AMBIENT: RefCell<Vec<(RNode, WindowAmbient)>> = const { RefCell::new(Vec::new()) };
56    static SEED: std::cell::Cell<Option<day_geometry::Insets>> = const { std::cell::Cell::new(None) };
57}
58
59/// One window's signals, created on first touch.
60///
61/// Returns them by value and drops the registry borrow before the caller touches either. Writing
62/// a signal runs its bindings synchronously, and those bindings read ambient state (a nav host
63/// re-presenting on a class change reads the class right back), so holding the borrow across a
64/// write is a reentrant panic waiting to happen.
65fn ambient_of(root: RNode) -> WindowAmbient {
66    AMBIENT.with(|m| {
67        let mut m = m.borrow_mut();
68        if let Some((_, a)) = m.iter().find(|(r, _)| *r == root) {
69            return *a;
70        }
71        let ambient = day_reactive::Scope::root().enter(|| WindowAmbient {
72            size_class: Signal::global(None),
73            reported_class: Signal::global(None),
74            overridden: Signal::global(false),
75            safe_area: Signal::global(initial_safe_area()),
76        });
77        m.push((root, ambient));
78        ambient
79    })
80}
81
82/// Seeded from the launch environment so a page built during startup, before the backend's first
83/// live report lands, already sees the right top inset (the same lazy-env pattern as
84/// `layout_direction`/`DAY_LOCALE`). Points, not px.
85fn initial_safe_area() -> day_geometry::Insets {
86    if let Some(seed) = SEED.with(|s| s.take()) {
87        return seed;
88    }
89    std::env::var("DAY_SAFE_AREA_TOP")
90        .ok()
91        .and_then(|v| v.parse::<f64>().ok())
92        .map(|top| day_geometry::Insets {
93            top,
94            ..Default::default()
95        })
96        .unwrap_or_default()
97}
98
99/// Backend-facing: the first window's safe-area insets, known before its content builds and
100/// before the tree that owns them exists (an overlay title bar's height, docs/window-chrome.md).
101/// The first window's content reads them at build time; later changes go through
102/// [`set_safe_area`].
103pub fn seed_safe_area(insets: day_geometry::Insets) {
104    SEED.with(|s| s.set(Some(insets)));
105}
106
107/// The window a read or report targets: the one being built, else the primary root. Shared with
108/// [`crate::toolbar`], which scopes the same way for the same reason: an app's one `toolbar(…)`
109/// or `size_class()` call inside a shared `build_shell` must mean "this window".
110fn target_window() -> RNode {
111    crate::toolbar::window_being_built()
112}
113
114/// The current window's size class, or `None` on a backend that does not report one yet.
115/// Tracked: a piece that lays out from this rebuilds when the window crosses a breakpoint.
116///
117/// Outside a window's content build this reads the primary window, so a long-lived effect that
118/// wants a secondary window's class must capture its root at build time and use
119/// [`window_size_class`], the same discipline `toolbar_reactive` follows.
120pub fn size_class() -> Option<SizeClass> {
121    // Headless (no tree on this thread, as in an app's model unit test): no window, so no class,
122    // rather than a panic from asking which window is being built.
123    if !crate::tree::has_tree() {
124        return None;
125    }
126    window_size_class(target_window())
127}
128
129/// [`size_class`] against an explicit window root.
130pub fn window_size_class(root: RNode) -> Option<SizeClass> {
131    ambient_of(root).size_class.get()
132}
133
134/// [`size_class`] without subscribing, for code that reacts to the change itself.
135pub fn window_size_class_untracked(root: RNode) -> Option<SizeClass> {
136    ambient_of(root).size_class.get_untracked()
137}
138
139/// Backend-facing: report a window's size class. Call whenever the window's size changes; the
140/// signal only notifies when the bucket changes, so a resize within one class is free.
141pub fn set_window_size_class(root: RNode, class: SizeClass) {
142    ambient_of(root).reported_class.set(Some(class));
143    if ambient_of(root).overridden.get_untracked() {
144        return;
145    }
146    let signal = ambient_of(root).size_class;
147    if signal.get_untracked() == Some(class) {
148        return;
149    }
150    signal.set(Some(class));
151    with_tree(|t| t.layout_if_needed());
152}
153
154/// [`set_window_size_class`] against the primary window, which is what a single-window backend
155/// reports.
156pub fn set_size_class(class: SizeClass) {
157    set_window_size_class(primary_root(), class);
158}
159
160/// Force a class the window is not at: dayscript's `size_class:` step and tests.
161///
162/// Leaves the reported class alone: this is a claim about the window, not a report from it, so
163/// [`restore_reported_size_class`] can still put back what the backend last said. The force
164/// holds until then, through any reports the backend makes in the meantime.
165pub fn override_size_class(class: SizeClass) {
166    let root = primary_root();
167    ambient_of(root).overridden.set(true);
168    let signal = ambient_of(root).size_class;
169    if signal.get_untracked() == Some(class) {
170        return;
171    }
172    signal.set(Some(class));
173    with_tree(|t| t.layout_if_needed());
174}
175
176/// Undo an [`override_size_class`]: back to the class the window itself last reported.
177///
178/// A no-op where the backend has reported nothing yet: there is nothing to restore, and the
179/// forced class is better than none.
180pub fn restore_reported_size_class() {
181    let root = primary_root();
182    ambient_of(root).overridden.set(false);
183    let Some(class) = ambient_of(root).reported_class.get_untracked() else {
184        return;
185    };
186    let signal = ambient_of(root).size_class;
187    if signal.get_untracked() == Some(class) {
188        return;
189    }
190    signal.set(Some(class));
191    with_tree(|t| t.layout_if_needed());
192}
193
194/// The window's safe-area insets, in points. Zero on every backend that clamps Day's root to
195/// the safe area natively (the default everywhere); nonzero only where a backend runs the root
196/// edge-to-edge, today day-toolkit-material's opt-in immersive mode (docs/layout.md, the android
197/// platform page). Compose it yourself where a background should run under the system bars:
198/// paint the background unpadded, pad the content by these insets. The read is tracked, but
199/// layout attributes like `.padding` capture the value at build time, so a mid-run inset change
200/// (rotation) does not re-pad already-built pages.
201pub fn safe_area() -> day_geometry::Insets {
202    window_safe_area(target_window())
203}
204
205/// [`safe_area`] against an explicit window root.
206pub fn window_safe_area(root: RNode) -> day_geometry::Insets {
207    ambient_of(root).safe_area.get()
208}
209
210/// Backend-facing: report a window's safe-area insets (points). Call from the native inset pass
211/// whenever the value changes; apps observe it through [`safe_area`].
212pub fn set_window_safe_area(root: RNode, insets: day_geometry::Insets) {
213    ambient_of(root).safe_area.set(insets);
214}
215
216/// [`set_window_safe_area`] against the primary window, which is what a single-window backend
217/// reports.
218pub fn set_safe_area(insets: day_geometry::Insets) {
219    set_window_safe_area(primary_root(), insets);
220}
221
222fn primary_root() -> RNode {
223    with_tree(|t| t.root_node())
224}
225
226/// Drop a closed window's ambient state (called from the window teardown path).
227pub(crate) fn forget_window(root: RNode) {
228    AMBIENT.with(|m| m.borrow_mut().retain(|(r, _)| *r != root));
229}
230
231/// Reset every window's ambient state (tests; pairs with `uninstall_tree`).
232pub fn reset_ambient() {
233    AMBIENT.with(|m| m.borrow_mut().clear());
234}
235// ---------------------------------------------------------------------------
236// @Environment: ambient values over day-reactive's scope context (§4.3). No backend work.
237// ---------------------------------------------------------------------------
238
239/// Provide an ambient value `T` to `content` and its entire descendant subtree (the SwiftUI
240/// `@Environment`/`.environment(_)` analog, layered over day-reactive's scope context). `content`,
241/// and any piece built within it, reads it back with [`environment`]. A thin, non-reactive
242/// wrapper: `T` is a snapshot captured here; for a value that must react, provide a `Signal<T>`
243/// (or a `Memo<T>`) and read it reactively inside the subtree.
244///
245/// ```ignore
246/// #[derive(Clone)] struct Theme { accent: Color }
247/// with_environment(Theme { accent: BLUE }, || my_screen())
248/// // deep inside my_screen():  let accent = environment::<Theme>().unwrap().accent;
249/// ```
250pub fn with_environment<T: Clone + 'static, P: Piece>(
251    value: T,
252    content: impl FnOnce() -> P + 'static,
253) -> impl Piece {
254    piece_fn(move |cx| {
255        // A child scope carrying `T`, entered for the whole of `content`'s construction and build,
256        // so both `content`'s own body and every descendant piece's build resolve it via
257        // `use_context` (which walks scope → ancestors). Owned by the current build scope, so it is
258        // disposed with the enclosing subtree (e.g. a `when` arm) exactly like `when`/`each` scopes.
259        let scope = Scope::child();
260        scope.provide(value);
261        scope.enter(|| content().build(cx))
262    })
263}
264
265/// Read the nearest ambient `T` provided by an enclosing [`with_environment`], or `None` if none is
266/// in scope. Call it while constructing or building a piece within that subtree.
267pub fn environment<T: Clone + 'static>() -> Option<T> {
268    Scope::current().use_context::<T>()
269}
270
271/// The ambient `T` of the window that currently has focus (docs/state.md): SwiftUI's
272/// `@FocusedValue`.
273///
274/// [`environment`] answers "what did my ancestors provide", which is the right question inside a
275/// piece and the wrong one inside an app-wide menu action: a desktop menu bar is one bar for the
276/// whole app, and its commands act on the front window. This resolves through that window's own
277/// scope instead of the calling scope, so `File ▸ New Item` adds to the list the user is
278/// looking at. A cover that provides no `T` answers with the primary window's, since the
279/// cover renders inside it (following the window scope chain). `None` ⇒ no window is
280/// open, or the front one provides no `T`.
281pub fn focused_environment<T: Clone + 'static>() -> Option<T> {
282    crate::windows::focused_scope_chain()
283        .into_iter()
284        .find_map(|scope| scope.use_context::<T>())
285}
286
287/// The app-wide `T`: created on the reactive root scope the first time it is asked for, and
288/// returned unchanged by every later call (docs/state.md).
289///
290/// The counterpart to [`with_environment`]'s subtree scope: state that belongs to the app
291/// rather than to a window or a page, reachable from every window, every menu action, and every
292/// task, and alive for as long as the process. `make` runs at most once.
293pub fn app_environment<T: Clone + 'static>(make: impl FnOnce() -> T) -> T {
294    let root = Scope::root();
295    if let Some(existing) = root.use_context::<T>() {
296        return existing;
297    }
298    // Created IN the root scope, not merely stored there: signals inside `T` must outlive
299    // whatever window happened to ask for it first.
300    let value = root.enter(make);
301    root.provide(value.clone());
302    value
303}
304
305/// State an ancestor provides once and any descendant reads back by type: SwiftUI's
306/// `@EnvironmentObject`, and Day's answer to "where does app state live?" (docs/state.md).
307///
308/// Implement it on a `Copy` struct of handles. `Signal`, `Memo`, `Trigger` and `Store` are all
309/// `Copy` and all cheap, so the struct is a bundle of pointers that rides into closures without
310/// `Rc` or `clone()` ceremony:
311///
312/// ```ignore
313/// #[derive(Clone, Copy)]
314/// struct Scene { selected: Signal<Option<u32>>, items: Store<Keyed<Item>> }
315///
316/// impl Ambient for Scene {
317///     fn create() -> Self { Scene { selected: Signal::new(None), items: Store::new(..) } }
318/// }
319///
320/// // one per window; File ▸ New Window gets its own:
321/// Scene::scoped(|scene| my_shell(scene))
322/// // anywhere below it:
323/// let scene = Scene::ambient();
324/// // in an app-wide menu action, which belongs to no window:
325/// menu_item("New").action(|| if let Some(s) = Scene::focused() { s.add() })
326/// ```
327///
328/// The alternative, a `thread_local!` holding `Signal::global`, is one instance for the whole
329/// process, which is indistinguishable from correct until the app opens a second window
330/// (docs/windows.md) and both windows start sharing a selection.
331pub trait Ambient: Clone + 'static {
332    /// A fresh instance. Called once per providing site: once per window for [`Ambient::scoped`],
333    /// once per process for [`Ambient::app`].
334    fn create() -> Self;
335
336    /// One instance owned by the scope this piece BUILDS in, provided to `content` and
337    /// everything under it. The per-window idiom: call it from a window's shell and each window
338    /// gets its own.
339    ///
340    /// Creation is deferred to build time, which is what makes that true: a piece's
341    /// construction runs in the caller's scope, and only its build runs inside the window's
342    /// (`day_core::launch_with` for the primary, `open_window` for the rest). Creating the state
343    /// eagerly would hand every window the first one's.
344    ///
345    /// It provides on the current scope rather than a fresh child, and at a window's root that
346    /// scope is the window's own. That is what lets [`Ambient::focused`] find it: a context
347    /// lookup walks ancestors, so a value tucked into a child of the window scope would be
348    /// invisible to anything resolving from the window down, including every app-wide menu
349    /// command.
350    fn scoped<P: Piece>(content: impl FnOnce(Self) -> P + 'static) -> AnyPiece
351    where
352        Self: Sized,
353    {
354        AnyPiece::new(piece_fn(move |cx| {
355            let scope = Scope::current();
356            let value = Self::create();
357            scope.provide(value.clone());
358            content(value).build(cx)
359        }))
360    }
361
362    /// The one app-wide instance, created on first use and alive for the whole process. Visible
363    /// from every window, menu action, and task, with no `with_environment` needed.
364    fn app() -> Self
365    where
366        Self: Sized,
367    {
368        app_environment(Self::create)
369    }
370
371    /// The nearest instance an ancestor provided, panicking when there is none. The read a piece
372    /// writes when the value is a precondition of it existing at all, like
373    /// `@EnvironmentObject`, which likewise traps rather than rendering something wrong.
374    ///
375    /// A build-time read, like [`environment`]: call it in a piece's body and capture the value
376    /// in whatever closures need it. Calling it *inside* a reactive closure works on the first
377    /// run and panics on the next, because a re-running reaction is no longer inside the scope
378    /// that provided the value.
379    fn ambient() -> Self
380    where
381        Self: Sized,
382    {
383        Self::try_ambient().unwrap_or_else(|| {
384            let t = std::any::type_name::<Self>();
385            panic!(
386                "day: no ambient `{t}` in scope. Provide one above this piece with \
387                 `{t}::scoped(…)` (per window) or `{t}::app()` (app-wide). If there IS one, \
388                 this read is running too late: `ambient()` resolves while a piece BUILDS, so \
389                 read it in the piece's body and capture the value rather than calling it \
390                 inside a reactive closure (docs/state.md)."
391            )
392        })
393    }
394
395    /// [`Ambient::ambient`] without the panic.
396    fn try_ambient() -> Option<Self>
397    where
398        Self: Sized,
399    {
400        environment::<Self>()
401    }
402
403    /// The instance belonging to the window that currently has focus, which is what an app-wide
404    /// menu command acts on. See [`focused_environment`].
405    fn focused() -> Option<Self>
406    where
407        Self: Sized,
408    {
409        focused_environment::<Self>()
410    }
411}