From 956e4109f90740a1a00b3428b0daa5367014654d Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Thu, 11 Jan 2024 09:47:51 -0800 Subject: [PATCH] Added InvalidationBatch --- CHANGELOG.md | 7 ++ examples/invalidation-guard.rs | 82 +++++++++++++++++++ src/value.rs | 142 ++++++++++++++++++++++++++++----- 3 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 examples/invalidation-guard.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b960f01..b3b3461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `RgbaPicker` picks `Color` - `ComponentPicker` is a picker of various `ColorComponent` implementors. It has constructors for each +- `InvalidationBatch` is a type that can batch invalidation requests being made + by a background task. This can be useful if the background task is updating a + variety of `Dynamic`s, but wish to limit redrawing the interface until the + task has completed its updates. + + This type does not prevent redraws from being performed due to the operating + system or other threads requeseting them. [99]: https://github.com/khonsulabs/cushy/issues/99 [120]: https://github.com/khonsulabs/cushy/issues/120 diff --git a/examples/invalidation-guard.rs b/examples/invalidation-guard.rs new file mode 100644 index 0000000..f8e3ba4 --- /dev/null +++ b/examples/invalidation-guard.rs @@ -0,0 +1,82 @@ +//! This example shows how to use a [`InvalidationBatch`] in a background task +//! to synchronize when the user interface is updated/invalidated. +//! +//! This does not prevent the user interface from displaying the intermediate +//! state if it is redrawn for other reasons or by other threads. For example, +//! if the user resizes the window, the window will be redrawn during the +//! resize, and the current values of the dynamic values will be used. +use std::time::Duration; + +use cushy::value::{Destination, Dynamic, InvalidationBatch, Source}; +use cushy::widget::MakeWidget; +use cushy::widgets::grid::{GridDimension, GridWidgets}; +use cushy::widgets::progress::Progressable; +use cushy::widgets::Grid; +use cushy::Run; + +// This task will update both `progress_a` and `progress_b` at varying times, +// but the user interface will only be refreshed when the `InvalidationBatch` is +// dropped. +fn background_task(progress_a: Dynamic, progress_b: Dynamic) { + loop { + InvalidationBatch::batch(|_batch| { + // This set of operations has a net effect of incrementing + // progress_a, and adding 5 to progress_b. But the operations are + // are done by first adding double the amount, waiting for a bit, + // then subtracting to get back to the desired loop behavior. + // + // This is a convoluted way to simulate having a complex operation + // in a background thread that a user wishes to synchronize the user + // interface updates to. The specific operations here aren't + // important. The important part is that all invalidation related to + // the changes to these widgets is delayed until this batch is + // executed, which happens when dropped or by using the batch + // parameter to this function to invoke them when desired. + progress_a.set(progress_a.get().wrapping_add(1)); + progress_b.set(progress_b.get().wrapping_add(2)); + std::thread::sleep(Duration::from_millis(100)); + progress_b.set(progress_b.get().wrapping_add(2)); + std::thread::sleep(Duration::from_millis(100)); + progress_a.set(progress_a.get().wrapping_add(1)); + progress_b.set(progress_b.get().wrapping_add(1)); + }); + // We sleep for 300 additional milliseconds to make the average loop + // take half a second. The progress will only ever be refreshed by this + // thread when the `progress_a` has been incremented by 2, and + // `progress_b` has been incremented by 5. + std::thread::sleep(Duration::from_millis(300)); + } +} + +fn main() -> cushy::Result { + const EXPLANATION: &str = "This example uses a background task that updates these progress values in such a way that it only requests this window be redrawn when the first has been incremented by 2 and the second has been incremented by 5. For fun, try resizing the window to force the window to redraw and observing that the intermediate states can still be seen."; + let progress_a = Dynamic::new(0); + let progress_a_text = progress_a.map_each(ToString::to_string); + let progress_b = Dynamic::new(0); + let progress_b_text = progress_b.map_each(ToString::to_string); + + std::thread::spawn({ + let progress_a = progress_a.clone(); + let progress_b = progress_b.clone(); + move || background_task(progress_a, progress_b) + }); + + EXPLANATION + .and( + Grid::from_rows( + GridWidgets::new() + .and((progress_a.progress_bar(), progress_a_text)) + .and((progress_b.progress_bar(), progress_b_text)), + ) + .dimensions([ + GridDimension::Fractional { weight: 1 }, + GridDimension::FitContent, + ]), + ) + .into_rows() + .contain() + .pad() + .expand_horizontally() + .centered() + .run() +} diff --git a/src/value.rs b/src/value.rs index 9096a74..35f5345 100644 --- a/src/value.rs +++ b/src/value.rs @@ -1389,12 +1389,12 @@ impl DynamicData { pub fn redraw_when_changed(&self, window: WindowHandle) { let mut state = self.state().expect("deadlocked"); - state.windows.insert(window); + state.invalidation.windows.insert(window); } pub fn invalidate_when_changed(&self, window: WindowHandle, widget: WidgetId) { let mut state = self.state().expect("deadlocked"); - state.widgets.insert((window, widget)); + state.invalidation.widgets.insert((window, widget)); } pub fn map_mut(&self, map: impl FnOnce(Mutable) -> R) -> Result { @@ -1653,13 +1653,47 @@ impl AddAssign for CallbackHandle { } } +#[derive(Default)] +struct InvalidationState { + windows: AHashSet, + widgets: AHashSet<(WindowHandle, WidgetId)>, + wakers: Vec, +} + +impl InvalidationState { + fn invoke(&mut self) { + for (window, widget) in self.widgets.drain() { + window.invalidate(widget); + } + for window in self.windows.drain() { + window.redraw(); + } + for waker in self.wakers.drain(..) { + waker.wake(); + } + } + + fn extend(&mut self, other: &mut InvalidationState) { + self.widgets.extend(other.widgets.drain()); + self.windows.extend(other.windows.drain()); + + for waker in other.wakers.drain(..) { + if !self + .wakers + .iter() + .any(|existing| existing.will_wake(&waker)) + { + self.wakers.push(waker); + } + } + } +} + struct State { wrapped: GenerationalValue, source_callback: CallbackHandle, callbacks: Arc, - windows: AHashSet, - widgets: AHashSet<(WindowHandle, WidgetId)>, - wakers: Vec, + invalidation: InvalidationState, on_disconnect: Option>, readers: usize, } @@ -1672,10 +1706,12 @@ impl State { generation: Generation::default(), }, callbacks: Arc::default(), - windows: AHashSet::new(), + invalidation: InvalidationState { + windows: AHashSet::new(), + wakers: Vec::new(), + widgets: AHashSet::new(), + }, readers: 0, - wakers: Vec::new(), - widgets: AHashSet::new(), on_disconnect: Some(Vec::new()), source_callback: CallbackHandle::default(), } @@ -1684,14 +1720,8 @@ impl State { fn note_changed(&mut self) -> ChangeCallbacks { self.wrapped.generation = self.wrapped.generation.next(); - for (window, widget) in self.widgets.drain() { - window.invalidate(widget); - } - for window in self.windows.drain() { - window.redraw(); - } - for waker in self.wakers.drain(..) { - waker.wake(); + if !InvalidationBatch::take_invalidations(&mut self.invalidation) { + self.invalidation.invoke(); } ChangeCallbacks { @@ -1714,7 +1744,7 @@ impl State { fn cleanup(&mut self) -> StateCleanup { StateCleanup { on_disconnect: self.on_disconnect.take(), - wakers: std::mem::take(&mut self.wakers), + wakers: std::mem::take(&mut self.invalidation.wakers), } } } @@ -2208,7 +2238,7 @@ impl<'a, T> Future for BlockUntilUpdatedFuture<'a, T> { return Poll::Ready(false); } - state.wakers.push(cx.waker().clone()); + state.invalidation.wakers.push(cx.waker().clone()); Poll::Pending } } @@ -3508,6 +3538,82 @@ where } } +/// A batch of invalidations across one or more windows. +/// +/// This type helps background tasks synchronize when to invalidate or redraw a +/// widget. Without this type, if a tracked dynamic is changed, the window is +/// immediately sent a request to redraw itself. These requests are batched to +/// ensure efficiency, but if a background task is updating several dynamics +/// independent of one another, it may desire that those updates only trigger +/// one redraw per "step". +/// +/// The closure invoked by [`InvalidationBatch::batch`] will gather all +/// invalidations into a single batch that can be executed by the handle +/// provided or automatically when the closure returns. +pub struct InvalidationBatch<'a>(&'a RefCell); + +#[derive(Default)] +struct InvalidationBatchGuard { + nesting: usize, + state: InvalidationState, +} + +thread_local! { + static GUARD: RefCell = RefCell::default(); +} + +impl InvalidationBatch<'_> { + /// Executes `batched` gathering all tracked invalidations into a shared + /// batch. + /// + /// The closure accepts an `&InvalidationBatch<'_>` parameter which can be + /// used to [`invoke()`](Self::invoke) the batch on-demand while during + /// `batched`. + /// + /// This function supports nested invocation. When nested, only the + /// outermost batch can manually invoke. When the outermost batch's callback + /// ends, any pending invalidations are invoked automatically. + pub fn batch(batched: impl FnOnce(&InvalidationBatch<'_>)) { + GUARD.with(|guard| { + let mut batch = guard.borrow_mut(); + batch.nesting += 1; + drop(batch); + + batched(&InvalidationBatch(guard)); + + let mut batch = guard.borrow_mut(); + batch.nesting -= 1; + if batch.nesting == 0 { + batch.state.invoke(); + } + }); + } + + /// Invokes all pending invalidations. + /// + /// This function is a no-op if `self` is a nested batch. Only the first batch of each thread + pub fn invoke(&self) { + let mut batch = self.0.borrow_mut(); + if batch.nesting == 1 { + batch.state.invoke(); + } + } + + #[must_use] + fn take_invalidations(state: &mut InvalidationState) -> bool { + GUARD.with(|guard| { + let mut batch = guard.borrow_mut(); + if batch.nesting > 0 { + // A batch is active on this thread + batch.state.extend(state); + true + } else { + false + } + }) + } +} + #[test] fn map_cycle_is_finite() { crate::initialize_tracing();