Skip to main content

ravenrs_widgets/
tag_box.rs

1//! Choosing several values, shown as tags.
2
3use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, CursorIcon, Insets, Key, Role};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle};
11
12use crate::context::{BuildCx, NodeId, Paint, Placement, QuadPaint, Semantics};
13use crate::floating::{fuzzy_match, search_row};
14use crate::overlay::panel;
15use crate::{ButtonSize, IconButton, IconName, LookupItem, TextField, View, util};
16
17/// The options shown at a time; a search reaches the others.
18const MOST_SHOWN: usize = 50;
19
20/// Picks several values from a list, showing each as a tag with a button to take it
21/// off. The list searches as you type; Enter picks or takes off the highlighted
22/// option, Backspace on the empty search takes off the last tag, Escape closes.
23///
24/// ```
25/// use ravenrs_reactive::State;
26/// use ravenrs_widgets::{LookupItem, TagBox, Ui};
27///
28/// let tags = State::new(vec!["red".to_owned()]);
29/// let field = TagBox::new(
30///     tags.binding(),
31///     vec![LookupItem::new("red", "Red"), LookupItem::new("blue", "Blue")],
32/// );
33/// let mut ui = Ui::new(field, ravenrs_style::Theme::raven_dark());
34/// ui.update();
35/// ```
36#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
37pub struct TagBox {
38    selected: Binding<Vec<String>>,
39    options: Vec<LookupItem>,
40    placeholder: Option<String>,
41    allow_new: bool,
42    most: Option<usize>,
43    width: f32,
44    fill: bool,
45    label: Option<String>,
46    state: util::EditorState,
47}
48
49impl fmt::Debug for TagBox {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.debug_struct("TagBox").field("options", &self.options.len()).finish_non_exhaustive()
52    }
53}
54
55#[cfg_attr(feature = "live", ravenrs_live::expose)]
56impl TagBox {
57    /// A field bound to the keys chosen, in the order they were chosen.
58    pub fn new(selected: Binding<Vec<String>>, options: Vec<LookupItem>) -> Self {
59        Self {
60            selected,
61            options,
62            placeholder: None,
63            allow_new: false,
64            most: None,
65            width: 280.0,
66            fill: false,
67            label: None,
68            state: util::EditorState::default(),
69        }
70    }
71
72    /// Text shown while nothing is chosen.
73    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
74        self.placeholder = Some(text.into());
75        self
76    }
77
78    /// Lets people add what they typed when it is not in the list. The text becomes the key.
79    pub fn allow_new(mut self, allow: bool) -> Self {
80        self.allow_new = allow;
81        self
82    }
83
84    /// The most values that can be chosen.
85    pub fn most(mut self, most: usize) -> Self {
86        self.most = Some(most.max(1));
87        self
88    }
89
90    /// Takes the width of its parent, as other fields of a form do.
91    pub fn fill(mut self) -> Self {
92        self.fill = true;
93        self
94    }
95
96    /// Width in logical pixels. 280 by default; tags that do not fit go to a new line.
97    pub fn width(mut self, width: f32) -> Self {
98        self.width = width;
99        self
100    }
101
102    /// The name screen readers announce. Inside a [`crate::FormField`], its label is used instead.
103    pub fn label(mut self, label: impl Into<String>) -> Self {
104        self.label = Some(label.into());
105        self
106    }
107
108    /// Turns the field off: it fades, leaves the tab order and ignores input.
109    pub fn disabled(mut self, disabled: bool) -> Self {
110        self.state.disabled = disabled;
111        self
112    }
113
114    /// Shows the values without letting them change; it can still be focused and read.
115    pub fn read_only(mut self, read_only: bool) -> Self {
116        self.state.read_only = read_only;
117        self
118    }
119
120    fn caption(&self, key: &str) -> String {
121        self.options.iter().find(|o| o.key == key).map_or_else(|| key.to_owned(), |o| o.label.clone())
122    }
123}
124
125/// Picks `key` if it is not chosen and there is room; takes it off if it is.
126fn flip(chosen: &mut Vec<String>, key: &str, most: Option<usize>) {
127    if let Some(at) = chosen.iter().position(|k| k == key) {
128        chosen.remove(at);
129    } else if most.is_none_or(|most| chosen.len() < most) {
130        chosen.push(key.to_owned());
131    }
132}
133
134struct TagPanel<'a> {
135    field: &'a TagBox,
136    open: State<bool>,
137}
138
139impl View for TagPanel<'_> {
140    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
141        let theme = *cx.theme();
142        let query: State<String> = cx.use_state(String::new);
143        let highlight: State<usize> = cx.use_state(|| 0);
144        let q = query.get();
145        let chosen = self.field.selected.get();
146        let typed = q.trim().to_owned();
147        let mut shown: Vec<LookupItem> = self
148            .field
149            .options
150            .iter()
151            .filter(|o| fuzzy_match(&o.label, &q))
152            .take(MOST_SHOWN)
153            .cloned()
154            .collect();
155        let exists = self.field.options.iter().any(|o| o.label.eq_ignore_ascii_case(&typed))
156            || chosen.iter().any(|k| k.eq_ignore_ascii_case(&typed));
157        if self.field.allow_new && !typed.is_empty() && !exists {
158            shown.push(LookupItem::new(typed.clone(), cx.labels().tag_add(&typed)));
159        }
160        let hl = highlight.get().min(shown.len().saturating_sub(1));
161        let shown = Rc::new(shown);
162        let most = self.field.most;
163        let pick = {
164            let (selected, shown, query) = (self.field.selected.clone(), shown.clone(), query.clone());
165            Rc::new(move |i: usize| {
166                if let Some(item) = shown.get(i) {
167                    selected.update(|chosen| flip(chosen, &item.key, most));
168                    query.set(String::new());
169                }
170            })
171        };
172        let count = shown.len();
173        let (h, p, open, selected, empty) =
174            (highlight.clone(), pick.clone(), self.open.clone(), self.field.selected.clone(), q.is_empty());
175        let search = cx.child(
176            &TextField::new(query.binding())
177                .placeholder(cx.labels().search.clone())
178                .icon(IconName::Search)
179                .compact()
180                .autofocus()
181                .intercept_keys(Rc::new(move |key, _| {
182                    match key {
183                        Key::ArrowDown if count > 0 => h.update(|x| *x = (*x + 1).min(count - 1)),
184                        Key::ArrowUp => h.update(|x| *x = x.saturating_sub(1)),
185                        Key::Enter if count > 0 => p(h.get().min(count - 1)),
186                        Key::Enter => {}
187                        Key::Escape => open.set(false),
188                        Key::Backspace if empty => selected.update(|chosen| {
189                            chosen.pop();
190                        }),
191                        _ => return false,
192                    }
193                    true
194                })),
195        );
196        let mut rows = vec![search];
197        for (i, item) in shown.iter().enumerate() {
198            let p = pick.clone();
199            let on = chosen.contains(&item.key);
200            rows.push(search_row(cx, &item.label, on, i == hl, Rc::new(move || p(i))));
201        }
202        if shown.is_empty() {
203            let note = cx.labels().no_results.clone();
204            rows.push(util::label(cx, &note, TextStyle::Callout, None, theme.color(Semantic::InkMuted)));
205        }
206        let node = panel(cx, &rows, 6.0, self.field.width);
207        cx.semantics(node, Semantics::new(Role::ListBox));
208        node
209    }
210}
211
212impl View for TagBox {
213    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
214        let theme = *cx.theme();
215        let open: State<bool> = cx.use_state(|| false);
216        let state = self.state;
217        let is_open = open.get() && state.accepts_input();
218        let hovered = cx.is_hovered() && state.accepts_input();
219        let chosen = self.selected.get();
220        let ink = state.ink(theme.color(Semantic::Ink));
221        let mut tags = Vec::with_capacity(chosen.len());
222        for key in &chosen {
223            let caption = self.caption(key);
224            let text = util::label(cx, &caption, TextStyle::Callout, None, ink);
225            cx.semantics(text, Semantics::hidden());
226            let mut parts = vec![text];
227            if state.accepts_input() {
228                let (selected, key) = (self.selected.clone(), key.clone());
229                let name = format!("{} {caption}", cx.labels().tag_remove);
230                parts.push(
231                    cx.keyed_child(
232                        util::hash_text(&key),
233                        &IconButton::new(IconName::X)
234                            .label(name)
235                            .size(ButtonSize::Small)
236                            .on_click(move || selected.update(|chosen| chosen.retain(|k| *k != key))),
237                    ),
238                );
239            }
240            let tag = cx.container(
241                LayoutStyle {
242                    padding: Insets { left: 8.0, right: 2.0, top: 2.0, bottom: 2.0 },
243                    spacing: 2.0,
244                    align: Align::Center,
245                    min_height: Some(26.0),
246                    ..LayoutStyle::stack(Axis::Horizontal)
247                },
248                &parts,
249                Paint::Quad(QuadPaint {
250                    corner_radius: theme.radii.sm,
251                    ..QuadPaint::new(theme.color(Semantic::Ink).with_alpha(0.08))
252                }),
253            );
254            tags.push(tag);
255        }
256        if chosen.is_empty() {
257            let hint = self.placeholder.clone().unwrap_or_else(|| cx.labels().select.clone());
258            let muted = state.ink(theme.color(Semantic::InkMuted));
259            tags.push(util::label(cx, &hint, TextStyle::Body, None, muted));
260        }
261        let holder = cx.container(
262            LayoutStyle {
263                grow: 1.0,
264                min_width: Some(0.0),
265                spacing: 4.0,
266                wrap: true,
267                align: Align::Center,
268                ..LayoutStyle::stack(Axis::Horizontal)
269            },
270            &tags,
271            Paint::None,
272        );
273        let chevron = util::icon(cx, IconName::ChevronDown, 16.0, state.ink(theme.color(Semantic::InkMuted)));
274        let border = if is_open {
275            Border { width: 1.5, color: theme.color(Semantic::Accent) }
276        } else {
277            let base = theme.color(Semantic::Border);
278            Border {
279                width: 1.0,
280                color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
281            }
282        };
283        let node = cx.container(
284            LayoutStyle {
285                width: (!self.fill).then_some(self.width),
286                min_height: Some(40.0),
287                padding: Insets { left: 6.0, right: 12.0, top: 6.0, bottom: 6.0 },
288                spacing: 8.0,
289                align: Align::Center,
290                align_self: Some(if self.fill { Align::Stretch } else { Align::Fit }),
291                ..LayoutStyle::stack(Axis::Horizontal)
292            },
293            &[holder, chevron],
294            Paint::Quad(QuadPaint {
295                corner_radius: theme.radii.md,
296                border: Some(border),
297                ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
298            }),
299        );
300        let value = chosen.iter().map(|k| self.caption(k)).collect::<Vec<_>>().join(", ");
301        let mut semantics = Semantics::new(Role::ComboBox).value(value).expanded(is_open);
302        if let Some(label) = &self.label {
303            semantics = semantics.name(label.clone());
304        }
305        cx.semantics(node, state.semantics(semantics));
306        if state.disabled {
307            return node;
308        }
309        cx.focusable(node, theme.radii.md);
310        if !state.accepts_input() {
311            return node;
312        }
313        let toggle = open.clone();
314        cx.on_click(node, Rc::new(move || toggle.update(|o| *o = !*o)));
315        let (opener, selected) = (open.clone(), self.selected.clone());
316        cx.on_key(
317            node,
318            Rc::new(move |key, _| match key {
319                Key::ArrowDown | Key::ArrowUp => {
320                    opener.set(true);
321                    true
322                }
323                Key::Backspace => {
324                    selected.update(|chosen| {
325                        chosen.pop();
326                    });
327                    true
328                }
329                _ => false,
330            }),
331        );
332        cx.cursor(node, CursorIcon::Pointer);
333        let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
334        if is_open {
335            was_open.set(true);
336            let close = open.clone();
337            let list = TagPanel { field: self, open: open.clone() };
338            cx.overlay(node, Placement::Below, &list, Some(Rc::new(move || close.set(false))));
339        } else if was_open.replace(false) {
340            cx.request_focus();
341        }
342        node
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn flipping_takes_off_what_is_on_and_respects_the_limit() {
352        let mut chosen = vec!["a".to_owned()];
353        flip(&mut chosen, "b", Some(2));
354        assert_eq!(chosen, ["a", "b"]);
355        flip(&mut chosen, "c", Some(2));
356        assert_eq!(chosen, ["a", "b"], "no room");
357        flip(&mut chosen, "a", Some(2));
358        assert_eq!(chosen, ["b"]);
359    }
360}