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