Skip to main content

ravenrs_widgets/
floating.rs

1//! More floating UI: context menus, hover cards, sheets, splash screens, toasts,
2//! the command palette and the combobox.
3
4use std::cell::Cell;
5use std::collections::hash_map::DefaultHasher;
6use std::fmt;
7use std::hash::{Hash, Hasher};
8use std::rc::Rc;
9
10use ravenrs_animation::Transition;
11use ravenrs_core::{Border, Color, CursorIcon, Effect, Insets, Key, Point, Role};
12use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
13use ravenrs_reactive::{Binding, State};
14use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
15
16use crate::context::{BuildCx, NodeId, Paint, Placement, QuadPaint, Semantics};
17use crate::overlay::{Entry, MenuList, PanelOf, panel};
18use crate::{
19    Button, ButtonSize, ButtonStyle, IconButton, IconName, MenuItem, Motion, TextField, Tone, View, motion,
20    util,
21};
22
23/// Opens a menu at the pointer on right click (or long press, planned) over its content.
24pub struct ContextMenu<V> {
25    content: V,
26    entries: Vec<Entry>,
27}
28
29impl<V> fmt::Debug for ContextMenu<V> {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        f.debug_struct("ContextMenu").field("entries", &self.entries.len()).finish_non_exhaustive()
32    }
33}
34
35#[cfg_attr(feature = "live", ravenrs_live::expose)]
36impl<V: View> ContextMenu<V> {
37    /// Wraps `content` with an initially empty menu.
38    pub fn new(content: V) -> Self {
39        Self { content, entries: Vec::new() }
40    }
41
42    /// Appends a menu item.
43    pub fn item(mut self, item: MenuItem) -> Self {
44        self.entries.push(Entry::Item(item));
45        self
46    }
47
48    /// Appends a separator line.
49    pub fn separator(mut self) -> Self {
50        self.entries.push(Entry::Separator);
51        self
52    }
53}
54
55impl<V: View> View for ContextMenu<V> {
56    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
57        let at: State<Option<Point>> = cx.use_state(|| None);
58        let child = cx.child(&self.content);
59        let node = cx.container(
60            LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
61            &[child],
62            Paint::None,
63        );
64        let open = at.clone();
65        cx.on_context_menu(node, Rc::new(move |p| open.set(Some(p))));
66        if let Some(p) = at.get() {
67            let close: Rc<dyn Fn()> = {
68                let at = at.clone();
69                Rc::new(move || at.set(None))
70            };
71            cx.overlay(
72                node,
73                Placement::At(p),
74                &MenuList { entries: &self.entries, close: close.clone(), on_side: None },
75                Some(close),
76            );
77        }
78        node
79    }
80}
81
82/// Rich content shown below a trigger while the pointer is over the trigger or
83/// the card itself.
84pub struct HoverCard<A, C> {
85    trigger: A,
86    content: C,
87}
88
89impl<A, C> fmt::Debug for HoverCard<A, C> {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.write_str("HoverCard")
92    }
93}
94
95#[cfg_attr(feature = "live", ravenrs_live::expose)]
96impl<A: View, C: View> HoverCard<A, C> {
97    /// Shows `content` below `trigger` on hover.
98    pub fn new(trigger: A, content: C) -> Self {
99        Self { trigger, content }
100    }
101}
102
103impl<A: View, C: View> View for HoverCard<A, C> {
104    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
105        let child = cx.child(&self.trigger);
106        let node = cx.container(
107            LayoutStyle { align_self: Some(Align::Fit), ..LayoutStyle::stack(Axis::Horizontal) },
108            &[child],
109            Paint::None,
110        );
111        cx.cursor(node, CursorIcon::Default);
112        if cx.is_hover_within() {
113            cx.overlay(node, Placement::Below, &PanelOf(&self.content), None);
114        }
115        node
116    }
117}
118
119/// The edge a [`Sheet`] slides in from.
120#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
121#[non_exhaustive]
122#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
123pub enum Side {
124    /// The left edge.
125    Left,
126    /// The right edge.
127    #[default]
128    Right,
129}
130
131/// A panel that slides over the UI from one edge, over a dimmed backdrop.
132#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
133pub struct Sheet {
134    open: Binding<bool>,
135    title: String,
136    description: Option<String>,
137    content: Option<crate::AnyView>,
138    side: Side,
139    width: f32,
140}
141
142impl fmt::Debug for Sheet {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.debug_struct("Sheet").field("title", &self.title).finish_non_exhaustive()
145    }
146}
147
148#[cfg_attr(feature = "live", ravenrs_live::expose)]
149impl Sheet {
150    /// A sheet shown while `open` is true; slides from the right, 400 logical pixels wide.
151    pub fn new(open: Binding<bool>, title: impl Into<String>) -> Self {
152        Self { open, title: title.into(), description: None, content: None, side: Side::Right, width: 400.0 }
153    }
154
155    /// Adds a muted description under the title.
156    pub fn description(mut self, text: impl Into<String>) -> Self {
157        self.description = Some(text.into());
158        self
159    }
160
161    /// Sets the sheet body.
162    pub fn content(mut self, content: impl View + 'static) -> Self {
163        self.content = Some(Box::new(content));
164        self
165    }
166
167    /// Sets the edge it slides in from.
168    pub fn side(mut self, side: Side) -> Self {
169        self.side = side;
170        self
171    }
172
173    /// Sets the panel width in logical pixels.
174    pub fn width(mut self, width: f32) -> Self {
175        self.width = width;
176        self
177    }
178}
179
180/// The dimmed backdrop and sliding panel of a sheet. `bare` leaves out the title
181/// row and the padding, for content that brings its own (a sidebar).
182pub(crate) struct SheetLayer<'a> {
183    pub open: &'a Binding<bool>,
184    pub title: &'a str,
185    pub description: Option<&'a str>,
186    pub content: Option<&'a dyn View>,
187    pub side: Side,
188    pub width: f32,
189    pub bare: bool,
190}
191
192impl View for SheetLayer<'_> {
193    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
194        let theme = *cx.theme();
195        let s = self;
196        let viewport = cx.viewport();
197        let mut body = Vec::new();
198        if !s.bare {
199            let close = s.open.clone();
200            let title = util::label(cx, s.title, TextStyle::Title, None, theme.color(Semantic::Ink));
201            let title = cx.container(
202                LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
203                &[title],
204                Paint::None,
205            );
206            let x = cx.child(
207                &IconButton::new(IconName::X)
208                    .label(cx.labels().close.clone())
209                    .size(ButtonSize::Small)
210                    .on_click(move || close.set(false)),
211            );
212            let header = cx.container(
213                LayoutStyle { align: Align::Center, spacing: 12.0, ..LayoutStyle::stack(Axis::Horizontal) },
214                &[title, x],
215                Paint::None,
216            );
217            body.push(header);
218        }
219        if let Some(d) = s.description {
220            body.push(util::label(cx, d, TextStyle::Body, None, theme.color(Semantic::InkMuted)));
221        }
222        if let Some(c) = s.content {
223            body.push(cx.child(c));
224        }
225        let panel = cx.container(
226            LayoutStyle {
227                width: Some(s.width.min(viewport.width)),
228                height: Some(viewport.height),
229                padding: if s.bare { Insets::ZERO } else { Insets::uniform(24.0) },
230                spacing: if s.bare { 0.0 } else { 16.0 },
231                align: Align::Stretch,
232                ..LayoutStyle::stack(Axis::Vertical)
233            },
234            &body,
235            Paint::Quad(QuadPaint {
236                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
237                shadow: Some(theme.elevation.raised),
238                ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
239            }),
240        );
241        let mut semantics = Semantics::new(Role::Dialog).name(s.title).modal();
242        if let Some(d) = s.description {
243            semantics = semantics.description(d);
244        }
245        cx.semantics(panel, semantics);
246        cx.cursor(panel, CursorIcon::Default);
247        let away = s.width.min(viewport.width) * if s.side == Side::Right { 1.0 } else { -1.0 };
248        cx.motion(
249            panel,
250            Motion::new()
251                .enter(Effect::moved(away, 0.0), theme.motion.gentle)
252                .exit(Effect::moved(away, 0.0), theme.motion.base),
253        );
254        let scrim = motion::scrim(cx, viewport);
255        let backdrop = cx.container(
256            LayoutStyle {
257                width: Some(viewport.width),
258                height: Some(viewport.height),
259                justify: if s.side == Side::Right { Justify::End } else { Justify::Start },
260                ..LayoutStyle::stack(Axis::Horizontal)
261            },
262            &[scrim, panel],
263            Paint::None,
264        );
265        let close = s.open.clone();
266        cx.on_click(backdrop, Rc::new(move || close.set(false)));
267        backdrop
268    }
269}
270
271impl View for Sheet {
272    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
273        let anchor = cx.empty();
274        if self.open.get() {
275            let close = self.open.clone();
276            let layer = SheetLayer {
277                open: &self.open,
278                title: &self.title,
279                description: self.description.as_deref(),
280                content: self.content.as_deref(),
281                side: self.side,
282                width: self.width,
283                bare: false,
284            };
285            cx.overlay(anchor, Placement::Fill, &layer, Some(Rc::new(move || close.set(false))));
286        }
287        anchor
288    }
289}
290
291/// How wide the content of a [`SplashScreen`] gets, in logical pixels.
292const SPLASH_CONTENT_WIDTH: f32 = 320.0;
293/// The size of a [`SplashScreen`]'s icon, in logical pixels.
294const SPLASH_ICON_SIZE: f32 = 64.0;
295/// How long after the name the rest of a [`SplashScreen`] appears, in seconds.
296const SPLASH_REST_LAG: f64 = 0.2;
297/// Keeps a [`SplashScreen`] the same layer whatever opens before it.
298const SPLASH_OVERLAY_KEY: u64 = 0x7370_6c61_7368;
299
300/// The opening screen that covers the window while the app loads: a logo or
301/// icon, the app's name, a status message and its progress.
302///
303/// It takes no input and has nothing to focus, so the page behind it stays out
304/// of reach until the app sets `visible` to false and the screen fades out. It
305/// is on screen from the very first frame, with no fade in. Without
306/// [`Self::progress`] it shows a [`Spinner`](crate::Spinner).
307///
308/// Status and progress are plain values, so build it where they are read, such
309/// as in a [`Component`](crate::Component) body or a [`view_fn`](crate::view_fn).
310///
311/// ```
312/// use ravenrs_reactive::State;
313/// use ravenrs_widgets::{IconName, SplashScreen, View, view_fn};
314///
315/// let loading = State::new(true);
316/// let progress = State::new(0.25);
317/// let splash = view_fn(move || {
318///     SplashScreen::new(loading.binding(), "Raven Finance")
319///         .icon(IconName::Wallet)
320///         .status("Opening the database")
321///         .progress(progress.get())
322/// });
323/// ```
324#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
325pub struct SplashScreen {
326    visible: Binding<bool>,
327    name: String,
328    logo: Option<crate::AnyView>,
329    icon: Option<IconName>,
330    status: Option<String>,
331    progress: Option<f32>,
332    reveal_after: f64,
333}
334
335impl fmt::Debug for SplashScreen {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        f.debug_struct("SplashScreen").field("name", &self.name).finish_non_exhaustive()
338    }
339}
340
341#[cfg_attr(feature = "live", ravenrs_live::expose)]
342impl SplashScreen {
343    /// A splash screen for the app called `name`, shown while `visible` is true.
344    pub fn new(visible: Binding<bool>, name: impl Into<String>) -> Self {
345        Self {
346            visible,
347            name: name.into(),
348            logo: None,
349            icon: None,
350            status: None,
351            progress: None,
352            reveal_after: 0.0,
353        }
354    }
355
356    /// Shows the app's own artwork above the name, such as an [`Image`](crate::Image).
357    /// It replaces [`Self::icon`].
358    pub fn logo(mut self, logo: impl View + 'static) -> Self {
359        self.logo = Some(Box::new(logo));
360        self
361    }
362
363    /// Shows an icon in the accent color above the name.
364    pub fn icon(mut self, icon: IconName) -> Self {
365        self.icon = Some(icon);
366        self
367    }
368
369    /// Sets the one-line message under the name. Screen readers announce each new one.
370    pub fn status(mut self, status: impl Into<String>) -> Self {
371        self.status = Some(status.into());
372        self
373    }
374
375    /// Replaces the spinner with a progress bar at `value`, clamped to 0.0–1.0.
376    pub fn progress(mut self, value: f32) -> Self {
377        self.progress = Some(value);
378        self
379    }
380
381    /// Holds the name back this many seconds, and what is under it a little longer,
382    /// for an animated [`Self::logo`] to play alone. Their places are kept, so nothing
383    /// moves when they appear, and screen readers have them from the start. Where
384    /// motion is reduced they show at once.
385    pub fn reveal_after(mut self, seconds: f64) -> Self {
386        self.reveal_after = seconds;
387        self
388    }
389}
390
391/// Fades in `node` once `held` turns false, keeping its place until then.
392fn held_back(cx: &mut BuildCx<'_>, node: NodeId, held: bool, hidden: Effect, transition: Transition) {
393    let effect = if held { hidden } else { Effect::IDENTITY };
394    cx.motion(node, Motion::new().effect(effect, transition));
395}
396
397/// The status message, as a live region.
398struct SplashStatus<'a>(&'a str, f32);
399
400impl View for SplashStatus<'_> {
401    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
402        let theme = *cx.theme();
403        let attrs = util::attrs(&theme, TextStyle::Callout, None);
404        let style = LayoutStyle { max_width: Some(self.1), ..LayoutStyle::leaf() };
405        let text = cx.text_truncated(self.0, &attrs, theme.color(Semantic::InkMuted), style);
406        let style = LayoutStyle { max_width: Some(self.1), ..LayoutStyle::stack(Axis::Horizontal) };
407        let node = cx.container(style, &[text], Paint::None);
408        cx.semantics(node, Semantics::new(Role::Status));
409        cx.motion(node, Motion::new().enter(Effect::hidden(), theme.motion.fast));
410        node
411    }
412}
413
414/// A progress bar named for screen readers.
415struct SplashBar(f32);
416
417impl View for SplashBar {
418    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
419        let bar = cx.child(&crate::ProgressBar::new(self.0));
420        let name = cx.labels().loading.clone();
421        cx.label_control(bar, &name, None);
422        bar
423    }
424}
425
426struct SplashLayer<'a>(&'a SplashScreen);
427
428impl View for SplashLayer<'_> {
429    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
430        let theme = *cx.theme();
431        let s = self.0;
432        let viewport = cx.viewport();
433        let width = (viewport.width - theme.spacing.s6 * 2.0).clamp(0.0, SPLASH_CONTENT_WIDTH);
434
435        let now = cx.now();
436        let began = cx.use_state(|| now).get();
437        let reveal = if cx.reduced_motion() { 0.0 } else { s.reveal_after };
438        let name_at = began + reveal;
439        let rest_at = if reveal > 0.0 { name_at + SPLASH_REST_LAG } else { name_at };
440        for at in [name_at, rest_at] {
441            if now < at {
442                cx.wake_at(at);
443            }
444        }
445
446        let mut content = Vec::new();
447        if let Some(logo) = &s.logo {
448            content.push(cx.child(logo.as_ref()));
449        } else if let Some(icon) = s.icon {
450            content.push(util::icon(cx, icon, SPLASH_ICON_SIZE, theme.color(Semantic::Accent)));
451        }
452        let attrs = util::attrs(&theme, TextStyle::Title, None);
453        let style = LayoutStyle { max_width: Some(width), ..LayoutStyle::leaf() };
454        let name = cx.text_truncated(&s.name, &attrs, theme.color(Semantic::Ink), style);
455        held_back(cx, name, now < name_at, Effect::hidden().with_scale(1.04), theme.motion.slow);
456        content.push(name);
457
458        // Changing the node's identity is what makes every platform read a new message.
459        if let Some(status) = &s.status {
460            let mut hasher = DefaultHasher::new();
461            status.hash(&mut hasher);
462            let status = cx.keyed_child(hasher.finish(), &SplashStatus(status, width));
463            let holder = cx.container(LayoutStyle::stack(Axis::Vertical), &[status], Paint::None);
464            held_back(cx, holder, now < rest_at, Effect::hidden(), theme.motion.base);
465            content.push(holder);
466        }
467
468        let spinner =
469            crate::VStack::new((crate::Spinner::new().size(theme.spacing.s6),)).align(Align::Center);
470        let spinner = motion::collapse(cx, s.progress.is_none(), &spinner);
471        let bar = motion::collapse(cx, s.progress.is_some(), &SplashBar(s.progress.unwrap_or(1.0)));
472        let indicator = cx.container(
473            LayoutStyle { width: Some(width), align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
474            &[spinner, bar],
475            Paint::None,
476        );
477        held_back(cx, indicator, now < rest_at, Effect::hidden(), theme.motion.base);
478        content.push(indicator);
479
480        let node = cx.container(
481            LayoutStyle {
482                width: Some(viewport.width),
483                height: Some(viewport.height),
484                padding: Insets::uniform(theme.spacing.s6),
485                spacing: theme.spacing.s4,
486                align: Align::Center,
487                justify: Justify::Center,
488                ..LayoutStyle::stack(Axis::Vertical)
489            },
490            &content,
491            Paint::Quad(QuadPaint::new(theme.color(Semantic::Canvas))),
492        );
493        cx.semantics(node, Semantics::new(Role::Dialog).name(s.name.clone()).modal());
494        // Takes the clicks that would otherwise reach the page behind it.
495        cx.cursor(node, CursorIcon::Default);
496        cx.motion(
497            node,
498            Motion::new()
499                .enter(Effect::hidden(), theme.motion.base)
500                .exit(Effect::hidden().with_scale(1.02), theme.motion.slow),
501        );
502        node
503    }
504}
505
506impl View for SplashScreen {
507    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
508        let anchor = cx.empty();
509        if self.visible.get() {
510            // The app usually appears under it, with layers of its own built first.
511            cx.keyed_overlay(SPLASH_OVERLAY_KEY, anchor, Placement::Fill, &SplashLayer(self), None);
512        }
513        anchor
514    }
515}
516
517/// One notification in a [`Toaster`].
518#[derive(Clone, Debug, PartialEq)]
519#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
520pub struct Toast {
521    id: u64,
522    title: String,
523    message: String,
524    tone: Tone,
525    duration: f64,
526    /// When it was first shown, on the UI clock.
527    shown_at: Rc<Cell<Option<f64>>>,
528    extras: ToastExtras,
529}
530
531/// Where in the window a [`Toaster`] stacks its toasts.
532#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
533#[non_exhaustive]
534#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
535pub enum ToastPosition {
536    /// The top-left corner.
537    TopLeft,
538    /// The middle of the top edge.
539    TopCenter,
540    /// The top-right corner.
541    TopRight,
542    /// The bottom-left corner.
543    BottomLeft,
544    /// The middle of the bottom edge.
545    BottomCenter,
546    /// The bottom-right corner.
547    #[default]
548    BottomRight,
549}
550
551#[cfg_attr(feature = "live", ravenrs_live::expose)]
552impl ToastPosition {
553    fn placement(self) -> Placement {
554        match self {
555            Self::TopLeft => Placement::TopLeft,
556            Self::TopCenter => Placement::TopCenter,
557            Self::TopRight => Placement::TopRight,
558            Self::BottomLeft => Placement::BottomLeft,
559            Self::BottomCenter => Placement::BottomCenter,
560            Self::BottomRight => Placement::BottomRight,
561        }
562    }
563
564    /// Where a toast comes from and goes to: the edge it is nearest to.
565    fn travel(self) -> Effect {
566        match self {
567            Self::TopLeft | Self::BottomLeft => Effect::moved(-32.0, 0.0),
568            Self::TopRight | Self::BottomRight => Effect::moved(32.0, 0.0),
569            Self::TopCenter => Effect::moved(0.0, -32.0),
570            Self::BottomCenter => Effect::moved(0.0, 32.0),
571        }
572    }
573
574    fn at_bottom(self) -> bool {
575        matches!(self, Self::BottomLeft | Self::BottomCenter | Self::BottomRight)
576    }
577}
578
579/// What a toast can do besides showing: a button, something to run once it is gone, and
580/// a place of its own.
581#[derive(Clone, Default)]
582#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
583pub struct ToastExtras {
584    action: Option<(String, Rc<dyn Fn()>)>,
585    on_close: Option<Rc<dyn Fn()>>,
586    position: Option<ToastPosition>,
587}
588
589impl fmt::Debug for ToastExtras {
590    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
591        f.debug_struct("ToastExtras")
592            .field("action", &self.action.as_ref().map(|(label, _)| label))
593            .finish_non_exhaustive()
594    }
595}
596
597impl PartialEq for ToastExtras {
598    fn eq(&self, other: &Self) -> bool {
599        let same = |a: &Option<Rc<dyn Fn()>>, b: &Option<Rc<dyn Fn()>>| match (a, b) {
600            (Some(a), Some(b)) => Rc::ptr_eq(a, b),
601            (None, None) => true,
602            _ => false,
603        };
604        let action = |extras: &Self| extras.action.as_ref().map(|(_, run)| run.clone());
605        same(&action(self), &action(other))
606            && same(&self.on_close, &other.on_close)
607            && self.position == other.position
608    }
609}
610
611#[cfg_attr(feature = "live", ravenrs_live::expose)]
612impl ToastExtras {
613    /// Nothing besides showing.
614    pub fn new() -> Self {
615        Self::default()
616    }
617
618    /// A button on the toast. Pressing it runs this and closes the toast.
619    pub fn action(mut self, label: impl Into<String>, run: impl Fn() + 'static) -> Self {
620        self.action = Some((label.into(), Rc::new(run)));
621        self
622    }
623
624    /// Runs this once the toast is gone, by itself or closed by a person.
625    pub fn on_close(mut self, run: impl Fn() + 'static) -> Self {
626        self.on_close = Some(Rc::new(run));
627        self
628    }
629
630    /// Shows this toast here instead of where its [`Toaster`] puts the others.
631    pub fn position(mut self, position: ToastPosition) -> Self {
632        self.position = Some(position);
633        self
634    }
635}
636
637/// A handle to show toasts from anywhere (event handlers, async tasks).
638/// Place one [`Toaster`] in the UI to display them.
639///
640/// ```
641/// use ravenrs_widgets::{Toaster, Toasts, Tone, Ui};
642///
643/// let toasts = Toasts::new();
644/// let mut ui = Ui::new(Toaster::new(toasts.clone()), ravenrs_style::Theme::raven_dark());
645///
646/// toasts.show("Saved", "Your changes are stored.", Tone::Success);
647/// assert_eq!(toasts.len(), 1);
648/// ui.update();
649/// ```
650#[derive(Clone, Debug, Default)]
651#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
652pub struct Toasts {
653    list: State<Vec<Toast>>,
654    next: Rc<Cell<u64>>,
655}
656
657#[cfg_attr(feature = "live", ravenrs_live::expose)]
658impl Toasts {
659    /// An empty toast list.
660    pub fn new() -> Self {
661        Self::default()
662    }
663
664    /// Shows a toast for 4 seconds.
665    pub fn show(&self, title: impl Into<String>, message: impl Into<String>, tone: Tone) {
666        self.show_for(title, message, tone, 4.0);
667    }
668
669    /// Shows a toast for `seconds` seconds. At most four are kept; the oldest go first.
670    pub fn show_for(&self, title: impl Into<String>, message: impl Into<String>, tone: Tone, seconds: f64) {
671        self.show_with(title, message, tone, seconds, ToastExtras::new());
672    }
673
674    /// Shows a toast that stays until a person closes it or [`Self::dismiss`] does.
675    pub fn show_until_closed(&self, title: impl Into<String>, message: impl Into<String>, tone: Tone) {
676        self.show_for(title, message, tone, f64::INFINITY);
677    }
678
679    /// Shows a toast for `seconds` seconds, with a button or something to run once it is
680    /// gone. With `f64::INFINITY` it stays until closed.
681    pub fn show_with(
682        &self,
683        title: impl Into<String>,
684        message: impl Into<String>,
685        tone: Tone,
686        seconds: f64,
687        extras: ToastExtras,
688    ) {
689        let id = self.next.get();
690        self.next.set(id + 1);
691        let toast = Toast {
692            id,
693            title: title.into(),
694            message: message.into(),
695            tone,
696            duration: seconds,
697            shown_at: Rc::new(Cell::new(None)),
698            extras,
699        };
700        let mut gone = Vec::new();
701        self.list.update(|l| {
702            l.push(toast);
703            // Keep the stack short; the oldest go first.
704            let excess = l.len().saturating_sub(4);
705            gone.extend(l.drain(..excess));
706        });
707        tell_gone(&gone);
708    }
709
710    /// Removes the toast with this id, if still shown.
711    pub fn dismiss(&self, id: u64) {
712        let gone: Vec<Toast> = self.list.peek().iter().filter(|t| t.id == id).cloned().collect();
713        self.list.update(|l| l.retain(|t| t.id != id));
714        tell_gone(&gone);
715    }
716
717    /// Number of toasts currently shown.
718    pub fn len(&self) -> usize {
719        self.list.peek().len()
720    }
721
722    /// No toast is shown.
723    pub fn is_empty(&self) -> bool {
724        self.len() == 0
725    }
726}
727
728/// Displays [`Toasts`] stacked in a corner of the window, the bottom-right one unless
729/// told otherwise; each disappears after its duration or when closed.
730///
731/// ```
732/// use ravenrs_widgets::{ToastExtras, ToastPosition, Toaster, Toasts, Tone, Ui};
733///
734/// let toasts = Toasts::new();
735/// let toaster = Toaster::new(toasts.clone()).position(ToastPosition::TopCenter);
736/// let mut ui = Ui::new(toaster, ravenrs_style::Theme::raven_dark());
737///
738/// toasts.show_until_closed("Sending", "Three files left.", Tone::Accent);
739/// let corner = ToastExtras::new().position(ToastPosition::BottomRight);
740/// toasts.show_with("Slow connection", "It may take longer.", Tone::Warning, 8.0, corner);
741/// ui.update();
742/// ```
743#[derive(Debug)]
744#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
745pub struct Toaster {
746    toasts: Toasts,
747    bottom_inset: f32,
748    position: ToastPosition,
749}
750
751#[cfg_attr(feature = "live", ravenrs_live::expose)]
752impl Toaster {
753    /// Displays the toasts of `toasts`.
754    pub fn new(toasts: Toasts) -> Self {
755        Self { toasts, bottom_inset: 0.0, position: ToastPosition::default() }
756    }
757
758    /// Where in the window the toasts stack (the bottom-right corner by default). A toast
759    /// can ask for another place with [`ToastExtras::position`].
760    pub fn position(mut self, position: ToastPosition) -> Self {
761        self.position = position;
762        self
763    }
764
765    /// Lifts the toasts by this much, in logical pixels, to clear something fixed
766    /// along the bottom of the window, such as a [`StatusBar`](crate::StatusBar).
767    pub fn bottom_inset(mut self, inset: f32) -> Self {
768        self.bottom_inset = inset;
769        self
770    }
771}
772
773/// The layers of a [`Toaster`], one for each corner in use.
774const TOAST_OVERLAY_KEY: u64 = 0x746f_6173_7400_0000;
775
776fn tell_gone(gone: &[Toast]) {
777    for toast in gone {
778        if let Some(run) = &toast.extras.on_close {
779            run();
780        }
781    }
782}
783
784fn tone_color(theme: &Theme, tone: Tone) -> (Color, IconName) {
785    match tone {
786        Tone::Neutral => (theme.color(Semantic::InkMuted), IconName::Bell),
787        Tone::Accent => {
788            (theme.color(Semantic::Accent).lerp(theme.color(Semantic::Ink), 0.25), IconName::Info)
789        }
790        Tone::Success => (theme.color(Semantic::Success), IconName::CircleCheck),
791        Tone::Warning => (theme.color(Semantic::Warning), IconName::TriangleAlert),
792        Tone::Danger => (theme.color(Semantic::Danger), IconName::CircleAlert),
793    }
794}
795
796struct ToastStack<'a>(&'a [Toast], Toasts, f32, ToastPosition);
797
798impl View for ToastStack<'_> {
799    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
800        let theme = *cx.theme();
801        let mut cards = Vec::new();
802        for t in self.0 {
803            let (color, icon) = tone_color(&theme, t.tone);
804            let icon = util::icon(cx, icon, 18.0, color);
805            let title = util::label(
806                cx,
807                &t.title,
808                TextStyle::Body,
809                Some(Weight::SEMIBOLD),
810                theme.color(Semantic::Ink),
811            );
812            let msg = util::label(cx, &t.message, TextStyle::Callout, None, theme.color(Semantic::InkMuted));
813            let mut lines = vec![title, msg];
814            if let Some((label, run)) = &t.extras.action {
815                let (toasts, id, run) = (self.1.clone(), t.id, run.clone());
816                lines.push(cx.keyed_child(
817                    t.id << 1 | 1,
818                    &Button::new(label.clone()).style(ButtonStyle::Ghost).size(ButtonSize::Small).on_click(
819                        move || {
820                            run();
821                            toasts.dismiss(id);
822                        },
823                    ),
824                ));
825            }
826            let text = cx.container(
827                LayoutStyle { grow: 1.0, spacing: 2.0, ..LayoutStyle::stack(Axis::Vertical) },
828                &lines,
829                Paint::None,
830            );
831            let (toasts, id) = (self.1.clone(), t.id);
832            let x = cx.keyed_child(
833                t.id << 1,
834                &IconButton::new(IconName::X)
835                    .label(cx.labels().dismiss.clone())
836                    .size(ButtonSize::Small)
837                    .on_click(move || toasts.dismiss(id)),
838            );
839            let card = cx.container(
840                LayoutStyle {
841                    width: Some(360.0),
842                    padding: Insets { top: 14.0, bottom: 14.0, left: 16.0, right: 8.0 },
843                    spacing: 12.0,
844                    ..LayoutStyle::stack(Axis::Horizontal)
845                },
846                &[icon, text, x],
847                Paint::Quad(QuadPaint {
848                    corner_radius: theme.radii.lg,
849                    border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
850                    shadow: Some(theme.elevation.raised),
851                    ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
852                }),
853            );
854            // Announced as it appears; danger toasts interrupt.
855            let role = if t.tone == Tone::Danger { Role::Alert } else { Role::Status };
856            cx.semantics(card, Semantics::new(role));
857            cx.cursor(card, CursorIcon::Default);
858            // The rest of the stack makes room as one comes or goes.
859            cx.motion(
860                card,
861                Motion::new()
862                    .key(t.id)
863                    .layout(theme.motion.snappy)
864                    .enter(self.3.travel(), theme.motion.gentle)
865                    .exit(self.3.travel(), theme.motion.fast),
866            );
867            cards.push(card);
868        }
869        cx.container(
870            LayoutStyle {
871                spacing: 10.0,
872                padding: Insets {
873                    bottom: if self.3.at_bottom() { self.2 } else { 0.0 },
874                    ..Insets::uniform(0.0)
875                },
876                ..LayoutStyle::stack(Axis::Vertical)
877            },
878            &cards,
879            Paint::None,
880        )
881    }
882}
883
884impl View for Toaster {
885    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
886        let anchor = cx.empty();
887        let now = cx.now();
888        let all = self.toasts.list.get();
889        let mut visible = Vec::new();
890        let mut expired = false;
891        for t in &all {
892            let shown = t.shown_at.get().unwrap_or_else(|| {
893                t.shown_at.set(Some(now));
894                now
895            });
896            let until = shown + t.duration;
897            if now >= until {
898                expired = true;
899            } else {
900                // One that stays until closed has no moment to wake for.
901                if until.is_finite() {
902                    cx.wake_at(until);
903                }
904                visible.push(t.clone());
905            }
906        }
907        if expired {
908            let ids: Vec<u64> = visible.iter().map(|t| t.id).collect();
909            let gone: Vec<Toast> = all.iter().filter(|t| !ids.contains(&t.id)).cloned().collect();
910            self.toasts.list.update(|l| l.retain(|t| ids.contains(&t.id)));
911            tell_gone(&gone);
912        }
913        let place = |t: &Toast| t.extras.position.unwrap_or(self.position);
914        let mut places: Vec<ToastPosition> = visible.iter().map(place).collect();
915        places.sort_unstable();
916        places.dedup();
917        for position in places {
918            let here: Vec<Toast> = visible.iter().filter(|t| place(t) == position).cloned().collect();
919            // By corner: a stack that comes or goes does not make another start over.
920            cx.keyed_overlay(
921                TOAST_OVERLAY_KEY + position as u64,
922                anchor,
923                position.placement(),
924                &ToastStack(&here, self.toasts.clone(), self.bottom_inset, position),
925                None,
926            );
927        }
928        anchor
929    }
930}
931
932/// An action in the [`CommandPalette`].
933#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
934pub struct Command {
935    label: String,
936    group: String,
937    icon: Option<IconName>,
938    shortcut: Option<String>,
939    action: Rc<dyn Fn()>,
940}
941
942impl fmt::Debug for Command {
943    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
944        f.debug_struct("Command").field("label", &self.label).finish_non_exhaustive()
945    }
946}
947
948#[cfg_attr(feature = "live", ravenrs_live::expose)]
949impl Command {
950    /// A command labeled `label`; `action` runs when it is chosen.
951    pub fn new(label: impl Into<String>, action: impl Fn() + 'static) -> Self {
952        Self {
953            label: label.into(),
954            group: String::new(),
955            icon: None,
956            shortcut: None,
957            action: Rc::new(action),
958        }
959    }
960
961    /// Lists the command under this group heading.
962    pub fn group(mut self, group: impl Into<String>) -> Self {
963        self.group = group.into();
964        self
965    }
966
967    /// Shows an icon before the label.
968    pub fn icon(mut self, icon: IconName) -> Self {
969        self.icon = Some(icon);
970        self
971    }
972
973    /// Shows a shortcut hint, e.g. `"⌘S"` (display only).
974    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
975        self.shortcut = Some(shortcut.into());
976        self
977    }
978}
979
980/// Is every character of `query` found in order in `text`? (case-insensitive)
981pub(crate) fn fuzzy_match(text: &str, query: &str) -> bool {
982    let text = text.to_lowercase();
983    let mut chars = text.chars();
984    query.to_lowercase().chars().filter(|c| !c.is_whitespace()).all(|q| chars.any(|c| c == q))
985}
986
987/// A searchable list of commands in a modal, opened with Cmd/Ctrl+K.
988/// Type to filter (fuzzy), use ↑ ↓ to choose, Enter to run, Escape to close.
989///
990/// ```
991/// use ravenrs_reactive::State;
992/// use ravenrs_widgets::{Command, CommandPalette, IconName, Ui};
993///
994/// let open = State::new(true);
995/// let palette = CommandPalette::new(open.binding())
996///     .command(Command::new("New file", || {}).group("File").icon(IconName::Plus).shortcut("⌘N"))
997///     .command(Command::new("Toggle theme", || {}).group("View"));
998///
999/// let mut ui = Ui::new(palette, ravenrs_style::Theme::raven_dark());
1000/// ui.update();
1001/// ```
1002#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1003pub struct CommandPalette {
1004    open: Binding<bool>,
1005    commands: Vec<Command>,
1006    placeholder: Option<String>,
1007}
1008
1009impl fmt::Debug for CommandPalette {
1010    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1011        f.debug_struct("CommandPalette").field("commands", &self.commands.len()).finish_non_exhaustive()
1012    }
1013}
1014
1015#[cfg_attr(feature = "live", ravenrs_live::expose)]
1016impl CommandPalette {
1017    /// A palette shown while `open` is true; Cmd/Ctrl+K toggles it.
1018    pub fn new(open: Binding<bool>) -> Self {
1019        Self { open, commands: Vec::new(), placeholder: None }
1020    }
1021
1022    /// Appends a command.
1023    pub fn command(mut self, command: Command) -> Self {
1024        self.commands.push(command);
1025        self
1026    }
1027
1028    /// Sets the search box placeholder.
1029    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
1030        self.placeholder = Some(text.into());
1031        self
1032    }
1033}
1034
1035struct PaletteLayer<'a> {
1036    palette: &'a CommandPalette,
1037    query: State<String>,
1038    highlight: State<usize>,
1039}
1040
1041impl View for PaletteLayer<'_> {
1042    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1043        let theme = *cx.theme();
1044        let viewport = cx.viewport();
1045        let q = self.query.get();
1046        let matches: Vec<usize> = (0..self.palette.commands.len())
1047            .filter(|&i| fuzzy_match(&self.palette.commands[i].label, &q))
1048            .collect();
1049        let highlight = self.highlight.get().min(matches.len().saturating_sub(1));
1050        let actions: Rc<Vec<Rc<dyn Fn()>>> =
1051            Rc::new(matches.iter().map(|&i| self.palette.commands[i].action.clone()).collect());
1052
1053        let (hl, open, acts, count) =
1054            (self.highlight.clone(), self.palette.open.clone(), actions.clone(), matches.len());
1055        let field = cx.child(
1056            &TextField::new(self.query.binding())
1057                .placeholder(
1058                    self.palette.placeholder.clone().unwrap_or_else(|| cx.labels().search_commands.clone()),
1059                )
1060                .icon(IconName::Search)
1061                .autofocus()
1062                .intercept_keys(Rc::new(move |key, _| {
1063                    match key {
1064                        Key::ArrowDown if count > 0 => hl.update(|h| *h = (*h + 1).min(count - 1)),
1065                        Key::ArrowUp => hl.update(|h| *h = h.saturating_sub(1)),
1066                        Key::Enter => {
1067                            if let Some(a) = acts.get(hl.get().min(count.saturating_sub(1))) {
1068                                open.set(false);
1069                                a();
1070                            }
1071                        }
1072                        Key::Escape => open.set(false),
1073                        _ => return false,
1074                    }
1075                    true
1076                })),
1077        );
1078        let mut rows = vec![field];
1079        let mut last_group: Option<&str> = None;
1080        for (n, &i) in matches.iter().enumerate().take(12) {
1081            let c = &self.palette.commands[i];
1082            if last_group != Some(c.group.as_str()) && !c.group.is_empty() {
1083                rows.push(util::label(
1084                    cx,
1085                    &c.group.to_uppercase(),
1086                    TextStyle::Caption,
1087                    None,
1088                    theme.color(Semantic::InkMuted),
1089                ));
1090            }
1091            last_group = Some(c.group.as_str());
1092            let on = n == highlight;
1093            let ink = theme.color(Semantic::Ink);
1094            let mut items = Vec::new();
1095            if let Some(icon) = c.icon {
1096                items.push(util::icon(cx, icon, 16.0, theme.color(Semantic::InkMuted)));
1097            }
1098            let label = util::label(cx, &c.label, TextStyle::Body, None, ink);
1099            items.push(cx.container(
1100                LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
1101                &[label],
1102                Paint::None,
1103            ));
1104            if let Some(s) = &c.shortcut {
1105                items.push(cx.child(&crate::Kbd::new(s.clone())));
1106            }
1107            let fill = if on { theme.color(Semantic::Accent).with_alpha(0.16) } else { Color::TRANSPARENT };
1108            let row = cx.container(
1109                LayoutStyle {
1110                    height: Some(38.0),
1111                    padding: Insets::symmetric(0.0, 10.0),
1112                    spacing: 10.0,
1113                    align: Align::Center,
1114                    ..LayoutStyle::stack(Axis::Horizontal)
1115                },
1116                &items,
1117                Paint::Quad(QuadPaint { corner_radius: 8.0, ..QuadPaint::new(fill) }),
1118            );
1119            let mut semantics = Semantics::new(Role::ListBoxOption).selected(on).active(on);
1120            if let Some(shortcut) = &c.shortcut {
1121                semantics = semantics.name(c.label.clone()).description(shortcut.clone());
1122            }
1123            cx.semantics(row, semantics);
1124            let (open, action, hl) = (self.palette.open.clone(), c.action.clone(), self.highlight.clone());
1125            cx.on_click(
1126                row,
1127                Rc::new(move || {
1128                    open.set(false);
1129                    action();
1130                }),
1131            );
1132            let _ = hl;
1133            cx.cursor(row, CursorIcon::Pointer);
1134            rows.push(row);
1135        }
1136        if matches.is_empty() {
1137            rows.push(util::label(
1138                cx,
1139                &cx.labels().no_commands.clone(),
1140                TextStyle::Callout,
1141                None,
1142                theme.color(Semantic::InkMuted),
1143            ));
1144        }
1145        let palette_name = cx.labels().command_palette.clone();
1146        cx.label_control(field, &palette_name, None);
1147        let card = panel(cx, &rows, 10.0, (viewport.width - 32.0).clamp(280.0, 560.0));
1148        cx.semantics(card, Semantics::new(Role::Dialog).name(palette_name).modal());
1149        let scrim = motion::scrim(cx, viewport);
1150        let backdrop = cx.container(
1151            LayoutStyle {
1152                width: Some(viewport.width),
1153                height: Some(viewport.height),
1154                padding: Insets { top: (viewport.height * 0.15).min(140.0), ..Insets::ZERO },
1155                align: Align::Center,
1156                ..LayoutStyle::stack(Axis::Vertical)
1157            },
1158            &[scrim, card],
1159            Paint::None,
1160        );
1161        let close = self.palette.open.clone();
1162        cx.on_click(backdrop, Rc::new(move || close.set(false)));
1163        backdrop
1164    }
1165}
1166
1167impl View for CommandPalette {
1168    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1169        let query: State<String> = cx.use_state(String::new);
1170        let highlight: State<usize> = cx.use_state(|| 0);
1171        let toggle = self.open.clone();
1172        let (q, h) = (query.clone(), highlight.clone());
1173        cx.shortcut(
1174            Key::Character("k".into()),
1175            true,
1176            Rc::new(move || {
1177                q.set(String::new());
1178                h.set(0);
1179                toggle.update(|o| *o = !*o);
1180            }),
1181        );
1182        let anchor = cx.empty();
1183        if self.open.get() {
1184            let close = self.open.clone();
1185            let layer = PaletteLayer { palette: self, query, highlight };
1186            cx.overlay(anchor, Placement::Fill, &layer, Some(Rc::new(move || close.set(false))));
1187        }
1188        anchor
1189    }
1190}
1191
1192/// A select with a search box: type to filter the options.
1193#[derive(Debug)]
1194#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1195pub struct Combobox {
1196    selected: Binding<Option<usize>>,
1197    options: Vec<String>,
1198    placeholder: Option<String>,
1199    width: f32,
1200    fill: bool,
1201    label: Option<String>,
1202    state: util::EditorState,
1203}
1204
1205#[cfg_attr(feature = "live", ravenrs_live::expose)]
1206impl Combobox {
1207    /// A combobox over `options`; `selected` holds the zero-based index of the choice, or `None`.
1208    pub fn new(
1209        selected: Binding<Option<usize>>,
1210        options: impl IntoIterator<Item = impl Into<String>>,
1211    ) -> Self {
1212        Self {
1213            selected,
1214            options: options.into_iter().map(Into::into).collect(),
1215            placeholder: None,
1216            width: 240.0,
1217            fill: false,
1218            label: None,
1219            state: util::EditorState::default(),
1220        }
1221    }
1222
1223    /// Sets the text shown when nothing is selected.
1224    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
1225        self.placeholder = Some(text.into());
1226        self
1227    }
1228
1229    /// Takes the width of its parent, as other fields of a form do.
1230    pub fn fill(mut self) -> Self {
1231        self.fill = true;
1232        self
1233    }
1234
1235    /// Sets the width in logical pixels (240 by default).
1236    pub fn width(mut self, width: f32) -> Self {
1237        self.width = width;
1238        self
1239    }
1240
1241    /// The name screen readers announce, such as "Framework". Inside a
1242    /// [`crate::FormField`], its label is used instead.
1243    pub fn label(mut self, label: impl Into<String>) -> Self {
1244        self.label = Some(label.into());
1245        self
1246    }
1247
1248    /// Turns the control off: it fades, leaves the tab order and ignores input.
1249    pub fn disabled(mut self, disabled: bool) -> Self {
1250        self.state.disabled = disabled;
1251        self
1252    }
1253
1254    /// Shows the value without letting it change; it can still be focused and read.
1255    pub fn read_only(mut self, read_only: bool) -> Self {
1256        self.state.read_only = read_only;
1257        self
1258    }
1259}
1260
1261/// One option under a search box: what a [`Combobox`] and a lookup list show.
1262pub(crate) fn search_row(
1263    cx: &mut BuildCx<'_>,
1264    text: &str,
1265    selected: bool,
1266    highlighted: bool,
1267    on_pick: Rc<dyn Fn()>,
1268) -> NodeId {
1269    let theme = *cx.theme();
1270    let ink = theme.color(Semantic::Ink);
1271    let label = util::label(cx, text, TextStyle::Body, selected.then_some(Weight::SEMIBOLD), ink);
1272    let mut items = vec![cx.container(
1273        LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
1274        &[label],
1275        Paint::None,
1276    )];
1277    if selected {
1278        items.push(util::icon(cx, IconName::Check, 16.0, theme.color(Semantic::Accent).lerp(ink, 0.3)));
1279    }
1280    let fill = if highlighted { ink.with_alpha(0.07) } else { Color::TRANSPARENT };
1281    let row = cx.container(
1282        LayoutStyle {
1283            height: Some(34.0),
1284            padding: Insets::symmetric(0.0, 10.0),
1285            align: Align::Center,
1286            ..LayoutStyle::stack(Axis::Horizontal)
1287        },
1288        &items,
1289        Paint::Quad(QuadPaint { corner_radius: 7.0, ..QuadPaint::new(fill) }),
1290    );
1291    cx.semantics(row, Semantics::new(Role::ListBoxOption).selected(selected).active(highlighted));
1292    cx.on_click(row, on_pick);
1293    cx.cursor(row, CursorIcon::Pointer);
1294    row
1295}
1296
1297/// A search box over a list of options, as a [`Combobox`] and a searchable
1298/// [`Select`](crate::Select) open. `pick` also has to close it.
1299pub(crate) struct SearchPanel<'a> {
1300    pub options: &'a [String],
1301    pub current: Option<usize>,
1302    pub pick: Rc<dyn Fn(usize)>,
1303    pub query: State<String>,
1304    pub highlight: State<usize>,
1305    pub open: State<bool>,
1306    pub width: f32,
1307}
1308
1309impl View for SearchPanel<'_> {
1310    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1311        let theme = *cx.theme();
1312        let q = self.query.get();
1313        let matches: Vec<usize> =
1314            (0..self.options.len()).filter(|&i| fuzzy_match(&self.options[i], &q)).collect();
1315        let hl = self.highlight.get().min(matches.len().saturating_sub(1));
1316        let (current, pick) = (self.current, self.pick.clone());
1317        let (h, m, p, open) =
1318            (self.highlight.clone(), Rc::new(matches.clone()), pick.clone(), self.open.clone());
1319        let field = cx.child(
1320            &TextField::new(self.query.binding())
1321                .placeholder(cx.labels().search.clone())
1322                .icon(IconName::Search)
1323                .compact()
1324                .autofocus()
1325                .intercept_keys(Rc::new(move |key, _| {
1326                    match key {
1327                        Key::ArrowDown if !m.is_empty() => h.update(|x| *x = (*x + 1).min(m.len() - 1)),
1328                        Key::ArrowUp => h.update(|x| *x = x.saturating_sub(1)),
1329                        Key::Enter => {
1330                            if let Some(&i) = m.get(h.get().min(m.len().saturating_sub(1))) {
1331                                p(i);
1332                            }
1333                        }
1334                        Key::Escape => open.set(false),
1335                        _ => return false,
1336                    }
1337                    true
1338                })),
1339        );
1340        let mut rows = vec![field];
1341        for (n, &i) in matches.iter().enumerate().take(50) {
1342            let p = pick.clone();
1343            rows.push(search_row(cx, &self.options[i], current == Some(i), n == hl, Rc::new(move || p(i))));
1344        }
1345        if matches.is_empty() {
1346            rows.push(util::label(
1347                cx,
1348                &cx.labels().no_results.clone(),
1349                TextStyle::Callout,
1350                None,
1351                theme.color(Semantic::InkMuted),
1352            ));
1353        }
1354        let node = panel(cx, &rows, 6.0, self.width);
1355        cx.semantics(node, Semantics::new(Role::ListBox));
1356        node
1357    }
1358}
1359
1360impl View for Combobox {
1361    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1362        let theme = *cx.theme();
1363        let open: State<bool> = cx.use_state(|| false);
1364        let query: State<String> = cx.use_state(String::new);
1365        let highlight: State<usize> = cx.use_state(|| 0);
1366        let state = self.state;
1367        let is_open = open.get() && state.accepts_input();
1368        let hovered = cx.is_hovered() && state.accepts_input();
1369        let (text, color) = match self.selected.get().and_then(|i| self.options.get(i)) {
1370            Some(t) => (t.clone(), theme.color(Semantic::Ink)),
1371            None => (
1372                self.placeholder.clone().unwrap_or_else(|| cx.labels().select.clone()),
1373                theme.color(Semantic::InkMuted),
1374            ),
1375        };
1376        let label = util::label(cx, &text, TextStyle::Body, None, state.ink(color));
1377        let label = cx.container(
1378            LayoutStyle { grow: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
1379            &[label],
1380            Paint::None,
1381        );
1382        let chevrons =
1383            util::icon(cx, IconName::ChevronDown, 16.0, state.ink(theme.color(Semantic::InkMuted)));
1384        let border = if is_open {
1385            Border { width: 1.5, color: theme.color(Semantic::Accent) }
1386        } else {
1387            let base = theme.color(Semantic::Border);
1388            Border {
1389                width: 1.0,
1390                color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
1391            }
1392        };
1393        let node = cx.container(
1394            LayoutStyle {
1395                width: (!self.fill).then_some(self.width),
1396                height: Some(40.0),
1397                padding: Insets::symmetric(0.0, 12.0),
1398                spacing: 8.0,
1399                align: Align::Center,
1400                align_self: Some(if self.fill { Align::Stretch } else { Align::Fit }),
1401                ..LayoutStyle::stack(Axis::Horizontal)
1402            },
1403            &[label, chevrons],
1404            Paint::Quad(QuadPaint {
1405                corner_radius: theme.radii.md,
1406                border: Some(border),
1407                ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
1408            }),
1409        );
1410        cx.semantics(label, Semantics::hidden());
1411        let value = if self.selected.get().is_some() { text.clone() } else { String::new() };
1412        let mut semantics = Semantics::new(Role::ComboBox).value(value).expanded(is_open);
1413        if let Some(label) = &self.label {
1414            semantics = semantics.name(label.clone());
1415        }
1416        cx.semantics(node, state.semantics(semantics));
1417        if state.disabled {
1418            return node;
1419        }
1420        if !state.accepts_input() {
1421            cx.focusable(node, theme.radii.md);
1422            return node;
1423        }
1424        let (o, q, h) = (open.clone(), query.clone(), highlight.clone());
1425        cx.on_click(
1426            node,
1427            Rc::new(move || {
1428                q.set(String::new());
1429                h.set(0);
1430                o.update(|x| *x = !*x);
1431            }),
1432        );
1433        cx.focusable(node, theme.radii.md);
1434        cx.cursor(node, CursorIcon::Pointer);
1435        if is_open {
1436            let close = open.clone();
1437            let pick: Rc<dyn Fn(usize)> = {
1438                let (selected, open) = (self.selected.clone(), open.clone());
1439                Rc::new(move |i| {
1440                    selected.set(Some(i));
1441                    open.set(false);
1442                })
1443            };
1444            let panel = SearchPanel {
1445                options: &self.options,
1446                current: self.selected.get(),
1447                pick,
1448                query,
1449                highlight,
1450                open: open.clone(),
1451                width: self.width,
1452            };
1453            cx.overlay(node, Placement::Below, &panel, Some(Rc::new(move || close.set(false))));
1454        }
1455        node
1456    }
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461    use super::fuzzy_match;
1462
1463    #[test]
1464    fn fuzzy_matching() {
1465        assert!(fuzzy_match("Toggle theme", "tgth"));
1466        assert!(fuzzy_match("Open settings", "open set"));
1467        assert!(!fuzzy_match("Export CSV", "xcz"));
1468        assert!(fuzzy_match("anything", ""));
1469    }
1470}