Skip to main content

ravenrs_widgets/
overlay.rs

1//! Floating layers: tooltips, popovers, menus, selects and dialogs.
2
3use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, Color, CursorIcon, Effect, Insets, Point, Role};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
11
12use crate::context::{BuildCx, NodeId, Paint, Placement, QuadPaint, Semantics};
13use crate::{AnyView, Button, ButtonStyle, IconButton, IconName, Motion, View, motion, util};
14
15/// The floating panel surface shared by menus, selects and popovers.
16fn panel_paint(theme: &Theme) -> Paint {
17    Paint::Quad(QuadPaint {
18        corner_radius: theme.radii.lg,
19        border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
20        shadow: Some(theme.elevation.raised),
21        ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
22    })
23}
24
25/// Wraps children in the floating panel surface. Clicks on its background stay
26/// in the panel instead of reaching what is underneath.
27pub(crate) fn panel(cx: &mut BuildCx<'_>, children: &[NodeId], padding: f32, min_width: f32) -> NodeId {
28    let theme = *cx.theme();
29    let style = LayoutStyle {
30        padding: Insets::uniform(padding),
31        min_width: Some(min_width),
32        align: Align::Stretch,
33        ..LayoutStyle::stack(Axis::Vertical)
34    };
35    let node = cx.container(style, children, panel_paint(&theme));
36    cx.cursor(node, CursorIcon::Default);
37    motion::popup(cx, node);
38    node
39}
40
41/// Shows a short label above its content while the pointer is over it, with the key
42/// that does the same when there is one.
43///
44/// ```
45/// use ravenrs_widgets::{Button, Tooltip, Ui, ViewExt};
46///
47/// let save = Tooltip::new(Button::new("Save"), "Save the file").shortcut("⌘S");
48/// let open = Button::new("Open").tooltip("Open a file");
49/// let mut ui = Ui::new(ravenrs_widgets::VStack::new((save, open)), ravenrs_style::Theme::raven_dark());
50/// ui.update();
51/// ```
52pub struct Tooltip<V> {
53    content: V,
54    text: String,
55    shortcut: Option<String>,
56}
57
58impl<V> fmt::Debug for Tooltip<V> {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.debug_struct("Tooltip").field("text", &self.text).finish_non_exhaustive()
61    }
62}
63
64#[cfg_attr(feature = "live", ravenrs_live::expose)]
65impl<V: View> Tooltip<V> {
66    /// Shows `text` over `content` on hover.
67    pub fn new(content: V, text: impl Into<String>) -> Self {
68        Self { content, text: text.into(), shortcut: None }
69    }
70
71    /// Shows this key as a key cap beside the text, such as `"⌘S"`.
72    pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
73        self.shortcut = Some(keys.into());
74        self
75    }
76}
77
78pub(crate) struct Bubble<'a>(pub &'a str);
79
80/// The bubble of a tooltip that also shows a key.
81struct KeyBubble<'a> {
82    text: &'a str,
83    keys: Option<&'a str>,
84}
85
86impl View for Bubble<'_> {
87    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
88        KeyBubble { text: self.0, keys: None }.build(cx)
89    }
90}
91
92impl View for KeyBubble<'_> {
93    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
94        let theme = *cx.theme();
95        // Inverted colors read as a tooltip in every theme.
96        let ink = theme.color(Semantic::Canvas);
97        let mut children = vec![util::label(cx, self.text, TextStyle::Callout, Some(Weight::MEDIUM), ink)];
98        if let Some(keys) = self.keys {
99            let cap = crate::Kbd::new(keys.to_owned()).inherit_color();
100            children.push(cx.with_foreground(ink, |cx| cx.child(&cap)));
101        }
102        let node = cx.container(
103            LayoutStyle {
104                padding: Insets::symmetric(5.0, 10.0),
105                spacing: theme.spacing.s2,
106                align: Align::Center,
107                ..LayoutStyle::stack(Axis::Horizontal)
108            },
109            &children,
110            Paint::Quad(QuadPaint { corner_radius: 7.0, ..QuadPaint::new(theme.color(Semantic::Ink)) }),
111        );
112        // The text is already the name or description of the anchor.
113        cx.semantics(node, Semantics::hidden());
114        motion::popup(cx, node);
115        node
116    }
117}
118
119impl<V: View> View for Tooltip<V> {
120    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
121        let child = cx.child(&self.content);
122        cx.label_control(child, &self.text, self.shortcut.as_deref());
123        let node = cx.container(
124            LayoutStyle { align_self: Some(Align::Fit), ..LayoutStyle::stack(Axis::Horizontal) },
125            &[child],
126            Paint::None,
127        );
128        // Hoverable even when the content itself is not interactive.
129        cx.cursor(node, CursorIcon::Default);
130        if cx.is_hover_within() {
131            let bubble = KeyBubble { text: &self.text, keys: self.shortcut.as_deref() };
132            cx.overlay(node, Placement::AboveCenter, &bubble, None);
133        }
134        node
135    }
136}
137
138/// What a [`HelpTip`] opens: what something is and, if it helps, how to use it.
139struct HelpPanel<'a> {
140    title: &'a str,
141    text: &'a str,
142    how: Option<&'a str>,
143}
144
145impl View for HelpPanel<'_> {
146    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
147        let theme = *cx.theme();
148        let (ink, muted) = (theme.color(Semantic::Ink), theme.color(Semantic::InkMuted));
149        let wrapped = |cx: &mut BuildCx<'_>, text: &str, weight, color| {
150            cx.child(
151                &crate::Text::new(text.to_owned())
152                    .style(TextStyle::Callout)
153                    .font_weight(weight)
154                    .foreground(color),
155            )
156        };
157        let mut lines = vec![
158            wrapped(cx, self.title, Weight::SEMIBOLD, ink),
159            wrapped(cx, self.text, Weight::REGULAR, muted),
160        ];
161        if let Some(how) = self.how {
162            lines.push(cx.child(&crate::Divider::horizontal()));
163            lines.push(wrapped(cx, how, Weight::REGULAR, ink));
164        }
165        let column = cx.container(
166            LayoutStyle {
167                width: Some(HELP_WIDTH),
168                spacing: theme.spacing.s2,
169                align: Align::Stretch,
170                ..LayoutStyle::stack(Axis::Vertical)
171            },
172            &lines,
173            Paint::None,
174        );
175        panel(cx, &[column], theme.spacing.s3, 0.0)
176    }
177}
178
179const HELP_WIDTH: f32 = 280.0;
180
181/// A small "?" beside something that needs explaining. Its panel opens under the
182/// pointer and on keyboard focus, and a click keeps it open until a click elsewhere.
183/// Screen readers get the same words as the button's description.
184#[derive(Clone, Debug)]
185#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
186pub struct HelpTip {
187    title: String,
188    text: String,
189    how: Option<String>,
190}
191
192#[cfg_attr(feature = "live", ravenrs_live::expose)]
193impl HelpTip {
194    /// Explains what `title` is.
195    pub fn new(title: impl Into<String>, text: impl Into<String>) -> Self {
196        Self { title: title.into(), text: text.into(), how: None }
197    }
198
199    /// Adds how to use it, set apart from what it is.
200    #[must_use]
201    pub fn how(mut self, how: impl Into<String>) -> Self {
202        self.how = Some(how.into());
203        self
204    }
205}
206
207impl View for HelpTip {
208    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
209        let pinned: State<bool> = cx.use_state(|| false);
210        let toggle = pinned.clone();
211        let button = IconButton::new(IconName::CircleHelp)
212            .label(format!("{}: {}", cx.labels().help, self.title))
213            .size(crate::ButtonSize::Small)
214            .style(ButtonStyle::Ghost)
215            .on_click(move || toggle.update(|open| *open = !*open));
216        // Built as this view, so that hover and focus on the button are this view's.
217        let node = button.build(cx);
218        let description = match &self.how {
219            Some(how) => format!("{} {how}", self.text),
220            None => self.text.clone(),
221        };
222        cx.update_semantics(node, |s| s.description = Some(description.clone()));
223        if pinned.get() || cx.is_hovered() || cx.is_focus_visible() {
224            let panel = HelpPanel { title: &self.title, text: &self.text, how: self.how.as_deref() };
225            let close = pinned.clone();
226            let dismiss: Rc<dyn Fn()> = Rc::new(move || close.set(false));
227            cx.overlay(node, Placement::Below, &panel, pinned.get().then_some(dismiss));
228        }
229        node
230    }
231}
232
233/// Floating content anchored to a view, open while `open` is true. Clicking
234/// outside or pressing Escape closes it. It opens below the view on a panel, unless
235/// told otherwise.
236///
237/// ```
238/// use ravenrs_reactive::State;
239/// use ravenrs_widgets::{Button, Placement, Popover, Text, Ui};
240///
241/// let open = State::new(true);
242/// let popover = Popover::new(open.binding(), Button::new("Refused"), Text::new("Two files"))
243///     .placement(Placement::Above)
244///     .surface(false);
245/// let mut ui = Ui::new(popover, ravenrs_style::Theme::raven_dark());
246/// ui.update();
247/// ```
248pub struct Popover<A, C> {
249    open: Binding<bool>,
250    anchor: A,
251    content: C,
252    placement: Placement,
253    surface: bool,
254    padding: f32,
255    min_width: f32,
256}
257
258impl<A, C> fmt::Debug for Popover<A, C> {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        f.debug_struct("Popover").finish_non_exhaustive()
261    }
262}
263
264#[cfg_attr(feature = "live", ravenrs_live::expose)]
265impl<A: View, C: View> Popover<A, C> {
266    /// Shows `content` below `anchor` while `open` is true.
267    pub fn new(open: Binding<bool>, anchor: A, content: C) -> Self {
268        Self {
269            open,
270            anchor,
271            content,
272            placement: Placement::Below,
273            surface: true,
274            padding: 16.0,
275            min_width: 220.0,
276        }
277    }
278
279    /// Where it opens relative to the anchor ([`Placement::Below`] by default).
280    pub fn placement(mut self, placement: Placement) -> Self {
281        self.placement = placement;
282        self
283    }
284
285    /// Whether the content sits on the floating panel (it does by default). Without it,
286    /// the content draws its own surface and takes only the room it needs.
287    pub fn surface(mut self, surface: bool) -> Self {
288        self.surface = surface;
289        self
290    }
291
292    /// The space between the panel and the content, in logical pixels (16 by default).
293    pub fn padding(mut self, padding: f32) -> Self {
294        self.padding = padding;
295        self
296    }
297
298    /// The narrowest the panel gets, in logical pixels (220 by default).
299    pub fn min_width(mut self, width: f32) -> Self {
300        self.min_width = width;
301        self
302    }
303}
304
305struct PopoverLayer<'a> {
306    content: &'a dyn View,
307    surface: bool,
308    padding: f32,
309    min_width: f32,
310}
311
312impl View for PopoverLayer<'_> {
313    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
314        let child = cx.child(self.content);
315        if self.surface {
316            return panel(cx, &[child], self.padding, self.min_width);
317        }
318        let node = cx.container(LayoutStyle::stack(Axis::Vertical), &[child], Paint::None);
319        // Clicks on the content stay in it, panel or not.
320        cx.cursor(node, CursorIcon::Default);
321        motion::popup(cx, node);
322        node
323    }
324}
325
326pub(crate) struct PanelOf<'a>(pub &'a dyn View);
327
328impl View for PanelOf<'_> {
329    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
330        let child = cx.child(self.0);
331        panel(cx, &[child], 16.0, 220.0)
332    }
333}
334
335impl<A: View, C: View> View for Popover<A, C> {
336    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
337        let anchor = cx.child(&self.anchor);
338        if self.open.get() {
339            let close = self.open.clone();
340            let layer = PopoverLayer {
341                content: &self.content,
342                surface: self.surface,
343                padding: self.padding,
344                min_width: self.min_width,
345            };
346            cx.overlay(anchor, self.placement, &layer, Some(Rc::new(move || close.set(false))));
347        }
348        anchor
349    }
350}
351
352/// One entry of a [`DropdownMenu`] or a [`ContextMenu`](crate::ContextMenu). It can run an
353/// action, show a check mark, show any view, or open a submenu at its side.
354///
355/// ```
356/// use ravenrs_widgets::{DropdownMenu, MenuItem, Ui};
357///
358/// let menu = DropdownMenu::new("View")
359///     .item(MenuItem::new("Show grid").checked(true))
360///     .item(MenuItem::new("Export").item(MenuItem::new("As PDF")).item(MenuItem::new("As CSV")));
361/// let mut ui = Ui::new(menu, ravenrs_style::Theme::raven_dark());
362/// ui.update();
363/// ```
364#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
365pub struct MenuItem {
366    label: String,
367    icon: Option<IconName>,
368    shortcut: Option<String>,
369    destructive: bool,
370    on_select: Option<Rc<dyn Fn()>>,
371    checked: Option<bool>,
372    element: Option<AnyView>,
373    submenu: Vec<Entry>,
374}
375
376impl fmt::Debug for MenuItem {
377    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378        f.debug_struct("MenuItem").field("label", &self.label).finish_non_exhaustive()
379    }
380}
381
382#[cfg_attr(feature = "live", ravenrs_live::expose)]
383impl MenuItem {
384    /// A menu item labeled `label`.
385    pub fn new(label: impl Into<String>) -> Self {
386        Self {
387            label: label.into(),
388            icon: None,
389            shortcut: None,
390            destructive: false,
391            on_select: None,
392            checked: None,
393            element: None,
394            submenu: Vec::new(),
395        }
396    }
397
398    /// Shows an icon before the label.
399    pub fn icon(mut self, icon: IconName) -> Self {
400        self.icon = Some(icon);
401        self
402    }
403
404    /// Shows a shortcut hint at the trailing edge, e.g. `"⌘C"` (display only).
405    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
406        self.shortcut = Some(shortcut.into());
407        self
408    }
409
410    /// Paints the item in the danger color (Delete, Remove…).
411    pub fn destructive(mut self) -> Self {
412        self.destructive = true;
413        self
414    }
415
416    /// Runs `handler` when the item is chosen; the menu closes first.
417    pub fn on_select(mut self, handler: impl Fn() + 'static) -> Self {
418        self.on_select = Some(Rc::new(handler));
419        self
420    }
421
422    /// Makes it an item that is on or off: a check mark shows while it is on, and screen
423    /// readers hear which. What choosing it does is still up to [`Self::on_select`].
424    pub fn checked(mut self, checked: bool) -> Self {
425        self.checked = Some(checked);
426        self
427    }
428
429    /// Shows this view in the row instead of the label, such as a color with its name.
430    /// The label still names the item for screen readers.
431    pub fn element(mut self, view: impl View + 'static) -> Self {
432        self.element = Some(Box::new(view));
433        self
434    }
435
436    /// Adds an item to this item's submenu, which opens at its side on hover, on click or
437    /// with the Right arrow; Left and Escape come back. An item with a submenu does not
438    /// run [`Self::on_select`].
439    pub fn item(mut self, item: MenuItem) -> Self {
440        self.submenu.push(Entry::Item(item));
441        self
442    }
443
444    /// Adds a separator line to this item's submenu.
445    pub fn separator(mut self) -> Self {
446        self.submenu.push(Entry::Separator);
447        self
448    }
449}
450
451pub(crate) enum Entry {
452    Item(MenuItem),
453    Separator,
454}
455
456/// A button that opens a list of actions. The first item takes focus as the
457/// menu opens; arrow keys move, Enter runs, Escape closes, and focus returns
458/// to the button.
459#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
460pub struct DropdownMenu {
461    label: String,
462    icon: Option<IconName>,
463    style: ButtonStyle,
464    size: crate::ButtonSize,
465    icon_only: bool,
466    entries: Vec<Entry>,
467}
468
469impl fmt::Debug for DropdownMenu {
470    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
471        f.debug_struct("DropdownMenu").field("label", &self.label).finish_non_exhaustive()
472    }
473}
474
475#[cfg_attr(feature = "live", ravenrs_live::expose)]
476impl DropdownMenu {
477    /// A secondary button labeled `label` with an empty menu.
478    pub fn new(label: impl Into<String>) -> Self {
479        Self {
480            label: label.into(),
481            icon: None,
482            style: ButtonStyle::Secondary,
483            size: crate::ButtonSize::Medium,
484            icon_only: false,
485            entries: Vec::new(),
486        }
487    }
488
489    /// Shows only the icon, for a tight bar; the label still names the button for screen
490    /// readers. Give it an [`Self::icon`].
491    pub fn icon_only(mut self) -> Self {
492        self.icon_only = true;
493        self
494    }
495
496    /// The trigger's size (`Medium` by default).
497    pub fn size(mut self, size: crate::ButtonSize) -> Self {
498        self.size = size;
499        self
500    }
501
502    /// Shows an icon before the label.
503    pub fn icon(mut self, icon: IconName) -> Self {
504        self.icon = Some(icon);
505        self
506    }
507
508    /// Sets the trigger button style.
509    pub fn style(mut self, style: ButtonStyle) -> Self {
510        self.style = style;
511        self
512    }
513
514    /// Appends a menu item.
515    pub fn item(mut self, item: MenuItem) -> Self {
516        self.entries.push(Entry::Item(item));
517        self
518    }
519
520    /// Appends a separator line.
521    pub fn separator(mut self) -> Self {
522        self.entries.push(Entry::Separator);
523        self
524    }
525}
526
527/// A row in a menu or select list. `on_click` runs after `close`. Rows take
528/// focus as the list opens, and arrow keys move between them.
529struct ListRow<'a> {
530    label: &'a str,
531    icon: Option<IconName>,
532    trailing: Option<&'a str>,
533    checked: bool,
534    destructive: bool,
535    close: Rc<dyn Fn()>,
536    on_click: Option<Rc<dyn Fn()>>,
537    roving: util::Roving,
538    /// `MenuItem` in menus, `ListBoxOption` in select lists.
539    role: Role,
540    /// Left (false) or Right (true) in a menubar menu: open the neighbor menu.
541    on_side: Option<Rc<dyn Fn(bool)>>,
542    /// Shown instead of the label, which still names the row.
543    element: Option<&'a dyn View>,
544    /// An item that is on or off, as opposed to a plain command.
545    toggled: Option<bool>,
546    /// What this row opens at its side.
547    submenu: &'a [Entry],
548    /// The row of this list whose submenu is open, set when any row of the list has one.
549    /// The flag says the submenu takes the keyboard, as it does unless the pointer opened it.
550    open_submenu: Option<State<Option<(usize, bool)>>>,
551    /// How the pointer is moving over this list, set along with `open_submenu`.
552    aim: Option<SubmenuAim>,
553    /// Told when the pointer is over this list, if it is itself a submenu.
554    pointer_in: Option<Rc<dyn Fn()>>,
555}
556
557/// How long the pointer can cross other rows on its way to an open submenu.
558const SUBMENU_GRACE: f64 = 0.25;
559
560/// What lets the pointer go diagonally to a submenu: crossing the rows between would
561/// otherwise close it. A move that is mostly sideways gets a moment; the row it is over
562/// takes over when that ends, unless the pointer has reached the submenu.
563#[derive(Clone)]
564struct SubmenuAim {
565    last: Rc<Cell<Option<Point>>>,
566    /// The row waiting to take over, and whether it has a submenu of its own.
567    waiting: State<Option<(usize, bool)>>,
568    due: Rc<Cell<Option<f64>>>,
569}
570
571#[cfg_attr(feature = "live", ravenrs_live::expose)]
572impl SubmenuAim {
573    /// Keeps the position and says whether this move can be going to the side.
574    fn sideways(&self, position: Point) -> bool {
575        self.last.replace(Some(position)).is_some_and(|from| {
576            let (dx, dy) = (position.x - from.x, position.y - from.y);
577            dx != 0.0 && dy.abs() <= dx.abs() * 2.0
578        })
579    }
580
581    fn cancel(&self) {
582        if self.waiting.peek().is_some() {
583            self.waiting.set(None);
584        }
585    }
586}
587
588/// Makes this row's submenu the open one, or none if it has none.
589fn switch_submenu(
590    open: &State<Option<(usize, bool)>>,
591    index: usize,
592    has_submenu: bool,
593    focus_to: &util::FocusTarget,
594) {
595    let current = *open.peek();
596    open.set(has_submenu.then_some((index, false)));
597    // The submenu that goes away may hold the keyboard.
598    if current.is_some_and(|(_, focused)| focused) {
599        focus_to.request(index);
600    }
601}
602
603#[cfg_attr(feature = "live", ravenrs_live::expose)]
604impl ListRow<'_> {
605    /// Opens this row's submenu from the keyboard or a click.
606    fn opener(&self) -> Option<Rc<dyn Fn()>> {
607        let open = self.open_submenu.clone().filter(|_| !self.submenu.is_empty())?;
608        let index = self.roving.index;
609        Some(Rc::new(move || open.set(Some((index, true)))))
610    }
611
612    /// While the pointer moves over this row, its submenu is the open one, if it has one.
613    fn follow_pointer(&self, cx: &mut BuildCx<'_>, node: NodeId) {
614        let pointer_in = self.pointer_in.clone();
615        let list = self.open_submenu.clone().zip(self.aim.clone());
616        if list.is_none() && pointer_in.is_none() {
617            return;
618        }
619        let (index, has_submenu, focus_to) =
620            (self.roving.index, !self.submenu.is_empty(), self.roving.focus_to.clone());
621        cx.on_pointer_move(
622            node,
623            Rc::new(move |position, _| {
624                if let Some(pointer_in) = &pointer_in {
625                    pointer_in();
626                }
627                let Some((open, aim)) = &list else { return };
628                let sideways = aim.sideways(position);
629                let current = *open.peek();
630                if current.map(|(i, _)| i) == has_submenu.then_some(index) {
631                    aim.cancel();
632                    return;
633                }
634                if current.is_some() && sideways {
635                    if *aim.waiting.peek() != Some((index, has_submenu)) {
636                        aim.waiting.set(Some((index, has_submenu)));
637                    }
638                    return;
639                }
640                aim.cancel();
641                switch_submenu(open, index, has_submenu, &focus_to);
642            }),
643        );
644    }
645
646    /// Builds the submenu at the side of this row while it is the open one.
647    fn submenu_overlay(&self, cx: &mut BuildCx<'_>, node: NodeId) {
648        let Some(open) = &self.open_submenu else { return };
649        let Some((_, focused)) = open.get().filter(|(i, _)| *i == self.roving.index) else { return };
650        if self.submenu.is_empty() {
651            return;
652        }
653        let close_submenu: Rc<dyn Fn()> = {
654            let (open, focus_to, index) = (open.clone(), self.roving.focus_to.clone(), self.roving.index);
655            Rc::new(move || {
656                open.set(None);
657                focus_to.request(index);
658            })
659        };
660        // Left comes back here; Right goes on to the next menu of a menubar, as anywhere else.
661        let on_side: Rc<dyn Fn(bool)> = {
662            let (close, outer) = (close_submenu.clone(), self.on_side.clone());
663            Rc::new(move |forward| {
664                if !forward {
665                    close();
666                } else if let Some(outer) = &outer {
667                    outer(true);
668                }
669            })
670        };
671        let reached = self.aim.clone().map(|aim| Rc::new(move || aim.cancel()) as Rc<dyn Fn()>);
672        let list = MenuLevel {
673            entries: self.submenu,
674            close: self.close.clone(),
675            on_side: Some(on_side),
676            takes_focus: focused,
677            pointer_in: reached,
678        };
679        // Told apart by the flag: asked again from the keyboard, it starts over and takes focus.
680        let key = 0x7375_626d_656e_7500 + u64::from(focused);
681        cx.keyed_overlay(key, node, Placement::Right, &list, Some(close_submenu));
682    }
683}
684
685impl View for ListRow<'_> {
686    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
687        let theme = *cx.theme();
688        let hovered = cx.is_hovered() || cx.is_focus_visible();
689        let ink = if self.destructive { theme.color(Semantic::Danger) } else { theme.color(Semantic::Ink) };
690        let muted = if self.destructive { ink } else { theme.color(Semantic::InkMuted) };
691        let mut children = Vec::new();
692        if let Some(icon) = self.icon {
693            children.push(util::icon(cx, icon, 16.0, muted));
694        }
695        let weight = self.checked.then_some(Weight::SEMIBOLD);
696        let label = match self.element {
697            Some(element) => cx.keyed_child(1, element),
698            None => util::label(cx, self.label, TextStyle::Body, weight, ink),
699        };
700        children.push(cx.container(
701            LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
702            &[label],
703            Paint::None,
704        ));
705        if let Some(t) = self.trailing {
706            children.push(util::label(cx, t, TextStyle::Callout, None, theme.color(Semantic::InkMuted)));
707        }
708        if self.checked {
709            children.push(util::icon(
710                cx,
711                IconName::Check,
712                16.0,
713                theme.color(Semantic::Accent).lerp(ink, 0.3),
714            ));
715        }
716        let opener = self.opener();
717        let is_open =
718            self.open_submenu.as_ref().and_then(|o| o.get()).is_some_and(|(i, _)| i == self.roving.index);
719        if opener.is_some() {
720            children.push(util::icon(cx, IconName::ChevronRight, 16.0, muted));
721        }
722        let lit = hovered || (is_open && opener.is_some());
723        let fill = if lit { theme.color(Semantic::Ink).with_alpha(0.07) } else { Color::TRANSPARENT };
724        let style = LayoutStyle {
725            padding: Insets::symmetric(0.0, 10.0),
726            height: Some(34.0),
727            spacing: 10.0,
728            align: Align::Center,
729            ..LayoutStyle::stack(Axis::Horizontal)
730        };
731        let node = cx.container(
732            style,
733            &children,
734            Paint::Quad(QuadPaint { corner_radius: 7.0, ..QuadPaint::new(fill) }),
735        );
736        motion::control(cx, node);
737        let mut semantics = Semantics::new(self.role);
738        if self.role == Role::ListBoxOption {
739            semantics = semantics.selected(self.checked);
740        }
741        if let Some(t) = self.trailing {
742            semantics = semantics.name(self.label).description(t);
743        }
744        if self.element.is_some() {
745            semantics = semantics.name(self.label);
746        }
747        if let Some(on) = self.toggled {
748            semantics = semantics.toggled(on);
749        }
750        if opener.is_some() {
751            semantics = semantics.expanded(is_open);
752        }
753        cx.semantics(node, semantics);
754        if let Some(open) = &opener {
755            let open = open.clone();
756            cx.on_click(node, Rc::new(move || open()));
757        } else {
758            let (action, close) = (self.on_click.clone(), self.close.clone());
759            cx.on_click(
760                node,
761                Rc::new(move || {
762                    close();
763                    if let Some(a) = &action {
764                        a();
765                    }
766                }),
767            );
768        }
769        cx.focusable(node, 7.0);
770        let before: Option<crate::KeyHandler> = (self.on_side.is_some() || opener.is_some()).then(|| {
771            let (side, opener) = (self.on_side.clone(), opener.clone());
772            Rc::new(move |key: &ravenrs_core::Key, _: ravenrs_core::Modifiers| match key {
773                ravenrs_core::Key::ArrowRight if opener.is_some() => {
774                    if let Some(open) = &opener {
775                        open();
776                    }
777                    true
778                }
779                ravenrs_core::Key::ArrowLeft | ravenrs_core::Key::ArrowRight if side.is_some() => {
780                    if let Some(side) = &side {
781                        side(*key == ravenrs_core::Key::ArrowRight);
782                    }
783                    true
784                }
785                _ => false,
786            }) as crate::KeyHandler
787        });
788        self.roving.wire_with(cx, node, None, before);
789        self.follow_pointer(cx, node);
790        self.submenu_overlay(cx, node);
791        cx.cursor(node, CursorIcon::Pointer);
792        node
793    }
794}
795
796/// The shared roving focus of a list that focuses row `start` when it opens.
797fn list_focus(cx: &mut BuildCx<'_>, start: usize) -> util::FocusTarget {
798    util::roving_cell(cx, Some(start))
799}
800
801pub(crate) struct MenuList<'a> {
802    pub entries: &'a [Entry],
803    pub close: Rc<dyn Fn()>,
804    /// Set by a menubar: Left and Right open the neighbor menu.
805    pub on_side: Option<Rc<dyn Fn(bool)>>,
806}
807
808impl View for MenuList<'_> {
809    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
810        MenuLevel {
811            entries: self.entries,
812            close: self.close.clone(),
813            on_side: self.on_side.clone(),
814            takes_focus: true,
815            pointer_in: None,
816        }
817        .build(cx)
818    }
819}
820
821/// One panel of a menu, the first one or a submenu. `close` closes the whole menu.
822struct MenuLevel<'a> {
823    entries: &'a [Entry],
824    close: Rc<dyn Fn()>,
825    on_side: Option<Rc<dyn Fn(bool)>>,
826    /// A submenu the pointer opened leaves the keyboard where it was.
827    takes_focus: bool,
828    /// A submenu tells the list that opened it when the pointer gets here.
829    pointer_in: Option<Rc<dyn Fn()>>,
830}
831
832impl View for MenuLevel<'_> {
833    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
834        let mut rows = Vec::new();
835        let count = self.entries.iter().filter(|e| matches!(e, Entry::Item(_))).count();
836        let focus_to = util::roving_cell(cx, self.takes_focus.then_some(0));
837        let open_submenu: State<Option<(usize, bool)>> = cx.use_state(|| None);
838        // Plain menus never pay for following the pointer.
839        let has_submenus =
840            self.entries.iter().any(|e| matches!(e, Entry::Item(item) if !item.submenu.is_empty()));
841        let aim = SubmenuAim {
842            last: cx.use_state(|| Rc::new(Cell::new(None))).get(),
843            waiting: cx.use_state(|| None),
844            due: cx.use_state(|| Rc::new(Cell::new(None))).get(),
845        };
846        if let Some((row, its_own)) = aim.waiting.get() {
847            let now = cx.now();
848            let due = aim.due.get().unwrap_or(now + SUBMENU_GRACE);
849            if now >= due {
850                aim.due.set(None);
851                aim.waiting.set(None);
852                switch_submenu(&open_submenu, row, its_own, &focus_to);
853            } else {
854                aim.due.set(Some(due));
855                cx.wake_at(due);
856            }
857        } else {
858            aim.due.set(None);
859        }
860        let mut item_index = 0;
861        for (i, entry) in self.entries.iter().enumerate() {
862            match entry {
863                Entry::Item(item) => {
864                    let roving = util::Roving { index: item_index, count, focus_to: focus_to.clone() };
865                    item_index += 1;
866                    let row = ListRow {
867                        roving,
868                        label: &item.label,
869                        icon: item.icon,
870                        trailing: item.shortcut.as_deref(),
871                        checked: item.checked == Some(true),
872                        destructive: item.destructive,
873                        close: self.close.clone(),
874                        on_click: item.on_select.clone(),
875                        role: Role::MenuItem,
876                        on_side: self.on_side.clone(),
877                        element: item.element.as_deref().map(|view| view as &dyn View),
878                        toggled: item.checked,
879                        submenu: &item.submenu,
880                        open_submenu: has_submenus.then(|| open_submenu.clone()),
881                        aim: has_submenus.then(|| aim.clone()),
882                        pointer_in: self.pointer_in.clone(),
883                    };
884                    rows.push(cx.keyed_child(i as u64, &row));
885                }
886                Entry::Separator => {
887                    let line = cx.child(&crate::Divider::horizontal());
888                    rows.push(cx.container(
889                        LayoutStyle {
890                            padding: Insets::symmetric(4.0, 4.0),
891                            align: Align::Stretch,
892                            ..LayoutStyle::stack(Axis::Vertical)
893                        },
894                        &[line],
895                        Paint::None,
896                    ));
897                }
898            }
899        }
900        let node = panel(cx, &rows, 6.0, 220.0);
901        cx.semantics(node, Semantics::new(Role::Menu));
902        if let Some(pointer_in) = self.pointer_in.clone() {
903            cx.on_pointer_move(node, Rc::new(move |_, _| pointer_in()));
904        }
905        node
906    }
907}
908
909impl View for DropdownMenu {
910    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
911        let open: State<bool> = cx.use_state(|| false);
912        let toggle = open.clone();
913        let shown = if self.icon_only { String::new() } else { self.label.clone() };
914        let mut trigger = Button::new(shown)
915            .style(self.style)
916            .size(self.size)
917            .on_click(move || toggle.update(|o| *o = !*o));
918        if !self.icon_only {
919            trigger = trigger.trailing_icon(IconName::ChevronDown);
920        }
921        if let Some(icon) = self.icon {
922            trigger = trigger.icon(icon);
923        }
924        let anchor = cx.child(&trigger);
925        let is_open = open.get();
926        let name = self.icon_only.then(|| self.label.clone());
927        cx.update_semantics(anchor, |s| {
928            s.expanded = Some(is_open);
929            if let Some(name) = name {
930                s.name = Some(name);
931            }
932        });
933        let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
934        if is_open {
935            was_open.set(true);
936            let close: Rc<dyn Fn()> = {
937                let open = open.clone();
938                Rc::new(move || open.set(false))
939            };
940            cx.overlay(
941                anchor,
942                Placement::Below,
943                &MenuList { entries: &self.entries, close: close.clone(), on_side: None },
944                Some(close),
945            );
946        } else if was_open.replace(false) {
947            // Focus returns to the trigger when the menu closes.
948            cx.request_focus_node(anchor);
949        }
950        anchor
951    }
952}
953
954/// Picks one option from a list, shown in a field-like trigger. Enter, Space or
955/// an arrow key opens the list with the current option focused; arrows move,
956/// Enter picks, Escape closes, and focus returns to the field.
957#[derive(Debug)]
958#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
959pub struct Select {
960    selected: Binding<usize>,
961    options: Vec<String>,
962    width: f32,
963    label: Option<String>,
964    state: util::EditorState,
965    fill: bool,
966    size: crate::ButtonSize,
967    searchable: bool,
968}
969
970#[cfg_attr(feature = "live", ravenrs_live::expose)]
971impl Select {
972    /// A select over `options`; `selected` holds the zero-based index of the choice.
973    pub fn new(selected: Binding<usize>, options: impl IntoIterator<Item = impl Into<String>>) -> Self {
974        Self {
975            selected,
976            options: options.into_iter().map(Into::into).collect(),
977            width: 220.0,
978            fill: false,
979            label: None,
980            state: util::EditorState::default(),
981            size: crate::ButtonSize::Medium,
982            searchable: false,
983        }
984    }
985
986    /// A select over values of any type, each with the text that shows it: `value` holds
987    /// the value itself, not its position. A value that is not among the options shows
988    /// nothing until one is picked.
989    ///
990    /// ```
991    /// use ravenrs_reactive::State;
992    /// use ravenrs_widgets::{Select, Ui};
993    ///
994    /// #[derive(Clone, PartialEq)]
995    /// enum Quality {
996    ///     Draft,
997    ///     Final,
998    /// }
999    ///
1000    /// let quality = State::new(Quality::Final);
1001    /// let select = Select::of(quality.binding(), [(Quality::Draft, "Draft"), (Quality::Final, "Final")]);
1002    /// let mut ui = Ui::new(select, ravenrs_style::Theme::raven_dark());
1003    /// ui.update();
1004    /// ```
1005    pub fn of<T: Clone + PartialEq + 'static>(
1006        value: Binding<T>,
1007        options: impl IntoIterator<Item = (T, impl Into<String>)>,
1008    ) -> Self {
1009        let (values, labels): (Vec<T>, Vec<String>) =
1010            options.into_iter().map(|(value, label)| (value, label.into())).unzip();
1011        let values = Rc::new(values);
1012        let (read, write, known) = (value.clone(), value, values.clone());
1013        // Past the end when it is not among the options, which shows nothing.
1014        let selected = Binding::new(
1015            move || {
1016                let current = read.get();
1017                known.iter().position(|v| *v == current).unwrap_or(usize::MAX)
1018            },
1019            move |i: usize| {
1020                if let Some(value) = values.get(i) {
1021                    write.set(value.clone());
1022                }
1023            },
1024        );
1025        Self::new(selected, labels)
1026    }
1027
1028    /// Opens with a search box that filters the options as you type.
1029    pub fn searchable(mut self) -> Self {
1030        self.searchable = true;
1031        self
1032    }
1033
1034    /// Sets the height and text size to line up with a [`Button`] of the same size
1035    /// (`Medium` by default).
1036    pub fn size(mut self, size: crate::ButtonSize) -> Self {
1037        self.size = size;
1038        self
1039    }
1040
1041    /// Takes the width of its parent, as other fields of a form do.
1042    pub fn fill(mut self) -> Self {
1043        self.fill = true;
1044        self
1045    }
1046
1047    /// Sets the width in logical pixels (220 by default).
1048    pub fn width(mut self, width: f32) -> Self {
1049        self.width = width;
1050        self
1051    }
1052
1053    /// The name screen readers announce, such as "Country". Inside a
1054    /// [`crate::FormField`], its label is used instead.
1055    pub fn label(mut self, label: impl Into<String>) -> Self {
1056        self.label = Some(label.into());
1057        self
1058    }
1059
1060    /// Turns the control off: it fades, leaves the tab order and ignores input.
1061    pub fn disabled(mut self, disabled: bool) -> Self {
1062        self.state.disabled = disabled;
1063        self
1064    }
1065
1066    /// Shows the value without letting it change; it can still be focused and read.
1067    pub fn read_only(mut self, read_only: bool) -> Self {
1068        self.state.read_only = read_only;
1069        self
1070    }
1071}
1072
1073pub(crate) struct Options<'a> {
1074    pub options: &'a [String],
1075    pub selected: Binding<usize>,
1076    pub close: Rc<dyn Fn()>,
1077    pub width: f32,
1078}
1079
1080impl View for Options<'_> {
1081    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1082        let current = self.selected.get();
1083        let count = self.options.len();
1084        // Nothing to start from when the value is not among the options.
1085        let focus_to = list_focus(cx, if current < count { current } else { 0 });
1086        let rows: Vec<NodeId> = self
1087            .options
1088            .iter()
1089            .enumerate()
1090            .map(|(i, option)| {
1091                let selected = self.selected.clone();
1092                let row = ListRow {
1093                    roving: util::Roving { index: i, count, focus_to: focus_to.clone() },
1094                    label: option,
1095                    icon: None,
1096                    trailing: None,
1097                    checked: i == current,
1098                    destructive: false,
1099                    close: self.close.clone(),
1100                    on_click: Some(Rc::new(move || selected.set(i))),
1101                    role: Role::ListBoxOption,
1102                    on_side: None,
1103                    element: None,
1104                    toggled: None,
1105                    submenu: &[],
1106                    open_submenu: None,
1107                    aim: None,
1108                    pointer_in: None,
1109                };
1110                cx.keyed_child(i as u64, &row)
1111            })
1112            .collect();
1113        let node = panel(cx, &rows, 6.0, self.width);
1114        cx.semantics(node, Semantics::new(Role::ListBox));
1115        node
1116    }
1117}
1118
1119impl View for Select {
1120    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1121        let theme = *cx.theme();
1122        let open: State<bool> = cx.use_state(|| false);
1123        let query: State<String> = cx.use_state(String::new);
1124        let highlight: State<usize> = cx.use_state(|| 0);
1125        let state = self.state;
1126        let is_open = open.get() && state.accepts_input();
1127        let hovered = cx.is_hovered() && state.accepts_input();
1128        let current = self.options.get(self.selected.get()).map_or("", String::as_str);
1129        let label =
1130            util::label(cx, current, self.size.text_style(), None, state.ink(theme.color(Semantic::Ink)));
1131        let label = cx.container(
1132            LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
1133            &[label],
1134            Paint::None,
1135        );
1136        let chevron = util::icon(
1137            cx,
1138            if is_open { IconName::ChevronUp } else { IconName::ChevronDown },
1139            if self.size == crate::ButtonSize::Mini { 12.0 } else { 16.0 },
1140            state.ink(theme.color(Semantic::InkMuted)),
1141        );
1142        let border = if is_open {
1143            Border { width: 1.5, color: theme.color(Semantic::Accent) }
1144        } else {
1145            let base = theme.color(Semantic::Border);
1146            Border {
1147                width: 1.0,
1148                color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
1149            }
1150        };
1151        let style = LayoutStyle {
1152            padding: Insets::symmetric(
1153                0.0,
1154                if matches!(self.size, crate::ButtonSize::Small | crate::ButtonSize::Mini) {
1155                    8.0
1156                } else {
1157                    12.0
1158                },
1159            ),
1160            width: (!self.fill).then_some(self.width),
1161            height: Some(self.size.height()),
1162            spacing: 8.0,
1163            align: Align::Center,
1164            align_self: Some(if self.fill { Align::Stretch } else { Align::Fit }),
1165            ..LayoutStyle::stack(Axis::Horizontal)
1166        };
1167        let paint = QuadPaint {
1168            corner_radius: theme.radii.md,
1169            border: Some(border),
1170            ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
1171        };
1172        let node = cx.container(style, &[label, chevron], Paint::Quad(paint));
1173        cx.semantics(label, Semantics::hidden());
1174        let mut semantics = Semantics::new(Role::ComboBox).value(current).expanded(is_open);
1175        if let Some(label) = &self.label {
1176            semantics = semantics.name(label.clone());
1177        }
1178        cx.semantics(node, state.semantics(semantics));
1179        if state.disabled {
1180            return node;
1181        }
1182        if !state.accepts_input() {
1183            cx.focusable(node, theme.radii.md);
1184            return node;
1185        }
1186        // A new search each time, starting from the current option.
1187        let reset: Rc<dyn Fn()> = {
1188            let (query, highlight, selected) = (query.clone(), highlight.clone(), self.selected.clone());
1189            let count = self.options.len();
1190            Rc::new(move || {
1191                query.set(String::new());
1192                highlight.set(Some(selected.get()).filter(|i| *i < count).unwrap_or(0));
1193            })
1194        };
1195        let (toggle, new_search) = (open.clone(), reset.clone());
1196        cx.on_click(
1197            node,
1198            Rc::new(move || {
1199                new_search();
1200                toggle.update(|o| *o = !*o);
1201            }),
1202        );
1203        // Arrow keys open the list too, like a native select.
1204        let opener = open.clone();
1205        cx.on_key(
1206            node,
1207            Rc::new(move |key, _| {
1208                let opens = matches!(key, ravenrs_core::Key::ArrowDown | ravenrs_core::Key::ArrowUp);
1209                if opens {
1210                    reset();
1211                    opener.set(true);
1212                }
1213                opens
1214            }),
1215        );
1216        cx.focusable(node, theme.radii.md);
1217        cx.cursor(node, CursorIcon::Pointer);
1218        let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
1219        if !is_open && was_open.replace(false) {
1220            // Focus returns to the field when the list closes.
1221            cx.request_focus();
1222        }
1223        if is_open {
1224            was_open.set(true);
1225            let close: Rc<dyn Fn()> = {
1226                let open = open.clone();
1227                Rc::new(move || open.set(false))
1228            };
1229            if self.searchable {
1230                let pick: Rc<dyn Fn(usize)> = {
1231                    let (selected, close) = (self.selected.clone(), close.clone());
1232                    Rc::new(move |i| {
1233                        selected.set(i);
1234                        close();
1235                    })
1236                };
1237                let list = crate::floating::SearchPanel {
1238                    options: &self.options,
1239                    current: Some(self.selected.get()),
1240                    pick,
1241                    query,
1242                    highlight,
1243                    open: open.clone(),
1244                    width: self.width,
1245                };
1246                cx.overlay(node, Placement::Below, &list, Some(close));
1247            } else {
1248                let list = Options {
1249                    options: &self.options,
1250                    selected: self.selected.clone(),
1251                    close: close.clone(),
1252                    width: self.width,
1253                };
1254                cx.overlay(node, Placement::Below, &list, Some(close));
1255            }
1256        }
1257        node
1258    }
1259}
1260
1261/// A modal dialog over a dimmed backdrop, shown while `open` is true.
1262/// Clicking the backdrop, the close button or pressing Escape closes it, and each of
1263/// the three can be turned off for what must not be dismissed by accident.
1264///
1265/// ```
1266/// use ravenrs_reactive::State;
1267/// use ravenrs_widgets::{Dialog, Ui};
1268///
1269/// let open = State::new(true);
1270/// // Only its own buttons or Escape close it: a stray click does not lose what was typed.
1271/// let dialog = Dialog::new(open.binding(), "Discard the changes?").close_on_backdrop(false).close_button(false);
1272/// let mut ui = Ui::new(dialog, ravenrs_style::Theme::raven_dark());
1273/// ui.update();
1274/// ```
1275#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1276pub struct Dialog {
1277    open: Binding<bool>,
1278    title: String,
1279    description: Option<String>,
1280    content: Option<AnyView>,
1281    actions: Vec<AnyView>,
1282    width: f32,
1283    close_label: Option<String>,
1284    close_on_escape: bool,
1285    close_on_backdrop: bool,
1286    close_button: bool,
1287}
1288
1289impl fmt::Debug for Dialog {
1290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1291        f.debug_struct("Dialog").field("title", &self.title).finish_non_exhaustive()
1292    }
1293}
1294
1295#[cfg_attr(feature = "live", ravenrs_live::expose)]
1296impl Dialog {
1297    /// A dialog shown while `open` is true.
1298    pub fn new(open: Binding<bool>, title: impl Into<String>) -> Self {
1299        Self {
1300            open,
1301            title: title.into(),
1302            description: None,
1303            content: None,
1304            actions: Vec::new(),
1305            width: 460.0,
1306            close_label: None,
1307            close_on_escape: true,
1308            close_on_backdrop: true,
1309            close_button: true,
1310        }
1311    }
1312
1313    /// Adds a muted description under the title.
1314    pub fn description(mut self, text: impl Into<String>) -> Self {
1315        self.description = Some(text.into());
1316        self
1317    }
1318
1319    /// Sets the dialog body.
1320    pub fn content(mut self, content: impl View + 'static) -> Self {
1321        self.content = Some(Box::new(content));
1322        self
1323    }
1324
1325    /// The widest the dialog gets, in logical pixels (460 by default). It always
1326    /// fits the window.
1327    pub fn width(mut self, width: f32) -> Self {
1328        self.width = width;
1329        self
1330    }
1331
1332    /// The close button's name for screen readers (from [`Labels`](crate::Labels) by default).
1333    pub fn close_label(mut self, label: impl Into<String>) -> Self {
1334        self.close_label = Some(label.into());
1335        self
1336    }
1337
1338    /// Whether Escape closes it (it does by default).
1339    pub fn close_on_escape(mut self, close: bool) -> Self {
1340        self.close_on_escape = close;
1341        self
1342    }
1343
1344    /// Whether a click on the backdrop closes it (it does by default).
1345    pub fn close_on_backdrop(mut self, close: bool) -> Self {
1346        self.close_on_backdrop = close;
1347        self
1348    }
1349
1350    /// Whether it shows the X in its corner (it does by default).
1351    pub fn close_button(mut self, show: bool) -> Self {
1352        self.close_button = show;
1353        self
1354    }
1355
1356    /// Buttons at the bottom-right, in order.
1357    pub fn action(mut self, action: impl View + 'static) -> Self {
1358        self.actions.push(Box::new(action));
1359        self
1360    }
1361}
1362
1363struct DialogLayer<'a> {
1364    dialog: &'a Dialog,
1365}
1366
1367impl View for DialogLayer<'_> {
1368    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1369        let theme = *cx.theme();
1370        let viewport = cx.viewport();
1371        let d = self.dialog;
1372        let close = d.open.clone();
1373        let title = util::label(cx, &d.title, TextStyle::Title, None, theme.color(Semantic::Ink));
1374        let title = cx.container(
1375            LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
1376            &[title],
1377            Paint::None,
1378        );
1379        cx.semantics(title, Semantics::new(Role::Heading).level(2));
1380        let mut header = vec![title];
1381        if d.close_button {
1382            header.push(
1383                cx.child(
1384                    &IconButton::new(IconName::X)
1385                        .label(d.close_label.clone().unwrap_or_else(|| cx.labels().close.clone()))
1386                        .size(crate::ButtonSize::Small)
1387                        .on_click(move || close.set(false)),
1388                ),
1389            );
1390        }
1391        let header = cx.container(
1392            LayoutStyle { align: Align::Center, spacing: 12.0, ..LayoutStyle::stack(Axis::Horizontal) },
1393            &header,
1394            Paint::None,
1395        );
1396        let mut body = vec![header];
1397        if let Some(text) = &d.description {
1398            body.push(util::label(cx, text, TextStyle::Body, None, theme.color(Semantic::InkMuted)));
1399        }
1400        if let Some(content) = &d.content {
1401            body.push(cx.child(content.as_ref()));
1402        }
1403        if !d.actions.is_empty() {
1404            let actions: Vec<NodeId> = d.actions.iter().map(|a| cx.child(a.as_ref())).collect();
1405            body.push(cx.container(
1406                LayoutStyle {
1407                    spacing: 10.0,
1408                    justify: Justify::End,
1409                    padding: Insets { top: 8.0, ..Insets::ZERO },
1410                    ..LayoutStyle::stack(Axis::Horizontal)
1411                },
1412                &actions,
1413                Paint::None,
1414            ));
1415        }
1416        let card = cx.container(
1417            LayoutStyle {
1418                width: Some((viewport.width - 32.0).clamp(240.0, d.width.max(240.0))),
1419                padding: Insets::uniform(24.0),
1420                spacing: 14.0,
1421                align: Align::Stretch,
1422                ..LayoutStyle::stack(Axis::Vertical)
1423            },
1424            &body,
1425            Paint::Quad(QuadPaint {
1426                corner_radius: theme.radii.xl,
1427                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
1428                shadow: Some(theme.elevation.raised),
1429                ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
1430            }),
1431        );
1432        let mut semantics = Semantics::new(Role::Dialog).name(d.title.clone()).modal();
1433        if let Some(text) = &d.description {
1434            semantics = semantics.description(text.clone());
1435        }
1436        cx.semantics(card, semantics);
1437        // The card swallows clicks so only the backdrop closes the dialog.
1438        cx.cursor(card, CursorIcon::Default);
1439        cx.motion(
1440            card,
1441            Motion::new()
1442                .surface_exit()
1443                .enter(Effect::moved(0.0, 8.0).with_scale(0.96), theme.motion.fast)
1444                .exit(Effect { opacity: 0.0, ..Effect::IDENTITY.with_scale(0.98) }, theme.motion.fast),
1445        );
1446        let scrim = motion::scrim(cx, viewport);
1447        let backdrop = cx.container(
1448            LayoutStyle {
1449                width: Some(viewport.width),
1450                height: Some(viewport.height),
1451                align: Align::Center,
1452                justify: Justify::Center,
1453                ..LayoutStyle::stack(Axis::Vertical)
1454            },
1455            &[scrim, card],
1456            Paint::None,
1457        );
1458        // Still a target when it does not close: the click must not reach what is behind.
1459        let close = d.close_on_backdrop.then(|| d.open.clone());
1460        cx.on_click(
1461            backdrop,
1462            Rc::new(move || {
1463                if let Some(close) = &close {
1464                    close.set(false);
1465                }
1466            }),
1467        );
1468        backdrop
1469    }
1470}
1471
1472impl View for Dialog {
1473    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1474        let anchor = cx.empty();
1475        if self.open.get() {
1476            // It takes Escape even when it does not close, or a layer under it would.
1477            let close = self.close_on_escape.then(|| self.open.clone());
1478            cx.overlay(
1479                anchor,
1480                Placement::Fill,
1481                &DialogLayer { dialog: self },
1482                Some(Rc::new(move || {
1483                    if let Some(close) = &close {
1484                        close.set(false);
1485                    }
1486                })),
1487            );
1488        }
1489        anchor
1490    }
1491}