1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum CaptionButton {
41 Minimize,
43 Maximize,
45 Close,
47}
48
49#[derive(Clone, Copy, Debug, PartialEq)]
52pub struct TitleBar {
53 pub height: f32,
55 pub leading: f32,
58 pub buttons: Option<Rect>,
61 pub icon: Option<&'static [u8]>,
64 pub hovered: Option<CaptionButton>,
66 pub pressed: Option<CaptionButton>,
68 pub maximized: bool,
70 pub active: bool,
72 pub background: Option<Color>,
75 pub foreground: Option<Color>,
77 pub system_paints: bool,
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub enum TitleBarRole {
88 Icon,
91 Buttons,
93}
94
95pub(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
110const 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 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 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 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
237pub(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
274struct TitleBarRow<Msg> {
278 children: Vec<Box<dyn Widget<Msg>>>,
279 width: f32,
280 height: f32,
281 top: f32,
282 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
325const MENU_BAR_HEIGHT: f32 = 30.0;
327
328const WINDOW_ICON: f32 = 16.0;
330
331pub struct WindowMenuBar<Msg = crate::callback::Callback> {
374 children: Vec<Box<dyn Widget<Msg>>>,
376 width: f32,
377 height: f32,
378 own: std::rc::Rc<std::cell::Cell<TitleBarTheme>>,
381}
382
383impl<Msg: Clone + 'static> WindowMenuBar<Msg> {
384 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 let (top, sides) = if line.is_some() {
394 (0.0, (0.0, 0.0))
395 } else {
396 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 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 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 #[must_use]
438 pub fn background(self, color: Color) -> Self {
439 self.restyle(|t| t.background = Some(color))
440 }
441
442 #[must_use]
444 pub fn foreground(self, color: Color) -> Self {
445 self.restyle(|t| t.foreground = Some(color))
446 }
447
448 #[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
463const HIGHLIGHT: f32 = 0.12;
466
467fn 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
498fn 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
508fn 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 .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 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 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
555fn 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 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 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 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 type Seen = Rc<Cell<Option<MediaQuery>>>;
655
656 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 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 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 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 #[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 #[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 #[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 #[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 #[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 const CAPTION: Color = Color::rgb(0.13, 0.12, 0.16);
891 const CAPTION_INK: Color = Color::WHITE;
892
893 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 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 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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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}