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::hash::{Hash, Hasher};
24use std::rc::Rc;
25use std::sync::{Arc, Mutex};
26use std::time::Duration;
27
28use crate::callback::Callback;
29use crate::component::request_rebuild;
30use crate::locale::Locale;
31use crate::localizations::Localizations;
32use crate::media::Brightness;
33use crate::scrollposition::ScrollTo;
34use crate::sheet::SheetTo;
35use crate::theme::{Theme, ThemeMode};
36
37// ---------------------------------------------------------------------------------------
38// The application's own settings
39// ---------------------------------------------------------------------------------------
40
41struct Settings {
42    title: String,
43    theme: Theme,
44    dark_theme: Option<Theme>,
45    theme_mode: ThemeMode,
46    density: f32,
47    locale: Option<Locale>,
48    supported_locales: Vec<Locale>,
49    localizations: Option<Rc<dyn Localizations>>,
50}
51
52impl Default for Settings {
53    fn default() -> Self {
54        Self {
55            title: "frus".to_string(),
56            theme: Theme::light(),
57            dark_theme: None,
58            theme_mode: ThemeMode::System,
59            density: 1.0,
60            locale: None,
61            supported_locales: Vec::new(),
62            localizations: None,
63        }
64    }
65}
66
67thread_local! {
68    static SETTINGS: RefCell<Settings> = RefCell::new(Settings::default());
69    static EFFECTS: RefCell<Vec<Effect>> = const { RefCell::new(Vec::new()) };
70}
71
72/// The application the interface is part of, as a component sees it: a handle on the
73/// settings the shell reads every frame — the theme, the language, the zoom, the title.
74///
75/// Changing one asks for a rebuild, so the next frame is built with it. It is the
76/// interface's own thread that owns them: there is one set per thread, and a handle is
77/// only a way to reach it.
78#[derive(Clone, Copy, Default, Debug)]
79pub struct App;
80
81/// The handle on the application's settings.
82pub fn app() -> App {
83    App
84}
85
86macro_rules! setting {
87    ($(#[$doc:meta])* $get:ident, $set:ident: $ty:ty, $field:ident) => {
88        $(#[$doc])*
89        pub fn $get(&self) -> $ty {
90            SETTINGS.with(|s| s.borrow().$field.clone())
91        }
92
93        #[doc = concat!("Changes [`App::", stringify!($get), "`].")]
94        pub fn $set(&self, value: $ty) {
95            SETTINGS.with(|s| s.borrow_mut().$field = value);
96            request_rebuild();
97        }
98    };
99}
100
101impl App {
102    setting!(
103        /// The window's title.
104        title, set_title: String, title
105    );
106    setting!(
107        /// The theme — the light one, where the application has two.
108        theme, set_theme: Theme, theme
109    );
110    setting!(
111        /// The theme for a dark interface, if the application has one.
112        dark_theme, set_dark_theme: Option<Theme>, dark_theme
113    );
114    setting!(
115        /// Which of the two themes is on display: the system's choice by default.
116        theme_mode, set_theme_mode: ThemeMode, theme_mode
117    );
118    setting!(
119        /// A zoom over the whole interface, on top of the system's scale.
120        density, set_density: f32, density
121    );
122    setting!(
123        /// The language the reader picked in the application; `None` follows the device.
124        locale, set_locale: Option<Locale>, locale
125    );
126    setting!(
127        /// The languages the application has, best first.
128        supported_locales, set_supported_locales: Vec<Locale>, supported_locales
129    );
130    setting!(
131        /// The words the framework itself says on the application's behalf, when they are
132        /// not the English ones.
133        localizations, set_localizations: Option<Rc<dyn Localizations>>, localizations
134    );
135
136    /// The theme to dress the window in for a system that is `brightness` — what
137    /// [`theme_mode`](Self::theme_mode) chooses between the two.
138    pub fn resolved_theme(&self, brightness: Brightness) -> Theme {
139        let dark = match self.theme_mode() {
140            ThemeMode::Light => false,
141            ThemeMode::Dark => true,
142            ThemeMode::System => brightness == Brightness::Dark,
143        };
144        match (dark, self.dark_theme()) {
145            (true, Some(theme)) => theme,
146            _ => self.theme(),
147        }
148    }
149}
150
151// ---------------------------------------------------------------------------------------
152// Effects
153// ---------------------------------------------------------------------------------------
154
155/// A request to the shell that is not a widget: something to do once.
156pub enum Effect {
157    /// Focus the widget wrapped in `keyed(key, …)` — the hash of its key.
158    Focus(u64),
159    /// Move the scroll region named by the hash of a key.
160    Scroll(u64, ScrollTo),
161    /// Move the sheet named by the hash of a key.
162    Sheet(u64, SheetTo),
163    /// Run this on another thread; if it answers with a callback, that callback runs on the
164    /// interface's thread.
165    Spawn(Box<dyn FnOnce() -> Option<Callback> + Send>),
166    /// Run this callback after a delay.
167    After(Duration, Callback),
168}
169
170/// The hash a key is known by, the same one `keyed(key, …)` and the shell's commands use.
171pub fn key_hash(key: impl Hash) -> u64 {
172    let mut hasher = std::collections::hash_map::DefaultHasher::new();
173    key.hash(&mut hasher);
174    hasher.finish()
175}
176
177fn push(effect: Effect) {
178    EFFECTS.with(|effects| effects.borrow_mut().push(effect));
179    // Something has to be there to take it: a frame.
180    request_rebuild();
181}
182
183/// Takes the effects asked for since the last time, oldest first. The shell calls this after
184/// each frame.
185pub fn take_effects() -> Vec<Effect> {
186    EFFECTS.with(|effects| std::mem::take(&mut *effects.borrow_mut()))
187}
188
189/// Asks for the focus to go to the widget wrapped in `keyed(key, …)`. Resolved against the
190/// frame that follows.
191pub fn focus(key: impl Hash) {
192    push(Effect::Focus(key_hash(key)));
193}
194
195/// Asks for the scroll region wrapped in `keyed(key, …)` to go where `to` says.
196pub fn scroll_to(key: impl Hash, to: ScrollTo) {
197    push(Effect::Scroll(key_hash(key), to));
198}
199
200/// Asks for the sheet wrapped in `keyed(key, …)` to go where `to` says.
201pub fn sheet_to(key: impl Hash, to: SheetTo) {
202    push(Effect::Sheet(key_hash(key), to));
203}
204
205/// Runs `work` on another thread — a file to read, something slow — and then `then` with
206/// what it made, on the interface's thread, where it may change state.
207///
208/// `then` runs once. If the work is still going when the component that asked has left the
209/// tree, `then` still runs, so what it touches should say whether it is still there
210/// ([`StateHandle::is_mounted`](crate::StateHandle::is_mounted)).
211pub fn spawn<R: Send + 'static>(
212    work: impl FnOnce() -> R + Send + 'static,
213    then: impl Fn(R) + 'static,
214) {
215    let answer: Arc<Mutex<Option<R>>> = Arc::default();
216    let deliver = {
217        let answer = answer.clone();
218        Callback::new(move || {
219            let made = answer.lock().ok().and_then(|mut slot| slot.take());
220            if let Some(made) = made {
221                then(made);
222            }
223        })
224    };
225    push(Effect::Spawn(Box::new(move || {
226        let made = work();
227        *answer.lock().ok()? = Some(made);
228        Some(deliver)
229    })));
230}
231
232/// Runs `then` on the interface's thread once `delay` has passed.
233pub fn after(delay: Duration, then: impl Fn() + 'static) {
234    push(Effect::After(delay, Callback::new(then)));
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn a_setting_is_read_back_and_asks_for_a_rebuild() {
243        crate::take_rebuild_request();
244        app().set_theme_mode(ThemeMode::Dark);
245        assert_eq!(app().theme_mode(), ThemeMode::Dark);
246        assert!(crate::rebuild_requested());
247        app().set_title("hello".into());
248        assert_eq!(app().title(), "hello");
249    }
250
251    #[test]
252    fn the_mode_chooses_between_the_two_themes() {
253        app().set_theme(Theme::light());
254        app().set_dark_theme(Some(Theme::dark()));
255        let light = Theme::light().background;
256        app().set_theme_mode(ThemeMode::Light);
257        assert_eq!(app().resolved_theme(Brightness::Dark).background, light);
258        app().set_theme_mode(ThemeMode::Dark);
259        assert_ne!(app().resolved_theme(Brightness::Light).background, light);
260        app().set_theme_mode(ThemeMode::System);
261        assert_eq!(app().resolved_theme(Brightness::Light).background, light);
262        assert_ne!(app().resolved_theme(Brightness::Dark).background, light);
263    }
264
265    #[test]
266    fn a_dark_mode_with_no_dark_theme_stays_light() {
267        app().set_theme(Theme::light());
268        app().set_dark_theme(None);
269        app().set_theme_mode(ThemeMode::Dark);
270        assert_eq!(
271            app().resolved_theme(Brightness::Dark).background,
272            Theme::light().background
273        );
274    }
275
276    #[test]
277    fn effects_are_kept_in_order_and_taken_once() {
278        let _ = take_effects();
279        focus("name");
280        scroll_to("list", ScrollTo::start());
281        after(Duration::from_millis(5), || {});
282        let effects = take_effects();
283        assert_eq!(effects.len(), 3);
284        assert!(matches!(effects[0], Effect::Focus(h) if h == key_hash("name")));
285        assert!(matches!(effects[1], Effect::Scroll(h, _) if h == key_hash("list")));
286        assert!(matches!(effects[2], Effect::After(d, _) if d == Duration::from_millis(5)));
287        assert!(take_effects().is_empty(), "taken once");
288    }
289
290    #[test]
291    fn spawned_work_comes_back_as_a_callback_that_hands_over_the_answer() {
292        let _ = take_effects();
293        let seen = Rc::new(RefCell::new(None));
294        let put = seen.clone();
295        spawn(|| 6 * 7, move |answer| *put.borrow_mut() = Some(answer));
296        let mut effects = take_effects();
297        let Some(Effect::Spawn(task)) = effects.pop() else {
298            panic!("a spawn is an effect");
299        };
300        // The shell runs it on another thread; the callback it returns is a Send handle.
301        let callback = std::thread::spawn(task)
302            .join()
303            .expect("the work ran")
304            .expect("and answered");
305        assert_eq!(*seen.borrow(), None, "nothing runs until it is delivered");
306        callback.call();
307        assert_eq!(*seen.borrow(), Some(42));
308        callback.call();
309        assert_eq!(
310            *seen.borrow(),
311            Some(42),
312            "and it hands the answer over once"
313        );
314    }
315}