Skip to main content

gpui_kit_grid/state/editors/
select.rs

1//! `SelectEditor`: one value of a closed set, picked from a searchable list
2//! under the cell.
3//!
4//! The list is gpui-kit's public `List` in the editor's own popup, not its
5//! `Select`: a `Select` has no public way to open, and its Enter opens the
6//! list and goes on to the grid, which would commit before the pick.
7
8use std::{
9    cell::{Cell, RefCell},
10    sync::Arc,
11};
12
13use gpui_kit::component::list::{List, ListDelegate, ListEvent, ListItem, ListState};
14use gpui_kit::component::{ActiveTheme as _, IndexPath, Sizable as _, Size as ComponentSize};
15use gpui_kit::{
16    AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _,
17    SharedString, Styled as _, Subscription, Task, Window, div, px,
18};
19use rust_i18n::t;
20
21use crate::{
22    actions::CommitEdit,
23    model::{CellValue, GridRow, OwnedValue, display_text},
24    state::edit::{
25        CellEditor, EditError, EditInput, EditorCell, EditorField, EditorOpen, EditorStart,
26    },
27};
28
29/// One value a [`SelectEditor`] offers, with the text the list shows.
30#[derive(Clone, Debug)]
31pub struct SelectOption {
32    value: OwnedValue,
33    label: SharedString,
34}
35
36impl SelectOption {
37    /// An option that commits `value`, shown as `label`.
38    pub fn new(value: OwnedValue, label: impl Into<SharedString>) -> Self {
39        Self {
40            value,
41            label: label.into(),
42        }
43    }
44
45    /// The value it commits.
46    pub fn value(&self) -> &OwnedValue {
47        &self.value
48    }
49
50    /// The text the list shows.
51    pub fn label(&self) -> &SharedString {
52        &self.label
53    }
54}
55
56/// Edits a cell with one value of a closed set, picked from a searchable
57/// list under the cell.
58///
59/// The list opens at once with the cell's value picked. Typing searches,
60/// ↑ and ↓ pick, Enter and a click commit the picked value, Tab commits it
61/// and moves on, Escape cancels. Typing on the cell starts the search with
62/// the typed text; Backspace and Delete pick the empty item
63/// ([`Self::with_empty`]), or take no edit without one.
64#[derive(Clone, Debug)]
65pub struct SelectEditor {
66    /// The empty item first, if any, then the options.
67    items: Arc<[SelectOption]>,
68    has_empty: bool,
69}
70
71impl SelectEditor {
72    /// A list of `options`, in their order.
73    pub fn new(options: impl IntoIterator<Item = SelectOption>) -> Self {
74        Self {
75            items: options.into_iter().collect(),
76            has_empty: false,
77        }
78    }
79
80    /// Adds an item on top that commits `Empty`, shown as `label`.
81    pub fn with_empty(self, label: impl Into<SharedString>) -> Self {
82        let empty = SelectOption::new(OwnedValue::Empty, label);
83        let options = if self.has_empty {
84            &self.items[1..]
85        } else {
86            &self.items[..]
87        };
88        Self {
89            items: std::iter::once(empty)
90                .chain(options.iter().cloned())
91                .collect(),
92            has_empty: true,
93        }
94    }
95
96    /// The options, without the empty item.
97    pub fn options(&self) -> &[SelectOption] {
98        if self.has_empty {
99            &self.items[1..]
100        } else {
101            &self.items
102        }
103    }
104
105    /// The label of the empty item, if there is one.
106    pub fn empty_label(&self) -> Option<&SharedString> {
107        self.has_empty.then(|| self.items[0].label())
108    }
109
110    /// The item a start picks: the empty one for `Clear` (`None`, no edit,
111    /// without it), the cell's value otherwise (`Some(None)` if it is none
112    /// of the items).
113    fn picked(&self, value: CellValue<'_>, input: &EditInput) -> Option<Option<usize>> {
114        match input {
115            EditInput::Clear => self.has_empty.then_some(Some(0)),
116            _ => {
117                let value = OwnedValue::from_cell(value);
118                Some(item_of(&self.items, &value))
119            }
120        }
121    }
122}
123
124/// The item of `items` with `value`.
125fn item_of(items: &[SelectOption], value: &OwnedValue) -> Option<usize> {
126    items
127        .iter()
128        .position(|item| item.value.cmp_total(value).is_eq())
129}
130
131impl<R: GridRow> CellEditor<R> for SelectEditor {
132    fn open(&self, start: &EditorStart<'_, R>, window: &mut Window, cx: &mut App) -> EditorOpen {
133        let Some(picked) = self.picked(start.value(), start.input()) else {
134            return EditorOpen::Refuse;
135        };
136        let options = OptionList {
137            matches: (0..self.items.len()).collect(),
138            items: self.items.clone(),
139        };
140        let list = cx.new(|cx| ListState::new(options, window, cx).searchable(true));
141        // Typed text filters the list now; the list's search field shows it
142        // from the editor's second frame (`SelectField::render`), as that
143        // field lays text out in the window's default font until its first
144        // frame.
145        let query = match start.input() {
146            EditInput::Typing(text) => Some(text.clone()),
147            _ => None,
148        };
149        list.update(cx, |list, cx| match start.input() {
150            EditInput::Typing(text) => {
151                list.delegate_mut().filter(text);
152                let first = (!list.delegate().matches.is_empty()).then(|| IndexPath::new(0));
153                list.set_selected_index(first, window, cx);
154            }
155            _ => {
156                let row = picked.map(IndexPath::new);
157                list.set_selected_index(row, window, cx);
158                if let Some(row) = row {
159                    list.scroll_to_item(row, gpui_kit::ScrollStrategy::Center, window, cx);
160                }
161            }
162        });
163        // A click on an item or Enter commits it, as the editor's Enter
164        // does.
165        let confirm = window.subscribe(&list, cx, |_, event: &ListEvent, window, cx| {
166            if let ListEvent::Confirm(_) = event {
167                window.dispatch_action(Box::new(CommitEdit), cx);
168            }
169        });
170        EditorOpen::Field(Box::new(SelectField {
171            list,
172            start: RefCell::new(OwnedValue::from_cell(start.value())),
173            initial: Cell::new(picked),
174            query: RefCell::new(query),
175            drawn: Cell::new(false),
176            _confirm: confirm,
177        }))
178    }
179}
180
181/// The items of an open list, and those its search shows.
182struct OptionList {
183    items: Arc<[SelectOption]>,
184    /// The shown items, by index into `items`.
185    matches: Vec<usize>,
186}
187
188impl OptionList {
189    /// Shows the items whose label contains `query`, ignoring case; every
190    /// item for an empty query.
191    fn filter(&mut self, query: &str) {
192        let query = query.to_lowercase();
193        self.matches = (0..self.items.len())
194            .filter(|ix| query.is_empty() || self.items[*ix].label.to_lowercase().contains(&query))
195            .collect();
196    }
197}
198
199impl ListDelegate for OptionList {
200    type Item = ListItem;
201
202    fn perform_search(
203        &mut self,
204        query: &str,
205        _: &mut Window,
206        _: &mut Context<ListState<Self>>,
207    ) -> Task<()> {
208        self.filter(query);
209        Task::ready(())
210    }
211
212    fn items_count(&self, _: usize, _: &App) -> usize {
213        self.matches.len()
214    }
215
216    fn render_item(
217        &mut self,
218        ix: IndexPath,
219        _: &mut Window,
220        _: &mut Context<ListState<Self>>,
221    ) -> Option<ListItem> {
222        let item = &self.items[*self.matches.get(ix.row)?];
223        Some(
224            ListItem::new(ix.row)
225                .accessibility_label(item.label.clone())
226                .child(item.label.clone()),
227        )
228    }
229
230    fn render_empty(
231        &mut self,
232        _: &mut Window,
233        cx: &mut Context<ListState<Self>>,
234    ) -> impl IntoElement {
235        div()
236            .px(px(8.))
237            .py(px(6.))
238            .text_sm()
239            .text_color(cx.theme().muted_foreground)
240            .child(SharedString::from(t!("live_grid.edit.no_matches")))
241    }
242
243    fn set_selected_index(
244        &mut self,
245        _: Option<IndexPath>,
246        _: &mut Window,
247        _: &mut Context<ListState<Self>>,
248    ) {
249    }
250}
251
252/// An open [`SelectEditor`]: the picked item's text in the cell, the list
253/// under it.
254struct SelectField {
255    list: Entity<ListState<OptionList>>,
256    /// The cell's value: the draft while nothing is picked.
257    start: RefCell<OwnedValue>,
258    /// The item picked when it opened, or the store's value picked since.
259    initial: Cell<Option<usize>>,
260    /// The text typed to open it, for the search field once the field has
261    /// drawn.
262    query: RefCell<Option<SharedString>>,
263    /// The field has drawn a frame: the list's search field is laid out.
264    drawn: Cell<bool>,
265    _confirm: Subscription,
266}
267
268impl SelectField {
269    /// The picked item, by index into the items.
270    fn picked(&self, cx: &App) -> Option<usize> {
271        let list = self.list.read(cx);
272        let row = list.selected_index()?.row;
273        list.delegate().matches.get(row).copied()
274    }
275
276    /// The picked item's value and label.
277    fn picked_item(&self, cx: &App) -> Option<SelectOption> {
278        let ix = self.picked(cx)?;
279        Some(self.list.read(cx).delegate().items[ix].clone())
280    }
281}
282
283impl EditorField for SelectField {
284    fn render(&self, cell: &EditorCell<'_>, window: &mut Window, cx: &mut App) -> AnyElement {
285        if self.query.borrow().is_some() {
286            if self.drawn.replace(true) {
287                // The search finds what the filter found: the same first
288                // match is picked.
289                let query = self.query.take().unwrap_or_default();
290                self.list
291                    .update(cx, |list, cx| list.set_query(&query, window, cx));
292            } else {
293                // Its first frame: the next one shows the text.
294                self.list.update(cx, |_, cx| cx.notify());
295            }
296        }
297        let text = self.picked_item(cx).map_or_else(
298            || {
299                let mut text = String::new();
300                display_text(self.start.borrow().as_cell(), &mut text).to_owned()
301            },
302            |item| item.label.to_string(),
303        );
304        let size = popup_size(cell.size());
305        let width = cell.bounds().size.width.max(px(160.));
306        div()
307            .flex_1()
308            .min_w_0()
309            .px(px(4.))
310            .truncate()
311            .child(text)
312            .child(
313                cell.popup(
314                    div().w(width).child(
315                        List::new(&self.list)
316                            .search_placeholder(SharedString::from(t!("live_grid.edit.search")))
317                            .with_size(size)
318                            .max_h(px(240.))
319                            .p(px(4.)),
320                    ),
321                    cx,
322                ),
323            )
324            .into_any_element()
325    }
326
327    fn focus(&self, window: &mut Window, cx: &mut App) {
328        self.list.update(cx, |list, cx| list.focus(window, cx));
329    }
330
331    fn value(&self, cx: &App) -> Result<OwnedValue, EditError> {
332        Ok(self
333            .picked_item(cx)
334            .map_or_else(|| self.start.borrow().clone(), |item| item.value))
335    }
336
337    fn set_value(&self, value: &OwnedValue, window: &mut Window, cx: &mut App) {
338        self.list.update(cx, |list, cx| {
339            let row = list
340                .delegate()
341                .matches
342                .iter()
343                .position(|ix| list.delegate().items[*ix].value.cmp_total(value).is_eq());
344            list.set_selected_index(row.map(IndexPath::new), window, cx);
345            cx.notify();
346        });
347        // The store's value is the new starting point: untouched again.
348        *self.start.borrow_mut() = value.clone();
349        self.initial.set(self.picked(cx));
350    }
351
352    fn is_dirty(&self, cx: &App) -> bool {
353        self.picked(cx) != self.initial.get()
354    }
355}
356
357/// The size of the list's search field and items: as the cell's field,
358/// but never smaller than `Small`, so the list reads.
359fn popup_size(size: ComponentSize) -> ComponentSize {
360    match size {
361        ComponentSize::XSmall => ComponentSize::Small,
362        size => size,
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    fn sides() -> SelectEditor {
371        SelectEditor::new([
372            SelectOption::new(OwnedValue::Text("Buy".into()), "Buy"),
373            SelectOption::new(OwnedValue::Text("Sell".into()), "Sell"),
374        ])
375    }
376
377    #[test]
378    fn test_select_picked() {
379        let editor = sides();
380        assert_eq!(editor.options().len(), 2);
381        assert_eq!(editor.empty_label(), None);
382        assert_eq!(
383            editor.picked(CellValue::Text("Sell"), &EditInput::Command),
384            Some(Some(1))
385        );
386        assert_eq!(
387            editor.picked(CellValue::Text("Hold"), &EditInput::Command),
388            Some(None)
389        );
390        // Backspace without an empty item takes no edit.
391        assert_eq!(
392            editor.picked(CellValue::Text("Sell"), &EditInput::Clear),
393            None
394        );
395
396        let editor = sides().with_empty("None").with_empty("—");
397        assert_eq!(editor.options().len(), 2, "one empty item");
398        assert_eq!(editor.empty_label().map(AsRef::as_ref), Some("—"));
399        assert_eq!(
400            editor.picked(CellValue::Text("Sell"), &EditInput::Clear),
401            Some(Some(0))
402        );
403        assert_eq!(
404            editor.picked(CellValue::Empty, &EditInput::Command),
405            Some(Some(0))
406        );
407        assert_eq!(
408            editor.picked(CellValue::Text("Buy"), &EditInput::Press { click_count: 2 }),
409            Some(Some(1))
410        );
411    }
412}