Skip to main content

frus_shell/
application.rs

1//! The [`Application`] trait: the contract between an **application** and the
2//! framework. The shell — window, renderer, input, runtime, animations — is generic
3//! over this trait, and the application supplies only its logic.
4//!
5//! A minimal app implements `update` and `view` and nothing else. Every other method
6//! has a default: one theme, no animation, no navigation.
7
8use frus_widgets::localizations::Localizations;
9use frus_widgets::{Brightness, Curve, Locale, Theme, ThemeMode, Widget, WindowInsets};
10
11use crate::command::Command;
12use crate::subscription::Subscription;
13
14/// The application's **lifecycle** state. The framework hands it to
15/// [`Application::on_lifecycle`] at every transition so the app can **react**: pause a
16/// timer or a camera in the background, write a save before being destroyed, and so on.
17#[derive(Copy, Clone, Debug, PartialEq, Eq)]
18pub enum Lifecycle {
19    /// In the **foreground**, visible and **interactive**.
20    Resumed,
21    /// Visible but **not focused** — a notification shade, a system dialog, a
22    /// multi-window layout. The app should not react to input but stays on screen.
23    Inactive,
24    /// In the **background**, **not visible**; on Android the render surface is lost.
25    /// A good moment to release resources and suspend non-essential work.
26    Paused,
27    /// Being **destroyed** — the last chance to persist state, since the process may
28    /// be killed afterwards with no further notice.
29    Detached,
30}
31
32/// How a location is written in a browser's address bar — see [`Application::location`].
33#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
34pub enum LocationStrategy {
35    /// After a `#`: `https://host/app/#/users/42`. Any static host serves it, because the
36    /// server only ever sees the page's own address; a reload comes back to the same screen
37    /// with nothing configured. The default.
38    #[default]
39    Hash,
40    /// As a path under the document's base: `https://host/app/users/42`. The addresses read
41    /// as an ordinary site's would, and the server has to answer every one of them with the
42    /// application's page, which a static host does not do without a rule for it. The base
43    /// is the document's `<base href>`, or `/` without one.
44    Path,
45}
46
47/// What an application supplies to the framework: an Elm-style message model.
48pub trait Application {
49    /// The message type the interface emits and [`Application::update`] consumes.
50    ///
51    /// `Send + 'static`, because effects ([`Command`]) cross threads.
52    type Message: Clone + Send + 'static;
53
54    /// Advances the state in response to a message, and returns the **effects** to
55    /// run: I/O, background tasks and the like. Use [`Command::none`] for none.
56    fn update(&mut self, message: Self::Message) -> Command<Self::Message>;
57
58    /// The effect to run **at startup**, such as an initial load.
59    fn init(&mut self) -> Command<Self::Message> {
60        Command::none()
61    }
62
63    /// Effects the application asked for **outside** an [`update`](Application::update) — from
64    /// a build, say, or from a component that has no message to return them with. The shell
65    /// asks after every frame and after every message, and runs what comes back like any
66    /// other command. An application that returns its effects from `update` never needs it.
67    fn effects(&mut self) -> Command<Self::Message> {
68        Command::none()
69    }
70
71    /// **Where the application is**, as an address: `/users/42?tab=posts`. `None` — the
72    /// default — for an application with no addresses, which the shell then leaves alone.
73    ///
74    /// On the **web** the shell asks after every message and every frame, and shows the
75    /// answer in the address bar: a new location becomes a new entry of the browser's history,
76    /// going back to a location the history already holds goes back in it rather than adding
77    /// one, and the very first answer replaces the address the page was opened at instead of
78    /// adding to it — so a redirect on the way in leaves no entry to be trapped by. Other
79    /// platforms have nowhere to show it yet.
80    fn location(&self) -> Option<String> {
81        None
82    }
83
84    /// Whether the move that led to [`location`](Self::location) **swapped** the page — a
85    /// router's `replace` — rather than adding one. The browser's entry is then swapped too,
86    /// instead of a new one being added behind it. `false` by default.
87    fn location_replaces(&self) -> bool {
88        false
89    }
90
91    /// The locations of the pages beneath the one shown, the lowest first, and
92    /// [`location`](Self::location) last. Used once, when the page is opened at an address
93    /// deep in the application: the browser's history is made to hold the pages beneath too,
94    /// so that going back — the application's own or the browser's — has somewhere to land
95    /// inside the application. The default is the location alone.
96    fn location_stack(&self) -> Vec<String> {
97        self.location().into_iter().collect()
98    }
99
100    /// **A location was asked for from outside**: the address the page was opened at, or the
101    /// one the browser's back and forward buttons landed on. Move to it — or to wherever it
102    /// redirects, and the shell corrects the address bar to match. Called with a location
103    /// the application may already be at; that is a no-op to do, not to report.
104    ///
105    /// The launch address is delivered once, before the first frame, and only when the
106    /// address carried one: a bare page keeps the application's own start.
107    fn open_location(&mut self, location: &str) -> Command<Self::Message> {
108        let _ = location;
109        Command::none()
110    }
111
112    /// Whether the application is **one window**, and its name: on a desktop, a second process
113    /// started with a link — the operating system's way of opening `myapp://orders/42` — hands
114    /// the link to the first and ends, instead of opening a second window. `None`, the default,
115    /// lets every process be its own.
116    ///
117    /// The name tells this application from the others on the machine; a reverse domain
118    /// (`com.example.myapp`) is what one is. Links reach [`open_location`](Self::open_location)
119    /// as locations — see [`location_of_link`](crate::location_of_link).
120    fn instance_id(&self) -> Option<String> {
121        None
122    }
123
124    /// How [`location`](Self::location) is written in the address bar. See
125    /// [`LocationStrategy`].
126    fn location_strategy(&self) -> LocationStrategy {
127        LocationStrategy::Hash
128    }
129
130    /// **Continuous** sources of messages — timers and so on — declared from the
131    /// state. The framework starts and stops them by diffing every cycle.
132    fn subscription(&self) -> Subscription<Self::Message> {
133        Subscription::none()
134    }
135
136    /// Builds the widget tree for the current theme.
137    ///
138    /// **It is not told the size.** The framework installs a description of the surface
139    /// — its size, its pixel ratio, the intrusions the platform last reported — around
140    /// every call to this, and any widget built here reads it with `MediaQuery::of()`.
141    /// A `Scaffold` takes no size because of that, and neither does an `AppBar` or a
142    /// `Navigator`.
143    ///
144    /// That is the reference's arrangement, and it is not a convenience: a size passed
145    /// as an argument gets carried down by hand, arithmetic gets done on it, and one of
146    /// those subtractions is eventually wrong. Milestone 392 is what that looks like
147    /// when it happens — a whole screen laid out to the width of its widest line.
148    fn view(&self, theme: &Theme) -> Box<dyn Widget<Self::Message>>;
149
150    /// **The application's theme** — its light one, where it has two (`app.dart:425`).
151    ///
152    /// This is the whole answer for an application with a single theme. One that follows
153    /// the system supplies a [`dark_theme`](Application::dark_theme) beside it and lets
154    /// [`theme_mode`](Application::theme_mode) choose; the framework resolves the pair
155    /// every frame and **fades** between them, so nothing here has to be an animated
156    /// blend and no application needs to hold a fade's progress in its own state.
157    ///
158    /// **The default is the light theme**, which is the reference's (`app.dart:415`). It
159    /// was dark here until milestone 452 named this slot the light one: a `theme()` that
160    /// answers dark and a `dark_theme()` beside it is a contradiction an application
161    /// would have had to work around.
162    fn theme(&self) -> Theme {
163        Theme::light()
164    }
165
166    /// **The theme to use when a dark interface is asked for** (`app.dart:447`).
167    ///
168    /// `None` — the default — means the application has only the one theme, and
169    /// [`Application::theme`] is used whatever the platform reports. Answer with a dark
170    /// theme and the framework switches to it by itself, following
171    /// [`Application::theme_mode`].
172    ///
173    /// **This is the hook that makes the platform's brightness usable.** The shell has
174    /// reported it since milestone 380 — `MediaQuery::platform_brightness`, read from the
175    /// window manager on desktop and from the system settings on Android — but until an
176    /// application could name a second theme there was nothing to switch *to*, and every
177    /// application that wanted to follow the system had to read the brightness and write
178    /// the crossfade itself.
179    fn dark_theme(&self) -> Option<Theme> {
180        None
181    }
182
183    /// **Which of the two themes is on display** (`app.dart:502`): the platform's choice
184    /// by default, or one of them pinned.
185    ///
186    /// [`ThemeMode::System`] follows [`MediaQuery::platform_brightness`], which is what
187    /// an application with a *System / Light / Dark* setting leaves it on until the
188    /// reader picks one of the other two.
189    ///
190    /// [`MediaQuery::platform_brightness`]: frus_widgets::MediaQuery::platform_brightness
191    fn theme_mode(&self) -> ThemeMode {
192        ThemeMode::System
193    }
194
195    /// **The theme to use when the platform asks for higher contrast** (`app.dart:460`);
196    /// `None` falls back to [`Application::theme`].
197    ///
198    /// The setting is the reader's, reported by the platform and readable at
199    /// [`Accessibility::high_contrast`](frus_widgets::Accessibility::high_contrast). A
200    /// design that already meets the contrast ratios has nothing to add here, which is
201    /// why the default is to add nothing.
202    fn high_contrast_theme(&self) -> Option<Theme> {
203        None
204    }
205
206    /// **The theme for a dark, high-contrast interface** (`app.dart:476`); `None` falls
207    /// back to [`Application::dark_theme`], then to [`Application::high_contrast_theme`],
208    /// then to [`Application::theme`] — the reference's own order, rung for rung.
209    fn high_contrast_dark_theme(&self) -> Option<Theme> {
210        None
211    }
212
213    /// **Which of this application's themes is on display**, given what the platform
214    /// reports — the reference's `_themeBuilder` (`app.dart:995`), rung for rung.
215    ///
216    /// A dark, high-contrast interface takes
217    /// [`high_contrast_dark_theme`](Application::high_contrast_dark_theme), then
218    /// [`dark_theme`](Application::dark_theme), then
219    /// [`high_contrast_theme`](Application::high_contrast_theme); a dark one takes
220    /// `dark_theme`; a high-contrast one takes `high_contrast_theme`; and anything still
221    /// unanswered takes [`theme`](Application::theme), which every application has.
222    ///
223    /// The fallbacks are the point. An application supplying only a `dark_theme` is right
224    /// about dark mode and silent about contrast, and gets its dark theme on a
225    /// high-contrast device rather than being sent back to its light one.
226    ///
227    /// **The framework calls this every frame**; an application rarely does. It is public
228    /// because there is no `BuildContext` here to ask, so anything outside the frame loop
229    /// that needs the theme an application would be showing — an off-screen renderer, a
230    /// golden harness, a test — has to be able to ask `self` for it. Overriding it
231    /// replaces the rungs above wholesale.
232    fn resolved_theme(&self, brightness: Brightness, high_contrast: bool) -> Theme {
233        let dark = self.theme_mode().wants_dark(brightness);
234        let picked = if dark && high_contrast {
235            self.high_contrast_dark_theme()
236                .or_else(|| self.dark_theme())
237                .or_else(|| self.high_contrast_theme())
238        } else if dark {
239            self.dark_theme()
240        } else if high_contrast {
241            self.high_contrast_theme()
242        } else {
243            None
244        };
245        picked.unwrap_or_else(|| self.theme())
246    }
247
248    /// **How long the framework takes to cross from one theme to the next**, in seconds
249    /// (`app.dart:516`); `0` switches at once. The default is the reference's 200 ms.
250    ///
251    /// Every change of theme is animated, not only a light/dark switch: a new seed
252    /// colour, a different density of tokens, anything at all that makes the resolved
253    /// theme a different value. The application does not run the fade and does not hold
254    /// its progress — it names the two themes and the mode, and the framework blends.
255    ///
256    /// A reader who has asked for **reduced motion** gets the change at once whatever
257    /// this says, as with every implicit animation the framework runs.
258    fn theme_animation_duration(&self) -> f32 {
259        0.2
260    }
261
262    /// **The shape of that crossing** (`app.dart:526`); linear by default, as in the
263    /// reference. Ignored when [`Application::theme_animation_duration`] is `0`.
264    fn theme_animation_curve(&self) -> Curve {
265        Curve::Linear
266    }
267
268    /// Advances the app's **own** animations — a screen transition, a gesture settling —
269    /// by `dt` seconds. Returns `true` while something is still moving, in which case the
270    /// framework asks for another frame.
271    ///
272    /// The theme fade is **not** one of them: since milestone 452 the framework runs it.
273    fn tick(&mut self, _dt: f32) -> bool {
274        false
275    }
276
277    /// The window's title.
278    fn title(&self) -> String {
279        "frus".to_string()
280    }
281
282    /// The application's icon: the frus logo unless it says otherwise. On the desktop it is the
283    /// window's, on the Web the tab's; see [`AppIcon`](crate::AppIcon) for what it does on each platform, and
284    /// for how to change or remove it.
285    fn icon(&self) -> crate::AppIcon {
286        crate::AppIcon::Frus
287    }
288
289    /// The window's desired initial **logical** size; `None` for the system default. Ignored on
290    /// the Web, where the page decides how big the canvas is.
291    fn window_size(&self) -> Option<(f32, f32)> {
292        None
293    }
294
295    /// What this application wants to say about the **accessibility settings** *instead
296    /// of the platform*.
297    ///
298    /// The platform is asked first, because the settings belong to the person using the
299    /// device and not to the program. This hook lays an application's own answers over
300    /// that, one setting at a time — a settings screen with a *reduce motion* switch of
301    /// its own speaks for that and stays quiet about the rest.
302    ///
303    /// The default is [`AccessibilityOverrides::NONE`]: nothing said, every answer the
304    /// user's. `None` per field is what makes that expressible; a plain
305    /// [`Accessibility`](frus_widgets::Accessibility) could not, a `false` in it being
306    /// indistinguishable from silence.
307    ///
308    /// The framework honours [`disable_animations`] itself, by completing implicit
309    /// animations at once rather than over time. The rest reach the widgets through
310    /// [`MediaQuery::of`](frus_widgets::MediaQuery::of).
311    ///
312    /// [`AccessibilityOverrides::NONE`]: frus_widgets::AccessibilityOverrides::NONE
313    /// [`disable_animations`]: frus_widgets::Accessibility::disable_animations
314    fn accessibility(&self) -> frus_widgets::AccessibilityOverrides {
315        frus_widgets::AccessibilityOverrides::NONE
316    }
317
318    /// The interface's **density**: an **application-level** zoom factor, `1.0` by
319    /// default, applied on top of the system DPI scale. `1.2` grows the whole UI by
320    /// 20%, `0.9` tightens it. It can change at runtime through the state.
321    fn density(&self) -> f32 {
322        1.0
323    }
324
325    /// **How the application's scrollables behave**: their physics, their scrollbars,
326    /// what they show past an edge and how they read a fling, all from one
327    /// [`frus_widgets::ScrollBehavior`] put in force at the root of the tree.
328    ///
329    /// The default, [`frus_widgets::ScrollBehavior::material`], follows the **theme's**
330    /// platform, so an application whose theme says iOS bounces and hides its scrollbars on
331    /// any device. Change what it changes and keep the rest:
332    ///
333    /// ```ignore
334    /// fn scroll_behavior(&self) -> ScrollBehavior {
335    ///     ScrollBehavior::material().with_scrollbars(false)
336    /// }
337    /// ```
338    ///
339    /// A subtree can put another in force with a [`frus_widgets::ScrollConfiguration`],
340    /// and a single scrollable can still ask for its own physics or scrollbars.
341    ///
342    /// **Breaking** (milestone 616): this replaces `scroll_physics()`, `scrollbars()` and
343    /// `overscroll_indicator()`, each of which decided one of these on its own, from the
344    /// build target.
345    fn scroll_behavior(&self) -> frus_widgets::ScrollBehavior {
346        frus_widgets::ScrollBehavior::material()
347    }
348
349    /// **The languages this application has** (`app.dart`'s `supportedLocales`), best
350    /// first — the answer to *what can I actually show this reader?*
351    ///
352    /// The default is `["en"]`, which is what every string in the framework was written in
353    /// before any of them could be translated. **The order matters**: it decides ties, so
354    /// an application listing `en-US` before `en-GB` has said which English a reader who
355    /// asked only for `en` should get, and the first entry is what a reader whose
356    /// languages are all unavailable ends up with.
357    ///
358    /// An application that translates nothing can leave this alone and lose nothing: the
359    /// resolution then always answers `en`, which is where it was.
360    fn supported_locales(&self) -> Vec<Locale> {
361        vec![Locale::default()]
362    }
363
364    /// **A language pinned by the application**, over the reader's own (`app.dart`'s
365    /// `locale`); `None` — the default — follows the device.
366    ///
367    /// This is what an application's *Language* setting writes to. It is still resolved
368    /// against [`supported_locales`](Application::supported_locales), so pinning one the
369    /// application does not have gives the nearest thing it does have rather than nothing.
370    fn locale(&self) -> Option<Locale> {
371        None
372    }
373
374    /// **Which language the interface ends up in**, given what the reader prefers.
375    ///
376    /// `preferred` is the platform's list, best first. An application that pinned a
377    /// [`locale`](Application::locale) replaces that list with the one it named — the
378    /// reference does the same, resolving the pin rather than obeying it blindly — and
379    /// everything then goes through [`locale::resolve`](frus_widgets::locale::resolve),
380    /// which is the reference's `basicLocaleListResolution` (`app.dart:146`).
381    ///
382    /// **The framework calls this every frame** and installs the answer, so a language
383    /// setting changed while the application is running is obeyed on the next one.
384    /// Override it to negotiate differently — the reference's `localeListResolutionCallback`.
385    fn resolved_locale(&self, preferred: &[Locale]) -> Locale {
386        let supported = self.supported_locales();
387        match self.locale() {
388            Some(pinned) => frus_widgets::locale::resolve(&[pinned], &supported),
389            None => frus_widgets::locale::resolve(preferred, &supported),
390        }
391    }
392
393    /// **The words the framework says on this application's behalf**, in the reader's
394    /// language: the label on a back arrow, the word on a notification's cross, a
395    /// calendar's month names and *which day its weeks start on*.
396    ///
397    /// `None` — the default — leaves them in English, which is what they were before
398    /// any of them could be translated. Answer with a table and the shell installs it
399    /// **every frame**, so an application that changes language while it is running is
400    /// obeyed on the next one.
401    ///
402    /// See [`frus_widgets::localizations`].
403    fn localizations(&self) -> Option<std::rc::Rc<dyn Localizations>> {
404        None
405    }
406
407    /// Called when the surface's **logical** size changes, whether the window was
408    /// resized **or** the density changed. It lets the app react to a **breakpoint
409    /// change** in its own logic — closing a drawer as things narrow, say — beyond
410    /// simply re-rendering.
411    fn on_resize(&mut self, _width: f32, _height: f32) {}
412
413    /// Called at every **lifecycle transition** (see [`Lifecycle`]): `Resumed` in the
414    /// foreground, `Inactive` when the window loses focus, `Paused` in the background
415    /// with the surface lost, `Detached` just before closing. The default does
416    /// nothing. This is where an app suspends and resumes its work — timers, sensors —
417    /// or persists its state.
418    fn on_lifecycle(&mut self, _state: Lifecycle) {}
419
420    /// Called when the **window insets** change, split by nature: `padding` is the
421    /// system bars and the notch, which are static, while `view_insets` is the
422    /// software keyboard, which is dynamic — avoiding the keyboard means pushing the
423    /// content away by `insets.safe()`. In **logical** px. Always zero on desktop.
424    fn on_insets(&mut self, _insets: WindowInsets) {}
425
426    /// **Live reload**, in development: a serialised snapshot of the state, captured
427    /// just before a recompiled binary relaunches the application (`FRUS_WATCH=1`).
428    /// `None`, the default, means the app opts out, and a reload then starts from a
429    /// fresh state. The byte format belongs to the app.
430    fn save_state(&self) -> Option<Vec<u8>> {
431        None
432    }
433
434    /// Rehydrates the state from an [`Application::save_state`] snapshot taken by the
435    /// **previous** binary; called before [`Application::init`]. The bytes come from
436    /// another version of the code, so tolerate unexpected formats — ignoring beats
437    /// panicking.
438    fn restore_state(&mut self, _bytes: &[u8]) {}
439
440    /// Can the app go back? This enables the edge back gesture.
441    fn can_go_back(&self) -> bool {
442        false
443    }
444
445    /// The back gesture is progressing (`0 → 1`), so `view` can preview it.
446    fn back_gesture(&mut self, _progress: f32) {}
447
448    /// The back gesture was released, with the finger's velocity in fractions per
449    /// second. It is up to the app to commit or cancel, usually through an animated
450    /// settle in `tick`.
451    fn back_gesture_end(&mut self, _velocity: f32) {}
452
453    /// **The system asked to go back** — Android's back button, the browser's Back, a back
454    /// key — with no finger on the page: a pop, played as a page leaves, not as a swipe
455    /// let go (milestone 622). Called only when [`Application::can_go_back`] says yes.
456    ///
457    /// The default plays it as a back gesture let go at once, which is all an application
458    /// that only answers the gesture can do. One that pops its own pages overrides it; an
459    /// application built from components does, through its router.
460    fn go_back(&mut self) {
461        self.back_gesture(0.0);
462        self.back_gesture_end(5.0);
463    }
464}