Skip to main content

ravenrs_widgets/
commands.rs

1//! Where commands live: a toolbar that folds into a menu when it runs out of
2//! room, a button with alternatives, and a question before something final.
3
4use std::cell::Cell;
5use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_core::{Border, CursorIcon, Insets, Key, Point, Role, Size};
9use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
10use ravenrs_reactive::{Binding, State};
11use ravenrs_style::{Semantic, TextStyle};
12
13use crate::chat::{PANEL_MARGIN, move_by_handle, panel_inside};
14use crate::context::{BuildCx, NodeId, Paint, Placement, QuadPaint, Semantics};
15use crate::overlay::{Entry, MenuList};
16use crate::{
17    AnyView, Button, ButtonSize, ButtonStyle, Dialog, IconButton, IconName, MenuItem, Text, TextField,
18    VStack, View, util,
19};
20
21/// Room a button takes besides its label: its padding, and the icon if it has one.
22const BUTTON_PADDING: f32 = 26.0;
23const ICON_ROOM: f32 = 22.0;
24const TOOLBAR_GAP: f32 = 4.0;
25const SEPARATOR_ROOM: f32 = 9.0;
26const MORE_ROOM: f32 = 36.0;
27
28/// A key combination that runs a command: a key, with Cmd (macOS) or Ctrl, and Shift.
29///
30/// ```
31/// use ravenrs_widgets::Hotkey;
32///
33/// assert_eq!(Hotkey::command("s").shift().label(), Hotkey::command("S").shift().label());
34/// assert!(Hotkey::key(ravenrs_core::Key::Delete).label().contains("Del"));
35/// ```
36#[derive(Clone, Debug, PartialEq, Eq, Hash)]
37#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
38pub struct Hotkey {
39    key: Key,
40    command: bool,
41    shift: bool,
42}
43
44#[cfg_attr(feature = "live", ravenrs_live::expose)]
45impl Hotkey {
46    /// This key alone.
47    pub fn key(key: Key) -> Self {
48        Self { key, command: false, shift: false }
49    }
50
51    /// This letter or digit with Cmd or Ctrl.
52    pub fn command(character: &str) -> Self {
53        Self { key: Key::Character(character.to_lowercase()), command: true, shift: false }
54    }
55
56    pub(crate) fn of(key: Key, command: bool, shift: bool) -> Self {
57        Self { key, command, shift }
58    }
59
60    /// Needs Shift too.
61    pub fn shift(mut self) -> Self {
62        self.shift = true;
63        self
64    }
65
66    /// How it is written next to a command, such as `⌘S` or `Ctrl+S`.
67    pub fn label(&self) -> String {
68        let key = match &self.key {
69            Key::Character(c) => c.to_uppercase(),
70            Key::Delete => "Del".to_owned(),
71            Key::Enter => "Enter".to_owned(),
72            Key::Escape => "Esc".to_owned(),
73            Key::Space => "Space".to_owned(),
74            other => format!("{other:?}"),
75        };
76        if cfg!(target_os = "macos") {
77            let shift = if self.shift { "⇧" } else { "" };
78            let command = if self.command { "⌘" } else { "" };
79            format!("{shift}{command}{key}")
80        } else {
81            let shift = if self.shift { "Shift+" } else { "" };
82            let command = if self.command { "Ctrl+" } else { "" };
83            format!("{command}{shift}{key}")
84        }
85    }
86
87    /// Makes it run `handler` for as long as the view building now is built.
88    pub fn register(&self, cx: &mut BuildCx<'_>, handler: Rc<dyn Fn()>) {
89        if self.shift {
90            cx.shortcut_with_shift(self.key.clone(), self.command, handler);
91        } else {
92            cx.shortcut(self.key.clone(), self.command, handler);
93        }
94    }
95}
96
97/// One command of a [`Toolbar`].
98#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
99pub struct ToolbarAction {
100    label: String,
101    icon: Option<IconName>,
102    style: ButtonStyle,
103    disabled: bool,
104    icon_only: bool,
105    hotkey: Option<Hotkey>,
106    on_click: Option<Rc<dyn Fn()>>,
107    accessible_name: Option<String>,
108    accessible_description: Option<String>,
109}
110
111impl fmt::Debug for ToolbarAction {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        f.debug_struct("ToolbarAction").field("label", &self.label).finish_non_exhaustive()
114    }
115}
116
117#[cfg_attr(feature = "live", ravenrs_live::expose)]
118impl ToolbarAction {
119    /// A ghost button with this text.
120    pub fn new(label: impl Into<String>) -> Self {
121        Self {
122            label: label.into(),
123            icon: None,
124            style: ButtonStyle::Ghost,
125            disabled: false,
126            icon_only: false,
127            hotkey: None,
128            on_click: None,
129            accessible_name: None,
130            accessible_description: None,
131        }
132    }
133
134    /// What screen readers call it, instead of its label.
135    pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
136        self.accessible_name = Some(name.into());
137        self
138    }
139
140    /// What screen readers say after its name. Its keys follow, when it has them.
141    pub fn accessible_description(mut self, text: impl Into<String>) -> Self {
142        self.accessible_description = Some(text.into());
143        self
144    }
145
146    /// What screen readers say after its name: its description, then its keys.
147    fn description(&self) -> Option<String> {
148        let keys = self.hotkey.as_ref().map(Hotkey::label);
149        let parts: Vec<&str> =
150            [self.accessible_description.as_deref(), keys.as_deref()].into_iter().flatten().collect();
151        (!parts.is_empty()).then(|| parts.join(". "))
152    }
153
154    /// An icon before the label.
155    pub fn icon(mut self, icon: IconName) -> Self {
156        self.icon = Some(icon);
157        self
158    }
159
160    /// Shows only the icon; the label stays as its name, and in the overflow menu.
161    pub fn icon_only(mut self) -> Self {
162        self.icon_only = true;
163        self
164    }
165
166    /// The button's style. `Ghost` by default.
167    pub fn style(mut self, style: ButtonStyle) -> Self {
168        self.style = style;
169        self
170    }
171
172    /// Greys the command out. It keeps its place, so its neighbors do not move
173    /// under the pointer, but it is left out of the overflow menu.
174    pub fn disabled(mut self, disabled: bool) -> Self {
175        self.disabled = disabled;
176        self
177    }
178
179    /// Runs the command with these keys, also from the overflow menu's place, and
180    /// shows them there. Off while the command is disabled.
181    pub fn hotkey(mut self, hotkey: Hotkey) -> Self {
182        self.hotkey = Some(hotkey);
183        self
184    }
185
186    /// Runs `handler` when the command is chosen.
187    pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
188        self.on_click = Some(Rc::new(handler));
189        self
190    }
191
192    fn room(&self, cx: &mut BuildCx<'_>) -> f32 {
193        if self.icon_only && self.icon.is_some() {
194            return MORE_ROOM;
195        }
196        let theme = *cx.theme();
197        let attrs = util::attrs(&theme, TextStyle::Callout, None);
198        let text = cx.measure_text(&self.label, &attrs).width;
199        text + BUTTON_PADDING + if self.icon.is_some() { ICON_ROOM } else { 0.0 }
200    }
201
202    fn view(&self) -> AnyView {
203        let handler = self.on_click.clone();
204        let click = move || {
205            if let Some(handler) = &handler {
206                handler();
207            }
208        };
209        let name = self.accessible_name.clone().unwrap_or_else(|| self.label.clone());
210        let description = self.description();
211        match (self.icon_only, self.icon) {
212            (true, Some(icon)) => {
213                let mut button = IconButton::new(icon)
214                    .label(name)
215                    .style(self.style)
216                    .size(ButtonSize::Small)
217                    .disabled(self.disabled)
218                    .on_click(click);
219                if let Some(description) = description {
220                    button = button.description(description);
221                }
222                Box::new(button)
223            }
224            (_, icon) => {
225                let mut button = Button::new(self.label.clone())
226                    .style(self.style)
227                    .size(ButtonSize::Small)
228                    .disabled(self.disabled)
229                    .on_click(click);
230                if self.accessible_name.is_some() {
231                    button = button.accessible_name(name);
232                }
233                if let Some(description) = description {
234                    button = button.accessible_description(description);
235                }
236                if let Some(icon) = icon {
237                    button = button.icon(icon);
238                }
239                Box::new(button)
240            }
241        }
242    }
243
244    fn menu_item(&self) -> MenuItem {
245        let mut item = MenuItem::new(self.label.clone());
246        if let Some(icon) = self.icon {
247            item = item.icon(icon);
248        }
249        if self.style == ButtonStyle::Danger {
250            item = item.destructive();
251        }
252        if let Some(hotkey) = &self.hotkey {
253            item = item.shortcut(hotkey.label());
254        }
255        if let Some(handler) = self.on_click.clone() {
256            item = item.on_select(move || handler());
257        }
258        item
259    }
260}
261
262enum ToolbarEntry {
263    Action(ToolbarAction),
264    Separator,
265    View(AnyView, f32),
266}
267
268/// Makes the keys of these commands work for as long as they are built.
269fn register_hotkeys(cx: &mut BuildCx<'_>, entries: &[ToolbarEntry]) {
270    // Shift first: without it, the same key would answer before.
271    for wants_shift in [true, false] {
272        for entry in entries {
273            if let ToolbarEntry::Action(ToolbarAction {
274                hotkey: Some(hotkey),
275                on_click: Some(handler),
276                disabled: false,
277                ..
278            }) = entry
279                && hotkey.shift == wants_shift
280            {
281                hotkey.register(cx, handler.clone());
282            }
283        }
284    }
285}
286
287/// The element of one entry of a bar of commands.
288fn entry_node(cx: &mut BuildCx<'_>, index: usize, entry: &ToolbarEntry) -> NodeId {
289    match entry {
290        ToolbarEntry::Action(action) => cx.keyed_child(index as u64, action.view().as_ref()),
291        ToolbarEntry::View(view, _) => cx.keyed_child(index as u64, view.as_ref()),
292        ToolbarEntry::Separator => {
293            let line = cx.theme().color(Semantic::Border);
294            cx.container(
295                LayoutStyle { width: Some(1.0), height: Some(18.0), ..LayoutStyle::leaf() },
296                &[],
297                Paint::Quad(QuadPaint::new(line)),
298            )
299        }
300    }
301}
302
303/// A row of commands. What does not fit moves to a menu at its end, from the
304/// last command back, so the order never changes.
305///
306/// ```
307/// use ravenrs_widgets::{IconName, Toolbar, ToolbarAction, Ui};
308///
309/// let bar = Toolbar::new()
310///     .action(ToolbarAction::new("New").icon(IconName::Plus).on_click(|| {}))
311///     .separator()
312///     .action(ToolbarAction::new("Delete").icon(IconName::Trash2).disabled(true));
313/// let mut ui = Ui::new(bar, ravenrs_style::Theme::raven_dark());
314/// ui.update();
315/// ```
316#[derive(Default)]
317#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
318pub struct Toolbar {
319    entries: Vec<ToolbarEntry>,
320    label: Option<String>,
321    aligned_end: bool,
322}
323
324impl fmt::Debug for Toolbar {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        f.debug_struct("Toolbar").field("entries", &self.entries.len()).finish_non_exhaustive()
327    }
328}
329
330#[cfg_attr(feature = "live", ravenrs_live::expose)]
331impl Toolbar {
332    /// An empty toolbar.
333    pub fn new() -> Self {
334        Self::default()
335    }
336
337    /// Appends a command.
338    pub fn action(mut self, action: ToolbarAction) -> Self {
339        self.entries.push(ToolbarEntry::Action(action));
340        self
341    }
342
343    /// Appends a line between groups of commands.
344    pub fn separator(mut self) -> Self {
345        self.entries.push(ToolbarEntry::Separator);
346        self
347    }
348
349    /// Appends any view, such as a search field, that needs `width` logical
350    /// pixels. It stays on the bar while there is room, and is dropped after that.
351    pub fn view(mut self, width: f32, view: impl View + 'static) -> Self {
352        self.entries.push(ToolbarEntry::View(Box::new(view), width));
353        self
354    }
355
356    /// The name screen readers announce.
357    pub fn label(mut self, label: impl Into<String>) -> Self {
358        self.label = Some(label.into());
359        self
360    }
361
362    /// Takes the room left in its row, with the commands at the far end.
363    pub(crate) fn aligned_end(mut self) -> Self {
364        self.aligned_end = true;
365        self
366    }
367}
368
369/// How many entries of these widths fit in `available`, leaving room for the
370/// overflow menu if some have to go.
371pub(crate) fn entries_that_fit(widths: &[f32], available: f32, more: f32, gap: f32) -> usize {
372    let total: f32 = widths.iter().map(|w| w + gap).sum();
373    if total - gap <= available {
374        return widths.len();
375    }
376    let mut used = 0.0;
377    for (i, w) in widths.iter().enumerate() {
378        used += w + gap;
379        if used + more > available {
380            return i;
381        }
382    }
383    widths.len()
384}
385
386impl View for Toolbar {
387    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
388        register_hotkeys(cx, &self.entries);
389        let widths: Vec<f32> = self
390            .entries
391            .iter()
392            .map(|entry| match entry {
393                ToolbarEntry::Action(action) => action.room(cx),
394                ToolbarEntry::Separator => SEPARATOR_ROOM,
395                ToolbarEntry::View(_, width) => *width,
396            })
397            .collect();
398        let fits = cx
399            .scroll_viewport()
400            .map_or(widths.len(), |v| entries_that_fit(&widths, v.width, MORE_ROOM, TOOLBAR_GAP));
401        // A line with nothing after it says nothing.
402        let shown = if fits < widths.len()
403            && matches!(self.entries.get(fits.wrapping_sub(1)), Some(ToolbarEntry::Separator))
404        {
405            fits - 1
406        } else {
407            fits
408        };
409
410        let mut children = Vec::with_capacity(shown + 1);
411        for (i, entry) in self.entries[..shown].iter().enumerate() {
412            children.push(entry_node(cx, i, entry));
413        }
414        let hidden = self.entries[shown..]
415            .iter()
416            .filter_map(|entry| match entry {
417                ToolbarEntry::Action(action) if !action.disabled => Some(Entry::Item(action.menu_item())),
418                _ => None,
419            })
420            .collect::<Vec<_>>();
421        if !hidden.is_empty() {
422            children.push(cx.keyed_child(u64::MAX, &MoreMenu(hidden)));
423        }
424        let node = cx.container(
425            LayoutStyle {
426                spacing: TOOLBAR_GAP,
427                align: Align::Center,
428                align_self: Some(Align::Stretch),
429                height: Some(36.0),
430                min_width: Some(0.0),
431                overflow_hidden: true,
432                grow: if self.aligned_end { 1.0 } else { 0.0 },
433                justify: if self.aligned_end { Justify::End } else { Justify::Start },
434                ..LayoutStyle::stack(Axis::Horizontal)
435            },
436            &children,
437            Paint::None,
438        );
439        // Being a scroll container is what tells the next build how wide it is.
440        cx.scrollable_virtual(node, false);
441        let mut semantics = Semantics::new(ravenrs_core::Role::Group);
442        if let Some(label) = &self.label {
443            semantics = semantics.name(label.clone());
444        }
445        cx.semantics(node, semantics);
446        node
447    }
448}
449
450/// How opaque a [`FloatingToolbar`] is, so that it says it is over what is under it.
451const FLOATING_OPACITY: f32 = 0.92;
452
453/// A translucent row of commands floating over its content, such as a zoom bar over a photo.
454/// Clicks on it never reach the content. Tab goes through its commands.
455///
456/// With [`FloatingToolbar::movable`] it has a handle: dragging it, or its arrow keys, take the
457/// bar anywhere in the content, and a double click brings it home. Smaller content than the bar
458/// leaves it at the top left corner.
459///
460/// ```
461/// use ravenrs_layout::Align;
462/// use ravenrs_widgets::{FloatingToolbar, IconName, Text, ToolbarAction, Ui};
463///
464/// let bar = FloatingToolbar::new(Text::new("A photo"))
465///     .label("Zoom")
466///     .action(ToolbarAction::new("Fit").on_click(|| {}))
467///     .separator()
468///     .action(ToolbarAction::new("Zoom in").icon(IconName::Plus).icon_only().on_click(|| {}))
469///     .place(Align::Start, Align::End);
470/// let mut ui = Ui::new(bar, ravenrs_style::Theme::raven_dark());
471/// ui.update();
472/// ```
473pub struct FloatingToolbar<C> {
474    content: C,
475    bar: FloatingBar,
476    fill: bool,
477}
478
479/// What a [`FloatingToolbar`] has apart from its content.
480struct FloatingBar {
481    entries: Vec<ToolbarEntry>,
482    label: Option<String>,
483    across: Align,
484    down: Align,
485    margin: f32,
486    place: Option<Binding<Option<Point>>>,
487}
488
489impl<C> fmt::Debug for FloatingToolbar<C> {
490    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
491        f.debug_struct("FloatingToolbar").field("entries", &self.bar.entries.len()).finish_non_exhaustive()
492    }
493}
494
495#[cfg_attr(feature = "live", ravenrs_live::expose)]
496impl<C: View> FloatingToolbar<C> {
497    /// An empty bar at the bottom center of `content`.
498    pub fn new(content: C) -> Self {
499        Self {
500            content,
501            bar: FloatingBar {
502                entries: Vec::new(),
503                label: None,
504                across: Align::Center,
505                down: Align::End,
506                margin: 12.0,
507                place: None,
508            },
509            fill: false,
510        }
511    }
512
513    /// Appends a command.
514    pub fn action(mut self, action: ToolbarAction) -> Self {
515        self.bar.entries.push(ToolbarEntry::Action(action));
516        self
517    }
518
519    /// Appends a line between groups of commands.
520    pub fn separator(mut self) -> Self {
521        self.bar.entries.push(ToolbarEntry::Separator);
522        self
523    }
524
525    /// Appends any view, such as a [`ValueSlider`](crate::ValueSlider).
526    pub fn view(mut self, view: impl View + 'static) -> Self {
527        self.bar.entries.push(ToolbarEntry::View(Box::new(view), 0.0));
528        self
529    }
530
531    /// The name screen readers announce.
532    pub fn label(mut self, label: impl Into<String>) -> Self {
533        self.bar.label = Some(label.into());
534        self
535    }
536
537    /// Which side of the content it stays on, across and down: `Start`, `Center` or `End`.
538    /// At the bottom center by default.
539    pub fn place(mut self, across: Align, down: Align) -> Self {
540        self.bar.across = across;
541        self.bar.down = down;
542        self
543    }
544
545    /// How far from the edges of the content it stays, in logical pixels (12 by default).
546    pub fn margin(mut self, margin: f32) -> Self {
547        self.bar.margin = margin;
548        self
549    }
550
551    /// Adds a handle that moves the bar. `place` is where it was left, from the top left
552    /// corner of the content, written when the drag ends so that the app can keep it; `None`
553    /// is the place given by [`Self::place`].
554    pub fn movable(mut self, place: Binding<Option<Point>>) -> Self {
555        self.bar.place = Some(place);
556        self
557    }
558
559    /// Takes the free space of the parent, for content that fills a stage or a whole screen.
560    pub fn fill(mut self) -> Self {
561        self.fill = true;
562        self
563    }
564}
565
566/// The handle of a movable [`FloatingToolbar`].
567struct BarHandle<'a> {
568    at: Point,
569    largest: Point,
570    place: &'a Binding<Option<Point>>,
571    dragging: State<Option<Point>>,
572    name: String,
573}
574
575impl View for BarHandle<'_> {
576    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
577        let theme = *cx.theme();
578        let ink = theme.color(if cx.is_hovered() { Semantic::Ink } else { Semantic::InkMuted });
579        let icon = util::icon(cx, IconName::EllipsisVertical, 14.0, ink);
580        let node = cx.container(
581            LayoutStyle {
582                width: Some(16.0),
583                height: Some(ButtonSize::Small.height()),
584                align: Align::Center,
585                justify: Justify::Center,
586                ..LayoutStyle::stack(Axis::Horizontal)
587            },
588            &[icon],
589            Paint::None,
590        );
591        move_by_handle(cx, node, self.at, self.largest, self.place, &self.dragging);
592        let home = self.place.clone();
593        cx.on_double_click(node, Rc::new(move || home.set(None)));
594        cx.semantics(node, Semantics::new(Role::Button).name(self.name.clone()));
595        cx.focusable(node, theme.radii.sm);
596        cx.cursor(node, CursorIcon::Pointer);
597        node
598    }
599}
600
601/// Where the bar goes along one side of the content.
602fn along(side: Align, room: f32, size: f32, margin: f32) -> f32 {
603    let at = match side {
604        Align::Center => (room - size) / 2.0,
605        Align::End => room - size - margin,
606        _ => margin,
607    };
608    at.max(PANEL_MARGIN)
609}
610
611/// The bar itself. Its own view, so that the size it reports is the bar's.
612struct BarSurface<'a> {
613    bar: &'a FloatingBar,
614    stage: Size,
615}
616
617impl View for BarSurface<'_> {
618    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
619        let theme = *cx.theme();
620        let bar = self.bar;
621        // Both sizes come from the last layout, and a change in either builds again.
622        let size = cx.scroll_viewport().unwrap_or(Size::ZERO);
623        let largest = Point::new(
624            (self.stage.width - size.width - PANEL_MARGIN).max(PANEL_MARGIN),
625            (self.stage.height - size.height - PANEL_MARGIN).max(PANEL_MARGIN),
626        );
627        let home = Point::new(
628            along(bar.across, self.stage.width, size.width, bar.margin),
629            along(bar.down, self.stage.height, size.height, bar.margin),
630        );
631        let dragging: State<Option<Point>> = cx.use_state(|| None);
632        let at = dragging
633            .get()
634            .or_else(|| bar.place.as_ref().and_then(Binding::get))
635            .map_or(home, |wanted| panel_inside(wanted, largest));
636
637        let mut children = Vec::with_capacity(bar.entries.len() + 1);
638        if let Some(place) = &bar.place {
639            let name = match &bar.label {
640                Some(label) => format!("{}: {label}", cx.labels().panel_move),
641                None => cx.labels().panel_move.clone(),
642            };
643            let handle = BarHandle { at, largest, place, dragging, name };
644            children.push(cx.keyed_child(u64::MAX, &handle));
645        }
646        for (i, entry) in bar.entries.iter().enumerate() {
647            children.push(entry_node(cx, i, entry));
648        }
649        let fill = theme.color(Semantic::SurfaceElevated);
650        let node = cx.container(
651            LayoutStyle {
652                absolute: Some((at.x, at.y)),
653                padding: Insets::uniform(theme.spacing.s1),
654                spacing: TOOLBAR_GAP,
655                align: Align::Center,
656                ..LayoutStyle::stack(Axis::Horizontal)
657            },
658            &children,
659            Paint::Quad(QuadPaint {
660                corner_radius: theme.radii.lg,
661                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
662                shadow: Some(theme.elevation.raised),
663                ..QuadPaint::new(fill.with_alpha(fill.a * FLOATING_OPACITY))
664            }),
665        );
666        cx.report_frame(node);
667        // What lands on it stays on it, instead of reaching the content.
668        cx.cursor(node, CursorIcon::Default);
669        let mut semantics = Semantics::new(Role::Toolbar);
670        if let Some(label) = &bar.label {
671            semantics = semantics.name(label.clone());
672        }
673        cx.semantics(node, semantics);
674        node
675    }
676}
677
678impl<C: View> View for FloatingToolbar<C> {
679    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
680        register_hotkeys(cx, &self.bar.entries);
681        let content = cx.child(&self.content);
682        let stage = cx.scroll_viewport().unwrap_or(Size::ZERO);
683        let surface = cx.child(&BarSurface { bar: &self.bar, stage });
684        let root = cx.container(
685            LayoutStyle {
686                align: Align::Stretch,
687                align_self: Some(Align::Stretch),
688                grow: if self.fill { 1.0 } else { 0.0 },
689                min_height: self.fill.then_some(0.0),
690                min_width: self.fill.then_some(0.0),
691                ..LayoutStyle::stack(Axis::Vertical)
692            },
693            &[content, surface],
694            Paint::None,
695        );
696        cx.report_frame(root);
697        root
698    }
699}
700
701struct MoreMenu(Vec<Entry>);
702
703impl View for MoreMenu {
704    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
705        menu_trigger(
706            cx,
707            &self.0,
708            IconName::Ellipsis,
709            ButtonStyle::Ghost,
710            ButtonSize::Small,
711            cx.labels().more_commands.clone(),
712        )
713    }
714}
715
716/// A square button that opens `entries` under it, and takes focus back after.
717pub(crate) fn menu_trigger(
718    cx: &mut BuildCx<'_>,
719    entries: &[Entry],
720    icon: IconName,
721    style: ButtonStyle,
722    size: ButtonSize,
723    name: String,
724) -> NodeId {
725    let open: State<bool> = cx.use_state(|| false);
726    let toggle = open.clone();
727    let anchor = cx.child(
728        &IconButton::new(icon)
729            .label(name)
730            .style(style)
731            .size(size)
732            .on_click(move || toggle.update(|o| *o = !*o)),
733    );
734    let is_open = open.get();
735    cx.update_semantics(anchor, |s| s.expanded = Some(is_open));
736    let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
737    if is_open {
738        was_open.set(true);
739        let close: Rc<dyn Fn()> = Rc::new(move || open.set(false));
740        cx.overlay(
741            anchor,
742            Placement::Below,
743            &MenuList { entries, close: close.clone(), on_side: None },
744            Some(close),
745        );
746    } else if was_open.replace(false) {
747        cx.request_focus_node(anchor);
748    }
749    anchor
750}
751
752/// A button with a main command and, beside it, a menu of alternatives, such as
753/// Save, with Save and Close and Save and New.
754///
755/// ```
756/// use ravenrs_widgets::{MenuItem, SplitButton, Ui};
757///
758/// let save = SplitButton::new("Save")
759///     .on_click(|| {})
760///     .item(MenuItem::new("Save and close").on_select(|| {}));
761/// let mut ui = Ui::new(save, ravenrs_style::Theme::raven_dark());
762/// ui.update();
763/// ```
764#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
765pub struct SplitButton {
766    label: String,
767    icon: Option<IconName>,
768    style: ButtonStyle,
769    size: ButtonSize,
770    disabled: bool,
771    on_click: Option<Rc<dyn Fn()>>,
772    entries: Vec<Entry>,
773}
774
775impl fmt::Debug for SplitButton {
776    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
777        f.debug_struct("SplitButton").field("label", &self.label).finish_non_exhaustive()
778    }
779}
780
781#[cfg_attr(feature = "live", ravenrs_live::expose)]
782impl SplitButton {
783    /// A primary button with this text and an empty menu.
784    pub fn new(label: impl Into<String>) -> Self {
785        Self {
786            label: label.into(),
787            icon: None,
788            style: ButtonStyle::Primary,
789            size: ButtonSize::Medium,
790            disabled: false,
791            on_click: None,
792            entries: Vec::new(),
793        }
794    }
795
796    /// An icon before the label.
797    pub fn icon(mut self, icon: IconName) -> Self {
798        self.icon = Some(icon);
799        self
800    }
801
802    /// The buttons' style. `Primary` by default.
803    pub fn style(mut self, style: ButtonStyle) -> Self {
804        self.style = style;
805        self
806    }
807
808    /// The buttons' size. `Medium` by default.
809    pub fn size(mut self, size: ButtonSize) -> Self {
810        self.size = size;
811        self
812    }
813
814    /// Turns off the main command and the menu.
815    pub fn disabled(mut self, disabled: bool) -> Self {
816        self.disabled = disabled;
817        self
818    }
819
820    /// Runs `handler` when the main button is clicked.
821    pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
822        self.on_click = Some(Rc::new(handler));
823        self
824    }
825
826    /// Appends an alternative to the menu.
827    pub fn item(mut self, item: MenuItem) -> Self {
828        self.entries.push(Entry::Item(item));
829        self
830    }
831}
832
833impl View for SplitButton {
834    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
835        let handler = self.on_click.clone();
836        let mut main = Button::new(self.label.clone())
837            .style(self.style)
838            .size(self.size)
839            .disabled(self.disabled)
840            .on_click(move || {
841                if let Some(handler) = &handler {
842                    handler();
843                }
844            });
845        if let Some(icon) = self.icon {
846            main = main.icon(icon);
847        }
848        let main = cx.child(&main);
849        let name = format!("{}: {}", self.label, cx.labels().more_commands);
850        let trigger = if self.disabled {
851            cx.child(
852                &IconButton::new(IconName::ChevronDown)
853                    .label(name)
854                    .style(self.style)
855                    .size(self.size)
856                    .disabled(true),
857            )
858        } else {
859            menu_trigger(cx, &self.entries, IconName::ChevronDown, self.style, self.size, name)
860        };
861        cx.container(
862            LayoutStyle {
863                spacing: 1.0,
864                align: Align::Center,
865                align_self: Some(Align::Fit),
866                ..LayoutStyle::stack(Axis::Horizontal)
867            },
868            &[main, trigger],
869            Paint::None,
870        )
871    }
872}
873
874/// A question before something that cannot be undone. Holds what is being asked
875/// until it is answered; show [`ConfirmDialog`] once, and call
876/// [`ask`](Self::ask) from anywhere.
877#[derive(Clone)]
878#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
879pub struct ConfirmDialog(State<Option<Rc<Question>>>);
880
881struct Question {
882    title: String,
883    message: String,
884    action: String,
885    /// The word that has to be typed, exactly, before the action can run.
886    word: Option<String>,
887    typed: State<String>,
888    run: Rc<dyn Fn()>,
889}
890
891impl fmt::Debug for ConfirmDialog {
892    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
893        f.debug_struct("ConfirmDialog").field("asking", &self.0.peek().is_some()).finish()
894    }
895}
896
897impl Default for ConfirmDialog {
898    fn default() -> Self {
899        Self::new()
900    }
901}
902
903#[cfg_attr(feature = "live", ravenrs_live::expose)]
904impl ConfirmDialog {
905    /// A dialog with nothing to ask yet.
906    pub fn new() -> Self {
907        Self(State::new(None))
908    }
909
910    /// Asks `title`, explaining with `message`. The button called `action` runs `run`.
911    pub fn ask(
912        &self,
913        title: impl Into<String>,
914        message: impl Into<String>,
915        action: impl Into<String>,
916        run: impl Fn() + 'static,
917    ) {
918        self.put(title.into(), message.into(), action.into(), None, Rc::new(run));
919    }
920
921    /// Like [`ask`](Self::ask), for what is too costly to answer with one click: the
922    /// button called `action` only works after `word` is typed exactly as it is, capitals
923    /// included, so a hurried click never runs `run`.
924    pub fn ask_typing(
925        &self,
926        title: impl Into<String>,
927        message: impl Into<String>,
928        action: impl Into<String>,
929        word: impl Into<String>,
930        run: impl Fn() + 'static,
931    ) {
932        self.put(title.into(), message.into(), action.into(), Some(word.into()), Rc::new(run));
933    }
934
935    fn put(&self, title: String, message: String, action: String, word: Option<String>, run: Rc<dyn Fn()>) {
936        let typed = State::new(String::new());
937        self.0.set(Some(Rc::new(Question { title, message, action, word, typed, run })));
938    }
939}
940
941impl View for ConfirmDialog {
942    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
943        let Some(question) = self.0.get() else { return cx.empty() };
944        let (read, write) = (self.0.clone(), self.0.clone());
945        let open = Binding::new(
946            move || read.get().is_some(),
947            move |open: bool| {
948                if !open {
949                    write.set(None);
950                }
951            },
952        );
953        let (cancel, done, run) = (self.0.clone(), self.0.clone(), question.run.clone());
954        let labels = cx.labels();
955        let typed_right = question.word.as_ref().is_none_or(|word| question.typed.get() == *word);
956        let mut dialog = Dialog::new(open, question.title.clone()).description(question.message.clone());
957        if let Some(word) = &question.word {
958            let (answered, run, word_typed) = (self.0.clone(), question.run.clone(), word.clone());
959            let field = TextField::new(question.typed.binding())
960                .placeholder(word.clone())
961                .autofocus()
962                .on_submit(move |text| {
963                    if text == word_typed {
964                        answered.set(None);
965                        run();
966                    }
967                });
968            dialog = dialog.content(
969                VStack::new((
970                    Text::new(format!("{} {word}", labels.confirm_by_typing)).style(TextStyle::Callout),
971                    field,
972                ))
973                .spacing(8.0)
974                .align(Align::Stretch),
975            );
976        }
977        cx.child(
978            &dialog
979                .action(
980                    Button::new(labels.cancel.clone())
981                        .style(ButtonStyle::Secondary)
982                        .on_click(move || cancel.set(None)),
983                )
984                .action(
985                    Button::new(question.action.clone())
986                        .style(ButtonStyle::Danger)
987                        .disabled(!typed_right)
988                        .on_click(move || {
989                            done.set(None);
990                            run();
991                        }),
992                ),
993        )
994    }
995}
996
997#[cfg(test)]
998mod tests {
999    use super::entries_that_fit;
1000
1001    #[test]
1002    fn everything_fits_or_the_menu_takes_room() {
1003        let w = [50.0, 50.0, 50.0];
1004        assert_eq!(entries_that_fit(&w, 160.0, 30.0, 0.0), 3, "no menu is needed");
1005        assert_eq!(entries_that_fit(&w, 150.0, 30.0, 0.0), 3, "exactly enough");
1006        assert_eq!(entries_that_fit(&w, 149.0, 30.0, 0.0), 2, "the menu pushes one out");
1007        assert_eq!(entries_that_fit(&w, 100.0, 30.0, 0.0), 1);
1008        assert_eq!(entries_that_fit(&w, 20.0, 30.0, 0.0), 0);
1009        assert_eq!(entries_that_fit(&[], 0.0, 30.0, 0.0), 0);
1010        assert_eq!(entries_that_fit(&w, 158.0, 30.0, 4.0), 3, "gaps only between entries");
1011    }
1012}