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
90impl Drop for Surface {
91    fn drop(&mut self) {
92        SURFACES.with(|s| {
93            s.borrow_mut().remove(&self.handle);
94        });
95    }
96}
97
98/// Restores the previously-active surface world when dropped. The per-world guards each restore their own
99/// (independent) thread-local; `_prev_surface` restores the reactive current-surface.
100#[must_use = "the surface is only active while this guard is alive"]
101pub struct SurfaceGuard {
102    _layout: LayoutGuard,
103    _overlay: OverlayGuard,
104    _focus: FocusGuard,
105    _input_region: InputRegionGuard,
106    _force_tick: ForceTickGuard,
107    _window_commands: WindowCommandGuard,
108    _services: ServiceGuard,
109    _prev_surface: RestoreSurface,
110}
111
112struct RestoreSurface(SurfaceHandle);
113
114impl Drop for RestoreSurface {
115    fn drop(&mut self) {
116        set_current_surface(self.0);
117    }
118}
119
120thread_local! {
121    // Weak, not Rc: an Rc here would keep every surface alive forever and its Drop (which unregisters) would
122    // never run. The hook upgrades on demand.
123    static SURFACES: RefCell<HashMap<SurfaceHandle, Weak<Surface>>> =
124        RefCell::new(HashMap::new());
125    // Handle 0 is SurfaceHandle::NONE (the ambient/no-surface world), so real surfaces start at 1.
126    static NEXT_HANDLE: Cell<u64> = const { Cell::new(1) };
127    static HOOK_INSTALLED: Cell<bool> = const { Cell::new(false) };
128}
129
130fn next_handle() -> SurfaceHandle {
131    NEXT_HANDLE.with(|c| {
132        let id = c.get();
133        c.set(id + 1);
134        SurfaceHandle(id)
135    })
136}
137
138/// Installs (once per thread) the reactive-core enter-hook: given the handle an effect captured, look up its
139/// surface and activate its full world for the duration of the effect. Returns a no-op when the surface is
140/// gone (e.g. torn down while a stale effect was still scheduled).
141fn install_enter_hook() {
142    HOOK_INSTALLED.with(|installed| {
143        if installed.replace(true) {
144            return;
145        }
146        set_surface_enter_hook(|handle| {
147            let surface = SURFACES.with(|s| s.borrow().get(&handle).and_then(Weak::upgrade));
148            match surface {
149                Some(surface) => {
150                    let guard = surface.enter();
151                    SurfaceEnterGuard::new(move || drop(guard))
152                }
153                None => SurfaceEnterGuard::noop(),
154            }
155        });
156    });
157}
158
159#[cfg(test)]
160mod tests {
161    use reactive_core::{current_surface, effect, signal};
162
163    use super::Surface;
164
165    // Two surfaces on one thread keep isolated layout worlds, and an effect built under surface A re-enters A
166    // when a shared signal set "from" surface B triggers it (the M3 owner-scope contract, end-to-end).
167    #[test]
168    fn effect_reenters_its_surface_layout_world() {
169        use layout_reactive::{
170            AvailableSpace, LayoutStyle, compute_layout, new_leaf, track_layout,
171        };
172        use std::cell::RefCell;
173        use std::rc::Rc;
174
175        let a = Surface::new();
176        let b = Surface::new();
177        assert_ne!(a.handle(), b.handle());
178        assert!(!a.handle().is_none());
179
180        // A shared signal (lives in the shared runtime) plus a node built inside each surface.
181        let shared = signal(0i32);
182
183        // Build an effect under A that, on change, creates a node in A's layout world and records the handle
184        // it ran under.
185        let ran_under: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(Vec::new()));
186        let ran_c = Rc::clone(&ran_under);
187        let read = shared.read_only();
188        let a_node = {
189            let _g = a.enter();
190            let (node, _) = new_leaf(LayoutStyle::new().width(10.0).height(10.0)).unwrap();
191            let _e = effect(move || {
192                read.get();
193                ran_c.borrow_mut().push(current_surface().0);
194            });
195            // Keep the effect alive for the whole test by leaking it into the surface's scope via Box.
196            std::mem::forget(_e);
197            node
198        };
199
200        ran_under.borrow_mut().clear();
201
202        // Set the shared signal while B is active. The flush must re-enter A for A's effect.
203        {
204            let _g = b.enter();
205            shared.set(1);
206        }
207        assert_eq!(
208            ran_under.borrow().as_slice(),
209            &[a.handle().0],
210            "A's effect must run under A's surface, not B's"
211        );
212
213        // A's node is laid out in A's world; B's world does not know it.
214        {
215            let _g = a.enter();
216            compute_layout(
217                a_node,
218                AvailableSpace::Definite(100.0),
219                AvailableSpace::Definite(100.0),
220            )
221            .unwrap();
222            assert_eq!(track_layout(a_node).unwrap().get().width, 10.0);
223        }
224        {
225            let _g = b.enter();
226            assert!(
227                track_layout(a_node).is_none(),
228                "A's node must not exist in B's layout world"
229            );
230        }
231    }
232
233    // Per-surface DI/context (services-core `provide`/`inject`): each surface resolves its own value, and an
234    // effect built under one surface injects THAT surface's context even when fired while another is active
235    // (owner-scope re-entry now swaps the service scope too). This is what lets an app carry per-window config.
236    #[test]
237    fn provide_inject_is_per_surface_and_survives_into_effects() {
238        use std::cell::RefCell;
239        use std::rc::Rc;
240
241        use services_core::{provide, try_inject};
242
243        let a = Surface::new();
244        let b = Surface::new();
245
246        {
247            let _g = a.enter();
248            provide(String::from("A")).unwrap();
249            assert_eq!(try_inject::<String>().as_deref(), Some("A"));
250        }
251        {
252            let _g = b.enter();
253            provide(String::from("B")).unwrap();
254            assert_eq!(try_inject::<String>().as_deref(), Some("B"));
255        }
256
257        let shared = signal(0i32);
258        let read = shared.read_only();
259        let seen: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
260        let seen_c = Rc::clone(&seen);
261        let ea = {
262            let _g = a.enter();
263            effect(move || {
264                read.get();
265                seen_c
266                    .borrow_mut()
267                    .push(try_inject::<String>().unwrap_or_default());
268            })
269        };
270
271        seen.borrow_mut().clear();
272        {
273            let _g = b.enter();
274            shared.set(1);
275        }
276        assert_eq!(
277            seen.borrow().as_slice(),
278            &[String::from("A")],
279            "A's effect must inject A's context even when fired from B"
280        );
281        drop(ea);
282    }
283
284    // T-3.1 / T-8.2: a global signal (theme/locale/motion are thread-local singletons — shared across
285    // surfaces on the one UI thread) written once re-runs every surface's effects, each under its OWN
286    // surface context. This is what makes a single dark-mode toggle update all windows correctly.
287    #[test]
288    fn global_signal_reruns_all_surfaces_each_under_its_context() {
289        use std::cell::RefCell;
290        use std::rc::Rc;
291
292        let a = Surface::new();
293        let b = Surface::new();
294
295        // A shared "global" signal, standing in for the theme/locale signal both surfaces read.
296        let global = signal(0i32);
297
298        // Each surface registers an effect reading the global signal; each records the surface it ran under.
299        let log: Rc<RefCell<Vec<(char, u64)>>> = Rc::new(RefCell::new(Vec::new()));
300
301        let log_a = Rc::clone(&log);
302        let read_a = global.read_only();
303        let ea = {
304            let _g = a.enter();
305            effect(move || {
306                read_a.get();
307                log_a.borrow_mut().push(('a', current_surface().0));
308            })
309        };
310
311        let log_b = Rc::clone(&log);
312        let read_b = global.read_only();
313        let eb = {
314            let _g = b.enter();
315            effect(move || {
316                read_b.get();
317                log_b.borrow_mut().push(('b', current_surface().0));
318            })
319        };
320
321        log.borrow_mut().clear();
322
323        // A single global write re-runs both surfaces' effects, each under its own context.
324        global.set(1);
325
326        let entries = log.borrow().clone();
327        assert!(
328            entries.contains(&('a', a.handle().0)),
329            "A's effect must re-run under A: {entries:?}"
330        );
331        assert!(
332            entries.contains(&('b', b.handle().0)),
333            "B's effect must re-run under B: {entries:?}"
334        );
335
336        drop(ea);
337        drop(eb);
338    }
339}