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}