Skip to main content

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}