Skip to main content

teksilo_core/
async_completion.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Generic, runtime-agnostic delivery of a one-shot main-thread callback that
5//! runs with a *fresh* [`EventContext`] bound to a window's tree.
6//!
7//! This is the plumbing the optional `teksilo-async` crate uses to implement
8//! `spawn_local_with`: a future runs on the main-thread executor, and when it
9//! completes its result is handed to a callback that needs ambient context
10//! operations (`open_window`, `send_intent`, …). Those operations require an
11//! [`EventContext`], which only exists *during* event dispatch — so the
12//! callback is *registered* here (keyed by an id and the originating window)
13//! and *delivered* later by `teksilo-app`, which routes an
14//! [`AsyncCompletionPayload`] to the window's tree and calls
15//! [`AsyncCompletionHandle::deliver`] inside a freshly-minted context.
16//!
17//! It mirrors the file-dialog result-delivery pattern, but uses only
18//! teksilo-core types so a crate layered *above* `teksilo-app` (like
19//! `teksilo-async`) can register callbacks without forcing `teksilo-app` to
20//! depend on it (which would be a dependency cycle). There is no async,
21//! future, or runtime type here — just a callback registry and a `Send`
22//! payload.
23
24use std::cell::RefCell;
25use std::collections::HashMap;
26use std::rc::Rc;
27
28use crate::widget::EventContext;
29use crate::window::TeksiloWindowId;
30
31type CompletionCallback = Box<dyn FnOnce(&mut EventContext)>;
32
33struct Pending {
34    window_id: TeksiloWindowId,
35    callback: CompletionCallback,
36}
37
38struct CompletionState {
39    next_id: u64,
40    pending: HashMap<u64, Pending>,
41}
42
43/// Main-thread registry of pending async completions. `Clone` shares the same
44/// inner state (`Rc`), so the executor and the app event loop both hold a
45/// handle to one registry. `!Send` by construction — completions only ever run
46/// on the UI thread.
47#[derive(Clone)]
48pub struct AsyncCompletionHandle {
49    inner: Rc<RefCell<CompletionState>>,
50}
51
52impl Default for AsyncCompletionHandle {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl AsyncCompletionHandle {
59    pub fn new() -> Self {
60        Self {
61            inner: Rc::new(RefCell::new(CompletionState {
62                next_id: 0,
63                pending: HashMap::new(),
64            })),
65        }
66    }
67
68    /// Register a callback to run later with a fresh [`EventContext`] on the
69    /// tree of `window_id`. Returns the id to place in an
70    /// [`AsyncCompletionPayload`].
71    pub fn register(&self, window_id: TeksiloWindowId, callback: CompletionCallback) -> u64 {
72        let mut state = self.inner.borrow_mut();
73        let id = state.next_id;
74        state.next_id = state.next_id.wrapping_add(1);
75        state.pending.insert(
76            id,
77            Pending {
78                window_id,
79                callback,
80            },
81        );
82        id
83    }
84
85    /// Invoke and remove the completion registered under `id`, if its target
86    /// window still matches `window_id`. Called by `teksilo-app` from inside
87    /// [`WidgetTree::run_with_event_context`](crate::WidgetTree::run_with_event_context).
88    /// A no-op if the entry was already purged (e.g. the window closed) — the
89    /// use-after-free guard.
90    pub fn deliver(&self, id: u64, window_id: TeksiloWindowId, ctx: &mut EventContext) {
91        let entry = self.inner.borrow_mut().pending.remove(&id);
92        if let Some(pending) = entry
93            && pending.window_id == window_id
94        {
95            (pending.callback)(ctx);
96        }
97    }
98
99    /// Drop every pending completion targeting `window_id`. Called when a
100    /// window closes so a late-arriving completion never touches a torn-down
101    /// tree.
102    pub fn purge_window(&self, window_id: TeksiloWindowId) {
103        self.inner
104            .borrow_mut()
105            .pending
106            .retain(|_, p| p.window_id != window_id);
107    }
108
109    /// Number of pending completions (diagnostics / tests).
110    pub fn pending_len(&self) -> usize {
111        self.inner.borrow().pending.len()
112    }
113}
114
115/// `Send` payload posted through [`AppEventPoster`](crate::AppEventPoster) when
116/// an async task completes. `teksilo-app` downcasts it, routes to the target
117/// window's tree, and calls [`AsyncCompletionHandle::deliver`] with a fresh
118/// context. Carries only ids — the (`!Send`) callback stays in the registry.
119#[derive(Debug, Clone, Copy)]
120pub struct AsyncCompletionPayload {
121    pub id: u64,
122    pub window_id: TeksiloWindowId,
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::widget_tree::WidgetTree;
129    use crate::window::NoopWindowOps;
130    use std::cell::Cell;
131    use std::rc::Rc;
132
133    #[test]
134    fn deliver_runs_callback_with_fresh_context() {
135        let handle = AsyncCompletionHandle::new();
136        let win = TeksiloWindowId::new(1);
137        let ran = Rc::new(Cell::new(false));
138        let flag = ran.clone();
139        let id = handle.register(win, Box::new(move |_ctx| flag.set(true)));
140        assert_eq!(handle.pending_len(), 1);
141
142        let mut tree = WidgetTree::new();
143        tree.run_with_event_context(&mut NoopWindowOps, |ctx| handle.deliver(id, win, ctx));
144
145        assert!(ran.get(), "callback must run with the fresh context");
146        assert_eq!(
147            handle.pending_len(),
148            0,
149            "delivered completion must be removed"
150        );
151    }
152
153    #[test]
154    fn purge_window_drops_only_that_windows_completions() {
155        let handle = AsyncCompletionHandle::new();
156        let win = TeksiloWindowId::new(7);
157        handle.register(win, Box::new(|_ctx| {}));
158        handle.register(win, Box::new(|_ctx| {}));
159        handle.register(TeksiloWindowId::new(8), Box::new(|_ctx| {}));
160        assert_eq!(handle.pending_len(), 3);
161
162        handle.purge_window(win);
163        assert_eq!(
164            handle.pending_len(),
165            1,
166            "only the other window's completion survives"
167        );
168    }
169
170    #[test]
171    fn deliver_to_mismatched_window_does_not_run() {
172        let handle = AsyncCompletionHandle::new();
173        let win = TeksiloWindowId::new(3);
174        let ran = Rc::new(Cell::new(false));
175        let flag = ran.clone();
176        let id = handle.register(win, Box::new(move |_ctx| flag.set(true)));
177
178        let mut tree = WidgetTree::new();
179        tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
180            handle.deliver(id, TeksiloWindowId::new(999), ctx)
181        });
182        assert!(
183            !ran.get(),
184            "a window-mismatched delivery must not run the callback"
185        );
186    }
187}