1use std::cell::{OnceCell, RefCell};
28
29use frus_core::{Color, Insets, Rect, Scene, TextStyle};
30use frus_layout::{Align, Dimension, FlexDirection, Style};
31
32use crate::flex::Flex;
33use crate::interaction::Status;
34use crate::spacer::Spacer;
35use crate::theme::Theme;
36use crate::widget::{FillAxes, Widget};
37
38pub const DRAWER_HEADER_HEIGHT: f32 = 160.0 + 1.0;
43const MARGIN: Insets = Insets::new(0.0, 0.0, 8.0, 0.0);
45const PADDING: Insets = Insets::new(16.0, 16.0, 8.0, 16.0);
47const ACCOUNT_PADDING: Insets = Insets::new(16.0, 0.0, 0.0, 16.0);
50const DETAILS_HEIGHT: f32 = 56.0;
53const CURRENT_PICTURE: f32 = 72.0;
55const OTHER_PICTURE: f32 = 40.0;
57const OTHER_PICTURE_LIMIT: usize = 3;
59const PICTURE_GAP: f32 = 8.0;
61
62pub struct DrawerHeader<Msg = crate::callback::Callback> {
67 child: RefCell<Option<Box<dyn Widget<Msg>>>>,
68 background: Option<Color>,
69 padding: Option<Insets>,
70 margin: Option<Insets>,
71 height: Option<f32>,
72 rule: bool,
75 built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
76}
77
78impl<Msg: Clone + 'static> DrawerHeader<Msg> {
79 pub fn new() -> Self {
81 Self {
82 child: RefCell::new(None),
83 background: None,
84 padding: None,
85 margin: None,
86 height: None,
87 rule: true,
88 built: OnceCell::new(),
89 }
90 }
91
92 pub fn child(self, child: impl Widget<Msg> + 'static) -> Self {
94 self.child_boxed(Box::new(child))
95 }
96
97 pub fn child_boxed(mut self, child: Box<dyn Widget<Msg>>) -> Self {
99 *self.child.borrow_mut() = Some(child);
100 self.built.take();
101 self
102 }
103
104 pub fn background_color(mut self, color: Color) -> Self {
107 self.background = Some(color);
108 self
109 }
110
111 pub fn padding(mut self, padding: Insets) -> Self {
114 self.padding = Some(padding);
115 self
116 }
117
118 pub fn margin(mut self, margin: Insets) -> Self {
120 self.margin = Some(margin);
121 self
122 }
123
124 pub fn height(mut self, height: f32) -> Self {
127 self.height = Some(height);
128 self
129 }
130
131 pub fn rule(mut self, rule: bool) -> Self {
137 self.rule = rule;
138 self
139 }
140
141 fn safe_top(&self) -> f32 {
144 crate::MediaQuery::of().padding.top
145 }
146
147 fn sizing(&self) -> Style {
148 let safe = self.safe_top();
149 let pad = self.padding.unwrap_or(PADDING);
150 Style {
151 height: Dimension::Length(self.height.unwrap_or(DRAWER_HEADER_HEIGHT) + safe),
152 flex_direction: FlexDirection::Column,
153 padding: Insets::new(pad.top + safe, pad.right, pad.bottom, pad.left),
154 margin: self.margin.unwrap_or(MARGIN),
155 ..Default::default()
156 }
157 }
158}
159
160impl<Msg: Clone + 'static> Default for DrawerHeader<Msg> {
161 fn default() -> Self {
162 Self::new()
163 }
164}
165
166impl<Msg: Clone + 'static> Widget<Msg> for DrawerHeader<Msg> {
167 fn style(&self) -> Style {
168 self.sizing()
169 }
170
171 fn fill_axes(&self, _theme: &Theme) -> FillAxes {
172 FillAxes::WIDTH
173 }
174
175 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
176 self.built
177 .get_or_init(|| self.child.borrow_mut().take().into_iter().collect())
178 }
179
180 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
181 let o = status.opacity;
182 if let Some(background) = self.background {
183 scene.fill_rect(bounds, background.fade(o));
184 }
185 if self.rule {
186 let thickness = theme
190 .widgets
191 .divider
192 .thickness
193 .unwrap_or(crate::DIVIDER_THICKNESS);
194 let color = theme
195 .widgets
196 .divider
197 .color
198 .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 color.fade(o),
207 );
208 }
209 }
210
211 fn on_click(&self) -> Option<Msg> {
212 None
213 }
214}
215
216pub struct UserAccountsDrawerHeader<Msg = crate::callback::Callback> {
220 account_name: Option<String>,
221 account_email: Option<String>,
222 current_picture: RefCell<Option<Box<dyn Widget<Msg>>>>,
223 other_pictures: RefCell<Vec<Box<dyn Widget<Msg>>>>,
224 current_picture_size: Option<f32>,
225 other_pictures_size: Option<f32>,
226 background: Option<Color>,
227 margin: Option<Insets>,
228 arrow_color: Option<Color>,
229 name_text_style: Option<TextStyle>,
230 email_text_style: Option<TextStyle>,
231 on_details_pressed: Option<Msg>,
232 open: bool,
233 built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
234}
235
236impl<Msg: Clone + 'static> UserAccountsDrawerHeader<Msg> {
237 pub fn new() -> Self {
239 Self {
240 account_name: None,
241 account_email: None,
242 current_picture: RefCell::new(None),
243 other_pictures: RefCell::new(Vec::new()),
244 current_picture_size: None,
245 other_pictures_size: None,
246 background: None,
247 margin: None,
248 arrow_color: None,
249 name_text_style: None,
250 email_text_style: None,
251 on_details_pressed: None,
252 open: false,
253 built: OnceCell::new(),
254 }
255 }
256
257 pub fn account_name(mut self, name: impl Into<String>) -> Self {
259 self.account_name = Some(name.into());
260 self.built.take();
261 self
262 }
263
264 pub fn account_email(mut self, email: impl Into<String>) -> Self {
266 self.account_email = Some(email.into());
267 self.built.take();
268 self
269 }
270
271 pub fn current_picture(self, picture: impl Widget<Msg> + 'static) -> Self {
274 self.current_picture_boxed(Box::new(picture))
275 }
276
277 pub fn current_picture_boxed(mut self, picture: Box<dyn Widget<Msg>>) -> Self {
279 *self.current_picture.borrow_mut() = Some(picture);
280 self.built.take();
281 self
282 }
283
284 pub fn other_picture(self, picture: impl Widget<Msg> + 'static) -> Self {
289 self.other_picture_boxed(Box::new(picture))
290 }
291
292 pub fn other_picture_boxed(mut self, picture: Box<dyn Widget<Msg>>) -> Self {
294 self.other_pictures.borrow_mut().push(picture);
295 self.built.take();
296 self
297 }
298
299 pub fn current_picture_size(mut self, size: f32) -> Self {
301 self.current_picture_size = Some(size);
302 self.built.take();
303 self
304 }
305
306 pub fn other_pictures_size(mut self, size: f32) -> Self {
308 self.other_pictures_size = Some(size);
309 self.built.take();
310 self
311 }
312
313 pub fn background_color(mut self, color: Color) -> Self {
316 self.background = Some(color);
317 self.built.take();
318 self
319 }
320
321 pub fn margin(mut self, margin: Insets) -> Self {
323 self.margin = Some(margin);
324 self.built.take();
325 self
326 }
327
328 pub fn arrow_color(mut self, color: Color) -> Self {
335 self.arrow_color = Some(color);
336 self.built.take();
337 self
338 }
339
340 pub fn name_text_style(mut self, style: TextStyle) -> Self {
343 self.name_text_style = Some(style);
344 self.built.take();
345 self
346 }
347
348 pub fn email_text_style(mut self, style: TextStyle) -> Self {
350 self.email_text_style = Some(style);
351 self.built.take();
352 self
353 }
354
355 pub fn on_details_pressed(mut self, message: impl Into<Msg>) -> Self {
360 self.on_details_pressed = Some(message.into());
361 self.built.take();
362 self
363 }
364
365 pub fn open(mut self, open: bool) -> Self {
368 self.open = open;
369 self.built.take();
370 self
371 }
372
373 fn bottom_lift(&self, theme: &Theme) -> f32 {
383 let (name, email) = self.line_heights(theme);
384 let bottom = if self.account_email.is_some() {
386 email
387 } else {
388 name
389 };
390 (DETAILS_HEIGHT * 0.5 - bottom * 0.5).max(0.0)
391 }
392
393 fn line_heights(&self, theme: &Theme) -> (f32, f32) {
402 let scale = crate::theme::type_scale(Some(theme));
403 let measured = |text: &Option<String>, style: TextStyle| {
404 text.as_ref().map_or(0.0, |text| {
405 frus_text::measure_resolved(text, &style.resolved()).height
406 })
407 };
408 (
409 measured(
410 &self.account_name,
411 self.name_text_style.unwrap_or(scale.body_large),
412 ),
413 measured(
414 &self.account_email,
415 self.email_text_style.unwrap_or(scale.body_medium),
416 ),
417 )
418 }
419
420 fn details(&self, theme: &Theme) -> Flex<Msg> {
422 let scale = crate::theme::type_scale(Some(theme));
423 let ink = theme.scheme.on_primary;
424 let name_style = self.name_text_style.unwrap_or(scale.body_large);
425 let email_style = self.email_text_style.unwrap_or(scale.body_medium);
426 let lift = self.bottom_lift(theme);
427
428 let mut lines = Flex::<Msg>::column().padding_each(0.0, 0.0, lift, 0.0);
429 if let Some(name) = &self.account_name {
430 lines = lines.child(
431 crate::Text::styled(name.clone(), name_style)
432 .color(ink)
433 .ellipsis(),
434 );
435 }
436 if let Some(email) = &self.account_email {
437 lines = lines.child(
438 crate::Text::styled(email.clone(), email_style)
439 .color(ink)
440 .ellipsis(),
441 );
442 }
443
444 let mut row = Flex::<Msg>::row()
445 .align(Align::End)
446 .child(Flex::<Msg>::column().width(0.0).height(DETAILS_HEIGHT))
462 .child(crate::Expanded::new(lines));
463 if self.on_details_pressed.is_some() {
464 let words = crate::localizations::of();
465 let (glyph, label) = if self.open {
468 (crate::Icons::EXPAND_LESS, words.hide_accounts_label())
469 } else {
470 (crate::Icons::EXPAND_MORE, words.show_accounts_label())
471 };
472 row = row.child(
473 crate::Semantics::new(
474 frus_core::SemanticsProperties::new(frus_core::Role::Button).clickable(),
475 Flex::<Msg>::column()
476 .width(DETAILS_HEIGHT)
477 .height(DETAILS_HEIGHT)
478 .justify(frus_layout::Justify::Center)
479 .align(Align::Center)
480 .child(crate::Icon::new(glyph).color(self.arrow_color.unwrap_or(ink))),
481 )
482 .label(label.to_string()),
483 );
484 }
485 row
486 }
487
488 fn assemble(&self, theme: &Theme) -> Vec<Box<dyn Widget<Msg>>> {
490 let current = self.current_picture.borrow_mut().take();
491 let others = std::mem::take(&mut *self.other_pictures.borrow_mut());
492 let current_size = self.current_picture_size.unwrap_or(CURRENT_PICTURE);
493 let other_size = self.other_pictures_size.unwrap_or(OTHER_PICTURE);
494
495 let mut pictures = Flex::<Msg>::row()
500 .flex(1.0)
501 .align(Align::Start)
502 .padding_insets(frus_core::InsetsDirectional::only_end(16.0));
503 pictures = match current {
504 Some(picture) => pictures.child_boxed(Box::new(
505 Flex::<Msg>::column()
506 .width(current_size)
507 .height(current_size)
508 .child_boxed(picture),
509 )),
510 None => pictures.child(Flex::<Msg>::column().width(current_size)),
511 };
512 pictures = pictures.child(Spacer::new());
513 let mut rest = Flex::<Msg>::row().gap(PICTURE_GAP).align(Align::Start);
514 for picture in others.into_iter().take(OTHER_PICTURE_LIMIT) {
515 rest = rest.child_boxed(Box::new(
516 Flex::<Msg>::column()
517 .width(other_size)
518 .height(other_size)
519 .child_boxed(picture),
520 ));
521 }
522 pictures = pictures.child(rest);
523
524 let body = Flex::<Msg>::column()
525 .child(pictures)
526 .child(self.details(theme));
527
528 let words = crate::localizations::of();
532 let mut header = DrawerHeader::<Msg>::new()
533 .rule(false)
534 .padding(ACCOUNT_PADDING)
535 .background_color(self.background.unwrap_or(theme.scheme.primary))
536 .child(body);
537 if let Some(margin) = self.margin {
538 header = header.margin(margin);
539 }
540 let annotated = crate::Semantics::new(
541 frus_core::SemanticsProperties::default(),
542 match &self.on_details_pressed {
543 Some(message) => Box::new(
547 crate::Container::new()
548 .on_click(message.clone())
549 .child(header),
550 ) as Box<dyn Widget<Msg>>,
551 None => Box::new(header),
552 },
553 )
554 .label(words.signed_in_label().to_string());
555
556 vec![Box::new(annotated)]
557 }
558}
559
560impl<Msg: Clone + 'static> Default for UserAccountsDrawerHeader<Msg> {
561 fn default() -> Self {
562 Self::new()
563 }
564}
565
566impl<Msg: Clone + 'static> Widget<Msg> for UserAccountsDrawerHeader<Msg> {
567 fn style(&self) -> Style {
568 Style {
569 flex_direction: FlexDirection::Column,
570 ..Default::default()
571 }
572 }
573
574 fn fill_axes(&self, _theme: &Theme) -> FillAxes {
575 FillAxes::WIDTH
576 }
577
578 fn build_themed(&self, theme: &Theme) {
581 let _ = self.built.set(self.assemble(theme));
582 }
583
584 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
585 self.built.get_or_init(|| self.assemble(&Theme::default()))
586 }
587
588 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
589
590 fn on_click(&self) -> Option<Msg> {
591 None
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598 use frus_core::Primitive;
599
600 #[derive(Clone, Debug, PartialEq)]
601 enum Msg {
602 Details,
603 }
604
605 fn scene_of(widget: &dyn Widget<Msg>, bounds: Rect, theme: &Theme) -> Scene {
606 let mut scene = Scene::new();
607 widget.paint(bounds, Status::default(), theme, &mut scene);
608 scene
609 }
610
611 fn find<'a>(node: &'a dyn Widget<Msg>, name: &str, out: &mut Vec<&'a dyn Widget<Msg>>) {
613 if node.debug_name() == name {
614 out.push(node);
615 }
616 for child in node.children() {
617 find(&**child, name, out);
618 }
619 }
620
621 fn glyph_count(root: &dyn Widget<Msg>) -> usize {
623 let mut found = Vec::new();
624 find(root, "Icon", &mut found);
625 found.len()
626 }
627
628 fn announced(root: &dyn Widget<Msg>) -> Vec<String> {
630 let mut found = Vec::new();
631 find(root, "Semantics", &mut found);
632 found
633 .iter()
634 .filter_map(|node| node.describes()?.props.label.clone())
635 .collect()
636 }
637
638 #[test]
645 fn the_notch_is_added_to_the_height_and_to_the_padding() {
646 let flat = Widget::<Msg>::style(&DrawerHeader::<Msg>::new());
647 assert_eq!(flat.height, Dimension::Length(DRAWER_HEADER_HEIGHT));
648 assert_eq!(flat.padding.top, PADDING.top);
649
650 let notched = crate::MediaQuery::new(frus_core::Size::new(360.0, 780.0))
651 .with_insets(frus_core::WindowInsets::bars(Insets::new(
652 48.0, 0.0, 0.0, 0.0,
653 )))
654 .scope(|| Widget::<Msg>::style(&DrawerHeader::<Msg>::new()));
655 assert_eq!(
656 notched.height,
657 Dimension::Length(DRAWER_HEADER_HEIGHT + 48.0),
658 "the block grows by the intrusion"
659 );
660 assert_eq!(
661 notched.padding.top,
662 PADDING.top + 48.0,
663 "and the content is pushed clear of it, so the block below the notch is \
664 still the same block"
665 );
666 }
667
668 #[test]
670 fn the_rule_is_the_headers_own_last_pixel() {
671 let theme = Theme::default();
672 let bounds = Rect::new(0.0, 0.0, 300.0, DRAWER_HEADER_HEIGHT);
673 let scene = scene_of(&DrawerHeader::<Msg>::new(), bounds, &theme);
674 let rule = scene
675 .primitives()
676 .iter()
677 .find_map(|p| match p {
678 Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
679 _ => None,
680 })
681 .expect("a header draws its rule");
682 assert_eq!(rule.0.height, crate::DIVIDER_THICKNESS);
683 assert_eq!(rule.0.y, DRAWER_HEADER_HEIGHT - crate::DIVIDER_THICKNESS);
684 assert_eq!(rule.1, theme.scheme.outline_variant);
685
686 assert!(
687 scene_of(&DrawerHeader::<Msg>::new().rule(false), bounds, &theme)
688 .primitives()
689 .is_empty(),
690 "and a header told not to draw one draws nothing at all"
691 );
692 }
693
694 #[test]
697 fn there_is_no_switch_until_there_is_somewhere_to_switch_to() {
698 let theme = Theme::default();
699 let quiet = UserAccountsDrawerHeader::<Msg>::new()
700 .account_name("Ada Lovelace")
701 .account_email("ada@example.com");
702 quiet.build_themed(&theme);
703 assert_eq!(glyph_count(&quiet), 0);
704
705 let live = UserAccountsDrawerHeader::<Msg>::new()
706 .account_name("Ada Lovelace")
707 .account_email("ada@example.com")
708 .on_details_pressed(Msg::Details);
709 live.build_themed(&theme);
710 assert_eq!(glyph_count(&live), 1);
711 }
712
713 #[test]
716 fn the_switch_is_named_for_what_it_will_do() {
717 let theme = Theme::default();
718 let header = |open: bool| {
719 let header = UserAccountsDrawerHeader::<Msg>::new()
720 .account_name("Ada")
721 .on_details_pressed(Msg::Details)
722 .open(open);
723 header.build_themed(&theme);
724 announced(&header)
725 };
726 assert!(header(false).iter().any(|w| w == "Show accounts"));
727 assert!(header(true).iter().any(|w| w == "Hide accounts"));
728 }
729
730 #[test]
734 fn the_block_announces_itself_once() {
735 let theme = Theme::default();
736 let header = UserAccountsDrawerHeader::<Msg>::new().account_name("Ada");
737 header.build_themed(&theme);
738 assert!(announced(&header).iter().any(|w| w == "Signed in"));
739 }
740
741 #[test]
748 fn the_address_is_level_with_the_control() {
749 let theme = Theme::default();
750 let scale = crate::theme::type_scale(Some(&theme));
751
752 let both = UserAccountsDrawerHeader::<Msg>::new()
753 .account_name("Ada Lovelace")
754 .account_email("ada@example.com");
755 let email_h =
756 frus_text::measure_resolved("ada@example.com", &scale.body_medium.resolved()).height;
757 assert_eq!(
758 both.bottom_lift(&theme),
759 DETAILS_HEIGHT * 0.5 - email_h * 0.5,
760 "the address sits at the middle of the row"
761 );
762
763 let name_only = UserAccountsDrawerHeader::<Msg>::new().account_name("Ada Lovelace");
765 let name_h =
766 frus_text::measure_resolved("Ada Lovelace", &scale.body_large.resolved()).height;
767 assert_eq!(
768 name_only.bottom_lift(&theme),
769 DETAILS_HEIGHT * 0.5 - name_h * 0.5,
770 );
771 assert_ne!(
772 both.bottom_lift(&theme),
773 name_only.bottom_lift(&theme),
774 "the two steps have to differ for that to mean anything"
775 );
776 }
777
778 #[test]
781 fn only_three_of_the_other_accounts_are_shown() {
782 let theme = Theme::default();
783 let header = UserAccountsDrawerHeader::<Msg>::new()
784 .account_name("Ada")
785 .other_picture(crate::CircleAvatar::initials("A"))
786 .other_picture(crate::CircleAvatar::initials("B"))
787 .other_picture(crate::CircleAvatar::initials("C"))
788 .other_picture(crate::CircleAvatar::initials("D"));
789 header.build_themed(&theme);
790 let mut found = Vec::new();
791 find(&header, "CircleAvatar", &mut found);
792 assert_eq!(found.len(), 3, "the fourth is dropped, not squeezed in");
793 }
794
795 #[test]
797 fn saying_it_afterwards_says_it() {
798 let theme = Theme::default();
799 let after = UserAccountsDrawerHeader::<Msg>::new()
800 .account_name("Ada")
801 .on_details_pressed(Msg::Details)
802 .open(true);
803 after.build_themed(&theme);
804 assert!(announced(&after).iter().any(|w| w == "Hide accounts"));
805 }
806}