Skip to main content

tilo_app/framework/views/
chips.rs

1//! The filter chips under a query view's field: one per filterable field
2//! of the app's types, drawn the same way in every app.
3//!
4//! A toggle field (Liked) is a chip that is on or off. A value field
5//! (Artist, Year) is a chip that opens a list of the values the results
6//! share, with how many share each, any number of which may be chosen; the
7//! chip then reads its choice ("Artist: Aurora Skies") and offers to clear
8//! it. A value typed as a token (`artist:ada`) is the same choice; the token
9//! in the field is what takes it away.
10
11use std::collections::HashMap;
12use std::rc::Rc;
13
14use ui_core::vello::kurbo::Rect;
15use ui_core::widget::context_menu::{ContextMenu, MenuItem};
16use ui_core::widget::{Chip as ChipWidget, Element, Row};
17
18use super::engine::QueryResults;
19use super::kind::{FilterKind, Item, KindDecl, filters};
20use super::query::{Chip, QuerySpec};
21
22/// How long a chip's label may grow with the values it names before it says
23/// how many more there are.
24const LABEL_VALUES: usize = 2;
25
26/// One value a value filter offers.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct FacetValue {
29    pub value: String,
30    /// How many of the results have it.
31    pub count: usize,
32    pub chosen: bool,
33    /// It was typed as a token.
34    pub typed: bool,
35}
36
37/// The chip of one filterable field.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct FacetOffer {
40    pub key: String,
41    /// The field's name ("Artist").
42    pub name: String,
43    pub kind: FilterKind,
44    /// What the chip reads: its name, with its choice once it has one
45    /// ("Artist: Aurora Skies").
46    pub label: String,
47    /// The filter narrows the results now.
48    pub active: bool,
49    /// Its choice can be cleared with the chip's own control: something of it
50    /// was picked, not typed.
51    pub clearable: bool,
52    /// A value filter's values, the most shared first, chosen ones always
53    /// among them.
54    pub values: Vec<FacetValue>,
55}
56
57impl FacetOffer {
58    /// The query with `value` of this filter chosen, or no longer chosen.
59    #[must_use]
60    pub fn toggled(&self, query: &QuerySpec, value: &str) -> QuerySpec {
61        query.toggled(&Chip::value(self.key.clone(), value))
62    }
63}
64
65/// The chips for `query` and what it found: one per filterable field of
66/// `kinds`, in declaration order, each value counted among `results`.
67#[must_use]
68pub fn facet_offers(
69    kinds: &[KindDecl],
70    query: &QuerySpec,
71    results: &QueryResults,
72) -> Vec<FacetOffer> {
73    let rows = result_rows(results);
74    offers_counting(kinds, query, &|_| rows.clone())
75}
76
77/// Every row `results` shows, local first.
78pub(crate) fn result_rows(results: &QueryResults) -> Vec<Item> {
79    results
80        .local
81        .iter()
82        .flat_map(|group| &group.items)
83        .chain(
84            results
85                .remote
86                .iter()
87                .flat_map(|section| section.groups.iter().flat_map(|group| &group.items)),
88        )
89        .cloned()
90        .collect()
91}
92
93/// The chips for `query`, each value filter's values counted among
94/// `rows_for(key)`: what the query finds without that filter's own choice,
95/// so choosing one artist still lists the others to add.
96pub(crate) fn offers_counting(
97    kinds: &[KindDecl],
98    query: &QuerySpec,
99    rows_for: &dyn Fn(&str) -> Vec<Item>,
100) -> Vec<FacetOffer> {
101    let mut offers = Vec::new();
102    for field in filters(kinds) {
103        let Some(kind) = field.filter else {
104            continue;
105        };
106        let held: Vec<&Chip> = query
107            .chips
108            .iter()
109            .filter(|chip| chip.key == field.key)
110            .collect();
111        let active = !held.is_empty();
112        let clearable = held.iter().any(|chip| !chip.typed);
113        let (label, values) = match kind {
114            FilterKind::Toggle => (field.label.clone(), Vec::new()),
115            FilterKind::Value => {
116                let rows = rows_for(field.key);
117                let rows: Vec<&Item> = rows.iter().collect();
118                let values = facet_values(field.key, &rows, &held);
119                let chosen: Vec<&str> = values
120                    .iter()
121                    .filter(|value| value.chosen)
122                    .map(|value| value.value.as_str())
123                    .collect();
124                (choice_label(&field.label, &chosen), values)
125            }
126        };
127        offers.push(FacetOffer {
128            key: field.key.to_owned(),
129            name: field.label.clone(),
130            kind,
131            label,
132            active,
133            clearable,
134            values,
135        });
136    }
137    offers
138}
139
140/// The values of the field `key` among `rows`, the most shared first and,
141/// among equals, the one met first; every chosen value is among them.
142fn facet_values(key: &str, rows: &[&Item], held: &[&Chip]) -> Vec<FacetValue> {
143    let mut counts: HashMap<&str, usize> = HashMap::new();
144    let mut order: Vec<&str> = Vec::new();
145    for row in rows {
146        for value in row.values(key) {
147            let count = counts.entry(value).or_insert(0);
148            if *count == 0 {
149                order.push(value);
150            }
151            *count = count.saturating_add(1);
152        }
153    }
154    let fold = |text: &str| tilo_search::fold(text.trim());
155    let chosen_of = |value: &str| {
156        held.iter().find(|chip| {
157            chip.value
158                .as_deref()
159                .is_some_and(|held| fold(held) == fold(value))
160        })
161    };
162    let mut values: Vec<(usize, usize, FacetValue)> = order
163        .iter()
164        .enumerate()
165        .map(|(at, value)| {
166            let chip = chosen_of(value);
167            (
168                counts.get(value).copied().unwrap_or(0),
169                at,
170                FacetValue {
171                    value: (*value).to_owned(),
172                    count: counts.get(value).copied().unwrap_or(0),
173                    chosen: chip.is_some(),
174                    typed: chip.is_some_and(|chip| chip.typed),
175                },
176            )
177        })
178        .collect();
179    values.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
180    let mut out: Vec<FacetValue> = values.into_iter().map(|(_, _, value)| value).collect();
181    // A chosen value nothing shown has any more still reads as chosen.
182    for chip in held {
183        if let Some(value) = &chip.value
184            && !out.iter().any(|known| fold(&known.value) == fold(value))
185        {
186            out.insert(
187                0,
188                FacetValue {
189                    value: value.clone(),
190                    count: 0,
191                    chosen: true,
192                    typed: chip.typed,
193                },
194            );
195        }
196    }
197    out
198}
199
200/// "Artist", "Artist: Ada", "Artist: Ada, Grace", "Artist: Ada and 2 more".
201fn choice_label(name: &str, chosen: &[&str]) -> String {
202    match chosen {
203        [] => name.to_owned(),
204        few if few.len() <= LABEL_VALUES => format!("{name}: {}", few.join(", ")),
205        [first, rest @ ..] => format!("{name}: {first} and {} more", rest.len()),
206    }
207}
208
209/// What a press on a chip asks of the app: a value filter's chip opens its
210/// list of values, the app showing it where it suits (`key`).
211pub type OpenFacet = Rc<dyn Fn(&str)>;
212
213/// The chips as a row: a toggle flips, a value filter's chip opens its
214/// values (`open`), and an active chip's clear control hands `on_change`
215/// the query without its choice.
216#[must_use]
217pub fn facet_bar(
218    offers: &[FacetOffer],
219    query: &QuerySpec,
220    on_change: &Rc<dyn Fn(QuerySpec)>,
221    open: &OpenFacet,
222) -> Element {
223    let mut row = Row::new()
224        .with_gap(6.0)
225        .with_align_items(ui_core::taffy::AlignItems::Center);
226    for offer in offers {
227        let mut chip =
228            ChipWidget::new(offer.label.clone()).with_id(format!("view-chip:{}", offer.key));
229        match offer.kind {
230            FilterKind::Toggle => {
231                chip = chip.selected(offer.active);
232                // A typed toggle is taken away by its token in the field.
233                if !offer.active || offer.clearable {
234                    let change = Rc::clone(on_change);
235                    let next = query.toggled(&Chip::toggle(offer.key.clone()));
236                    chip = chip.on_toggle_view(move |_| change(next.clone()));
237                }
238            }
239            FilterKind::Value => {
240                let open = Rc::clone(open);
241                let key = offer.key.clone();
242                chip = chip
243                    .menu()
244                    .current(offer.active)
245                    .on_view(move || open(&key));
246                if offer.clearable {
247                    let change = Rc::clone(on_change);
248                    let next = query.cleared(&offer.key);
249                    chip = chip.on_remove_view(move || change(next.clone()));
250                }
251            }
252        }
253        row = row.push(Box::new(chip));
254    }
255    Box::new(row)
256}
257
258/// The values of one value filter, opened over its chip: each with how many
259/// results share it and a check on those chosen, since any number may be.
260/// Choosing one toggles it in the query; the menu closes, and opening it
261/// again chooses another. An app that paints its own surface owns one,
262/// draws it over its page and routes events to it while it is open.
263#[derive(Default)]
264pub struct FacetMenu {
265    menu: ContextMenu,
266    /// What choosing each row asks, in row order.
267    choices: Vec<QuerySpec>,
268}
269
270impl FacetMenu {
271    /// Open `offer`'s values for `query` beside `anchor` (its chip), kept
272    /// inside `viewport`.
273    pub fn open(&mut self, offer: &FacetOffer, query: &QuerySpec, anchor: Rect, viewport: Rect) {
274        let mut menu = ContextMenu::new();
275        self.choices.clear();
276        for value in &offer.values {
277            let mut item = MenuItem::selection(value.value.clone())
278                .with_checked(value.chosen)
279                // A typed token is taken away by editing the field.
280                .with_enabled(!value.typed);
281            if value.count > 0 {
282                item = item.with_detail(value.count.to_string());
283            }
284            menu.push(item);
285            self.choices.push(offer.toggled(query, &value.value));
286        }
287        menu.open_anchored(anchor, viewport);
288        self.menu = menu;
289    }
290
291    #[must_use]
292    pub fn is_open(&self) -> bool {
293        self.menu.is_open()
294    }
295
296    /// The menu, to draw and to route events to.
297    pub fn menu_mut(&mut self) -> &mut ContextMenu {
298        &mut self.menu
299    }
300
301    /// The query a chosen value asks for, once one was chosen.
302    pub fn take_choice(&mut self) -> Option<QuerySpec> {
303        let chosen = self.menu.take_selection()?;
304        self.choices.get(chosen).cloned()
305    }
306}