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}
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
322const MENU_BAR_HEIGHT: f32 = 30.0;
324
325const WINDOW_ICON: f32 = 16.0;
327
328pub struct WindowMenuBar<Msg = crate::callback::Callback> {
371 children: Vec<Box<dyn Widget<Msg>>>,
373 width: f32,
374 height: f32,
375 own: std::rc::Rc<std::cell::Cell<TitleBarTheme>>,
378}
379
380impl<Msg: Clone + 'static> WindowMenuBar<Msg> {
381 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 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 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 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 #[must_use]
432 pub fn background(self, color: Color) -> Self {
433 self.restyle(|t| t.background = Some(color))
434 }
435
436 #[must_use]
438 pub fn foreground(self, color: Color) -> Self {
439 self.restyle(|t| t.foreground = Some(color))
440 }
441
442 #[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
457const HIGHLIGHT: f32 = 0.12;
460
461fn 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
492fn 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
502fn 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 .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 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 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
549fn 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 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 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 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 type Seen = Rc<Cell<Option<MediaQuery>>>;
647
648 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 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 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 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 #[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 #[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 #[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 #[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 const CAPTION: Color = Color::rgb(0.13, 0.12, 0.16);
849 const CAPTION_INK: Color = Color::WHITE;
850
851 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 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 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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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}