Skip to main content

egui_reactor/
hooks.rs

1//! The hooks: `use_state`, `use_handle`, `use_memo`, `use_effect`, `use_persisted`,
2//! `use_animate`.
3
4use std::any::Any;
5use std::hash::{DefaultHasher, Hash, Hasher};
6use std::panic::Location;
7
8use crate::cx::{Cx, location_key};
9use serde::Serialize;
10use serde::de::DeserializeOwned;
11
12use crate::state::{Handle, State};
13
14/// Hold `T` in the store, initialising it on the first visit.
15///
16/// Returns a guard that derefs to `T`. The guard borrows the store, not the
17/// `Cx`, so sibling event handlers can each take it mutably in turn.
18#[track_caller]
19pub fn use_state<'s, T: 'static>(cx: &mut Cx<'s, '_>, init: impl FnOnce() -> T) -> State<'s, T> {
20    let location = Location::caller();
21    let store = cx.store;
22    let id = cx.scope_id().with(location_key(location));
23    let slot = store.slot(id, location, || Box::new(init()) as Box<dyn Any>);
24    State::new(store, slot, location)
25}
26
27/// Like [`use_state`], but returns a `Copy` [`Handle`] instead of a guard.
28#[track_caller]
29pub fn use_handle<'s, T: 'static>(cx: &mut Cx<'s, '_>, init: impl FnOnce() -> T) -> Handle<'s, T> {
30    let location = Location::caller();
31    let store = cx.store;
32    let id = cx.scope_id().with(location_key(location));
33    let slot = store.slot(id, location, || Box::new(init()) as Box<dyn Any>);
34    Handle::new(store, slot)
35}
36
37/// Hash `deps` the way `use_effect` and `use_memo` compare them.
38///
39/// `Hash` rather than `PartialEq` so that borrowed deps (`(&str, &[T])`) are
40/// allowed; hash collisions are as unlikely as egui's own id collisions.
41pub(crate) fn deps_hash<D: Hash>(deps: &D) -> u64 {
42    let mut hasher = DefaultHasher::new();
43    deps.hash(&mut hasher);
44    hasher.finish()
45}
46
47/// Compute `f` on the first visit and whenever the hash of `deps` changes.
48///
49/// Returns a reference that lives as long as the store borrow, not as long as
50/// the `&mut Cx`, so a memo can be read next to a `State` guard. Superseded
51/// values are only dropped between passes, because a reference handed out
52/// earlier in the same pass may still point at one.
53#[track_caller]
54pub fn use_memo<'s, D: Hash, T: 'static>(
55    cx: &mut Cx<'s, '_>,
56    deps: D,
57    f: impl FnOnce() -> T,
58) -> &'s T {
59    let location = Location::caller();
60    let store = cx.store;
61    let id = cx.scope_id().with(location_key(location));
62    let slot = store.slot(id, location, || Box::new(()) as Box<dyn Any>);
63
64    let hash = deps_hash(&deps);
65    let cached = match slot.memo_last() {
66        Some(value) if slot.deps_hash() == Some(hash) => Some(value),
67        _ => None,
68    };
69    let value = match cached {
70        Some(value) => value,
71        None => {
72            let value = slot.memo_push(Box::new(f()) as Box<dyn Any>);
73            slot.set_deps_hash(hash);
74            value
75        }
76    };
77    value.downcast_ref::<T>().expect("memo slot type mismatch")
78}
79
80/// Hold `T` in the store *and* in the app's storage, keyed by `key`.
81///
82/// Unlike every other hook the id comes from `key` alone, not from the call
83/// site: a call site moves whenever the source is edited, and saved data has to
84/// survive that (3.4). Two calls with the same key therefore share one value,
85/// and calling it twice in one pass is a collision like any other.
86///
87/// The value is restored by [`crate::Store::load_persisted`] before the first
88/// pass and written back by [`crate::Store::save_persisted`]; the runner wires
89/// both to eframe's storage.
90#[track_caller]
91pub fn use_persisted<'s, T: Serialize + DeserializeOwned + 'static>(
92    cx: &mut Cx<'s, '_>,
93    key: &str,
94    init: impl FnOnce() -> T,
95) -> State<'s, T> {
96    fn to_json<T: Serialize + 'static>(value: &dyn Any) -> Option<String> {
97        serde_json::to_string(value.downcast_ref::<T>()?).ok()
98    }
99
100    let location = Location::caller();
101    let store = cx.store;
102    let slot = store.persisted_slot(key, location, to_json::<T>, || {
103        let restored =
104            store
105                .persisted_json(key)
106                .and_then(|json| match serde_json::from_str::<T>(&json) {
107                    Ok(value) => Some(value),
108                    Err(err) => {
109                        log::warn!(
110                            "egui-reactor: persisted value {key:?} could not be read: {err}"
111                        );
112                        None
113                    }
114                });
115        Box::new(restored.unwrap_or_else(init)) as Box<dyn Any>
116    });
117    State::new(store, slot, location)
118}
119
120/// Marker for an effect body that returns no cleanup.
121pub struct NoCleanup;
122
123/// Marker for an effect body that returns a cleanup closure.
124pub struct FnCleanup;
125
126/// What an effect body may return.
127///
128/// The `Marker` type parameter exists only to keep the `()` and
129/// `FnOnce() + 'static` impls from overlapping; it is always inferred.
130pub trait IntoCleanup<Marker> {
131    /// Convert into a stored cleanup, if any.
132    fn into_cleanup(self) -> Option<Box<dyn FnOnce()>>;
133}
134
135impl IntoCleanup<NoCleanup> for () {
136    fn into_cleanup(self) -> Option<Box<dyn FnOnce()>> {
137        None
138    }
139}
140
141impl<F: FnOnce() + 'static> IntoCleanup<FnCleanup> for F {
142    fn into_cleanup(self) -> Option<Box<dyn FnOnce()>> {
143        Some(Box::new(self))
144    }
145}
146
147/// Run `f` on the first visit and whenever the hash of `deps` changes.
148///
149/// The body runs *at the call site*, not after the pass, so it can borrow
150/// locals and `State` guards. The cleanup it returns is stored and run before
151/// the next body and on unmount.
152#[track_caller]
153pub fn use_effect<D, C, M>(cx: &mut Cx<'_, '_>, deps: D, f: impl FnOnce() -> C)
154where
155    D: Hash,
156    C: IntoCleanup<M>,
157{
158    let location = Location::caller();
159    let store = cx.store;
160    let id = cx.scope_id().with(location_key(location));
161    let slot = store.slot(id, location, || Box::new(()) as Box<dyn Any>);
162
163    let hash = deps_hash(&deps);
164    if slot.deps_hash() == Some(hash) {
165        return;
166    }
167    if let Some(cleanup) = slot.take_cleanup() {
168        cleanup();
169    }
170    let cleanup = f().into_cleanup();
171    slot.set_cleanup(cleanup);
172    slot.set_deps_hash(hash);
173}
174
175/// egui's `animate_bool_with_time_and_easing` behind a hook: 0 while `on` is
176/// false, 1 while it is true, and in between for `time` seconds after `on`
177/// flips, eased with `cubic_out`.
178///
179/// egui's `AnimationManager` owns the value and asks for the repaints while it
180/// moves, so nothing is stored in the `Store` and the repaint policy (5.6) is
181/// untouched. No `Store` slot means no sweep either: the animation of a
182/// component that stops being drawn stays in egui's memory at its last value,
183/// which is what `animate_bool` does for everyone.
184#[track_caller]
185pub fn use_animate(cx: &mut Cx<'_, '_>, on: bool, time: f32) -> f32 {
186    use_animate_with(cx, on, time, egui::emath::easing::cubic_out)
187}
188
189/// [`use_animate`] with an easing of the caller's choosing (`egui::emath::easing`).
190#[track_caller]
191pub fn use_animate_with(cx: &mut Cx<'_, '_>, on: bool, time: f32, easing: fn(f32) -> f32) -> f32 {
192    let id = cx.scope_id().with(location_key(Location::caller()));
193    cx.ctx()
194        .animate_bool_with_time_and_easing(id, on, time, easing)
195}