Skip to main content

telar_ui_core/
surface_context.rs

1//! `Surface` — one RSX surface's complete per-surface world.
2//!
3//! A surface (a window, or a Wayland layer-surface) owns a set of thread-local worlds: its layout tree,
4//! overlay registry, focus state, input region, force-tick, and window-command queue. Under M3 several
5//! surfaces share one UI thread and one reactive runtime, so those worlds are swappable: the runner
6//! activates a surface with [`Surface::enter`] around its build/event/frame, and the reactive flush
7//! re-enters the surface that owns each effect through the hook this module installs into reactive-core.
8//!
9//! Single-window apps never build a `Surface`: the reactive current-surface stays [`SurfaceHandle::NONE`],
10//! every effect captures `NONE`, and `enter` is a no-op — so they run against the ambient thread-local
11//! worlds exactly as before, at zero added cost.
12
13use std::cell::{Cell, RefCell};
14use std::collections::HashMap;
15use std::rc::{Rc, Weak};
16
17use layout_reactive::{LayoutContext, LayoutGuard};
18use platform_core::{WindowCommandContext, WindowCommandGuard};
19use reactive_core::{
20    SurfaceEnterGuard, SurfaceHandle, set_current_surface, set_surface_enter_hook,
21};
22use services_core::{ServiceContext, ServiceGuard};
23use ui_tree::{ForceTickContext, ForceTickGuard, OverlayContext, OverlayGuard};
24
25use crate::focus::{FocusContext, FocusGuard};
26use crate::input_region::{InputRegionContext, InputRegionGuard};
27
28/// The complete per-surface world plus its reactive [`SurfaceHandle`]. Build one per window/layer-surface
29/// with [`Surface::new`]; activate it with [`Surface::enter`].
30pub struct Surface {
31    handle: SurfaceHandle,
32    layout: LayoutContext,
33    overlay: OverlayContext,
34    focus: FocusContext,
35    input_region: InputRegionContext,
36    force_tick: ForceTickContext,
37    window_commands: WindowCommandContext,
38    // Per-surface DI/context scope: `provide`/`inject` (services-core) resolve against this while the surface
39    // is active, so an app carries per-window context (config, theme, locale) as typed values, read even from
40    // effects (the flush re-enters the surface). The generic per-surface-context primitive, à la Floem/Leptos.
41    services: ServiceContext,
42}
43
44impl Surface {
45    /// Allocates a fresh, inactive surface world with a unique handle and registers it so the reactive
46    /// flush can re-enter it for its effects. The returned `Rc` is the sole owner; the registry keeps only a
47    /// `Weak`, so dropping the `Rc` tears the surface down (and unregisters it).
48    pub fn new() -> Rc<Self> {
49        install_enter_hook();
50        let handle = next_handle();
51        let surface = Rc::new(Self {
52            handle,
53            layout: LayoutContext::new(),
54            overlay: OverlayContext::new(),
55            focus: FocusContext::new(),
56            input_region: InputRegionContext::new(),
57            force_tick: ForceTickContext::new(),
58            window_commands: WindowCommandContext::new(),
59            services: ServiceContext::new(),
60        });
61        SURFACES.with(|s| s.borrow_mut().insert(handle, Rc::downgrade(&surface)));
62        surface
63    }
64
65    /// This surface's reactive handle. Effects registered while it is active capture it and re-run under it.
66    pub fn handle(&self) -> SurfaceHandle {
67        self.handle
68    }
69
70    /// Activates this surface's world until the returned guard drops, which restores the previously-active
71    /// world. The swapped worlds are independent thread-locals, so restore order among them is irrelevant;
72    /// nesting `enter`s is fine.
73    #[must_use = "the surface is only active while this guard is alive"]
74    pub fn enter(&self) -> SurfaceGuard {
75        // Set the reactive current-surface first so any effect registered while active captures this handle.
76        let prev_surface = set_current_surface(self.handle);
77        SurfaceGuard {
78            _layout: self.layout.enter(),
79            _overlay: self.overlay.enter(),
80            _focus: self.focus.enter(),
81            _input_region: self.input_region.enter(),
82            _force_tick: self.force_tick.enter(),
83            _window_commands: self.window_commands.enter(),
84            _services: self.services.enter(),
85            _prev_surface: RestoreSurface(prev_surface),
86        }
87    }
88
89    /// Activates the ambient world — the one that exists before any [`Surface`] is built.
90    ///
91    /// A single-window app never builds a surface, so its whole tree is owned by
92    /// [`SurfaceHandle::NONE`] and its effects have to re-enter *this*. Without it they run against
93    /// whichever surface happened to be entered when the signal fired, which is a live case as soon as one
94    /// app has both — a window tree that never built a surface and a [`TextureUi`] that did.
95    ///
96    /// [`TextureUi`]: https://docs.rs/telar/latest/telar/struct.TextureUi.html
97    #[must_use = "the ambient world is only active while this guard is alive"]
98    fn enter_ambient() -> SurfaceGuard {
99        let prev_surface = set_current_surface(SurfaceHandle::NONE);
100        SurfaceGuard {
101            _layout: LayoutContext::enter_ambient(),
102            _overlay: OverlayContext::enter_ambient(),
103            _focus: FocusContext::enter_ambient(),
104            _input_region: InputRegionContext::enter_ambient(),
105            _force_tick: ForceTickContext::enter_ambient(),
106            _window_commands: WindowCommandContext::enter_ambient(),
107            _services: ServiceContext::enter_ambient(),
108            _prev_surface: RestoreSurface(prev_surface),
109        }
110    }
111}
112
113impl Drop for Surface {
114    fn drop(&mut self) {
115        SURFACES.with(|s| {
116            s.borrow_mut().remove(&self.handle);
117        });
118    }
119}
120
121/// Restores the previously-active surface world when dropped. The per-world guards each restore their own
122/// (independent) thread-local; `_prev_surface` restores the reactive current-surface.
123#[must_use = "the surface is only active while this guard is alive"]
124pub struct SurfaceGuard {
125    _layout: LayoutGuard,
126    _overlay: OverlayGuard,
127    _focus: FocusGuard,
128    _input_region: InputRegionGuard,
129    _force_tick: ForceTickGuard,
130    _window_commands: WindowCommandGuard,
131    _services: ServiceGuard,
132    _prev_surface: RestoreSurface,
133}
134
135struct RestoreSurface(SurfaceHandle);
136
137impl Drop for RestoreSurface {
138    fn drop(&mut self) {
139        set_current_surface(self.0);
140    }
141}
142
143thread_local! {
144    // Weak, not Rc: an Rc here would keep every surface alive forever and its Drop (which unregisters) would
145    // never run. The hook upgrades on demand.
146    static SURFACES: RefCell<HashMap<SurfaceHandle, Weak<Surface>>> =
147        RefCell::new(HashMap::new());
148    // Handle 0 is SurfaceHandle::NONE (the ambient/no-surface world), so real surfaces start at 1.
149    static NEXT_HANDLE: Cell<u64> = const { Cell::new(1) };
150    static HOOK_INSTALLED: Cell<bool> = const { Cell::new(false) };
151}
152
153fn next_handle() -> SurfaceHandle {
154    NEXT_HANDLE.with(|c| {
155        let id = c.get();
156        c.set(id + 1);
157        SurfaceHandle(id)
158    })
159}
160
161/// Installs (once per thread) the reactive-core enter-hook: given the handle an effect captured, look up its
162/// surface and activate its full world for the duration of the effect. Returns a no-op when the surface is
163/// gone (e.g. torn down while a stale effect was still scheduled).
164fn install_enter_hook() {
165    HOOK_INSTALLED.with(|installed| {
166        if installed.replace(true) {
167            return;
168        }
169        set_surface_enter_hook(|handle| {
170            if handle.is_none() {
171                let guard = Surface::enter_ambient();
172                return SurfaceEnterGuard::new(move || drop(guard));
173            }
174            let surface = SURFACES.with(|s| s.borrow().get(&handle).and_then(Weak::upgrade));
175            match surface {
176                Some(surface) => {
177                    let guard = surface.enter();
178                    SurfaceEnterGuard::new(move || drop(guard))
179                }
180                // Torn down while a stale effect was still scheduled: there is no world to enter, and guessing one would run it against a stranger's.
181                None => SurfaceEnterGuard::noop(),
182            }
183        });
184    });
185}
186
187#[cfg(test)]
188mod tests {
189    use reactive_core::{current_surface, effect, signal};
190
191    use super::Surface;
192
193    // Two surfaces on one thread keep isolated layout worlds, and an effect built under surface A re-enters A
194    // when a shared signal set "from" surface B triggers it (the M3 owner-scope contract, end-to-end).
195    #[test]
196    fn effect_reenters_its_surface_layout_world() {
197        use layout_reactive::{
198            AvailableSpace, LayoutStyle, compute_layout, new_leaf, track_layout,
199        };
200        use std::cell::RefCell;
201        use std::rc::Rc;
202
203        let a = Surface::new();
204        let b = Surface::new();
205        assert_ne!(a.handle(), b.handle());
206        assert!(!a.handle().is_none());
207
208        // A shared signal (lives in the shared runtime) plus a node built inside each surface.
209        let shared = signal(0i32);
210
211        // Build an effect under A that, on change, creates a node in A's layout world and records the handle
212        // it ran under.
213        let ran_under: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(Vec::new()));
214        let ran_c = Rc::clone(&ran_under);
215        let read = shared.read_only();
216        let a_node = {
217            let _g = a.enter();
218            let (node, _) = new_leaf(LayoutStyle::new().width(10.0).height(10.0)).unwrap();
219            let _e = effect(move || {
220                read.get();
221                ran_c.borrow_mut().push(current_surface().0);
222            });
223            // Keep the effect alive for the whole test by leaking it into the surface's scope via Box.
224            std::mem::forget(_e);
225            node
226        };
227
228        ran_under.borrow_mut().clear();
229
230        // Set the shared signal while B is active. The flush must re-enter A for A's effect.
231        {
232            let _g = b.enter();
233            shared.set(1);
234        }
235        assert_eq!(
236            ran_under.borrow().as_slice(),
237            &[a.handle().0],
238            "A's effect must run under A's surface, not B's"
239        );
240
241        // A's node is laid out in A's world; B's world does not know it.
242        {
243            let _g = a.enter();
244            compute_layout(
245                a_node,
246                AvailableSpace::Definite(100.0),
247                AvailableSpace::Definite(100.0),
248            )
249            .unwrap();
250            assert_eq!(track_layout(a_node).unwrap().get().width, 10.0);
251        }
252        {
253            let _g = b.enter();
254            assert!(
255                track_layout(a_node).is_none(),
256                "A's node must not exist in B's layout world"
257            );
258        }
259    }
260
261    // An effect that belongs to no surface has a world of its own — the ambient one — and must re-enter it when it fires. Every effect of a single-window app is one of these: the runner builds no `Surface` for one. Left un-restored they ran against whichever surface happened to be active, so a window widget re-rendering during another tree's event dispatch resolved its layout in that tree's world and found nothing there.
262    #[test]
263    fn an_effect_owned_by_no_surface_reenters_the_ambient_world() {
264        use layout_reactive::{
265            AvailableSpace, LayoutStyle, compute_layout, new_leaf, track_layout,
266        };
267        use std::cell::RefCell;
268        use std::rc::Rc;
269
270        use super::Surface;
271
272        // Built with no surface active, so both the node and the effect below belong to the ambient world.
273        let (ambient_node, _) = new_leaf(LayoutStyle::new().width(42.0).height(10.0)).unwrap();
274        compute_layout(
275            ambient_node,
276            AvailableSpace::Definite(100.0),
277            AvailableSpace::Definite(100.0),
278        )
279        .unwrap();
280
281        let other = Surface::new();
282        let shared = signal(0i32);
283        let read = shared.read_only();
284        let seen: Rc<RefCell<Vec<Option<f32>>>> = Rc::new(RefCell::new(Vec::new()));
285        let seen_c = Rc::clone(&seen);
286        let watcher = effect(move || {
287            read.get();
288            seen_c
289                .borrow_mut()
290                .push(track_layout(ambient_node).map(|rect| rect.get().width));
291        });
292
293        seen.borrow_mut().clear();
294        {
295            let _g = other.enter();
296            shared.set(1);
297        }
298        assert_eq!(
299            seen.borrow().as_slice(),
300            &[Some(42.0)],
301            "an ambient effect must resolve against the ambient layout world, not the active surface's"
302        );
303        drop(watcher);
304    }
305
306    // Per-surface DI/context (services-core `provide`/`inject`): each surface resolves its own value, and an
307    // effect built under one surface injects THAT surface's context even when fired while another is active
308    // (owner-scope re-entry now swaps the service scope too). This is what lets an app carry per-window config.
309    #[test]
310    fn provide_inject_is_per_surface_and_survives_into_effects() {
311        use std::cell::RefCell;
312        use std::rc::Rc;
313
314        use services_core::{provide, try_inject};
315
316        let a = Surface::new();
317        let b = Surface::new();
318
319        {
320            let _g = a.enter();
321            provide(String::from("A")).unwrap();
322            assert_eq!(try_inject::<String>().as_deref(), Some("A"));
323        }
324        {
325            let _g = b.enter();
326            provide(String::from("B")).unwrap();
327            assert_eq!(try_inject::<String>().as_deref(), Some("B"));
328        }
329
330        let shared = signal(0i32);
331        let read = shared.read_only();
332        let seen: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
333        let seen_c = Rc::clone(&seen);
334        let ea = {
335            let _g = a.enter();
336            effect(move || {
337                read.get();
338                seen_c
339                    .borrow_mut()
340                    .push(try_inject::<String>().unwrap_or_default());
341            })
342        };
343
344        seen.borrow_mut().clear();
345        {
346            let _g = b.enter();
347            shared.set(1);
348        }
349        assert_eq!(
350            seen.borrow().as_slice(),
351            &[String::from("A")],
352            "A's effect must inject A's context even when fired from B"
353        );
354        drop(ea);
355    }
356
357    // T-3.1 / T-8.2: a global signal (theme/locale/motion are thread-local singletons — shared across
358    // surfaces on the one UI thread) written once re-runs every surface's effects, each under its OWN
359    // surface context. This is what makes a single dark-mode toggle update all windows correctly.
360    #[test]
361    fn global_signal_reruns_all_surfaces_each_under_its_context() {
362        use std::cell::RefCell;
363        use std::rc::Rc;
364
365        let a = Surface::new();
366        let b = Surface::new();
367
368        // A shared "global" signal, standing in for the theme/locale signal both surfaces read.
369        let global = signal(0i32);
370
371        // Each surface registers an effect reading the global signal; each records the surface it ran under.
372        let log: Rc<RefCell<Vec<(char, u64)>>> = Rc::new(RefCell::new(Vec::new()));
373
374        let log_a = Rc::clone(&log);
375        let read_a = global.read_only();
376        let ea = {
377            let _g = a.enter();
378            effect(move || {
379                read_a.get();
380                log_a.borrow_mut().push(('a', current_surface().0));
381            })
382        };
383
384        let log_b = Rc::clone(&log);
385        let read_b = global.read_only();
386        let eb = {
387            let _g = b.enter();
388            effect(move || {
389                read_b.get();
390                log_b.borrow_mut().push(('b', current_surface().0));
391            })
392        };
393
394        log.borrow_mut().clear();
395
396        // A single global write re-runs both surfaces' effects, each under its own context.
397        global.set(1);
398
399        let entries = log.borrow().clone();
400        assert!(
401            entries.contains(&('a', a.handle().0)),
402            "A's effect must re-run under A: {entries:?}"
403        );
404        assert!(
405            entries.contains(&('b', b.handle().0)),
406            "B's effect must re-run under B: {entries:?}"
407        );
408
409        drop(ea);
410        drop(eb);
411    }
412}