Skip to main content

teksilo_core/off_thread/
repaint_trigger.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`RepaintTrigger`]: a widget whose content changes off the UI thread
5//! repaints, relayouts or takes the change in, and only its window wakes.
6
7use std::sync::{Arc, Weak};
8
9use teksilo_canvas::sync::{AtomicBool, AtomicU32, AtomicU64, Mutex, Ordering};
10use teksilo_canvas::wake::{RedrawWaker, WakeFlag, WakeKind};
11
12/// Marks the widgets it is attached to for repaint, relayout or a pull
13/// from any thread, and wakes only their windows.
14///
15/// For content that changes off the UI thread: a terminal's child printing,
16/// a background task's progress, a status a service publishes. The producer
17/// stores the new state where the widget will read it, *then* requests; the
18/// widget reads it in the frame the request wakes. Requests coalesce: one
19/// wake per window until the widget has consumed the request.
20///
21/// - [`request_repaint`](Self::request_repaint): only what the widget paints
22///   changed. Its window redraws, and the widget alone repaints.
23/// - [`request_relayout`](Self::request_relayout): its size or what layout
24///   reads changed. It and its ancestors relayout, also in a window that
25///   draws nothing.
26/// - [`request_pull`](Self::request_pull): the widget has data to take
27///   in on the UI thread whether or not it is shown, through the hook it
28///   registered with `BuildContext::on_trigger_pull`. A widget that is not
29///   shown takes it in without its window drawing a frame.
30///
31/// A relayout or a pull reaches a window that draws nothing (minimised,
32/// covered, its frames withheld by the compositor), whose event loop then
33/// runs the layout half of a frame for it; a repaint waits there for the
34/// window to draw again.
35///
36/// Attach it in every `build()` with `BuildContext::attach_repaint_trigger`;
37/// a rebuild or the widget's destruction releases the attachment, and the
38/// tree's drop releases the rest. One trigger may be attached to several
39/// widgets, in several windows. `Send + Sync`; a clone shares the trigger.
40///
41/// Do not request from `paint()` or a frame-tick effect: the widget would
42/// repaint at display rate. Inside an event handler, the widget's own
43/// invalidation (`EventContext::request_repaint`) is cheaper.
44#[derive(Clone, Default)]
45pub struct RepaintTrigger {
46    shared: Arc<TriggerShared>,
47}
48
49/// What a [`RepaintTrigger`] did. Monotonic counters.
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
51#[non_exhaustive]
52pub struct RepaintTriggerStats {
53    /// Requests of every kind.
54    pub requests: u64,
55    /// Window wakes issued.
56    pub wakes: u64,
57    /// Requests that found at least one attached widget and woke nobody:
58    /// merged into a wake already on its way.
59    pub wakes_coalesced: u64,
60    /// Paints that consumed a pending repaint.
61    pub repaints: u64,
62    /// Layout passes or activations that consumed a pending relayout.
63    pub relayouts: u64,
64    /// Pull hooks run for a pending pull.
65    pub pulls: u64,
66    /// Widgets it is attached to.
67    pub attachments: u32,
68}
69
70/// What a widget's pull hook (`BuildContext::on_trigger_pull`) did with
71/// the data it took in.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73#[non_exhaustive]
74pub enum PullOutcome {
75    /// Nothing the widget shows changed.
76    Unchanged,
77    /// What it paints changed. A widget that is shown repaints in this frame;
78    /// one that is not repaints when it is shown again, and its window draws
79    /// nothing for it now.
80    Repaint,
81    /// Its size or what layout reads changed: it and its ancestors relayout,
82    /// and it repaints.
83    Relayout,
84}
85
86#[derive(Default)]
87struct TriggerShared {
88    /// The widgets attached, by their wake state. A leaf lock, released
89    /// before anything else is locked or any waker runs.
90    nodes: Mutex<Vec<Weak<NodeWakeState>>>,
91    counters: Arc<TriggerCounters>,
92}
93
94#[derive(Default)]
95pub(crate) struct TriggerCounters {
96    requests: AtomicU64,
97    wakes: AtomicU64,
98    wakes_coalesced: AtomicU64,
99    repaints: AtomicU64,
100    relayouts: AtomicU64,
101    pulls: AtomicU64,
102    attachments: AtomicU32,
103}
104
105#[derive(Clone, Copy)]
106enum Request {
107    Repaint,
108    Relayout,
109    Pull,
110}
111
112impl RepaintTrigger {
113    /// A trigger attached to nothing yet.
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Only what the attached widgets paint changed. Each window wakes at
119    /// most once until the widget's next paint. A widget that is not painted
120    /// (dormant, clipped out) keeps the request for when it is, and costs one
121    /// wake until then, however many requests follow.
122    pub fn request_repaint(&self) {
123        self.request(Request::Repaint);
124    }
125
126    /// The attached widgets' size or what layout reads changed: they and
127    /// their ancestors relayout, and repaint. Each window wakes at most once
128    /// until the widget's next layout pass, and the wake reaches a window
129    /// that draws nothing.
130    pub fn request_relayout(&self) {
131        self.request(Request::Relayout);
132    }
133
134    /// The attached widgets have data to take in on the UI thread, shown or
135    /// not: their pull hook (`BuildContext::on_trigger_pull`) runs in
136    /// their window's next frame. The window draws that frame when a widget
137    /// with a pull pending was shown in its last one, and otherwise runs
138    /// one that draws nothing. Each window wakes at most once until the hook
139    /// has run. A widget with no hook is repainted as by
140    /// [`request_repaint`](Self::request_repaint).
141    pub fn request_pull(&self) {
142        self.request(Request::Pull);
143    }
144
145    /// Whether the trigger is attached to at least one live widget.
146    pub fn is_attached(&self) -> bool {
147        self.shared.counters.attachments.load(Ordering::Relaxed) > 0
148    }
149
150    /// What the trigger did so far.
151    pub fn stats(&self) -> RepaintTriggerStats {
152        let c = &self.shared.counters;
153        RepaintTriggerStats {
154            requests: c.requests.load(Ordering::Relaxed),
155            wakes: c.wakes.load(Ordering::Relaxed),
156            wakes_coalesced: c.wakes_coalesced.load(Ordering::Relaxed),
157            repaints: c.repaints.load(Ordering::Relaxed),
158            relayouts: c.relayouts.load(Ordering::Relaxed),
159            pulls: c.pulls.load(Ordering::Relaxed),
160            attachments: c.attachments.load(Ordering::Relaxed),
161        }
162    }
163
164    /// Whether `self` and `other` are the same trigger.
165    pub fn ptr_eq(&self, other: &Self) -> bool {
166        Arc::ptr_eq(&self.shared, &other.shared)
167    }
168
169    fn request(&self, request: Request) {
170        let counters = &self.shared.counters;
171        counters.requests.fetch_add(1, Ordering::Relaxed);
172        // Under the list lock only long enough to copy it out: no waker runs
173        // and no other lock is taken while it is held.
174        let live: Vec<Arc<NodeWakeState>> = {
175            let mut nodes = self
176                .shared
177                .nodes
178                .lock()
179                .unwrap_or_else(std::sync::PoisonError::into_inner);
180            nodes.retain(|weak| {
181                weak.upgrade()
182                    .is_some_and(|state| !state.detached.load(Ordering::Acquire))
183            });
184            nodes.iter().filter_map(Weak::upgrade).collect()
185        };
186        let mut wakers: Vec<(Arc<dyn RedrawWaker>, WakeKind)> = Vec::new();
187        let mut raised_any = false;
188        for state in &live {
189            let flag = match request {
190                Request::Repaint => &state.repaint,
191                Request::Relayout => &state.relayout,
192                Request::Pull => &state.pull,
193            };
194            if !flag.raise() {
195                continue;
196            }
197            raised_any = true;
198            let Some(waker) = state.waker() else {
199                continue;
200            };
201            // A relayout and a pull keep state current, so they must
202            // reach a window that draws nothing. Whether one draws a frame
203            // is the event loop's to decide, which knows what the window
204            // showed and whether it draws: a guess made here, from another
205            // thread, can be stale in a way that strands the request.
206            let kind = match request {
207                Request::Repaint => WakeKind::Draw,
208                Request::Relayout | Request::Pull => WakeKind::Layout,
209            };
210            // Each window once per request, with the larger kind.
211            let data = Arc::as_ptr(&waker) as *const ();
212            match wakers
213                .iter_mut()
214                .find(|(other, _)| Arc::as_ptr(other) as *const () == data)
215            {
216                Some((_, other_kind)) => *other_kind = (*other_kind).max(kind),
217                None => wakers.push((waker, kind)),
218            }
219        }
220        for (waker, kind) in wakers {
221            waker.wake(kind);
222            counters.wakes.fetch_add(1, Ordering::Relaxed);
223        }
224        if !live.is_empty() && !raised_any {
225            counters.wakes_coalesced.fetch_add(1, Ordering::Relaxed);
226        }
227    }
228
229    /// Detach from `state` only, leaving its other triggers and whatever is
230    /// pending on it: a rebuild whose new `build()` no longer attaches this
231    /// trigger. UI thread.
232    pub(crate) fn detach_state(&self, state: &Arc<NodeWakeState>) {
233        self.shared
234            .nodes
235            .lock()
236            .unwrap_or_else(std::sync::PoisonError::into_inner)
237            .retain(|weak| !std::ptr::eq(weak.as_ptr(), Arc::as_ptr(state)));
238        if state.remove_sink(&self.shared.counters) {
239            self.shared
240                .counters
241                .attachments
242                .fetch_sub(1, Ordering::Relaxed);
243        }
244    }
245
246    /// Attach to `state`: the producer side of register-then-read. The state
247    /// is published to the list under its lock, after which a request sees
248    /// it.
249    pub(crate) fn attach_state(&self, state: &Arc<NodeWakeState>) {
250        state.add_sink(self.shared.counters.clone());
251        let mut nodes = self
252            .shared
253            .nodes
254            .lock()
255            .unwrap_or_else(std::sync::PoisonError::into_inner);
256        // Prune here too: a trigger attached on every rebuild and never
257        // requested would otherwise grow its list without bound.
258        nodes.retain(|weak| {
259            weak.upgrade()
260                .is_some_and(|other| !other.detached.load(Ordering::Acquire))
261        });
262        nodes.push(Arc::downgrade(state));
263        drop(nodes);
264        self.shared
265            .counters
266            .attachments
267            .fetch_add(1, Ordering::Relaxed);
268    }
269
270    #[cfg(test)]
271    pub(crate) fn node_list_len(&self) -> usize {
272        self.shared
273            .nodes
274            .lock()
275            .unwrap_or_else(std::sync::PoisonError::into_inner)
276            .len()
277    }
278}
279
280impl std::fmt::Debug for RepaintTrigger {
281    /// Counters only: never takes the list lock, so it is safe to print from
282    /// anywhere, while a request is in progress included.
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        let s = self.stats();
285        write!(
286            f,
287            "RepaintTrigger(attachments {}, requests {}, wakes {})",
288            s.attachments, s.requests, s.wakes
289        )
290    }
291}
292
293/// One per attached widget, shared by every trigger attached to it.
294pub(crate) struct NodeWakeState {
295    repaint: WakeFlag,
296    relayout: WakeFlag,
297    pull: WakeFlag,
298    detached: AtomicBool,
299    /// The window's waker. A leaf lock.
300    waker: Mutex<Option<Arc<dyn RedrawWaker>>>,
301    /// The counters of every trigger attached here. UI thread only.
302    sinks: Mutex<Vec<Arc<TriggerCounters>>>,
303}
304
305impl NodeWakeState {
306    pub(crate) fn new(waker: Option<Arc<dyn RedrawWaker>>) -> Self {
307        Self {
308            repaint: WakeFlag::new(),
309            relayout: WakeFlag::new(),
310            pull: WakeFlag::new(),
311            detached: AtomicBool::new(false),
312            waker: Mutex::new(waker),
313            sinks: Mutex::new(Vec::new()),
314        }
315    }
316
317    fn waker(&self) -> Option<Arc<dyn RedrawWaker>> {
318        self.waker
319            .lock()
320            .unwrap_or_else(std::sync::PoisonError::into_inner)
321            .clone()
322    }
323
324    /// Install the window's waker, then wake it for whatever was requested
325    /// while there was none: such a request raised its flag, found no waker,
326    /// and woke nobody.
327    pub(crate) fn set_waker(&self, waker: Option<Arc<dyn RedrawWaker>>) {
328        *self
329            .waker
330            .lock()
331            .unwrap_or_else(std::sync::PoisonError::into_inner) = waker.clone();
332        let Some(waker) = waker else {
333            return;
334        };
335        // Once, with the larger kind owed: a relayout or a pull must reach
336        // a window that draws nothing; a repaint need not.
337        if self.relayout.is_raised() || self.pull.is_raised() {
338            waker.wake(WakeKind::Layout);
339        } else if self.repaint.is_raised() {
340            waker.wake(WakeKind::Draw);
341        }
342    }
343
344    fn add_sink(&self, counters: Arc<TriggerCounters>) {
345        self.sinks
346            .lock()
347            .unwrap_or_else(std::sync::PoisonError::into_inner)
348            .push(counters);
349    }
350
351    /// Drop the counters of one trigger; whether they were here.
352    fn remove_sink(&self, counters: &Arc<TriggerCounters>) -> bool {
353        let mut sinks = self
354            .sinks
355            .lock()
356            .unwrap_or_else(std::sync::PoisonError::into_inner);
357        let before = sinks.len();
358        sinks.retain(|sink| !Arc::ptr_eq(sink, counters));
359        sinks.len() != before
360    }
361
362    fn for_each_sink(&self, f: impl Fn(&TriggerCounters)) {
363        for sink in self
364            .sinks
365            .lock()
366            .unwrap_or_else(std::sync::PoisonError::into_inner)
367            .iter()
368        {
369            f(sink);
370        }
371    }
372
373    /// The paint walker, right before the widget's `paint()`: the repaint
374    /// requested so far is served by it.
375    pub(crate) fn consume_repaint(&self) {
376        if self.repaint.take() {
377            self.for_each_sink(|c| {
378                c.repaints.fetch_add(1, Ordering::Relaxed);
379            });
380        }
381    }
382
383    /// The pre-pass, or an activation: take a pending relayout.
384    pub(crate) fn take_relayout(&self) -> bool {
385        let taken = self.relayout.take();
386        if taken {
387            self.for_each_sink(|c| {
388                c.relayouts.fetch_add(1, Ordering::Relaxed);
389            });
390        }
391        taken
392    }
393
394    /// Leave a relayout for the widget's activation, without waking.
395    pub(crate) fn keep_relayout_for_activation(&self) {
396        self.relayout.park();
397    }
398
399    /// The pre-pass: take a pending pull, counting the hook run for it.
400    pub(crate) fn take_pull(&self) -> bool {
401        let taken = self.pull.take();
402        if taken {
403            self.for_each_sink(|c| {
404                c.pulls.fetch_add(1, Ordering::Relaxed);
405            });
406        }
407        taken
408    }
409
410    /// The pre-pass, deciding whether to mark the widget for paint before
411    /// the walker takes the request: an RMW, see `WakeFlag::peek`.
412    pub(crate) fn repaint_pending(&self) -> bool {
413        self.repaint.peek()
414    }
415
416    /// Whether a relayout or a pull is pending. Advisory: the UI thread
417    /// choosing a frame or a frame that draws nothing, either of which takes
418    /// it in.
419    pub(crate) fn state_pending(&self) -> bool {
420        self.relayout.is_raised() || self.pull.is_raised()
421    }
422
423    /// Release the attachment: no request reaches it again, and its window's
424    /// waker is let go here, on the UI thread. Idempotent.
425    pub(crate) fn detach(&self) {
426        if self.detached.swap(true, Ordering::AcqRel) {
427            return;
428        }
429        let waker = self
430            .waker
431            .lock()
432            .unwrap_or_else(std::sync::PoisonError::into_inner)
433            .take();
434        drop(waker);
435        self.for_each_sink(|c| {
436            c.attachments.fetch_sub(1, Ordering::Relaxed);
437        });
438    }
439}
440
441#[cfg(all(test, not(teksilo_loom)))]
442mod tests;
443
444#[cfg(all(test, teksilo_loom))]
445mod loom_tests;