teksilo_core/off_thread.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Content that changes off the UI thread: the registry of what is attached
5//! to the tree's widgets, and the pre-pass that takes the requests in.
6//!
7//! A producer on any thread stores its change and requests through a
8//! [`RepaintTrigger`]; the request raises a flag on the widget's
9//! `NodeWakeState` and, on the clear-to-set edge, wakes the window through
10//! the tree's redraw waker (see [`teksilo_canvas::wake`]). The window's next
11//! frame takes the flags in:
12//!
13//! - **repaint**: taken by the paint walker right before the widget's
14//! `paint()`. The pre-pass only marks a widget whose paint is cached, so a
15//! widget that is not painted (dormant, clipped out) keeps the flag raised
16//! and absorbs a burst of requests in one wake.
17//! - **relayout**: taken by the pre-pass for an active widget, which marks
18//! it and its ancestors for layout, and by `WidgetArena::activate` for one
19//! being woken, so a request made while it slept is not lost.
20//! - **pull**: taken by the pre-pass for every attached widget, active or
21//! not, which runs the widget's pull hook and applies what it returns.
22//!
23//! A relayout and a pull wake the window with a state wake, which reaches
24//! a window that draws nothing. The event loop then asks the tree
25//! ([`WidgetTree::off_thread_needs_frame`]) whether a widget shown in the
26//! last frame has one pending: if so the window draws a frame, which takes it
27//! in and repaints; if not, it runs a frame that draws nothing.
28//!
29//! A widget's wake state outlives its rebuilds: a request raised before or
30//! during one is still pending after it, and its new `build()` attaching a
31//! trigger it had already attached changes nothing. A rebuild releases only
32//! the triggers its new `build()` no longer attaches; the widget's
33//! destruction releases the rest.
34//!
35//! A live image attaches a `LiveImageSource` instead
36//! ([`BuildContext::attach_live_image`](crate::build_context::BuildContext::attach_live_image)).
37//! Its pixels never pass through the tree: a commit raises a flag the
38//! window's renderer takes, and wakes the window, which replays its cached
39//! frame while the renderer uploads. Its size and status reach layout here:
40//! the pre-pass takes every attachment's geometry flag, visible or not, then
41//! reads each source's size and status once and writes them into the
42//! attachment's [`LiveImageSignals`], which the widget binds. A rebuild
43//! detaches a widget's live images and its new `build()` attaches them again;
44//! the textures, which the renderer keys by source, are kept.
45
46mod live_image;
47mod repaint_trigger;
48
49use std::collections::HashMap;
50use std::rc::Rc;
51use std::sync::Arc;
52
53use teksilo_canvas::ImageGeometry;
54use teksilo_canvas::live_image::{LiveImageConsumer, LiveImageId, LiveImageSource, LiveImageStats};
55use teksilo_canvas::wake::RedrawWaker;
56
57pub(crate) use live_image::AttachmentEntry;
58pub use live_image::{LiveImageAttachment, LiveImageSignals};
59pub(crate) use repaint_trigger::NodeWakeState;
60pub use repaint_trigger::{PullOutcome, RepaintTrigger, RepaintTriggerStats};
61
62use crate::widget_id::WidgetId;
63use crate::widget_tree::WidgetTree;
64
65/// A widget's pull hook: runs on the UI thread in the pre-pass after a
66/// `RepaintTrigger::request_pull`.
67pub(crate) type PullHook = Box<dyn FnMut() -> PullOutcome>;
68
69/// What a tree's widgets have attached, and the waker their requests wake.
70#[derive(Default)]
71pub(crate) struct OffThreadRegistry {
72 waker: Option<Arc<dyn RedrawWaker>>,
73 triggers: HashMap<WidgetId, TriggerNode>,
74 /// Each widget's live-image attachments, in the order it attached them.
75 live: HashMap<WidgetId, Vec<Rc<AttachmentEntry>>>,
76}
77
78struct TriggerNode {
79 state: Arc<NodeWakeState>,
80 /// The triggers attached, so a second attach of one is a no-op.
81 triggers: Vec<RepaintTrigger>,
82 hook: Option<PullHook>,
83 /// During a rebuild, the triggers the previous build attached that the
84 /// new one has not attached again yet.
85 previous: Option<Vec<RepaintTrigger>>,
86}
87
88impl TriggerNode {
89 fn new(waker: Option<Arc<dyn RedrawWaker>>) -> Self {
90 Self {
91 // The waker is set before the state is published to any trigger.
92 state: Arc::new(NodeWakeState::new(waker)),
93 triggers: Vec::new(),
94 hook: None,
95 previous: None,
96 }
97 }
98}
99
100impl OffThreadRegistry {
101 pub(crate) fn waker(&self) -> Option<&Arc<dyn RedrawWaker>> {
102 self.waker.as_ref()
103 }
104
105 /// Store the waker and give it to every attached widget, which wakes it
106 /// for whatever was requested while there was none.
107 pub(crate) fn set_waker(&mut self, waker: Option<Arc<dyn RedrawWaker>>) {
108 for node in self.triggers.values() {
109 node.state.set_waker(waker.clone());
110 }
111 for entry in self.live.values().flatten() {
112 entry.consumer.set_waker(waker.clone());
113 }
114 self.waker = waker;
115 }
116
117 /// Release everything attached to `id`, on its destruction.
118 pub(crate) fn cancel_by_widget(&mut self, id: WidgetId) {
119 self.release_triggers(id);
120 self.detach_live(id);
121 }
122
123 /// Release `id`'s wake state, which its triggers and pull hook share,
124 /// and nothing else: its live images are attached apart.
125 fn release_triggers(&mut self, id: WidgetId) {
126 if let Some(node) = self.triggers.remove(&id) {
127 node.state.detach();
128 }
129 }
130
131 /// Widget `id` is about to be rebuilt: its new `build()` attaches its
132 /// triggers and sets its hook again. Its wake state, and whatever is
133 /// pending on it, stays. Its live images are detached: the new `build()`
134 /// attaches them again, and records their current size and status as it
135 /// does.
136 pub(crate) fn begin_rebuild(&mut self, id: WidgetId) {
137 if let Some(node) = self.triggers.get_mut(&id) {
138 node.previous = Some(std::mem::take(&mut node.triggers));
139 node.hook = None;
140 }
141 self.detach_live(id);
142 }
143
144 fn detach_live(&mut self, id: WidgetId) {
145 for entry in self.live.remove(&id).unwrap_or_default() {
146 entry.consumer.detach();
147 }
148 }
149
150 #[cfg(test)]
151 pub(crate) fn trigger_node_count(&self) -> usize {
152 self.triggers.len()
153 }
154}
155
156impl Drop for OffThreadRegistry {
157 fn drop(&mut self) {
158 for node in self.triggers.values() {
159 node.state.detach();
160 }
161 for entry in self.live.values().flatten() {
162 entry.consumer.detach();
163 }
164 }
165}
166
167impl WidgetTree {
168 /// Attach `trigger` to widget `id`. Register-then-read: the widget's wake
169 /// state is published to the trigger before the widget's first paint
170 /// after this build, so a request either lands before that paint reads
171 /// the state or wakes the window. Idempotent per (widget, trigger).
172 pub(crate) fn attach_repaint_trigger(&mut self, id: WidgetId, trigger: &RepaintTrigger) {
173 let waker = self.off_thread.waker.clone();
174 let node = self
175 .off_thread
176 .triggers
177 .entry(id)
178 .or_insert_with(|| TriggerNode::new(waker));
179 if node.triggers.iter().any(|t| t.ptr_eq(trigger)) {
180 return;
181 }
182 let state = node.state.clone();
183 node.triggers.push(trigger.clone());
184 if let Some(arena_node) = self.arena.get_mut(id) {
185 debug_assert!(
186 arena_node.dirty.needs_paint,
187 "a widget attaching in build() is about to paint, which is the read"
188 );
189 arena_node.repaint_wake = Some(state.clone());
190 }
191 // Attached by the build before this rebuild: still published to the
192 // trigger, so there is nothing to register.
193 if let Some(previous) = node.previous.as_mut()
194 && let Some(at) = previous.iter().position(|t| t.ptr_eq(trigger))
195 {
196 previous.remove(at);
197 return;
198 }
199 trigger.attach_state(&state);
200 }
201
202 /// Widget `id`'s rebuild ran its `build()`: release the triggers it no
203 /// longer attached, and the widget's wake state if it attached none and
204 /// set no hook. The live images that `build()` attached stay: they are
205 /// the widget's, not its triggers'.
206 pub(crate) fn finish_off_thread_rebuild(&mut self, id: WidgetId) {
207 let Some(node) = self.off_thread.triggers.get_mut(&id) else {
208 return;
209 };
210 for stale in node.previous.take().unwrap_or_default() {
211 stale.detach_state(&node.state);
212 }
213 if node.triggers.is_empty() && node.hook.is_none() {
214 self.off_thread.release_triggers(id);
215 if let Some(arena_node) = self.arena.get_mut(id) {
216 arena_node.repaint_wake = None;
217 }
218 }
219 }
220
221 /// Set widget `id`'s pull hook, replacing the one an earlier build set.
222 pub(crate) fn set_trigger_pull_hook(&mut self, id: WidgetId, hook: PullHook) {
223 let waker = self.off_thread.waker.clone();
224 let node = self
225 .off_thread
226 .triggers
227 .entry(id)
228 .or_insert_with(|| TriggerNode::new(waker));
229 let state = node.state.clone();
230 node.hook = Some(hook);
231 if let Some(arena_node) = self.arena.get_mut(id) {
232 arena_node.repaint_wake = Some(state);
233 }
234 }
235
236 /// How many widgets have a `RepaintTrigger` attached.
237 pub fn repaint_trigger_count(&self) -> usize {
238 self.off_thread
239 .triggers
240 .values()
241 .filter(|node| !node.triggers.is_empty())
242 .count()
243 }
244
245 /// Attach `source` to widget `id`, in this tree's window, writing its
246 /// size and status into `signals`. Register-then-read: the source sees
247 /// the attachment before its size and status are read, so a change made
248 /// meanwhile is either in what is written or wakes the window.
249 pub(crate) fn attach_live_image(
250 &mut self,
251 id: WidgetId,
252 source: &LiveImageSource,
253 signals: &LiveImageSignals,
254 ) -> LiveImageAttachment {
255 let consumer = source.attach(self.off_thread.waker.clone());
256 let meta = consumer.layout_meta();
257 signals.frame_size.set_if_changed(meta.size);
258 signals.status.set_if_changed(meta.status);
259 let entry = Rc::new(AttachmentEntry::new(consumer, signals.clone(), id));
260 self.off_thread
261 .live
262 .entry(id)
263 .or_default()
264 .push(entry.clone());
265 LiveImageAttachment { entry }
266 }
267
268 /// How many live-image attachments this tree holds.
269 pub fn live_image_attachment_count(&self) -> usize {
270 self.off_thread.live.values().map(Vec::len).sum()
271 }
272
273 /// The consumer of the live image widget `id` shows: the first it
274 /// attached. Found by widget id, so a `WidgetBuilder` wrapper around the
275 /// widget does not hide it.
276 pub fn live_image_consumer(&self, id: WidgetId) -> Option<LiveImageConsumer> {
277 self.first_live(id).map(|e| e.consumer.clone())
278 }
279
280 /// The counters of the live image widget `id` shows: its source's and
281 /// its attachment's.
282 pub fn live_image_stats(&self, id: WidgetId) -> Option<LiveImageStats> {
283 self.first_live(id).map(|e| e.consumer.stats())
284 }
285
286 /// The placement the live image widget `id` recorded at its last layout.
287 pub fn live_image_geometry(&self, id: WidgetId) -> Option<teksilo_canvas::ImageGeometry> {
288 self.first_live(id).and_then(|e| e.geometry())
289 }
290
291 /// Every live-image attachment of this tree, with its widget: what a
292 /// screenshot matches a frame's live quads against.
293 pub fn live_image_attachments(
294 &self,
295 ) -> impl Iterator<Item = (WidgetId, LiveImageConsumer)> + '_ {
296 self.off_thread
297 .live
298 .iter()
299 .flat_map(|(&id, entries)| entries.iter().map(move |e| (id, e.consumer.clone())))
300 }
301
302 fn first_live(&self, id: WidgetId) -> Option<&Rc<AttachmentEntry>> {
303 self.off_thread
304 .live
305 .get(&id)
306 .and_then(|entries| entries.first())
307 }
308
309 /// The layout pre-pass: take in what changed off the UI thread since the
310 /// last frame, triggers first, then live images.
311 pub(crate) fn poll_off_thread(&mut self) {
312 self.poll_triggers();
313 self.poll_live_images();
314 }
315
316 /// The live-image half: for every attachment, visible or not, take its
317 /// geometry flag, then read its source's size and status once per
318 /// source, record them for the paint that follows, and write the
319 /// attachment's Signals where they changed. Two phases: everything is
320 /// collected before a Signal is set, so no registry borrow is held while
321 /// a Signal's observers run.
322 fn poll_live_images(&mut self) {
323 if self.off_thread.live.is_empty() {
324 return;
325 }
326 let mut orphans = Vec::new();
327 let mut resnap = Vec::new();
328 let mut by_source: Vec<(LiveImageId, Vec<Rc<AttachmentEntry>>)> = Vec::new();
329 for (&id, entries) in &self.off_thread.live {
330 if self.arena.get(id).is_none() {
331 orphans.push(id);
332 continue;
333 }
334 // Snapped under a transform an ancestor has changed since: a
335 // transform scope repaints and lays nothing out, so the widget is
336 // laid out again here to snap anew. Between two transforms that
337 // neither snaps under, its placement is the same.
338 if self.arena.is_active(id)
339 && entries.iter().any(|entry| {
340 entry.snapped_under().is_some_and(|then| {
341 let now = self.arena.effective_transform(id);
342 now != then
343 && (ImageGeometry::snaps_under(then) || ImageGeometry::snaps_under(now))
344 })
345 })
346 {
347 resnap.push(id);
348 }
349 for entry in entries {
350 let source = entry.consumer.source().id();
351 match by_source.iter_mut().find(|(other, _)| *other == source) {
352 Some((_, group)) => group.push(entry.clone()),
353 None => by_source.push((source, vec![entry.clone()])),
354 }
355 }
356 }
357 for id in orphans {
358 self.off_thread.cancel_by_widget(id);
359 }
360 for id in resnap {
361 self.arena.mark_needs_layout(id);
362 self.arena.mark_ancestors_need_layout(id);
363 }
364 let mut writes = Vec::new();
365 for (_, group) in by_source {
366 for entry in &group {
367 let _ = entry.consumer.take_geometry();
368 }
369 // Read after every flag of the source is taken: a change made
370 // after this read raised a flag again and wakes the window.
371 let meta = group[0].consumer.source().meta();
372 for entry in group {
373 entry.consumer.record_layout_meta(meta);
374 writes.push((entry.signals.clone(), meta));
375 }
376 }
377 for (signals, meta) in writes {
378 signals.frame_size.set_if_changed(meta.size);
379 signals.status.set_if_changed(meta.status);
380 }
381 }
382
383 /// The trigger half of the pre-pass: take in what attached triggers
384 /// requested since the last frame. Collects first, then marks, so no
385 /// arena borrow is held while a pull hook runs.
386 fn poll_triggers(&mut self) {
387 if self.off_thread.triggers.is_empty() {
388 return;
389 }
390 let mut orphans = Vec::new();
391 let mut relayout = Vec::new();
392 let mut repaint = Vec::new();
393 let mut pulled = Vec::new();
394 for (&id, node) in &mut self.off_thread.triggers {
395 let alive = self.arena.get(id).is_some_and(|arena_node| {
396 arena_node
397 .repaint_wake
398 .as_ref()
399 .is_some_and(|state| Arc::ptr_eq(state, &node.state))
400 });
401 if !alive {
402 orphans.push(id);
403 continue;
404 }
405 if node.state.take_pull() {
406 let outcome = match node.hook.as_mut() {
407 Some(hook) => hook(),
408 None => PullOutcome::Repaint,
409 };
410 pulled.push((id, outcome));
411 }
412 if !self.arena.is_active(id) {
413 // Activation takes a pending relayout; the walker, a repaint.
414 continue;
415 }
416 if node.state.take_relayout() {
417 relayout.push(id);
418 }
419 // Only a widget whose paint is cached needs marking: the walker
420 // paints any other. Peeked, not loaded: a producer whose raise
421 // follows this look must see the frame being delivered (see
422 // `teksilo_canvas::wake`, "Deciding before taking").
423 let cached = self
424 .arena
425 .get(id)
426 .is_some_and(|arena_node| arena_node.cached_paint.is_some());
427 if cached && node.state.repaint_pending() {
428 repaint.push(id);
429 }
430 }
431 for id in orphans {
432 self.off_thread.cancel_by_widget(id);
433 }
434 for (id, outcome) in pulled {
435 self.apply_pull_outcome(id, outcome);
436 }
437 for id in relayout {
438 self.arena.mark_needs_layout(id);
439 self.arena.mark_ancestors_need_layout(id);
440 }
441 for id in repaint {
442 self.arena.mark_needs_paint(id);
443 }
444 }
445
446 /// Apply what a pull hook returned for widget `id`.
447 fn apply_pull_outcome(&mut self, id: WidgetId, outcome: PullOutcome) {
448 let active = self.arena.is_active(id);
449 let shown = self.off_thread_widget_shown(id);
450 match outcome {
451 PullOutcome::Unchanged => {}
452 PullOutcome::Repaint if shown => self.arena.mark_needs_paint(id),
453 // Not shown: drop what it painted, so it repaints whenever it is
454 // painted again, without asking its window for a frame now.
455 PullOutcome::Repaint => {
456 if let Some(node) = self.arena.get_mut(id) {
457 node.cached_paint = None;
458 node.cached_post_paint = None;
459 }
460 }
461 PullOutcome::Relayout if active => {
462 self.arena.mark_needs_layout(id);
463 self.arena.mark_ancestors_need_layout(id);
464 }
465 PullOutcome::Relayout => {
466 if let Some(node) = self.off_thread.triggers.get(&id) {
467 node.state.keep_relayout_for_activation();
468 }
469 }
470 }
471 }
472
473 /// Whether attached widget `id` showed in the last frame: active, painted
474 /// in it, and not made transparent by an opacity of its own (the walker
475 /// stamps a widget painted before it skips one whose opacity is below
476 /// what can be seen).
477 fn off_thread_widget_shown(&self, id: WidgetId) -> bool {
478 self.arena.is_active(id)
479 && crate::motion_visibility::painted_this_frame(&self.arena, id, self.paint_epoch)
480 && self.arena.get(id).is_some_and(|node| {
481 node.opacity_prop
482 .as_ref()
483 .is_none_or(|opacity| opacity.get() >= 1.0 / 512.0)
484 })
485 }
486
487 /// Whether a widget shown in the last frame has a relayout or a pull
488 /// requested off the UI thread pending, or a live image whose size or
489 /// status changed. The event loop asks it on a state wake: a window that
490 /// draws then draws a frame, which takes the change in and repaints;
491 /// otherwise it runs a frame that draws nothing, which keeps layout's
492 /// view of the change current.
493 #[doc(hidden)]
494 pub fn off_thread_needs_frame(&self) -> bool {
495 self.off_thread
496 .triggers
497 .iter()
498 .any(|(&id, node)| node.state.state_pending() && self.off_thread_widget_shown(id))
499 || self.off_thread.live.iter().any(|(&id, entries)| {
500 entries.iter().any(|e| e.consumer.geometry_pending())
501 && self.off_thread_widget_shown(id)
502 })
503 }
504}