Skip to main content

frus_widgets/
host.rs

1//! **What a component asks of the application around it.**
2//!
3//! A component builds widgets. Some of what an interface does is not a widget: the theme
4//! the whole window is dressed in, the language, the zoom; a field that should take the
5//! focus, a list that should scroll to its top; a file to read on another thread, and what
6//! to do with it when it is read. Those belong to the application and the shell, and a
7//! component reaches them from here, from a handler or a build alike:
8//!
9//! ```
10//! use frus_widgets::{host, ThemeMode};
11//!
12//! host::app().set_theme_mode(ThemeMode::Dark);
13//! host::focus("email");
14//! host::spawn(|| 6 * 7, |answer| assert_eq!(answer, 42));
15//! # let _ = host::take_effects();
16//! ```
17//!
18//! Nothing here runs anything. [`App`] keeps what the application is dressed in, and the
19//! shell reads it every frame; an *effect* is a request, kept until the shell takes it
20//! with [`take_effects`] after the frame and runs it as it runs any other effect.
21
22use std::cell::RefCell;
23use std::future::Future;
24use std::hash::{Hash, Hasher};
25use std::pin::Pin;
26use std::rc::Rc;
27use std::sync::{Arc, Mutex};
28use std::time::Duration;
29
30use crate::callback::Callback;
31use crate::component::request_rebuild;
32use crate::locale::Locale;
33use crate::localizations::Localizations;
34use crate::media::Brightness;
35use crate::scrollposition::ScrollTo;
36use crate::sheet::SheetTo;
37use crate::theme::{Theme, ThemeMode};
38
39// ---------------------------------------------------------------------------------------
40// The application's own settings
41// ---------------------------------------------------------------------------------------
42
43struct Settings {
44    title: String,
45    theme: Theme,
46    dark_theme: Option<Theme>,
47    theme_mode: ThemeMode,
48    density: f32,
49    locale: Option<Locale>,
50    supported_locales: Vec<Locale>,
51    localizations: Option<Rc<dyn Localizations>>,
52}
53
54impl Default for Settings {
55    fn default() -> Self {
56        Self {
57            title: "frus".to_string(),
58            theme: Theme::light(),
59            dark_theme: None,
60            theme_mode: ThemeMode::System,
61            density: 1.0,
62            locale: None,
63            supported_locales: Vec::new(),
64            localizations: None,
65        }
66    }
67}
68
69thread_local! {
70    static SETTINGS: RefCell<Settings> = RefCell::new(Settings::default());
71    static EFFECTS: RefCell<Vec<Effect>> = const { RefCell::new(Vec::new()) };
72}
73
74/// The application the interface is part of, as a component sees it: a handle on the
75/// settings the shell reads every frame — the theme, the language, the zoom, the title.
76///
77/// Changing one asks for a rebuild, so the next frame is built with it. It is the
78/// interface's own thread that owns them: there is one set per thread, and a handle is
79/// only a way to reach it.
80#[derive(Clone, Copy, Default, Debug)]
81pub struct App;
82
83/// The handle on the application's settings.
84pub fn app() -> App {
85    App
86}
87
88macro_rules! setting {
89    ($(#[$doc:meta])* $get:ident, $set:ident: $ty:ty, $field:ident) => {
90        $(#[$doc])*
91        #[inline(never)]
92        pub fn $get(&self) -> $ty {
93            SETTINGS.with(|s| s.borrow().$field.clone())
94        }
95
96        #[doc = concat!("Changes [`App::", stringify!($get), "`].")]
97        #[inline(never)]
98        pub fn $set(&self, value: $ty) {
99            SETTINGS.with(|s| s.borrow_mut().$field = value);
100            request_rebuild();
101        }
102    };
103}
104
105impl App {
106    setting!(
107        /// The window's title.
108        title, set_title: String, title
109    );
110    setting!(
111        /// The theme — the light one, where the application has two.
112        theme, set_theme: Theme, theme
113    );
114    setting!(
115        /// The theme for a dark interface, if the application has one.
116        dark_theme, set_dark_theme: Option<Theme>, dark_theme
117    );
118    setting!(
119        /// Which of the two themes is on display: the system's choice by default.
120        theme_mode, set_theme_mode: ThemeMode, theme_mode
121    );
122    setting!(
123        /// A zoom over the whole interface, on top of the system's scale.
124        density, set_density: f32, density
125    );
126    setting!(
127        /// The language the reader picked in the application; `None` follows the device.
128        locale, set_locale: Option<Locale>, locale
129    );
130    setting!(
131        /// The languages the application has, best first.
132        supported_locales, set_supported_locales: Vec<Locale>, supported_locales
133    );
134    setting!(
135        /// The words the framework itself says on the application's behalf, when they are
136        /// not the English ones.
137        localizations, set_localizations: Option<Rc<dyn Localizations>>, localizations
138    );
139
140    /// The theme to dress the window in for a system that is `brightness` — what
141    /// [`theme_mode`](Self::theme_mode) chooses between the two.
142    pub fn resolved_theme(&self, brightness: Brightness) -> Theme {
143        let dark = match self.theme_mode() {
144            ThemeMode::Light => false,
145            ThemeMode::Dark => true,
146            ThemeMode::System => brightness == Brightness::Dark,
147        };
148        match (dark, self.dark_theme()) {
149            (true, Some(theme)) => theme,
150            _ => self.theme(),
151        }
152    }
153}
154
155// ---------------------------------------------------------------------------------------
156// Effects
157// ---------------------------------------------------------------------------------------
158
159/// A request to the shell that is not a widget: something to do once.
160pub enum Effect {
161    /// Focus the widget wrapped in `keyed(key, …)` — the hash of its key.
162    Focus(u64),
163    /// Move the scroll region named by the hash of a key.
164    Scroll(u64, ScrollTo),
165    /// Move the sheet named by the hash of a key.
166    Sheet(u64, SheetTo),
167    /// Run this on another thread; if it answers with a callback, that callback runs on the
168    /// interface's thread.
169    Spawn(Box<dyn FnOnce() -> Option<Callback> + Send>),
170    /// Run this callback after a delay.
171    After(Duration, Callback),
172    /// Await this; if it answers with a callback, that callback runs on the interface's thread.
173    Async(Task),
174}
175
176/// What [`Effect::Async`] awaits: on the shell's executor natively, where it must be `Send`,
177/// and on the browser's own loop on the Web.
178#[cfg(not(target_arch = "wasm32"))]
179pub type Task = Pin<Box<dyn Future<Output = Option<Callback>> + Send>>;
180
181/// What [`Effect::Async`] awaits: on the shell's executor natively, where it must be `Send`,
182/// and on the browser's own loop on the Web.
183#[cfg(target_arch = "wasm32")]
184pub type Task = Pin<Box<dyn Future<Output = Option<Callback>>>>;
185
186/// The hash a key is known by, the same one `keyed(key, …)` and the shell's commands use.
187pub fn key_hash(key: impl Hash) -> u64 {
188    let mut hasher = std::collections::hash_map::DefaultHasher::new();
189    key.hash(&mut hasher);
190    hasher.finish()
191}
192
193#[inline(never)]
194fn push(effect: Effect) {
195    EFFECTS.with(|effects| effects.borrow_mut().push(effect));
196    // Something has to be there to take it: a frame.
197    request_rebuild();
198}
199
200/// Takes the effects asked for since the last time, oldest first. The shell calls this after
201/// each frame.
202#[inline(never)]
203pub fn take_effects() -> Vec<Effect> {
204    EFFECTS.with(|effects| std::mem::take(&mut *effects.borrow_mut()))
205}
206
207/// Asks for the focus to go to the widget wrapped in `keyed(key, …)`. Resolved against the
208/// frame that follows.
209pub fn focus(key: impl Hash) {
210    push(Effect::Focus(key_hash(key)));
211}
212
213/// Asks for the scroll region wrapped in `keyed(key, …)` to go where `to` says.
214pub fn scroll_to(key: impl Hash, to: ScrollTo) {
215    push(Effect::Scroll(key_hash(key), to));
216}
217
218/// Asks for the sheet wrapped in `keyed(key, …)` to go where `to` says.
219pub fn sheet_to(key: impl Hash, to: SheetTo) {
220    push(Effect::Sheet(key_hash(key), to));
221}
222
223/// Runs `work` on another thread — a file to read, something slow — and then `then` with
224/// what it made, on the interface's thread, where it may change state.
225///
226/// `then` runs once. If the work is still going when the component that asked has left the
227/// tree, `then` still runs, so what it touches should say whether it is still there
228/// ([`StateHandle::is_mounted`](crate::StateHandle::is_mounted)).
229pub fn spawn<R: Send + 'static>(
230    work: impl FnOnce() -> R + Send + 'static,
231    then: impl Fn(R) + 'static,
232) {
233    let answer: Arc<Mutex<Option<R>>> = Arc::default();
234    let deliver = {
235        let answer = answer.clone();
236        Callback::new(move || {
237            let made = answer.lock().ok().and_then(|mut slot| slot.take());
238            if let Some(made) = made {
239                then(made);
240            }
241        })
242    };
243    push(Effect::Spawn(Box::new(move || {
244        let made = work();
245        *answer.lock().ok()? = Some(made);
246        Some(deliver)
247    })));
248}
249
250/// Awaits `work` — something that **waits** rather than works: an answer from the person, a
251/// response from a server — and then runs `then` with what it gave, on the interface's thread.
252///
253/// The asynchronous counterpart of [`spawn`], and the one that works on the Web, where
254/// nothing may block: a [`spawn`] there runs on the page's only thread. Natively `work` is
255/// driven by the shell's executor, which waits without holding a thread, so it must be `Send`
256/// and must not block — a blocking call is [`spawn`]'s.
257#[cfg(not(target_arch = "wasm32"))]
258pub fn spawn_async<R: Send + 'static>(
259    work: impl Future<Output = R> + Send + 'static,
260    then: impl Fn(R) + 'static,
261) {
262    let (deliver, answer) = delivery(then);
263    push(Effect::Async(Box::pin(async move {
264        let made = work.await;
265        *answer.lock().ok()? = Some(made);
266        Some(deliver)
267    })));
268}
269
270/// Awaits `work` — something that **waits** rather than works: an answer from the person, a
271/// response from a server — and then runs `then` with what it gave, on the interface's thread.
272///
273/// The asynchronous counterpart of [`spawn`], and the one that works on the Web, where
274/// nothing may block: the browser drives `work` on the page's own loop.
275#[cfg(target_arch = "wasm32")]
276pub fn spawn_async<R: 'static>(
277    work: impl Future<Output = R> + 'static,
278    then: impl Fn(R) + 'static,
279) {
280    let (deliver, answer) = delivery(then);
281    push(Effect::Async(Box::pin(async move {
282        let made = work.await;
283        *answer.lock().ok()? = Some(made);
284        Some(deliver)
285    })));
286}
287
288/// A slot for what the work makes, and the callback that hands it to `then` once it is there.
289fn delivery<R: 'static>(then: impl Fn(R) + 'static) -> (Callback, Arc<Mutex<Option<R>>>) {
290    let answer: Arc<Mutex<Option<R>>> = Arc::default();
291    let deliver = {
292        let answer = answer.clone();
293        Callback::new(move || {
294            let made = answer.lock().ok().and_then(|mut slot| slot.take());
295            if let Some(made) = made {
296                then(made);
297            }
298        })
299    };
300    (deliver, answer)
301}
302
303/// Runs `then` on the interface's thread once `delay` has passed.
304pub fn after(delay: Duration, then: impl Fn() + 'static) {
305    push(Effect::After(delay, Callback::new(then)));
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    /// **What an awaited work gives reaches `then`, once**, and only through the callback the
313    /// effect answers with — on the interface's thread, where the shell runs it.
314    #[test]
315    fn an_awaited_answer_reaches_then_once() {
316        let _ = take_effects();
317        let seen = Rc::new(RefCell::new(Vec::new()));
318        let record = seen.clone();
319        spawn_async(async { 7 }, move |value| record.borrow_mut().push(value));
320        let mut effects = take_effects();
321        assert_eq!(effects.len(), 1);
322        let Some(Effect::Async(mut task)) = effects.pop() else {
323            panic!("spawn_async asks for an awaited effect");
324        };
325        assert!(
326            seen.borrow().is_empty(),
327            "nothing before the work is awaited"
328        );
329        let mut context = std::task::Context::from_waker(std::task::Waker::noop());
330        let std::task::Poll::Ready(Some(deliver)) = task.as_mut().poll(&mut context) else {
331            panic!("a ready work answers with its delivery");
332        };
333        assert!(seen.borrow().is_empty(), "nothing before the delivery runs");
334        deliver.call();
335        deliver.call();
336        assert_eq!(*seen.borrow(), vec![7]);
337    }
338
339    #[test]
340    fn a_setting_is_read_back_and_asks_for_a_rebuild() {
341        crate::take_rebuild_request();
342        app().set_theme_mode(ThemeMode::Dark);
343        assert_eq!(app().theme_mode(), ThemeMode::Dark);
344        assert!(crate::rebuild_requested());
345        app().set_title("hello".into());
346        assert_eq!(app().title(), "hello");
347    }
348
349    #[test]
350    fn the_mode_chooses_between_the_two_themes() {
351        app().set_theme(Theme::light());
352        app().set_dark_theme(Some(Theme::dark()));
353        let light = Theme::light().background;
354        app().set_theme_mode(ThemeMode::Light);
355        assert_eq!(app().resolved_theme(Brightness::Dark).background, light);
356        app().set_theme_mode(ThemeMode::Dark);
357        assert_ne!(app().resolved_theme(Brightness::Light).background, light);
358        app().set_theme_mode(ThemeMode::System);
359        assert_eq!(app().resolved_theme(Brightness::Light).background, light);
360        assert_ne!(app().resolved_theme(Brightness::Dark).background, light);
361    }
362
363    #[test]
364    fn a_dark_mode_with_no_dark_theme_stays_light() {
365        app().set_theme(Theme::light());
366        app().set_dark_theme(None);
367        app().set_theme_mode(ThemeMode::Dark);
368        assert_eq!(
369            app().resolved_theme(Brightness::Dark).background,
370            Theme::light().background
371        );
372    }
373
374    #[test]
375    fn effects_are_kept_in_order_and_taken_once() {
376        let _ = take_effects();
377        focus("name");
378        scroll_to("list", ScrollTo::start());
379        after(Duration::from_millis(5), || {});
380        let effects = take_effects();
381        assert_eq!(effects.len(), 3);
382        assert!(matches!(effects[0], Effect::Focus(h) if h == key_hash("name")));
383        assert!(matches!(effects[1], Effect::Scroll(h, _) if h == key_hash("list")));
384        assert!(matches!(effects[2], Effect::After(d, _) if d == Duration::from_millis(5)));
385        assert!(take_effects().is_empty(), "taken once");
386    }
387
388    #[test]
389    fn spawned_work_comes_back_as_a_callback_that_hands_over_the_answer() {
390        let _ = take_effects();
391        let seen = Rc::new(RefCell::new(None));
392        let put = seen.clone();
393        spawn(|| 6 * 7, move |answer| *put.borrow_mut() = Some(answer));
394        let mut effects = take_effects();
395        let Some(Effect::Spawn(task)) = effects.pop() else {
396            panic!("a spawn is an effect");
397        };
398        // The shell runs it on another thread; the callback it returns is a Send handle.
399        let callback = std::thread::spawn(task)
400            .join()
401            .expect("the work ran")
402            .expect("and answered");
403        assert_eq!(*seen.borrow(), None, "nothing runs until it is delivered");
404        callback.call();
405        assert_eq!(*seen.borrow(), Some(42));
406        callback.call();
407        assert_eq!(
408            *seen.borrow(),
409            Some(42),
410            "and it hands the answer over once"
411        );
412    }
413}