gpui_component/searchable_list/delegate.rs
1use gpui::{AnyElement, App, IntoElement, SharedString, Task, Window};
2
3use crate::IndexPath;
4
5use super::change::SearchableListChange;
6
7/// An item that can appear in a searchable list (Select, ComboBox).
8pub trait SearchableListItem: Clone {
9 type Value: Clone + PartialEq;
10
11 /// Short display label shown in the dropdown row and in the trigger by default.
12 ///
13 /// This is also what assistive technology reads as the committed value, so
14 /// it has to stand on its own as text even when [`Self::display_title`]
15 /// draws something richer.
16 fn title(&self) -> SharedString;
17
18 /// Override the trigger display element (e.g. "Country (US)" instead of just "United States").
19 ///
20 /// Returns `None` to fall back to `title()`.
21 ///
22 /// This is presentation only. An element is not text, so the accessible
23 /// value keeps reporting [`Self::title`]; if the two would read
24 /// differently, put the meaning a listener needs in `title()`.
25 fn display_title(&self) -> Option<AnyElement> {
26 None
27 }
28
29 /// Render this item's row content inside the dropdown.
30 ///
31 /// Override to add icons, avatars, secondary text, etc.
32 /// The default renders `title()`.
33 fn render(&self, _: &mut Window, _: &mut App) -> impl IntoElement {
34 self.title()
35 }
36
37 /// The value that identifies this item.
38 fn value(&self) -> &Self::Value;
39
40 /// Whether this item matches the search query.
41 ///
42 /// Defaults to case-insensitive substring match on `title()`.
43 fn matches(&self, query: &str) -> bool {
44 self.title().to_lowercase().contains(&query.to_lowercase())
45 }
46
47 /// Whether this item should be shown as non-interactive (grayed-out, unclickable).
48 fn disabled(&self) -> bool {
49 false
50 }
51}
52
53/// Provides data and search behaviour to a searchable list component.
54pub trait SearchableListDelegate: Sized + 'static {
55 type Item: SearchableListItem;
56
57 /// Number of sections (groups) in the list. Defaults to 1.
58 fn sections_count(&self, _: &App) -> usize {
59 1
60 }
61
62 /// Optional header element for the given section index.
63 ///
64 /// Deprecated: override [`render_section_header`] instead (provides `Window` + `App` access).
65 #[deprecated]
66 fn section(&self, _section: usize) -> Option<AnyElement> {
67 None
68 }
69
70 /// Number of items in the given section.
71 fn items_count(&self, section: usize) -> usize;
72
73 /// Return a reference to the item at the given index path.
74 fn item(&self, ix: IndexPath) -> Option<&Self::Item>;
75
76 /// Find the index path of the item whose value equals `value`.
77 fn position<V>(&self, _value: &V) -> Option<IndexPath>
78 where
79 Self::Item: SearchableListItem<Value = V>,
80 V: PartialEq;
81
82 /// Called when the search query changes.
83 ///
84 /// Implementations should filter or fetch items and may return an async `Task`.
85 /// The `App` context allows spawning background work.
86 fn perform_search(&mut self, _query: &str, _window: &mut Window, _cx: &mut App) -> Task<()> {
87 Task::ready(())
88 }
89
90 // MARK: Rendering hooks
91
92 /// Override the row content for the item at `ix`.
93 ///
94 /// When `Some(_)` is returned, the adapter suppresses its default `SearchableListItemElement`
95 /// layout (including the automatic trailing check icon) — the returned element is rendered
96 /// as-is. Return `None` to fall back to the standard rendering.
97 ///
98 /// `checked` is `true` when the item is in the current selection (as determined by
99 /// `is_item_checked`), letting custom renderers show their own selection indicator.
100 ///
101 /// Replaces the `item_renderer` closure that was previously set on `SearchableListAdapter`.
102 fn render_item(
103 &self,
104 _ix: IndexPath,
105 _item: &Self::Item,
106 _checked: bool,
107 _window: &mut Window,
108 _cx: &mut App,
109 ) -> Option<AnyElement> {
110 None
111 }
112
113 /// Render the header element for the given section (full render access).
114 ///
115 /// When `Some(_)` is returned, it is rendered directly — the adapter's default div wrapper
116 /// (padding, muted colour) is bypassed. Return `None` to fall back to the deprecated
117 /// `section()` wrapped in the standard div (no visual change for existing delegates).
118 fn render_section_header(
119 &self,
120 _section: usize,
121 _window: &mut Window,
122 _cx: &mut App,
123 ) -> Option<AnyElement> {
124 None
125 }
126
127 // MARK: Item state hooks
128
129 /// Whether the item at `ix` should be rendered as interactive.
130 ///
131 /// Default: `!item.disabled()`.
132 fn is_item_enabled(&self, _ix: IndexPath, item: &Self::Item, _cx: &App) -> bool {
133 !item.disabled()
134 }
135
136 /// Whether the item at `ix` should show a checkmark.
137 ///
138 /// `current_selection` is the slice of currently selected `(IndexPath, Item)` pairs.
139 ///
140 /// Default: checks whether the item's value is present in `current_selection`.
141 fn is_item_checked(
142 &self,
143 _ix: IndexPath,
144 item: &Self::Item,
145 current_selection: &[(IndexPath, Self::Item)],
146 _cx: &App,
147 ) -> bool {
148 current_selection
149 .iter()
150 .any(|(_, selected_item)| selected_item.value() == item.value())
151 }
152
153 // MARK: Lifecycle / selection hooks
154
155 /// Called before a user-triggered selection change is committed.
156 ///
157 /// `selection` is the live selection vec — the delegate may freely mutate it: add items,
158 /// remove items, reorder, or leave it unchanged to effectively veto the operation.
159 ///
160 /// `changes` is the slice of atomic changes the mode-strategy computed (e.g. Single
161 /// replacement deselects all then selects one; Multi toggles the clicked item). The delegate
162 /// is not required to apply them — they are informational. The default implementation applies
163 /// every change in order.
164 ///
165 /// No `cx` is available: this hook runs synchronously during the item-click handler while
166 /// the list entity is mutably borrowed. Side effects that need cx belong in `on_confirm`.
167 fn on_will_change(
168 &mut self,
169 selection: &mut Vec<(IndexPath, Self::Item)>,
170 changes: &[SearchableListChange],
171 ) {
172 for change in changes {
173 match change {
174 SearchableListChange::Select { index } => {
175 let Some(item) = self.item(*index) else {
176 continue;
177 };
178
179 if !selection
180 .iter()
181 .any(|(_, selected_item)| selected_item.value() == item.value())
182 {
183 selection.push((*index, item.clone()));
184 }
185 }
186 SearchableListChange::Deselect { index } => {
187 if let Some(item) = self.item(*index) {
188 let has_value = selection
189 .iter()
190 .any(|(_, selected_item)| selected_item.value() == item.value());
191
192 if has_value {
193 selection
194 .retain(|(_, selected_item)| selected_item.value() != item.value());
195 continue;
196 }
197 }
198
199 selection.retain(|(selected_ix, _)| selected_ix != index);
200 }
201 }
202 }
203 }
204
205 /// Called when the dropdown/popover is committed (Escape, `close_on_select`, or explicit
206 /// confirm). `final_selection` is the selection after the last committed change.
207 fn on_confirm(&mut self, _final_selection: &[(IndexPath, Self::Item)]) {}
208}