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}