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::widget_tree::WidgetTree;
30use crate::window::{NoopWindowOps, TeksiloWindowId};
31
32type CompletionCallback = Box<dyn FnOnce(&mut EventContext)>;
33
34struct Pending {
35    window_id: TeksiloWindowId,
36    callback: CompletionCallback,
37}
38
39struct CompletionState {
40    next_id: u64,
41    pending: HashMap<u64, Pending>,
42}
43
44/// Main-thread registry of pending async completions. `Clone` shares the same
45/// inner state (`Rc`), so the executor and the app event loop both hold a
46/// handle to one registry. `!Send` by construction — completions only ever run
47/// on the UI thread.
48#[derive(Clone)]
49pub struct AsyncCompletionHandle {
50    inner: Rc<RefCell<CompletionState>>,
51}
52
53impl Default for AsyncCompletionHandle {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl AsyncCompletionHandle {
60    pub fn new() -> Self {
61        Self {
62            inner: Rc::new(RefCell::new(CompletionState {
63                next_id: 0,
64                pending: HashMap::new(),
65            })),
66        }
67    }
68
69    /// Register a callback to run later with a fresh [`EventContext`] on the
70    /// tree of `window_id`. Returns the id to place in an
71    /// [`AsyncCompletionPayload`].
72    pub fn register(&self, window_id: TeksiloWindowId, callback: CompletionCallback) -> u64 {
73        let mut state = self.inner.borrow_mut();
74        let id = state.next_id;
75        state.next_id = state.next_id.wrapping_add(1);
76        state.pending.insert(
77            id,
78            Pending {
79                window_id,
80                callback,
81            },
82        );
83        id
84    }
85
86    /// Invoke and remove the completion registered under `id`, if its target
87    /// window still matches `window_id`. Called by `teksilo-app` from inside
88    /// [`WidgetTree::run_with_event_context`](crate::WidgetTree::run_with_event_context).
89    /// A no-op if the entry was already purged (e.g. the window closed) — the
90    /// use-after-free guard.
91    pub fn deliver(&self, id: u64, window_id: TeksiloWindowId, ctx: &mut EventContext) {
92        let entry = self.inner.borrow_mut().pending.remove(&id);
93        if let Some(pending) = entry
94            && pending.window_id == window_id
95        {
96            (pending.callback)(ctx);
97        }
98    }
99
100    /// Drop every pending completion targeting `window_id`. Called when a
101    /// window closes so a late-arriving completion never touches a torn-down
102    /// tree.
103    pub fn purge_window(&self, window_id: TeksiloWindowId) {
104        self.inner
105            .borrow_mut()
106            .pending
107            .retain(|_, p| p.window_id != window_id);
108    }
109
110    /// Deliver every completion that is ready, each with its own fresh
111    /// [`EventContext`] over `tree` and a [`NoopWindowOps`] sink. Returns how
112    /// many ran.
113    ///
114    /// A completion is registered only once its future has resolved, so every
115    /// pending entry is ready. This is the test door: a headless
116    /// [`WidgetTree`] has no event loop to route an
117    /// [`AsyncCompletionPayload`], so a test ticks the executor and then calls
118    /// this instead. Completions registered by a callback that runs here wait
119    /// for the next call. Delivery order is registration order.
120    ///
121    /// Window ops a callback performs (`open_window`, …) go to the no-op sink.
122    /// In a running app the event loop delivers completions itself; calling
123    /// this there is harmless (the loop's later delivery finds nothing).
124    pub fn drain_into(&self, tree: &mut WidgetTree) -> usize {
125        let mut ready: Vec<(u64, TeksiloWindowId)> = self
126            .inner
127            .borrow()
128            .pending
129            .iter()
130            .map(|(id, p)| (*id, p.window_id))
131            .collect();
132        ready.sort_by_key(|(id, _)| *id);
133        for (id, window_id) in &ready {
134            tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
135                self.deliver(*id, *window_id, ctx)
136            });
137        }
138        ready.len()
139    }
140
141    /// Number of pending completions (diagnostics / tests).
142    pub fn pending_len(&self) -> usize {
143        self.inner.borrow().pending.len()
144    }
145}
146
147/// `Send` payload posted through [`AppEventPoster`](crate::AppEventPoster) when
148/// an async task completes. `teksilo-app` downcasts it, routes to the target
149/// window's tree, and calls [`AsyncCompletionHandle::deliver`] with a fresh
150/// context. Carries only ids — the (`!Send`) callback stays in the registry.
151#[derive(Debug, Clone, Copy)]
152pub struct AsyncCompletionPayload {
153    pub id: u64,
154    pub window_id: TeksiloWindowId,
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::widget_tree::WidgetTree;
161    use crate::window::NoopWindowOps;
162    use std::cell::Cell;
163    use std::rc::Rc;
164
165    #[test]
166    fn deliver_runs_callback_with_fresh_context() {
167        let handle = AsyncCompletionHandle::new();
168        let win = TeksiloWindowId::new(1);
169        let ran = Rc::new(Cell::new(false));
170        let flag = ran.clone();
171        let id = handle.register(win, Box::new(move |_ctx| flag.set(true)));
172        assert_eq!(handle.pending_len(), 1);
173
174        let mut tree = WidgetTree::new();
175        tree.run_with_event_context(&mut NoopWindowOps, |ctx| handle.deliver(id, win, ctx));
176
177        assert!(ran.get(), "callback must run with the fresh context");
178        assert_eq!(
179            handle.pending_len(),
180            0,
181            "delivered completion must be removed"
182        );
183    }
184
185    #[test]
186    fn drain_into_delivers_every_pending_completion_in_order() {
187        let handle = AsyncCompletionHandle::new();
188        let order = Rc::new(RefCell::new(Vec::new()));
189        for n in 0..3 {
190            let log = order.clone();
191            handle.register(
192                TeksiloWindowId::new(n),
193                Box::new(move |_ctx| log.borrow_mut().push(n)),
194            );
195        }
196        let mut tree = WidgetTree::new();
197        assert_eq!(handle.drain_into(&mut tree), 3);
198        assert_eq!(*order.borrow(), vec![0, 1, 2]);
199        assert_eq!(handle.pending_len(), 0);
200        assert_eq!(handle.drain_into(&mut tree), 0, "nothing left to deliver");
201    }
202
203    #[test]
204    fn purge_window_drops_only_that_windows_completions() {
205        let handle = AsyncCompletionHandle::new();
206        let win = TeksiloWindowId::new(7);
207        handle.register(win, Box::new(|_ctx| {}));
208        handle.register(win, Box::new(|_ctx| {}));
209        handle.register(TeksiloWindowId::new(8), Box::new(|_ctx| {}));
210        assert_eq!(handle.pending_len(), 3);
211
212        handle.purge_window(win);
213        assert_eq!(
214            handle.pending_len(),
215            1,
216            "only the other window's completion survives"
217        );
218    }
219
220    #[test]
221    fn deliver_to_mismatched_window_does_not_run() {
222        let handle = AsyncCompletionHandle::new();
223        let win = TeksiloWindowId::new(3);
224        let ran = Rc::new(Cell::new(false));
225        let flag = ran.clone();
226        let id = handle.register(win, Box::new(move |_ctx| flag.set(true)));
227
228        let mut tree = WidgetTree::new();
229        tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
230            handle.deliver(id, TeksiloWindowId::new(999), ctx)
231        });
232        assert!(
233            !ran.get(),
234            "a window-mismatched delivery must not run the callback"
235        );
236    }
237}