diff --git a/crates/component/src/list/delegate.rs b/crates/component/src/list/delegate.rs index 81e3d49aa9..e5cb515727 100644 --- a/crates/component/src/list/delegate.rs +++ b/crates/component/src/list/delegate.rs @@ -21,6 +21,14 @@ pub trait ListDelegate: Sized + 'static { Task::ready(()) } + /// Return the preferred keyboard cursor for the current search results. + /// + /// Called after search starts and again when its task completes. An absent or out-of-range + /// index falls back to the first item in the first non-empty section, or no selection. + fn preferred_selected_index(&self, _: &App) -> Option { + None + } + /// Return the number of sections in the list, default is 1. /// /// Min value is 1. diff --git a/crates/component/src/list/list.rs b/crates/component/src/list/list.rs index 732b8da91e..631e504260 100644 --- a/crates/component/src/list/list.rs +++ b/crates/component/src/list/list.rs @@ -289,18 +289,13 @@ where fn start_search(&mut self, query: String, window: &mut Window, cx: &mut Context) { self.set_searching(true, window, cx); let search = self.delegate.perform_search(&query, window, cx); - - if self.rows_cache.len() > 0 { - self._set_selected_index(Some(IndexPath::default()), window, cx); - } else { - self._set_selected_index(None, window, cx); - } + self.restore_search_cursor(window, cx); self._search_task = cx.spawn_in(window, async move |this, window| { search.await; - _ = this.update_in(window, |this, _, _| { - this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); + _ = this.update_in(window, |this, window, cx| { + this.restore_search_cursor(window, cx); this.last_query = Some(query); }); @@ -315,6 +310,23 @@ where }); } + fn restore_search_cursor(&mut self, window: &mut Window, cx: &mut Context) { + // Search results can invalidate row indices. Restore the delegate's preferred item only + // when it still exists; otherwise select the first item in the first non-empty section. + let sections_count = self.delegate.sections_count(cx).max(1); + let preferred = self.delegate.preferred_selected_index(cx).filter(|index| { + index.section < sections_count + && index.row < self.delegate.items_count(index.section, cx) + }); + let first = (0..sections_count) + .find(|section| self.delegate.items_count(*section, cx) > 0) + .map(|section| IndexPath::default().section(section)); + + self.deferred_scroll_to_index = None; + self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); + self._set_selected_index(preferred.or(first), window, cx); + } + fn set_searching(&mut self, searching: bool, window: &mut Window, cx: &mut Context) { self.query_input .update(cx, |input, cx| input.set_loading(searching, window, cx)); @@ -778,3 +790,136 @@ where .child(self.state.clone()) } } + +#[cfg(test)] +mod search_cursor_tests { + use super::*; + use crate::list::ListItem; + use gpui::TestAppContext; + + struct SearchDelegate { + counts: Vec, + preferred: Option, + } + + impl ListDelegate for SearchDelegate { + type Item = ListItem; + + fn perform_search( + &mut self, + query: &str, + _: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + if query == "sync" { + self.counts = vec![0, 2]; + self.preferred = None; + return Task::ready(()); + } + let query = query.to_owned(); + cx.spawn(async move |list, cx| { + list.update(cx, |list, _| { + let delegate = &mut list.delegate; + delegate.counts = if query == "empty" { + vec![0, 0] + } else { + vec![0, 3] + }; + delegate.preferred = + Some(IndexPath::new(if query == "preferred" { 2 } else { 99 }).section(1)); + }) + .unwrap(); + }) + } + + fn preferred_selected_index(&self, _: &App) -> Option { + self.preferred + } + fn sections_count(&self, _: &App) -> usize { + self.counts.len() + } + fn items_count(&self, section: usize, _: &App) -> usize { + self.counts[section] + } + fn set_selected_index( + &mut self, + _: Option, + _: &mut Window, + _: &mut Context>, + ) { + } + fn render_item( + &mut self, + _: IndexPath, + _: &mut Window, + _: &mut Context>, + ) -> Option { + None + } + } + + #[gpui::test] + fn search_restores_cursor_after_results_arrive(cx: &mut TestAppContext) { + cx.update(crate::init); + let window = cx.add_empty_window(); + let list = window.update(|window, cx| { + cx.new(|cx| { + ListState::new( + SearchDelegate { + counts: vec![1], + preferred: None, + }, + window, + cx, + ) + }) + }); + for (query, expected) in [ + ("preferred", Some(IndexPath::new(2).section(1))), + ("invalid", Some(IndexPath::new(0).section(1))), + ("empty", None), + ("sync", Some(IndexPath::new(0).section(1))), + ] { + window + .update(|window, cx| list.update(cx, |list, cx| list.set_query(query, window, cx))); + window.run_until_parked(); + window.update(|_, cx| { + let list = list.read(cx); + assert_eq!(list.selected_index(), expected); + assert_eq!( + list.deferred_scroll_to_index.map(|(index, _)| index), + expected + ); + }); + } + } + + #[gpui::test] + fn filtering_restores_the_committed_item_by_value(cx: &mut TestAppContext) { + use crate::select::{SearchableVec, SelectState}; + + cx.update(crate::init); + let window = cx.add_empty_window(); + let (select, list) = window.update(|window, cx| { + let select = cx.new(|cx| { + SelectState::new( + SearchableVec::new(vec!["Rust", "Go", "C++"]), + Some(IndexPath::new(1)), + window, + cx, + ) + }); + let list = select.read(cx).state.list.clone(); + (select, list) + }); + for (query, row) in [("Go", 0), ("Rust", 0), ("", 1)] { + window + .update(|window, cx| list.update(cx, |list, cx| list.set_query(query, window, cx))); + window.run_until_parked(); + window.update(|_, cx| { + assert_eq!(list.read(cx).selected_index(), Some(IndexPath::new(row))); + assert_eq!(select.read(cx).selected_value(), Some(&"Go")); + }); + } + } +} diff --git a/crates/component/src/searchable_list/adapter.rs b/crates/component/src/searchable_list/adapter.rs index 1c846ea1f0..88baab4ad4 100644 --- a/crates/component/src/searchable_list/adapter.rs +++ b/crates/component/src/searchable_list/adapter.rs @@ -161,6 +161,11 @@ impl ListDelegate for SearchableListAdapter self.delegate.perform_search(query, window, cx) } + fn preferred_selected_index(&self, _: &App) -> Option { + let selected_value = self.selection_snapshot.first()?.1.value(); + self.delegate.position(selected_value) + } + fn set_selected_index( &mut self, ix: Option, diff --git a/website/docs/components/list.md b/website/docs/components/list.md index c5b8c5d0f1..4c8e5991bc 100644 --- a/website/docs/components/list.md +++ b/website/docs/components/list.md @@ -491,3 +491,12 @@ impl ListDelegate for ContactListDelegate { } } ``` + +## Selection after search + +After search starts and again when its task completes, the list validates +`ListDelegate::preferred_selected_index`. Return an index in the current results +to restore a particular keyboard cursor. The default returns `None`; an absent or +invalid preference selects the first item in the first non-empty section. Empty +results clear the cursor. This does not commit a selection in `Select` or +`SearchableList`: their committed values remain separate from the keyboard cursor.