Skip to main content

eave_api/
ui.rs

1//! The closed UI vocabulary widgets build their views from.
2//!
3//! Every node is semantic: it says *what* something is (a label, a heading, a button), never how
4//! it looks. The host maps nodes to pixels using its theme, so every plugin gets the same style,
5//! and the same animations, without being able to break them.
6
7use std::ops::RangeInclusive;
8
9/// The role of a piece of text. The theme decides the color.
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
11pub enum Tone {
12    #[default]
13    Normal,
14    /// Secondary information.
15    Dim,
16    /// The thing to notice: today in a calendar, the active workspace.
17    Accent,
18    Warning,
19    Critical,
20    /// Healthy, or gaining: a charging battery.
21    Good,
22}
23
24/// A status symbol. It names what to show and how full it is; the host draws it in the text's
25/// tone, at the text's size. Fractions are 0.0 to 1.0.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub enum Icon {
28    /// A speaker with sound waves for the level. Above 1.0 is amplified.
29    Volume { level: f32, muted: bool },
30    /// A Wi-Fi fan with as many bars as the signal. `None` when there's no link.
31    Wifi { strength: Option<f32> },
32    /// A wired connection.
33    Wired,
34    /// A battery filled to its charge.
35    Battery { charge: f32, charging: bool },
36    /// A padlock: a secured network.
37    Lock,
38    /// An `i` in a circle: more about this.
39    Info,
40    /// A chevron pointing left: back, or earlier.
41    Back,
42    /// A chevron pointing right: on, or later.
43    Forward,
44    /// A check mark: the one in use.
45    Check,
46    /// A circling arrow: look again.
47    Refresh,
48    /// A cross: close this.
49    Close,
50    /// An eye: show what's hidden.
51    Reveal,
52    /// A lightning bolt: charging.
53    Bolt,
54    /// A power plug: on mains power.
55    Plug,
56    /// A microphone, struck through when muted.
57    Mic { muted: bool },
58    /// A loudspeaker box: built-in speakers.
59    Speaker,
60    /// Headphones, or a headset.
61    Headphones,
62    /// A screen: a monitor or TV that plays sound.
63    Display,
64    /// A bell: notifications, struck through when they're silenced.
65    Bell { muted: bool },
66    /// Three level bars, like an equalizer: media. Uneven while `playing`, low and level when
67    /// not.
68    Media { playing: bool },
69    /// A triangle pointing right: play.
70    Play,
71    /// Two upright bars: pause.
72    Pause,
73    /// A triangle pointing left against a bar: back to the previous track.
74    Previous,
75    /// A triangle pointing right against a bar: on to the next track.
76    Next,
77}
78
79/// A piece of UI. Build it with the functions in this module.
80pub struct Node<M>(Kind<M>);
81
82#[doc(hidden)]
83pub enum Kind<M> {
84    Label {
85        text: String,
86        tone: Option<Tone>,
87    },
88    Heading {
89        text: String,
90        tone: Option<Tone>,
91    },
92    Icon {
93        icon: Icon,
94        tone: Option<Tone>,
95    },
96    AppIcon {
97        app_id: String,
98        tone: Option<Tone>,
99    },
100    Image {
101        file: &'static [u8],
102        tint: bool,
103        tone: Option<Tone>,
104    },
105    Cover {
106        url: String,
107        /// Shown until the picture is loaded, and instead of it if it can't be.
108        fallback: Box<Node<M>>,
109        /// The main picture of a popover, not an icon.
110        large: bool,
111        tone: Option<Tone>,
112    },
113    Counter {
114        value: i64,
115        range: RangeInclusive<i64>,
116        unit: String,
117        tone: Option<Tone>,
118    },
119    Alternate {
120        options: Vec<Node<M>>,
121        current: usize,
122        /// Shown instead of `current` while the pointer is over the item.
123        hovered: Option<usize>,
124        /// As wide as the option on screen, hovered by its own width, after a rest ([`expand`]).
125        grows: bool,
126    },
127    Row(Vec<Node<M>>),
128    Column(Vec<Node<M>>),
129    Grid {
130        columns: usize,
131        cells: Vec<Node<M>>,
132    },
133    Button {
134        content: Box<Node<M>>,
135        on_press: M,
136        on_secondary: Option<M>,
137        /// The main action of a popover page: filled, so it stands out.
138        prominent: bool,
139    },
140    Switcher {
141        options: Vec<(Node<M>, M)>,
142        current: Option<usize>,
143    },
144    Gulp(Box<Node<M>>),
145    /// Centered across the panel's width.
146    Center(Box<Node<M>>),
147    Separator,
148    Empty,
149    ListItem {
150        title: String,
151        subtitle: Option<String>,
152        leading: Option<Box<Node<M>>>,
153        trailing: Option<Box<Node<M>>>,
154        selected: bool,
155        on_press: Option<M>,
156        on_secondary: Option<M>,
157    },
158    List(Vec<Node<M>>),
159    Section {
160        title: String,
161        trailing: Option<Box<Node<M>>>,
162    },
163    Header {
164        title: String,
165        on_back: Option<M>,
166        trailing: Option<Box<Node<M>>>,
167    },
168    Detail {
169        name: String,
170        value: Box<Node<M>>,
171    },
172    Fill,
173    Slider {
174        value: f32,
175        range: RangeInclusive<f32>,
176        step: f32,
177        on_change: Box<dyn Fn(f32) -> M>,
178        on_release: Option<M>,
179    },
180    Toggle {
181        on: bool,
182        on_toggle: M,
183    },
184    Meter {
185        fraction: f32,
186        /// Where a limit sits along it, like a battery's charge limit.
187        limit: Option<f32>,
188        tone: Option<Tone>,
189    },
190    TextInput {
191        placeholder: String,
192        value: String,
193        on_input: Box<dyn Fn(String) -> M>,
194        on_submit: Option<M>,
195        secret: bool,
196        trailing: Option<Box<Node<M>>>,
197        tone: Option<Tone>,
198    },
199    Display(Box<Node<M>>),
200    Mark(Box<Node<M>>),
201    Badge {
202        text: String,
203        tone: Option<Tone>,
204    },
205    /// Pages and their names. The host shows one at a time and owns which.
206    Tabs(Vec<(String, Node<M>)>),
207    Markdown {
208        text: String,
209        tone: Option<Tone>,
210    },
211    TextArea {
212        placeholder: String,
213        value: String,
214        on_input: Box<dyn Fn(String) -> M>,
215        on_submit: Option<M>,
216        trailing: Option<Box<Node<M>>>,
217    },
218    Scroll {
219        content: Box<Node<M>>,
220        /// Keeps the end in view as content arrives there, like a chat.
221        bottom: bool,
222    },
223}
224
225pub fn label<M>(text: impl Into<String>) -> Node<M> {
226    Node(Kind::Label {
227        text: text.into(),
228        tone: None,
229    })
230}
231
232/// A title, larger than a label. Meant for popovers.
233pub fn heading<M>(text: impl Into<String>) -> Node<M> {
234    Node(Kind::Heading {
235        text: text.into(),
236        tone: None,
237    })
238}
239
240/// A symbol, sized like a label.
241pub fn icon<M>(icon: Icon) -> Node<M> {
242    Node(Kind::Icon { icon, tone: None })
243}
244
245/// An application's icon, found by its app id or window class (`firefox`, `org.gnome.Nautilus`)
246/// in the user's icon theme. Sized like an [`icon`], in the app's own colors: a tone only dims it.
247/// Until the host has found it, or if there is none, it shows the id's first letter.
248pub fn app_icon<M>(app_id: impl Into<String>) -> Node<M> {
249    Node(Kind::AppIcon {
250        app_id: app_id.into(),
251        tone: None,
252    })
253}
254
255/// An icon the plugin ships: an SVG or PNG file compiled into it with `include_bytes!`, told
256/// apart by its first bytes. Sized like an [`icon`], in the file's own colors: a tone only dims
257/// it, as with [`app_icon`]. The host draws it once, off the UI thread, and shows nothing in its
258/// place until then.
259pub fn image<M>(file: &'static [u8]) -> Node<M> {
260    Node(Kind::Image {
261        file,
262        tint: false,
263        tone: None,
264    })
265}
266
267/// Like [`image`], but only the file's shape counts: it's drawn in the text's tone, like an
268/// [`icon`]. For a monochrome symbol the vocabulary doesn't have.
269pub fn symbol<M>(file: &'static [u8]) -> Node<M> {
270    Node(Kind::Image {
271        file,
272        tint: true,
273        tone: None,
274    })
275}
276
277/// A picture a program hands over at runtime, by URL: a track's cover art. `file://` and
278/// `https://` URLs load; the host fetches, crops it square and rounds it, off the UI thread, and
279/// keeps the few latest. Drawn at icon size, or big with [`Node::large`]. Until it's there, or if
280/// it can't be loaded, `fallback` shows in its place. A tone only dims it.
281pub fn cover<M>(url: impl Into<String>, fallback: Node<M>) -> Node<M> {
282    Node(Kind::Cover {
283        url: url.into(),
284        fallback: Box::new(fallback),
285        large: false,
286        tone: None,
287    })
288}
289
290/// A number from `range`, with a unit after it (`"%"`, `"°"`, or `""`), shown like a label. It
291/// keeps room for the widest number in `range`, so it doesn't change width as it changes. When
292/// `value` changes, the host counts to it through every number in between.
293pub fn counter<M>(value: i64, range: RangeInclusive<i64>, unit: impl Into<String>) -> Node<M> {
294    Node(Kind::Counter {
295        value,
296        range,
297        unit: unit.into(),
298        tone: None,
299    })
300}
301
302/// One of `options` at a time: the one at `current`, or the last one if it's past the end. It's as
303/// big as the biggest option, so it doesn't change size when `current` does. The host fades from
304/// one to the other. Keep the options the same from one
305/// view to the next, so the host can tell which one is coming in.
306pub fn alternate<M>(options: impl IntoIterator<Item = Node<M>>, current: usize) -> Node<M> {
307    Node(Kind::Alternate {
308        options: options.into_iter().collect(),
309        current,
310        hovered: None,
311        grows: false,
312    })
313}
314
315/// `normal`, or `hovered` while the pointer is over the item, fading between them like an
316/// [`alternate`]: an icon that shows its percent, a time that shows the date.
317pub fn hover<M>(normal: Node<M>, hovered: Node<M>) -> Node<M> {
318    alternate([normal, hovered], 0).on_hover(1)
319}
320
321/// `collapsed`, or `expanded` once the pointer rests on this node, fading between them like a
322/// [`hover`]. Unlike `hover`, it's only as big as what it shows, so the item grows to make room
323/// and the island springs along, and it reacts to the pointer over it, not the whole item: a
324/// workspace that shows every window when you point at it.
325pub fn expand<M>(collapsed: Node<M>, expanded: Node<M>) -> Node<M> {
326    Node(Kind::Alternate {
327        options: vec![collapsed, expanded],
328        current: 0,
329        hovered: Some(1),
330        grows: true,
331    })
332}
333
334/// Children side by side, vertically centered.
335pub fn row<M>(children: impl IntoIterator<Item = Node<M>>) -> Node<M> {
336    Node(Kind::Row(children.into_iter().collect()))
337}
338
339/// Children stacked top to bottom.
340pub fn column<M>(children: impl IntoIterator<Item = Node<M>>) -> Node<M> {
341    Node(Kind::Column(children.into_iter().collect()))
342}
343
344/// `content` centered across the panel's width: a cover, a title, a row of controls. Text in it
345/// stays on one line, cut and faded at the edges of its room if it's too long. In the bar, it's
346/// just `content`.
347pub fn center<M>(content: Node<M>) -> Node<M> {
348    Node(Kind::Center(Box::new(content)))
349}
350
351/// Cells laid out left to right in rows of `columns`, all the same width. A calendar, a keypad.
352pub fn grid<M>(columns: usize, cells: impl IntoIterator<Item = Node<M>>) -> Node<M> {
353    Node(Kind::Grid {
354        columns: columns.max(1),
355        cells: cells.into_iter().collect(),
356    })
357}
358
359pub fn button<M>(content: Node<M>, on_press: M) -> Node<M> {
360    Node(Kind::Button {
361        content: Box::new(content),
362        on_press,
363        on_secondary: None,
364        prominent: false,
365    })
366}
367
368/// A row of choices, one of them current: workspaces, output devices. Each option sends its
369/// message when clicked. The host marks the current option and moves the mark smoothly when
370/// `current` changes.
371pub fn switcher<M>(
372    options: impl IntoIterator<Item = (Node<M>, M)>,
373    current: Option<usize>,
374) -> Node<M> {
375    Node(Kind::Switcher {
376        options: options.into_iter().collect(),
377        current,
378    })
379}
380
381/// Keeps gulping, like a bottle being poured: the host swells `content` gently and steadily for as
382/// long as it's on screen. For something filling up, like a charging battery's percent.
383pub fn gulp<M>(content: Node<M>) -> Node<M> {
384    Node(Kind::Gulp(Box::new(content)))
385}
386
387/// A thin horizontal line between groups.
388pub fn separator<M>() -> Node<M> {
389    Node(Kind::Separator)
390}
391
392/// Takes up a grid cell, or nothing elsewhere.
393pub fn empty<M>() -> Node<M> {
394    Node(Kind::Empty)
395}
396
397/// A row in a popover list: a network, a device, a window. Give it a [`subtitle`](Node::subtitle),
398/// a [`leading`](Node::leading) icon, a [`trailing`](Node::trailing) node at the far end (a lock,
399/// an info button), mark the one in use with [`selected`](Node::selected), and make it clickable
400/// with [`on_press`](Node::on_press). A long title fades out at the end rather than wrapping.
401pub fn list_item<M>(title: impl Into<String>) -> Node<M> {
402    Node(Kind::ListItem {
403        title: title.into(),
404        subtitle: None,
405        leading: None,
406        trailing: None,
407        selected: false,
408        on_press: None,
409        on_secondary: None,
410    })
411}
412
413/// Rows stacked closely, usually [`list_item`]s. In a popover, a long list scrolls instead of
414/// growing past a few rows.
415pub fn list<M>(rows: impl IntoIterator<Item = Node<M>>) -> Node<M> {
416    Node(Kind::List(rows.into_iter().collect()))
417}
418
419/// A small caption over a group in a popover ("Output", "Health"), with an optional
420/// [`trailing`](Node::trailing) node at the far end.
421pub fn section<M>(title: impl Into<String>) -> Node<M> {
422    Node(Kind::Section {
423        title: title.into(),
424        trailing: None,
425    })
426}
427
428/// The title of a popover page. Sub-pages get a back button with [`on_back`](Node::on_back); a
429/// [`trailing`](Node::trailing) node sits at the far end (a switch, a count).
430pub fn header<M>(title: impl Into<String>) -> Node<M> {
431    Node(Kind::Header {
432        title: title.into(),
433        on_back: None,
434        trailing: None,
435    })
436}
437
438/// A named value on one line: the name dimmed at the start, the value at the end.
439pub fn detail<M>(name: impl Into<String>, value: Node<M>) -> Node<M> {
440    Node(Kind::Detail {
441        name: name.into(),
442        value: Box::new(value),
443    })
444}
445
446/// Takes the free space in a popover row, pushing what follows to the end. Nothing in the bar.
447pub fn fill<M>() -> Node<M> {
448    Node(Kind::Fill)
449}
450
451/// A value dragged along `range`: a volume. Sends `on_change` while dragging; add
452/// [`on_release`](Node::on_release) to act once it's let go, and [`step`](Node::step) to snap.
453pub fn slider<M: 'static>(
454    value: f32,
455    range: RangeInclusive<f32>,
456    on_change: impl Fn(f32) -> M + 'static,
457) -> Node<M> {
458    Node(Kind::Slider {
459        value,
460        range,
461        step: 1.0,
462        on_change: Box::new(on_change),
463        on_release: None,
464    })
465}
466
467/// An on/off switch. Sends `on_toggle` when clicked; the host slides it over.
468pub fn toggle<M>(on: bool, on_toggle: M) -> Node<M> {
469    Node(Kind::Toggle { on, on_toggle })
470}
471
472/// A bar filled to `fraction` (0.0 to 1.0), in its tone: a charge, a signal. The host eases it to
473/// a new value.
474pub fn meter<M>(fraction: f32) -> Node<M> {
475    Node(Kind::Meter {
476        fraction,
477        limit: None,
478        tone: None,
479    })
480}
481
482/// A field to type into, showing `value`. Sends `on_input` with the new text on every change.
483/// It takes the focus when it appears, and while it has it, keys stay in the popover; Esc still
484/// closes it. [`on_submit`](Node::on_submit) is sent on Enter. One per popover page. A
485/// [`Tone::Critical`] tone marks it as wrong.
486pub fn text_input<M: 'static>(
487    placeholder: impl Into<String>,
488    value: impl Into<String>,
489    on_input: impl Fn(String) -> M + 'static,
490) -> Node<M> {
491    Node(Kind::TextInput {
492        placeholder: placeholder.into(),
493        value: value.into(),
494        on_input: Box::new(on_input),
495        on_submit: None,
496        secret: false,
497        trailing: None,
498        tone: None,
499    })
500}
501
502/// A field to type several lines into, showing `value`: a message. It grows with its text up
503/// to a few lines, then scrolls. Sends `on_input` with the new text on every change, and
504/// [`on_submit`](Node::on_submit) on Enter; Shift+Enter starts a new line. In a popover it takes
505/// the focus when it appears, like a [`text_input`]. One per popover page.
506pub fn text_area<M: 'static>(
507    placeholder: impl Into<String>,
508    value: impl Into<String>,
509    on_input: impl Fn(String) -> M + 'static,
510) -> Node<M> {
511    Node(Kind::TextArea {
512        placeholder: placeholder.into(),
513        value: value.into(),
514        on_input: Box::new(on_input),
515        on_submit: None,
516        trailing: None,
517    })
518}
519
520/// Text written in Markdown, as people and programs write messages: paragraphs, emphasis,
521/// `code`, lists, quotes, headings, code blocks and tables. The host sets it in its own type and
522/// colors, wrapped to the width it has. Links show as links but don't open: the bar starts no
523/// programs.
524pub fn markdown<M>(text: impl Into<String>) -> Node<M> {
525    Node(Kind::Markdown {
526        text: text.into(),
527        tone: None,
528    })
529}
530
531/// Its content, scrolling in a popover once it's taller than the host allows a page to grow: a
532/// conversation, a long document. With [`Node::bottom`] it keeps its end in view. In the bar it's
533/// just its content.
534pub fn scroll<M>(content: Node<M>) -> Node<M> {
535    Node(Kind::Scroll {
536        content: Box::new(content),
537        bottom: false,
538    })
539}
540
541/// Its text, large: the one number a popover is about, like a battery's charge.
542pub fn display<M>(content: Node<M>) -> Node<M> {
543    Node(Kind::Display(Box::new(content)))
544}
545
546/// Its content marked with a ring: today in a calendar.
547pub fn mark<M>(content: Node<M>) -> Node<M> {
548    Node(Kind::Mark(Box::new(content)))
549}
550
551/// A short code in a small outlined box: a keyboard layout's `US`.
552pub fn badge<M>(text: impl Into<String>) -> Node<M> {
553    Node(Kind::Badge {
554        text: text.into(),
555        tone: None,
556    })
557}
558
559/// Pages behind a row of tabs, one page showing at a time: a popover's Output, Input and Apps.
560/// The host draws the tabs, switches pages when one is clicked and starts on the first each time
561/// the popover opens, so the plugin keeps no state for it. In the bar, only the first page shows.
562pub fn tabs<M>(pages: impl IntoIterator<Item = (impl Into<String>, Node<M>)>) -> Node<M> {
563    Node(Kind::Tabs(
564        pages
565            .into_iter()
566            .map(|(name, page)| (name.into(), page))
567            .collect(),
568    ))
569}
570
571impl<M> Node<M> {
572    /// Sets the tone of this text, or of every text inside this node that has none yet.
573    pub fn tone(mut self, tone: Tone) -> Self {
574        self.set_tone(tone);
575        self
576    }
577
578    /// For an [`alternate`]: shows the option at `index` while the pointer is over the item,
579    /// whatever `current` is. Other nodes have nothing to show on hover and ignore it.
580    pub fn on_hover(mut self, index: usize) -> Self {
581        if let Kind::Alternate { hovered, .. } = &mut self.0 {
582            *hovered = Some(index);
583        }
584        self
585    }
586
587    /// For a [`button`] or [`list_item`]: sent on a secondary (right) click on it. Other nodes
588    /// ignore it.
589    pub fn on_secondary(mut self, message: M) -> Self {
590        if let Kind::Button { on_secondary, .. } | Kind::ListItem { on_secondary, .. } = &mut self.0
591        {
592            *on_secondary = Some(message);
593        }
594        self
595    }
596
597    /// For a [`button`]: the main action of its page, filled so it stands out. Other nodes ignore
598    /// it.
599    pub fn prominent(mut self) -> Self {
600        if let Kind::Button { prominent, .. } = &mut self.0 {
601            *prominent = true;
602        }
603        self
604    }
605
606    /// For a [`list_item`]: sent on a click on it. Other nodes ignore it.
607    pub fn on_press(mut self, message: M) -> Self {
608        if let Kind::ListItem { on_press, .. } = &mut self.0 {
609            *on_press = Some(message);
610        }
611        self
612    }
613
614    /// For a [`list_item`]: a second, dimmer line under the title. Other nodes ignore it.
615    pub fn subtitle(mut self, text: impl Into<String>) -> Self {
616        if let Kind::ListItem { subtitle, .. } = &mut self.0 {
617            *subtitle = Some(text.into());
618        }
619        self
620    }
621
622    /// For a [`list_item`]: an icon before the title. Other nodes ignore it.
623    pub fn leading(mut self, node: Node<M>) -> Self {
624        if let Kind::ListItem { leading, .. } = &mut self.0 {
625            *leading = Some(Box::new(node));
626        }
627        self
628    }
629
630    /// For a [`list_item`], [`section`], [`header`], [`text_input`] or [`text_area`]: a node at the
631    /// far end, like a lock or an info button. Other nodes ignore it.
632    pub fn trailing(mut self, node: Node<M>) -> Self {
633        if let Kind::ListItem { trailing, .. }
634        | Kind::Section { trailing, .. }
635        | Kind::Header { trailing, .. }
636        | Kind::TextInput { trailing, .. }
637        | Kind::TextArea { trailing, .. } = &mut self.0
638        {
639            *trailing = Some(Box::new(node));
640        }
641        self
642    }
643
644    /// For a [`list_item`]: marks it as the one in use. Other nodes ignore it.
645    pub fn selected(mut self, yes: bool) -> Self {
646        if let Kind::ListItem { selected, .. } = &mut self.0 {
647            *selected = yes;
648        }
649        self
650    }
651
652    /// For a [`header`]: shows a back button that sends `message`. Other nodes ignore it.
653    pub fn on_back(mut self, message: M) -> Self {
654        if let Kind::Header { on_back, .. } = &mut self.0 {
655            *on_back = Some(message);
656        }
657        self
658    }
659
660    /// For a [`slider`]: the step its value snaps to (1 by default). Other nodes ignore it.
661    pub fn step(mut self, by: f32) -> Self {
662        if let Kind::Slider { step, .. } = &mut self.0 {
663            *step = by;
664        }
665        self
666    }
667
668    /// For a [`slider`]: sent when it's let go. Other nodes ignore it.
669    pub fn on_release(mut self, message: M) -> Self {
670        if let Kind::Slider { on_release, .. } = &mut self.0 {
671            *on_release = Some(message);
672        }
673        self
674    }
675
676    /// For a [`text_input`] or [`text_area`]: sent on Enter. Other nodes ignore it.
677    pub fn on_submit(mut self, message: M) -> Self {
678        if let Kind::TextInput { on_submit, .. } | Kind::TextArea { on_submit, .. } = &mut self.0 {
679            *on_submit = Some(message);
680        }
681        self
682    }
683
684    /// For a [`text_input`]: hides what's typed, like a password. Other nodes ignore it.
685    pub fn secret(mut self, yes: bool) -> Self {
686        if let Kind::TextInput { secret, .. } = &mut self.0 {
687            *secret = yes;
688        }
689        self
690    }
691
692    /// For a [`cover`]: drawn big, as the main picture of a popover, not as an icon. Other nodes
693    /// ignore it.
694    pub fn large(mut self) -> Self {
695        if let Kind::Cover { large, .. } = &mut self.0 {
696            *large = true;
697        }
698        self
699    }
700
701    /// For a [`scroll`]: keeps its end in view as content arrives there, unless you've scrolled
702    /// up to read. Other nodes ignore it.
703    pub fn bottom(mut self) -> Self {
704        if let Kind::Scroll { bottom, .. } = &mut self.0 {
705            *bottom = true;
706        }
707        self
708    }
709
710    /// For a [`meter`]: marks a limit along it (0.0 to 1.0). Other nodes ignore it.
711    pub fn limit(mut self, at: f32) -> Self {
712        if let Kind::Meter { limit, .. } = &mut self.0 {
713            *limit = Some(at);
714        }
715        self
716    }
717
718    fn set_tone(&mut self, new: Tone) {
719        match &mut self.0 {
720            Kind::Label { tone, .. }
721            | Kind::Heading { tone, .. }
722            | Kind::Icon { tone, .. }
723            | Kind::AppIcon { tone, .. }
724            | Kind::Image { tone, .. }
725            | Kind::Counter { tone, .. }
726            | Kind::Meter { tone, .. }
727            | Kind::TextInput { tone, .. }
728            | Kind::Markdown { tone, .. }
729            | Kind::Badge { tone, .. } => {
730                tone.get_or_insert(new);
731            }
732            Kind::Cover { tone, fallback, .. } => {
733                tone.get_or_insert(new);
734                fallback.set_tone(new);
735            }
736            Kind::Alternate {
737                options: children, ..
738            }
739            | Kind::Row(children)
740            | Kind::Column(children)
741            | Kind::Grid {
742                cells: children, ..
743            }
744            | Kind::List(children) => {
745                children.iter_mut().for_each(|child| child.set_tone(new));
746            }
747            Kind::Button { content, .. }
748            | Kind::Gulp(content)
749            | Kind::Center(content)
750            | Kind::Display(content)
751            | Kind::Mark(content)
752            | Kind::Scroll { content, .. }
753            | Kind::Detail { value: content, .. } => content.set_tone(new),
754            Kind::ListItem {
755                leading, trailing, ..
756            } => {
757                leading
758                    .iter_mut()
759                    .chain(trailing.iter_mut())
760                    .for_each(|child| child.set_tone(new));
761            }
762            Kind::Tabs(pages) => {
763                for (_, page) in pages {
764                    page.set_tone(new);
765                }
766            }
767            Kind::Switcher { options, .. } => {
768                options
769                    .iter_mut()
770                    .for_each(|(child, _)| child.set_tone(new));
771            }
772            Kind::Separator
773            | Kind::Empty
774            | Kind::Section { .. }
775            | Kind::Header { .. }
776            | Kind::Fill
777            | Kind::Slider { .. }
778            | Kind::TextArea { .. }
779            | Kind::Toggle { .. } => {}
780        }
781    }
782
783    /// Converts every message in this node. Sliders and text fields keep `f` to convert the
784    /// messages they make later, so it must be `Clone + 'static`, like a function or a closure
785    /// that captures nothing.
786    pub fn map<N: 'static>(self, f: &(impl Fn(M) -> N + Clone + 'static)) -> Node<N>
787    where
788        M: 'static,
789    {
790        let map_all = |nodes: Vec<Node<M>>| nodes.into_iter().map(|node| node.map(f)).collect();
791        let map_box = |node: Option<Box<Node<M>>>| node.map(|node| Box::new(node.map(f)));
792
793        Node(match self.0 {
794            Kind::Label { text, tone } => Kind::Label { text, tone },
795            Kind::Heading { text, tone } => Kind::Heading { text, tone },
796            Kind::Icon { icon, tone } => Kind::Icon { icon, tone },
797            Kind::AppIcon { app_id, tone } => Kind::AppIcon { app_id, tone },
798            Kind::Image { file, tint, tone } => Kind::Image { file, tint, tone },
799            Kind::Cover {
800                url,
801                fallback,
802                large,
803                tone,
804            } => Kind::Cover {
805                url,
806                fallback: Box::new(fallback.map(f)),
807                large,
808                tone,
809            },
810            Kind::Counter {
811                value,
812                range,
813                unit,
814                tone,
815            } => Kind::Counter {
816                value,
817                range,
818                unit,
819                tone,
820            },
821            Kind::Alternate {
822                options,
823                current,
824                hovered,
825                grows,
826            } => Kind::Alternate {
827                options: map_all(options),
828                current,
829                hovered,
830                grows,
831            },
832            Kind::Row(children) => Kind::Row(map_all(children)),
833            Kind::Column(children) => Kind::Column(map_all(children)),
834            Kind::Grid { columns, cells } => Kind::Grid {
835                columns,
836                cells: map_all(cells),
837            },
838            Kind::Button {
839                content,
840                on_press,
841                on_secondary,
842                prominent,
843            } => Kind::Button {
844                content: Box::new(content.map(f)),
845                on_press: f(on_press),
846                on_secondary: on_secondary.map(f),
847                prominent,
848            },
849            Kind::Switcher { options, current } => Kind::Switcher {
850                options: options
851                    .into_iter()
852                    .map(|(node, message)| (node.map(f), f(message)))
853                    .collect(),
854                current,
855            },
856            Kind::Gulp(content) => Kind::Gulp(Box::new(content.map(f))),
857            Kind::Center(content) => Kind::Center(Box::new(content.map(f))),
858            Kind::Separator => Kind::Separator,
859            Kind::Empty => Kind::Empty,
860            Kind::ListItem {
861                title,
862                subtitle,
863                leading,
864                trailing,
865                selected,
866                on_press,
867                on_secondary,
868            } => Kind::ListItem {
869                title,
870                subtitle,
871                leading: map_box(leading),
872                trailing: map_box(trailing),
873                selected,
874                on_press: on_press.map(f),
875                on_secondary: on_secondary.map(f),
876            },
877            Kind::List(rows) => Kind::List(map_all(rows)),
878            Kind::Section { title, trailing } => Kind::Section {
879                title,
880                trailing: map_box(trailing),
881            },
882            Kind::Header {
883                title,
884                on_back,
885                trailing,
886            } => Kind::Header {
887                title,
888                on_back: on_back.map(f),
889                trailing: map_box(trailing),
890            },
891            Kind::Detail { name, value } => Kind::Detail {
892                name,
893                value: Box::new(value.map(f)),
894            },
895            Kind::Fill => Kind::Fill,
896            Kind::Slider {
897                value,
898                range,
899                step,
900                on_change,
901                on_release,
902            } => {
903                let g = f.clone();
904                Kind::Slider {
905                    value,
906                    range,
907                    step,
908                    on_change: Box::new(move |value| g(on_change(value))),
909                    on_release: on_release.map(f),
910                }
911            }
912            Kind::Toggle { on, on_toggle } => Kind::Toggle {
913                on,
914                on_toggle: f(on_toggle),
915            },
916            Kind::Meter {
917                fraction,
918                limit,
919                tone,
920            } => Kind::Meter {
921                fraction,
922                limit,
923                tone,
924            },
925            Kind::TextInput {
926                placeholder,
927                value,
928                on_input,
929                on_submit,
930                secret,
931                trailing,
932                tone,
933            } => {
934                let g = f.clone();
935                Kind::TextInput {
936                    placeholder,
937                    value,
938                    on_input: Box::new(move |text| g(on_input(text))),
939                    on_submit: on_submit.map(f),
940                    secret,
941                    trailing: map_box(trailing),
942                    tone,
943                }
944            }
945            Kind::Display(content) => Kind::Display(Box::new(content.map(f))),
946            Kind::Mark(content) => Kind::Mark(Box::new(content.map(f))),
947            Kind::Badge { text, tone } => Kind::Badge { text, tone },
948            Kind::Tabs(pages) => Kind::Tabs(
949                pages
950                    .into_iter()
951                    .map(|(name, page)| (name, page.map(f)))
952                    .collect(),
953            ),
954            Kind::Markdown { text, tone } => Kind::Markdown { text, tone },
955            Kind::TextArea {
956                placeholder,
957                value,
958                on_input,
959                on_submit,
960                trailing,
961            } => {
962                let g = f.clone();
963                Kind::TextArea {
964                    placeholder,
965                    value,
966                    on_input: Box::new(move |text| g(on_input(text))),
967                    on_submit: on_submit.map(f),
968                    trailing: map_box(trailing),
969                }
970            }
971            Kind::Scroll { content, bottom } => Kind::Scroll {
972                content: Box::new(content.map(f)),
973                bottom,
974            },
975        })
976    }
977
978    #[doc(hidden)]
979    pub fn into_kind(self) -> Kind<M> {
980        self.0
981    }
982
983    #[doc(hidden)]
984    pub fn kind(&self) -> &Kind<M> {
985        &self.0
986    }
987}
988
989/// What a widget shows in the bar.
990pub struct Item<M> {
991    content: Option<Node<M>>,
992    on_press: Option<M>,
993    on_secondary: Option<M>,
994    on_scroll: Option<(M, M)>,
995}
996
997impl<M> Item<M> {
998    pub fn new(content: Node<M>) -> Self {
999        Self {
1000            content: Some(content),
1001            on_press: None,
1002            on_secondary: None,
1003            on_scroll: None,
1004        }
1005    }
1006
1007    /// Takes no space in the bar. For widgets with nothing to show, like a battery on a desktop.
1008    pub fn hidden() -> Self {
1009        Self {
1010            content: None,
1011            on_press: None,
1012            on_secondary: None,
1013            on_scroll: None,
1014        }
1015    }
1016
1017    /// Sent on a primary click. Replaces the default of toggling the popover.
1018    pub fn on_press(mut self, message: M) -> Self {
1019        self.on_press = Some(message);
1020        self
1021    }
1022
1023    /// Sent on a secondary (right) click.
1024    pub fn on_secondary(mut self, message: M) -> Self {
1025        self.on_secondary = Some(message);
1026        self
1027    }
1028
1029    /// Sent once per scroll step over the item, `up` or `down`. The host turns smooth touchpad
1030    /// scrolling into steps.
1031    pub fn on_scroll(mut self, up: M, down: M) -> Self {
1032        self.on_scroll = Some((up, down));
1033        self
1034    }
1035
1036    pub fn map<N: 'static>(self, f: &(impl Fn(M) -> N + Clone + 'static)) -> Item<N>
1037    where
1038        M: 'static,
1039    {
1040        Item {
1041            content: self.content.map(|content| content.map(f)),
1042            on_press: self.on_press.map(f),
1043            on_secondary: self.on_secondary.map(f),
1044            on_scroll: self.on_scroll.map(|(up, down)| (f(up), f(down))),
1045        }
1046    }
1047
1048    #[doc(hidden)]
1049    pub fn into_parts(self) -> ItemParts<M> {
1050        ItemParts {
1051            content: self.content,
1052            on_press: self.on_press,
1053            on_secondary: self.on_secondary,
1054            on_scroll: self.on_scroll,
1055        }
1056    }
1057}
1058
1059#[doc(hidden)]
1060pub struct ItemParts<M> {
1061    pub content: Option<Node<M>>,
1062    pub on_press: Option<M>,
1063    pub on_secondary: Option<M>,
1064    /// Up, then down.
1065    pub on_scroll: Option<(M, M)>,
1066}
1067
1068/// The contents of a popover. The host draws the surface, its background and its edges, and lays
1069/// every popover out at the same width, or at a wider one if it asks ([`Panel::wide`]).
1070pub struct Panel<M> {
1071    content: Node<M>,
1072    on_open: Option<M>,
1073    on_close: Option<M>,
1074    wide: bool,
1075}
1076
1077impl<M> Panel<M> {
1078    pub fn new(content: Node<M>) -> Self {
1079        Self {
1080            content,
1081            on_open: None,
1082            on_close: None,
1083            wide: false,
1084        }
1085    }
1086
1087    /// Laid out wider than other popovers, for a page that's mostly reading: a conversation, a
1088    /// document. The host decides how wide.
1089    pub fn wide(mut self) -> Self {
1090        self.wide = true;
1091        self
1092    }
1093
1094    #[doc(hidden)]
1095    pub fn is_wide(&self) -> bool {
1096        self.wide
1097    }
1098
1099    /// Sent each time the popover opens: start a scan, show the first page.
1100    pub fn on_open(mut self, message: M) -> Self {
1101        self.on_open = Some(message);
1102        self
1103    }
1104
1105    /// Sent each time the popover starts closing: stop listening, forget what was typed.
1106    pub fn on_close(mut self, message: M) -> Self {
1107        self.on_close = Some(message);
1108        self
1109    }
1110
1111    pub fn map<N: 'static>(self, f: &(impl Fn(M) -> N + Clone + 'static)) -> Panel<N>
1112    where
1113        M: 'static,
1114    {
1115        Panel {
1116            content: self.content.map(f),
1117            on_open: self.on_open.map(f),
1118            on_close: self.on_close.map(f),
1119            wide: self.wide,
1120        }
1121    }
1122
1123    #[doc(hidden)]
1124    pub fn into_node(self) -> Node<M> {
1125        self.content
1126    }
1127
1128    #[doc(hidden)]
1129    pub fn on_open_message(self) -> Option<M> {
1130        self.on_open
1131    }
1132
1133    #[doc(hidden)]
1134    pub fn on_close_message(self) -> Option<M> {
1135        self.on_close
1136    }
1137}
1138
1139/// How much a [`Notice`] matters. The host decides what that looks like.
1140#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1141pub enum Urgency {
1142    /// Background news: a download finished.
1143    Low,
1144    #[default]
1145    Normal,
1146    /// Needs attention now: a battery about to die.
1147    Critical,
1148}
1149
1150/// A notification to show on screen, from [`Widget::notices`](crate::Widget::notices).
1151///
1152/// It says what the notification is, not how it looks: the host lays out the notification, places it and
1153/// animates it in and out. Its `key` is how the host tells notices apart from one view to the
1154/// next: a notice that keeps its key and changes its text is updated in place, and one whose key
1155/// is gone from the list leaves the screen.
1156pub struct Notice<M> {
1157    key: u64,
1158    summary: String,
1159    app: Option<String>,
1160    app_icon: Option<String>,
1161    image: Option<String>,
1162    body: Option<String>,
1163    markdown: bool,
1164    urgency: Urgency,
1165    actions: Vec<(String, M)>,
1166    on_press: Option<M>,
1167    on_dismiss: Option<M>,
1168    expires: Option<(std::time::Duration, M)>,
1169    reply: Option<Reply<M>>,
1170}
1171
1172/// A field on a [`Notice`] to type an answer into, like a reply to a message: several lines,
1173/// Enter sends and Shift+Enter starts a new line.
1174///
1175/// It shows with the notice, but the host takes the keyboard only once it's clicked, and gives it
1176/// back on Esc, on sending, or on a click elsewhere. The notice doesn't expire while it has the
1177/// keyboard.
1178pub struct Reply<M> {
1179    placeholder: String,
1180    value: String,
1181    on_input: std::rc::Rc<dyn Fn(String) -> M>,
1182    on_submit: Option<M>,
1183    on_cancel: Option<M>,
1184}
1185
1186impl<M> Reply<M> {
1187    /// Shows `value`, and sends `on_input` with the new text on every change.
1188    pub fn new(
1189        placeholder: impl Into<String>,
1190        value: impl Into<String>,
1191        on_input: impl Fn(String) -> M + 'static,
1192    ) -> Self {
1193        Self {
1194            placeholder: placeholder.into(),
1195            value: value.into(),
1196            on_input: std::rc::Rc::new(on_input),
1197            on_submit: None,
1198            on_cancel: None,
1199        }
1200    }
1201
1202    /// Sent on Enter, or a click on the field's send button.
1203    pub fn on_submit(mut self, message: M) -> Self {
1204        self.on_submit = Some(message);
1205        self
1206    }
1207
1208    /// Sent on Esc.
1209    pub fn on_cancel(mut self, message: M) -> Self {
1210        self.on_cancel = Some(message);
1211        self
1212    }
1213
1214    pub fn map<N: 'static>(self, f: &(impl Fn(M) -> N + Clone + 'static)) -> Reply<N>
1215    where
1216        M: 'static,
1217    {
1218        let g = f.clone();
1219        let on_input = self.on_input;
1220        Reply {
1221            placeholder: self.placeholder,
1222            value: self.value,
1223            on_input: std::rc::Rc::new(move |text| g(on_input(text))),
1224            on_submit: self.on_submit.map(f),
1225            on_cancel: self.on_cancel.map(f),
1226        }
1227    }
1228
1229    #[doc(hidden)]
1230    pub fn into_parts(self) -> ReplyParts<M> {
1231        ReplyParts {
1232            placeholder: self.placeholder,
1233            value: self.value,
1234            on_input: self.on_input,
1235            on_submit: self.on_submit,
1236            on_cancel: self.on_cancel,
1237        }
1238    }
1239}
1240
1241#[doc(hidden)]
1242#[derive(Clone)]
1243pub struct ReplyParts<M> {
1244    pub placeholder: String,
1245    pub value: String,
1246    pub on_input: std::rc::Rc<dyn Fn(String) -> M>,
1247    pub on_submit: Option<M>,
1248    pub on_cancel: Option<M>,
1249}
1250
1251impl<M> Notice<M> {
1252    pub fn new(key: u64, summary: impl Into<String>) -> Self {
1253        Self {
1254            key,
1255            summary: summary.into(),
1256            app: None,
1257            app_icon: None,
1258            image: None,
1259            body: None,
1260            markdown: false,
1261            urgency: Urgency::Normal,
1262            actions: Vec::new(),
1263            on_press: None,
1264            on_dismiss: None,
1265            expires: None,
1266            reply: None,
1267        }
1268    }
1269
1270    /// The name of the app it comes from.
1271    pub fn app(mut self, name: impl Into<String>) -> Self {
1272        self.app = Some(name.into());
1273        self
1274    }
1275
1276    /// The app's icon, by app id, like [`app_icon`].
1277    pub fn app_icon(mut self, app_id: impl Into<String>) -> Self {
1278        self.app_icon = Some(app_id.into());
1279        self
1280    }
1281
1282    /// A picture to show instead of the app's icon, by URL, like a [`cover`]: an icon file the
1283    /// app gave as a path.
1284    pub fn image(mut self, url: impl Into<String>) -> Self {
1285        self.image = Some(url.into());
1286        self
1287    }
1288
1289    /// Plain text under the summary. The host wraps it and cuts it off after a few lines.
1290    pub fn body(mut self, text: impl Into<String>) -> Self {
1291        self.body = Some(text.into());
1292        self
1293    }
1294
1295    /// The body is Markdown, like a [`markdown`] node.
1296    pub fn markdown(mut self) -> Self {
1297        self.markdown = true;
1298        self
1299    }
1300
1301    pub fn urgency(mut self, urgency: Urgency) -> Self {
1302        self.urgency = urgency;
1303        self
1304    }
1305
1306    /// A button on the notification that sends `message`. The first one is the main action.
1307    pub fn action(mut self, label: impl Into<String>, message: M) -> Self {
1308        self.actions.push((label.into(), message));
1309        self
1310    }
1311
1312    /// Sent on a click on the notification itself, outside its buttons.
1313    pub fn on_press(mut self, message: M) -> Self {
1314        self.on_press = Some(message);
1315        self
1316    }
1317
1318    /// Sent when its close button is clicked. Without it, it has none.
1319    pub fn on_dismiss(mut self, message: M) -> Self {
1320        self.on_dismiss = Some(message);
1321        self
1322    }
1323
1324    /// Sent once it has been on screen for `after`. The host doesn't count while the pointer is
1325    /// on it or it's waiting to be shown. Without it, it stays
1326    /// until it leaves the list.
1327    pub fn expires(mut self, after: std::time::Duration, message: M) -> Self {
1328        self.expires = Some((after, message));
1329        self
1330    }
1331
1332    /// A field to type an answer into ([`Reply`]).
1333    pub fn reply(mut self, reply: Reply<M>) -> Self {
1334        self.reply = Some(reply);
1335        self
1336    }
1337
1338    pub fn map<N: 'static>(self, f: &(impl Fn(M) -> N + Clone + 'static)) -> Notice<N>
1339    where
1340        M: 'static,
1341    {
1342        Notice {
1343            key: self.key,
1344            summary: self.summary,
1345            app: self.app,
1346            app_icon: self.app_icon,
1347            image: self.image,
1348            body: self.body,
1349            markdown: self.markdown,
1350            urgency: self.urgency,
1351            actions: self
1352                .actions
1353                .into_iter()
1354                .map(|(label, message)| (label, f(message)))
1355                .collect(),
1356            on_press: self.on_press.map(f),
1357            on_dismiss: self.on_dismiss.map(f),
1358            expires: self.expires.map(|(after, message)| (after, f(message))),
1359            reply: self.reply.map(|reply| reply.map(f)),
1360        }
1361    }
1362
1363    pub fn key(&self) -> u64 {
1364        self.key
1365    }
1366
1367    #[doc(hidden)]
1368    pub fn into_parts(self) -> NoticeParts<M> {
1369        NoticeParts {
1370            key: self.key,
1371            summary: self.summary,
1372            app: self.app,
1373            app_icon: self.app_icon,
1374            image: self.image,
1375            body: self.body,
1376            markdown: self.markdown,
1377            urgency: self.urgency,
1378            actions: self.actions,
1379            on_press: self.on_press,
1380            on_dismiss: self.on_dismiss,
1381            expires: self.expires,
1382            reply: self.reply.map(Reply::into_parts),
1383        }
1384    }
1385}
1386
1387#[doc(hidden)]
1388#[derive(Clone)]
1389pub struct NoticeParts<M> {
1390    pub key: u64,
1391    pub summary: String,
1392    pub app: Option<String>,
1393    pub app_icon: Option<String>,
1394    pub image: Option<String>,
1395    pub body: Option<String>,
1396    pub markdown: bool,
1397    pub urgency: Urgency,
1398    pub actions: Vec<(String, M)>,
1399    pub on_press: Option<M>,
1400    pub on_dismiss: Option<M>,
1401    pub expires: Option<(std::time::Duration, M)>,
1402    pub reply: Option<ReplyParts<M>>,
1403}
1404
1405#[cfg(test)]
1406mod tests {
1407    use super::*;
1408
1409    #[test]
1410    fn map_converts_every_message() {
1411        let node = row([
1412            button(label("a"), 1).on_secondary(4),
1413            column([button(label("b"), 2), label("c")]),
1414            switcher([(label("d"), 3)], Some(0)),
1415        ]);
1416
1417        let Kind::Row(children) = node.map(&|n: i32| n * 10).into_kind() else {
1418            panic!("row became something else");
1419        };
1420        let mut messages = Vec::new();
1421        collect(children, &mut messages);
1422        assert_eq!(messages, [10, 40, 20, 30]);
1423    }
1424
1425    fn collect(nodes: Vec<Node<i32>>, out: &mut Vec<i32>) {
1426        for node in nodes {
1427            match node.into_kind() {
1428                Kind::Button {
1429                    on_press,
1430                    on_secondary,
1431                    ..
1432                } => {
1433                    out.push(on_press);
1434                    out.extend(on_secondary);
1435                }
1436                Kind::Switcher { options, .. } => {
1437                    out.extend(options.into_iter().map(|(_, message)| message))
1438                }
1439                Kind::Row(children) | Kind::Column(children) => collect(children, out),
1440                _ => {}
1441            }
1442        }
1443    }
1444
1445    #[test]
1446    fn hover_shows_the_second_option() {
1447        let node: Node<()> = hover(label("a"), label("b"));
1448        assert!(matches!(
1449            node.into_kind(),
1450            Kind::Alternate {
1451                current: 0,
1452                hovered: Some(1),
1453                ..
1454            }
1455        ));
1456        let node: Node<()> = label("a").on_hover(1);
1457        assert!(matches!(node.into_kind(), Kind::Label { .. }));
1458    }
1459
1460    #[test]
1461    fn tone_fills_only_unset_text() {
1462        let node: Node<()> =
1463            row([label("a").tone(Tone::Accent), label("b"), icon(Icon::Wired)]).tone(Tone::Dim);
1464        let Kind::Row(children) = node.into_kind() else {
1465            unreachable!()
1466        };
1467        let tones: Vec<_> = children
1468            .into_iter()
1469            .map(|child| match child.into_kind() {
1470                Kind::Label { tone, .. } | Kind::Icon { tone, .. } => tone,
1471                _ => None,
1472            })
1473            .collect();
1474        assert_eq!(
1475            tones,
1476            [Some(Tone::Accent), Some(Tone::Dim), Some(Tone::Dim)]
1477        );
1478    }
1479
1480    #[test]
1481    fn map_reaches_new_nodes_and_their_closures() {
1482        let node = column([
1483            header("h").on_back(1).trailing(toggle(true, 2)),
1484            list([list_item("a")
1485                .on_press(3)
1486                .on_secondary(4)
1487                .trailing(button(icon(Icon::Info), 5))]),
1488            slider(0.5, 0.0..=1.0, |v| (v * 10.0) as i32).on_release(6),
1489            text_input("p", "v", |text: String| text.len() as i32).on_submit(7),
1490            scroll(text_area("p", "v", |text: String| text.len() as i32 + 1).on_submit(8)).bottom(),
1491        ]);
1492        let Kind::Column(children) = node.map(&|n: i32| n * 10).into_kind() else {
1493            panic!("column became something else");
1494        };
1495        let mut children = children.into_iter().map(Node::into_kind);
1496        let Some(Kind::Header {
1497            on_back: Some(10),
1498            trailing: Some(trailing),
1499            ..
1500        }) = children.next()
1501        else {
1502            panic!("header lost its back message");
1503        };
1504        assert!(matches!(
1505            trailing.into_kind(),
1506            Kind::Toggle { on_toggle: 20, .. }
1507        ));
1508        let Some(Kind::List(rows)) = children.next() else {
1509            panic!("list lost");
1510        };
1511        let Some(Kind::ListItem {
1512            on_press: Some(30),
1513            on_secondary: Some(40),
1514            trailing: Some(trailing),
1515            ..
1516        }) = rows.into_iter().next().map(Node::into_kind)
1517        else {
1518            panic!("list item lost its messages");
1519        };
1520        assert!(matches!(
1521            trailing.into_kind(),
1522            Kind::Button { on_press: 50, .. }
1523        ));
1524        let Some(Kind::Slider {
1525            on_change,
1526            on_release: Some(60),
1527            ..
1528        }) = children.next()
1529        else {
1530            panic!("slider lost");
1531        };
1532        assert_eq!(on_change(0.3), 30);
1533        let Some(Kind::TextInput {
1534            on_input,
1535            on_submit: Some(70),
1536            ..
1537        }) = children.next()
1538        else {
1539            panic!("text input lost");
1540        };
1541        assert_eq!(on_input("abcd".into()), 40);
1542        let Some(Kind::Scroll {
1543            content,
1544            bottom: true,
1545        }) = children.next()
1546        else {
1547            panic!("scroll lost");
1548        };
1549        let Kind::TextArea {
1550            on_input,
1551            on_submit: Some(80),
1552            ..
1553        } = content.into_kind()
1554        else {
1555            panic!("text area lost");
1556        };
1557        assert_eq!(on_input("abcd".into()), 50);
1558    }
1559
1560    #[test]
1561    fn tabs_map_and_tone_every_page() {
1562        let node = tabs([("a", button(label("x"), 1)), ("b", button(label("y"), 2))])
1563            .tone(Tone::Dim)
1564            .map(&|n: i32| n * 10);
1565        let Kind::Tabs(pages) = node.into_kind() else {
1566            panic!("tabs became something else");
1567        };
1568        let names: Vec<_> = pages.iter().map(|(name, _)| name.as_str()).collect();
1569        assert_eq!(names, ["a", "b"]);
1570        let mut messages = Vec::new();
1571        for (_, page) in pages {
1572            let Kind::Button {
1573                content, on_press, ..
1574            } = page.into_kind()
1575            else {
1576                panic!("page lost its button");
1577            };
1578            assert!(matches!(
1579                content.into_kind(),
1580                Kind::Label {
1581                    tone: Some(Tone::Dim),
1582                    ..
1583                }
1584            ));
1585            messages.push(on_press);
1586        }
1587        assert_eq!(messages, [10, 20]);
1588    }
1589
1590    #[test]
1591    fn notice_map_reaches_every_message() {
1592        let notice = Notice::new(7, "s")
1593            .action("a", 1)
1594            .action("b", 2)
1595            .on_press(3)
1596            .on_dismiss(4)
1597            .expires(std::time::Duration::from_secs(5), 5)
1598            .reply(
1599                Reply::new("p", "v", |text: String| text.len() as i32)
1600                    .on_submit(6)
1601                    .on_cancel(7),
1602            )
1603            .map(&|n: i32| n * 10);
1604        assert_eq!(notice.key(), 7);
1605        let parts = notice.into_parts();
1606        let actions: Vec<_> = parts.actions.into_iter().map(|(_, m)| m).collect();
1607        assert_eq!(actions, [10, 20]);
1608        assert_eq!(parts.on_press, Some(30));
1609        assert_eq!(parts.on_dismiss, Some(40));
1610        assert_eq!(parts.expires.map(|(_, m)| m), Some(50));
1611        let reply = parts.reply.unwrap();
1612        assert_eq!((reply.on_input)("abc".into()), 30);
1613        assert_eq!((reply.on_submit, reply.on_cancel), (Some(60), Some(70)));
1614    }
1615
1616    #[test]
1617    fn modifiers_only_touch_their_nodes() {
1618        let node: Node<i32> = label("a").trailing(label("b")).on_press(1).selected(true);
1619        assert!(matches!(node.into_kind(), Kind::Label { .. }));
1620        let node: Node<i32> = button(label("a"), 1).on_press(2).prominent();
1621        assert!(matches!(
1622            node.into_kind(),
1623            Kind::Button {
1624                on_press: 1,
1625                prominent: true,
1626                ..
1627            }
1628        ));
1629    }
1630
1631    #[test]
1632    fn tone_reaches_details_and_row_icons() {
1633        let node: Node<()> = column([
1634            detail("n", label("v")),
1635            list_item("t").leading(icon(Icon::Lock)),
1636        ])
1637        .tone(Tone::Good);
1638        let Kind::Column(children) = node.into_kind() else {
1639            unreachable!()
1640        };
1641        let mut children = children.into_iter().map(Node::into_kind);
1642        let Some(Kind::Detail { value, .. }) = children.next() else {
1643            unreachable!()
1644        };
1645        assert!(matches!(
1646            value.into_kind(),
1647            Kind::Label {
1648                tone: Some(Tone::Good),
1649                ..
1650            }
1651        ));
1652        let Some(Kind::ListItem {
1653            leading: Some(leading),
1654            ..
1655        }) = children.next()
1656        else {
1657            unreachable!()
1658        };
1659        assert!(matches!(
1660            leading.into_kind(),
1661            Kind::Icon {
1662                tone: Some(Tone::Good),
1663                ..
1664            }
1665        ));
1666    }
1667}