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;