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