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    /// The application's **tray icon**, declared from the state like its subscriptions: built
137    /// when one is declared, changed when the description changes, taken away when there is
138    /// none. See [`Tray`](crate::Tray). Acted on on the desktop with the `tray` feature;
139    /// ignored elsewhere.
140    fn tray(&self) -> Option<crate::Tray<Self::Message>> {
141        None
142    }
143
144    /// What the application's **taskbar button** shows — a count, a progress — declared from
145    /// the state and followed by the shell. See [`Taskbar`](crate::Taskbar). Acted on on the
146    /// desktop with the `taskbar` feature; ignored elsewhere.
147    fn taskbar(&self) -> crate::Taskbar {
148        crate::Taskbar::new()
149    }
150
151    /// The application's **global shortcuts** — combinations that reach it from any window —
152    /// declared from the state: registered when declared, given back when withdrawn. See
153    /// [`GlobalShortcut`](crate::GlobalShortcut). Acted on on the desktop with the
154    /// `global-shortcuts` feature; ignored elsewhere.
155    fn global_shortcuts(&self) -> Vec<crate::GlobalShortcut<Self::Message>> {
156        Vec::new()
157    }
158
159    /// Builds the widget tree for the current theme.
160    ///
161    /// **It is not told the size.** The framework installs a description of the surface
162    /// — its size, its pixel ratio, the intrusions the platform last reported — around
163    /// every call to this, and any widget built here reads it with `MediaQuery::of()`.
164    /// A `Scaffold` takes no size because of that, and neither does an `AppBar` or a
165    /// `Navigator`.
166    ///
167    /// That is the reference's arrangement, and it is not a convenience: a size passed
168    /// as an argument gets carried down by hand, arithmetic gets done on it, and one of
169    /// those subtractions is eventually wrong. Milestone 392 is what that looks like
170    /// when it happens — a whole screen laid out to the width of its widest line.
171    fn view(&self, theme: &Theme) -> Box<dyn Widget<Self::Message>>;
172
173    /// **The application's theme** — its light one, where it has two (`app.dart:425`).
174    ///
175    /// This is the whole answer for an application with a single theme. One that follows
176    /// the system supplies a [`dark_theme`](Application::dark_theme) beside it and lets
177    /// [`theme_mode`](Application::theme_mode) choose; the framework resolves the pair
178    /// every frame and **fades** between them, so nothing here has to be an animated
179    /// blend and no application needs to hold a fade's progress in its own state.
180    ///
181    /// **The default is the light theme**, which is the reference's (`app.dart:415`). It
182    /// was dark here until milestone 452 named this slot the light one: a `theme()` that
183    /// answers dark and a `dark_theme()` beside it is a contradiction an application
184    /// would have had to work around.
185    fn theme(&self) -> Theme {
186        Theme::light()
187    }
188
189    /// **The theme to use when a dark interface is asked for** (`app.dart:447`).
190    ///
191    /// `None` — the default — means the application has only the one theme, and
192    /// [`Application::theme`] is used whatever the platform reports. Answer with a dark
193    /// theme and the framework switches to it by itself, following
194    /// [`Application::theme_mode`].
195    ///
196    /// **This is the hook that makes the platform's brightness usable.** The shell has
197    /// reported it since milestone 380 — `MediaQuery::platform_brightness`, read from the
198    /// window manager on desktop and from the system settings on Android — but until an
199    /// application could name a second theme there was nothing to switch *to*, and every
200    /// application that wanted to follow the system had to read the brightness and write
201    /// the crossfade itself.
202    fn dark_theme(&self) -> Option<Theme> {
203        None
204    }
205
206    /// **Which of the two themes is on display** (`app.dart:502`): the platform's choice
207    /// by default, or one of them pinned.
208    ///
209    /// [`ThemeMode::System`] follows [`MediaQuery::platform_brightness`], which is what
210    /// an application with a *System / Light / Dark* setting leaves it on until the
211    /// reader picks one of the other two.
212    ///
213    /// [`MediaQuery::platform_brightness`]: frus_widgets::MediaQuery::platform_brightness
214    fn theme_mode(&self) -> ThemeMode {
215        ThemeMode::System
216    }
217
218    /// **The theme to use when the platform asks for higher contrast** (`app.dart:460`);
219    /// `None` falls back to [`Application::theme`].
220    ///
221    /// The setting is the reader's, reported by the platform and readable at
222    /// [`Accessibility::high_contrast`](frus_widgets::Accessibility::high_contrast). A
223    /// design that already meets the contrast ratios has nothing to add here, which is
224    /// why the default is to add nothing.
225    fn high_contrast_theme(&self) -> Option<Theme> {
226        None
227    }
228
229    /// **The theme for a dark, high-contrast interface** (`app.dart:476`); `None` falls
230    /// back to [`Application::dark_theme`], then to [`Application::high_contrast_theme`],
231    /// then to [`Application::theme`] — the reference's own order, rung for rung.
232    fn high_contrast_dark_theme(&self) -> Option<Theme> {
233        None
234    }
235
236    /// **Which of this application's themes is on display**, given what the platform
237    /// reports — the reference's `_themeBuilder` (`app.dart:995`), rung for rung.
238    ///
239    /// A dark, high-contrast interface takes
240    /// [`high_contrast_dark_theme`](Application::high_contrast_dark_theme), then
241    /// [`dark_theme`](Application::dark_theme), then
242    /// [`high_contrast_theme`](Application::high_contrast_theme); a dark one takes
243    /// `dark_theme`; a high-contrast one takes `high_contrast_theme`; and anything still
244    /// unanswered takes [`theme`](Application::theme), which every application has.
245    ///
246    /// The fallbacks are the point. An application supplying only a `dark_theme` is right
247    /// about dark mode and silent about contrast, and gets its dark theme on a
248    /// high-contrast device rather than being sent back to its light one.
249    ///
250    /// **The framework calls this every frame**; an application rarely does. It is public
251    /// because there is no `BuildContext` here to ask, so anything outside the frame loop
252    /// that needs the theme an application would be showing — an off-screen renderer, a
253    /// golden harness, a test — has to be able to ask `self` for it. Overriding it
254    /// replaces the rungs above wholesale.
255    fn resolved_theme(&self, brightness: Brightness, high_contrast: bool) -> Theme {
256        let dark = self.theme_mode().wants_dark(brightness);
257        let picked = if dark && high_contrast {
258            self.high_contrast_dark_theme()
259                .or_else(|| self.dark_theme())
260                .or_else(|| self.high_contrast_theme())
261        } else if dark {
262            self.dark_theme()
263        } else if high_contrast {
264            self.high_contrast_theme()
265        } else {
266            None
267        };
268        picked.unwrap_or_else(|| self.theme())
269    }
270
271    /// **How long the framework takes to cross from one theme to the next**, in seconds
272    /// (`app.dart:516`); `0` switches at once. The default is the reference's 200 ms.
273    ///
274    /// Every change of theme is animated, not only a light/dark switch: a new seed
275    /// colour, a different density of tokens, anything at all that makes the resolved
276    /// theme a different value. The application does not run the fade and does not hold
277    /// its progress — it names the two themes and the mode, and the framework blends.
278    ///
279    /// A reader who has asked for **reduced motion** gets the change at once whatever
280    /// this says, as with every implicit animation the framework runs.
281    fn theme_animation_duration(&self) -> f32 {
282        0.2
283    }
284
285    /// **The shape of that crossing** (`app.dart:526`); linear by default, as in the
286    /// reference. Ignored when [`Application::theme_animation_duration`] is `0`.
287    fn theme_animation_curve(&self) -> Curve {
288        Curve::Linear
289    }
290
291    /// Advances the app's **own** animations — a screen transition, a gesture settling —
292    /// by `dt` seconds. Returns `true` while something is still moving, in which case the
293    /// framework asks for another frame.
294    ///
295    /// The theme fade is **not** one of them: since milestone 452 the framework runs it.
296    fn tick(&mut self, _dt: f32) -> bool {
297        false
298    }
299
300    /// The window's title.
301    fn title(&self) -> String {
302        "frus".to_string()
303    }
304
305    /// The application's icon: the frus logo unless it says otherwise. On the desktop it is the
306    /// window's, on the Web the tab's; see [`AppIcon`](crate::AppIcon) for what it does on each platform, and
307    /// for how to change or remove it.
308    fn icon(&self) -> crate::AppIcon {
309        crate::AppIcon::Frus
310    }
311
312    /// The window's desired initial **logical** size; `None` for the system default. Ignored on
313    /// the Web, where the page decides how big the canvas is.
314    fn window_size(&self) -> Option<(f32, f32)> {
315        None
316    }
317
318    /// What this application wants to say about the **accessibility settings** *instead
319    /// of the platform*.
320    ///
321    /// The platform is asked first, because the settings belong to the person using the
322    /// device and not to the program. This hook lays an application's own answers over
323    /// that, one setting at a time — a settings screen with a *reduce motion* switch of
324    /// its own speaks for that and stays quiet about the rest.
325    ///
326    /// The default is [`AccessibilityOverrides::NONE`]: nothing said, every answer the
327    /// user's. `None` per field is what makes that expressible; a plain
328    /// [`Accessibility`](frus_widgets::Accessibility) could not, a `false` in it being
329    /// indistinguishable from silence.
330    ///
331    /// The framework honours [`disable_animations`] itself, by completing implicit
332    /// animations at once rather than over time. The rest reach the widgets through
333    /// [`MediaQuery::of`](frus_widgets::MediaQuery::of).
334    ///
335    /// [`AccessibilityOverrides::NONE`]: frus_widgets::AccessibilityOverrides::NONE
336    /// [`disable_animations`]: frus_widgets::Accessibility::disable_animations
337    fn accessibility(&self) -> frus_widgets::AccessibilityOverrides {
338        frus_widgets::AccessibilityOverrides::NONE
339    }
340
341    /// The interface's **density**: an **application-level** zoom factor, `1.0` by
342    /// default, applied on top of the system DPI scale. `1.2` grows the whole UI by
343    /// 20%, `0.9` tightens it. It can change at runtime through the state.
344    fn density(&self) -> f32 {
345        1.0
346    }
347
348    /// **How the application's scrollables behave**: their physics, their scrollbars,
349    /// what they show past an edge and how they read a fling, all from one
350    /// [`frus_widgets::ScrollBehavior`] put in force at the root of the tree.
351    ///
352    /// The default, [`frus_widgets::ScrollBehavior::material`], follows the **theme's**
353    /// platform, so an application whose theme says iOS bounces and hides its scrollbars on
354    /// any device. Change what it changes and keep the rest:
355    ///
356    /// ```ignore
357    /// fn scroll_behavior(&self) -> ScrollBehavior {
358    ///     ScrollBehavior::material().with_scrollbars(false)
359    /// }
360    /// ```
361    ///
362    /// A subtree can put another in force with a [`frus_widgets::ScrollConfiguration`],
363    /// and a single scrollable can still ask for its own physics or scrollbars.
364    ///
365    /// **Breaking** (milestone 616): this replaces `scroll_physics()`, `scrollbars()` and
366    /// `overscroll_indicator()`, each of which decided one of these on its own, from the
367    /// build target.
368    fn scroll_behavior(&self) -> frus_widgets::ScrollBehavior {
369        frus_widgets::ScrollBehavior::material()
370    }
371
372    /// **The languages this application has** (`app.dart`'s `supportedLocales`), best
373    /// first — the answer to *what can I actually show this reader?*
374    ///
375    /// The default is `["en"]`, which is what every string in the framework was written in
376    /// before any of them could be translated. **The order matters**: it decides ties, so
377    /// an application listing `en-US` before `en-GB` has said which English a reader who
378    /// asked only for `en` should get, and the first entry is what a reader whose
379    /// languages are all unavailable ends up with.
380    ///
381    /// An application that translates nothing can leave this alone and lose nothing: the
382    /// resolution then always answers `en`, which is where it was.
383    fn supported_locales(&self) -> Vec<Locale> {
384        vec![Locale::default()]
385    }
386
387    /// **A language pinned by the application**, over the reader's own (`app.dart`'s
388    /// `locale`); `None` — the default — follows the device.
389    ///
390    /// This is what an application's *Language* setting writes to. It is still resolved
391    /// against [`supported_locales`](Application::supported_locales), so pinning one the
392    /// application does not have gives the nearest thing it does have rather than nothing.
393    fn locale(&self) -> Option<Locale> {
394        None
395    }
396
397    /// **Which language the interface ends up in**, given what the reader prefers.
398    ///
399    /// `preferred` is the platform's list, best first. An application that pinned a
400    /// [`locale`](Application::locale) replaces that list with the one it named — the
401    /// reference does the same, resolving the pin rather than obeying it blindly — and
402    /// everything then goes through [`locale::resolve`](frus_widgets::locale::resolve),
403    /// which is the reference's `basicLocaleListResolution` (`app.dart:146`).
404    ///
405    /// **The framework calls this every frame** and installs the answer, so a language
406    /// setting changed while the application is running is obeyed on the next one.
407    /// Override it to negotiate differently — the reference's `localeListResolutionCallback`.
408    fn resolved_locale(&self, preferred: &[Locale]) -> Locale {
409        let supported = self.supported_locales();
410        match self.locale() {
411            Some(pinned) => frus_widgets::locale::resolve(&[pinned], &supported),
412            None => frus_widgets::locale::resolve(preferred, &supported),
413        }
414    }
415
416    /// **The words the framework says on this application's behalf**, in the reader's
417    /// language: the label on a back arrow, the word on a notification's cross, a
418    /// calendar's month names and *which day its weeks start on*.
419    ///
420    /// `None` — the default — leaves them in English, which is what they were before
421    /// any of them could be translated. Answer with a table and the shell installs it
422    /// **every frame**, so an application that changes language while it is running is
423    /// obeyed on the next one.
424    ///
425    /// See [`frus_widgets::localizations`].
426    fn localizations(&self) -> Option<std::rc::Rc<dyn Localizations>> {
427        None
428    }
429
430    /// Called when the surface's **logical** size changes, whether the window was
431    /// resized **or** the density changed. It lets the app react to a **breakpoint
432    /// change** in its own logic — closing a drawer as things narrow, say — beyond
433    /// simply re-rendering.
434    fn on_resize(&mut self, _width: f32, _height: f32) {}
435
436    /// Called at every **lifecycle transition** (see [`Lifecycle`]): `Resumed` in the
437    /// foreground, `Inactive` when the window loses focus, `Paused` in the background
438    /// with the surface lost, `Detached` just before closing. The default does
439    /// nothing. This is where an app suspends and resumes its work — timers, sensors —
440    /// or persists its state.
441    fn on_lifecycle(&mut self, _state: Lifecycle) {}
442
443    /// Called when the **window insets** change, split by nature: `padding` is the
444    /// system bars and the notch, which are static, while `view_insets` is the
445    /// software keyboard, which is dynamic — avoiding the keyboard means pushing the
446    /// content away by `insets.safe()`. In **logical** px. Always zero on desktop.
447    fn on_insets(&mut self, _insets: WindowInsets) {}
448
449    /// **Live reload**, in development: a serialised snapshot of the state, captured
450    /// just before a recompiled binary relaunches the application (`FRUS_WATCH=1`).
451    /// `None`, the default, means the app opts out, and a reload then starts from a
452    /// fresh state. The byte format belongs to the app.
453    fn save_state(&self) -> Option<Vec<u8>> {
454        None
455    }
456
457    /// Rehydrates the state from an [`Application::save_state`] snapshot taken by the
458    /// **previous** binary; called before [`Application::init`]. The bytes come from
459    /// another version of the code, so tolerate unexpected formats — ignoring beats
460    /// panicking.
461    fn restore_state(&mut self, _bytes: &[u8]) {}
462
463    /// Can the app go back? This enables the edge back gesture.
464    fn can_go_back(&self) -> bool {
465        false
466    }
467
468    /// The back gesture is progressing (`0 → 1`), so `view` can preview it.
469    fn back_gesture(&mut self, _progress: f32) {}
470
471    /// The back gesture was released, with the finger's velocity in fractions per
472    /// second. It is up to the app to commit or cancel, usually through an animated
473    /// settle in `tick`.
474    fn back_gesture_end(&mut self, _velocity: f32) {}
475
476    /// **The system asked to go back** — Android's back button, the browser's Back, a back
477    /// key — with no finger on the page: a pop, played as a page leaves, not as a swipe
478    /// let go (milestone 622). Called only when [`Application::can_go_back`] says yes.
479    ///
480    /// The default plays it as a back gesture let go at once, which is all an application
481    /// that only answers the gesture can do. One that pops its own pages overrides it; an
482    /// application built from components does, through its router.
483    fn go_back(&mut self) {
484        self.back_gesture(0.0);
485        self.back_gesture_end(5.0);
486    }
487}