1use serde_json::{json, Value};
19
20use super::{js, str_prop, Builder, LNode, NodeKind};
21
22pub const SKELETON_BARS: [(f32, f32); 3] = [(56.0, 168.0), (56.0, 120.0), (56.0, 192.0)];
25
26pub const SEARCH_LIST_ROWS: usize = 8;
28
29pub fn search_matches(label: &str, identifier: &str, query: &str) -> bool {
31 let needle = query.trim().to_lowercase();
32 needle.is_empty() || label.to_lowercase().contains(&needle) || identifier.to_lowercase().contains(&needle)
33}
34
35pub fn reveal_offset(active: usize, row: f32, viewport: f32, offset: f32) -> f32 {
38 let top = active as f32 * row;
39 let bottom = top + row;
40 if top < offset {
41 top
42 } else if bottom > offset + viewport {
43 (bottom - viewport).max(0.0)
44 } else {
45 offset
46 }
47}
48
49impl Builder<'_, '_> {
50 pub(crate) fn slot_of_id(&self, id: &str) -> Option<u32> {
52 self.nodes.iter().rposition(|n| n.id == id).map(|i| i as u32)
53 }
54
55 fn max_rows(props: &crate::types::Props) -> usize {
57 props.get("maxRows").and_then(Value::as_f64).filter(|n| n.is_finite() && *n >= 1.0).map(|n| n.floor() as usize).unwrap_or(SEARCH_LIST_ROWS)
58 }
59
60 fn active_row(&self, key: &str, count: usize) -> usize {
62 self.ctx.local.active.get(key).copied().unwrap_or(0).min(count.saturating_sub(1))
63 }
64
65 #[allow(clippy::too_many_arguments)]
68 fn search_head(&mut self, parent: u32, owner: &LNode, suffix: &str, placeholder: &str, query: &str, count: Option<usize>, focus_first: bool) -> u32 {
69 let head = self.part_in(parent, owner, "head", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "flexShrink": 0}), json!({}), suffix);
70 let search = self.part_in(head, owner, "search", "SearchList", NodeKind::Leaf, json!({"flexGrow": 1, "flexShrink": 1, "minWidth": 0, "alignSelf": "stretch"}), json!({"placeholder": placeholder, "value": query, "icon": super::builtin_icon("SearchList.search"), "autofocus": focus_first}), suffix);
71 self.nodes[search as usize].pressable = true;
72 if let Some(n) = count {
73 let c = self.part_in(head, owner, "count", "Text", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"text": n.to_string(), "variant": "muted", "lines": 1}), suffix);
74 self.nodes[c as usize].live = Some("polite".into());
75 }
76 search
77 }
78
79 fn search_body(&mut self, parent: u32, owner: &LNode, suffix: &str, max_rows: usize, row: f32) -> (u32, u32) {
82 let body = self.part_in(parent, owner, "body", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "position": "relative", "flexShrink": 1, "minHeight": 0}), json!({}), suffix);
83 self.nodes[body as usize].part_query = None;
84 let max = max_rows as f32 * row;
85 let list = self.part_in(body, owner, "list", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "flexShrink": 1, "minHeight": 0, "maxHeight": max, "overflowY": "auto"}), json!({}), suffix);
86 (body, list)
87 }
88
89 #[allow(clippy::too_many_arguments)]
91 fn search_fade(&mut self, body: u32, owner: &LNode, suffix: &str, list_id: &str, rows: usize, max_rows: usize, row: f32) {
92 if rows <= max_rows {
93 return;
94 }
95 let viewport = max_rows as f32 * row;
96 let offset = self.ctx.local.scroll.get(list_id).map(|s| s.1).unwrap_or(0.0);
97 if offset + viewport >= rows as f32 * row - 0.5 {
98 return;
99 }
100 let fade = crate::layout::SEARCH_LIST_FADE;
101 self.part_in(body, owner, "fade", "Box", NodeKind::Container, json!({"position": "absolute", "left": 0, "right": 0, "bottom": 0, "height": fade}), json!({}), suffix);
102 }
103
104 pub(crate) fn search_list(&mut self, index: u32) {
106 self.nodes[index as usize].kind = NodeKind::Container;
107 self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("column")), ("overflow", json!("hidden"))]);
108 let owner = self.nodes[index as usize].clone();
109 let items: Vec<Value> = owner.props.get("items").and_then(Value::as_array).map(|a| a.iter().filter(|o| o.is_object()).cloned().collect()).unwrap_or_default();
110 let query = self.ctx.local.query.get(&owner.id).cloned().or_else(|| owner.props.get("query").map(js)).unwrap_or_default();
111 let host_filters = owner.on.as_ref().is_some_and(|o| o.contains_key("search"));
113 let shown: Vec<(usize, &Value)> = items
114 .iter()
115 .enumerate()
116 .filter(|(_, it)| host_filters || search_matches(&it.get("label").map(js).unwrap_or_default(), &it.get("identifier").map(js).unwrap_or_default(), &query))
117 .collect();
118 let max_rows = Self::max_rows(&owner.props);
119 let show_count = owner.props.get("showCount").and_then(Value::as_bool).unwrap_or(true) && !query.trim().is_empty();
120 let placeholder = str_prop(&owner.props, "placeholder").map(str::to_string).unwrap_or_else(|| self.string("search"));
121 self.search_head(index, &owner, "", &placeholder, &query, show_count.then_some(shown.len()), false);
122 let row = self.ctx.row_extent;
123 let (body, list) = self.search_body(index, &owner, "", max_rows, row);
124 let active = self.active_row(&owner.id, shown.len());
125 let picked = owner.props.get("value").map(js).unwrap_or_default();
126 for (pos, (i, item)) in shown.iter().enumerate() {
127 let suffix = format!(".{i}");
128 let value = item.get("value").map(js).unwrap_or_default();
129 let label = item.get("label").map(js).unwrap_or_default();
130 let disabled = item.get("disabled").and_then(Value::as_bool).unwrap_or(false);
131 let row = self.part_in(list, &owner, "item", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "flexShrink": 0}), json!({"value": value, "label": label, "position": pos, "disabled": disabled}), &suffix);
132 self.nodes[row as usize].pressable = !disabled;
133 if pos == active {
134 self.add_state(row, "selected");
135 }
136 if disabled {
137 self.add_state(row, "disabled");
138 }
139 if let Some(icon) = item.get("icon").filter(|v| v.is_string()) {
140 self.part_in(row, &owner, "icon", "Icon", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"name": icon, "size": "sm"}), &suffix);
141 }
142 if let Some(identifier) = item.get("identifier").map(js).filter(|s| !s.is_empty()) {
143 self.part_in(row, &owner, "identifier", "Text", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"text": identifier, "lines": 1}), &suffix);
144 }
145 self.part_in(row, &owner, "label", "Text", NodeKind::Leaf, json!({"flexGrow": 1, "flexShrink": 1, "minWidth": 0}), json!({"text": label, "lines": 1}), &suffix);
146 if !picked.is_empty() && value == picked {
147 self.icon_part(row, &owner, "check", "SearchList.check", &suffix);
148 }
149 }
150 if shown.is_empty() {
151 let empty = owner.props.get("emptyText").map(js).unwrap_or_else(|| self.string("noResults"));
152 self.text_part(list, &owner, "empty", &empty, "muted", "");
153 }
154 let list_id = self.nodes[list as usize].id.clone();
155 self.search_fade(body, &owner, "", &list_id, shown.len(), max_rows, row);
156 }
157
158 pub(crate) fn menu_search(&mut self, root: u32, owner: &LNode, item: &Value, suffix: &str) {
163 let key = format!("{}.search{suffix}", owner.id);
164 let query = self.ctx.local.query.get(&key).cloned().unwrap_or_default();
165 let sub_items = item.get("items").and_then(Value::as_array).cloned().unwrap_or_default();
166 let entries: Vec<(usize, Value)> = sub_items
167 .into_iter()
168 .enumerate()
169 .filter(|(_, it)| !matches!(it.get("kind").and_then(Value::as_str), Some("separator" | "label")))
170 .filter(|(_, it)| search_matches(&it.get("label").map(js).unwrap_or_default(), "", &query))
171 .collect();
172 let placeholder = str_prop(item.as_object().unwrap_or(&serde_json::Map::new()), "placeholder").map(str::to_string).unwrap_or_else(|| self.string("search"));
173 let count = (!query.trim().is_empty()).then_some(entries.len());
174 self.search_head(root, owner, suffix, &placeholder, &query, count, true);
175 let row = self.ctx.menu_row;
176 let (body, list) = self.search_body(root, owner, suffix, SEARCH_LIST_ROWS, row);
177 let active = self.active_row(&key, entries.len());
178 for (pos, (i, entry)) in entries.iter().enumerate() {
179 let row_suffix = format!("{suffix}.{i}");
180 let disabled = entry.get("disabled").and_then(Value::as_bool).unwrap_or(false);
181 let label = entry.get("label").map(js).unwrap_or_default();
182 let kind = entry.get("kind").and_then(Value::as_str).unwrap_or("item");
183 let mut props = json!({"text": label, "value": entry.get("value").cloned().unwrap_or(Value::Null), "kind": kind, "disabled": disabled, "lines": 1, "position": pos});
184 if kind == "checkbox" {
185 props["checked"] = json!(entry.get("checked").and_then(Value::as_bool).unwrap_or(false));
186 }
187 let row = self.part_in(list, owner, "item", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": "$spacing.sm", "flexShrink": 0}), props, &row_suffix);
188 self.nodes[row as usize].pressable = !disabled;
189 if pos == active {
190 self.add_state(row, "selected");
191 }
192 if disabled {
193 self.add_state(row, "disabled");
194 }
195 let mut label_props = json!({"text": label, "lines": 1});
196 if let Some(icon) = entry.get("icon") {
197 label_props["icon"] = icon.clone();
198 }
199 let l = self.part_in(row, owner, "itemLabel", "Text", NodeKind::Leaf, json!({"flexGrow": 1, "flexShrink": 1, "minWidth": 0}), label_props, &row_suffix);
200 self.nodes[l as usize].part_query = None;
201 if kind == "checkbox" && entry.get("checked").and_then(Value::as_bool) == Some(true) {
202 let glyph = format!("{}.check", owner.component);
203 self.icon_part(row, owner, "check", &glyph, &row_suffix);
204 }
205 }
206 if entries.is_empty() {
207 let empty = self.string("noResults");
208 self.text_part(list, owner, "empty", &empty, "muted", suffix);
209 }
210 let list_id = self.nodes[list as usize].id.clone();
211 self.search_fade(body, owner, suffix, &list_id, entries.len(), SEARCH_LIST_ROWS, row);
212 }
213
214 pub(crate) fn skeleton_rows(&mut self, index: u32) {
218 let owner = self.nodes[index as usize].clone();
219 let rows = owner.props.get("rows").and_then(Value::as_f64).filter(|n| n.is_finite() && *n >= 1.0).map(|n| n.floor().min(50.0) as usize).unwrap_or(0);
220 if rows == 0 {
221 return;
222 }
223 self.nodes[index as usize].kind = NodeKind::Container;
224 self.nodes[index as usize].own_query = None;
226 self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("column")), ("alignSelf", json!("stretch"))]);
227 let dot = owner.props.get("dot").and_then(Value::as_bool).unwrap_or(true);
228 let height = owner.props.get("rowHeight").and_then(Value::as_f64).filter(|n| n.is_finite() && *n > 0.0);
229 for r in 0..rows {
230 let suffix = format!(".{r}");
231 let mut style = json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "flexShrink": 0, "height": height.map(|h| json!(h)).unwrap_or_else(|| json!("$control.row"))});
233 if r > 0 {
234 style["borderTopWidth"] = json!("$border.hairline");
235 style["borderColor"] = json!("$color.border");
236 }
237 let row = self.part_in(index, &owner, "row", "Box", NodeKind::Container, style, json!({}), &suffix);
238 let leaf = |b: &mut Self, part: &str, style: Value, suffix: &str| b.part_in(row, &owner, part, "Skeleton", NodeKind::Leaf, style, json!({}), suffix);
239 if dot {
240 leaf(self, "dot", json!({"flexShrink": 0}), &suffix);
241 }
242 let (lead, title) = SKELETON_BARS[r % SKELETON_BARS.len()];
244 leaf(self, "bar", json!({"width": lead, "flexShrink": 0}), &format!("{suffix}.0"));
245 leaf(self, "bar", json!({"width": title, "flexShrink": 1, "minWidth": 0}), &format!("{suffix}.1"));
246 }
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn a_query_matches_the_label_or_the_identifier_case_insensitively() {
256 assert!(search_matches("Ship the search list", "EXP-12", ""));
257 assert!(search_matches("Ship the search list", "EXP-12", "SEARCH"));
258 assert!(search_matches("Ship the search list", "EXP-12", "exp-1"));
259 assert!(!search_matches("Ship the search list", "EXP-12", "tabs"));
260 }
261
262 #[test]
263 fn the_highlighted_row_scrolls_into_the_window() {
264 assert_eq!(reveal_offset(0, 32.0, 256.0, 0.0), 0.0);
266 assert_eq!(reveal_offset(8, 32.0, 256.0, 0.0), 32.0);
267 assert_eq!(reveal_offset(3, 32.0, 256.0, 64.0), 64.0);
268 assert_eq!(reveal_offset(1, 32.0, 256.0, 64.0), 32.0);
269 }
270}