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