1#[cfg(test)]
6use frus_core::FontWeight;
7use frus_core::{Color, Insets, Point, Rect, Scene, TextStyle};
8use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
9
10use crate::interaction::Status;
11use crate::theme::Theme;
12use crate::widget::Widget;
13
14const HEIGHT: f32 = 56.0;
16const PAD_LEFT: f32 = 28.0;
19const PADDING: Insets = Insets {
22 top: 0.0,
23 right: 16.0,
24 bottom: 0.0,
25 left: PAD_LEFT,
26};
27const DIVIDER_THICKNESS: f32 = 1.0;
29fn title_style_of(over: Option<TextStyle>, theme: &Theme) -> TextStyle {
33 over.or(theme.widgets.nav_bar.title_style)
34 .unwrap_or(theme.text.title_large)
35}
36
37pub struct NavigationBar<Msg = crate::callback::Callback> {
39 title: String,
40 title_style: Option<TextStyle>,
42 height: Option<f32>,
44 children: Vec<Box<dyn Widget<Msg>>>,
46}
47
48impl<Msg: Clone + 'static> NavigationBar<Msg> {
49 pub fn new(title: impl Into<String>) -> Self {
51 Self {
52 title: title.into(),
53 title_style: None,
54 height: None,
55 children: Vec::new(),
56 }
57 }
58
59 pub fn title_style(mut self, style: TextStyle) -> Self {
61 self.title_style = Some(style);
62 self
63 }
64
65 pub fn height(mut self, height: f32) -> Self {
67 self.height = Some(height);
68 self
69 }
70
71 pub fn on_back(mut self, message: impl Into<Msg>) -> Self {
78 self.children = vec![Box::new(
79 crate::BackButton::new().icon_size(20.0).on_press(message),
80 )];
81 self
82 }
83}
84
85impl<Msg> NavigationBar<Msg> {
89 fn bar_height(&self, theme: Option<&Theme>) -> f32 {
94 self.height
95 .or_else(|| theme.and_then(|t| t.widgets.nav_bar.height))
96 .unwrap_or(HEIGHT)
97 }
98
99 fn padding_of(theme: Option<&Theme>) -> Insets {
102 theme
103 .and_then(|t| t.widgets.nav_bar.padding)
104 .unwrap_or(PADDING)
105 }
106
107 fn clearance() -> Insets {
114 let taken = crate::MediaQuery::of().padding;
115 Insets::new(taken.top, taken.right, 0.0, taken.left)
116 }
117
118 fn sizing(&self, theme: Option<&Theme>) -> (f32, Insets) {
123 let clear = Self::clearance();
124 let pad = Self::padding_of(theme);
125 (
126 self.bar_height(theme) + clear.top,
127 Insets::new(
128 pad.top + clear.top,
129 pad.right + clear.right,
130 pad.bottom,
131 pad.left + clear.left,
132 ),
133 )
134 }
135}
136
137impl<Msg: Clone> Widget<Msg> for NavigationBar<Msg> {
138 fn style(&self) -> Style {
139 Style {
140 width: Dimension::Percent(1.0),
151 height: Dimension::Length(self.sizing(None).0),
152 flex_direction: FlexDirection::Row,
153 justify: Justify::Start,
154 align: Align::Center,
155 padding: self.sizing(None).1,
165 ..Default::default()
166 }
167 }
168
169 fn style_themed(&self, theme: &Theme) -> Style {
174 let (height, padding) = self.sizing(Some(theme));
175 Style {
176 height: Dimension::Length(height),
177 padding,
178 ..Widget::<Msg>::style(self)
179 }
180 }
181
182 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
183 &self.children
184 }
185
186 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
187 let o = status.opacity;
188 let t = &theme.widgets.nav_bar;
189 let background: Color = t.background.unwrap_or(theme.background);
191 scene.fill_rect(bounds, background.fade(o));
192 let thickness = t
195 .divider_thickness
196 .unwrap_or(DIVIDER_THICKNESS)
197 .min(bounds.height);
198 let rule: Color = t.divider_color.unwrap_or(theme.scheme.outline_variant);
199 scene.fill_rect(
200 Rect::new(
201 bounds.x,
202 bounds.y + bounds.height - thickness,
203 bounds.width,
204 thickness,
205 ),
206 rule.fade(o),
207 );
208
209 let clear = Self::clearance();
214 let room = Rect::new(
215 bounds.x + clear.left,
216 bounds.y + clear.top,
217 (bounds.width - clear.left - clear.right).max(0.0),
218 (bounds.height - clear.top).max(0.0),
219 );
220 let style = title_style_of(self.title_style, theme);
221 let measured = frus_text::measure_style(&self.title, style);
222 let tx = room.x + (room.width - measured.width) * 0.5;
223 let ty = room.y + (room.height - measured.height) * 0.5;
224 scene.text(
225 Point::new(tx, ty),
226 self.title.clone(),
227 &style.resolved(),
228 style.color.unwrap_or(theme.on_surface).fade(o),
229 );
230 }
231
232 fn on_click(&self) -> Option<Msg> {
233 None
234 }
235
236 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
240 (!self.title.is_empty()).then(|| {
241 frus_core::SemanticsProperties::new(frus_core::Role::Heading).label(self.title.clone())
242 })
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::{build_ui, Runtime, Size};
250 use frus_core::Color;
251 use frus_core::Primitive;
252
253 #[derive(Clone, Debug, PartialEq)]
254 enum Msg {
255 Back,
256 }
257
258 #[test]
261 fn the_title_is_the_screens_heading() {
262 let bar: NavigationBar<Msg> = NavigationBar::new("Task").on_back(Msg::Back);
263 let ui = build_ui(
264 &bar,
265 Size::new(400.0, 56.0),
266 &Runtime::default(),
267 &Theme::default(),
268 );
269 let heading = ui
270 .semantics()
271 .iter()
272 .find(|(_, _, s)| s.role == frus_core::Role::Heading)
273 .map(|(_, _, s)| s.label.clone());
274 assert_eq!(heading, Some(Some("Task".to_string())));
275 assert!(Widget::semantics(&NavigationBar::<Msg>::new("")).is_none());
276 }
277
278 #[test]
279 fn root_bar_has_no_back_button() {
280 let bar: NavigationBar<Msg> = NavigationBar::new("Home");
281 assert!(Widget::children(&bar).is_empty());
282 }
283
284 #[test]
285 fn title_style_and_height_are_customizable() {
286 let bar: NavigationBar<Msg> = NavigationBar::new("Title")
288 .title_style(TextStyle::new(24.0).weight(FontWeight::Bold).italic())
289 .height(72.0);
290 match Widget::style(&bar).height {
291 Dimension::Length(h) => assert_eq!(h, 72.0),
292 _ => panic!("an imposed height was expected"),
293 }
294 let ui = build_ui(
295 &bar,
296 Size::new(400.0, 72.0),
297 &Runtime::default(),
298 &Theme::default(),
299 );
300 let styled = ui.scene().primitives().iter().any(|p| {
301 matches!(
302 p,
303 Primitive::Text { text, size, weight, italic, .. }
304 if text == "Title" && *size == 24.0 && *weight == FontWeight::Bold && *italic
305 )
306 });
307 assert!(styled, "the title must carry the overridden style");
308 }
309
310 #[test]
311 fn back_button_emits_message() {
312 let bar: NavigationBar<Msg> = NavigationBar::new("Settings").on_back(Msg::Back);
313 let ui = build_ui(
314 &bar,
315 Size::new(400.0, 56.0),
316 &Runtime::default(),
317 &Theme::default(),
318 );
319 let id = ui.hit(Point::new(40.0, 28.0)).expect("back button");
321 assert_eq!(ui.msg_for(id), Some(Msg::Back));
322 }
323
324 #[test]
333 fn a_bar_given_no_width_still_spans_what_it_is_offered() {
334 const FRAME: f32 = 400.0;
335 let bar: NavigationBar<Msg> = NavigationBar::new("Settings").on_back(Msg::Back);
336 let root = crate::Flex::row().width(FRAME).child(bar);
341 let theme = Theme::default();
342 let ui = build_ui(&root, Size::new(FRAME, HEIGHT), &Runtime::default(), &theme);
343 let at = ui
344 .scene()
345 .primitives()
346 .iter()
347 .find_map(|p| match p {
348 Primitive::Text { text, position, .. } if text == "Settings" => Some(*position),
349 _ => None,
350 })
351 .expect("the title is painted");
352 let measured = frus_text::measure_style("Settings", title_style_of(None, &theme));
353 let centred = (FRAME - measured.width) * 0.5;
354 assert!(
355 (at.x - centred).abs() < 1.0,
356 "the title is centred in the frame: {} against {centred}",
357 at.x
358 );
359 assert!(
361 at.x > PAD_LEFT + crate::ICON_BUTTON_SIZE,
362 "the title is clear of the back button: {} against {}",
363 at.x,
364 PAD_LEFT + crate::ICON_BUTTON_SIZE
365 );
366 }
367
368 fn rects(bar: &NavigationBar<Msg>, theme: &Theme, frame: Size) -> Vec<(Rect, Color)> {
371 let ui = build_ui(bar, frame, &Runtime::default(), theme);
372 ui.scene()
373 .primitives()
374 .iter()
375 .filter_map(|p| match p {
376 Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
377 _ => None,
378 })
379 .collect()
380 }
381
382 #[test]
392 fn a_theme_reaches_every_property_the_bar_paints() {
393 const BACKGROUND: Color = Color::rgb(0.10, 0.20, 0.30);
394 const RULE: Color = Color::rgb(0.90, 0.10, 0.40);
395 let mut theme = Theme::default();
396 theme.widgets.nav_bar.height = Some(64.0);
397 theme.widgets.nav_bar.padding = Insets::new(0.0, 4.0, 0.0, 40.0).into();
398 theme.widgets.nav_bar.background = Some(BACKGROUND);
399 theme.widgets.nav_bar.divider_color = Some(RULE);
400 theme.widgets.nav_bar.divider_thickness = Some(3.0);
401 theme.widgets.nav_bar.title_style = Some(TextStyle::new(18.0).weight(FontWeight::Bold));
402
403 let bar: NavigationBar<Msg> = NavigationBar::new("Title").on_back(Msg::Back);
404 match Widget::<Msg>::style_themed(&bar, &theme).height {
407 Dimension::Length(h) => assert_eq!(h, 64.0, "the theme's height"),
408 other => panic!("a definite height was expected, got {other:?}"),
409 }
410 assert_eq!(
411 Widget::<Msg>::style_themed(&bar, &theme).padding,
412 Insets::new(0.0, 4.0, 0.0, 40.0),
413 "the theme's padding"
414 );
415
416 let painted = rects(&bar, &theme, Size::new(400.0, 64.0));
417 let (bg_rect, bg) = painted[0];
418 assert_eq!(bg, BACKGROUND, "the theme's background");
419 assert_eq!(bg_rect.height, 64.0, "and it fills the themed height");
420 let (rule_rect, rule) = painted[1];
421 assert_eq!(rule, RULE, "the theme's hairline colour");
422 assert_eq!(rule_rect.height, 3.0, "and its thickness");
423 assert_eq!(
424 rule_rect.y + rule_rect.height,
425 bg_rect.y + bg_rect.height,
426 "the hairline sits on the bottom edge whatever it weighs"
427 );
428
429 let ui = build_ui(&bar, Size::new(400.0, 64.0), &Runtime::default(), &theme);
430 let styled = ui.scene().primitives().iter().any(|p| {
431 matches!(p, Primitive::Text { text, size, weight, .. }
432 if text == "Title" && *size == 18.0 && *weight == FontWeight::Bold)
433 });
434 assert!(styled, "the theme's title style");
435 }
436
437 #[test]
441 fn a_call_sites_own_values_beat_the_theme() {
442 let mut theme = Theme::default();
443 theme.widgets.nav_bar.height = Some(64.0);
444 theme.widgets.nav_bar.title_style = Some(TextStyle::new(18.0));
445
446 let bar: NavigationBar<Msg> = NavigationBar::new("Title")
447 .height(72.0)
448 .title_style(TextStyle::new(30.0));
449 match Widget::<Msg>::style_themed(&bar, &theme).height {
450 Dimension::Length(h) => assert_eq!(h, 72.0, "the caller's height, not the theme's"),
451 other => panic!("a definite height was expected, got {other:?}"),
452 }
453 let ui = build_ui(&bar, Size::new(400.0, 72.0), &Runtime::default(), &theme);
454 let sized = ui.scene().primitives().iter().any(
455 |p| matches!(p, Primitive::Text { text, size, .. } if text == "Title" && *size == 30.0),
456 );
457 assert!(sized, "the caller's title style, not the theme's");
458
459 let plain: NavigationBar<Msg> = NavigationBar::new("Title");
462 match Widget::<Msg>::style_themed(&plain, &theme).height {
463 Dimension::Length(h) => assert_eq!(h, 64.0, "the theme's height"),
464 other => panic!("a definite height was expected, got {other:?}"),
465 }
466 }
467
468 const BAR_BG: Color = Color::rgb(0.12, 0.34, 0.56);
470 const STATUS: f32 = 48.0;
471 const SCREEN: Size = Size::new(400.0, 800.0);
472
473 fn phone() -> crate::MediaQuery {
475 crate::MediaQuery::new(SCREEN).with_insets(frus_core::WindowInsets::bars(Insets::new(
476 STATUS, 0.0, 0.0, 0.0,
477 )))
478 }
479
480 fn painted_under(
483 surface: crate::MediaQuery,
484 root: &dyn Widget<Msg>,
485 ) -> (Point, Rect, crate::Ui<Msg>) {
486 let mut theme = Theme::default();
487 theme.widgets.nav_bar.background = Some(BAR_BG);
488 let ui = surface.scope(|| build_ui(root, SCREEN, &Runtime::default(), &theme));
489 let title = ui
490 .scene()
491 .primitives()
492 .iter()
493 .find_map(|p| match p {
494 Primitive::Text { text, position, .. } if text == "Task" => Some(*position),
495 _ => None,
496 })
497 .expect("the title is painted");
498 let background = ui
499 .scene()
500 .primitives()
501 .iter()
502 .find_map(|p| match p {
503 Primitive::Rect { rect, color, .. } if *color == BAR_BG => Some(*rect),
504 _ => None,
505 })
506 .expect("the bar's background is painted");
507 (title, background, ui)
508 }
509
510 fn screen_with(bar: NavigationBar<Msg>) -> crate::Flex<Msg> {
511 crate::Flex::column()
512 .width(SCREEN.width)
513 .height(SCREEN.height)
514 .child(bar)
515 }
516
517 #[test]
525 fn a_bar_at_the_head_of_a_screen_clears_the_status_bar() {
526 let (flat, _, _) = painted_under(
527 crate::MediaQuery::new(SCREEN),
528 &screen_with(NavigationBar::new("Task")),
529 );
530 let (title, background, ui) = painted_under(
531 phone(),
532 &screen_with(NavigationBar::new("Task").on_back(Msg::Back)),
533 );
534 assert_eq!(
535 background.y, 0.0,
536 "the background runs behind the status bar"
537 );
538 assert_eq!(
539 background.height,
540 HEIGHT + STATUS,
541 "and the bar grows by it rather than giving it up"
542 );
543 assert!(
544 (title.y - (flat.y + STATUS)).abs() < 0.5,
545 "the title is centred below the status bar: {} against {}",
546 title.y,
547 flat.y + STATUS
548 );
549 let below = ui
552 .hit(Point::new(40.0, STATUS + HEIGHT / 2.0))
553 .expect("the back button, below the status bar");
554 assert_eq!(ui.msg_for(below), Some(Msg::Back));
555 assert!(
556 ui.hit(Point::new(40.0, STATUS / 2.0))
557 .and_then(|id| ui.msg_for(id))
558 .is_none(),
559 "the back button is not under the status bar"
560 );
561 }
562
563 #[test]
567 fn a_bar_in_a_safe_area_does_not_clear_the_status_bar_again() {
568 let (flat, _, _) = painted_under(
569 crate::MediaQuery::new(SCREEN),
570 &screen_with(NavigationBar::new("Task")),
571 );
572 let root = crate::Flex::column()
573 .width(SCREEN.width)
574 .height(SCREEN.height)
575 .child(crate::SafeArea::new(
576 crate::Flex::column().child(NavigationBar::new("Task")),
577 ));
578 let (title, background, _) = painted_under(phone(), &root);
579 assert_eq!(
580 background.y, STATUS,
581 "the safe area holds the bar below the notch"
582 );
583 assert_eq!(
584 background.height, HEIGHT,
585 "and the bar adds nothing of its own"
586 );
587 assert!(
588 (title.y - (flat.y + STATUS)).abs() < 0.5,
589 "the title is one status bar down, not two: {} against {}",
590 title.y,
591 flat.y + STATUS
592 );
593 }
594
595 #[test]
598 fn a_bar_in_a_scaffolds_app_bar_slot_clears_the_status_bar() {
599 let (flat, _, _) = painted_under(
600 crate::MediaQuery::new(SCREEN),
601 &screen_with(NavigationBar::new("Task")),
602 );
603 let scaffold = phone().scope(|| {
606 crate::Scaffold::new()
607 .size(SCREEN.width, SCREEN.height)
608 .app_bar(NavigationBar::new("Task"))
609 .body(crate::Container::new().flex(1.0))
610 .build()
611 });
612 let (title, background, _) = painted_under(phone(), &scaffold);
613 assert_eq!(background.y, 0.0);
614 assert_eq!(background.height, HEIGHT + STATUS);
615 assert!((title.y - (flat.y + STATUS)).abs() < 0.5, "{}", title.y);
616 }
617
618 #[test]
619 fn bar_paints_title_and_divider() {
620 let bar: NavigationBar<Msg> = NavigationBar::new("Title");
621 let ui = build_ui(
622 &bar,
623 Size::new(400.0, 56.0),
624 &Runtime::default(),
625 &Theme::default(),
626 );
627 let has_text = ui
628 .scene()
629 .primitives()
630 .iter()
631 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "Title"));
632 assert!(has_text, "the title is painted");
633 }
634}