diff --git a/crates/story/src/stories/combobox_story.rs b/crates/story/src/stories/combobox_story.rs index 9cd1ff391c..4a07bc437f 100644 --- a/crates/story/src/stories/combobox_story.rs +++ b/crates/story/src/stories/combobox_story.rs @@ -98,18 +98,18 @@ impl SearchableListItem for Industry { // MARK: Max2Delegate — allows at most 2 items to be selected simultaneously -/// Shadows the current selection indices so `is_item_enabled` can disable unselected items +/// Shadows the current selection values so `is_item_enabled` can disable unselected items /// when the capacity is reached, providing visual feedback without `current_selection` access. struct Max2Delegate { items: SearchableVec<&'static str>, - selected_indices: Vec, + selected_values: Vec<&'static str>, } impl Max2Delegate { fn new(items: SearchableVec<&'static str>) -> Self { Self { items, - selected_indices: Vec::new(), + selected_values: Vec::new(), } } } @@ -133,28 +133,38 @@ impl SearchableListDelegate for Max2Delegate { self.items.position(value) } - fn is_item_enabled(&self, ix: IndexPath, _item: &&'static str, _cx: &App) -> bool { - let at_capacity = self.selected_indices.len() >= 2; - let is_selected = self.selected_indices.contains(&ix); + fn item_by_value(&self, value: &V) -> Option + where + &'static str: SearchableListItem, + V: PartialEq, + { + self.items.item_by_value(value) + } + + fn is_item_enabled(&self, _ix: IndexPath, item: &&'static str, _cx: &App) -> bool { + let at_capacity = self.selected_values.len() >= 2; + let is_selected = self.selected_values.contains(item); !at_capacity || is_selected } fn on_will_change( &mut self, - selection: &mut Vec<(IndexPath, &'static str)>, + selection: &mut Vec<&'static str>, changes: &[SearchableListChange], ) { for change in changes { match change { SearchableListChange::Deselect { index } => { - selection.retain(|(ix, _)| ix != index); + if let Some(item) = self.item(*index) { + selection.retain(|selected_item| *selected_item != *item); + } } SearchableListChange::Select { index } => { if selection.len() < 2 { if let Some(item) = self.item(*index) { - if !selection.iter().any(|(ix, _)| ix == index) { - selection.push((*index, *item)); + if !selection.contains(item) { + selection.push(*item); } } } @@ -163,7 +173,7 @@ impl SearchableListDelegate for Max2Delegate { } // Keep the shadow in sync for is_item_enabled. - self.selected_indices = selection.iter().map(|(ix, _)| *ix).collect(); + self.selected_values = selection.clone(); } } @@ -190,6 +200,14 @@ impl SearchableListDelegate for PinnedDelegate { self.0.position(value) } + fn item_by_value(&self, value: &V) -> Option + where + &'static str: SearchableListItem, + V: PartialEq, + { + self.0.item_by_value(value) + } + fn is_item_enabled(&self, ix: IndexPath, _item: &&'static str, _cx: &App) -> bool { // Pinned items are non-interactive — their checked state is fixed. ix != IndexPath::new(0) && ix != IndexPath::new(1) @@ -198,15 +216,13 @@ impl SearchableListDelegate for PinnedDelegate { fn is_item_checked( &self, ix: IndexPath, - _item: &&'static str, - current_selection: &[(IndexPath, &'static str)], + item: &&'static str, + current_selection: &[&'static str], _cx: &App, ) -> bool { // First two items are always rendered checked (externally pinned), // regardless of what is in the normal selection. - ix == IndexPath::new(0) - || ix == IndexPath::new(1) - || current_selection.iter().any(|(sel_ix, _)| sel_ix == &ix) + ix == IndexPath::new(0) || ix == IndexPath::new(1) || current_selection.contains(item) } } @@ -233,6 +249,14 @@ impl SearchableListDelegate for FeaturedDelegate { self.0.position(value) } + fn item_by_value(&self, value: &V) -> Option + where + &'static str: SearchableListItem, + V: PartialEq, + { + self.0.item_by_value(value) + } + fn render_item( &self, ix: IndexPath, @@ -576,9 +600,7 @@ impl Render for ComboboxStory { .render_trigger(|trigger, _, cx| { let (icon, title) = match trigger.selection() { [] => (None, None), - [(_index, item)] => { - (Some(item.icon.clone()), Some(item.title().clone())) - } + [item] => (Some(item.icon.clone()), Some(item.title().clone())), items => ( None, Some(SharedString::new(format!( @@ -681,7 +703,7 @@ impl Render for ComboboxStory { .render_trigger(|trigger, _, cx| { let title = match trigger.selection() { [] => None, - [(_index, item)] => Some(item.title().clone()), + [item] => Some(item.title().clone()), items => { Some(SharedString::new(format!("{} selected", items.len()))) } @@ -760,40 +782,34 @@ impl Render for ComboboxStory { .min_w_0() .items_center() .gap_1() - .children(items.iter().take(1).cloned().map( - |(index, item)| { - let state = multi_badges_state.clone(); - h_flex() - .min_w_0() - .gap_0p5() - .items_center() - .rounded_sm() - .border_1() - .border_color(cx.theme().border) - .px_1() - .text_xs() - .child(div().truncate().child(item)) - .child( - Button::new(SharedString::from( - format!("remove-{item}"), - )) - .ghost() - .xsmall() - .icon( - Icon::new(IconName::Close).xsmall(), - ) - .tab_stop(false) - .on_click(move |_ev, _window, cx| { - cx.stop_propagation(); - state.update(cx, |s, cx| { - s.remove_selected_index( - index, cx, - ); - }); - }), - ) - }, - )) + .children(items.iter().take(1).cloned().map(|item| { + let state = multi_badges_state.clone(); + h_flex() + .min_w_0() + .gap_0p5() + .items_center() + .rounded_sm() + .border_1() + .border_color(cx.theme().border) + .px_1() + .text_xs() + .child(div().truncate().child(item)) + .child( + Button::new(SharedString::from(format!( + "remove-{item}" + ))) + .ghost() + .xsmall() + .icon(Icon::new(IconName::Close).xsmall()) + .tab_stop(false) + .on_click(move |_ev, _window, cx| { + cx.stop_propagation(); + state.update(cx, |s, cx| { + s.remove_selected_value(&item, cx); + }); + }), + ) + })) .when(hidden > 0, |this| { this.child( div() @@ -871,7 +887,7 @@ impl Render for ComboboxStory { .flex_wrap() .gap_1() .children(trigger.selection().iter().take(MAX_SHOWN).map( - |(_index, item)| { + |item| { div() .rounded_sm() .border_1() diff --git a/crates/ui/src/combobox.rs b/crates/ui/src/combobox.rs index 4ddef464c9..ce357b1cd7 100644 --- a/crates/ui/src/combobox.rs +++ b/crates/ui/src/combobox.rs @@ -31,7 +31,7 @@ use gpui_base::{Combobox as BaseCombobox, GlobalState}; /// The fields are private and reached through the methods below, so that a new /// one can be added without breaking the trigger renderers. pub struct ComboboxTriggerContext<'a, D: SearchableListDelegate + 'static> { - selection: &'a [(IndexPath, D::Item)], + selection: &'a [D::Item], placeholder: Option<&'a SharedString>, open: bool, disabled: bool, @@ -40,7 +40,7 @@ pub struct ComboboxTriggerContext<'a, D: SearchableListDelegate + 'static> { impl<'a, D: SearchableListDelegate + 'static> ComboboxTriggerContext<'a, D> { /// The items currently selected, empty when the combobox has no value. - pub fn selection(&self) -> &'a [(IndexPath, D::Item)] { + pub fn selection(&self) -> &'a [D::Item] { self.selection } @@ -177,11 +177,18 @@ where let s = weak.read(cx); (s.multiple, s.state.selection.clone()) }; + let previous_selection = selection.clone(); let changes = Self::selection_changes(multiple, &selection, ix, &item); - let before_indices: Vec = - selection.iter().map(|(ix, _)| *ix).collect(); + let before_values = selection + .iter() + .map(|selected_item| selected_item.value().clone()) + .collect::>(); + + if !multiple { + selection.clear(); + } // on_will_change is called directly — entity-handle access would // re-enter the ListState lock that defer_in holds for this callback. @@ -190,9 +197,17 @@ where .delegate .on_will_change(&mut selection, &changes); - let after_indices: Vec = - selection.iter().map(|(ix, _)| *ix).collect(); - let changed = before_indices != after_indices; + // A delegate can veto a single-select change by leaving the working + // selection empty. Preserve the committed selection in that case. + if !multiple && selection.is_empty() && !previous_selection.is_empty() { + selection = previous_selection; + } + + let after_values = selection + .iter() + .map(|selected_item| selected_item.value().clone()) + .collect::>(); + let changed = before_values != after_values; let should_close = changed && !multiple; let new_selection = weak_confirm.update(cx, |this, cx| { @@ -301,8 +316,8 @@ where self.state.selected_values().into_iter().next() } - /// Return the currently selected `(IndexPath, Item)` pairs. - pub fn selection(&self) -> &[(IndexPath, D::Item)] { + /// Return the currently selected items. + pub fn selection(&self) -> &[D::Item] { self.state.selection() } @@ -314,23 +329,25 @@ where pub fn set_selected_values( &mut self, values: &[::Value], - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { - let selected_indices = { + let selected_items = { let list = self.state.list.read(cx); let delegate = &list.delegate().delegate; values .iter() - .filter_map(|value| delegate.position(value)) + .filter_map(|value| delegate.item_by_value(value)) .collect::>() }; - self.set_selected_indices(selected_indices, window, cx); + self.state.set_selected_items(selected_items); + self.state.sync_snapshot(cx); + cx.notify(); } - /// Replace the entire selection set. + /// Replace the entire selection set with items at current visible indices. pub fn set_selected_indices( &mut self, indices: impl IntoIterator, @@ -342,7 +359,7 @@ where cx.notify(); } - /// Add a single index to the selection, if not already present, returning whether it was added. + /// Add the item at a current visible index, if not already present, returning whether it was added. pub fn add_selected_index(&mut self, index: IndexPath, cx: &mut Context) -> bool { let added = self.state.add_selected_index(index, cx); @@ -354,12 +371,45 @@ where added } - /// Remove a single index from the selection, returning whether it was removed. + /// Remove the item at a current visible index, returning whether it was removed. pub fn remove_selected_index(&mut self, index: IndexPath, cx: &mut Context) -> bool { - let removed = self.state.remove_selected_index(index); + let removed = self.state.remove_selected_index(index, cx); + + if removed { + self.state.sync_snapshot(cx); + cx.notify(); + } + + removed + } + + /// Add a single item to the selection by value, if it exists and is not already selected. + pub fn add_selected_value( + &mut self, + value: &::Value, + cx: &mut Context, + ) -> bool { + let added = self.state.add_selected_value(value, cx); + + if added { + self.state.sync_snapshot(cx); + cx.notify(); + } + + added + } + + /// Remove a single item from the selection by value. + pub fn remove_selected_value( + &mut self, + value: &::Value, + cx: &mut Context, + ) -> bool { + let removed = self.state.remove_selected_value(value); if removed { self.state.sync_snapshot(cx); + cx.notify(); } removed @@ -400,13 +450,13 @@ where fn selection_changes( multiple: bool, - selection: &[(IndexPath, D::Item)], + selection: &[D::Item], ix: IndexPath, item: &D::Item, ) -> Vec { let is_selected = selection .iter() - .any(|(_, selected_item)| selected_item.value() == item.value()); + .any(|selected_item| selected_item.value() == item.value()); if multiple { if is_selected { @@ -415,12 +465,7 @@ where vec![SearchableListChange::Select { index: ix }] } } else { - let mut changes: Vec = selection - .iter() - .map(|(cur_ix, _)| SearchableListChange::Deselect { index: *cur_ix }) - .collect(); - changes.push(SearchableListChange::Select { index: ix }); - changes + vec![SearchableListChange::Select { index: ix }] } } @@ -449,7 +494,15 @@ where let changes = Self::selection_changes(self.multiple, &self.state.selection, ix, &item); let mut selection = self.state.selection.clone(); - let before_indices: Vec = selection.iter().map(|(ix, _)| *ix).collect(); + let previous_selection = selection.clone(); + let before_values = selection + .iter() + .map(|selected_item| selected_item.value().clone()) + .collect::>(); + + if !self.multiple { + selection.clear(); + } self.state.list.update(cx, |list, _cx| { list.delegate_mut() @@ -457,8 +510,15 @@ where .on_will_change(&mut selection, &changes); }); - let after_indices: Vec = selection.iter().map(|(ix, _)| *ix).collect(); - let changed = before_indices != after_indices; + if !self.multiple && selection.is_empty() && !previous_selection.is_empty() { + selection = previous_selection; + } + + let after_values = selection + .iter() + .map(|selected_item| selected_item.value().clone()) + .collect::>(); + let changed = before_values != after_values; let should_close = changed && !self.multiple; self.state.selection = selection; @@ -556,7 +616,7 @@ where .state .selection .iter() - .map(|(_, i)| i.title()) + .map(|item| item.title()) .collect(); div() @@ -571,7 +631,7 @@ where .state .selection .first() - .map(|(_, i)| i.title()) + .map(|item| item.title()) .unwrap_or_default(); div() @@ -1225,14 +1285,7 @@ mod tests { state.set_selected_values(&["Vue", "Missing"], window, cx); assert_eq!(state.selected_values(), vec!["Vue"]); - assert_eq!( - state - .selection() - .iter() - .map(|(index, _)| *index) - .collect::>(), - vec![IndexPath::new(1)], - ); + assert_eq!(state.selection(), &["Vue"]); assert_eq!( state .state @@ -1248,14 +1301,7 @@ mod tests { state.set_selected_values(&["Go", "Vue"], window, cx); assert_eq!(state.selected_values(), vec!["Go", "Vue"]); - assert_eq!( - state - .selection() - .iter() - .map(|(index, _)| *index) - .collect::>(), - vec![IndexPath::new(2), IndexPath::new(0)], - ); + assert_eq!(state.selection(), &["Go", "Vue"]); assert_eq!( state .state @@ -1410,13 +1456,88 @@ mod tests { }); } + #[gpui::test] + fn test_multi_combo_box_value_selection_survives_filter(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true)); + + state.update(cx, |s, cx| { + s.set_selected_values(&["React"], window, cx); + s.state.list.update(cx, |list, cx| { + let _ = list + .delegate_mut() + .delegate + .perform_search("Ang", window, cx); + }); + + assert_eq!(s.selected_values(), &["React"]); + s.set_selected_values(&["React", "Angular"], window, cx); + assert_eq!(s.selected_values(), &["React", "Angular"]); + + let list = s.state.list.read(cx); + let delegate = &list.delegate().delegate; + let angular = delegate + .item(IndexPath::new(0)) + .expect("Angular is visible"); + assert!(delegate.is_item_checked(IndexPath::new(0), angular, s.selection(), cx)); + }); + + state.update(cx, |s, cx| { + s.state.list.update(cx, |list, cx| { + let _ = list.delegate_mut().delegate.perform_search("", window, cx); + }); + + let list = s.state.list.read(cx); + let delegate = &list.delegate().delegate; + let react = delegate.item(IndexPath::new(0)).expect("React is visible"); + assert!(delegate.is_item_checked(IndexPath::new(0), react, s.selection(), cx)); + }); + }); + } + + #[gpui::test] + fn test_multi_combo_box_index_mutations_use_current_visible_items(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true)); + + state.update(cx, |s, cx| { + s.set_selected_values(&["React"], window, cx); + s.state.list.update(cx, |list, cx| { + let _ = list + .delegate_mut() + .delegate + .perform_search("Ang", window, cx); + }); + + s.set_selected_indices(vec![IndexPath::new(0)], window, cx); + assert_eq!(s.selected_values(), &["Angular"]); + s.set_selected_values(&["React"], window, cx); + assert!(s.add_selected_index(IndexPath::new(0), cx)); + assert!(!s.add_selected_index(IndexPath::new(1), cx)); + assert_eq!(s.selected_values(), &["React", "Angular"]); + + assert!(s.remove_selected_index(IndexPath::new(0), cx)); + assert_eq!(s.selected_values(), &["React"]); + assert!(!s.remove_selected_index(IndexPath::new(1), cx)); + assert!(s.remove_selected_value(&"React", cx)); + assert!(s.selected_values().is_empty()); + }); + }); + } + #[gpui::test] fn test_searchable_list_default_change_uses_value_identity(cx: &mut TestAppContext) { cx.update(crate::init); let cx = cx.add_empty_window(); cx.update(|window, cx| { let mut delegate = SearchableVec::new(vec!["React", "Vue", "Angular"]); - let mut selection = vec![(IndexPath::new(1), "Vue")]; + let mut selection = vec!["Vue"]; let _ = delegate.perform_search("Vue", window, cx); delegate.on_will_change( @@ -1433,7 +1554,7 @@ mod tests { index: IndexPath::new(0), }], ); - assert_eq!(selection, vec![(IndexPath::new(0), "Vue")]); + assert_eq!(selection, vec!["Vue"]); }); } @@ -1500,7 +1621,7 @@ mod tests { fn on_will_change( &mut self, - _selection: &mut Vec<(IndexPath, &'static str)>, + _selection: &mut Vec<&'static str>, _changes: &[SearchableListChange], ) { // Leave selection unchanged — acts as a veto. diff --git a/crates/ui/src/searchable_list/adapter.rs b/crates/ui/src/searchable_list/adapter.rs index 1c846ea1f0..52363db16d 100644 --- a/crates/ui/src/searchable_list/adapter.rs +++ b/crates/ui/src/searchable_list/adapter.rs @@ -22,7 +22,7 @@ pub(crate) struct SearchableListAdapter { /// Snapshot of the parent's committed selection, kept in sync by the parent state after every /// selection change. `render_item` reads this directly so it never touches the parent entity /// (which would panic — the `ListState` entity is already locked during render). - pub(crate) selection_snapshot: Vec<(IndexPath, D::Item)>, + pub(crate) selection_snapshot: Vec, /// Called when the user confirms an item (click or Enter). on_confirm: Box, bool, &mut Window, &mut Context>) + 'static>, @@ -57,7 +57,7 @@ impl SearchableListAdapter { /// Replace the selection snapshot. Call this after every selection mutation so that /// `render_item` sees up-to-date check state without touching any external entity. - pub(crate) fn update_selection_snapshot(&mut self, snapshot: Vec<(IndexPath, D::Item)>) { + pub(crate) fn update_selection_snapshot(&mut self, snapshot: Vec) { self.selection_snapshot = snapshot; } } diff --git a/crates/ui/src/searchable_list/delegate.rs b/crates/ui/src/searchable_list/delegate.rs index 5d477933cd..ec08a42982 100644 --- a/crates/ui/src/searchable_list/delegate.rs +++ b/crates/ui/src/searchable_list/delegate.rs @@ -71,6 +71,18 @@ pub trait SearchableListDelegate: Sized + 'static { Self::Item: SearchableListItem, V: PartialEq; + /// Resolve an item by value, including items that are not in the current filtered view when + /// the delegate has access to a complete data source. + /// + /// The default implementation only supports the current visible list. + fn item_by_value(&self, value: &V) -> Option + where + Self::Item: SearchableListItem, + V: PartialEq, + { + self.position(value).and_then(|ix| self.item(ix).cloned()) + } + /// Called when the search query changes. /// /// Implementations should filter or fetch items and may return an async `Task`. @@ -127,19 +139,19 @@ pub trait SearchableListDelegate: Sized + 'static { /// Whether the item at `ix` should show a checkmark. /// - /// `current_selection` is the slice of currently selected `(IndexPath, Item)` pairs. + /// `current_selection` is the slice of currently selected items. /// /// Default: checks whether the item's value is present in `current_selection`. fn is_item_checked( &self, _ix: IndexPath, item: &Self::Item, - current_selection: &[(IndexPath, Self::Item)], + current_selection: &[Self::Item], _cx: &App, ) -> bool { current_selection .iter() - .any(|(_, selected_item)| selected_item.value() == item.value()) + .any(|selected_item| selected_item.value() == item.value()) } // MARK: Lifecycle / selection hooks @@ -149,16 +161,17 @@ pub trait SearchableListDelegate: Sized + 'static { /// `selection` is the live selection vec — the delegate may freely mutate it: add items, /// remove items, reorder, or leave it unchanged to effectively veto the operation. /// - /// `changes` is the slice of atomic changes the mode-strategy computed (e.g. Single - /// replacement deselects all then selects one; Multi toggles the clicked item). The delegate - /// is not required to apply them — they are informational. The default implementation applies - /// every change in order. + /// `changes` is the slice of atomic changes the mode-strategy computed. Multi-select changes + /// toggle the clicked item; single-select changes contain the new item to select, and the + /// parent starts the working selection empty so the default implementation replaces it. The + /// delegate is not required to apply them — they are informational. The default implementation + /// applies every change in order. /// /// No `cx` is available: this hook runs synchronously during the item-click handler while /// the list entity is mutably borrowed. Side effects that need cx belong in `on_confirm`. fn on_will_change( &mut self, - selection: &mut Vec<(IndexPath, Self::Item)>, + selection: &mut Vec, changes: &[SearchableListChange], ) { for change in changes { @@ -170,25 +183,21 @@ pub trait SearchableListDelegate: Sized + 'static { if !selection .iter() - .any(|(_, selected_item)| selected_item.value() == item.value()) + .any(|selected_item| selected_item.value() == item.value()) { - selection.push((*index, item.clone())); + selection.push(item.clone()); } } SearchableListChange::Deselect { index } => { if let Some(item) = self.item(*index) { let has_value = selection .iter() - .any(|(_, selected_item)| selected_item.value() == item.value()); + .any(|selected_item| selected_item.value() == item.value()); if has_value { - selection - .retain(|(_, selected_item)| selected_item.value() != item.value()); - continue; + selection.retain(|selected_item| selected_item.value() != item.value()); } } - - selection.retain(|(selected_ix, _)| selected_ix != index); } } } @@ -196,5 +205,5 @@ pub trait SearchableListDelegate: Sized + 'static { /// Called when the dropdown/popover is committed (Escape, `close_on_select`, or explicit /// confirm). `final_selection` is the selection after the last committed change. - fn on_confirm(&mut self, _final_selection: &[(IndexPath, Self::Item)]) {} + fn on_confirm(&mut self, _final_selection: &[Self::Item]) {} } diff --git a/crates/ui/src/searchable_list/state.rs b/crates/ui/src/searchable_list/state.rs index 34c2eee4d6..b0f11f5afd 100644 --- a/crates/ui/src/searchable_list/state.rs +++ b/crates/ui/src/searchable_list/state.rs @@ -20,7 +20,7 @@ where { pub focus_handle: FocusHandle, pub(crate) list: Entity>>, - pub(crate) selection: Vec<(IndexPath, D::Item)>, + pub(crate) selection: Vec, pub(crate) open: bool, /// Held while the popup is open, so that a component dropped without /// closing it first takes its registration with it. @@ -83,11 +83,20 @@ where let selection = { let delegate = &list.read(cx).delegate().delegate; - selected_indices - .iter() - .copied() - .filter_map(|ix| delegate.item(ix).map(|i| (ix, i.clone()))) - .collect::>() + let mut selection = Vec::new(); + for ix in selected_indices.iter().copied() { + let Some(item) = delegate.item(ix) else { + continue; + }; + + if !selection + .iter() + .any(|selected_item: &D::Item| selected_item.value() == item.value()) + { + selection.push(item.clone()); + } + } + selection }; if let Some(cursor) = selected_indices.first().copied() { @@ -131,14 +140,14 @@ where // MARK: Read-only accessors - pub fn selection(&self) -> &[(IndexPath, D::Item)] { + pub fn selection(&self) -> &[D::Item] { &self.selection } pub fn selected_values(&self) -> Vec<::Value> { self.selection .iter() - .map(|(_ix, i)| i.value().clone()) + .map(|item| item.value().clone()) .collect() } @@ -152,18 +161,26 @@ where // MARK: Mutation (no cx — callers emit events and notify) - /// Add an index+item pair to the selection; no-op if already present. - pub(crate) fn add_by_item(&mut self, index: IndexPath, item: D::Item) { - if self.selection.iter().any(|(ix, _)| ix == &index) { - return; + /// Add an item to the selection; no-op if its value is already present. + pub(crate) fn add_by_item(&mut self, item: D::Item) -> bool { + if self + .selection + .iter() + .any(|selected_item| selected_item.value() == item.value()) + { + return false; } - self.selection.push((index, item)); + self.selection.push(item); + true } - /// Remove an index from the selection by index path. - pub(crate) fn remove_by_index(&mut self, index: &IndexPath) -> bool { - if let Some(pos) = self.selection.iter().position(|(ix, _)| ix == index) { + /// Remove an item from the selection by value. + pub(crate) fn remove_by_value( + &mut self, + value: &::Value, + ) -> bool { + if let Some(pos) = self.selection.iter().position(|item| item.value() == value) { self.selection.remove(pos); return true; @@ -172,43 +189,71 @@ where false } - /// Add a single index to the selection by looking up the item in the list. + /// Add the item at a current visible index to the selection. /// /// Requires `cx` only to read the list entity; does not notify. pub fn add_selected_index(&mut self, index: IndexPath, cx: &App) -> bool { - if self.selection.iter().any(|(ix, _)| ix == &index) { + let Some(item) = self.list.read(cx).delegate().delegate.item(index).cloned() else { return false; - } + }; - let Some(item) = self.list.read(cx).delegate().delegate.item(index) else { + self.add_by_item(item) + } + + /// Remove the item at a current visible index from the selection. + pub fn remove_selected_index(&mut self, index: IndexPath, cx: &App) -> bool { + let Some(value) = self + .list + .read(cx) + .delegate() + .delegate + .item(index) + .map(|item| item.value().clone()) + else { return false; }; - self.add_by_item(index, item.clone()); + self.remove_by_value(&value) + } - true + /// Add a single item to the selection by value, resolving it from the delegate. + pub fn add_selected_value( + &mut self, + value: &::Value, + cx: &App, + ) -> bool { + let Some(item) = self.list.read(cx).delegate().delegate.item_by_value(value) else { + return false; + }; + + self.add_by_item(item) } - /// Remove a single index from the selection. - pub fn remove_selected_index(&mut self, index: IndexPath) -> bool { - self.remove_by_index(&index) + /// Remove a single item from the selection by value. + pub fn remove_selected_value( + &mut self, + value: &::Value, + ) -> bool { + self.remove_by_value(value) } - /// Replace the entire selection, looking up items from the list. + /// Replace the entire selection with items at current visible indices. pub fn set_selected_indices(&mut self, indices: impl IntoIterator, cx: &App) { - let indices: Vec = indices.into_iter().collect(); - - self.selection = indices + let items = indices .into_iter() - .filter_map(|ix| { - self.list - .read(cx) - .delegate() - .delegate - .item(ix) - .map(|i| (ix, i.clone())) - }) - .collect(); + .filter_map(|ix| self.list.read(cx).delegate().delegate.item(ix).cloned()) + .collect::>(); + + self.set_selected_items(items); + } + + /// Replace the entire selection with items, preserving input order and de-duplicating values. + pub(crate) fn set_selected_items(&mut self, items: impl IntoIterator) { + self.selection.clear(); + + for item in items { + self.add_by_item(item); + } } /// Push the current selection into the adapter's snapshot so the next render pass sees diff --git a/crates/ui/src/searchable_list/vec.rs b/crates/ui/src/searchable_list/vec.rs index 056e8f6aba..51b367eed5 100644 --- a/crates/ui/src/searchable_list/vec.rs +++ b/crates/ui/src/searchable_list/vec.rs @@ -64,6 +64,14 @@ impl SearchableListDelegate for Vec { .position(|v| v.value() == value) .map(|ix| IndexPath::default().row(ix)) } + + fn item_by_value(&self, value: &V) -> Option + where + Self::Item: SearchableListItem, + V: PartialEq, + { + self.iter().find(|item| item.value() == value).cloned() + } } // MARK: SearchableVec @@ -127,6 +135,17 @@ impl SearchableListDelegate for SearchableVec(&self, value: &V) -> Option + where + Self::Item: SearchableListItem, + V: PartialEq, + { + self.items + .iter() + .find(|item| item.value() == value) + .cloned() + } + fn perform_search(&mut self, query: &str, _: &mut Window, _: &mut App) -> Task<()> { self.matched_items = self .items @@ -222,6 +241,18 @@ impl SearchableListDelegate for SearchableVec(&self, value: &V) -> Option + where + Self::Item: SearchableListItem, + V: PartialEq, + { + self.items + .iter() + .flat_map(|group| group.items.iter()) + .find(|item| item.value() == value) + .cloned() + } + fn perform_search(&mut self, query: &str, _: &mut Window, _: &mut App) -> Task<()> { self.matched_items = self .items diff --git a/crates/ui/src/select.rs b/crates/ui/src/select.rs index ee243ac214..32b766f568 100644 --- a/crates/ui/src/select.rs +++ b/crates/ui/src/select.rs @@ -168,19 +168,14 @@ where .upgrade() .map(|e| e.read(cx).state.selection.clone()) .unwrap_or_default(); + let previous_selection = selection.clone(); - let changes = { - let mut changes: Vec = selection - .iter() - .map(|(ix, _)| SearchableListChange::Deselect { index: *ix }) - .collect(); + let changes = selected_index + .into_iter() + .map(|ix| SearchableListChange::Select { index: ix }) + .collect::>(); - if let Some(ix) = selected_index { - changes.push(SearchableListChange::Select { index: ix }); - } - - changes - }; + selection.clear(); // on_will_change is called directly — entity-handle access would // re-enter the ListState lock that defer_in holds for this callback. @@ -189,11 +184,21 @@ where .delegate .on_will_change(&mut selection, &changes); + if selected_index.is_some() + && selection.is_empty() + && !previous_selection.is_empty() + { + selection = previous_selection; + } + let new_selection = weak_confirm.update(cx, |this, cx| { this.state.selection = selection; - let final_value = - this.state.selection.first().map(|(_, i)| i.value().clone()); + let final_value = this + .state + .selection + .first() + .map(|item| item.value().clone()); cx.emit(SelectEvent::Confirm(final_value)); cx.notify(); @@ -221,9 +226,16 @@ where cx.defer_in(window, { let weak_cancel = weak_cancel.clone(); move |list_state, window, cx| { - let committed_ix = weak_cancel - .upgrade() - .and_then(|e| e.read(cx).state.selection.first().map(|(ix, _)| *ix)); + let committed_value = weak_cancel.upgrade().and_then(|e| { + e.read(cx) + .state + .selection + .first() + .map(|item| item.value().clone()) + }); + let committed_ix = committed_value + .as_ref() + .and_then(|value| list_state.delegate().delegate.position(value)); list_state.set_selected_index(committed_ix, window, cx); @@ -287,41 +299,37 @@ where .and_then(|ix| self.state.list.read(cx).delegate().delegate.item(ix)) .map(|i| i.clone()); - self.state.selection = match (selected_index, item) { - (Some(ix), Some(item)) => vec![(ix, item)], - _ => vec![], - }; + self.state.selection = item.into_iter().collect(); self.state.sync_snapshot(cx); + cx.notify(); } /// Set selected value for the select. /// - /// Looks up the position from the delegate and sets the selected index accordingly. - /// Passes `None` when the value is not found. - /// - /// The delegate looks the value up in its matched items, so an active search query is - /// cleared first to get an index into the full item list. + /// Resolves the item through [`SearchableListDelegate::item_by_value`]. [`SearchableVec`] + /// searches its complete data source; custom delegates can do the same by overriding that + /// method. The cursor is set only when the value is present in the current visible list. pub fn set_selected_value( &mut self, selected_value: &::Value, window: &mut Window, cx: &mut Context, ) { + let (selected_index, item) = { + let list = self.state.list.read(cx); + let delegate = &list.delegate().delegate; + ( + delegate.position(selected_value), + delegate.item_by_value(selected_value), + ) + }; + self.state.list.update(cx, |list, cx| { - if !list.query_input.read(cx).value().is_empty() { - list.set_query("", window, cx); - } + list._set_selected_index(selected_index, window, cx); }); - - let selected_index = self - .state - .list - .read(cx) - .delegate() - .delegate - .position(selected_value); - - self.set_selected_index(selected_index, window, cx); + self.state.selection = item.into_iter().collect(); + self.state.sync_snapshot(cx); + cx.notify(); } /// Replace the delegate (item data) for the select state. @@ -341,7 +349,7 @@ where /// Get the current selected value. pub fn selected_value(&self) -> Option<&::Value> { - self.state.selection.first().map(|(_, i)| i.value()) + self.state.selection.first().map(|item| item.value()) } /// Focus the select trigger input. @@ -356,7 +364,14 @@ where return; } - let committed_ix = self.state.selection.first().map(|(ix, _)| *ix); + let committed_value = self + .state + .selection + .first() + .map(|item| item.value().clone()); + let committed_ix = committed_value + .as_ref() + .and_then(|value| self.state.list.read(cx).delegate().delegate.position(value)); if self.selected_index(cx) != committed_ix { self.state.list.update(cx, |list, cx| { list.set_selected_index(committed_ix, window, cx); @@ -412,28 +427,16 @@ where .unwrap_or_else(|| t!("Select.placeholder").into()), ); - let Some(selected_index) = self.selected_index(cx) else { + let Some(item) = self.state.selection.first() else { return default_title; }; - let Some(title) = self - .state - .list - .read(cx) - .delegate() - .delegate - .item(selected_index) - .map(|item| { - if let Some(el) = item.display_title() { - el - } else if let Some(prefix) = self.title_prefix.as_ref() { - format!("{}{}", prefix, item.title()).into_any_element() - } else { - item.title().into_any_element() - } - }) - else { - return default_title; + let title = if let Some(el) = item.display_title() { + el + } else if let Some(prefix) = self.title_prefix.as_ref() { + format!("{}{}", prefix, item.title()).into_any_element() + } else { + item.title().into_any_element() }; div() @@ -452,7 +455,7 @@ where fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let searchable = self.searchable; let is_focused = self.state.focus_handle.is_focused(window); - let show_clean = self.state.cleanable && self.selected_index(cx).is_some(); + let show_clean = self.state.cleanable && !self.state.selection.is_empty(); let bounds = self.state.bounds; let allow_open = !(self.state.open || self.state.disabled); let outline_visible = self.state.open || (is_focused && !self.state.disabled); @@ -774,6 +777,8 @@ where #[cfg(test)] mod tests { + use std::{cell::Cell, rc::Rc}; + use gpui::{AppContext as _, TestAppContext}; use crate::{ @@ -817,49 +822,86 @@ mod tests { } #[gpui::test] - fn test_select_set_selected_value_clears_search_query(cx: &mut TestAppContext) { + fn test_select_hidden_value_keeps_selection_without_cursor_path(cx: &mut TestAppContext) { cx.update(crate::init); let cx = cx.add_empty_window(); cx.update(|window, cx| { - let items = SearchableVec::new(vec!["Rust", "Go", "C++"]); + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true)); let list = state.read(cx).state.list.clone(); - list.update(cx, |list, cx| list.set_query("Rust", window, cx)); - assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1); + list.update(cx, |list, cx| list.set_query("Ang", window, cx)); - state.update(cx, |state, cx| { - state.set_selected_value(&"Go", window, cx); + state.update(cx, |s, cx| { + s.set_selected_value(&"React", window, cx); + + assert_eq!(s.selected_value(), Some(&"React")); + assert_eq!(s.selected_index(cx), None); }); + assert_eq!(list.read(cx).query_input.read(cx).value(), "Ang"); - assert_eq!(state.read(cx).selected_value(), Some(&"Go")); - assert_eq!(state.read(cx).selected_index(cx), Some(IndexPath::new(1))); - assert_eq!(list.read(cx).query_input.read(cx).value(), ""); + list.update(cx, |list, cx| list.set_query("", window, cx)); + assert_eq!(state.read(cx).selected_value(), Some(&"React")); + }); + } + + #[gpui::test] + fn test_select_hidden_value_notifies_trigger(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + let state = cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true)) + }); + cx.run_until_parked(); + + let notified = Rc::new(Cell::new(false)); + let _subscription = cx.update({ + let state = state.clone(); + let notified = notified.clone(); + move |_, cx| cx.observe(&state, move |_, _| notified.set(true)) + }); + notified.set(false); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.state.list.update(cx, |list, cx| { + let _ = list + .delegate_mut() + .delegate + .perform_search("Ang", window, cx); + }); + state.set_selected_value(&"React", window, cx); + }); }); + cx.run_until_parked(); + + assert!( + notified.get(), + "setting a hidden value should redraw the trigger" + ); } #[gpui::test] - fn test_select_set_selected_value_clears_grouped_search_query(cx: &mut TestAppContext) { + fn test_select_grouped_hidden_value_resolves_from_full_items(cx: &mut TestAppContext) { cx.update(crate::init); let cx = cx.add_empty_window(); cx.update(|window, cx| { let mut groups: SearchableVec> = SearchableVec::new(vec![]); groups.push(SelectGroup::new("A").items(["Apple", "Avocado"])); groups.push(SelectGroup::new("B").items(["Banana", "Blueberry"])); - let state = cx.new(|cx| SelectState::new(groups, None, window, cx).searchable(true)); let list = state.read(cx).state.list.clone(); - list.update(cx, |list, cx| list.set_query("Blue", window, cx)); - state.update(cx, |state, cx| { - state.set_selected_value(&"Banana", window, cx); - }); + list.update(cx, |list, cx| list.set_query("Banana", window, cx)); - assert_eq!(state.read(cx).selected_value(), Some(&"Banana")); - assert_eq!( - state.read(cx).selected_index(cx), - Some(IndexPath::new(0).section(1)), - ); + state.update(cx, |s, cx| { + s.set_selected_value(&"Apple", window, cx); + + assert_eq!(s.selected_value(), Some(&"Apple")); + assert_eq!(s.selected_index(cx), None); + }); + assert_eq!(list.read(cx).query_input.read(cx).value(), "Banana"); }); } } diff --git a/skills/gpui/references/async.md b/skills/gpui/references/async.md index f25bf7c915..fcb144cbb0 100644 --- a/skills/gpui/references/async.md +++ b/skills/gpui/references/async.md @@ -192,7 +192,9 @@ fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context … { - let checked = parent.read(cx).selection.contains(&ix); // PANIC +fn render_item(&mut self, ix: IndexPath, item: &MyItem, …) -> … { + let checked = parent + .read(cx) + .selection + .iter() + .any(|selected_item| selected_item.value() == item.value()); // PANIC } // ✅ Read from snapshot field — no entity access at all -fn render_item(&mut self, ix: IndexPath, …) -> … { - let checked = self.selection_snapshot.iter().any(|(sel_ix, _)| sel_ix == &ix); +fn render_item(&mut self, ix: IndexPath, item: &MyItem, …) -> … { + let checked = self + .selection_snapshot + .iter() + .any(|selected_item| selected_item.value() == item.value()); } // After every mutation from outside render: -list.update(cx, |l, _| l.delegate_mut().update_snapshot(new_snapshot)); +list.update(cx, |l, _| { + l.delegate_mut().update_selection_snapshot(new_snapshot) +}); ``` ### Use Weak References in Closures diff --git a/skills/gpui/references/entity.md b/skills/gpui/references/entity.md index 1741b92c21..4ebf11acf8 100644 --- a/skills/gpui/references/entity.md +++ b/skills/gpui/references/entity.md @@ -182,7 +182,9 @@ impl SomeDelegate for MyAdapter { parent.update(cx, |this, cx| { /* … */ }); // Sync list state directly after parent update - list_state.delegate_mut().update_snapshot(new_val); + list_state + .delegate_mut() + .update_selection_snapshot(new_val); }); } } @@ -192,13 +194,20 @@ impl SomeDelegate for MyAdapter { ```rust // ❌ Panic in render_item — ListState is already locked -fn render_item(&mut self, ix: IndexPath, window: &mut Window, cx: &mut Context>) -> … { - let checked = parent_entity.read(cx).selection.contains(&ix); // PANIC +fn render_item(&mut self, ix: IndexPath, item: &MyItem, window: &mut Window, cx: &mut Context>) -> … { + let checked = parent_entity + .read(cx) + .selection + .iter() + .any(|selected_item| selected_item.value() == item.value()); // PANIC } // ✅ Read from a plain snapshot field — no entity access -fn render_item(&mut self, ix: IndexPath, window: &mut Window, cx: &mut Context>) -> … { - let checked = self.selection_snapshot.iter().any(|(sel_ix, _)| sel_ix == &ix); +fn render_item(&mut self, ix: IndexPath, item: &MyItem, window: &mut Window, cx: &mut Context>) -> … { + let checked = self + .selection_snapshot + .iter() + .any(|selected_item| selected_item.value() == item.value()); } ``` diff --git a/skills/gpui/references/test-reference.md b/skills/gpui/references/test-reference.md index 2187a925b0..7ee65665ce 100644 --- a/skills/gpui/references/test-reference.md +++ b/skills/gpui/references/test-reference.md @@ -246,9 +246,9 @@ Use a recording delegate to assert that hooks fire with the right arguments and ```rust #[derive(Default)] struct RecordingDelegate { - items: Vec, - will_change_calls: Vec>, - confirm_calls: Vec>, + items: SearchableVec, + will_change_calls: Vec>, + confirm_calls: Vec>, } impl SearchableListDelegate for RecordingDelegate { @@ -256,18 +256,15 @@ impl SearchableListDelegate for RecordingDelegate { fn on_will_change( &mut self, - change: &mut SearchableListChange, - _current: &[(IndexPath, Self::Item)], + selection: &mut Vec, + changes: &[SearchableListChange], ) { - self.will_change_calls.push( - change.select_queue.iter().map(|(ix, _)| *ix).collect() - ); + self.items.on_will_change(selection, changes); + self.will_change_calls.push(selection.clone()); } - fn on_confirm(&mut self, final_selection: &[(IndexPath, Self::Item)]) { - self.confirm_calls.push( - final_selection.iter().map(|(ix, _)| *ix).collect() - ); + fn on_confirm(&mut self, final_selection: &[Self::Item]) { + self.confirm_calls.push(final_selection.to_vec()); } } @@ -282,10 +279,13 @@ fn test_hooks_fire_in_correct_order(cx: &mut TestAppContext) { cx.run_until_parked(); state.read_with(cx, |s, cx| { - let delegate = s.state.list.read(cx).delegate().delegate; + let delegate = &s.state.list.read(cx).delegate().delegate; assert_eq!(delegate.will_change_calls.len(), 1); assert_eq!(delegate.confirm_calls.len(), 1); - assert_eq!(delegate.confirm_calls[0], vec![IndexPath::new(0)]); + assert_eq!(delegate.confirm_calls[0].len(), 1); + + let expected = delegate.items.item(IndexPath::new(0)).unwrap(); + assert_eq!(delegate.confirm_calls[0][0].value(), expected.value()); }); } ``` @@ -470,4 +470,4 @@ fn test_networked_components(cx: &mut TestAppContext) { let received = receiver.read_with(cx, |receiver, _| receiver.messages.clone()); assert_eq!(received, vec!["Hello"]); } -``` \ No newline at end of file +``` diff --git a/website/docs/components/combobox.md b/website/docs/components/combobox.md index 2b6e3e2d30..9024067308 100644 --- a/website/docs/components/combobox.md +++ b/website/docs/components/combobox.md @@ -23,7 +23,7 @@ Use `Select` for simple single-value picking. Use `Combobox` when you need multi ```rust use gpui_component::combobox::{ - Combobox, ComboboxState, ComboboxEvent, ComboboxTriggerCtx, + Combobox, ComboboxState, ComboboxEvent, ComboboxTriggerContext, }; use gpui_component::searchable_list::{ SearchableListItem, SearchableVec, SearchableGroup, @@ -171,20 +171,21 @@ Combobox::new(&state) ### Custom Trigger -Override the entire trigger element. `ComboboxTriggerCtx` exposes the current selection, open/disabled flags, and size: +Override the entire trigger element. `ComboboxTriggerContext` exposes the current selection, +placeholder, open/disabled flags, and size through accessor methods: ```rust Combobox::new(&state) - .render_trigger(|ctx, _, cx| { + .render_trigger(|trigger, _, cx| { h_flex() .w_full() .items_center() .gap_2() - .when(ctx.selection.is_empty(), |this| { + .when(trigger.selection().is_empty(), |this| { this.text_color(cx.theme().muted_foreground) .child("Select...") }) - .children(ctx.selection.iter().map(|(_, item)| { + .children(trigger.selection().iter().map(|item| { div() .bg(cx.theme().accent) .rounded_sm() @@ -197,6 +198,10 @@ Combobox::new(&state) }) ``` +`trigger.selection()` contains the selected items in selection order. The item's `Value` is used +for identity; it does not include an `IndexPath`, so a selection remains valid while the list is +filtered. The other accessors are `placeholder()`, `is_open()`, `is_disabled()`, and `size()`. + ### Sizes ```rust @@ -255,6 +260,12 @@ state.update(cx, |s, cx| { s.remove_selected_index(IndexPath::new(0), cx); }); +// Add / remove by value +state.update(cx, |s, cx| { + s.add_selected_value(&"React", cx); + s.remove_selected_value(&"Angular", cx); +}); + // Clear all selections state.update(cx, |s, cx| { s.clear_selection(cx); @@ -267,6 +278,11 @@ let values = state.read(cx).selected_values(); // Vec let value = state.read(cx).selected_value(); // Option ``` +Index-based methods operate on the current visible list. Value-based methods resolve through +`SearchableListDelegate::item_by_value`. `SearchableVec` searches its complete data source, so it +can resolve items hidden by the current search. Custom delegates must override `item_by_value` to +provide the same behavior. + ## Keyboard Shortcuts | Key | Action | diff --git a/website/docs/components/select.md b/website/docs/components/select.md index 3d8a12a53e..e343c2e8bc 100644 --- a/website/docs/components/select.md +++ b/website/docs/components/select.md @@ -233,6 +233,11 @@ state.update(cx, |state, cx| { let current_value = state.read(cx).selected_value(); ``` +`set_selected_value` resolves the item through `SearchableListDelegate::item_by_value`. +`SearchableVec` searches its complete data source, so it can select an item hidden by the current +search. Custom delegates must override `item_by_value` to provide the same behavior. The +`selected_index` cursor is only restored when that item is visible in the current filtered list. + Update items: ```rust diff --git a/website/zh-CN/docs/components/combobox.md b/website/zh-CN/docs/components/combobox.md index 592e068d1d..a9330e693f 100644 --- a/website/zh-CN/docs/components/combobox.md +++ b/website/zh-CN/docs/components/combobox.md @@ -23,7 +23,7 @@ description: 带有可搜索下拉列表的自动补全输入组件。 ```rust use gpui_component::combobox::{ - Combobox, ComboboxState, ComboboxEvent, ComboboxTriggerCtx, + Combobox, ComboboxState, ComboboxEvent, ComboboxTriggerContext, }; use gpui_component::searchable_list::{ SearchableListItem, SearchableVec, SearchableGroup, @@ -171,20 +171,21 @@ Combobox::new(&state) ### 自定义触发器 -完全覆盖触发器元素的渲染。`ComboboxTriggerCtx` 包含当前选中状态、开关标志和尺寸信息: +完全覆盖触发器元素的渲染。`ComboboxTriggerContext` 通过访问器方法提供当前选中状态、 +占位内容、开关标志和尺寸信息: ```rust Combobox::new(&state) - .render_trigger(|ctx, _, cx| { + .render_trigger(|trigger, _, cx| { h_flex() .w_full() .items_center() .gap_2() - .when(ctx.selection.is_empty(), |this| { + .when(trigger.selection().is_empty(), |this| { this.text_color(cx.theme().muted_foreground) .child("请选择...") }) - .children(ctx.selection.iter().map(|(_, item)| { + .children(trigger.selection().iter().map(|item| { div() .bg(cx.theme().accent) .rounded_sm() @@ -197,6 +198,10 @@ Combobox::new(&state) }) ``` +`trigger.selection()` 包含按选中顺序排列的选中项。选项的 `Value` 用于身份比较;其中不包含 +`IndexPath`,因此列表过滤时选中状态仍然有效。其他访问器包括 `placeholder()`、 +`is_open()`、`is_disabled()` 和 `size()`。 + ### 尺寸 ```rust @@ -255,6 +260,12 @@ state.update(cx, |s, cx| { s.remove_selected_index(IndexPath::new(0), cx); }); +// 按值增加 / 移除 +state.update(cx, |s, cx| { + s.add_selected_value(&"React", cx); + s.remove_selected_value(&"Angular", cx); +}); + // 清空选中 state.update(cx, |s, cx| { s.clear_selection(cx); @@ -267,6 +278,10 @@ let values = state.read(cx).selected_values(); // Vec let value = state.read(cx).selected_value(); // Option ``` +按索引操作的方法只作用于当前可见列表。按值操作的方法通过 +`SearchableListDelegate::item_by_value` 解析选项。`SearchableVec` 会搜索完整数据源,因此能 +解析当前搜索隐藏的选项;自定义 delegate 必须覆盖 `item_by_value` 才能提供相同行为。 + ## 键盘快捷键 | 按键 | 操作 | diff --git a/website/zh-CN/docs/components/select.md b/website/zh-CN/docs/components/select.md index 517e162627..2280a2a49f 100644 --- a/website/zh-CN/docs/components/select.md +++ b/website/zh-CN/docs/components/select.md @@ -223,6 +223,11 @@ state.update(cx, |state, cx| { let current_value = state.read(cx).selected_value(); ``` +`set_selected_value` 通过 `SearchableListDelegate::item_by_value` 解析选项。 +`SearchableVec` 会搜索完整数据源,因此可以选中当前搜索隐藏的选项;自定义 delegate 必须覆盖 +`item_by_value` 才能提供相同行为。只有当该选项在当前过滤列表中可见时,才会恢复 +`selected_index` 光标。 + 更新选项列表: ```rust