Skip to main content

frus_widgets/
system_ui.rs

1//! **The platform's own bars** — the status bar across the top of a phone, the navigation
2//! bar along its bottom — and what colour they are and how their icons are drawn.
3//!
4//! They are not this framework's to paint: the platform draws them, beside or over the
5//! window. What it has to be told is their colour and whether their icons are dark or
6//! light, and the cost of never telling it is the bug #46 opened on — a light screen under
7//! the platform's light icons, and the clock, the battery and the signal simply gone.
8//!
9//! ## Who decides
10//!
11//! Not the application, once, at start-up: **the part of the screen underneath**. A screen
12//! with a photograph behind the status bar wants light icons, and the settings screen next
13//! to it wants dark ones. So a subtree says what it wants with an [`AnnotatedRegion`], and
14//! each frame asks which regions lie under each bar — the one drawn last first, **field by
15//! field**, so a region that only states its icons still takes its colour from the one
16//! beneath it. What no region states comes from the theme: bars in its background colour,
17//! with icons that can be read on it.
18//!
19//! On a desktop and on the web there are no such bars, and nothing is sent anywhere.
20
21use frus_core::{Color, Point, Rect, Scene};
22use frus_layout::Style;
23
24use crate::interaction::Status;
25use crate::media::Brightness;
26use crate::theme::Theme;
27use crate::widget::Widget;
28
29/// What a part of the screen asks of the system bars over it — the reference's
30/// `SystemUiOverlayStyle`.
31///
32/// Every field is optional: a region states what it cares about, and the rest comes from
33/// the region beneath it and finally from the theme ([`SystemUiOverlayStyle::resolve`]).
34///
35/// The icons are named by **their own** brightness: `Brightness::Dark` is dark icons, for
36/// a light bar. That is the question a reader of the screen answers — can I read the clock
37/// — and the platform's own name for it, a "light status bar", is the opposite word.
38#[derive(Clone, Copy, Debug, Default, PartialEq)]
39pub struct SystemUiOverlayStyle {
40    /// The status bar's background.
41    pub status_bar_color: Option<Color>,
42    /// The status bar's icons: the clock, the battery, the signal.
43    pub status_bar_icons: Option<Brightness>,
44    /// The navigation bar's background.
45    pub navigation_bar_color: Option<Color>,
46    /// The navigation bar's icons, or its gesture handle.
47    pub navigation_bar_icons: Option<Brightness>,
48}
49
50impl SystemUiOverlayStyle {
51    /// States nothing: every field is left to what lies beneath.
52    pub const NONE: Self = Self {
53        status_bar_color: None,
54        status_bar_icons: None,
55        navigation_bar_color: None,
56        navigation_bar_icons: None,
57    };
58
59    /// Both bars in `color`, with icons that can be read on it.
60    pub fn for_background(color: Color) -> Self {
61        let icons = icons_on(color);
62        Self {
63            status_bar_color: Some(color),
64            status_bar_icons: Some(icons),
65            navigation_bar_color: Some(color),
66            navigation_bar_icons: Some(icons),
67        }
68    }
69
70    /// The status bar's background. Its icons, unless stated too, are chosen to be read
71    /// on it — not taken from the theme, which answers for a different colour.
72    pub fn status_bar_color(mut self, color: Color) -> Self {
73        self.status_bar_color = Some(color);
74        self
75    }
76
77    /// The status bar's icons: `Brightness::Dark` for dark icons.
78    pub fn status_bar_icons(mut self, icons: Brightness) -> Self {
79        self.status_bar_icons = Some(icons);
80        self
81    }
82
83    /// The navigation bar's background, with the same rule for its icons.
84    pub fn navigation_bar_color(mut self, color: Color) -> Self {
85        self.navigation_bar_color = Some(color);
86        self
87    }
88
89    /// The navigation bar's icons: `Brightness::Dark` for dark icons.
90    pub fn navigation_bar_icons(mut self, icons: Brightness) -> Self {
91        self.navigation_bar_icons = Some(icons);
92        self
93    }
94
95    /// This, with whatever it does not state filled in from `under`.
96    pub fn over(self, under: Self) -> Self {
97        Self {
98            status_bar_color: self.status_bar_color.or(under.status_bar_color),
99            status_bar_icons: self.status_bar_icons.or(under.status_bar_icons),
100            navigation_bar_color: self.navigation_bar_color.or(under.navigation_bar_color),
101            navigation_bar_icons: self.navigation_bar_icons.or(under.navigation_bar_icons),
102        }
103    }
104
105    /// Every question answered: what is stated, and the theme for the rest.
106    ///
107    /// A bar whose colour is stated but not its icons gets icons **read on that colour**,
108    /// so a region that only paints a bar black still gets a readable clock.
109    pub fn resolve(self, theme: &Theme) -> SystemBars {
110        let status_bar_color = self.status_bar_color.unwrap_or(theme.background);
111        let navigation_bar_color = self.navigation_bar_color.unwrap_or(theme.background);
112        SystemBars {
113            status_bar_color,
114            status_bar_icons: self
115                .status_bar_icons
116                .unwrap_or_else(|| icons_on(status_bar_color)),
117            navigation_bar_color,
118            navigation_bar_icons: self
119                .navigation_bar_icons
120                .unwrap_or_else(|| icons_on(navigation_bar_color)),
121        }
122    }
123}
124
125/// Both system bars with every question answered — what the shell hands the platform.
126#[derive(Clone, Copy, Debug, PartialEq)]
127pub struct SystemBars {
128    /// The status bar's background.
129    pub status_bar_color: Color,
130    /// The status bar's icons: `Brightness::Dark` for dark icons.
131    pub status_bar_icons: Brightness,
132    /// The navigation bar's background.
133    pub navigation_bar_color: Color,
134    /// The navigation bar's icons: `Brightness::Dark` for dark icons.
135    pub navigation_bar_icons: Brightness,
136}
137
138/// Icons that can be read on `color`: dark on a light colour, light on a dark one.
139///
140/// The threshold is the reference's estimate, a relative luminance of about a third rather
141/// than a half, because it weighs the contrast against white and against black the way the
142/// accessibility guidelines do: a mid grey reads better under dark icons.
143pub(crate) fn icons_on(color: Color) -> Brightness {
144    let lin = color.to_linear();
145    let luminance = 0.2126 * lin.r + 0.7152 * lin.g + 0.0722 * lin.b;
146    if (luminance + 0.05) * (luminance + 0.05) > 0.15 {
147        Brightness::Dark
148    } else {
149        Brightness::Light
150    }
151}
152
153/// Says what the system bars over this part of the screen should look like — the
154/// reference's `AnnotatedRegion<SystemUiOverlayStyle>`.
155///
156/// ```
157/// use frus_core::Color;
158/// use frus_widgets::{AnnotatedRegion, Container, SystemUiOverlayStyle};
159///
160/// // A dark photograph under the status bar wants light icons, whatever the theme is.
161/// let photo = Container::<()>::new().color(Color::rgb(0.08, 0.08, 0.1));
162/// let _header = AnnotatedRegion::new(
163///     SystemUiOverlayStyle::for_background(Color::rgb(0.08, 0.08, 0.1)),
164///     photo,
165/// );
166/// ```
167///
168/// It takes effect where its box lies **against a bar**: a region covering the first row of
169/// the content under the status bar answers for the status bar, and one covering the last
170/// row above the navigation bar for the navigation bar. Nested regions answer field by
171/// field, the inner one first.
172pub struct AnnotatedRegion<Msg = crate::callback::Callback> {
173    style: SystemUiOverlayStyle,
174    children: Vec<Box<dyn Widget<Msg>>>,
175}
176
177impl<Msg> AnnotatedRegion<Msg> {
178    /// Asks for `style` over `child`.
179    pub fn new(style: SystemUiOverlayStyle, child: impl Widget<Msg> + 'static) -> Self {
180        Self {
181            style,
182            children: vec![Box::new(child)],
183        }
184    }
185}
186
187impl<Msg: Clone> Widget<Msg> for AnnotatedRegion<Msg> {
188    fn style(&self) -> Style {
189        Style::default()
190    }
191
192    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
193        &self.children
194    }
195
196    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
197
198    fn on_click(&self) -> Option<Msg> {
199        None
200    }
201
202    fn system_ui_style(&self) -> Option<SystemUiOverlayStyle> {
203        Some(self.style)
204    }
205
206    fn debug_name(&self) -> &'static str {
207        "AnnotatedRegion"
208    }
209}
210
211/// The style the regions under two points ask for, the region drawn last first and field
212/// by field: `status_bar` answers for the status bar, `navigation_bar` for the other.
213pub(crate) fn style_under(
214    regions: &[(Rect, SystemUiOverlayStyle)],
215    status_bar: Point,
216    navigation_bar: Point,
217) -> SystemUiOverlayStyle {
218    let under = |point: Point| {
219        regions
220            .iter()
221            .rev()
222            .filter(|(rect, _)| rect.contains(point))
223            .fold(SystemUiOverlayStyle::NONE, |above, (_, style)| {
224                above.over(*style)
225            })
226    };
227    let top = under(status_bar);
228    let bottom = under(navigation_bar);
229    SystemUiOverlayStyle {
230        status_bar_color: top.status_bar_color,
231        status_bar_icons: top.status_bar_icons,
232        navigation_bar_color: bottom.navigation_bar_color,
233        navigation_bar_icons: bottom.navigation_bar_icons,
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::ui::build_ui;
241    use crate::{Container, Flex, Keyed, Runtime};
242    use frus_core::Size;
243
244    const SCREEN: Size = Size::new(200.0, 400.0);
245
246    /// The first row of the screen, and the last.
247    fn top() -> Point {
248        Point::new(100.0, 0.5)
249    }
250    fn bottom() -> Point {
251        Point::new(100.0, 399.5)
252    }
253
254    /// A dark photograph.
255    fn photo() -> Color {
256        Color::rgb(0.05, 0.05, 0.08)
257    }
258
259    /// Both the screen's full width: a column here places its children at the start rather
260    /// than stretching them, and a region with no width lies under nothing.
261    fn header() -> Container<()> {
262        Container::new().width(200.0).height(100.0).color(photo())
263    }
264
265    fn body() -> Container<()> {
266        Container::new().width(200.0).height(300.0)
267    }
268
269    fn style_of(root: &dyn Widget<()>) -> SystemUiOverlayStyle {
270        let ui = build_ui(root, SCREEN, &Runtime::default(), &Theme::light());
271        ui.system_ui_style(top(), bottom())
272    }
273
274    /// **The region under the status bar answers for it, and for nothing else.** A dark
275    /// header at the top of a light screen asks for light icons over it; the navigation bar,
276    /// over the body, is left to the theme.
277    #[test]
278    fn a_region_under_the_status_bar_answers_for_it_and_no_further() {
279        let screen = Flex::<()>::column()
280            .child(AnnotatedRegion::new(
281                SystemUiOverlayStyle::for_background(photo()),
282                header(),
283            ))
284            .child(body());
285        let style = style_of(&screen);
286        assert_eq!(style.status_bar_color, Some(photo()));
287        assert_eq!(style.status_bar_icons, Some(Brightness::Light));
288        assert_eq!(style.navigation_bar_color, None, "the body said nothing");
289        assert_eq!(style.navigation_bar_icons, None);
290    }
291
292    /// **What no region states comes from the theme**: its background, and icons that can
293    /// be read on it — dark on the light theme, light on the dark one. This is the whole of
294    /// #46 for an application that never names a region.
295    #[test]
296    fn the_theme_answers_what_no_region_states() {
297        let light = SystemUiOverlayStyle::NONE.resolve(&Theme::light());
298        assert_eq!(light.status_bar_color, Theme::light().background);
299        assert_eq!(
300            light.status_bar_icons,
301            Brightness::Dark,
302            "dark icons on a light bar"
303        );
304        assert_eq!(light.navigation_bar_icons, Brightness::Dark);
305
306        let dark = SystemUiOverlayStyle::NONE.resolve(&Theme::dark());
307        assert_eq!(dark.status_bar_color, Theme::dark().background);
308        assert_eq!(dark.status_bar_icons, Brightness::Light);
309        assert_eq!(dark.navigation_bar_icons, Brightness::Light);
310    }
311
312    /// **A colour stated alone gets icons read on that colour**, not the theme's: the theme
313    /// answers for its own background, and a region that painted the bar black under a light
314    /// theme would otherwise get dark icons on black.
315    #[test]
316    fn a_colour_stated_alone_gets_icons_read_on_it() {
317        let bars = SystemUiOverlayStyle::NONE
318            .status_bar_color(photo())
319            .resolve(&Theme::light());
320        assert_eq!(bars.status_bar_icons, Brightness::Light);
321        assert_eq!(
322            bars.navigation_bar_icons,
323            Brightness::Dark,
324            "the other bar is still the theme's"
325        );
326    }
327
328    /// **Nested regions answer field by field, the inner one first.** An outer region
329    /// paints both bars white; an inner one over the header only asks for light icons. The
330    /// status bar gets the inner icons on the outer colour; the navigation bar, outside the
331    /// inner region, gets the outer answer whole.
332    #[test]
333    fn nested_regions_answer_field_by_field_the_inner_first() {
334        let screen = AnnotatedRegion::new(
335            SystemUiOverlayStyle::for_background(Color::WHITE),
336            Flex::<()>::column()
337                .child(AnnotatedRegion::new(
338                    SystemUiOverlayStyle::NONE.status_bar_icons(Brightness::Light),
339                    header(),
340                ))
341                .child(body()),
342        );
343        let style = style_of(&screen);
344        assert_eq!(
345            style.status_bar_icons,
346            Some(Brightness::Light),
347            "the inner one"
348        );
349        assert_eq!(style.status_bar_color, Some(Color::WHITE), "the outer one");
350        assert_eq!(style.navigation_bar_color, Some(Color::WHITE));
351        assert_eq!(style.navigation_bar_icons, Some(Brightness::Dark));
352    }
353
354    /// **Behind a key, a region still answers.** A key is a transparent wrapper: the walk
355    /// asks it, and it has to pass the question on.
356    #[test]
357    fn a_keyed_region_still_answers() {
358        let screen = Flex::<()>::column()
359            .child(Keyed::new(
360                1u8,
361                AnnotatedRegion::new(SystemUiOverlayStyle::for_background(photo()), header()),
362            ))
363            .child(body());
364        assert_eq!(style_of(&screen).status_bar_color, Some(photo()));
365    }
366
367    /// **A region replayed from the paint cache still answers.** A frame that reuses a
368    /// repaint boundary's recording rebuilds the registries from what was recorded; a
369    /// registry left out of the recording is a region that answers on the first frame and
370    /// then falls silent — with the bars switching back to the theme's a frame later.
371    #[test]
372    fn a_region_replayed_from_the_paint_cache_still_answers() {
373        let screen = || {
374            Flex::<()>::column()
375                .child(
376                    Container::new()
377                        .repaint_boundary()
378                        .child(AnnotatedRegion::new(
379                            SystemUiOverlayStyle::for_background(photo()),
380                            header(),
381                        )),
382                )
383                .child(body())
384        };
385        let runtime = Runtime::default();
386        let theme = Theme::light();
387        let first = build_ui(&screen(), SCREEN, &runtime, &theme);
388        assert_eq!(
389            first.system_ui_style(top(), bottom()).status_bar_color,
390            Some(photo())
391        );
392        let second = build_ui(&screen(), SCREEN, &runtime, &theme);
393        let (hits, _) = runtime.paint_cache.borrow().last_frame_stats();
394        assert!(
395            hits > 0,
396            "the fixture: the second frame came from the cache"
397        );
398        assert_eq!(
399            second.system_ui_style(top(), bottom()).status_bar_color,
400            Some(photo()),
401            "and the region came back with it"
402        );
403    }
404
405    /// The contrast rule at its ends.
406    #[test]
407    fn icons_are_read_on_their_bar() {
408        assert_eq!(icons_on(Color::WHITE), Brightness::Dark);
409        assert_eq!(icons_on(Color::BLACK), Brightness::Light);
410    }
411}