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    #[allow(clippy::type_complexity)]
204    pub(crate) subscription_callbacks: RefCell<HashMap<SubscriptionId, Box<dyn Fn(&dyn Any)>>>,
205    /// Context-bearing subscription callbacks + the window they target.
206    /// Populated by [`BuildContext::subscribe_event_with_ctx`](crate::build_context::BuildContext::subscribe_event_with_ctx);
207    /// dispatched by teksilo-app with a freshly-minted [`EventContext`]. A given
208    /// `SubscriptionId` lives in exactly one of the two callback maps.
209    ///
210    /// The window is `Option` because a subscription registered from a windowless
211    /// tree (headless / tests) has no tree to mint an `EventContext` from — the
212    /// app-side router then can't deliver it (real app widgets always have a
213    /// window). Direct [`dispatch_subscription_event_with_ctx`](Self::dispatch_subscription_event_with_ctx)
214    /// is window-agnostic, which is what unit tests drive.
215    #[allow(clippy::type_complexity)]
216    pub(crate) subscription_ctx_callbacks:
217        RefCell<HashMap<SubscriptionId, (Option<TeksiloWindowId>, CtxSubscriptionCallback)>>,
218    pub(crate) next_subscription_id: Cell<u64>,
219    /// Application-scoped values keyed by `TypeId`.
220    /// Populated at builder time, read-only after the tree starts running.
221    pub(crate) app_state: HashMap<TypeId, Box<dyn Any>>,
222}
223
224impl TreeAppContext {
225    /// Empty context — no event source, no proxy poster. Used by tests
226    /// and by `WidgetTree::new()`.
227    pub fn empty() -> Self {
228        Self {
229            poster: None,
230            event_source: None,
231            subscription_callbacks: RefCell::new(HashMap::new()),
232            subscription_ctx_callbacks: RefCell::new(HashMap::new()),
233            next_subscription_id: Cell::new(1),
234            app_state: HashMap::new(),
235        }
236    }
237
238    /// Build a context with both a registered event source and a proxy
239    /// poster. Called by `teksilo-app` when constructing a window for an
240    /// application that registered an event source on the builder.
241    pub fn with_source_and_poster(
242        event_source: EventSourceAdapter,
243        poster: Arc<dyn AppEventPoster>,
244    ) -> Self {
245        Self {
246            poster: Some(poster),
247            event_source: Some(event_source),
248            subscription_callbacks: RefCell::new(HashMap::new()),
249            subscription_ctx_callbacks: RefCell::new(HashMap::new()),
250            next_subscription_id: Cell::new(1),
251            app_state: HashMap::new(),
252        }
253    }
254
255    /// Install an app-state registry. Consumes `self`
256    /// and returns a new context with the registry attached; the builder
257    /// calls this after constructing the context and before wrapping it
258    /// in `Rc`.
259    pub fn with_app_state(mut self, registry: HashMap<TypeId, Box<dyn Any>>) -> Self {
260        self.app_state = registry;
261        self
262    }
263
264    /// Install an [`AppEventPoster`] so background work (file dialogs,
265    /// future async-result features) can post typed payloads back to
266    /// the UI loop via `AppEvent::External`. The builder calls this
267    /// unconditionally during `TeksiloAppBuilder::run` — the poster is
268    /// cheap (a thin wrapper around the event-loop proxy) and being
269    /// reachable means widgets do not have to depend on the event-source
270    /// feature for unrelated async-result delivery.
271    pub fn with_poster(mut self, poster: Arc<dyn AppEventPoster>) -> Self {
272        self.poster = Some(poster);
273        self
274    }
275
276    /// Borrow the registered [`AppEventPoster`] if one was installed.
277    /// Used by integrations that need to post typed payloads back to
278    /// the UI loop from an external thread (e.g. file-dialog backends).
279    pub fn poster(&self) -> Option<&Arc<dyn AppEventPoster>> {
280        self.poster.as_ref()
281    }
282
283    /// Look up an app-state value of type `T` previously registered via
284    /// `TeksiloAppBuilder::app_state`.
285    pub fn app_state<T: 'static>(&self) -> Option<&T> {
286        self.app_state
287            .get(&TypeId::of::<T>())
288            .and_then(|boxed| boxed.downcast_ref::<T>())
289    }
290
291    pub(crate) fn allocate_subscription_id(&self) -> SubscriptionId {
292        let id = self.next_subscription_id.get();
293        self.next_subscription_id.set(id + 1);
294        SubscriptionId(id)
295    }
296
297    /// Look up and invoke the UI-side callback for a posted subscription
298    /// event. Returns `true` if a callback was found and invoked.
299    pub fn dispatch_subscription_event(&self, sub_id: SubscriptionId, event: &dyn Any) -> bool {
300        let callbacks = self.subscription_callbacks.borrow();
301        if let Some(callback) = callbacks.get(&sub_id) {
302            callback(event);
303            true
304        } else {
305            false
306        }
307    }
308
309    /// The window a *context-bearing* subscription targets, if `sub_id` names one
310    /// **and** it was registered from a window (always true in a real app).
311    /// teksilo-app peeks this to know which window's tree to mint the
312    /// [`EventContext`] from before dispatching.
313    pub fn ctx_subscription_window(&self, sub_id: SubscriptionId) -> Option<TeksiloWindowId> {
314        self.subscription_ctx_callbacks
315            .borrow()
316            .get(&sub_id)
317            .and_then(|(window_id, _)| *window_id)
318    }
319
320    /// Invoke the *context-bearing* UI-side callback for a posted subscription
321    /// event, passing the freshly-minted [`EventContext`]. Returns `true` if a
322    /// callback was found and invoked. Called by teksilo-app from inside
323    /// [`WidgetTree::run_with_event_context`](crate::WidgetTree::run_with_event_context).
324    ///
325    /// The `Rc` handle is **cloned and the map borrow released before the call**,
326    /// so the callback may freely re-enter this map — e.g. `ctx.open_window(...)`
327    /// synchronously builds a new tree whose widgets register their own
328    /// context-bearing subscriptions. (Holding the borrow across the call would
329    /// panic there; hence `Rc`, not `Box`.)
330    pub fn dispatch_subscription_event_with_ctx(
331        &self,
332        sub_id: SubscriptionId,
333        event: &dyn Any,
334        ctx: &mut EventContext,
335    ) -> bool {
336        let callback = self
337            .subscription_ctx_callbacks
338            .borrow()
339            .get(&sub_id)
340            .map(|(_window_id, callback)| Rc::clone(callback));
341        match callback {
342            Some(callback) => {
343                callback(event, ctx);
344                true
345            }
346            None => false,
347        }
348    }
349
350    /// Number of context-bearing subscription callbacks currently installed.
351    /// Companion to [`subscription_count`](Self::subscription_count); used by
352    /// lifecycle tests to assert the ctx-map teardown ran.
353    pub fn ctx_subscription_count(&self) -> usize {
354        self.subscription_ctx_callbacks.borrow().len()
355    }
356
357    /// Drop every context-bearing subscription targeting `window_id`. Called by
358    /// teksilo-app when a window closes, so the shared, longer-lived
359    /// `TreeAppContext` map doesn't retain inert callbacks for a torn-down tree
360    /// (a window's tree is dropped without a per-widget `destroy_subtree` pass).
361    /// Mirrors [`AsyncCompletionHandle::purge_window`](crate::AsyncCompletionHandle::purge_window).
362    pub fn purge_ctx_subscriptions_for_window(&self, window_id: TeksiloWindowId) {
363        self.subscription_ctx_callbacks
364            .borrow_mut()
365            .retain(|_, (win, _)| *win != Some(window_id));
366    }
367
368    /// Number of UI-side subscription callbacks currently installed.
369    /// Used by lifecycle tests to assert cleanup ran correctly.
370    pub fn subscription_count(&self) -> usize {
371        self.subscription_callbacks.borrow().len()
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::signal::Signal;
379    use crate::widget::{LayoutContext, Widget};
380    use crate::widget_id::WidgetId;
381    use crate::widget_tree::WidgetTree;
382    use std::sync::Mutex;
383    use teksilo_canvas::SizeProposal;
384
385    // --- Test event source ---
386
387    #[derive(Clone, PartialEq, Eq, Hash, Debug)]
388    enum TestOrigin {
389        Created,
390        Updated,
391    }
392
393    #[derive(Clone, Debug, PartialEq)]
394    struct TestEvent {
395        id: u64,
396        message: String,
397    }
398
399    /// A trivial in-process event source. Holds a list of (id, origin, callback)
400    /// entries; `publish` walks them and invokes matching callbacks
401    /// synchronously on the calling thread.
402    ///
403    /// ⚠ **Its handle really unsubscribes**, which is not decoration. A source that
404    /// returns [`SubscriptionHandle::empty`] keeps every subscriber it was ever given,
405    /// so one publish reaches the wrappers of *all* of a widget's past builds. That used
406    /// to be invisible, because each of those wrappers posted a by-then-dead id and the
407    /// dispatch dropped it; now that an id outlives a rebuild it would deliver the same
408    /// event once per past build. A mock that never removes anything would model a
409    /// source no real one resembles and would make this suite assert the wrong thing.
410    #[derive(Default)]
411    struct MockEventSource {
412        #[allow(clippy::type_complexity)]
413        subscribers: Arc<
414            Mutex<
415                Vec<(
416                    u64,
417                    TestOrigin,
418                    Arc<dyn Fn(TestEvent) + Send + Sync + 'static>,
419                )>,
420            >,
421        >,
422        next_id: std::sync::atomic::AtomicU64,
423    }
424
425    /// Removes its entry from [`MockEventSource`] on drop, the way a real source's
426    /// token does.
427    struct MockToken {
428        #[allow(clippy::type_complexity)]
429        subscribers: Arc<
430            Mutex<
431                Vec<(
432                    u64,
433                    TestOrigin,
434                    Arc<dyn Fn(TestEvent) + Send + Sync + 'static>,
435                )>,
436            >,
437        >,
438        id: u64,
439    }
440
441    impl Drop for MockToken {
442        fn drop(&mut self) {
443            if let Ok(mut subs) = self.subscribers.lock() {
444                subs.retain(|(id, _, _)| *id != self.id);
445            }
446        }
447    }
448
449    impl EventSource for MockEventSource {
450        type Origin = TestOrigin;
451        type Event = TestEvent;
452
453        fn subscribe(
454            &self,
455            origin: Self::Origin,
456            callback: Arc<dyn Fn(Self::Event) + Send + Sync + 'static>,
457        ) -> SubscriptionHandle {
458            let id = self
459                .next_id
460                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
461            self.subscribers
462                .lock()
463                .unwrap()
464                .push((id, origin, callback));
465            SubscriptionHandle::new(MockToken {
466                subscribers: self.subscribers.clone(),
467                id,
468            })
469        }
470    }
471
472    impl MockEventSource {
473        fn publish(&self, origin: TestOrigin, event: TestEvent) {
474            let subs = self.subscribers.lock().unwrap();
475            for (_id, sub_origin, cb) in subs.iter() {
476                if *sub_origin == origin {
477                    cb(event.clone());
478                }
479            }
480        }
481
482        fn subscriber_count(&self) -> usize {
483            self.subscribers.lock().unwrap().len()
484        }
485    }
486
487    /// A poster that buffers posted events into a thread-safe queue. Tests
488    /// drain it after `publish` and dispatch them through the tree's
489    /// app_context, mirroring the real proxy → user_event flow.
490    #[derive(Default)]
491    struct TestPoster {
492        #[allow(clippy::type_complexity)]
493        queue: Mutex<Vec<(SubscriptionId, Box<dyn Any + Send>)>>,
494    }
495
496    impl AppEventPoster for TestPoster {
497        fn post_subscription_event(&self, sub_id: SubscriptionId, event: Box<dyn Any + Send>) {
498            self.queue.lock().unwrap().push((sub_id, event));
499        }
500    }
501
502    impl TestPoster {
503        fn drain(&self) -> Vec<(SubscriptionId, Box<dyn Any + Send>)> {
504            std::mem::take(&mut *self.queue.lock().unwrap())
505        }
506    }
507
508    // --- Test widget that subscribes in build() ---
509
510    #[derive(Debug)]
511    struct SubscribingWidget {
512        origin: TestOrigin,
513        last_message: Signal<String>,
514    }
515
516    impl Widget for SubscribingWidget {
517        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
518            let last_message = self.last_message.clone();
519            ctx.subscribe_event(self.origin.clone(), move |event: &TestEvent| {
520                last_message.set(event.message.clone());
521            });
522            Vec::new()
523        }
524
525        fn layout_response(
526            &self,
527            proposal: SizeProposal,
528            _ctx: &LayoutContext,
529        ) -> crate::widget::LayoutResponse {
530            proposal.resolve(0.0, 0.0).into()
531        }
532    }
533
534    /// Subscribes via the *context-bearing* API in `build()` — headless, so the
535    /// registration records `None` for the window but still lands in the ctx map
536    /// and is torn down on destroy.
537    #[derive(Debug)]
538    struct CtxSubscribingWidget {
539        origin: TestOrigin,
540        last_message: Signal<String>,
541    }
542
543    impl Widget for CtxSubscribingWidget {
544        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
545            let last_message = self.last_message.clone();
546            ctx.subscribe_event_with_ctx(
547                self.origin.clone(),
548                move |event: &TestEvent, _ctx: &mut crate::widget::EventContext| {
549                    last_message.set(event.message.clone());
550                },
551            );
552            Vec::new()
553        }
554
555        fn layout_response(
556            &self,
557            proposal: SizeProposal,
558            _ctx: &LayoutContext,
559        ) -> crate::widget::LayoutResponse {
560            proposal.resolve(0.0, 0.0).into()
561        }
562    }
563
564    // --- Helpers ---
565
566    fn install_source(
567        tree: &mut WidgetTree,
568        source: MockEventSource,
569    ) -> (Arc<MockEventSource>, Arc<TestPoster>) {
570        let source = Arc::new(source);
571        // We need to share the source between the test and the adapter,
572        // so wrap a thin proxy that delegates to the Arc.
573        struct SharedSource {
574            inner: Arc<MockEventSource>,
575        }
576        impl EventSource for SharedSource {
577            type Origin = TestOrigin;
578            type Event = TestEvent;
579            fn subscribe(
580                &self,
581                origin: Self::Origin,
582                callback: Arc<dyn Fn(Self::Event) + Send + Sync + 'static>,
583            ) -> SubscriptionHandle {
584                self.inner.subscribe(origin, callback)
585            }
586        }
587
588        let adapter = EventSourceAdapter::new(SharedSource {
589            inner: source.clone(),
590        });
591        let poster: Arc<TestPoster> = Arc::new(TestPoster::default());
592        let poster_dyn: Arc<dyn AppEventPoster> = poster.clone();
593        let app_context =
594            std::rc::Rc::new(TreeAppContext::with_source_and_poster(adapter, poster_dyn));
595        tree.set_app_context(app_context);
596        (source, poster)
597    }
598
599    fn drain_and_dispatch(tree: &WidgetTree, poster: &TestPoster) {
600        let events = poster.drain();
601        for (sub_id, event) in events {
602            tree.app_context()
603                .dispatch_subscription_event(sub_id, &*event);
604        }
605    }
606
607    // --- Tests ---
608
609    #[test]
610    fn subscribe_event_delivers_to_widget_signal() {
611        let mut tree = WidgetTree::new();
612        let (source, poster) = install_source(&mut tree, MockEventSource::default());
613
614        let signal = Signal::new(String::new());
615        let _id = tree.add(SubscribingWidget {
616            origin: TestOrigin::Created,
617            last_message: signal.clone(),
618        });
619
620        assert_eq!(source.subscriber_count(), 1);
621        assert_eq!(tree.app_context().subscription_count(), 1);
622
623        source.publish(
624            TestOrigin::Created,
625            TestEvent {
626                id: 1,
627                message: "hello".to_string(),
628            },
629        );
630        drain_and_dispatch(&tree, &poster);
631
632        assert_eq!(signal.get(), "hello");
633    }
634
635    #[test]
636    fn subscribe_event_with_ctx_dispatches_inside_fresh_context() {
637        use crate::window::{NoopWindowOps, TeksiloWindowId};
638
639        let mut tree = WidgetTree::new();
640        // Register a context-bearing callback the way
641        // `BuildContext::subscribe_event_with_ctx` does — but directly, so the
642        // test needs no real window (that routing is covered end-to-end by the
643        // `toast_demo` example and the Skribisto importer).
644        let app_ctx = tree.app_context().clone();
645        let sub_id = app_ctx.allocate_subscription_id();
646        let win = TeksiloWindowId::new(1);
647        let seen = Signal::new(String::new());
648        let seen_cb = seen.clone();
649        let stored: std::rc::Rc<dyn Fn(&dyn Any, &mut crate::widget::EventContext)> =
650            std::rc::Rc::new(move |event_any, _ctx: &mut crate::widget::EventContext| {
651                let ev = event_any
652                    .downcast_ref::<TestEvent>()
653                    .expect("subscription event downcast failed");
654                seen_cb.set(ev.message.clone());
655            });
656        app_ctx
657            .subscription_ctx_callbacks
658            .borrow_mut()
659            .insert(sub_id, (Some(win), stored));
660
661        // The target window is peekable (teksilo-app reads it to pick the tree
662        // whose `EventContext` it mints).
663        assert_eq!(app_ctx.ctx_subscription_window(sub_id), Some(win));
664        assert_eq!(app_ctx.ctx_subscription_window(SubscriptionId(9999)), None);
665
666        // Dispatch inside a fresh `EventContext`, exactly like teksilo-app's
667        // `try_dispatch_subscription_with_ctx`.
668        let event = TestEvent {
669            id: 9,
670            message: "progress-42".to_string(),
671        };
672        let handled = std::cell::Cell::new(false);
673        tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
674            handled.set(app_ctx.dispatch_subscription_event_with_ctx(sub_id, &event, ctx));
675        });
676        assert!(
677            handled.get(),
678            "context-bearing dispatch must find the callback"
679        );
680        assert_eq!(seen.get(), "progress-42");
681
682        // An unknown sub_id is not consumed (so the caller falls back to the
683        // plain, context-free path).
684        tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
685            assert!(!app_ctx.dispatch_subscription_event_with_ctx(
686                SubscriptionId(9999),
687                &event,
688                ctx
689            ));
690        });
691    }
692
693    /// Regression for the re-entrancy panic: dispatch must clone the `Rc` and
694    /// **release the map borrow before invoking** the callback, so a callback
695    /// that re-enters the same map — as `ctx.open_window(...)` does via a nested
696    /// `build()` calling `subscribe_event_with_ctx` — does not `BorrowMutError`.
697    #[test]
698    fn ctx_dispatch_releases_borrow_before_invoking_callback() {
699        use crate::window::{NoopWindowOps, TeksiloWindowId};
700
701        let mut tree = WidgetTree::new();
702        let app_ctx = tree.app_context().clone();
703        let sub_id = app_ctx.allocate_subscription_id();
704
705        let reenter_ctx = app_ctx.clone();
706        let reentered = std::rc::Rc::new(std::cell::Cell::new(false));
707        let flag = reentered.clone();
708        let cb: std::rc::Rc<dyn Fn(&dyn Any, &mut crate::widget::EventContext)> =
709            std::rc::Rc::new(move |_ev, _ctx| {
710                // Simulate open_window → build() → subscribe_event_with_ctx: a
711                // fresh registration into the SAME map while this callback runs.
712                reenter_ctx.subscription_ctx_callbacks.borrow_mut().insert(
713                    SubscriptionId(4242),
714                    (
715                        Some(TeksiloWindowId::new(2)),
716                        std::rc::Rc::new(|_e: &dyn Any, _c: &mut crate::widget::EventContext| {}),
717                    ),
718                );
719                flag.set(true);
720            });
721        app_ctx
722            .subscription_ctx_callbacks
723            .borrow_mut()
724            .insert(sub_id, (Some(TeksiloWindowId::new(1)), cb));
725
726        let event = TestEvent {
727            id: 1,
728            message: String::new(),
729        };
730        tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
731            assert!(app_ctx.dispatch_subscription_event_with_ctx(sub_id, &event, ctx));
732        });
733
734        assert!(
735            reentered.get(),
736            "callback ran and its re-entrant map insert did not panic"
737        );
738        assert_eq!(
739            app_ctx.ctx_subscription_count(),
740            2,
741            "original + the re-entrant insert both present"
742        );
743    }
744
745    /// Exercises the real `BuildContext::subscribe_event_with_ctx` (headless →
746    /// window `None`) end-to-end: registration lands in the ctx map, and
747    /// destroying the widget tears it back down (covers the widget-destroy path's
748    /// removal from the ctx map).
749    #[test]
750    fn subscribe_event_with_ctx_registers_and_tears_down() {
751        let mut tree = WidgetTree::new();
752        let (_source, _poster) = install_source(&mut tree, MockEventSource::default());
753
754        let id = tree.add(CtxSubscribingWidget {
755            origin: TestOrigin::Created,
756            last_message: Signal::new(String::new()),
757        });
758        // The ctx path uses the OTHER map — the plain count stays 0.
759        assert_eq!(tree.app_context().ctx_subscription_count(), 1);
760        assert_eq!(tree.app_context().subscription_count(), 0);
761
762        tree.destroy_subtree(id);
763        assert_eq!(
764            tree.app_context().ctx_subscription_count(),
765            0,
766            "destroying the widget must remove its context-bearing subscription"
767        );
768    }
769
770    #[test]
771    fn unrelated_origin_does_not_fire_callback() {
772        let mut tree = WidgetTree::new();
773        let (source, poster) = install_source(&mut tree, MockEventSource::default());
774
775        let signal = Signal::new(String::new());
776        let _id = tree.add(SubscribingWidget {
777            origin: TestOrigin::Created,
778            last_message: signal.clone(),
779        });
780
781        source.publish(
782            TestOrigin::Updated,
783            TestEvent {
784                id: 1,
785                message: "ignored".to_string(),
786            },
787        );
788        drain_and_dispatch(&tree, &poster);
789
790        assert_eq!(signal.get(), "");
791    }
792
793    #[test]
794    fn destroying_widget_removes_ui_callback() {
795        let mut tree = WidgetTree::new();
796        let (_source, _poster) = install_source(&mut tree, MockEventSource::default());
797
798        let signal = Signal::new(String::new());
799        let id = tree.add(SubscribingWidget {
800            origin: TestOrigin::Created,
801            last_message: signal.clone(),
802        });
803
804        assert_eq!(tree.app_context().subscription_count(), 1);
805        tree.destroy_subtree(id);
806        assert_eq!(tree.app_context().subscription_count(), 0);
807    }
808
809    #[test]
810    fn in_flight_event_after_destroy_is_dropped_not_delivered() {
811        // An event that was buffered in the proxy queue before the widget
812        // was destroyed is silently dropped once cleanup completes. The
813        // destroy path removes the UI-side callback synchronously, so by
814        // the time the drain happens the callback lookup misses. This
815        // preserves the invariant that a destroyed widget never sees
816        // another event.
817        let mut tree = WidgetTree::new();
818        let (source, poster) = install_source(&mut tree, MockEventSource::default());
819
820        let signal = Signal::new(String::new());
821        let id = tree.add(SubscribingWidget {
822            origin: TestOrigin::Created,
823            last_message: signal.clone(),
824        });
825
826        // Publish — the wrapper fires and enqueues into the test poster.
827        source.publish(
828            TestOrigin::Created,
829            TestEvent {
830                id: 7,
831                message: "buffered".to_string(),
832            },
833        );
834
835        tree.destroy_subtree(id);
836        drain_and_dispatch(&tree, &poster);
837
838        assert_eq!(signal.get(), "");
839        assert_eq!(tree.app_context().subscription_count(), 0);
840    }
841
842    #[test]
843    #[should_panic(expected = "no event source was registered")]
844    fn subscribe_without_event_source_panics() {
845        let mut tree = WidgetTree::new();
846        let signal = Signal::new(String::new());
847        // No install_source — tree has the empty default app context.
848        tree.add(SubscribingWidget {
849            origin: TestOrigin::Created,
850            last_message: signal,
851        });
852    }
853
854    // --- app_state tests (architecture §9.5) ---
855
856    use std::rc::Rc;
857
858    struct TestGlobals {
859        greeting: Signal<String>,
860    }
861
862    /// Widget that reads `Rc<TestGlobals>` from app_state in `build()` and
863    /// records what it observed into an out-of-band signal.
864    #[derive(Debug)]
865    struct AppStateReader {
866        observed: Signal<String>,
867        saw_none: Signal<bool>,
868    }
869
870    impl Widget for AppStateReader {
871        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
872            match ctx.app_state::<Rc<TestGlobals>>() {
873                Some(globals) => self.observed.set(globals.greeting.get()),
874                None => self.saw_none.set(true),
875            }
876            Vec::new()
877        }
878
879        fn layout_response(
880            &self,
881            proposal: SizeProposal,
882            _ctx: &LayoutContext,
883        ) -> crate::widget::LayoutResponse {
884            proposal.resolve(0.0, 0.0).into()
885        }
886    }
887
888    #[test]
889    fn app_state_roundtrip_in_build_context() {
890        let globals = Rc::new(TestGlobals {
891            greeting: Signal::new("hello from registry".to_string()),
892        });
893
894        let mut registry: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
895        registry.insert(TypeId::of::<Rc<TestGlobals>>(), Box::new(globals.clone()));
896
897        let mut tree = WidgetTree::new();
898        tree.set_app_context(Rc::new(TreeAppContext::empty().with_app_state(registry)));
899
900        let observed = Signal::new(String::new());
901        let saw_none = Signal::new(false);
902        tree.add(AppStateReader {
903            observed: observed.clone(),
904            saw_none: saw_none.clone(),
905        });
906
907        assert_eq!(observed.get(), "hello from registry");
908        assert!(!saw_none.get());
909    }
910
911    #[test]
912    fn app_state_missing_returns_none() {
913        let mut tree = WidgetTree::new();
914        // No app_state installed — tree has the empty default app context.
915
916        let observed = Signal::new(String::new());
917        let saw_none = Signal::new(false);
918        tree.add(AppStateReader {
919            observed: observed.clone(),
920            saw_none: saw_none.clone(),
921        });
922
923        assert_eq!(observed.get(), "");
924        assert!(saw_none.get());
925    }
926
927    #[test]
928    fn app_state_distinct_types_coexist() {
929        struct Alpha(u32);
930        struct Beta(String);
931
932        let mut registry: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
933        registry.insert(TypeId::of::<Rc<Alpha>>(), Box::new(Rc::new(Alpha(42))));
934        registry.insert(
935            TypeId::of::<Rc<Beta>>(),
936            Box::new(Rc::new(Beta("beta!".to_string()))),
937        );
938
939        let ctx = TreeAppContext::empty().with_app_state(registry);
940        assert_eq!(ctx.app_state::<Rc<Alpha>>().unwrap().0, 42);
941        assert_eq!(ctx.app_state::<Rc<Beta>>().unwrap().0, "beta!");
942        assert!(ctx.app_state::<Rc<u64>>().is_none());
943    }
944
945    /// **An event posted before a rebuild must still reach the widget after it.**
946    ///
947    /// A backend event crosses two thread boundaries and a queue: the source publishes
948    /// on its own thread, the wrapper posts an `AppEvent::SubscriptionEvent` carrying
949    /// the `SubscriptionId` it captured at *publish* time, and the UI thread dispatches
950    /// it some frames later. A rebuild in that gap used to be fatal — `build()` runs
951    /// again, allocates fresh ids, and the teardown in `rebuild_single_widget` removes
952    /// the previous build's callbacks, so the queued event named a dead id and
953    /// `dispatch_subscription_event` dropped it on the floor and returned `false`.
954    ///
955    /// ⚠ The window is **not** the microsecond between dropping the source handle and
956    /// removing the callback, which is what that function's `§9.4.5` comment reasons
957    /// about. It is the whole span from publish to dispatch, and a widget opens it on
958    /// itself simply by binding a signal at `BindingLevel::Rebuild` and then setting
959    /// that signal — the ordinary documented pattern. Skribisto's Analysis pane starts
960    /// a long operation in `build()` and sets its own state signal to `Running`, so
961    /// whenever the operation finished inside that gap the completion was lost and the
962    /// pane sat on "Reading the manuscript…" for the rest of the session.
963    #[test]
964    fn an_event_posted_before_a_rebuild_still_reaches_the_widget() {
965        let mut tree = WidgetTree::new();
966        let (source, poster) = install_source(&mut tree, MockEventSource::default());
967
968        let signal = Signal::new(String::new());
969        let id = tree.add(SubscribingWidget {
970            origin: TestOrigin::Created,
971            last_message: signal.clone(),
972        });
973
974        // Posted now: the queued event carries the id minted by the first build.
975        source.publish(
976            TestOrigin::Created,
977            TestEvent {
978                id: 1,
979                message: "landed".to_string(),
980            },
981        );
982
983        // …and the widget rebuilds before the UI thread gets to it. This is exactly
984        // what a `BindingLevel::Rebuild` binding does when its signal changes.
985        tree.arena_mark_needs_rebuild_for_testing(id);
986        tree.layout(SizeProposal::exact(100.0, 100.0));
987
988        drain_and_dispatch(&tree, &poster);
989
990        assert_eq!(
991            signal.get(),
992            "landed",
993            "the rebuild must not swallow an event that was already in flight"
994        );
995    }
996
997    /// The same guarantee for the **context-bearing** API.
998    ///
999    /// `subscribe_event_with_ctx` keeps its callbacks in a second map and is dispatched
1000    /// by a different function, so it fails and has to be fixed separately from the
1001    /// plain path. It is also the API the framework documents as *the* bridge for
1002    /// long-operation progress, which is precisely the traffic this race eats.
1003    #[test]
1004    fn an_event_posted_before_a_rebuild_still_reaches_a_context_bearing_subscription() {
1005        use crate::window::NoopWindowOps;
1006
1007        let mut tree = WidgetTree::new();
1008        let (source, poster) = install_source(&mut tree, MockEventSource::default());
1009
1010        let signal = Signal::new(String::new());
1011        let id = tree.add(CtxSubscribingWidget {
1012            origin: TestOrigin::Created,
1013            last_message: signal.clone(),
1014        });
1015
1016        source.publish(
1017            TestOrigin::Created,
1018            TestEvent {
1019                id: 1,
1020                message: "landed".to_string(),
1021            },
1022        );
1023
1024        tree.arena_mark_needs_rebuild_for_testing(id);
1025        tree.layout(SizeProposal::exact(100.0, 100.0));
1026
1027        // Dispatched the way teksilo-app's `try_dispatch_subscription_with_ctx` does,
1028        // from the queue the wrapper actually posted into.
1029        let app_ctx = tree.app_context().clone();
1030        let events = poster.drain();
1031        assert!(!events.is_empty(), "the source must have posted something");
1032        for (sub_id, event) in events {
1033            tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
1034                app_ctx.dispatch_subscription_event_with_ctx(sub_id, &*event, ctx);
1035            });
1036        }
1037
1038        assert_eq!(
1039            signal.get(),
1040            "landed",
1041            "the ctx-bearing path must survive a rebuild too"
1042        );
1043    }
1044
1045    /// A widget that is genuinely **destroyed** must not have its callback fired by a
1046    /// late event, and must not leave one behind. The fix above makes a subscription's
1047    /// identity outlive a rebuild; it must not make it outlive the widget.
1048    #[test]
1049    fn an_event_posted_before_a_destroy_fires_nothing_and_leaks_nothing() {
1050        let mut tree = WidgetTree::new();
1051        let (source, poster) = install_source(&mut tree, MockEventSource::default());
1052
1053        let signal = Signal::new(String::new());
1054        let id = tree.add(SubscribingWidget {
1055            origin: TestOrigin::Created,
1056            last_message: signal.clone(),
1057        });
1058
1059        source.publish(
1060            TestOrigin::Created,
1061            TestEvent {
1062                id: 1,
1063                message: "too late".to_string(),
1064            },
1065        );
1066        tree.destroy_subtree(id);
1067        drain_and_dispatch(&tree, &poster);
1068
1069        assert_eq!(
1070            signal.get(),
1071            "",
1072            "a destroyed widget's callback must not run"
1073        );
1074        assert_eq!(
1075            tree.app_context().subscription_count(),
1076            0,
1077            "and nothing may be left behind in the callback map"
1078        );
1079    }
1080
1081    /// The mirror of the case below: a rebuild that subscribes **more** times than the one
1082    /// before it re-uses what it can and allocates the rest.
1083    ///
1084    /// Worth its own test because the re-use is matched by position against a list that can
1085    /// simply run out. Reading one past its end has to mean "allocate", not panic and not
1086    /// silently re-use somebody else's id, and the extra subscription has to be a real live
1087    /// one rather than a slot that quietly went nowhere.
1088    #[test]
1089    fn a_rebuild_that_subscribes_more_reuses_what_it_can_and_allocates_the_rest() {
1090        /// Subscribes once on the first build and twice on every build after it.
1091        #[derive(Debug)]
1092        struct GrowingWidget {
1093            built: std::rc::Rc<std::cell::Cell<u32>>,
1094            first_message: Signal<String>,
1095            second_message: Signal<String>,
1096        }
1097
1098        impl Widget for GrowingWidget {
1099            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1100                let first = self.built.get() == 0;
1101                self.built.set(self.built.get() + 1);
1102                let one = self.first_message.clone();
1103                ctx.subscribe_event(TestOrigin::Created, move |event: &TestEvent| {
1104                    one.set(event.message.clone());
1105                });
1106                if !first {
1107                    let two = self.second_message.clone();
1108                    ctx.subscribe_event(TestOrigin::Updated, move |event: &TestEvent| {
1109                        two.set(event.message.clone());
1110                    });
1111                }
1112                Vec::new()
1113            }
1114
1115            fn layout_response(
1116                &self,
1117                proposal: SizeProposal,
1118                _ctx: &LayoutContext,
1119            ) -> crate::widget::LayoutResponse {
1120                proposal.resolve(0.0, 0.0).into()
1121            }
1122        }
1123
1124        let mut tree = WidgetTree::new();
1125        let (source, poster) = install_source(&mut tree, MockEventSource::default());
1126
1127        let built = std::rc::Rc::new(std::cell::Cell::new(0));
1128        let one = Signal::new(String::new());
1129        let two = Signal::new(String::new());
1130        let id = tree.add(GrowingWidget {
1131            built: built.clone(),
1132            first_message: one.clone(),
1133            second_message: two.clone(),
1134        });
1135        assert_eq!(tree.app_context().subscription_count(), 1);
1136
1137        tree.arena_mark_needs_rebuild_for_testing(id);
1138        tree.layout(SizeProposal::exact(100.0, 100.0));
1139        assert_eq!(
1140            tree.app_context().subscription_count(),
1141            2,
1142            "the re-used slot plus a freshly allocated one"
1143        );
1144        assert_eq!(
1145            source.subscriber_count(),
1146            2,
1147            "and both are registered with the source, not just the re-used one"
1148        );
1149
1150        // Both deliver, and neither is delivering the other's traffic.
1151        source.publish(
1152            TestOrigin::Created,
1153            TestEvent {
1154                id: 1,
1155                message: "to the first".to_string(),
1156            },
1157        );
1158        source.publish(
1159            TestOrigin::Updated,
1160            TestEvent {
1161                id: 2,
1162                message: "to the second".to_string(),
1163            },
1164        );
1165        drain_and_dispatch(&tree, &poster);
1166
1167        assert_eq!(one.get(), "to the first");
1168        assert_eq!(
1169            two.get(),
1170            "to the second",
1171            "the newly allocated id must be live"
1172        );
1173    }
1174
1175    /// A rebuild that subscribes **fewer** times than the one before it must not leave
1176    /// the surplus subscription live. Reusing a slot across a rebuild is only safe if a
1177    /// slot the new build did not claim is dropped.
1178    #[test]
1179    fn a_rebuild_that_subscribes_less_drops_the_surplus_subscription() {
1180        /// Subscribes twice on the first build and once on every build after it.
1181        #[derive(Debug)]
1182        struct ShrinkingWidget {
1183            built: std::rc::Rc<std::cell::Cell<u32>>,
1184        }
1185
1186        impl Widget for ShrinkingWidget {
1187            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1188                let first = self.built.get() == 0;
1189                self.built.set(self.built.get() + 1);
1190                ctx.subscribe_event(TestOrigin::Created, |_event: &TestEvent| {});
1191                if first {
1192                    ctx.subscribe_event(TestOrigin::Updated, |_event: &TestEvent| {});
1193                }
1194                Vec::new()
1195            }
1196
1197            fn layout_response(
1198                &self,
1199                proposal: SizeProposal,
1200                _ctx: &LayoutContext,
1201            ) -> crate::widget::LayoutResponse {
1202                proposal.resolve(0.0, 0.0).into()
1203            }
1204        }
1205
1206        let mut tree = WidgetTree::new();
1207        let (source, _poster) = install_source(&mut tree, MockEventSource::default());
1208
1209        let built = std::rc::Rc::new(std::cell::Cell::new(0));
1210        let id = tree.add(ShrinkingWidget {
1211            built: built.clone(),
1212        });
1213        assert_eq!(tree.app_context().subscription_count(), 2);
1214        assert_eq!(source.subscriber_count(), 2);
1215
1216        tree.arena_mark_needs_rebuild_for_testing(id);
1217        tree.layout(SizeProposal::exact(100.0, 100.0));
1218
1219        assert_eq!(
1220            tree.app_context().subscription_count(),
1221            1,
1222            "the second slot was not re-registered, so it must be gone"
1223        );
1224        assert_eq!(
1225            source.subscriber_count(),
1226            1,
1227            "and the source must not still be holding it"
1228        );
1229    }
1230}