Skip to main content

frus_widgets/
drawerheader.rs

1//! [`DrawerHeader`] and [`UserAccountsDrawerHeader`]: the block at the top of a side
2//! panel, above the destinations.
3//!
4//! [`NavigationDrawer`](crate::NavigationDrawer) has had a
5//! [header](crate::NavigationDrawer::header) slot since milestone 467 and nothing to put
6//! in it. These are the two things the reference puts there: a plain block of a fixed
7//! height with a rule under it, and the same block laid out for an account — a picture,
8//! a name, an address, and a control for switching.
9//!
10//! ```ignore
11//! NavigationDrawer::new(app.tab, Msg::Go)
12//!     .header(
13//!         UserAccountsDrawerHeader::new()
14//!             .account_name("Ada Lovelace")
15//!             .account_email("ada@example.com")
16//!             .current_picture(CircleAvatar::initials("AL")),
17//!     )
18//!     .item("in", "Inbox")
19//! ```
20//!
21//! **The header is where the notch lands.** A panel runs to the top of the screen, so the
22//! first thing in it is the thing under the status bar: both of these add the top
23//! intrusion to their height *and* to their padding, which is what keeps the block the
24//! same size below the notch on every device while its background still runs up behind it
25//! (`drawer_header.dart:86`, `:90`).
26
27use 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
38/// The block's height, the rule under it included (`drawer_header.dart:16`). The
39/// reference writes it as `160.0 + 1.0`, the `+ 1` being the bottom edge, and that is
40/// worth keeping visible: the rule is *inside* the header rather than under it, so a
41/// header and its rule together are 161 and the thing below starts at 161.
42pub const DRAWER_HEADER_HEIGHT: f32 = 160.0 + 1.0;
43/// The room under the header, before whatever comes next (`drawer_header.dart:39`).
44const MARGIN: Insets = Insets::new(0.0, 0.0, 8.0, 0.0);
45/// The room around a plain header's content (`drawer_header.dart:40`, `fromLTRB`).
46const PADDING: Insets = Insets::new(16.0, 16.0, 8.0, 16.0);
47/// And around an account header's, which leaves its trailing edge to the pictures
48/// (`user_accounts_drawer_header.dart:366`).
49const ACCOUNT_PADDING: Insets = Insets::new(16.0, 0.0, 0.0, 16.0);
50/// The row at the foot of an account header: the name, the address, the arrow
51/// (`user_accounts_drawer_header.dart:208`).
52const DETAILS_HEIGHT: f32 = 56.0;
53/// The current account's picture (`user_accounts_drawer_header.dart:296`).
54const CURRENT_PICTURE: f32 = 72.0;
55/// And each of the others (`:297`).
56const OTHER_PICTURE: f32 = 40.0;
57/// How many of the others are shown (`user_accounts_drawer_header.dart:43`).
58const OTHER_PICTURE_LIMIT: usize = 3;
59/// Between them.
60const PICTURE_GAP: f32 = 8.0;
61
62/// A fixed block at the top of a side panel, with a rule under it.
63///
64/// It sizes and places its content and paints two things: a background, when it was given
65/// one, and the rule. Everything else is the child's.
66pub 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    /// Whether the rule under it is drawn. The reference always draws it; an account
73    /// header on a coloured ground does not want it, and says so.
74    rule: bool,
75    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
76}
77
78impl<Msg: Clone + 'static> DrawerHeader<Msg> {
79    /// An empty header of the default height.
80    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    /// What goes in it, inset by the padding.
93    pub fn child(self, child: impl Widget<Msg> + 'static) -> Self {
94        self.child_boxed(Box::new(child))
95    }
96
97    /// [`Self::child`], for a widget already boxed.
98    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    /// The block's fill (`drawer_header.dart:52`). Unset, nothing — the panel's own
105    /// surface shows through, which is what a header that is only a title wants.
106    pub fn background_color(mut self, color: Color) -> Self {
107        self.background = Some(color);
108        self
109    }
110
111    /// The room around the content. Unset, the reference's `16` on three sides and `8`
112    /// underneath. **The top intrusion is added to it**, never replaced by it.
113    pub fn padding(mut self, padding: Insets) -> Self {
114        self.padding = Some(padding);
115        self
116    }
117
118    /// The room around the block itself. Unset, `8` underneath and nothing elsewhere.
119    pub fn margin(mut self, margin: Insets) -> Self {
120        self.margin = Some(margin);
121        self
122    }
123
124    /// The block's height, before the top intrusion is added. Unset,
125    /// [`DRAWER_HEADER_HEIGHT`].
126    pub fn height(mut self, height: f32) -> Self {
127        self.height = Some(height);
128        self
129    }
130
131    /// Whether the rule under it is drawn. Unset, `true`.
132    ///
133    /// A header with a background of its own is already told apart from what follows it,
134    /// and a hairline over a colour is the mash-up [`Card`](crate::Card) was taken apart
135    /// for — so [`UserAccountsDrawerHeader`] turns it off.
136    pub fn rule(mut self, rule: bool) -> Self {
137        self.rule = rule;
138        self
139    }
140
141    /// The intrusion at the top of the screen, which a header standing at the top of a
142    /// panel is under.
143    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            // The rule is the header's **last pixel**, not a line under it — which is
187            // what the reference's `160.0 + 1.0` says, and why a header and the thing
188            // below it never have a gap of exactly one pixel between them.
189            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
216/// A [`DrawerHeader`] laid out for the account the application is signed in to: a
217/// picture, up to three others, a name, an address, and a control for switching between
218/// them.
219pub 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    /// An account header with nothing said about the account yet.
238    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    /// The account's name, on the upper of the two lines.
258    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    /// The account's address, on the lower one.
265    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    /// The picture standing for the account, in the upper corner. Usually a
272    /// [`CircleAvatar`](crate::CircleAvatar).
273    pub fn current_picture(self, picture: impl Widget<Msg> + 'static) -> Self {
274        self.current_picture_boxed(Box::new(picture))
275    }
276
277    /// [`Self::current_picture`], for a widget already boxed.
278    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    /// Adds a picture for one of the account's *others*, in the opposite corner. **Three
285    /// of them are shown** and the rest are dropped, as the reference drops them
286    /// (`user_accounts_drawer_header.dart:43`): the corner has room for three, and a
287    /// fourth would either shrink the other three or run under the current account's.
288    pub fn other_picture(self, picture: impl Widget<Msg> + 'static) -> Self {
289        self.other_picture_boxed(Box::new(picture))
290    }
291
292    /// [`Self::other_picture`], for a widget already boxed.
293    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    /// The size of the current account's picture. Unset, `72`.
300    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    /// The size of each of the others. Unset, `40`.
307    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    /// The block's fill. Unset, the scheme's `primary`
314    /// (`user_accounts_drawer_header.dart:364`).
315    pub fn background_color(mut self, color: Color) -> Self {
316        self.background = Some(color);
317        self.built.take();
318        self
319    }
320
321    /// The room around the block. Unset, `8` underneath.
322    pub fn margin(mut self, margin: Insets) -> Self {
323        self.margin = Some(margin);
324        self.built.take();
325        self
326    }
327
328    /// The switching control's colour.
329    ///
330    /// Unset, `on_primary` — **not** the reference's `Colors.white`
331    /// (`user_accounts_drawer_header.dart:301`). White is right for the dark primary a
332    /// 2014 palette had and wrong on a light one, and this framework has a role that means
333    /// *what goes on primary*. Naming a colour here still wins.
334    pub fn arrow_color(mut self, color: Color) -> Self {
335        self.arrow_color = Some(color);
336        self.built.take();
337        self
338    }
339
340    /// The name's type. Unset, `bodyLarge`
341    /// (`user_accounts_drawer_header.dart:151`).
342    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    /// The address's type. Unset, `bodyMedium` (`:163`).
349    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    /// What to emit when the lower half is pressed — the request to show or hide the
356    /// other accounts. Without it there is **no control at all**, which is the
357    /// reference's rule (`user_accounts_drawer_header.dart:169`): an arrow that opened
358    /// nothing would be a promise the header cannot keep.
359    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    /// Whether the other accounts are currently showing, which is what the control points
366    /// at. The state lives in the application, as every other piece of state here does.
367    pub fn open(mut self, open: bool) -> Self {
368        self.open = open;
369        self.built.take();
370        self
371    }
372
373    /// **How far the two lines are lifted off the foot of the details row**, so that the
374    /// *bottom* one is centred on the control beside it rather than the pair of them being
375    /// centred together (`user_accounts_drawer_header.dart:262`).
376    ///
377    /// The address sits at the middle of the 56-pixel row and the name goes *above* it,
378    /// overflowing up into the pictures. That is not a rounding of "centre the two lines"
379    /// — it is about ten pixels different — and it is what keeps the address level with
380    /// the arrow whether or not there is a name at all. Which is the whole reason the
381    /// reference lays this row out by hand instead of using a column.
382    fn bottom_lift(&self, theme: &Theme) -> f32 {
383        let (name, email) = self.line_heights(theme);
384        // Which line is the bottom one: the address when there is one, else the name.
385        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    /// **How tall the two lines actually lay out**, measured rather than derived from the
394    /// type scale's line height.
395    ///
396    /// The two are not the same number and the difference is under a pixel, which is
397    /// exactly the size of difference that costs an afternoon: a row sized from
398    /// `line_height` and filled with text laid out from a measurement is a row that
399    /// overflows by six tenths of a pixel and says so in a yellow banner across the
400    /// header. Measure the thing that will be drawn.
401    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    /// The two lines and the control, in a row at least [`DETAILS_HEIGHT`] tall.
421    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            // **A strut, not a height.** The row is as tall as [`DETAILS_HEIGHT`] or as
447            // tall as its lines, whichever is more — a name above an address that is
448            // itself centred on the row's midpoint is taller than 56 by construction, and
449            // taller again under a reader who enlarged the type.
450            //
451            // Saying that as `height(max(56, lines))` means computing what the lines will
452            // measure, and a row sized from one arithmetic and filled from another
453            // overflows by six tenths of a pixel and says so in a yellow band across the
454            // header. A zero-width child of the right height floors the row without
455            // anybody predicting anything.
456            //
457            // The reference has the row at a flat 56 and lets the name overflow up into
458            // the pictures, which its hand-written layout does not mind. Here the boxes
459            // are real, so the row grows and the pictures give up the difference — every
460            // line lands where the reference puts it, with nothing outside its box.
461            .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            // The reference rotates one triangle through half a turn; two chevrons are
466            // the two ends of that turn, and this framework has both drawn on the grid.
467            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    /// The header's subtree, built once under the theme it will be drawn in.
489    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        // The pictures: the current account in the leading corner, the others in the
496        // trailing one. The reference stacks them at `top: 0` and `end: 0`; a row with a
497        // `Spacer` between puts them in the same two places and follows the direction
498        // across, so a header in Arabic does not have to be told which corner is which.
499        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        // The whole block is **one thing** to a reader, announced as such
529        // (`user_accounts_drawer_header.dart:359`) — otherwise a name, an address and an
530        // arrow arrive as three unrelated nodes at the top of the panel.
531        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                // The press is the row's, and it is the row the reference makes tappable
544                // (`:197`) rather than the arrow: a 56-pixel arrow beside a name that is
545                // not tappable is a target nobody finds.
546                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    /// The two type steps and the ink are the theme's, so the subtree cannot be built
579    /// before there is one — the idiom every composed widget here uses.
580    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    /// Every widget in a built subtree whose short type name matches.
612    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    /// How many glyphs the subtree draws.
622    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    /// Every label a `Semantics` wrapper in the subtree carries.
629    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    /// **The notch is added to the block, not taken out of it.** A header is the first
639    /// thing in a panel that runs to the top of the screen, so its background has to go up
640    /// behind the status bar while its content stays the same 160 pixels tall underneath —
641    /// which means the intrusion lands on the height *and* on the padding
642    /// (`drawer_header.dart:86`, `:90`). Subtracting it in one place and not the other is
643    /// the mistake, and it only shows on a device.
644    #[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    /// The rule is the header's **last pixel**, which is what `160.0 + 1.0` says.
669    #[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    /// **No control without something for it to do.** An arrow that opened nothing is a
695    /// promise the header cannot keep (`user_accounts_drawer_header.dart:169`).
696    #[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    /// And the word on it says what the press **will do**, which is the only kind a reader
714    /// can act on — so it is two entries on the table rather than one that flips.
715    #[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    /// The whole block is **one thing** to a reader (`user_accounts_drawer_header.dart:359`).
731    /// Without it, a name, an address and an arrow arrive as three unrelated nodes at the
732    /// top of a panel and none of them says what it is the top of.
733    #[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    /// **The bottom line is centred on the control, not the pair of lines.**
742    ///
743    /// The reference places the bottom line's centre at the row's centre and puts the name
744    /// above it (`user_accounts_drawer_header.dart:262`), so the address stays level with
745    /// the arrow beside it whether or not there is a name. Centring the two lines together
746    /// is the obvious reading and lands about ten pixels off.
747    #[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        // With no address, the **name** is the bottom line and takes its place.
764        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    /// Three of the other accounts are shown and the rest are dropped: the corner has room
779    /// for three (`user_accounts_drawer_header.dart:43`).
780    #[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    /// The builders are order-independent, the subtree being thrown away by each of them.
796    #[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}