tilo_app/framework/views/
chips.rs1use 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
22const LABEL_VALUES: usize = 2;
25
26#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct FacetValue {
29 pub value: String,
30 pub count: usize,
32 pub chosen: bool,
33 pub typed: bool,
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct FacetOffer {
40 pub key: String,
41 pub name: String,
43 pub kind: FilterKind,
44 pub label: String,
47 pub active: bool,
49 pub clearable: bool,
52 pub values: Vec<FacetValue>,
55}
56
57impl FacetOffer {
58 #[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#[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
77pub(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
93pub(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
140fn 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 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
200fn 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
209pub type OpenFacet = Rc<dyn Fn(&str)>;
212
213#[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 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#[derive(Default)]
264pub struct FacetMenu {
265 menu: ContextMenu,
266 choices: Vec<QuerySpec>,
268}
269
270impl FacetMenu {
271 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 .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 pub fn menu_mut(&mut self) -> &mut ContextMenu {
298 &mut self.menu
299 }
300
301 pub fn take_choice(&mut self) -> Option<QuerySpec> {
303 let chosen = self.menu.take_selection()?;
304 self.choices.get(chosen).cloned()
305 }
306}