Skip to main content

frus_widgets/
titlebar.rs

1//! **The title bar's line**, shared with the system (milestone 640), and **the window's menu
2//! bar** on it (milestone 642).
3//!
4//! On a desktop whose system allows it, a [`WindowMenuBar`] puts the menu bar on the window's
5//! title bar line, as a code editor does. The system keeps its own window: its icon still
6//! opens the window menu, its three buttons still minimize, maximize and close, and the
7//! empty part of the line still moves the window and maximizes it on a double click. Where
8//! it can, the system also paints the line — its caption backdrop and its own buttons — and
9//! frus draws the window's icon and the menu bar over it (milestone 643); elsewhere frus
10//! paints the line, in the caption's colours.
11//!
12//! The shell says when the line is shared, and what the system keeps on it, through
13//! [`MediaQuery::title_bar`](crate::MediaQuery::title_bar).
14//!
15//! ## The window's, not a page's
16//!
17//! A desktop application has one window, and its pages take turns inside it. The title bar
18//! and the menu bar on it belong to the window: they stay put while a page slides in, and
19//! they do not come and go with the pages. So the menu bar goes **above the pages** — around
20//! the router, with `frus_shell::FrusApp::builder` — and not in a page's scaffold, where it
21//! would leave with the page, slide with the page's transition, and hand the line back to
22//! the system on every page that has none.
23
24use frus_core::{Color, Insets, Path, Point, Rect, Scene};
25use frus_layout::{Dimension, Style};
26
27use crate::container::Container;
28use crate::expanded::Expanded;
29use crate::interaction::Status;
30use crate::media::{Edges, MediaQuery};
31use crate::mediascope::MediaScope;
32use crate::menubar::MenuBar;
33use crate::theme::Theme;
34use crate::themed::Themed;
35use crate::widget::Widget;
36use crate::widgettheme::TitleBarTheme;
37
38/// One of the window's three buttons.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum CaptionButton {
41    /// Minimize.
42    Minimize,
43    /// Maximize, or restore a maximized window.
44    Maximize,
45    /// Close.
46    Close,
47}
48
49/// **The title bar's line, when the application's content shares it**, as the shell
50/// reports it: how tall the line is, and what the system keeps on it.
51#[derive(Clone, Copy, Debug, PartialEq)]
52pub struct TitleBar {
53    /// The line's height, in logical pixels: the system's.
54    pub height: f32,
55    /// Room the system keeps at the line's start, in logical pixels — buttons it draws
56    /// itself there.
57    pub leading: f32,
58    /// Where the system's three buttons are, in logical pixels from the window's top left,
59    /// when the application paints them; `None` when the system draws them itself.
60    pub buttons: Option<Rect>,
61    /// The window's icon, to paint at the line's start; `None` when the system shows none
62    /// there.
63    pub icon: Option<&'static [u8]>,
64    /// The button the pointer is over.
65    pub hovered: Option<CaptionButton>,
66    /// The button being pressed.
67    pub pressed: Option<CaptionButton>,
68    /// Whether the window is maximized: the middle button then restores.
69    pub maximized: bool,
70    /// Whether the window is the active one: an inactive window's glyphs are quieter.
71    pub active: bool,
72    /// The system's surface for the line, as it would draw its own caption for the window
73    /// now — active or not; `None` where the shell cannot tell (milestone 642).
74    pub background: Option<Color>,
75    /// The system's words on that caption, likewise.
76    pub foreground: Option<Color>,
77    /// **The system paints the line** (milestone 643): its backdrop — on Windows 11 the
78    /// wallpaper-tinted one — and its three buttons, with their own hover and press, show
79    /// through wherever the application leaves the line transparent. The application then
80    /// paints neither, unless it was told a background; it paints the window's icon and
81    /// its menu bar.
82    pub system_paints: bool,
83}
84
85/// What a part of the title bar's line is to the system, which acts on it.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub enum TitleBarRole {
88    /// The window's icon: the system opens the window menu on a press, and closes the window
89    /// on a double press.
90    Icon,
91    /// The window's three buttons: the system acts on them.
92    Buttons,
93}
94
95/// The window's three buttons, **painted where the system has them**, for the system to act
96/// on — the minimize, maximize-or-restore and close glyphs of the desktop, in cells of equal
97/// width, with the system's hover and press states. Their colours are
98/// [`CaptionButtonsTheme`](crate::CaptionButtonsTheme)'s.
99pub(crate) struct CaptionButtons {
100    bar: TitleBar,
101    width: f32,
102}
103
104impl CaptionButtons {
105    pub(crate) fn new(bar: TitleBar, width: f32) -> Self {
106        Self { bar, width }
107    }
108}
109
110/// The glyph's side, in logical pixels.
111const GLYPH: f32 = 10.0;
112
113impl<Msg> Widget<Msg> for CaptionButtons {
114    fn style(&self) -> Style {
115        Style {
116            width: Dimension::Length(self.width),
117            height: Dimension::Length(self.bar.height),
118            ..Style::default()
119        }
120    }
121
122    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
123        &[]
124    }
125
126    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
127        let o = status.opacity;
128        let t = &theme.widgets.caption_buttons;
129        // The line's surface and ink, as its scope resolved them (`WindowMenuBar`).
130        let surface = theme
131            .widgets
132            .menu_bar
133            .background
134            .unwrap_or(theme.scheme.surface_container);
135        let ink = theme
136            .widgets
137            .menu_bar
138            .foreground
139            .unwrap_or(theme.scheme.on_surface);
140        let glyph = if self.bar.active {
141            t.glyph_color.unwrap_or(ink)
142        } else {
143            t.inactive_glyph_color
144                .unwrap_or_else(|| surface.lerp(t.glyph_color.unwrap_or(ink), 0.45))
145        };
146        let cell = bounds.width / 3.0;
147        for (i, button) in [
148            CaptionButton::Minimize,
149            CaptionButton::Maximize,
150            CaptionButton::Close,
151        ]
152        .into_iter()
153        .enumerate()
154        {
155            let r = Rect::new(bounds.x + cell * i as f32, bounds.y, cell, bounds.height);
156            let hovered = self.bar.hovered == Some(button);
157            let pressed = self.bar.pressed == Some(button);
158            let close = button == CaptionButton::Close;
159            // The system's states: a wash under the pointer and a lighter one pressed; the
160            // close button turns red, and its glyph white.
161            let fill = match (close, hovered, pressed) {
162                (true, _, true) => Some(
163                    t.close_pressed_color
164                        .unwrap_or(Color::rgb(0.70, 0.17, 0.12)),
165                ),
166                (true, true, _) => {
167                    Some(t.close_hover_color.unwrap_or(Color::rgb(0.77, 0.17, 0.11)))
168                }
169                (false, _, true) => {
170                    Some(t.pressed_color.unwrap_or_else(|| surface.lerp(glyph, 0.04)))
171                }
172                (false, true, _) => {
173                    Some(t.hover_color.unwrap_or_else(|| surface.lerp(glyph, 0.08)))
174                }
175                _ => None,
176            };
177            if let Some(fill) = fill {
178                scene.fill_rect(r, fill.fade(o));
179            }
180            let ink = if close && (hovered || pressed) {
181                t.close_glyph_hover_color.unwrap_or(Color::WHITE)
182            } else {
183                glyph
184            };
185            let cx = r.x + r.width * 0.5;
186            let cy = r.y + r.height * 0.5;
187            let h = GLYPH * 0.5;
188            let path = match button {
189                CaptionButton::Minimize => Path::new()
190                    .move_to(Point::new(cx - h, cy))
191                    .line_to(Point::new(cx + h, cy)),
192                CaptionButton::Maximize if self.bar.maximized => {
193                    // Restore: a square, and a second one behind it, up and to the right.
194                    let s = GLYPH - 2.0;
195                    let (x, y) = (cx - h, cy - h + 2.0);
196                    Path::new()
197                        .move_to(Point::new(x, y))
198                        .line_to(Point::new(x + s, y))
199                        .line_to(Point::new(x + s, y + s))
200                        .line_to(Point::new(x, y + s))
201                        .close()
202                        .move_to(Point::new(x + 2.0, y))
203                        .line_to(Point::new(x + 2.0, y - 2.0))
204                        .line_to(Point::new(x + s + 2.0, y - 2.0))
205                        .line_to(Point::new(x + s + 2.0, y + s - 2.0))
206                        .line_to(Point::new(x + s, y + s - 2.0))
207                }
208                CaptionButton::Maximize => Path::new()
209                    .move_to(Point::new(cx - h, cy - h))
210                    .line_to(Point::new(cx + h, cy - h))
211                    .line_to(Point::new(cx + h, cy + h))
212                    .line_to(Point::new(cx - h, cy + h))
213                    .close(),
214                CaptionButton::Close => Path::new()
215                    .move_to(Point::new(cx - h, cy - h))
216                    .line_to(Point::new(cx + h, cy + h))
217                    .move_to(Point::new(cx + h, cy - h))
218                    .line_to(Point::new(cx - h, cy + h)),
219            };
220            scene.stroke_path(&path, ink.fade(o), 1.0);
221        }
222    }
223
224    fn title_bar_role(&self) -> Option<TitleBarRole> {
225        Some(TitleBarRole::Buttons)
226    }
227
228    fn on_click(&self) -> Option<Msg> {
229        None
230    }
231
232    fn debug_name(&self) -> &'static str {
233        "CaptionButtons"
234    }
235}
236
237/// The window's icon on the title bar's line: the system opens the window menu from it.
238pub(crate) struct WindowIcon<Msg> {
239    children: Vec<Box<dyn Widget<Msg>>>,
240}
241
242impl<Msg: 'static> WindowIcon<Msg> {
243    pub(crate) fn new(png: &'static [u8], side: f32) -> Self {
244        Self {
245            children: vec![Box::new(crate::Image::memory(png).width(side).height(side))],
246        }
247    }
248}
249
250impl<Msg> Widget<Msg> for WindowIcon<Msg> {
251    fn style(&self) -> Style {
252        Style::default()
253    }
254
255    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
256        &self.children
257    }
258
259    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
260
261    fn title_bar_role(&self) -> Option<TitleBarRole> {
262        Some(TitleBarRole::Icon)
263    }
264
265    fn on_click(&self) -> Option<Msg> {
266        None
267    }
268
269    fn debug_name(&self) -> &'static str {
270        "WindowIcon"
271    }
272}
273
274/// The row a [`WindowMenuBar`] puts its menu bar in, which says it wants the title bar's
275/// line. Under a top intrusion — a tablet's status bar — it starts below it, and paints
276/// behind it.
277struct TitleBarRow<Msg> {
278    children: Vec<Box<dyn Widget<Msg>>>,
279    width: f32,
280    height: f32,
281    top: f32,
282    /// What the menus keep clear of on the left and the right: a phone's cutout and rounded
283    /// corners on its side. The row's colour still goes under them (milestone 725).
284    sides: (f32, f32),
285}
286
287impl<Msg> Widget<Msg> for TitleBarRow<Msg> {
288    fn style(&self) -> Style {
289        Style {
290            flex_direction: frus_layout::FlexDirection::Row,
291            align: frus_layout::Align::Center,
292            width: Dimension::Length(self.width),
293            height: Dimension::Length(self.top + self.height),
294            padding: Insets::new(self.top, self.sides.1, 0.0, self.sides.0),
295            ..Style::default()
296        }
297    }
298
299    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
300        &self.children
301    }
302
303    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
304        let bg = theme
305            .widgets
306            .menu_bar
307            .background
308            .unwrap_or(theme.scheme.surface_container);
309        scene.fill_rect(bounds, bg.fade(status.opacity));
310    }
311
312    fn wants_title_bar(&self) -> bool {
313        true
314    }
315
316    fn on_click(&self) -> Option<Msg> {
317        None
318    }
319
320    fn debug_name(&self) -> &'static str {
321        "TitleBarRow"
322    }
323}
324
325/// A menu bar's height off the title bar's line, unless it says otherwise: a desktop's.
326const MENU_BAR_HEIGHT: f32 = 30.0;
327
328/// The window's icon on the title bar's line, a side.
329const WINDOW_ICON: f32 = 16.0;
330
331/// **The window's menu bar**, above everything the window shows (milestone 642).
332///
333/// It goes around the application's pages, so that it stays put while they change — with
334/// `FrusApp::builder`, which hands it the pages:
335///
336/// ```ignore
337/// FrusApp::router(router).builder(|cx, pages| {
338///     let wide = MediaQuery::of().size_class() == SizeClass::Expanded;
339///     let bar = wide.then(|| menu_bar(cx)); // `None` on a narrow window
340///     Box::new(WindowMenuBar::new(bar, pages))
341/// })
342/// ```
343///
344/// On a desktop whose system allows it — Windows — the bar goes **on the title bar's line**,
345/// as a code editor's does: the system keeps the window's icon, which opens the window menu,
346/// and its three buttons, which minimize, maximize and close the window, and the empty part
347/// of the line moves the window and maximizes it on a double click. On Windows 11 the system
348/// paints the line — its backdrop and its buttons — and frus the icon, from the
349/// application's own, and the bar; where the system cannot, frus paints the line too, and the
350/// buttons where the system has them. Elsewhere the bar is the first line under the system's
351/// title bar. On the title bar's line it is as tall as
352/// the system's line; elsewhere as tall as the bar's own [`height`](MenuBar::height), or a
353/// desktop's 30.
354///
355/// **`child` gets the rest of the window**: below it, [`MediaQuery::of`] is the window less
356/// the bar's line — its size shorter by it, its top intrusion consumed — so a
357/// [`Scaffold`](crate::Scaffold) built there fits under the bar. That holds for what is built
358/// where it sits: the router's pages, a component. A scaffold made before, outside, has
359/// already read the whole window.
360///
361/// **With no bar** — `None`, a narrow window that folds its menus away — `child` gets the
362/// whole window and the system gets its title bar back. The tree keeps its shape either
363/// way, so the pages below keep their state when the bar comes and goes: keep the
364/// `WindowMenuBar` and pass it `None`, rather than leaving it out.
365///
366/// **Its colours are the system's** on the title bar's line: the caption the desktop would
367/// draw for the window — light or dark, the accent colour where the person asked for it,
368/// quieter when the window is not the active one. [`background`](Self::background),
369/// [`foreground`](Self::foreground) and [`style`](Self::style) say otherwise for this bar, and
370/// [`TitleBarTheme`] (`theme.widgets.title_bar`) for every bar; what this bar says outranks
371/// the theme, which outranks the system. Off the line, unset, the bar's own
372/// [`MenuBarTheme`](crate::MenuBarTheme).
373pub struct WindowMenuBar<Msg = crate::callback::Callback> {
374    /// The bar's row (an empty box with no bar), then `child`.
375    children: Vec<Box<dyn Widget<Msg>>>,
376    width: f32,
377    height: f32,
378    /// The colours this bar was given, read by the row's scope when the walk reaches it — a
379    /// setter called after `new` still reaches the row.
380    own: std::rc::Rc<std::cell::Cell<TitleBarTheme>>,
381}
382
383impl<Msg: Clone + 'static> WindowMenuBar<Msg> {
384    /// `bar` on the window's first line, or none, and `child` under it.
385    pub fn new(bar: impl Into<Option<MenuBar<Msg>>>, child: impl Widget<Msg> + 'static) -> Self {
386        let surface = MediaQuery::of();
387        let own = std::rc::Rc::new(std::cell::Cell::new(TitleBarTheme::default()));
388        let (row, taken): (Box<dyn Widget<Msg>>, f32) = match bar.into() {
389            Some(bar) => {
390                let line = surface.title_bar;
391                // On the title bar's line the system has the window's top: no intrusion
392                // there to start below.
393                let (top, sides) = if line.is_some() {
394                    (0.0, (0.0, 0.0))
395                } else {
396                    // Nor anywhere else: the bar keeps clear of every intrusion — a phone on
397                    // its side has its cutout at the left (milestone 725).
398                    let padding = surface.padding;
399                    (padding.top, (padding.left, padding.right))
400                };
401                let row = title_bar_row(bar, line, surface.size.width, top, sides);
402                let taken = row.top + row.height;
403                // The line's colours, for everything on it: resolved under the theme the
404                // walk brings, so the theme's say is the one in force where the bar is.
405                let colours = own.clone();
406                let row = Themed::tweak(
407                    move |theme: &mut Theme| paint_line(theme, colours.get(), line),
408                    row,
409                );
410                (Box::new(row), taken)
411            }
412            None => (Box::new(Container::new().height(0.0)), 0.0),
413        };
414        let content = MediaScope::tweak(
415            move |media: &mut MediaQuery| {
416                if taken > 0.0 {
417                    *media = media.remove_padding(Edges {
418                        top: true,
419                        ..Edges::NONE
420                    });
421                    media.size.height = (media.size.height - taken).max(0.0);
422                    // The line is this bar's: nothing below is on it.
423                    media.title_bar = None;
424                }
425            },
426            child,
427        );
428        Self {
429            children: vec![row, Box::new(Expanded::new(content))],
430            width: surface.size.width,
431            height: surface.size.height,
432            own,
433        }
434    }
435
436    /// **The line's surface**, active or not, for this bar.
437    #[must_use]
438    pub fn background(self, color: Color) -> Self {
439        self.restyle(|t| t.background = Some(color))
440    }
441
442    /// **The words and glyphs on the line**, active or not, for this bar.
443    #[must_use]
444    pub fn foreground(self, color: Color) -> Self {
445        self.restyle(|t| t.foreground = Some(color))
446    }
447
448    /// **Every colour of the line** for this bar, the inactive window's included. What it
449    /// leaves unset is the theme's, then the system's.
450    #[must_use]
451    pub fn style(self, style: TitleBarTheme) -> Self {
452        self.restyle(|t| *t = style)
453    }
454
455    fn restyle(self, change: impl FnOnce(&mut TitleBarTheme)) -> Self {
456        let mut style = self.own.get();
457        change(&mut style);
458        self.own.set(style);
459        self
460    }
461}
462
463/// How far the bar's highlight moves its surface toward its words, on the system's line: a
464/// menu bar's open word's.
465const HIGHLIGHT: f32 = 0.12;
466
467/// **`ink` at the opacity that reads, over `base`, as `base` moved `strength` of the way
468/// to `ink`** (milestone 643).
469///
470/// Over its own surface the bar's highlight is that colour, opaque. Over the system's
471/// backdrop it has to be a wash, and frus blends a wash in linear light, where 12 % of white
472/// over a dark caption reads as 40 %: measured on the system's line, the open word's box was
473/// `#7C7E7D` on `#1E2120`. So the opacity is the one that, blended in linear light over the
474/// system's caption colour, gives the colour the opaque highlight would have.
475fn wash(base: Color, ink: Color, strength: f32) -> Color {
476    let target = base.lerp(ink, strength).to_linear();
477    let (base, linear_ink) = (base.to_linear(), ink.to_linear());
478    let mut sum = 0.0;
479    let mut channels = 0.0;
480    for (target, base, ink) in [
481        (target.r, base.r, linear_ink.r),
482        (target.g, base.g, linear_ink.g),
483        (target.b, base.b, linear_ink.b),
484    ] {
485        if (ink - base).abs() > 1e-3 {
486            sum += (target - base) / (ink - base);
487            channels += 1.0;
488        }
489    }
490    let alpha = if channels > 0.0 {
491        (sum / channels).clamp(0.0, 1.0)
492    } else {
493        strength
494    };
495    ink.with_alpha(ink.a * alpha)
496}
497
498/// One of the line's colours for the window as it is: active, or what is said for an
499/// inactive window, else the active one's.
500fn for_window(active: bool, color: Option<Color>, inactive: Option<Color>) -> Option<Color> {
501    if active {
502        color
503    } else {
504        inactive.or(color)
505    }
506}
507
508/// **Says the line's colours to everything on it**: what the bar was given, then the theme's
509/// [`TitleBarTheme`], then the system's caption ([`TitleBar::background`]). The bar's words
510/// and highlight read them as the menu bar's surface and ink, and the window's buttons as
511/// their glyphs; what the menu bar itself was given still outranks them.
512fn paint_line(theme: &mut Theme, own: TitleBarTheme, line: Option<TitleBar>) {
513    let active = line.is_none_or(|line| line.active);
514    let said = theme.widgets.title_bar;
515    let background = for_window(active, own.background, own.inactive_background)
516        .or(for_window(
517            active,
518            said.background,
519            said.inactive_background,
520        ))
521        .or(line.and_then(|line| line.background.filter(|_| !line.system_paints)))
522        // Where the system paints the line, nothing of the application's is in its way.
523        .or(line
524            .filter(|line| line.system_paints)
525            .map(|_| Color::TRANSPARENT));
526    let foreground = for_window(active, own.foreground, own.inactive_foreground)
527        .or(for_window(
528            active,
529            said.foreground,
530            said.inactive_foreground,
531        ))
532        .or(line.and_then(|line| line.foreground));
533    if let Some(color) = background {
534        theme.widgets.menu_bar.background = Some(color);
535    }
536    // Over the system's own backdrop, the bar's highlight is a wash of its words that reads
537    // as it would over the system's caption (milestone 643).
538    if let Some(line) = line.filter(|line| line.system_paints) {
539        let bar = &mut theme.widgets.menu_bar;
540        if let (None, Some(base), Some(ink)) = (bar.highlight, line.background, foreground) {
541            bar.highlight = Some(wash(base, ink, HIGHLIGHT));
542        }
543    }
544    if let Some(color) = foreground {
545        theme.widgets.menu_bar.foreground = Some(color);
546        // Already the inactive window's when the window is not active: the glyphs take it
547        // as it is, rather than quietened a second time.
548        if !active {
549            let buttons = &mut theme.widgets.caption_buttons;
550            buttons.inactive_glyph_color = buttons.inactive_glyph_color.or(Some(color));
551        }
552    }
553}
554
555/// The bar's row: on the title bar's line, the system's height, with the window's icon at
556/// its start and its three buttons where the system has them; elsewhere, the bar alone.
557fn title_bar_row<Msg: Clone + 'static>(
558    bar: MenuBar<Msg>,
559    line: Option<TitleBar>,
560    width: f32,
561    top: f32,
562    sides: (f32, f32),
563) -> TitleBarRow<Msg> {
564    let height = line
565        .map(|line| line.height)
566        .or(bar.given_height())
567        .unwrap_or(MENU_BAR_HEIGHT);
568    let mut cells: Vec<Box<dyn Widget<Msg>>> = Vec::new();
569    if let Some(line) = line {
570        if line.leading > 0.0 {
571            cells.push(Box::new(
572                Container::new().width(line.leading).height(height),
573            ));
574        }
575        if let Some(png) = line.icon {
576            cells.push(Box::new(
577                Container::new()
578                    .padding_each(0.0, 6.0, 0.0, 10.0)
579                    .child(WindowIcon::new(png, WINDOW_ICON)),
580            ));
581        }
582    }
583    cells.push(Box::new(bar.height(height)));
584    // The empty part of the line: nothing of the application's, so the system's to move the
585    // window by.
586    cells.push(Box::new(Container::new().flex(1.0).height(height)));
587    if let Some(line) = line {
588        if let Some(buttons) = line.buttons {
589            if line.system_paints {
590                // The system's own buttons show there: the room is kept, and left clear.
591                cells.push(Box::new(
592                    Container::new().width(buttons.width).height(height),
593                ));
594            } else {
595                cells.push(Box::new(CaptionButtons::new(line, buttons.width)));
596            }
597        }
598    }
599    TitleBarRow {
600        children: cells,
601        width,
602        height,
603        top,
604        sides,
605    }
606}
607
608impl<Msg> Widget<Msg> for WindowMenuBar<Msg> {
609    fn style(&self) -> Style {
610        Style {
611            flex_direction: frus_layout::FlexDirection::Column,
612            width: Dimension::Length(self.width),
613            height: Dimension::Length(self.height),
614            ..Style::default()
615        }
616    }
617
618    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
619        &self.children
620    }
621
622    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
623
624    fn on_click(&self) -> Option<Msg> {
625        None
626    }
627
628    fn debug_name(&self) -> &'static str {
629        "WindowMenuBar"
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use crate::{
637        build_ui, BuildContext, Callback, Component, MenuBar, MenuPath, Runtime, Scaffold, Size,
638        SubmenuButton, UseState,
639    };
640    use std::cell::{Cell, RefCell};
641    use std::rc::Rc;
642
643    type Msg = Callback;
644
645    const SIZE: Size = Size::new(1000.0, 700.0);
646    /// The frus logo, a real PNG.
647    const ICON: &[u8] = include_bytes!("../../frus-shell/assets/icon.png");
648
649    fn bar() -> MenuBar<Msg> {
650        MenuBar::new(&MenuPath::closed(), |_| Callback::new(|| {})).menu(SubmenuButton::new("File"))
651    }
652
653    /// What a page read of the window when it was built.
654    type Seen = Rc<Cell<Option<MediaQuery>>>;
655
656    /// A page as the router makes one: a component, built where it sits, whose scaffold has
657    /// a title, a footer and a floating button.
658    fn page(seen: &Seen) -> Component {
659        let seen = seen.clone();
660        Component::stateless(move |_: &BuildContext| {
661            seen.set(Some(MediaQuery::of()));
662            Scaffold::new()
663                .app_bar(crate::Text::new("Title"))
664                .persistent_footer(crate::Text::new("Foot"))
665                .fab(crate::Text::new("Fab"))
666                .build()
667        })
668    }
669
670    /// One frame of the shell's, less the drawing, on `surface`: `tree` is made under it, as
671    /// the shell calls `view`, then built and laid out.
672    fn frame(
673        surface: MediaQuery,
674        runtime: &Runtime,
675        tree: impl FnOnce() -> Box<dyn Widget<Msg>>,
676    ) -> crate::Ui<Msg> {
677        frame_themed(surface, runtime, &windows(), tree)
678    }
679
680    fn windows() -> Theme {
681        Theme::default().with_platform(frus_core::TargetPlatform::Windows)
682    }
683
684    /// [`frame`], under `theme`.
685    fn frame_themed(
686        surface: MediaQuery,
687        runtime: &Runtime,
688        theme: &Theme,
689        tree: impl FnOnce() -> Box<dyn Widget<Msg>>,
690    ) -> crate::Ui<Msg> {
691        let theme = theme.clone();
692        surface.scope(|| {
693            runtime.states.begin_build();
694            let tree = tree();
695            crate::build_deferred(tree.as_ref(), &theme, runtime);
696            let ui = build_ui(tree.as_ref(), SIZE, runtime, &theme);
697            runtime.states.end_frame();
698            ui
699        })
700    }
701
702    /// The window's menu bar around a page, on `surface`.
703    fn window(surface: MediaQuery, seen: &Seen) -> crate::Ui<Msg> {
704        let page = page(seen);
705        frame(surface, &Runtime::default(), || {
706            Box::new(WindowMenuBar::new(bar(), page))
707        })
708    }
709
710    fn text_at(ui: &crate::Ui<Msg>, label: &str) -> Rect {
711        find_text(ui, label).unwrap_or_else(|| panic!("{label:?} is painted"))
712    }
713
714    fn find_text(ui: &crate::Ui<Msg>, label: &str) -> Option<Rect> {
715        ui.scene().primitives().iter().find_map(|p| match p {
716            frus_core::Primitive::Text { text, .. } if text == label => Some(p.bounds()),
717            _ => None,
718        })
719    }
720
721    fn line(buttons: Option<Rect>, icon: Option<&'static [u8]>) -> TitleBar {
722        TitleBar {
723            height: 40.0,
724            leading: 0.0,
725            buttons,
726            icon,
727            hovered: None,
728            pressed: None,
729            maximized: false,
730            active: true,
731            background: None,
732            foreground: None,
733            system_paints: false,
734        }
735    }
736
737    /// **The window's menu bar is its first line, and the page gets the rest**: off the
738    /// title bar's line a desktop's 30 px; the page is built for the window less them, so
739    /// its scaffold fits under the bar — its footer and floating button still at the
740    /// window's bottom — and nothing below is on the line.
741    #[test]
742    fn the_menu_bar_is_the_window_s_first_line_and_the_page_gets_the_rest() {
743        let seen = Seen::default();
744        let ui = window(MediaQuery::new(SIZE), &seen);
745        let file = text_at(&ui, "File");
746        let title = text_at(&ui, "Title");
747        assert!(file.y + file.height <= 30.0, "in the first 30 px: {file:?}");
748        assert!(title.y >= 30.0, "the app bar under it: {title:?}");
749        assert!(ui.wants_title_bar(), "and it asks for the line");
750        let page = seen.get().expect("the page was built");
751        assert_eq!(
752            page.size,
753            Size::new(SIZE.width, SIZE.height - 30.0),
754            "the page is built for the window less the bar"
755        );
756        assert_eq!(page.title_bar, None, "the line is the bar's");
757        for part in ["Foot", "Fab"] {
758            let r = text_at(&ui, part);
759            assert!(
760                r.y + r.height <= SIZE.height && r.y + r.height > SIZE.height - 80.0,
761                "{part} is at the window's bottom: {r:?}"
762            );
763        }
764        assert!(
765            ui.title_bar_regions().is_empty(),
766            "nothing of the system's on it"
767        );
768    }
769
770    /// **On the title bar's line**, the row is the system's height, the window's icon is
771    /// at its start, and the three buttons are where the system has them.
772    #[test]
773    fn on_the_title_bar_line_the_system_s_parts_are_painted_where_it_has_them() {
774        let buttons = Rect::new(862.0, 0.0, 138.0, 40.0);
775        let seen = Seen::default();
776        let surface = MediaQuery::new(SIZE).with_title_bar(Some(line(Some(buttons), Some(ICON))));
777        let ui = window(surface, &seen);
778        let title = text_at(&ui, "Title");
779        assert!(title.y >= 40.0, "the app bar under the line: {title:?}");
780        let page = seen.get().expect("the page was built");
781        assert_eq!(page.size.height, SIZE.height - 40.0, "the line taken off");
782        assert_eq!(page.title_bar, None, "the line is the bar's");
783        let regions = ui.title_bar_regions();
784        let (button_box, _) = regions
785            .iter()
786            .find(|(_, role)| *role == TitleBarRole::Buttons)
787            .expect("the buttons");
788        assert_eq!(*button_box, buttons, "where the system has them");
789        let (icon_box, _) = regions
790            .iter()
791            .find(|(_, role)| *role == TitleBarRole::Icon)
792            .expect("the icon");
793        assert!(
794            icon_box.x < 40.0 && icon_box.width >= 16.0,
795            "at the start: {icon_box:?}"
796        );
797        let file = text_at(&ui, "File");
798        assert!(
799            file.x > icon_box.x + icon_box.width,
800            "the menu after the icon"
801        );
802    }
803
804    /// **Under a top intrusion** — a tablet's status bar — the bar starts below it, and the
805    /// page is told the intrusion is dealt with.
806    #[test]
807    fn under_a_status_bar_the_menu_bar_starts_below_it() {
808        let seen = Seen::default();
809        let surface = MediaQuery::new(SIZE).with_insets(frus_core::WindowInsets {
810            padding: frus_core::Insets::new(24.0, 0.0, 0.0, 0.0),
811            ..Default::default()
812        });
813        let ui = window(surface, &seen);
814        let file = text_at(&ui, "File");
815        assert!(file.y >= 24.0, "below the status bar: {file:?}");
816        let page = seen.get().expect("the page was built");
817        assert_eq!(page.size.height, SIZE.height - 24.0 - 30.0);
818        assert_eq!(page.padding.top, 0.0, "the status bar is dealt with");
819        let title = text_at(&ui, "Title");
820        assert!(title.y >= 54.0, "the app bar under both: {title:?}");
821    }
822
823    /// **Beside a cutout** — a phone on its side, its cutout at the left — the menus start
824    /// clear of it and end clear of the right corner, and the bar's colour still goes under
825    /// both (milestone 725).
826    #[test]
827    fn beside_a_cutout_the_menus_keep_clear_of_it() {
828        let seen = Seen::default();
829        let surface = MediaQuery::new(SIZE).with_insets(frus_core::WindowInsets {
830            padding: frus_core::Insets::new(0.0, 40.0, 21.0, 59.0),
831            ..Default::default()
832        });
833        let ui = window(surface, &seen);
834        let file = text_at(&ui, "File");
835        assert!(file.x >= 59.0, "clear of the cutout: {file:?}");
836        let row = ui
837            .scene()
838            .primitives()
839            .iter()
840            .find_map(|p| match p {
841                frus_core::Primitive::Rect { rect, .. } if rect.y == 0.0 && rect.height == 30.0 => {
842                    Some(*rect)
843                }
844                _ => None,
845            })
846            .expect("the bar's surface");
847        assert_eq!(
848            (row.x, row.width),
849            (0.0, SIZE.width),
850            "the colour goes under"
851        );
852        let page = seen.get().expect("the page was built");
853        assert_eq!(page.padding.left, 59.0, "the page still keeps clear of it");
854        assert_eq!(page.padding.right, 40.0);
855    }
856
857    /// **With no bar** — a narrow window — the page has the whole window, the system gets
858    /// its line back, and **the page keeps its state** while the bar comes and goes: the
859    /// tree keeps its shape.
860    #[test]
861    fn with_no_bar_the_page_has_the_window_and_keeps_its_state() {
862        let runtime = Runtime::default();
863        let count: Rc<RefCell<Option<UseState<i32>>>> = Rc::default();
864        let tree = |with_bar: bool| {
865            let count = count.clone();
866            move || -> Box<dyn Widget<Msg>> {
867                let page = Component::stateless(move |cx: &BuildContext| -> Box<dyn Widget> {
868                    let n = cx.use_state(|| 0);
869                    *count.borrow_mut() = Some(n.clone());
870                    Box::new(crate::Text::new(format!("count {}", n.get())))
871                });
872                Box::new(WindowMenuBar::new(with_bar.then(bar), page))
873            }
874        };
875        let ui = frame(MediaQuery::new(SIZE), &runtime, tree(true));
876        assert!(ui.wants_title_bar());
877        count.borrow().as_ref().expect("built").set(5);
878
879        let ui = frame(MediaQuery::new(SIZE), &runtime, tree(false));
880        assert!(find_text(&ui, "File").is_none(), "no bar");
881        assert!(!ui.wants_title_bar(), "the line is the system's again");
882        let text = text_at(&ui, "count 5");
883        assert!(text.y < 30.0, "the page from the window's top: {text:?}");
884
885        let ui = frame(MediaQuery::new(SIZE), &runtime, tree(true));
886        assert!(find_text(&ui, "count 5").is_some(), "kept, both ways");
887    }
888
889    /// The system's caption, as the shell reports it for an active dark window.
890    const CAPTION: Color = Color::rgb(0.13, 0.12, 0.16);
891    const CAPTION_INK: Color = Color::WHITE;
892
893    /// The line as the shell reports it: the system's buttons, and its caption's colours.
894    fn system_line(active: bool, background: Color, foreground: Color) -> TitleBar {
895        TitleBar {
896            active,
897            background: Some(background),
898            foreground: Some(foreground),
899            ..line(Some(Rect::new(862.0, 0.0, 138.0, 40.0)), None)
900        }
901    }
902
903    /// The window's menu bar around an empty page, on `surface`, under `theme`; `dress`
904    /// gives the bar its colours.
905    fn dressed(
906        surface: MediaQuery,
907        theme: &Theme,
908        dress: impl FnOnce(WindowMenuBar<Msg>) -> WindowMenuBar<Msg>,
909    ) -> crate::Ui<Msg> {
910        frame_themed(surface, &Runtime::default(), theme, || {
911            Box::new(dress(WindowMenuBar::new(bar(), crate::Text::new("page"))))
912        })
913    }
914
915    /// The line's surface, the bar's words and the window's glyphs, as painted.
916    fn colours(ui: &crate::Ui<Msg>) -> (Color, Color, Vec<Color>) {
917        let prims = ui.scene().primitives();
918        let surface = prims
919            .iter()
920            .find_map(|p| match p {
921                frus_core::Primitive::Rect { rect, color, .. }
922                    if rect.x == 0.0 && rect.y == 0.0 && rect.width == SIZE.width =>
923                {
924                    Some(*color)
925                }
926                _ => None,
927            })
928            .expect("the line's surface");
929        let words = prims
930            .iter()
931            .find_map(|p| match p {
932                frus_core::Primitive::Text { text, color, .. } if text == "File" => Some(*color),
933                _ => None,
934            })
935            .expect("the bar's words");
936        let glyphs = prims
937            .iter()
938            .filter_map(|p| match p {
939                frus_core::Primitive::Path {
940                    stroke: Some(stroke),
941                    ..
942                } => Some(stroke.color),
943                _ => None,
944            })
945            .collect();
946        (surface, words, glyphs)
947    }
948
949    /// **The system's caption is the line's by default**: its surface, and its ink on the
950    /// words and the window's glyphs — for an inactive window the inactive caption's, taken
951    /// as it is and not quietened a second time.
952    #[test]
953    fn the_line_takes_the_system_s_caption_colours() {
954        let surface =
955            MediaQuery::new(SIZE).with_title_bar(Some(system_line(true, CAPTION, CAPTION_INK)));
956        let (fill, words, glyphs) = colours(&dressed(surface, &windows(), |w| w));
957        assert_eq!(fill, CAPTION, "the system's surface");
958        assert_eq!(words, CAPTION_INK, "its ink on the words");
959        assert_eq!(glyphs, vec![CAPTION_INK; 3], "and on the glyphs");
960
961        let (grey, quiet) = (
962            Color::rgb(0.125, 0.125, 0.125),
963            Color::rgb(0.47, 0.47, 0.47),
964        );
965        let surface = MediaQuery::new(SIZE).with_title_bar(Some(system_line(false, grey, quiet)));
966        let (fill, words, glyphs) = colours(&dressed(surface, &windows(), |w| w));
967        assert_eq!(fill, grey, "the inactive caption's surface");
968        assert_eq!(words, quiet, "its ink");
969        assert_eq!(glyphs, vec![quiet; 3], "the glyphs as the system has them");
970    }
971
972    /// **The theme outranks the system, and the bar outranks the theme** — for an active
973    /// window and, with what is said for one, an inactive window.
974    #[test]
975    fn the_bar_and_the_theme_say_otherwise() {
976        let red = Color::rgb(1.0, 0.0, 0.0);
977        let green = Color::rgb(0.0, 1.0, 0.0);
978        let blue = Color::rgb(0.0, 0.0, 1.0);
979        let yellow = Color::rgb(1.0, 1.0, 0.0);
980        let mut themed = windows();
981        themed.widgets.title_bar = TitleBarTheme {
982            background: Some(red),
983            foreground: Some(green),
984            inactive_background: None,
985            inactive_foreground: Some(blue),
986        };
987        let on = |active| {
988            MediaQuery::new(SIZE).with_title_bar(Some(system_line(active, CAPTION, CAPTION_INK)))
989        };
990
991        let (fill, words, glyphs) = colours(&dressed(on(true), &themed, |w| w));
992        assert_eq!(
993            (fill, words),
994            (red, green),
995            "the theme's, over the system's"
996        );
997        assert_eq!(glyphs, vec![green; 3]);
998
999        let (fill, words, glyphs) = colours(&dressed(on(false), &themed, |w| w));
1000        assert_eq!(fill, red, "no inactive surface said: the active one's");
1001        assert_eq!(words, blue, "the inactive window's ink");
1002        assert_eq!(glyphs, vec![blue; 3]);
1003
1004        let (fill, words, glyphs) = colours(&dressed(on(true), &themed, |w| {
1005            w.background(blue).foreground(yellow)
1006        }));
1007        assert_eq!((fill, words), (blue, yellow), "the bar's, over the theme's");
1008        assert_eq!(glyphs, vec![yellow; 3]);
1009    }
1010
1011    /// **Where the system paints the line**, the application paints neither its surface nor
1012    /// the window's buttons — the system's show through — and the room the buttons need is
1013    /// kept; the words take the system's ink, and their highlight is a wash that reads as it
1014    /// would over the system's caption.
1015    #[test]
1016    fn where_the_system_paints_the_line_the_application_leaves_it_clear() {
1017        let painted = |system_paints| {
1018            let line = TitleBar {
1019                system_paints,
1020                ..system_line(true, CAPTION, CAPTION_INK)
1021            };
1022            dressed(
1023                MediaQuery::new(SIZE).with_title_bar(Some(line)),
1024                &windows(),
1025                |w| w,
1026            )
1027        };
1028        let ui = painted(true);
1029        let opaque_line = ui.scene().primitives().iter().any(|p| {
1030            matches!(p, frus_core::Primitive::Rect { rect, color, .. }
1031                if rect.y < 40.0 && rect.width >= 100.0 && color.a > 0.0)
1032        });
1033        assert!(!opaque_line, "nothing of the application's under the line");
1034        let ink = ui
1035            .scene()
1036            .primitives()
1037            .iter()
1038            .find_map(|p| match p {
1039                frus_core::Primitive::Text { text, color, .. } if text == "File" => Some(*color),
1040                _ => None,
1041            })
1042            .expect("the bar's words");
1043        assert_eq!(ink, CAPTION_INK, "the system's ink on the words");
1044        let strokes = ui
1045            .scene()
1046            .primitives()
1047            .iter()
1048            .filter(|p| {
1049                matches!(
1050                    p,
1051                    frus_core::Primitive::Path {
1052                        stroke: Some(_),
1053                        ..
1054                    }
1055                )
1056            })
1057            .count();
1058        assert_eq!(strokes, 0, "the system's own buttons, not painted ones");
1059        let file = text_at(&ui, "File");
1060        let painted_file = text_at(&painted(false), "File");
1061        assert_eq!(file, painted_file, "the bar where it was");
1062
1063        // The open word's highlight is the wash that reads as on the system's caption.
1064        let line = TitleBar {
1065            system_paints: true,
1066            ..system_line(true, CAPTION, CAPTION_INK)
1067        };
1068        let open = frame_themed(
1069            MediaQuery::new(SIZE).with_title_bar(Some(line)),
1070            &Runtime::default(),
1071            &windows(),
1072            || {
1073                let bar = MenuBar::new(&MenuPath::from_indices([0]), |_| Callback::new(|| {}))
1074                    .menu(SubmenuButton::new("File"));
1075                Box::new(WindowMenuBar::new(bar, crate::Text::new("page")))
1076            },
1077        );
1078        let file = text_at(&open, "File");
1079        let lit = open
1080            .scene()
1081            .primitives()
1082            .iter()
1083            .find_map(|p| match p {
1084                frus_core::Primitive::Rect { rect, color, .. }
1085                    if rect.contains(frus_core::Point::new(file.x + 1.0, file.y + 4.0))
1086                        && color.a > 0.0 =>
1087                {
1088                    Some(*color)
1089                }
1090                _ => None,
1091            })
1092            .expect("the open word's highlight");
1093        assert_eq!(lit, wash(CAPTION, CAPTION_INK, HIGHLIGHT));
1094    }
1095
1096    /// **The wash reads as the opaque highlight would**: blended in linear light over the
1097    /// system's caption, it gives the caption moved 12 % toward the words — on a dark
1098    /// caption, where white at 12 % would read far stronger, and on a light one, where black
1099    /// needs more than 12 % to read as much.
1100    #[test]
1101    fn the_highlight_on_the_system_s_line_reads_as_on_its_caption() {
1102        for (base, ink, lighter) in [
1103            (Color::rgb8(0x20, 0x20, 0x20), Color::WHITE, true),
1104            (Color::rgb8(0xF3, 0xF3, 0xF3), Color::BLACK, false),
1105        ] {
1106            let washed = wash(base, ink, HIGHLIGHT);
1107            assert!(washed.a > 0.0 && washed.a < 1.0, "a wash: {washed:?}");
1108            assert_eq!(washed.a < HIGHLIGHT, lighter, "{base:?}: {washed:?}");
1109            let (b, i) = (base.to_linear(), ink.to_linear());
1110            let blended = b.r + (i.r - b.r) * washed.a;
1111            let wanted = base.lerp(ink, HIGHLIGHT).to_linear().r;
1112            assert!(
1113                (blended - wanted).abs() < 1e-4,
1114                "{base:?}: {blended} for {wanted}"
1115            );
1116        }
1117    }
1118
1119    /// **Off the title bar's line, with nothing said**, the row is the menu bar's own:
1120    /// `surface_container` and `on_surface`, as before.
1121    #[test]
1122    fn off_the_line_the_row_is_the_menu_bar_s_own() {
1123        let theme = windows();
1124        let (fill, words, glyphs) = colours(&dressed(MediaQuery::new(SIZE), &theme, |w| w));
1125        assert_eq!(fill, theme.scheme.surface_container);
1126        assert_eq!(words, theme.scheme.on_surface);
1127        assert!(glyphs.is_empty(), "no buttons of the system's");
1128    }
1129
1130    /// The three glyphs and the system's states, painted.
1131    fn painted(hovered: Option<CaptionButton>, maximized: bool) -> Vec<frus_core::Primitive> {
1132        let bar = TitleBar {
1133            hovered,
1134            maximized,
1135            ..line(Some(Rect::new(0.0, 0.0, 138.0, 40.0)), None)
1136        };
1137        let mut scene = Scene::new();
1138        Widget::<Msg>::paint(
1139            &CaptionButtons::new(bar, 138.0),
1140            Rect::new(0.0, 0.0, 138.0, 40.0),
1141            Status {
1142                opacity: 1.0,
1143                ..Default::default()
1144            },
1145            &Theme::dark(),
1146            &mut scene,
1147        );
1148        scene.primitives().to_vec()
1149    }
1150
1151    /// **Three glyphs at rest, a wash under the pointer, and red under it on close.**
1152    #[test]
1153    fn the_buttons_take_the_system_s_states() {
1154        let fills = |prims: &[frus_core::Primitive]| -> Vec<(Rect, Color)> {
1155            prims
1156                .iter()
1157                .filter_map(|p| match p {
1158                    frus_core::Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
1159                    _ => None,
1160                })
1161                .collect()
1162        };
1163        let strokes = |prims: &[frus_core::Primitive]| {
1164            prims
1165                .iter()
1166                .filter(|p| matches!(p, frus_core::Primitive::Path { .. }))
1167                .count()
1168        };
1169        let rest = painted(None, false);
1170        assert!(fills(&rest).is_empty(), "nothing behind them at rest");
1171        assert_eq!(strokes(&rest), 3, "three glyphs");
1172
1173        let close = fills(&painted(Some(CaptionButton::Close), false));
1174        assert_eq!(close.len(), 1);
1175        assert_eq!(
1176            close[0].0,
1177            Rect::new(92.0, 0.0, 46.0, 40.0),
1178            "the last cell"
1179        );
1180        assert_eq!(close[0].1, Color::rgb(0.77, 0.17, 0.11), "red");
1181
1182        let minimize = fills(&painted(Some(CaptionButton::Minimize), false));
1183        assert_eq!(minimize[0].0.x, 0.0, "the first cell");
1184        assert_ne!(minimize[0].1, close[0].1, "a wash, not red");
1185
1186        // Maximized, the middle glyph is the restore glyph: two squares, not one.
1187        let path_len = |prims: Vec<frus_core::Primitive>| -> usize {
1188            prims
1189                .iter()
1190                .filter_map(|p| match p {
1191                    frus_core::Primitive::Path { path, .. } => Some(path.verbs().len()),
1192                    _ => None,
1193                })
1194                .nth(1)
1195                .expect("the middle glyph")
1196        };
1197        assert!(path_len(painted(None, true)) > path_len(painted(None, false)));
1198    }
1199}