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}