Skip to main content

eui_core/layout_tree/
search.rs

1//! EXP-1259 (the UI batch): the low-level pieces the app settled on, built
2//! from the natives every painter already draws.
3//!
4//! - `SearchList` (packages/domain-contract/fixtures/search-list.json): a
5//!   `head` row (the host-owned `search` field + the muted `count` while a
6//!   query is typed) over the `body`: a `list` of `item` rows (`icon`,
7//!   mono `identifier`, `label`, the picked row's `check`) at most
8//!   `maxRows` tall, scrolling, under a bottom `fade` while rows sit below.
9//!   The HIGHLIGHTED row (`selected` state) is core state: the field's
10//!   arrows move it (no wrap), Enter or Tab picks it, a hover moves it,
11//!   typing puts it back on the top row.
12//! - A searchable Menu submenu: the same head + list + fade around the
13//!   submenu's entries (`menuItem.searchable`).
14//! - `Skeleton` rows (feedback-states.json `placeholder`): `rows` × a `row`
15//!   (the row height) of a `dot` and two `bar`s, hairline-divided; the dot
16//!   and bars are `Skeleton` leaves, so every painter's pulse applies.
17
18use serde_json::{json, Value};
19
20use super::{js, str_prop, Builder, LNode, NodeKind};
21
22/// The bars' widths per placeholder row (lead, title): the web's
23/// `SkeletonRows`, so a stack never reads as one ruled block.
24pub const SKELETON_BARS: [(f32, f32); 3] = [(56.0, 168.0), (56.0, 120.0), (56.0, 192.0)];
25
26/// The rows a search list shows by default before it scrolls.
27pub const SEARCH_LIST_ROWS: usize = 8;
28
29/// Does a search row match the query (case-insensitive, label or identifier)?
30pub 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
35/// The scroll offset that keeps row `active` (fixed `row` px rows) inside a
36/// `viewport` px window currently at `offset`.
37pub 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    /// The index of the node built with `id` in this build.
51    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    /// The rows visible before a search list scrolls (`maxRows`, ≥ 1).
56    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    /// The highlighted row of the list keyed `key`, clamped to `count`.
61    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    /// The `head` row: the `search` field (a host-owned one-line field,
66    /// component `SearchList`, keyed `key` for its query) and the `count`.
67    #[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    /// The scrolling `list` (at most `max_rows` rows tall) inside a
80    /// relative `body`; returns (body, list).
81    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    /// The bottom `fade` while rows sit below the list's window.
90    #[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    /// SearchList: head + body (list, fade).
105    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        // A host that searches (`on.search`) ranks the rows itself.
112        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    /// A searchable Menu submenu's content (`menuItem.searchable`): head,
159    /// then the entries that match the query as menu rows (the highlighted
160    /// one `selected`), the empty line, the fade. `suffix` = the submenu
161    /// row's (`.2`), so the parts are `<menu>.search.2`, `<menu>.list.2`.
162    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    /// Skeleton with `rows`: the root holds `rows` placeholder rows (the
215    /// row height, else `rowHeight`), each a `dot` (unless `dot: false`)
216    /// and two `bar`s; the root paints nothing itself.
217    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        // The block's own recipe (its fill) gives way to the rows'.
225        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            // The ROW height (the recipe only spaces the row): `rowHeight`, else `$control.row`.
232            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            // The two bars: `<id>.bar.<row>.0` (the lead) and `.1` (the title).
243            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        // 32 px rows, 8 visible.
265        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}