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
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::{build_ui, Runtime, Size};
241 use frus_core::Color;
242 use frus_core::Primitive;
243
244 #[derive(Clone, Debug, PartialEq)]
245 enum Msg {
246 Back,
247 }
248
249 #[test]
250 fn root_bar_has_no_back_button() {
251 let bar: NavigationBar<Msg> = NavigationBar::new("Home");
252 assert!(Widget::children(&bar).is_empty());
253 }
254
255 #[test]
256 fn title_style_and_height_are_customizable() {
257 let bar: NavigationBar<Msg> = NavigationBar::new("Title")
259 .title_style(TextStyle::new(24.0).weight(FontWeight::Bold).italic())
260 .height(72.0);
261 match Widget::style(&bar).height {
262 Dimension::Length(h) => assert_eq!(h, 72.0),
263 _ => panic!("an imposed height was expected"),
264 }
265 let ui = build_ui(
266 &bar,
267 Size::new(400.0, 72.0),
268 &Runtime::default(),
269 &Theme::default(),
270 );
271 let styled = ui.scene().primitives().iter().any(|p| {
272 matches!(
273 p,
274 Primitive::Text { text, size, weight, italic, .. }
275 if text == "Title" && *size == 24.0 && *weight == FontWeight::Bold && *italic
276 )
277 });
278 assert!(styled, "the title must carry the overridden style");
279 }
280
281 #[test]
282 fn back_button_emits_message() {
283 let bar: NavigationBar<Msg> = NavigationBar::new("Settings").on_back(Msg::Back);
284 let ui = build_ui(
285 &bar,
286 Size::new(400.0, 56.0),
287 &Runtime::default(),
288 &Theme::default(),
289 );
290 let id = ui.hit(Point::new(40.0, 28.0)).expect("back button");
292 assert_eq!(ui.msg_for(id), Some(Msg::Back));
293 }
294
295 #[test]
304 fn a_bar_given_no_width_still_spans_what_it_is_offered() {
305 const FRAME: f32 = 400.0;
306 let bar: NavigationBar<Msg> = NavigationBar::new("Settings").on_back(Msg::Back);
307 let root = crate::Flex::row().width(FRAME).child(bar);
312 let theme = Theme::default();
313 let ui = build_ui(&root, Size::new(FRAME, HEIGHT), &Runtime::default(), &theme);
314 let at = ui
315 .scene()
316 .primitives()
317 .iter()
318 .find_map(|p| match p {
319 Primitive::Text { text, position, .. } if text == "Settings" => Some(*position),
320 _ => None,
321 })
322 .expect("the title is painted");
323 let measured = frus_text::measure_style("Settings", title_style_of(None, &theme));
324 let centred = (FRAME - measured.width) * 0.5;
325 assert!(
326 (at.x - centred).abs() < 1.0,
327 "the title is centred in the frame: {} against {centred}",
328 at.x
329 );
330 assert!(
332 at.x > PAD_LEFT + crate::ICON_BUTTON_SIZE,
333 "the title is clear of the back button: {} against {}",
334 at.x,
335 PAD_LEFT + crate::ICON_BUTTON_SIZE
336 );
337 }
338
339 fn rects(bar: &NavigationBar<Msg>, theme: &Theme, frame: Size) -> Vec<(Rect, Color)> {
342 let ui = build_ui(bar, frame, &Runtime::default(), theme);
343 ui.scene()
344 .primitives()
345 .iter()
346 .filter_map(|p| match p {
347 Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
348 _ => None,
349 })
350 .collect()
351 }
352
353 #[test]
363 fn a_theme_reaches_every_property_the_bar_paints() {
364 const BACKGROUND: Color = Color::rgb(0.10, 0.20, 0.30);
365 const RULE: Color = Color::rgb(0.90, 0.10, 0.40);
366 let mut theme = Theme::default();
367 theme.widgets.nav_bar.height = Some(64.0);
368 theme.widgets.nav_bar.padding = Insets::new(0.0, 4.0, 0.0, 40.0).into();
369 theme.widgets.nav_bar.background = Some(BACKGROUND);
370 theme.widgets.nav_bar.divider_color = Some(RULE);
371 theme.widgets.nav_bar.divider_thickness = Some(3.0);
372 theme.widgets.nav_bar.title_style = Some(TextStyle::new(18.0).weight(FontWeight::Bold));
373
374 let bar: NavigationBar<Msg> = NavigationBar::new("Title").on_back(Msg::Back);
375 match Widget::<Msg>::style_themed(&bar, &theme).height {
378 Dimension::Length(h) => assert_eq!(h, 64.0, "the theme's height"),
379 other => panic!("a definite height was expected, got {other:?}"),
380 }
381 assert_eq!(
382 Widget::<Msg>::style_themed(&bar, &theme).padding,
383 Insets::new(0.0, 4.0, 0.0, 40.0),
384 "the theme's padding"
385 );
386
387 let painted = rects(&bar, &theme, Size::new(400.0, 64.0));
388 let (bg_rect, bg) = painted[0];
389 assert_eq!(bg, BACKGROUND, "the theme's background");
390 assert_eq!(bg_rect.height, 64.0, "and it fills the themed height");
391 let (rule_rect, rule) = painted[1];
392 assert_eq!(rule, RULE, "the theme's hairline colour");
393 assert_eq!(rule_rect.height, 3.0, "and its thickness");
394 assert_eq!(
395 rule_rect.y + rule_rect.height,
396 bg_rect.y + bg_rect.height,
397 "the hairline sits on the bottom edge whatever it weighs"
398 );
399
400 let ui = build_ui(&bar, Size::new(400.0, 64.0), &Runtime::default(), &theme);
401 let styled = ui.scene().primitives().iter().any(|p| {
402 matches!(p, Primitive::Text { text, size, weight, .. }
403 if text == "Title" && *size == 18.0 && *weight == FontWeight::Bold)
404 });
405 assert!(styled, "the theme's title style");
406 }
407
408 #[test]
412 fn a_call_sites_own_values_beat_the_theme() {
413 let mut theme = Theme::default();
414 theme.widgets.nav_bar.height = Some(64.0);
415 theme.widgets.nav_bar.title_style = Some(TextStyle::new(18.0));
416
417 let bar: NavigationBar<Msg> = NavigationBar::new("Title")
418 .height(72.0)
419 .title_style(TextStyle::new(30.0));
420 match Widget::<Msg>::style_themed(&bar, &theme).height {
421 Dimension::Length(h) => assert_eq!(h, 72.0, "the caller's height, not the theme's"),
422 other => panic!("a definite height was expected, got {other:?}"),
423 }
424 let ui = build_ui(&bar, Size::new(400.0, 72.0), &Runtime::default(), &theme);
425 let sized = ui.scene().primitives().iter().any(
426 |p| matches!(p, Primitive::Text { text, size, .. } if text == "Title" && *size == 30.0),
427 );
428 assert!(sized, "the caller's title style, not the theme's");
429
430 let plain: NavigationBar<Msg> = NavigationBar::new("Title");
433 match Widget::<Msg>::style_themed(&plain, &theme).height {
434 Dimension::Length(h) => assert_eq!(h, 64.0, "the theme's height"),
435 other => panic!("a definite height was expected, got {other:?}"),
436 }
437 }
438
439 const BAR_BG: Color = Color::rgb(0.12, 0.34, 0.56);
441 const STATUS: f32 = 48.0;
442 const SCREEN: Size = Size::new(400.0, 800.0);
443
444 fn phone() -> crate::MediaQuery {
446 crate::MediaQuery::new(SCREEN).with_insets(frus_core::WindowInsets::bars(Insets::new(
447 STATUS, 0.0, 0.0, 0.0,
448 )))
449 }
450
451 fn painted_under(
454 surface: crate::MediaQuery,
455 root: &dyn Widget<Msg>,
456 ) -> (Point, Rect, crate::Ui<Msg>) {
457 let mut theme = Theme::default();
458 theme.widgets.nav_bar.background = Some(BAR_BG);
459 let ui = surface.scope(|| build_ui(root, SCREEN, &Runtime::default(), &theme));
460 let title = ui
461 .scene()
462 .primitives()
463 .iter()
464 .find_map(|p| match p {
465 Primitive::Text { text, position, .. } if text == "Task" => Some(*position),
466 _ => None,
467 })
468 .expect("the title is painted");
469 let background = ui
470 .scene()
471 .primitives()
472 .iter()
473 .find_map(|p| match p {
474 Primitive::Rect { rect, color, .. } if *color == BAR_BG => Some(*rect),
475 _ => None,
476 })
477 .expect("the bar's background is painted");
478 (title, background, ui)
479 }
480
481 fn screen_with(bar: NavigationBar<Msg>) -> crate::Flex<Msg> {
482 crate::Flex::column()
483 .width(SCREEN.width)
484 .height(SCREEN.height)
485 .child(bar)
486 }
487
488 #[test]
496 fn a_bar_at_the_head_of_a_screen_clears_the_status_bar() {
497 let (flat, _, _) = painted_under(
498 crate::MediaQuery::new(SCREEN),
499 &screen_with(NavigationBar::new("Task")),
500 );
501 let (title, background, ui) = painted_under(
502 phone(),
503 &screen_with(NavigationBar::new("Task").on_back(Msg::Back)),
504 );
505 assert_eq!(
506 background.y, 0.0,
507 "the background runs behind the status bar"
508 );
509 assert_eq!(
510 background.height,
511 HEIGHT + STATUS,
512 "and the bar grows by it rather than giving it up"
513 );
514 assert!(
515 (title.y - (flat.y + STATUS)).abs() < 0.5,
516 "the title is centred below the status bar: {} against {}",
517 title.y,
518 flat.y + STATUS
519 );
520 let below = ui
523 .hit(Point::new(40.0, STATUS + HEIGHT / 2.0))
524 .expect("the back button, below the status bar");
525 assert_eq!(ui.msg_for(below), Some(Msg::Back));
526 assert!(
527 ui.hit(Point::new(40.0, STATUS / 2.0))
528 .and_then(|id| ui.msg_for(id))
529 .is_none(),
530 "the back button is not under the status bar"
531 );
532 }
533
534 #[test]
538 fn a_bar_in_a_safe_area_does_not_clear_the_status_bar_again() {
539 let (flat, _, _) = painted_under(
540 crate::MediaQuery::new(SCREEN),
541 &screen_with(NavigationBar::new("Task")),
542 );
543 let root = crate::Flex::column()
544 .width(SCREEN.width)
545 .height(SCREEN.height)
546 .child(crate::SafeArea::new(
547 crate::Flex::column().child(NavigationBar::new("Task")),
548 ));
549 let (title, background, _) = painted_under(phone(), &root);
550 assert_eq!(
551 background.y, STATUS,
552 "the safe area holds the bar below the notch"
553 );
554 assert_eq!(
555 background.height, HEIGHT,
556 "and the bar adds nothing of its own"
557 );
558 assert!(
559 (title.y - (flat.y + STATUS)).abs() < 0.5,
560 "the title is one status bar down, not two: {} against {}",
561 title.y,
562 flat.y + STATUS
563 );
564 }
565
566 #[test]
569 fn a_bar_in_a_scaffolds_app_bar_slot_clears_the_status_bar() {
570 let (flat, _, _) = painted_under(
571 crate::MediaQuery::new(SCREEN),
572 &screen_with(NavigationBar::new("Task")),
573 );
574 let scaffold = phone().scope(|| {
577 crate::Scaffold::new()
578 .size(SCREEN.width, SCREEN.height)
579 .app_bar(NavigationBar::new("Task"))
580 .body(crate::Container::new().flex(1.0))
581 .build()
582 });
583 let (title, background, _) = painted_under(phone(), &scaffold);
584 assert_eq!(background.y, 0.0);
585 assert_eq!(background.height, HEIGHT + STATUS);
586 assert!((title.y - (flat.y + STATUS)).abs() < 0.5, "{}", title.y);
587 }
588
589 #[test]
590 fn bar_paints_title_and_divider() {
591 let bar: NavigationBar<Msg> = NavigationBar::new("Title");
592 let ui = build_ui(
593 &bar,
594 Size::new(400.0, 56.0),
595 &Runtime::default(),
596 &Theme::default(),
597 );
598 let has_text = ui
599 .scene()
600 .primitives()
601 .iter()
602 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "Title"));
603 assert!(has_text, "the title is painted");
604 }
605}