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}
283
284impl<Msg> Widget<Msg> for TitleBarRow<Msg> {
285    fn style(&self) -> Style {
286        Style {
287            flex_direction: frus_layout::FlexDirection::Row,
288            align: frus_layout::Align::Center,
289            width: Dimension::Length(self.width),
290            height: Dimension::Length(self.top + self.height),
291            padding: Insets::new(self.top, 0.0, 0.0, 0.0),
292            ..Style::default()
293        }
294    }
295
296    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
297        &self.children
298    }
299
300    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
301        let bg = theme
302            .widgets
303            .menu_bar
304            .background
305            .unwrap_or(theme.scheme.surface_container);
306        scene.fill_rect(bounds, bg.fade(status.opacity));
307    }
308
309    fn wants_title_bar(&self) -> bool {
310        true
311    }
312
313    fn on_click(&self) -> Option<Msg> {
314        None
315    }
316
317    fn debug_name(&self) -> &'static str {
318        "TitleBarRow"
319    }
320}
321
322/// A menu bar's height off the title bar's line, unless it says otherwise: a desktop's.
323const MENU_BAR_HEIGHT: f32 = 30.0;
324
325/// The window's icon on the title bar's line, a side.
326const WINDOW_ICON: f32 = 16.0;
327
328/// **The window's menu bar**, above everything the window shows (milestone 642).
329///
330/// It goes around the application's pages, so that it stays put while they change — with
331/// `FrusApp::builder`, which hands it the pages:
332///
333/// ```ignore
334/// FrusApp::router(router).builder(|cx, pages| {
335///     let wide = MediaQuery::of().size_class() == SizeClass::Expanded;
336///     let bar = wide.then(|| menu_bar(cx)); // `None` on a narrow window
337///     Box::new(WindowMenuBar::new(bar, pages))
338/// })
339/// ```
340///
341/// On a desktop whose system allows it — Windows — the bar goes **on the title bar's line**,
342/// as a code editor's does: the system keeps the window's icon, which opens the window menu,
343/// and its three buttons, which minimize, maximize and close the window, and the empty part
344/// of the line moves the window and maximizes it on a double click. On Windows 11 the system
345/// paints the line — its backdrop and its buttons — and frus the icon, from the
346/// application's own, and the bar; where the system cannot, frus paints the line too, and the
347/// buttons where the system has them. Elsewhere the bar is the first line under the system's
348/// title bar. On the title bar's line it is as tall as
349/// the system's line; elsewhere as tall as the bar's own [`height`](MenuBar::height), or a
350/// desktop's 30.
351///
352/// **`child` gets the rest of the window**: below it, [`MediaQuery::of`] is the window less
353/// the bar's line — its size shorter by it, its top intrusion consumed — so a
354/// [`Scaffold`](crate::Scaffold) built there fits under the bar. That holds for what is built
355/// where it sits: the router's pages, a component. A scaffold made before, outside, has
356/// already read the whole window.
357///
358/// **With no bar** — `None`, a narrow window that folds its menus away — `child` gets the
359/// whole window and the system gets its title bar back. The tree keeps its shape either
360/// way, so the pages below keep their state when the bar comes and goes: keep the
361/// `WindowMenuBar` and pass it `None`, rather than leaving it out.
362///
363/// **Its colours are the system's** on the title bar's line: the caption the desktop would
364/// draw for the window — light or dark, the accent colour where the person asked for it,
365/// quieter when the window is not the active one. [`background`](Self::background),
366/// [`foreground`](Self::foreground) and [`style`](Self::style) say otherwise for this bar, and
367/// [`TitleBarTheme`] (`theme.widgets.title_bar`) for every bar; what this bar says outranks
368/// the theme, which outranks the system. Off the line, unset, the bar's own
369/// [`MenuBarTheme`](crate::MenuBarTheme).
370pub struct WindowMenuBar<Msg = crate::callback::Callback> {
371    /// The bar's row (an empty box with no bar), then `child`.
372    children: Vec<Box<dyn Widget<Msg>>>,
373    width: f32,
374    height: f32,
375    /// The colours this bar was given, read by the row's scope when the walk reaches it — a
376    /// setter called after `new` still reaches the row.
377    own: std::rc::Rc<std::cell::Cell<TitleBarTheme>>,
378}
379
380impl<Msg: Clone + 'static> WindowMenuBar<Msg> {
381    /// `bar` on the window's first line, or none, and `child` under it.
382    pub fn new(bar: impl Into<Option<MenuBar<Msg>>>, child: impl Widget<Msg> + 'static) -> Self {
383        let surface = MediaQuery::of();
384        let own = std::rc::Rc::new(std::cell::Cell::new(TitleBarTheme::default()));
385        let (row, taken): (Box<dyn Widget<Msg>>, f32) = match bar.into() {
386            Some(bar) => {
387                let line = surface.title_bar;
388                // On the title bar's line the system has the window's top: no intrusion
389                // there to start below.
390                let top = if line.is_some() {
391                    0.0
392                } else {
393                    surface.padding.top
394                };
395                let row = title_bar_row(bar, line, surface.size.width, top);
396                let taken = row.top + row.height;
397                // The line's colours, for everything on it: resolved under the theme the
398                // walk brings, so the theme's say is the one in force where the bar is.
399                let colours = own.clone();
400                let row = Themed::tweak(
401                    move |theme: &mut Theme| paint_line(theme, colours.get(), line),
402                    row,
403                );
404                (Box::new(row), taken)
405            }
406            None => (Box::new(Container::new().height(0.0)), 0.0),
407        };
408        let content = MediaScope::tweak(
409            move |media: &mut MediaQuery| {
410                if taken > 0.0 {
411                    *media = media.remove_padding(Edges {
412                        top: true,
413                        ..Edges::NONE
414                    });
415                    media.size.height = (media.size.height - taken).max(0.0);
416                    // The line is this bar's: nothing below is on it.
417                    media.title_bar = None;
418                }
419            },
420            child,
421        );
422        Self {
423            children: vec![row, Box::new(Expanded::new(content))],
424            width: surface.size.width,
425            height: surface.size.height,
426            own,
427        }
428    }
429
430    /// **The line's surface**, active or not, for this bar.
431    #[must_use]
432    pub fn background(self, color: Color) -> Self {
433        self.restyle(|t| t.background = Some(color))
434    }
435
436    /// **The words and glyphs on the line**, active or not, for this bar.
437    #[must_use]
438    pub fn foreground(self, color: Color) -> Self {
439        self.restyle(|t| t.foreground = Some(color))
440    }
441
442    /// **Every colour of the line** for this bar, the inactive window's included. What it
443    /// leaves unset is the theme's, then the system's.
444    #[must_use]
445    pub fn style(self, style: TitleBarTheme) -> Self {
446        self.restyle(|t| *t = style)
447    }
448
449    fn restyle(self, change: impl FnOnce(&mut TitleBarTheme)) -> Self {
450        let mut style = self.own.get();
451        change(&mut style);
452        self.own.set(style);
453        self
454    }
455}
456
457/// How far the bar's highlight moves its surface toward its words, on the system's line: a
458/// menu bar's open word's.
459const HIGHLIGHT: f32 = 0.12;
460
461/// **`ink` at the opacity that reads, over `base`, as `base` moved `strength` of the way
462/// to `ink`** (milestone 643).
463///
464/// Over its own surface the bar's highlight is that colour, opaque. Over the system's
465/// backdrop it has to be a wash, and frus blends a wash in linear light, where 12 % of white
466/// over a dark caption reads as 40 %: measured on the system's line, the open word's box was
467/// `#7C7E7D` on `#1E2120`. So the opacity is the one that, blended in linear light over the
468/// system's caption colour, gives the colour the opaque highlight would have.
469fn wash(base: Color, ink: Color, strength: f32) -> Color {
470    let target = base.lerp(ink, strength).to_linear();
471    let (base, linear_ink) = (base.to_linear(), ink.to_linear());
472    let mut sum = 0.0;
473    let mut channels = 0.0;
474    for (target, base, ink) in [
475        (target.r, base.r, linear_ink.r),
476        (target.g, base.g, linear_ink.g),
477        (target.b, base.b, linear_ink.b),
478    ] {
479        if (ink - base).abs() > 1e-3 {
480            sum += (target - base) / (ink - base);
481            channels += 1.0;
482        }
483    }
484    let alpha = if channels > 0.0 {
485        (sum / channels).clamp(0.0, 1.0)
486    } else {
487        strength
488    };
489    ink.with_alpha(ink.a * alpha)
490}
491
492/// One of the line's colours for the window as it is: active, or what is said for an
493/// inactive window, else the active one's.
494fn for_window(active: bool, color: Option<Color>, inactive: Option<Color>) -> Option<Color> {
495    if active {
496        color
497    } else {
498        inactive.or(color)
499    }
500}
501
502/// **Says the line's colours to everything on it**: what the bar was given, then the theme's
503/// [`TitleBarTheme`], then the system's caption ([`TitleBar::background`]). The bar's words
504/// and highlight read them as the menu bar's surface and ink, and the window's buttons as
505/// their glyphs; what the menu bar itself was given still outranks them.
506fn paint_line(theme: &mut Theme, own: TitleBarTheme, line: Option<TitleBar>) {
507    let active = line.is_none_or(|line| line.active);
508    let said = theme.widgets.title_bar;
509    let background = for_window(active, own.background, own.inactive_background)
510        .or(for_window(
511            active,
512            said.background,
513            said.inactive_background,
514        ))
515        .or(line.and_then(|line| line.background.filter(|_| !line.system_paints)))
516        // Where the system paints the line, nothing of the application's is in its way.
517        .or(line
518            .filter(|line| line.system_paints)
519            .map(|_| Color::TRANSPARENT));
520    let foreground = for_window(active, own.foreground, own.inactive_foreground)
521        .or(for_window(
522            active,
523            said.foreground,
524            said.inactive_foreground,
525        ))
526        .or(line.and_then(|line| line.foreground));
527    if let Some(color) = background {
528        theme.widgets.menu_bar.background = Some(color);
529    }
530    // Over the system's own backdrop, the bar's highlight is a wash of its words that reads
531    // as it would over the system's caption (milestone 643).
532    if let Some(line) = line.filter(|line| line.system_paints) {
533        let bar = &mut theme.widgets.menu_bar;
534        if let (None, Some(base), Some(ink)) = (bar.highlight, line.background, foreground) {
535            bar.highlight = Some(wash(base, ink, HIGHLIGHT));
536        }
537    }
538    if let Some(color) = foreground {
539        theme.widgets.menu_bar.foreground = Some(color);
540        // Already the inactive window's when the window is not active: the glyphs take it
541        // as it is, rather than quietened a second time.
542        if !active {
543            let buttons = &mut theme.widgets.caption_buttons;
544            buttons.inactive_glyph_color = buttons.inactive_glyph_color.or(Some(color));
545        }
546    }
547}
548
549/// The bar's row: on the title bar's line, the system's height, with the window's icon at
550/// its start and its three buttons where the system has them; elsewhere, the bar alone.
551fn title_bar_row<Msg: Clone + 'static>(
552    bar: MenuBar<Msg>,
553    line: Option<TitleBar>,
554    width: f32,
555    top: f32,
556) -> TitleBarRow<Msg> {
557    let height = line
558        .map(|line| line.height)
559        .or(bar.given_height())
560        .unwrap_or(MENU_BAR_HEIGHT);
561    let mut cells: Vec<Box<dyn Widget<Msg>>> = Vec::new();
562    if let Some(line) = line {
563        if line.leading > 0.0 {
564            cells.push(Box::new(
565                Container::new().width(line.leading).height(height),
566            ));
567        }
568        if let Some(png) = line.icon {
569            cells.push(Box::new(
570                Container::new()
571                    .padding_each(0.0, 6.0, 0.0, 10.0)
572                    .child(WindowIcon::new(png, WINDOW_ICON)),
573            ));
574        }
575    }
576    cells.push(Box::new(bar.height(height)));
577    // The empty part of the line: nothing of the application's, so the system's to move the
578    // window by.
579    cells.push(Box::new(Container::new().flex(1.0).height(height)));
580    if let Some(line) = line {
581        if let Some(buttons) = line.buttons {
582            if line.system_paints {
583                // The system's own buttons show there: the room is kept, and left clear.
584                cells.push(Box::new(
585                    Container::new().width(buttons.width).height(height),
586                ));
587            } else {
588                cells.push(Box::new(CaptionButtons::new(line, buttons.width)));
589            }
590        }
591    }
592    TitleBarRow {
593        children: cells,
594        width,
595        height,
596        top,
597    }
598}
599
600impl<Msg> Widget<Msg> for WindowMenuBar<Msg> {
601    fn style(&self) -> Style {
602        Style {
603            flex_direction: frus_layout::FlexDirection::Column,
604            width: Dimension::Length(self.width),
605            height: Dimension::Length(self.height),
606            ..Style::default()
607        }
608    }
609
610    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
611        &self.children
612    }
613
614    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
615
616    fn on_click(&self) -> Option<Msg> {
617        None
618    }
619
620    fn debug_name(&self) -> &'static str {
621        "WindowMenuBar"
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use crate::{
629        build_ui, BuildContext, Callback, Component, MenuBar, MenuPath, Runtime, Scaffold, Size,
630        SubmenuButton, UseState,
631    };
632    use std::cell::{Cell, RefCell};
633    use std::rc::Rc;
634
635    type Msg = Callback;
636
637    const SIZE: Size = Size::new(1000.0, 700.0);
638    /// The frus logo, a real PNG.
639    const ICON: &[u8] = include_bytes!("../../frus-shell/assets/icon.png");
640
641    fn bar() -> MenuBar<Msg> {
642        MenuBar::new(&MenuPath::closed(), |_| Callback::new(|| {})).menu(SubmenuButton::new("File"))
643    }
644
645    /// What a page read of the window when it was built.
646    type Seen = Rc<Cell<Option<MediaQuery>>>;
647
648    /// A page as the router makes one: a component, built where it sits, whose scaffold has
649    /// a title, a footer and a floating button.
650    fn page(seen: &Seen) -> Component {
651        let seen = seen.clone();
652        Component::stateless(move |_: &BuildContext| {
653            seen.set(Some(MediaQuery::of()));
654            Scaffold::new()
655                .app_bar(crate::Text::new("Title"))
656                .persistent_footer(crate::Text::new("Foot"))
657                .fab(crate::Text::new("Fab"))
658                .build()
659        })
660    }
661
662    /// One frame of the shell's, less the drawing, on `surface`: `tree` is made under it, as
663    /// the shell calls `view`, then built and laid out.
664    fn frame(
665        surface: MediaQuery,
666        runtime: &Runtime,
667        tree: impl FnOnce() -> Box<dyn Widget<Msg>>,
668    ) -> crate::Ui<Msg> {
669        frame_themed(surface, runtime, &windows(), tree)
670    }
671
672    fn windows() -> Theme {
673        Theme::default().with_platform(frus_core::TargetPlatform::Windows)
674    }
675
676    /// [`frame`], under `theme`.
677    fn frame_themed(
678        surface: MediaQuery,
679        runtime: &Runtime,
680        theme: &Theme,
681        tree: impl FnOnce() -> Box<dyn Widget<Msg>>,
682    ) -> crate::Ui<Msg> {
683        let theme = theme.clone();
684        surface.scope(|| {
685            runtime.states.begin_build();
686            let tree = tree();
687            crate::build_deferred(tree.as_ref(), &theme, runtime);
688            let ui = build_ui(tree.as_ref(), SIZE, runtime, &theme);
689            runtime.states.end_frame();
690            ui
691        })
692    }
693
694    /// The window's menu bar around a page, on `surface`.
695    fn window(surface: MediaQuery, seen: &Seen) -> crate::Ui<Msg> {
696        let page = page(seen);
697        frame(surface, &Runtime::default(), || {
698            Box::new(WindowMenuBar::new(bar(), page))
699        })
700    }
701
702    fn text_at(ui: &crate::Ui<Msg>, label: &str) -> Rect {
703        find_text(ui, label).unwrap_or_else(|| panic!("{label:?} is painted"))
704    }
705
706    fn find_text(ui: &crate::Ui<Msg>, label: &str) -> Option<Rect> {
707        ui.scene().primitives().iter().find_map(|p| match p {
708            frus_core::Primitive::Text { text, .. } if text == label => Some(p.bounds()),
709            _ => None,
710        })
711    }
712
713    fn line(buttons: Option<Rect>, icon: Option<&'static [u8]>) -> TitleBar {
714        TitleBar {
715            height: 40.0,
716            leading: 0.0,
717            buttons,
718            icon,
719            hovered: None,
720            pressed: None,
721            maximized: false,
722            active: true,
723            background: None,
724            foreground: None,
725            system_paints: false,
726        }
727    }
728
729    /// **The window's menu bar is its first line, and the page gets the rest**: off the
730    /// title bar's line a desktop's 30 px; the page is built for the window less them, so
731    /// its scaffold fits under the bar — its footer and floating button still at the
732    /// window's bottom — and nothing below is on the line.
733    #[test]
734    fn the_menu_bar_is_the_window_s_first_line_and_the_page_gets_the_rest() {
735        let seen = Seen::default();
736        let ui = window(MediaQuery::new(SIZE), &seen);
737        let file = text_at(&ui, "File");
738        let title = text_at(&ui, "Title");
739        assert!(file.y + file.height <= 30.0, "in the first 30 px: {file:?}");
740        assert!(title.y >= 30.0, "the app bar under it: {title:?}");
741        assert!(ui.wants_title_bar(), "and it asks for the line");
742        let page = seen.get().expect("the page was built");
743        assert_eq!(
744            page.size,
745            Size::new(SIZE.width, SIZE.height - 30.0),
746            "the page is built for the window less the bar"
747        );
748        assert_eq!(page.title_bar, None, "the line is the bar's");
749        for part in ["Foot", "Fab"] {
750            let r = text_at(&ui, part);
751            assert!(
752                r.y + r.height <= SIZE.height && r.y + r.height > SIZE.height - 80.0,
753                "{part} is at the window's bottom: {r:?}"
754            );
755        }
756        assert!(
757            ui.title_bar_regions().is_empty(),
758            "nothing of the system's on it"
759        );
760    }
761
762    /// **On the title bar's line**, the row is the system's height, the window's icon is
763    /// at its start, and the three buttons are where the system has them.
764    #[test]
765    fn on_the_title_bar_line_the_system_s_parts_are_painted_where_it_has_them() {
766        let buttons = Rect::new(862.0, 0.0, 138.0, 40.0);
767        let seen = Seen::default();
768        let surface = MediaQuery::new(SIZE).with_title_bar(Some(line(Some(buttons), Some(ICON))));
769        let ui = window(surface, &seen);
770        let title = text_at(&ui, "Title");
771        assert!(title.y >= 40.0, "the app bar under the line: {title:?}");
772        let page = seen.get().expect("the page was built");
773        assert_eq!(page.size.height, SIZE.height - 40.0, "the line taken off");
774        assert_eq!(page.title_bar, None, "the line is the bar's");
775        let regions = ui.title_bar_regions();
776        let (button_box, _) = regions
777            .iter()
778            .find(|(_, role)| *role == TitleBarRole::Buttons)
779            .expect("the buttons");
780        assert_eq!(*button_box, buttons, "where the system has them");
781        let (icon_box, _) = regions
782            .iter()
783            .find(|(_, role)| *role == TitleBarRole::Icon)
784            .expect("the icon");
785        assert!(
786            icon_box.x < 40.0 && icon_box.width >= 16.0,
787            "at the start: {icon_box:?}"
788        );
789        let file = text_at(&ui, "File");
790        assert!(
791            file.x > icon_box.x + icon_box.width,
792            "the menu after the icon"
793        );
794    }
795
796    /// **Under a top intrusion** — a tablet's status bar — the bar starts below it, and the
797    /// page is told the intrusion is dealt with.
798    #[test]
799    fn under_a_status_bar_the_menu_bar_starts_below_it() {
800        let seen = Seen::default();
801        let surface = MediaQuery::new(SIZE).with_insets(frus_core::WindowInsets {
802            padding: frus_core::Insets::new(24.0, 0.0, 0.0, 0.0),
803            ..Default::default()
804        });
805        let ui = window(surface, &seen);
806        let file = text_at(&ui, "File");
807        assert!(file.y >= 24.0, "below the status bar: {file:?}");
808        let page = seen.get().expect("the page was built");
809        assert_eq!(page.size.height, SIZE.height - 24.0 - 30.0);
810        assert_eq!(page.padding.top, 0.0, "the status bar is dealt with");
811        let title = text_at(&ui, "Title");
812        assert!(title.y >= 54.0, "the app bar under both: {title:?}");
813    }
814
815    /// **With no bar** — a narrow window — the page has the whole window, the system gets
816    /// its line back, and **the page keeps its state** while the bar comes and goes: the
817    /// tree keeps its shape.
818    #[test]
819    fn with_no_bar_the_page_has_the_window_and_keeps_its_state() {
820        let runtime = Runtime::default();
821        let count: Rc<RefCell<Option<UseState<i32>>>> = Rc::default();
822        let tree = |with_bar: bool| {
823            let count = count.clone();
824            move || -> Box<dyn Widget<Msg>> {
825                let page = Component::stateless(move |cx: &BuildContext| -> Box<dyn Widget> {
826                    let n = cx.use_state(|| 0);
827                    *count.borrow_mut() = Some(n.clone());
828                    Box::new(crate::Text::new(format!("count {}", n.get())))
829                });
830                Box::new(WindowMenuBar::new(with_bar.then(bar), page))
831            }
832        };
833        let ui = frame(MediaQuery::new(SIZE), &runtime, tree(true));
834        assert!(ui.wants_title_bar());
835        count.borrow().as_ref().expect("built").set(5);
836
837        let ui = frame(MediaQuery::new(SIZE), &runtime, tree(false));
838        assert!(find_text(&ui, "File").is_none(), "no bar");
839        assert!(!ui.wants_title_bar(), "the line is the system's again");
840        let text = text_at(&ui, "count 5");
841        assert!(text.y < 30.0, "the page from the window's top: {text:?}");
842
843        let ui = frame(MediaQuery::new(SIZE), &runtime, tree(true));
844        assert!(find_text(&ui, "count 5").is_some(), "kept, both ways");
845    }
846
847    /// The system's caption, as the shell reports it for an active dark window.
848    const CAPTION: Color = Color::rgb(0.13, 0.12, 0.16);
849    const CAPTION_INK: Color = Color::WHITE;
850
851    /// The line as the shell reports it: the system's buttons, and its caption's colours.
852    fn system_line(active: bool, background: Color, foreground: Color) -> TitleBar {
853        TitleBar {
854            active,
855            background: Some(background),
856            foreground: Some(foreground),
857            ..line(Some(Rect::new(862.0, 0.0, 138.0, 40.0)), None)
858        }
859    }
860
861    /// The window's menu bar around an empty page, on `surface`, under `theme`; `dress`
862    /// gives the bar its colours.
863    fn dressed(
864        surface: MediaQuery,
865        theme: &Theme,
866        dress: impl FnOnce(WindowMenuBar<Msg>) -> WindowMenuBar<Msg>,
867    ) -> crate::Ui<Msg> {
868        frame_themed(surface, &Runtime::default(), theme, || {
869            Box::new(dress(WindowMenuBar::new(bar(), crate::Text::new("page"))))
870        })
871    }
872
873    /// The line's surface, the bar's words and the window's glyphs, as painted.
874    fn colours(ui: &crate::Ui<Msg>) -> (Color, Color, Vec<Color>) {
875        let prims = ui.scene().primitives();
876        let surface = prims
877            .iter()
878            .find_map(|p| match p {
879                frus_core::Primitive::Rect { rect, color, .. }
880                    if rect.x == 0.0 && rect.y == 0.0 && rect.width == SIZE.width =>
881                {
882                    Some(*color)
883                }
884                _ => None,
885            })
886            .expect("the line's surface");
887        let words = prims
888            .iter()
889            .find_map(|p| match p {
890                frus_core::Primitive::Text { text, color, .. } if text == "File" => Some(*color),
891                _ => None,
892            })
893            .expect("the bar's words");
894        let glyphs = prims
895            .iter()
896            .filter_map(|p| match p {
897                frus_core::Primitive::Path {
898                    stroke: Some(stroke),
899                    ..
900                } => Some(stroke.color),
901                _ => None,
902            })
903            .collect();
904        (surface, words, glyphs)
905    }
906
907    /// **The system's caption is the line's by default**: its surface, and its ink on the
908    /// words and the window's glyphs — for an inactive window the inactive caption's, taken
909    /// as it is and not quietened a second time.
910    #[test]
911    fn the_line_takes_the_system_s_caption_colours() {
912        let surface =
913            MediaQuery::new(SIZE).with_title_bar(Some(system_line(true, CAPTION, CAPTION_INK)));
914        let (fill, words, glyphs) = colours(&dressed(surface, &windows(), |w| w));
915        assert_eq!(fill, CAPTION, "the system's surface");
916        assert_eq!(words, CAPTION_INK, "its ink on the words");
917        assert_eq!(glyphs, vec![CAPTION_INK; 3], "and on the glyphs");
918
919        let (grey, quiet) = (
920            Color::rgb(0.125, 0.125, 0.125),
921            Color::rgb(0.47, 0.47, 0.47),
922        );
923        let surface = MediaQuery::new(SIZE).with_title_bar(Some(system_line(false, grey, quiet)));
924        let (fill, words, glyphs) = colours(&dressed(surface, &windows(), |w| w));
925        assert_eq!(fill, grey, "the inactive caption's surface");
926        assert_eq!(words, quiet, "its ink");
927        assert_eq!(glyphs, vec![quiet; 3], "the glyphs as the system has them");
928    }
929
930    /// **The theme outranks the system, and the bar outranks the theme** — for an active
931    /// window and, with what is said for one, an inactive window.
932    #[test]
933    fn the_bar_and_the_theme_say_otherwise() {
934        let red = Color::rgb(1.0, 0.0, 0.0);
935        let green = Color::rgb(0.0, 1.0, 0.0);
936        let blue = Color::rgb(0.0, 0.0, 1.0);
937        let yellow = Color::rgb(1.0, 1.0, 0.0);
938        let mut themed = windows();
939        themed.widgets.title_bar = TitleBarTheme {
940            background: Some(red),
941            foreground: Some(green),
942            inactive_background: None,
943            inactive_foreground: Some(blue),
944        };
945        let on = |active| {
946            MediaQuery::new(SIZE).with_title_bar(Some(system_line(active, CAPTION, CAPTION_INK)))
947        };
948
949        let (fill, words, glyphs) = colours(&dressed(on(true), &themed, |w| w));
950        assert_eq!(
951            (fill, words),
952            (red, green),
953            "the theme's, over the system's"
954        );
955        assert_eq!(glyphs, vec![green; 3]);
956
957        let (fill, words, glyphs) = colours(&dressed(on(false), &themed, |w| w));
958        assert_eq!(fill, red, "no inactive surface said: the active one's");
959        assert_eq!(words, blue, "the inactive window's ink");
960        assert_eq!(glyphs, vec![blue; 3]);
961
962        let (fill, words, glyphs) = colours(&dressed(on(true), &themed, |w| {
963            w.background(blue).foreground(yellow)
964        }));
965        assert_eq!((fill, words), (blue, yellow), "the bar's, over the theme's");
966        assert_eq!(glyphs, vec![yellow; 3]);
967    }
968
969    /// **Where the system paints the line**, the application paints neither its surface nor
970    /// the window's buttons — the system's show through — and the room the buttons need is
971    /// kept; the words take the system's ink, and their highlight is a wash that reads as it
972    /// would over the system's caption.
973    #[test]
974    fn where_the_system_paints_the_line_the_application_leaves_it_clear() {
975        let painted = |system_paints| {
976            let line = TitleBar {
977                system_paints,
978                ..system_line(true, CAPTION, CAPTION_INK)
979            };
980            dressed(
981                MediaQuery::new(SIZE).with_title_bar(Some(line)),
982                &windows(),
983                |w| w,
984            )
985        };
986        let ui = painted(true);
987        let opaque_line = ui.scene().primitives().iter().any(|p| {
988            matches!(p, frus_core::Primitive::Rect { rect, color, .. }
989                if rect.y < 40.0 && rect.width >= 100.0 && color.a > 0.0)
990        });
991        assert!(!opaque_line, "nothing of the application's under the line");
992        let ink = ui
993            .scene()
994            .primitives()
995            .iter()
996            .find_map(|p| match p {
997                frus_core::Primitive::Text { text, color, .. } if text == "File" => Some(*color),
998                _ => None,
999            })
1000            .expect("the bar's words");
1001        assert_eq!(ink, CAPTION_INK, "the system's ink on the words");
1002        let strokes = ui
1003            .scene()
1004            .primitives()
1005            .iter()
1006            .filter(|p| {
1007                matches!(
1008                    p,
1009                    frus_core::Primitive::Path {
1010                        stroke: Some(_),
1011                        ..
1012                    }
1013                )
1014            })
1015            .count();
1016        assert_eq!(strokes, 0, "the system's own buttons, not painted ones");
1017        let file = text_at(&ui, "File");
1018        let painted_file = text_at(&painted(false), "File");
1019        assert_eq!(file, painted_file, "the bar where it was");
1020
1021        // The open word's highlight is the wash that reads as on the system's caption.
1022        let line = TitleBar {
1023            system_paints: true,
1024            ..system_line(true, CAPTION, CAPTION_INK)
1025        };
1026        let open = frame_themed(
1027            MediaQuery::new(SIZE).with_title_bar(Some(line)),
1028            &Runtime::default(),
1029            &windows(),
1030            || {
1031                let bar = MenuBar::new(&MenuPath::from_indices([0]), |_| Callback::new(|| {}))
1032                    .menu(SubmenuButton::new("File"));
1033                Box::new(WindowMenuBar::new(bar, crate::Text::new("page")))
1034            },
1035        );
1036        let file = text_at(&open, "File");
1037        let lit = open
1038            .scene()
1039            .primitives()
1040            .iter()
1041            .find_map(|p| match p {
1042                frus_core::Primitive::Rect { rect, color, .. }
1043                    if rect.contains(frus_core::Point::new(file.x + 1.0, file.y + 4.0))
1044                        && color.a > 0.0 =>
1045                {
1046                    Some(*color)
1047                }
1048                _ => None,
1049            })
1050            .expect("the open word's highlight");
1051        assert_eq!(lit, wash(CAPTION, CAPTION_INK, HIGHLIGHT));
1052    }
1053
1054    /// **The wash reads as the opaque highlight would**: blended in linear light over the
1055    /// system's caption, it gives the caption moved 12 % toward the words — on a dark
1056    /// caption, where white at 12 % would read far stronger, and on a light one, where black
1057    /// needs more than 12 % to read as much.
1058    #[test]
1059    fn the_highlight_on_the_system_s_line_reads_as_on_its_caption() {
1060        for (base, ink, lighter) in [
1061            (Color::rgb8(0x20, 0x20, 0x20), Color::WHITE, true),
1062            (Color::rgb8(0xF3, 0xF3, 0xF3), Color::BLACK, false),
1063        ] {
1064            let washed = wash(base, ink, HIGHLIGHT);
1065            assert!(washed.a > 0.0 && washed.a < 1.0, "a wash: {washed:?}");
1066            assert_eq!(washed.a < HIGHLIGHT, lighter, "{base:?}: {washed:?}");
1067            let (b, i) = (base.to_linear(), ink.to_linear());
1068            let blended = b.r + (i.r - b.r) * washed.a;
1069            let wanted = base.lerp(ink, HIGHLIGHT).to_linear().r;
1070            assert!(
1071                (blended - wanted).abs() < 1e-4,
1072                "{base:?}: {blended} for {wanted}"
1073            );
1074        }
1075    }
1076
1077    /// **Off the title bar's line, with nothing said**, the row is the menu bar's own:
1078    /// `surface_container` and `on_surface`, as before.
1079    #[test]
1080    fn off_the_line_the_row_is_the_menu_bar_s_own() {
1081        let theme = windows();
1082        let (fill, words, glyphs) = colours(&dressed(MediaQuery::new(SIZE), &theme, |w| w));
1083        assert_eq!(fill, theme.scheme.surface_container);
1084        assert_eq!(words, theme.scheme.on_surface);
1085        assert!(glyphs.is_empty(), "no buttons of the system's");
1086    }
1087
1088    /// The three glyphs and the system's states, painted.
1089    fn painted(hovered: Option<CaptionButton>, maximized: bool) -> Vec<frus_core::Primitive> {
1090        let bar = TitleBar {
1091            hovered,
1092            maximized,
1093            ..line(Some(Rect::new(0.0, 0.0, 138.0, 40.0)), None)
1094        };
1095        let mut scene = Scene::new();
1096        Widget::<Msg>::paint(
1097            &CaptionButtons::new(bar, 138.0),
1098            Rect::new(0.0, 0.0, 138.0, 40.0),
1099            Status {
1100                opacity: 1.0,
1101                ..Default::default()
1102            },
1103            &Theme::dark(),
1104            &mut scene,
1105        );
1106        scene.primitives().to_vec()
1107    }
1108
1109    /// **Three glyphs at rest, a wash under the pointer, and red under it on close.**
1110    #[test]
1111    fn the_buttons_take_the_system_s_states() {
1112        let fills = |prims: &[frus_core::Primitive]| -> Vec<(Rect, Color)> {
1113            prims
1114                .iter()
1115                .filter_map(|p| match p {
1116                    frus_core::Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
1117                    _ => None,
1118                })
1119                .collect()
1120        };
1121        let strokes = |prims: &[frus_core::Primitive]| {
1122            prims
1123                .iter()
1124                .filter(|p| matches!(p, frus_core::Primitive::Path { .. }))
1125                .count()
1126        };
1127        let rest = painted(None, false);
1128        assert!(fills(&rest).is_empty(), "nothing behind them at rest");
1129        assert_eq!(strokes(&rest), 3, "three glyphs");
1130
1131        let close = fills(&painted(Some(CaptionButton::Close), false));
1132        assert_eq!(close.len(), 1);
1133        assert_eq!(
1134            close[0].0,
1135            Rect::new(92.0, 0.0, 46.0, 40.0),
1136            "the last cell"
1137        );
1138        assert_eq!(close[0].1, Color::rgb(0.77, 0.17, 0.11), "red");
1139
1140        let minimize = fills(&painted(Some(CaptionButton::Minimize), false));
1141        assert_eq!(minimize[0].0.x, 0.0, "the first cell");
1142        assert_ne!(minimize[0].1, close[0].1, "a wash, not red");
1143
1144        // Maximized, the middle glyph is the restore glyph: two squares, not one.
1145        let path_len = |prims: Vec<frus_core::Primitive>| -> usize {
1146            prims
1147                .iter()
1148                .filter_map(|p| match p {
1149                    frus_core::Primitive::Path { path, .. } => Some(path.verbs().len()),
1150                    _ => None,
1151                })
1152                .nth(1)
1153                .expect("the middle glyph")
1154        };
1155        assert!(path_len(painted(None, true)) > path_len(painted(None, false)));
1156    }
1157}