Skip to main content

ravenrs_widgets/
lookup.rs

1//! Choosing a record from a list too long to hold in memory.
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, TextField, View, util};
16
17/// How many options a lookup asks for at a time, unless told otherwise.
18const DEFAULT_LIMIT: usize = 50;
19/// The least it can show, since it fills what it is in unless given a width.
20const FIELD_MIN_WIDTH: f32 = 180.0;
21
22/// An option of a [`LookupField`]: the key the app knows it by, and the text people see.
23#[derive(Clone, Debug, PartialEq, Eq)]
24#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
25pub struct LookupItem {
26    /// What identifies it, such as a primary key.
27    pub key: String,
28    /// What is shown.
29    pub label: String,
30}
31
32#[cfg_attr(feature = "live", ravenrs_live::expose)]
33impl LookupItem {
34    /// An option with this key and text.
35    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
36        Self { key: key.into(), label: label.into() }
37    }
38}
39
40/// What a [`LookupField`] wants: up to `limit` options matching `query`.
41#[derive(Clone, Debug, PartialEq, Eq)]
42#[non_exhaustive]
43#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
44pub struct LookupRequest {
45    /// What was typed in the search box; empty at first.
46    pub query: String,
47    /// The most options that will be shown.
48    pub limit: usize,
49}
50
51/// Delivers the answer to a [`LookupRequest`], now or later on the UI thread. An
52/// answer to a search that was since replaced is ignored.
53#[derive(Clone)]
54#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
55pub struct LookupResponder {
56    answers: State<Answer>,
57    request: u64,
58}
59
60impl fmt::Debug for LookupResponder {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.debug_struct("LookupResponder").field("request", &self.request).finish_non_exhaustive()
63    }
64}
65
66#[cfg_attr(feature = "live", ravenrs_live::expose)]
67impl LookupResponder {
68    /// Answers with these options.
69    pub fn respond(&self, items: Vec<LookupItem>) {
70        self.deliver(Loaded::Items(Rc::new(items)));
71    }
72
73    /// Answers that the options could not be loaded, in words for the user.
74    pub fn fail(&self, message: impl Into<String>) {
75        self.deliver(Loaded::Failed(message.into()));
76    }
77
78    fn deliver(&self, loaded: Loaded) {
79        let current = self.answers.get();
80        if current.request == self.request {
81            self.answers.set(Answer { loaded, ..current });
82        }
83    }
84}
85
86#[derive(Clone, Debug, PartialEq)]
87enum Loaded {
88    Waiting,
89    Items(Rc<Vec<LookupItem>>),
90    Failed(String),
91}
92
93#[derive(Clone, Debug, PartialEq)]
94struct Answer {
95    request: u64,
96    query: String,
97    loaded: Loaded,
98}
99
100type Loader = Rc<dyn Fn(LookupRequest, LookupResponder)>;
101
102/// Picks a record from a list that is searched as you type, loaded from memory or
103/// through a callback that can go to a database or a server. Arrows move through
104/// the options, Enter picks and Escape closes. The chosen option can be cleared.
105///
106/// ```
107/// use ravenrs_reactive::State;
108/// use ravenrs_widgets::{LookupField, LookupItem, Ui};
109///
110/// let city = State::new(None);
111/// let field = LookupField::remote(city.binding(), |request, responder| {
112///     // Ask a database here; answer now, or later with a clone of the responder.
113///     let found = LookupItem::new("1", format!("{} (first match)", request.query));
114///     responder.respond(vec![found]);
115/// });
116/// let mut ui = Ui::new(field, ravenrs_style::Theme::raven_dark());
117/// ui.update();
118/// ```
119#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
120pub struct LookupField {
121    selected: Binding<Option<LookupItem>>,
122    load: Loader,
123    placeholder: Option<String>,
124    limit: usize,
125    width: Option<f32>,
126    label: Option<String>,
127    state: util::EditorState,
128}
129
130impl fmt::Debug for LookupField {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        f.debug_struct("LookupField").field("limit", &self.limit).finish_non_exhaustive()
133    }
134}
135
136#[cfg_attr(feature = "live", ravenrs_live::expose)]
137impl LookupField {
138    /// A lookup over options already in memory.
139    pub fn new(selected: Binding<Option<LookupItem>>, items: Vec<LookupItem>) -> Self {
140        Self::remote(selected, move |request, responder| {
141            let found = items.iter().filter(|i| fuzzy_match(&i.label, &request.query));
142            responder.respond(found.take(request.limit).cloned().collect());
143        })
144    }
145
146    /// A lookup that asks `load` for its options when it opens and on every search.
147    pub fn remote(
148        selected: Binding<Option<LookupItem>>,
149        load: impl Fn(LookupRequest, LookupResponder) + 'static,
150    ) -> Self {
151        Self {
152            selected,
153            load: Rc::new(load),
154            placeholder: None,
155            limit: DEFAULT_LIMIT,
156            width: None,
157            label: None,
158            state: util::EditorState::default(),
159        }
160    }
161
162    /// Text shown while nothing is chosen.
163    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
164        self.placeholder = Some(text.into());
165        self
166    }
167
168    /// The most options asked for at a time. 50 by default.
169    pub fn limit(mut self, limit: usize) -> Self {
170        self.limit = limit.max(1);
171        self
172    }
173
174    /// Width in logical pixels. 240 by default.
175    pub fn width(mut self, width: f32) -> Self {
176        self.width = Some(width);
177        self
178    }
179
180    /// The name screen readers announce. Inside a [`crate::FormField`], its label is used instead.
181    pub fn label(mut self, label: impl Into<String>) -> Self {
182        self.label = Some(label.into());
183        self
184    }
185
186    /// Turns the lookup off: it fades, leaves the tab order and ignores input.
187    pub fn disabled(mut self, disabled: bool) -> Self {
188        self.state.disabled = disabled;
189        self
190    }
191
192    /// Shows the choice without letting it change; it can still be focused and read.
193    pub fn read_only(mut self, read_only: bool) -> Self {
194        self.state.read_only = read_only;
195        self
196    }
197}
198
199struct LookupPanel<'a> {
200    field: &'a LookupField,
201    open: State<bool>,
202}
203
204impl View for LookupPanel<'_> {
205    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
206        let theme = *cx.theme();
207        let query: State<String> = cx.use_state(String::new);
208        let highlight: State<usize> = cx.use_state(|| 0);
209        let answers: State<Answer> =
210            cx.use_state(|| Answer { request: 0, query: "\u{0}".into(), loaded: Loaded::Waiting });
211        let q = query.get();
212        if answers.peek().query != q {
213            let request = answers.peek().request + 1;
214            answers.set(Answer { request, query: q.clone(), loaded: Loaded::Waiting });
215            highlight.set_if_changed(0);
216            (self.field.load)(
217                LookupRequest { query: q, limit: self.field.limit },
218                LookupResponder { answers: answers.clone(), request },
219            );
220        }
221        let loaded = answers.get().loaded;
222        let items: Rc<Vec<LookupItem>> = match &loaded {
223            Loaded::Items(items) => items.clone(),
224            _ => Rc::new(Vec::new()),
225        };
226        let hl = highlight.get().min(items.len().saturating_sub(1));
227        let pick = {
228            let (selected, open, items) = (self.field.selected.clone(), self.open.clone(), items.clone());
229            Rc::new(move |i: usize| {
230                selected.set(items.get(i).cloned());
231                open.set(false);
232            })
233        };
234        let (h, count, p, open) = (highlight.clone(), items.len(), pick.clone(), self.open.clone());
235        let search = cx.child(
236            &TextField::new(query.binding())
237                .placeholder(cx.labels().search.clone())
238                .icon(IconName::Search)
239                .compact()
240                .autofocus()
241                .intercept_keys(Rc::new(move |key, _| {
242                    match key {
243                        Key::ArrowDown if count > 0 => h.update(|x| *x = (*x + 1).min(count - 1)),
244                        Key::ArrowUp => h.update(|x| *x = x.saturating_sub(1)),
245                        Key::Enter if count > 0 => p(h.get().min(count - 1)),
246                        Key::Enter => {}
247                        Key::Escape => open.set(false),
248                        _ => return false,
249                    }
250                    true
251                })),
252        );
253        let mut rows = vec![search];
254        let current = self.field.selected.get();
255        for (i, item) in items.iter().enumerate() {
256            let p = pick.clone();
257            let selected = current.as_ref().is_some_and(|c| c.key == item.key);
258            rows.push(search_row(cx, &item.label, selected, i == hl, Rc::new(move || p(i))));
259        }
260        let note = match &loaded {
261            Loaded::Waiting => Some(cx.labels().loading.clone()),
262            Loaded::Failed(message) => Some(message.clone()),
263            Loaded::Items(items) if items.is_empty() => Some(cx.labels().no_results.clone()),
264            Loaded::Items(_) => None,
265        };
266        if let Some(note) = note {
267            let tone =
268                if matches!(loaded, Loaded::Failed(_)) { Semantic::Danger } else { Semantic::InkMuted };
269            rows.push(util::label(cx, &note, TextStyle::Callout, None, theme.color(tone)));
270        }
271        let node = panel(cx, &rows, 6.0, self.field.width.unwrap_or(FIELD_MIN_WIDTH));
272        cx.semantics(node, Semantics::new(Role::ListBox));
273        node
274    }
275}
276
277impl View for LookupField {
278    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
279        let theme = *cx.theme();
280        let open: State<bool> = cx.use_state(|| false);
281        let state = self.state;
282        let is_open = open.get() && state.accepts_input();
283        let hovered = cx.is_hovered() && state.accepts_input();
284        let chosen = self.selected.get();
285        let (text, color) = match &chosen {
286            Some(item) => (item.label.clone(), theme.color(Semantic::Ink)),
287            None => (
288                self.placeholder.clone().unwrap_or_else(|| cx.labels().select.clone()),
289                theme.color(Semantic::InkMuted),
290            ),
291        };
292        let label = cx.text_truncated(
293            &text,
294            &util::attrs(&theme, TextStyle::Body, None),
295            state.ink(color),
296            LayoutStyle::leaf(),
297        );
298        let label = cx.container(
299            LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::stack(Axis::Horizontal) },
300            &[label],
301            Paint::None,
302        );
303        let mut children = vec![label];
304        if chosen.is_some() && state.accepts_input() {
305            let selected = self.selected.clone();
306            children.push(
307                cx.child(
308                    &IconButton::new(IconName::X)
309                        .label(cx.labels().clear.clone())
310                        .size(ButtonSize::Small)
311                        .on_click(move || selected.set(None)),
312                ),
313            );
314        }
315        children.push(util::icon(
316            cx,
317            IconName::ChevronDown,
318            16.0,
319            state.ink(theme.color(Semantic::InkMuted)),
320        ));
321        let border = if is_open {
322            Border { width: 1.5, color: theme.color(Semantic::Accent) }
323        } else {
324            let base = theme.color(Semantic::Border);
325            Border {
326                width: 1.0,
327                color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
328            }
329        };
330        let node = cx.container(
331            LayoutStyle {
332                width: self.width,
333                min_width: Some(FIELD_MIN_WIDTH),
334                height: Some(40.0),
335                padding: Insets::symmetric(0.0, 12.0),
336                spacing: 8.0,
337                align: Align::Center,
338                align_self: Some(if self.width.is_some() { Align::Fit } else { Align::Stretch }),
339                ..LayoutStyle::stack(Axis::Horizontal)
340            },
341            &children,
342            Paint::Quad(QuadPaint {
343                corner_radius: theme.radii.md,
344                border: Some(border),
345                ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
346            }),
347        );
348        cx.semantics(label, Semantics::hidden());
349        let value = chosen.as_ref().map_or_else(String::new, |item| item.label.clone());
350        let mut semantics = Semantics::new(Role::ComboBox).value(value).expanded(is_open);
351        if let Some(label) = &self.label {
352            semantics = semantics.name(label.clone());
353        }
354        cx.semantics(node, state.semantics(semantics));
355        if state.disabled {
356            return node;
357        }
358        cx.focusable(node, theme.radii.md);
359        if !state.accepts_input() {
360            return node;
361        }
362        let toggle = open.clone();
363        cx.on_click(node, Rc::new(move || toggle.update(|o| *o = !*o)));
364        let opener = open.clone();
365        cx.on_key(
366            node,
367            Rc::new(move |key, _| {
368                let opens = matches!(key, Key::ArrowDown | Key::ArrowUp);
369                if opens {
370                    opener.set(true);
371                }
372                opens
373            }),
374        );
375        cx.cursor(node, CursorIcon::Pointer);
376        let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
377        if is_open {
378            was_open.set(true);
379            let close = open.clone();
380            let list = LookupPanel { field: self, open: open.clone() };
381            cx.overlay(node, Placement::Below, &list, Some(Rc::new(move || close.set(false))));
382        } else if was_open.replace(false) {
383            cx.request_focus();
384        }
385        node
386    }
387}