Skip to main content

teksilo_core/
event_source.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Backend event subscription infrastructure (architecture §9.4).
5//!
6//! Widgets subscribe to external event sources (database change notifiers,
7//! file watchers, message buses, network response channels) directly from
8//! their `build()` method via [`crate::BuildContext::subscribe_event`]. The
9//! framework bridges events from the publisher's thread to the UI thread via
10//! the application's event-loop proxy and routes them to the widget's
11//! UI-side callback, with automatic per-widget lifetime cleanup.
12//!
13//! This module defines the public [`EventSource`] trait, the opaque
14//! [`SubscriptionHandle`] returned by sources, and the internal
15//! [`TreeAppContext`] / [`EventSourceAdapter`] / [`AppEventPoster`] types that
16//! plug a registered source into the tree.
17
18use std::any::{Any, TypeId};
19use std::cell::{Cell, RefCell};
20use std::collections::HashMap;
21use std::rc::Rc;
22use std::sync::Arc;
23
24use crate::widget::EventContext;
25use crate::window::TeksiloWindowId;
26
27/// An external source of events that widgets can subscribe to.
28///
29/// Implementations include backend message buses, database change notifiers,
30/// file watchers, network response channels — any source that publishes
31/// events asynchronously and that widgets need to react to.
32pub trait EventSource: 'static {
33    /// The key by which subscribers identify which events they care about.
34    /// Typically an enum (a Qleany `Origin`) or a topic string.
35    type Origin: Clone + 'static;
36
37    /// The event payload delivered to subscriber callbacks. Must be `Send`
38    /// because events cross from the publisher's thread to the UI thread via
39    /// the framework's proxy bridge.
40    type Event: Send + 'static;
41
42    /// Subscribe a callback to events of a given origin. The callback is
43    /// invoked on whatever thread the source publishes from (typically a
44    /// background thread). The returned handle, when dropped, removes the
45    /// subscription from the source's internal registry.
46    fn subscribe(
47        &self,
48        origin: Self::Origin,
49        callback: Arc<dyn Fn(Self::Event) + Send + Sync + 'static>,
50    ) -> SubscriptionHandle;
51}
52
53/// An opaque handle returned by [`EventSource::subscribe`].
54///
55/// The source defines what the handle contains; the framework treats it as a
56/// token whose `Drop` impl performs the unsubscription. Returning an empty
57/// handle (via [`SubscriptionHandle::empty`]) is acceptable for sources that
58/// outlive the application or do not support removal.
59pub struct SubscriptionHandle {
60    _inner: Box<dyn Any>,
61}
62
63impl SubscriptionHandle {
64    /// Wrap an arbitrary value as a subscription handle. The value is dropped
65    /// when the handle is dropped — typically that drop performs removal from
66    /// the source's internal subscriber registry.
67    pub fn new<T: 'static>(token: T) -> Self {
68        Self {
69            _inner: Box::new(token),
70        }
71    }
72
73    /// A handle that performs no cleanup on drop. Use this for sources that
74    /// outlive the application or whose subscribers cannot be individually
75    /// removed.
76    pub fn empty() -> Self {
77        Self::new(())
78    }
79}
80
81/// A unique identifier for a subscription installed via
82/// [`crate::BuildContext::subscribe_event`].
83///
84/// Used internally to look up the UI-side callback when a posted event
85/// arrives back on the UI thread, and to key the per-widget cleanup scope.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub struct SubscriptionId(pub(crate) u64);
88
89/// Posts events from background threads back to the UI thread.
90///
91/// teksilo-core cannot depend on winit or teksilo-app, so this trait acts as the
92/// boundary: teksilo-app provides an implementation that wraps the
93/// `EventLoopProxy<AppEvent>` and converts the calls into the
94/// matching `AppEvent::*` user-event variants.
95///
96/// Two posting paths share this trait:
97///
98/// - `post_subscription_event` — backend events for widgets that
99///   subscribed via [`BuildContext::subscribe_event`](crate::build_context::BuildContext).
100/// - `post_external` — arbitrary typed payloads delivered as
101///   [`AppEvent::External`](crate::app_event::AppEvent::External). Used
102///   by async OS-driven integrations (file dialogs, future background
103///   tasks) that resolve off the UI thread and need to deliver typed
104///   results back to the main loop.
105pub trait AppEventPoster: Send + Sync + 'static {
106    fn post_subscription_event(&self, sub_id: SubscriptionId, event: Box<dyn Any + Send>);
107
108    /// Post an arbitrary typed payload as `AppEvent::External(_)`.
109    /// Default body is a no-op so existing implementations stay
110    /// source-compatible; the real implementation in teksilo-app
111    /// forwards to `EventLoopProxy::send_event`.
112    fn post_external(&self, _payload: Box<dyn Any + Send>) {}
113}
114
115/// Type-erased wrapper around a registered [`EventSource`].
116///
117/// The generic source `S` is consumed when the adapter is constructed via
118/// [`EventSourceAdapter::new`]; only erased closures and `TypeId`s remain.
119/// This lets `WidgetTree` / `BuildContext` reach the source without becoming
120/// generic over `S`.
121pub struct EventSourceAdapter {
122    pub(crate) origin_type: TypeId,
123    pub(crate) origin_type_name: &'static str,
124    pub(crate) event_type: TypeId,
125    pub(crate) event_type_name: &'static str,
126    #[allow(clippy::type_complexity)]
127    pub(crate) subscribe_fn: Box<
128        dyn Fn(
129            Box<dyn Any>,
130            Arc<dyn Fn(Box<dyn Any + Send>) + Send + Sync + 'static>,
131        ) -> SubscriptionHandle,
132    >,
133}
134
135impl EventSourceAdapter {
136    /// Build an adapter from a concrete event source. Called by
137    /// `TeksiloAppBuilder::event_source`.
138    pub fn new<S: EventSource>(source: S) -> Self {
139        let source = Arc::new(source);
140        let origin_type = TypeId::of::<S::Origin>();
141        let origin_type_name = std::any::type_name::<S::Origin>();
142        let event_type = TypeId::of::<S::Event>();
143        let event_type_name = std::any::type_name::<S::Event>();
144
145        let subscribe_fn: Box<
146            dyn Fn(
147                Box<dyn Any>,
148                Arc<dyn Fn(Box<dyn Any + Send>) + Send + Sync + 'static>,
149            ) -> SubscriptionHandle,
150        > = Box::new(move |erased_origin, framework_wrapper| {
151            let origin: Box<S::Origin> = erased_origin
152                .downcast::<S::Origin>()
153                .expect("origin type mismatch — framework bug");
154
155            // Wrap the framework's `Fn(Box<dyn Any + Send>)` into the
156            // `Fn(S::Event)` shape the source expects. The wrapper boxes
157            // the typed event and forwards to the framework's poster.
158            let typed_callback: Arc<dyn Fn(S::Event) + Send + Sync + 'static> =
159                Arc::new(move |event: S::Event| {
160                    let erased: Box<dyn Any + Send> = Box::new(event);
161                    framework_wrapper(erased);
162                });
163
164            source.subscribe(*origin, typed_callback)
165        });
166
167        Self {
168            origin_type,
169            origin_type_name,
170            event_type,
171            event_type_name,
172            subscribe_fn,
173        }
174    }
175}
176
177/// UI-side callback for a *context-bearing* subscription
178/// ([`BuildContext::subscribe_event_with_ctx`](crate::build_context::BuildContext::subscribe_event_with_ctx)):
179/// it receives the downcast event **and** a fresh [`EventContext`], so it can
180/// imperatively drive toasts, modals, intents and navigation in reaction to a
181/// backend event — the things a plain, context-free `subscribe_event` callback
182/// cannot. teksilo-app invokes it from inside
183/// [`WidgetTree::run_with_event_context`](crate::WidgetTree::run_with_event_context),
184/// keyed by the originating window so the context binds to the right tree.
185///
186/// Stored behind `Rc` (not `Box`) so dispatch can **clone the handle and release
187/// the map borrow before invoking** — the callback runs arbitrary UI code (it
188/// may `open_window`, which synchronously builds a new tree whose widgets can
189/// register *their own* context-bearing subscriptions, i.e. re-enter this very
190/// map). Holding the borrow across the call would `BorrowMutError`-panic there.
191type CtxSubscriptionCallback = Rc<dyn Fn(&dyn Any, &mut EventContext)>;
192
193/// Per-tree app-level subscription state.
194///
195/// Held by `WidgetTree` as `Rc<TreeAppContext>` so that `BuildContext` can
196/// reach the event source adapter, allocate subscription ids, and post the
197/// UI-side callback into the lookup map. Fields are `RefCell`/`Cell`
198/// because the tree borrows itself mutably during `build()` and we need
199/// shared interior access.
200pub struct TreeAppContext {
201    pub(crate) poster: Option<Arc<dyn AppEventPoster>>,
202    pub(crate) event_source: Option<EventSourceAdapter>,
203    /// Plain (context-free) subscription callbacks and the window each was registered
204    /// from. Populated by
205    /// [`BuildContext::subscribe_event`](crate::build_context::BuildContext::subscribe_event).
206    ///
207    /// The window is recorded for the same reason `subscription_ctx_callbacks` below
208    /// records one: this map is shared by every window's tree (one `TreeAppContext`, one
209    /// `Rc` handed to each tree), while a closing window's tree is dropped wholesale with
210    /// no per-widget destroy pass. Without a window key the entries a closed window
211    /// installed could not be purged even in principle, and each would hold whatever its
212    /// closure captured for the rest of the process. See
213    /// [`purge_subscriptions_for_window`](Self::purge_subscriptions_for_window).
214    ///
215    /// `None` for a registration from a windowless tree (headless / tests). No window
216    /// purge ever touches those; only the per-widget teardown in `WidgetTree` does.
217    #[allow(clippy::type_complexity)]
218    pub(crate) subscription_callbacks:
219        RefCell<HashMap<SubscriptionId, (Option<TeksiloWindowId>, Rc<dyn Fn(&dyn Any)>)>>,
220    /// Context-bearing subscription callbacks + the window they target.
221    /// Populated by [`BuildContext::subscribe_event_with_ctx`](crate::build_context::BuildContext::subscribe_event_with_ctx);
222    /// dispatched by teksilo-app with a freshly-minted [`EventContext`]. A given
223    /// `SubscriptionId` lives in exactly one of the two callback maps.
224    ///
225    /// The window is `Option` because a subscription registered from a windowless
226    /// tree (headless / tests) has no tree to mint an `EventContext` from — the
227    /// app-side router then can't deliver it (real app widgets always have a
228    /// window). Direct [`dispatch_subscription_event_with_ctx`](Self::dispatch_subscription_event_with_ctx)
229    /// is window-agnostic, which is what unit tests drive.
230    #[allow(clippy::type_complexity)]
231    pub(crate) subscription_ctx_callbacks:
232        RefCell<HashMap<SubscriptionId, (Option<TeksiloWindowId>, CtxSubscriptionCallback)>>,
233    pub(crate) next_subscription_id: Cell<u64>,
234    /// Application-scoped values keyed by `TypeId`.
235    /// Populated at builder time, read-only after the tree starts running.
236    pub(crate) app_state: HashMap<TypeId, Box<dyn Any>>,
237}
238
239impl TreeAppContext {
240    /// Empty context — no event source, no proxy poster. Used by tests
241    /// and by `WidgetTree::new()`.
242    pub fn empty() -> Self {
243        Self {
244            poster: None,
245            event_source: None,
246            subscription_callbacks: RefCell::new(HashMap::new()),
247            subscription_ctx_callbacks: RefCell::new(HashMap::new()),
248            next_subscription_id: Cell::new(1),
249            app_state: HashMap::new(),
250        }
251    }
252
253    /// Build a context with both a registered event source and a proxy
254    /// poster. Called by `teksilo-app` when constructing a window for an
255    /// application that registered an event source on the builder.
256    pub fn with_source_and_poster(
257        event_source: EventSourceAdapter,
258        poster: Arc<dyn AppEventPoster>,
259    ) -> Self {
260        Self {
261            poster: Some(poster),
262            event_source: Some(event_source),
263            subscription_callbacks: RefCell::new(HashMap::new()),
264            subscription_ctx_callbacks: RefCell::new(HashMap::new()),
265            next_subscription_id: Cell::new(1),
266            app_state: HashMap::new(),
267        }
268    }
269
270    /// Install an app-state registry. Consumes `self`
271    /// and returns a new context with the registry attached; the builder
272    /// calls this after constructing the context and before wrapping it
273    /// in `Rc`.
274    pub fn with_app_state(mut self, registry: HashMap<TypeId, Box<dyn Any>>) -> Self {
275        self.app_state = registry;
276        self
277    }
278
279    /// Install an [`AppEventPoster`] so background work (file dialogs,
280    /// future async-result features) can post typed payloads back to
281    /// the UI loop via `AppEvent::External`. The builder calls this
282    /// unconditionally during `TeksiloAppBuilder::run` — the poster is
283    /// cheap (a thin wrapper around the event-loop proxy) and being
284    /// reachable means widgets do not have to depend on the event-source
285    /// feature for unrelated async-result delivery.
286    pub fn with_poster(mut self, poster: Arc<dyn AppEventPoster>) -> Self {
287        self.poster = Some(poster);
288        self
289    }
290
291    /// Borrow the registered [`AppEventPoster`] if one was installed.
292    /// Used by integrations that need to post typed payloads back to
293    /// the UI loop from an external thread (e.g. file-dialog backends).
294    pub fn poster(&self) -> Option<&Arc<dyn AppEventPoster>> {
295        self.poster.as_ref()
296    }
297
298    /// Look up an app-state value of type `T` previously registered via
299    /// `TeksiloAppBuilder::app_state`.
300    pub fn app_state<T: 'static>(&self) -> Option<&T> {
301        self.app_state
302            .get(&TypeId::of::<T>())
303            .and_then(|boxed| boxed.downcast_ref::<T>())
304    }
305
306    pub(crate) fn allocate_subscription_id(&self) -> SubscriptionId {
307        let id = self.next_subscription_id.get();
308        self.next_subscription_id.set(id + 1);
309        SubscriptionId(id)
310    }
311
312    /// Look up and invoke the UI-side callback for a posted subscription
313    /// event. Returns `true` if a callback was found and invoked.
314    /// The `Rc` handle is **cloned and the map borrow released before the call**, for
315    /// the same reason [`dispatch_subscription_event_with_ctx`](Self::dispatch_subscription_event_with_ctx)
316    /// does it: the callback may re-enter this map. It re-enters on two paths that both
317    /// exist today — a widget built from inside a handler registers its own
318    /// subscription (`borrow_mut` to insert), and a window closed from inside one is
319    /// purged by [`purge_subscriptions_for_window`](Self::purge_subscriptions_for_window)
320    /// (`borrow_mut` to retain). Holding the borrow across the call turns either into a
321    /// `BorrowMutError` panic; hence `Rc`, not `Box`.
322    pub fn dispatch_subscription_event(&self, sub_id: SubscriptionId, event: &dyn Any) -> bool {
323        let callback = self
324            .subscription_callbacks
325            .borrow()
326            .get(&sub_id)
327            .map(|(_window_id, callback)| Rc::clone(callback));
328        match callback {
329            Some(callback) => {
330                callback(event);
331                true
332            }
333            None => false,
334        }
335    }
336
337    /// The window a *context-bearing* subscription targets, if `sub_id` names one
338    /// **and** it was registered from a window (always true in a real app).
339    /// teksilo-app peeks this to know which window's tree to mint the
340    /// [`EventContext`] from before dispatching.
341    pub fn ctx_subscription_window(&self, sub_id: SubscriptionId) -> Option<TeksiloWindowId> {
342        self.subscription_ctx_callbacks
343            .borrow()
344            .get(&sub_id)
345            .and_then(|(window_id, _)| *window_id)
346    }
347
348    /// Invoke the *context-bearing* UI-side callback for a posted subscription
349    /// event, passing the freshly-minted [`EventContext`]. Returns `true` if a
350    /// callback was found and invoked. Called by teksilo-app from inside
351    /// [`WidgetTree::run_with_event_context`](crate::WidgetTree::run_with_event_context).
352    ///
353    /// The `Rc` handle is **cloned and the map borrow released before the call**,
354    /// so the callback may freely re-enter this map — e.g. `ctx.open_window(...)`
355    /// synchronously builds a new tree whose widgets register their own
356    /// context-bearing subscriptions. (Holding the borrow across the call would
357    /// panic there; hence `Rc`, not `Box`.)
358    pub fn dispatch_subscription_event_with_ctx(
359        &self,
360        sub_id: SubscriptionId,
361        event: &dyn Any,
362        ctx: &mut EventContext,
363    ) -> bool {
364        let callback = self
365            .subscription_ctx_callbacks
366            .borrow()
367            .get(&sub_id)
368            .map(|(_window_id, callback)| Rc::clone(callback));
369        match callback {
370            Some(callback) => {
371                callback(event, ctx);
372                true
373            }
374            None => false,
375        }
376    }
377
378    /// Number of context-bearing subscription callbacks currently installed.
379    /// Companion to [`subscription_count`](Self::subscription_count); used by
380    /// lifecycle tests to assert the ctx-map teardown ran.
381    pub fn ctx_subscription_count(&self) -> usize {
382        self.subscription_ctx_callbacks.borrow().len()
383    }
384
385    /// Drop every context-bearing subscription targeting `window_id`. Called by
386    /// teksilo-app when a window closes, so the shared, longer-lived
387    /// `TreeAppContext` map doesn't retain inert callbacks for a torn-down tree
388    /// (a window's tree is dropped without a per-widget `destroy_subtree` pass).
389    /// Mirrors [`AsyncCompletionHandle::purge_window`](crate::AsyncCompletionHandle::purge_window).
390    pub fn purge_ctx_subscriptions_for_window(&self, window_id: TeksiloWindowId) {
391        self.subscription_ctx_callbacks
392            .borrow_mut()
393            .retain(|_, (win, _)| *win != Some(window_id));
394    }
395
396    /// Drop every *plain* (context-free) subscription callback registered from
397    /// `window_id`. Called by teksilo-app when a window closes, beside
398    /// [`purge_ctx_subscriptions_for_window`](Self::purge_ctx_subscriptions_for_window).
399    ///
400    /// The two maps need two purges for the same reason they need two dispatch
401    /// functions: a given `SubscriptionId` lives in exactly one of them. Without this
402    /// one, every callback the closed window's widgets installed stays in the shared map
403    /// for the life of the process, holding strong references to whatever it captured
404    /// (view-models, document stores, context handles), because a closing window's tree
405    /// is dropped wholesale and nothing runs the per-widget removal in
406    /// `WidgetTree::destroy_subtree_inner`.
407    ///
408    /// A registration made from a windowless tree records `None` and is never purged
409    /// here; only the per-widget teardown reaches it, which an application drives
410    /// through [`BuildContext::destroy_subtree`](crate::BuildContext::destroy_subtree).
411    pub fn purge_subscriptions_for_window(&self, window_id: TeksiloWindowId) {
412        self.subscription_callbacks
413            .borrow_mut()
414            .retain(|_, (win, _)| *win != Some(window_id));
415    }
416
417    /// Number of UI-side subscription callbacks currently installed.
418    /// Used by lifecycle tests to assert cleanup ran correctly.
419    pub fn subscription_count(&self) -> usize {
420        self.subscription_callbacks.borrow().len()
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::signal::Signal;
428    use crate::widget::{LayoutContext, Widget};
429    use crate::widget_id::WidgetId;
430    use crate::widget_tree::WidgetTree;
431    use std::sync::Mutex;
432    use teksilo_canvas::SizeProposal;
433
434    // --- Test event source ---
435
436    #[derive(Clone, PartialEq, Eq, Hash, Debug)]
437    enum TestOrigin {
438        Created,
439        Updated,
440    }
441
442    #[derive(Clone, Debug, PartialEq)]
443    struct TestEvent {
444        id: u64,
445        message: String,
446    }
447
448    /// A trivial in-process event source. Holds a list of (id, origin, callback)
449    /// entries; `publish` walks them and invokes matching callbacks
450    /// synchronously on the calling thread.
451    ///
452    /// ⚠ **Its handle really unsubscribes**, which is not decoration. A source that
453    /// returns [`SubscriptionHandle::empty`] keeps every subscriber it was ever given,
454    /// so one publish reaches the wrappers of *all* of a widget's past builds. That used
455    /// to be invisible, because each of those wrappers posted a by-then-dead id and the
456    /// dispatch dropped it; now that an id outlives a rebuild it would deliver the same
457    /// event once per past build. A mock that never removes anything would model a
458    /// source no real one resembles and would make this suite assert the wrong thing.
459    #[derive(Default)]
460    struct MockEventSource {
461        #[allow(clippy::type_complexity)]
462        subscribers: Arc<
463            Mutex<
464                Vec<(
465                    u64,
466                    TestOrigin,
467                    Arc<dyn Fn(TestEvent) + Send + Sync + 'static>,
468                )>,
469            >,
470        >,
471        next_id: std::sync::atomic::AtomicU64,
472    }
473
474    /// Removes its entry from [`MockEventSource`] on drop, the way a real source's
475    /// token does.
476    struct MockToken {
477        #[allow(clippy::type_complexity)]
478        subscribers: Arc<
479            Mutex<
480                Vec<(
481                    u64,
482                    TestOrigin,
483                    Arc<dyn Fn(TestEvent) + Send + Sync + 'static>,
484                )>,
485            >,
486        >,
487        id: u64,
488    }
489
490    impl Drop for MockToken {
491        fn drop(&mut self) {
492            if let Ok(mut subs) = self.subscribers.lock() {
493                subs.retain(|(id, _, _)| *id != self.id);
494            }
495        }
496    }
497
498    impl EventSource for MockEventSource {
499        type Origin = TestOrigin;
500        type Event = TestEvent;
501
502        fn subscribe(
503            &self,
504            origin: Self::Origin,
505            callback: Arc<dyn Fn(Self::Event) + Send + Sync + 'static>,
506        ) -> SubscriptionHandle {
507            let id = self
508                .next_id
509                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
510            self.subscribers
511                .lock()
512                .unwrap()
513                .push((id, origin, callback));
514            SubscriptionHandle::new(MockToken {
515                subscribers: self.subscribers.clone(),
516                id,
517            })
518        }
519    }
520
521    impl MockEventSource {
522        fn publish(&self, origin: TestOrigin, event: TestEvent) {
523            let subs = self.subscribers.lock().unwrap();
524            for (_id, sub_origin, cb) in subs.iter() {
525                if *sub_origin == origin {
526                    cb(event.clone());
527                }
528            }
529        }
530
531        fn subscriber_count(&self) -> usize {
532            self.subscribers.lock().unwrap().len()
533        }
534    }
535
536    /// A poster that buffers posted events into a thread-safe queue. Tests
537    /// drain it after `publish` and dispatch them through the tree's
538    /// app_context, mirroring the real proxy → user_event flow.
539    #[derive(Default)]
540    struct TestPoster {
541        #[allow(clippy::type_complexity)]
542        queue: Mutex<Vec<(SubscriptionId, Box<dyn Any + Send>)>>,
543    }
544
545    impl AppEventPoster for TestPoster {
546        fn post_subscription_event(&self, sub_id: SubscriptionId, event: Box<dyn Any + Send>) {
547            self.queue.lock().unwrap().push((sub_id, event));
548        }
549    }
550
551    impl TestPoster {
552        fn drain(&self) -> Vec<(SubscriptionId, Box<dyn Any + Send>)> {
553            std::mem::take(&mut *self.queue.lock().unwrap())
554        }
555    }
556
557    // --- Test widget that subscribes in build() ---
558
559    #[derive(Debug)]
560    struct SubscribingWidget {
561        origin: TestOrigin,
562        last_message: Signal<String>,
563    }
564
565    impl Widget for SubscribingWidget {
566        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
567            let last_message = self.last_message.clone();
568            ctx.subscribe_event(self.origin.clone(), move |event: &TestEvent| {
569                last_message.set(event.message.clone());
570            });
571            Vec::new()
572        }
573
574        fn layout_response(
575            &self,
576            proposal: SizeProposal,
577            _ctx: &LayoutContext,
578        ) -> crate::widget::LayoutResponse {
579            proposal.resolve(0.0, 0.0).into()
580        }
581    }
582
583    /// Subscribes via the *context-bearing* API in `build()` — headless, so the
584    /// registration records `None` for the window but still lands in the ctx map
585    /// and is torn down on destroy.
586    #[derive(Debug)]
587    struct CtxSubscribingWidget {
588        origin: TestOrigin,
589        last_message: Signal<String>,
590    }
591
592    impl Widget for CtxSubscribingWidget {
593        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
594            let last_message = self.last_message.clone();
595            ctx.subscribe_event_with_ctx(
596                self.origin.clone(),
597                move |event: &TestEvent, _ctx: &mut crate::widget::EventContext| {
598                    last_message.set(event.message.clone());
599                },
600            );
601            Vec::new()
602        }
603
604        fn layout_response(
605            &self,
606            proposal: SizeProposal,
607            _ctx: &LayoutContext,
608        ) -> crate::widget::LayoutResponse {
609            proposal.resolve(0.0, 0.0).into()
610        }
611    }
612
613    // --- Helpers ---
614
615    fn install_source(
616        tree: &mut WidgetTree,
617        source: MockEventSource,
618    ) -> (Arc<MockEventSource>, Arc<TestPoster>) {
619        let source = Arc::new(source);
620        // We need to share the source between the test and the adapter,
621        // so wrap a thin proxy that delegates to the Arc.
622        struct SharedSource {
623            inner: Arc<MockEventSource>,
624        }
625        impl EventSource for SharedSource {
626            type Origin = TestOrigin;
627            type Event = TestEvent;
628            fn subscribe(
629                &self,
630                origin: Self::Origin,
631                callback: Arc<dyn Fn(Self::Event) + Send + Sync + 'static>,
632            ) -> SubscriptionHandle {
633                self.inner.subscribe(origin, callback)
634            }
635        }
636
637        let adapter = EventSourceAdapter::new(SharedSource {
638            inner: source.clone(),
639        });
640        let poster: Arc<TestPoster> = Arc::new(TestPoster::default());
641        let poster_dyn: Arc<dyn AppEventPoster> = poster.clone();
642        let app_context =
643            std::rc::Rc::new(TreeAppContext::with_source_and_poster(adapter, poster_dyn));
644        tree.set_app_context(app_context);
645        (source, poster)
646    }
647
648    fn drain_and_dispatch(tree: &WidgetTree, poster: &TestPoster) {
649        let events = poster.drain();
650        for (sub_id, event) in events {
651            tree.app_context()
652                .dispatch_subscription_event(sub_id, &*event);
653        }
654    }
655
656    // --- Tests ---
657
658    #[test]
659    fn subscribe_event_delivers_to_widget_signal() {
660        let mut tree = WidgetTree::new();
661        let (source, poster) = install_source(&mut tree, MockEventSource::default());
662
663        let signal = Signal::new(String::new());
664        let _id = tree.add(SubscribingWidget {
665            origin: TestOrigin::Created,
666            last_message: signal.clone(),
667        });
668
669        assert_eq!(source.subscriber_count(), 1);
670        assert_eq!(tree.app_context().subscription_count(), 1);
671
672        source.publish(
673            TestOrigin::Created,
674            TestEvent {
675                id: 1,
676                message: "hello".to_string(),
677            },
678        );
679        drain_and_dispatch(&tree, &poster);
680
681        assert_eq!(signal.get(), "hello");
682    }
683
684    #[test]
685    fn subscribe_event_with_ctx_dispatches_inside_fresh_context() {
686        use crate::window::{NoopWindowOps, TeksiloWindowId};
687
688        let mut tree = WidgetTree::new();
689        // Register a context-bearing callback the way
690        // `BuildContext::subscribe_event_with_ctx` does — but directly, so the
691        // test needs no real window (that routing is covered end-to-end by the
692        // `toast_demo` example and the Skribisto importer).
693        let app_ctx = tree.app_context().clone();
694        let sub_id = app_ctx.allocate_subscription_id();
695        let win = TeksiloWindowId::new(1);
696        let seen = Signal::new(String::new());
697        let seen_cb = seen.clone();
698        let stored: std::rc::Rc<dyn Fn(&dyn Any, &mut crate::widget::EventContext)> =
699            std::rc::Rc::new(move |event_any, _ctx: &mut crate::widget::EventContext| {
700                let ev = event_any
701                    .downcast_ref::<TestEvent>()
702                    .expect("subscription event downcast failed");
703                seen_cb.set(ev.message.clone());
704            });
705        app_ctx
706            .subscription_ctx_callbacks
707            .borrow_mut()
708            .insert(sub_id, (Some(win), stored));
709
710        // The target window is peekable (teksilo-app reads it to pick the tree
711        // whose `EventContext` it mints).
712        assert_eq!(app_ctx.ctx_subscription_window(sub_id), Some(win));
713        assert_eq!(app_ctx.ctx_subscription_window(SubscriptionId(9999)), None);
714
715        // Dispatch inside a fresh `EventContext`, exactly like teksilo-app's
716        // `try_dispatch_subscription_with_ctx`.
717        let event = TestEvent {
718            id: 9,
719            message: "progress-42".to_string(),
720        };
721        let handled = std::cell::Cell::new(false);
722        tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
723            handled.set(app_ctx.dispatch_subscription_event_with_ctx(sub_id, &event, ctx));
724        });
725        assert!(
726            handled.get(),
727            "context-bearing dispatch must find the callback"
728        );
729        assert_eq!(seen.get(), "progress-42");
730
731        // An unknown sub_id is not consumed (so the caller falls back to the
732        // plain, context-free path).
733        tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
734            assert!(!app_ctx.dispatch_subscription_event_with_ctx(
735                SubscriptionId(9999),
736                &event,
737                ctx
738            ));
739        });
740    }
741
742    /// Regression for the re-entrancy panic: dispatch must clone the `Rc` and
743    /// **release the map borrow before invoking** the callback, so a callback
744    /// that re-enters the same map — as `ctx.open_window(...)` does via a nested
745    /// `build()` calling `subscribe_event_with_ctx` — does not `BorrowMutError`.
746    #[test]
747    fn ctx_dispatch_releases_borrow_before_invoking_callback() {
748        use crate::window::{NoopWindowOps, TeksiloWindowId};
749
750        let mut tree = WidgetTree::new();
751        let app_ctx = tree.app_context().clone();
752        let sub_id = app_ctx.allocate_subscription_id();
753
754        let reenter_ctx = app_ctx.clone();
755        let reentered = std::rc::Rc::new(std::cell::Cell::new(false));
756        let flag = reentered.clone();
757        let cb: std::rc::Rc<dyn Fn(&dyn Any, &mut crate::widget::EventContext)> =
758            std::rc::Rc::new(move |_ev, _ctx| {
759                // Simulate open_window → build() → subscribe_event_with_ctx: a
760                // fresh registration into the SAME map while this callback runs.
761                reenter_ctx.subscription_ctx_callbacks.borrow_mut().insert(
762                    SubscriptionId(4242),
763                    (
764                        Some(TeksiloWindowId::new(2)),
765                        std::rc::Rc::new(|_e: &dyn Any, _c: &mut crate::widget::EventContext| {}),
766                    ),
767                );
768                flag.set(true);
769            });
770        app_ctx
771            .subscription_ctx_callbacks
772            .borrow_mut()
773            .insert(sub_id, (Some(TeksiloWindowId::new(1)), cb));
774
775        let event = TestEvent {
776            id: 1,
777            message: String::new(),
778        };
779        tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
780            assert!(app_ctx.dispatch_subscription_event_with_ctx(sub_id, &event, ctx));
781        });
782
783        assert!(
784            reentered.get(),
785            "callback ran and its re-entrant map insert did not panic"
786        );
787        assert_eq!(
788            app_ctx.ctx_subscription_count(),
789            2,
790            "original + the re-entrant insert both present"
791        );
792    }
793
794    /// Exercises the real `BuildContext::subscribe_event_with_ctx` (headless →
795    /// window `None`) end-to-end: registration lands in the ctx map, and
796    /// destroying the widget tears it back down (covers the widget-destroy path's
797    /// removal from the ctx map).
798    #[test]
799    fn subscribe_event_with_ctx_registers_and_tears_down() {
800        let mut tree = WidgetTree::new();
801        let (_source, _poster) = install_source(&mut tree, MockEventSource::default());
802
803        let id = tree.add(CtxSubscribingWidget {
804            origin: TestOrigin::Created,
805            last_message: Signal::new(String::new()),
806        });
807        // The ctx path uses the OTHER map — the plain count stays 0.
808        assert_eq!(tree.app_context().ctx_subscription_count(), 1);
809        assert_eq!(tree.app_context().subscription_count(), 0);
810
811        tree.destroy_subtree(id);
812        assert_eq!(
813            tree.app_context().ctx_subscription_count(),
814            0,
815            "destroying the widget must remove its context-bearing subscription"
816        );
817    }
818
819    #[test]
820    fn unrelated_origin_does_not_fire_callback() {
821        let mut tree = WidgetTree::new();
822        let (source, poster) = install_source(&mut tree, MockEventSource::default());
823
824        let signal = Signal::new(String::new());
825        let _id = tree.add(SubscribingWidget {
826            origin: TestOrigin::Created,
827            last_message: signal.clone(),
828        });
829
830        source.publish(
831            TestOrigin::Updated,
832            TestEvent {
833                id: 1,
834                message: "ignored".to_string(),
835            },
836        );
837        drain_and_dispatch(&tree, &poster);
838
839        assert_eq!(signal.get(), "");
840    }
841
842    #[test]
843    fn destroying_widget_removes_ui_callback() {
844        let mut tree = WidgetTree::new();
845        let (_source, _poster) = install_source(&mut tree, MockEventSource::default());
846
847        let signal = Signal::new(String::new());
848        let id = tree.add(SubscribingWidget {
849            origin: TestOrigin::Created,
850            last_message: signal.clone(),
851        });
852
853        assert_eq!(tree.app_context().subscription_count(), 1);
854        tree.destroy_subtree(id);
855        assert_eq!(tree.app_context().subscription_count(), 0);
856    }
857
858    /// The window-closing twin of [`destroying_widget_removes_ui_callback`].
859    ///
860    /// Closing a window drops its whole tree at once: nothing calls `destroy_subtree`,
861    /// so the per-widget removal the test above pins never runs for a single one of the
862    /// widgets that window built. The callback map is shared by every window in the
863    /// process (one `TreeAppContext`, one `Rc` per tree), so without a window key those
864    /// closures stay live for the rest of the session holding everything they captured.
865    /// `purge_subscriptions_for_window` is what the app-side close path calls instead,
866    /// and this test is the only place that says so about the plain map.
867    ///
868    /// Three trees stand in for the three cases that must be told apart: the window
869    /// being closed, a window that stays open, and a windowless (headless) registration
870    /// that no window purge may ever touch.
871    #[test]
872    fn closing_a_window_removes_its_ui_callbacks() {
873        use crate::WindowStateInit;
874        use crate::window::{TeksiloWindowId, WindowPlacement, WindowState};
875
876        fn window_state(id: u64) -> WindowState {
877            WindowState::new(WindowStateInit {
878                id: TeksiloWindowId::new(id),
879                string_id: None,
880                placement: WindowPlacement::Floating,
881                title: String::new(),
882                size: (800, 600),
883                position: (0, 0),
884                focused: true,
885                resizable: true,
886                always_on_top: false,
887            })
888        }
889
890        // Window 1 installs the shared context; window 2 and the headless tree get a
891        // clone of the same `Rc`, exactly as `WindowManager` hands out its
892        // `app_context_template`.
893        let mut tree_one = WidgetTree::new();
894        tree_one.set_window_state(window_state(1));
895        let (source, poster) = install_source(&mut tree_one, MockEventSource::default());
896
897        let mut tree_two = WidgetTree::new();
898        tree_two.set_window_state(window_state(2));
899        tree_two.set_app_context(tree_one.app_context().clone());
900
901        let mut tree_headless = WidgetTree::new();
902        tree_headless.set_app_context(tree_one.app_context().clone());
903
904        let one = Signal::new(String::new());
905        let two = Signal::new(String::new());
906        let headless = Signal::new(String::new());
907        tree_one.add(SubscribingWidget {
908            origin: TestOrigin::Created,
909            last_message: one.clone(),
910        });
911        tree_two.add(SubscribingWidget {
912            origin: TestOrigin::Created,
913            last_message: two.clone(),
914        });
915        tree_headless.add(SubscribingWidget {
916            origin: TestOrigin::Created,
917            last_message: headless.clone(),
918        });
919
920        let app_ctx = tree_one.app_context().clone();
921        assert_eq!(app_ctx.subscription_count(), 3);
922        assert_eq!(source.subscriber_count(), 3);
923
924        // Close window 1 the way `WindowManager::close_window` does: purge first, then
925        // drop the tree (which drops the arena's subscription handles and so
926        // unregisters that window from the source).
927        app_ctx.purge_subscriptions_for_window(TeksiloWindowId::new(1));
928        drop(tree_one);
929
930        assert_eq!(
931            app_ctx.subscription_count(),
932            2,
933            "the closed window's callback must be gone, and neither the other window's \
934             nor the windowless one may go with it"
935        );
936        assert_eq!(
937            source.subscriber_count(),
938            2,
939            "dropping the tree unregisters the closed window from the source"
940        );
941
942        source.publish(
943            TestOrigin::Created,
944            TestEvent {
945                id: 1,
946                message: "after the close".to_string(),
947            },
948        );
949        drain_and_dispatch(&tree_two, &poster);
950
951        assert_eq!(one.get(), "", "a closed window's callback must not run");
952        assert_eq!(
953            two.get(),
954            "after the close",
955            "a window that stayed open keeps receiving"
956        );
957        assert_eq!(
958            headless.get(),
959            "after the close",
960            "a windowless registration is not purged by any window id"
961        );
962
963        // And the surviving window purges on its own close, leaving only the
964        // windowless entry, which nothing but a widget destroy can reach.
965        app_ctx.purge_subscriptions_for_window(TeksiloWindowId::new(2));
966        assert_eq!(app_ctx.subscription_count(), 1);
967    }
968
969    #[test]
970    fn in_flight_event_after_destroy_is_dropped_not_delivered() {
971        // An event that was buffered in the proxy queue before the widget
972        // was destroyed is silently dropped once cleanup completes. The
973        // destroy path removes the UI-side callback synchronously, so by
974        // the time the drain happens the callback lookup misses. This
975        // preserves the invariant that a destroyed widget never sees
976        // another event.
977        let mut tree = WidgetTree::new();
978        let (source, poster) = install_source(&mut tree, MockEventSource::default());
979
980        let signal = Signal::new(String::new());
981        let id = tree.add(SubscribingWidget {
982            origin: TestOrigin::Created,
983            last_message: signal.clone(),
984        });
985
986        // Publish — the wrapper fires and enqueues into the test poster.
987        source.publish(
988            TestOrigin::Created,
989            TestEvent {
990                id: 7,
991                message: "buffered".to_string(),
992            },
993        );
994
995        tree.destroy_subtree(id);
996        drain_and_dispatch(&tree, &poster);
997
998        assert_eq!(signal.get(), "");
999        assert_eq!(tree.app_context().subscription_count(), 0);
1000    }
1001
1002    #[test]
1003    #[should_panic(expected = "no event source was registered")]
1004    fn subscribe_without_event_source_panics() {
1005        let mut tree = WidgetTree::new();
1006        let signal = Signal::new(String::new());
1007        // No install_source — tree has the empty default app context.
1008        tree.add(SubscribingWidget {
1009            origin: TestOrigin::Created,
1010            last_message: signal,
1011        });
1012    }
1013
1014    // --- app_state tests (architecture §9.5) ---
1015
1016    use std::rc::Rc;
1017
1018    struct TestGlobals {
1019        greeting: Signal<String>,
1020    }
1021
1022    /// Widget that reads `Rc<TestGlobals>` from app_state in `build()` and
1023    /// records what it observed into an out-of-band signal.
1024    #[derive(Debug)]
1025    struct AppStateReader {
1026        observed: Signal<String>,
1027        saw_none: Signal<bool>,
1028    }
1029
1030    impl Widget for AppStateReader {
1031        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1032            match ctx.app_state::<Rc<TestGlobals>>() {
1033                Some(globals) => self.observed.set(globals.greeting.get()),
1034                None => self.saw_none.set(true),
1035            }
1036            Vec::new()
1037        }
1038
1039        fn layout_response(
1040            &self,
1041            proposal: SizeProposal,
1042            _ctx: &LayoutContext,
1043        ) -> crate::widget::LayoutResponse {
1044            proposal.resolve(0.0, 0.0).into()
1045        }
1046    }
1047
1048    #[test]
1049    fn app_state_roundtrip_in_build_context() {
1050        let globals = Rc::new(TestGlobals {
1051            greeting: Signal::new("hello from registry".to_string()),
1052        });
1053
1054        let mut registry: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1055        registry.insert(TypeId::of::<Rc<TestGlobals>>(), Box::new(globals.clone()));
1056
1057        let mut tree = WidgetTree::new();
1058        tree.set_app_context(Rc::new(TreeAppContext::empty().with_app_state(registry)));
1059
1060        let observed = Signal::new(String::new());
1061        let saw_none = Signal::new(false);
1062        tree.add(AppStateReader {
1063            observed: observed.clone(),
1064            saw_none: saw_none.clone(),
1065        });
1066
1067        assert_eq!(observed.get(), "hello from registry");
1068        assert!(!saw_none.get());
1069    }
1070
1071    #[test]
1072    fn app_state_missing_returns_none() {
1073        let mut tree = WidgetTree::new();
1074        // No app_state installed — tree has the empty default app context.
1075
1076        let observed = Signal::new(String::new());
1077        let saw_none = Signal::new(false);
1078        tree.add(AppStateReader {
1079            observed: observed.clone(),
1080            saw_none: saw_none.clone(),
1081        });
1082
1083        assert_eq!(observed.get(), "");
1084        assert!(saw_none.get());
1085    }
1086
1087    #[test]
1088    fn app_state_distinct_types_coexist() {
1089        struct Alpha(u32);
1090        struct Beta(String);
1091
1092        let mut registry: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1093        registry.insert(TypeId::of::<Rc<Alpha>>(), Box::new(Rc::new(Alpha(42))));
1094        registry.insert(
1095            TypeId::of::<Rc<Beta>>(),
1096            Box::new(Rc::new(Beta("beta!".to_string()))),
1097        );
1098
1099        let ctx = TreeAppContext::empty().with_app_state(registry);
1100        assert_eq!(ctx.app_state::<Rc<Alpha>>().unwrap().0, 42);
1101        assert_eq!(ctx.app_state::<Rc<Beta>>().unwrap().0, "beta!");
1102        assert!(ctx.app_state::<Rc<u64>>().is_none());
1103    }
1104
1105    /// **An event posted before a rebuild must still reach the widget after it.**
1106    ///
1107    /// A backend event crosses two thread boundaries and a queue: the source publishes
1108    /// on its own thread, the wrapper posts an `AppEvent::SubscriptionEvent` carrying
1109    /// the `SubscriptionId` it captured at *publish* time, and the UI thread dispatches
1110    /// it some frames later. A rebuild in that gap used to be fatal — `build()` runs
1111    /// again, allocates fresh ids, and the teardown in `rebuild_single_widget` removes
1112    /// the previous build's callbacks, so the queued event named a dead id and
1113    /// `dispatch_subscription_event` dropped it on the floor and returned `false`.
1114    ///
1115    /// ⚠ The window is **not** the microsecond between dropping the source handle and
1116    /// removing the callback, which is what that function's `§9.4.5` comment reasons
1117    /// about. It is the whole span from publish to dispatch, and a widget opens it on
1118    /// itself simply by binding a signal at `BindingLevel::Rebuild` and then setting
1119    /// that signal — the ordinary documented pattern. Skribisto's Analysis pane starts
1120    /// a long operation in `build()` and sets its own state signal to `Running`, so
1121    /// whenever the operation finished inside that gap the completion was lost and the
1122    /// pane sat on "Reading the manuscript…" for the rest of the session.
1123    #[test]
1124    fn an_event_posted_before_a_rebuild_still_reaches_the_widget() {
1125        let mut tree = WidgetTree::new();
1126        let (source, poster) = install_source(&mut tree, MockEventSource::default());
1127
1128        let signal = Signal::new(String::new());
1129        let id = tree.add(SubscribingWidget {
1130            origin: TestOrigin::Created,
1131            last_message: signal.clone(),
1132        });
1133
1134        // Posted now: the queued event carries the id minted by the first build.
1135        source.publish(
1136            TestOrigin::Created,
1137            TestEvent {
1138                id: 1,
1139                message: "landed".to_string(),
1140            },
1141        );
1142
1143        // …and the widget rebuilds before the UI thread gets to it. This is exactly
1144        // what a `BindingLevel::Rebuild` binding does when its signal changes.
1145        tree.arena_mark_needs_rebuild_for_testing(id);
1146        tree.layout(SizeProposal::exact(100.0, 100.0));
1147
1148        drain_and_dispatch(&tree, &poster);
1149
1150        assert_eq!(
1151            signal.get(),
1152            "landed",
1153            "the rebuild must not swallow an event that was already in flight"
1154        );
1155    }
1156
1157    /// The same guarantee for the **context-bearing** API.
1158    ///
1159    /// `subscribe_event_with_ctx` keeps its callbacks in a second map and is dispatched
1160    /// by a different function, so it fails and has to be fixed separately from the
1161    /// plain path. It is also the API the framework documents as *the* bridge for
1162    /// long-operation progress, which is precisely the traffic this race eats.
1163    #[test]
1164    fn an_event_posted_before_a_rebuild_still_reaches_a_context_bearing_subscription() {
1165        use crate::window::NoopWindowOps;
1166
1167        let mut tree = WidgetTree::new();
1168        let (source, poster) = install_source(&mut tree, MockEventSource::default());
1169
1170        let signal = Signal::new(String::new());
1171        let id = tree.add(CtxSubscribingWidget {
1172            origin: TestOrigin::Created,
1173            last_message: signal.clone(),
1174        });
1175
1176        source.publish(
1177            TestOrigin::Created,
1178            TestEvent {
1179                id: 1,
1180                message: "landed".to_string(),
1181            },
1182        );
1183
1184        tree.arena_mark_needs_rebuild_for_testing(id);
1185        tree.layout(SizeProposal::exact(100.0, 100.0));
1186
1187        // Dispatched the way teksilo-app's `try_dispatch_subscription_with_ctx` does,
1188        // from the queue the wrapper actually posted into.
1189        let app_ctx = tree.app_context().clone();
1190        let events = poster.drain();
1191        assert!(!events.is_empty(), "the source must have posted something");
1192        for (sub_id, event) in events {
1193            tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
1194                app_ctx.dispatch_subscription_event_with_ctx(sub_id, &*event, ctx);
1195            });
1196        }
1197
1198        assert_eq!(
1199            signal.get(),
1200            "landed",
1201            "the ctx-bearing path must survive a rebuild too"
1202        );
1203    }
1204
1205    /// A widget that is genuinely **destroyed** must not have its callback fired by a
1206    /// late event, and must not leave one behind. The fix above makes a subscription's
1207    /// identity outlive a rebuild; it must not make it outlive the widget.
1208    #[test]
1209    fn an_event_posted_before_a_destroy_fires_nothing_and_leaks_nothing() {
1210        let mut tree = WidgetTree::new();
1211        let (source, poster) = install_source(&mut tree, MockEventSource::default());
1212
1213        let signal = Signal::new(String::new());
1214        let id = tree.add(SubscribingWidget {
1215            origin: TestOrigin::Created,
1216            last_message: signal.clone(),
1217        });
1218
1219        source.publish(
1220            TestOrigin::Created,
1221            TestEvent {
1222                id: 1,
1223                message: "too late".to_string(),
1224            },
1225        );
1226        tree.destroy_subtree(id);
1227        drain_and_dispatch(&tree, &poster);
1228
1229        assert_eq!(
1230            signal.get(),
1231            "",
1232            "a destroyed widget's callback must not run"
1233        );
1234        assert_eq!(
1235            tree.app_context().subscription_count(),
1236            0,
1237            "and nothing may be left behind in the callback map"
1238        );
1239    }
1240
1241    /// The mirror of the case below: a rebuild that subscribes **more** times than the one
1242    /// before it re-uses what it can and allocates the rest.
1243    ///
1244    /// Worth its own test because the re-use is matched by position against a list that can
1245    /// simply run out. Reading one past its end has to mean "allocate", not panic and not
1246    /// silently re-use somebody else's id, and the extra subscription has to be a real live
1247    /// one rather than a slot that quietly went nowhere.
1248    #[test]
1249    fn a_rebuild_that_subscribes_more_reuses_what_it_can_and_allocates_the_rest() {
1250        /// Subscribes once on the first build and twice on every build after it.
1251        #[derive(Debug)]
1252        struct GrowingWidget {
1253            built: std::rc::Rc<std::cell::Cell<u32>>,
1254            first_message: Signal<String>,
1255            second_message: Signal<String>,
1256        }
1257
1258        impl Widget for GrowingWidget {
1259            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1260                let first = self.built.get() == 0;
1261                self.built.set(self.built.get() + 1);
1262                let one = self.first_message.clone();
1263                ctx.subscribe_event(TestOrigin::Created, move |event: &TestEvent| {
1264                    one.set(event.message.clone());
1265                });
1266                if !first {
1267                    let two = self.second_message.clone();
1268                    ctx.subscribe_event(TestOrigin::Updated, move |event: &TestEvent| {
1269                        two.set(event.message.clone());
1270                    });
1271                }
1272                Vec::new()
1273            }
1274
1275            fn layout_response(
1276                &self,
1277                proposal: SizeProposal,
1278                _ctx: &LayoutContext,
1279            ) -> crate::widget::LayoutResponse {
1280                proposal.resolve(0.0, 0.0).into()
1281            }
1282        }
1283
1284        let mut tree = WidgetTree::new();
1285        let (source, poster) = install_source(&mut tree, MockEventSource::default());
1286
1287        let built = std::rc::Rc::new(std::cell::Cell::new(0));
1288        let one = Signal::new(String::new());
1289        let two = Signal::new(String::new());
1290        let id = tree.add(GrowingWidget {
1291            built: built.clone(),
1292            first_message: one.clone(),
1293            second_message: two.clone(),
1294        });
1295        assert_eq!(tree.app_context().subscription_count(), 1);
1296
1297        tree.arena_mark_needs_rebuild_for_testing(id);
1298        tree.layout(SizeProposal::exact(100.0, 100.0));
1299        assert_eq!(
1300            tree.app_context().subscription_count(),
1301            2,
1302            "the re-used slot plus a freshly allocated one"
1303        );
1304        assert_eq!(
1305            source.subscriber_count(),
1306            2,
1307            "and both are registered with the source, not just the re-used one"
1308        );
1309
1310        // Both deliver, and neither is delivering the other's traffic.
1311        source.publish(
1312            TestOrigin::Created,
1313            TestEvent {
1314                id: 1,
1315                message: "to the first".to_string(),
1316            },
1317        );
1318        source.publish(
1319            TestOrigin::Updated,
1320            TestEvent {
1321                id: 2,
1322                message: "to the second".to_string(),
1323            },
1324        );
1325        drain_and_dispatch(&tree, &poster);
1326
1327        assert_eq!(one.get(), "to the first");
1328        assert_eq!(
1329            two.get(),
1330            "to the second",
1331            "the newly allocated id must be live"
1332        );
1333    }
1334
1335    /// A rebuild that subscribes **fewer** times than the one before it must not leave
1336    /// the surplus subscription live. Reusing a slot across a rebuild is only safe if a
1337    /// slot the new build did not claim is dropped.
1338    #[test]
1339    fn a_rebuild_that_subscribes_less_drops_the_surplus_subscription() {
1340        /// Subscribes twice on the first build and once on every build after it.
1341        #[derive(Debug)]
1342        struct ShrinkingWidget {
1343            built: std::rc::Rc<std::cell::Cell<u32>>,
1344        }
1345
1346        impl Widget for ShrinkingWidget {
1347            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1348                let first = self.built.get() == 0;
1349                self.built.set(self.built.get() + 1);
1350                ctx.subscribe_event(TestOrigin::Created, |_event: &TestEvent| {});
1351                if first {
1352                    ctx.subscribe_event(TestOrigin::Updated, |_event: &TestEvent| {});
1353                }
1354                Vec::new()
1355            }
1356
1357            fn layout_response(
1358                &self,
1359                proposal: SizeProposal,
1360                _ctx: &LayoutContext,
1361            ) -> crate::widget::LayoutResponse {
1362                proposal.resolve(0.0, 0.0).into()
1363            }
1364        }
1365
1366        let mut tree = WidgetTree::new();
1367        let (source, _poster) = install_source(&mut tree, MockEventSource::default());
1368
1369        let built = std::rc::Rc::new(std::cell::Cell::new(0));
1370        let id = tree.add(ShrinkingWidget {
1371            built: built.clone(),
1372        });
1373        assert_eq!(tree.app_context().subscription_count(), 2);
1374        assert_eq!(source.subscriber_count(), 2);
1375
1376        tree.arena_mark_needs_rebuild_for_testing(id);
1377        tree.layout(SizeProposal::exact(100.0, 100.0));
1378
1379        assert_eq!(
1380            tree.app_context().subscription_count(),
1381            1,
1382            "the second slot was not re-registered, so it must be gone"
1383        );
1384        assert_eq!(
1385            source.subscriber_count(),
1386            1,
1387            "and the source must not still be holding it"
1388        );
1389    }
1390}