1mod nerd_fonts;
2pub mod sources;
3
4use std::borrow::Cow;
5use std::cell::RefCell;
6use std::mem::size_of;
7
8fn lowercased_query(query: &str) -> Cow<'_, str> {
12 let needs_fold = query
13 .bytes()
14 .any(|b| b.is_ascii_uppercase() || !b.is_ascii());
15 if needs_fold {
16 Cow::Owned(query.to_lowercase())
17 } else {
18 Cow::Borrowed(query)
19 }
20}
21
22#[derive(Clone)]
23pub struct IconEntry {
24 pub icon: String,
25 pub name: String,
26 pub name_lower: String,
27}
28
29impl IconEntry {
30 pub fn new(icon: impl Into<String>, name: impl Into<String>) -> Self {
31 let icon = icon.into();
32 let name = name.into();
33 let name_lower = name.to_lowercase();
34 Self {
35 icon,
36 name,
37 name_lower,
38 }
39 }
40
41 pub fn single_char(&self) -> Option<char> {
42 let mut chars = self.icon.chars();
43 let ch = chars.next()?;
44 if chars.next().is_none() {
45 Some(ch)
46 } else {
47 None
48 }
49 }
50
51 pub fn heap_footprint(&self) -> usize {
54 self.icon.capacity() + self.name.capacity() + self.name_lower.capacity()
55 }
56}
57
58pub struct SectionSpec {
61 pub title: String,
62 pub entries: Vec<IconEntry>,
63}
64
65impl SectionSpec {
66 pub fn new(title: impl Into<String>, entries: Vec<IconEntry>) -> Self {
67 Self {
68 title: title.into(),
69 entries,
70 }
71 }
72}
73
74pub enum SectionEntries<'a> {
78 Full(&'a [IconEntry]),
79 Filtered(Vec<&'a IconEntry>),
80}
81
82impl<'a> SectionEntries<'a> {
83 pub fn len(&self) -> usize {
84 match self {
85 Self::Full(s) => s.len(),
86 Self::Filtered(v) => v.len(),
87 }
88 }
89
90 pub fn is_empty(&self) -> bool {
91 self.len() == 0
92 }
93
94 pub fn get(&self, i: usize) -> Option<&'a IconEntry> {
95 match self {
96 Self::Full(s) => s.get(i),
97 Self::Filtered(v) => v.get(i).copied(),
98 }
99 }
100}
101
102pub struct SectionView<'a> {
103 pub title: &'a str,
104 pub entries: SectionEntries<'a>,
105}
106
107pub struct IconCatalogData {
111 tabs: Vec<Vec<SectionSpec>>,
112}
113
114impl IconCatalogData {
115 pub fn new(tabs: Vec<Vec<SectionSpec>>) -> Self {
116 Self { tabs }
117 }
118
119 pub fn tab_count(&self) -> usize {
120 self.tabs.len()
121 }
122
123 pub fn tabs(&self) -> &[Vec<SectionSpec>] {
124 &self.tabs
125 }
126
127 pub fn heap_footprint(&self) -> usize {
130 let mut total = self.tabs.capacity() * size_of::<Vec<SectionSpec>>();
131 for tab in &self.tabs {
132 total += tab.capacity() * size_of::<SectionSpec>();
133 for spec in tab {
134 total += spec.title.capacity();
135 total += spec.entries.capacity() * size_of::<IconEntry>();
136 for entry in &spec.entries {
137 total += entry.heap_footprint();
138 }
139 }
140 }
141 total
142 }
143
144 pub fn sections(&self, tab_idx: usize, query: &str) -> Vec<SectionView<'_>> {
148 let Some(tab) = self.tabs.get(tab_idx) else {
149 return Vec::new();
150 };
151 let query_lower = lowercased_query(query);
152 let q = query_lower.as_ref();
153 let mut out = Vec::new();
154 for spec in tab {
155 if q.is_empty() {
156 if spec.entries.is_empty() {
157 continue;
158 }
159 out.push(SectionView {
160 title: &spec.title,
161 entries: SectionEntries::Full(&spec.entries),
162 });
163 } else {
164 let filtered: Vec<&IconEntry> = spec
165 .entries
166 .iter()
167 .filter(|e| e.name_lower.contains(q))
168 .collect();
169 if filtered.is_empty() {
170 continue;
171 }
172 out.push(SectionView {
173 title: &spec.title,
174 entries: SectionEntries::Filtered(filtered),
175 });
176 }
177 }
178 out
179 }
180}
181
182pub fn selectable_count(sections: &[SectionView<'_>]) -> usize {
183 sections.iter().map(|s| s.entries.len()).sum()
184}
185
186pub fn flat_len(sections: &[SectionView<'_>]) -> usize {
187 sections.iter().map(|s| s.entries.len() + 1).sum()
188}
189
190pub fn selectable_to_flat(sections: &[SectionView<'_>], sel: usize) -> Option<usize> {
191 let mut flat = 0;
192 let mut remaining = sel;
193 for s in sections {
194 flat += 1;
195 let len = s.entries.len();
196 if remaining < len {
197 return Some(flat + remaining);
198 }
199 remaining -= len;
200 flat += len;
201 }
202 None
203}
204
205pub fn flat_to_selectable(sections: &[SectionView<'_>], flat_idx: usize) -> Option<usize> {
206 let mut flat = 0;
207 let mut selectable = 0;
208 for s in sections {
209 if flat_idx == flat {
210 return None;
211 }
212 flat += 1;
213 let len = s.entries.len();
214 if flat_idx < flat + len {
215 return Some(selectable + (flat_idx - flat));
216 }
217 flat += len;
218 selectable += len;
219 }
220 None
221}
222
223pub fn entry_at_selectable<'a>(
224 sections: &'a [SectionView<'a>],
225 sel: usize,
226) -> Option<&'a IconEntry> {
227 let mut remaining = sel;
228 for s in sections {
229 let len = s.entries.len();
230 if remaining < len {
231 return s.entries.get(remaining);
232 }
233 remaining -= len;
234 }
235 None
236}
237
238pub fn adjust_scroll_offset(current: usize, visible: usize, flat_idx: usize) -> usize {
239 let visible = visible.max(1);
240 if flat_idx < current {
241 flat_idx
242 } else if flat_idx >= current + visible {
243 flat_idx.saturating_sub(visible - 1)
244 } else {
245 current
246 }
247}
248
249pub struct MemoizedCatalog {
259 inner: IconCatalogData,
260 cache: RefCell<CacheState>,
261}
262
263#[derive(Default)]
264struct CacheState {
265 tab_idx: Option<usize>,
267 trail: Vec<CachedQuery>,
270}
271
272struct CachedQuery {
273 query: String,
274 sections: Vec<CachedSection>,
275}
276
277struct CachedSection {
278 section_idx: u32,
279 entries: Vec<u32>,
280}
281
282const TRAIL_CAP: usize = 64;
285
286impl MemoizedCatalog {
287 pub fn new(inner: IconCatalogData) -> Self {
288 Self {
289 inner,
290 cache: RefCell::default(),
291 }
292 }
293
294 pub fn inner(&self) -> &IconCatalogData {
295 &self.inner
296 }
297
298 pub fn into_inner(self) -> IconCatalogData {
299 self.inner
300 }
301
302 pub fn tab_count(&self) -> usize {
303 self.inner.tab_count()
304 }
305
306 pub fn invalidate(&self) {
309 let mut cache = self.cache.borrow_mut();
310 cache.tab_idx = None;
311 cache.trail.clear();
312 }
313
314 pub fn heap_footprint(&self) -> usize {
316 let cache = self.cache.borrow();
317 let mut total = self.inner.heap_footprint();
318 total += cache.trail.capacity() * size_of::<CachedQuery>();
319 for step in &cache.trail {
320 total += step.query.capacity();
321 total += step.sections.capacity() * size_of::<CachedSection>();
322 for section in &step.sections {
323 total += section.entries.capacity() * size_of::<u32>();
324 }
325 }
326 total
327 }
328
329 pub fn sections(&self, tab_idx: usize, query: &str) -> Vec<SectionView<'_>> {
332 if query.is_empty() {
336 return self.inner.sections(tab_idx, "");
337 }
338
339 let Some(tab) = self.inner.tabs.get(tab_idx) else {
340 return Vec::new();
341 };
342
343 let query_lower = lowercased_query(query);
344 let q = query_lower.as_ref();
345 let layout: Vec<(usize, Vec<u32>)> = {
346 let mut cache = self.cache.borrow_mut();
347
348 if cache.tab_idx != Some(tab_idx) {
350 cache.tab_idx = Some(tab_idx);
351 cache.trail.clear();
352 }
353
354 while cache
359 .trail
360 .last()
361 .is_some_and(|top| !q.starts_with(top.query.as_str()))
362 {
363 cache.trail.pop();
364 }
365
366 let top_exact = cache
367 .trail
368 .last()
369 .is_some_and(|top| top.query.as_str() == q);
370
371 if !top_exact {
372 let new_sections = match cache.trail.last() {
373 Some(top) => {
374 top.sections
376 .iter()
377 .filter_map(|cached| {
378 let spec = &tab[cached.section_idx as usize];
379 let entries: Vec<u32> = cached
380 .entries
381 .iter()
382 .copied()
383 .filter(|&i| spec.entries[i as usize].name_lower.contains(q))
384 .collect();
385 (!entries.is_empty()).then_some(CachedSection {
386 section_idx: cached.section_idx,
387 entries,
388 })
389 })
390 .collect()
391 }
392 None => {
393 tab.iter()
395 .enumerate()
396 .filter_map(|(section_idx, spec)| {
397 let entries: Vec<u32> = spec
398 .entries
399 .iter()
400 .enumerate()
401 .filter_map(|(i, entry)| {
402 entry.name_lower.contains(q).then_some(i as u32)
403 })
404 .collect();
405 (!entries.is_empty()).then_some(CachedSection {
406 section_idx: section_idx as u32,
407 entries,
408 })
409 })
410 .collect()
411 }
412 };
413
414 cache.trail.push(CachedQuery {
415 query: q.to_owned(),
416 sections: new_sections,
417 });
418
419 while cache.trail.len() > TRAIL_CAP {
421 cache.trail.remove(0);
422 }
423 }
424
425 cache
426 .trail
427 .last()
428 .expect("just pushed or already present")
429 .sections
430 .iter()
431 .map(|c| (c.section_idx as usize, c.entries.clone()))
432 .collect()
433 };
434
435 layout
436 .into_iter()
437 .map(|(section_idx, indices)| {
438 let spec = &tab[section_idx];
439 let entries: Vec<&IconEntry> = indices
440 .into_iter()
441 .map(|i| &spec.entries[i as usize])
442 .collect();
443 SectionView {
444 title: &spec.title,
445 entries: SectionEntries::Filtered(entries),
446 }
447 })
448 .collect()
449 }
450}
451
452impl From<IconCatalogData> for MemoizedCatalog {
453 fn from(inner: IconCatalogData) -> Self {
454 Self::new(inner)
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 fn entry(name: &str) -> IconEntry {
463 IconEntry::new("x", name)
464 }
465
466 fn fixture_catalog() -> IconCatalogData {
467 IconCatalogData::new(vec![
468 vec![
469 SectionSpec::new("a", vec![entry("a0"), entry("a1")]),
470 SectionSpec::new("b", vec![entry("b0 arrow"), entry("b1 arrow"), entry("b2")]),
471 ],
472 vec![SectionSpec::new("empty", vec![])],
473 ])
474 }
475
476 #[test]
477 fn empty_query_uses_borrowed_sections_and_query_uses_filtered_refs() {
478 let catalog = fixture_catalog();
479 let full = catalog.sections(0, "");
480 assert!(matches!(full[0].entries, SectionEntries::Full(_)));
481
482 let filtered = catalog.sections(0, "arrow");
483 assert!(filtered
484 .iter()
485 .all(|section| matches!(section.entries, SectionEntries::Filtered(_))));
486 }
487
488 #[test]
489 fn empty_sections_are_dropped_and_no_lone_headers() {
490 let catalog = fixture_catalog();
491 assert!(catalog.sections(1, "").is_empty());
493 assert!(catalog.sections(0, "zzzzzzz").is_empty());
495 }
496
497 #[test]
498 fn out_of_range_tab_returns_empty() {
499 let catalog = fixture_catalog();
500 assert!(catalog.sections(99, "").is_empty());
501 assert_eq!(catalog.tab_count(), 2);
502 }
503
504 #[test]
505 fn selectable_and_flat_indices_round_trip() {
506 let entries_a = [entry("a0"), entry("a1")];
507 let entries_b = [entry("b0"), entry("b1"), entry("b2")];
508 let sections = [
509 SectionView {
510 title: "a",
511 entries: SectionEntries::Full(&entries_a),
512 },
513 SectionView {
514 title: "b",
515 entries: SectionEntries::Full(&entries_b),
516 },
517 ];
518
519 assert_eq!(selectable_to_flat(§ions, 0), Some(1));
520 assert_eq!(selectable_to_flat(§ions, 2), Some(4));
521 assert_eq!(flat_to_selectable(§ions, 0), None);
522 assert_eq!(flat_to_selectable(§ions, 4), Some(2));
523 assert_eq!(entry_at_selectable(§ions, 4).unwrap().name, "b2");
524 }
525
526 #[test]
527 fn adjust_scroll_offset_keeps_selected_row_visible() {
528 assert_eq!(adjust_scroll_offset(10, 5, 8), 8);
529 assert_eq!(adjust_scroll_offset(10, 5, 10), 10);
530 assert_eq!(adjust_scroll_offset(10, 5, 14), 10);
531 assert_eq!(adjust_scroll_offset(10, 5, 15), 11);
532 }
533
534 #[test]
535 fn single_char_reports_single_scalar_only() {
536 let ascii = IconEntry::new("x", "ascii");
537 let emoji = IconEntry::new("🔥", "fire");
538 let multi = IconEntry::new("❤\u{fe0f}", "heart");
539
540 assert_eq!(ascii.single_char(), Some('x'));
541 assert_eq!(emoji.single_char(), Some('🔥'));
542 assert_eq!(multi.single_char(), None);
543 }
544
545 fn collect_names(sections: &[SectionView<'_>]) -> Vec<String> {
546 let mut out = Vec::new();
547 for s in sections {
548 for i in 0..s.entries.len() {
549 out.push(s.entries.get(i).unwrap().name.clone());
550 }
551 }
552 out
553 }
554
555 #[test]
556 fn memoized_matches_raw_across_tabs_and_queries() {
557 let memo = MemoizedCatalog::new(fixture_catalog());
558 for (tab, query) in &[
559 (0, ""),
560 (0, "a"),
561 (0, "arrow"),
562 (0, "zzzzzz"),
563 (1, ""),
564 (1, "anything"),
565 (99, ""),
566 ] {
567 let raw = memo.inner().sections(*tab, query);
568 let cached = memo.sections(*tab, query);
569 assert_eq!(
570 collect_names(&raw),
571 collect_names(&cached),
572 "tab {tab} query {query:?}"
573 );
574 }
575 }
576
577 #[test]
578 fn memoized_incremental_narrowing_produces_same_result() {
579 let memo = MemoizedCatalog::new(fixture_catalog());
580 let warm = memo.sections(0, "a");
583 assert_eq!(
584 collect_names(&warm),
585 vec!["a0", "a1", "b0 arrow", "b1 arrow"]
586 );
587 let narrowed = memo.sections(0, "arrow");
588 assert_eq!(collect_names(&narrowed), vec!["b0 arrow", "b1 arrow"]);
589 let reference = memo.inner().sections(0, "arrow");
591 assert_eq!(collect_names(&narrowed), collect_names(&reference));
592 }
593
594 #[test]
595 fn memoized_handles_query_shrink_via_trail_pop() {
596 let memo = MemoizedCatalog::new(fixture_catalog());
597 let _ = memo.sections(0, "a");
599 let _ = memo.sections(0, "ar");
600 let _ = memo.sections(0, "arrow");
601 let shrunk = memo.sections(0, "a");
602 let reference = memo.inner().sections(0, "a");
603 assert_eq!(collect_names(&shrunk), collect_names(&reference));
604 }
605
606 #[test]
607 fn memoized_handles_cold_shrink_without_trail() {
608 let memo = MemoizedCatalog::new(fixture_catalog());
609 let _ = memo.sections(0, "arrow");
612 let shrunk = memo.sections(0, "a");
613 let reference = memo.inner().sections(0, "a");
614 assert_eq!(collect_names(&shrunk), collect_names(&reference));
615 }
616
617 #[test]
618 fn memoized_tab_switch_invalidates_trail() {
619 let memo = MemoizedCatalog::new(fixture_catalog());
620 let _ = memo.sections(0, "a");
621 let other = memo.sections(1, "anything");
623 let reference = memo.inner().sections(1, "anything");
624 assert_eq!(collect_names(&other), collect_names(&reference));
625 let back = memo.sections(0, "arrow");
627 let ref2 = memo.inner().sections(0, "arrow");
628 assert_eq!(collect_names(&back), collect_names(&ref2));
629 }
630
631 #[test]
632 fn memoized_non_prefix_edit_discards_trail() {
633 let memo = MemoizedCatalog::new(fixture_catalog());
634 let _ = memo.sections(0, "a");
637 let _ = memo.sections(0, "arrow");
638 let shifted = memo.sections(0, "b");
639 let reference = memo.inner().sections(0, "b");
640 assert_eq!(collect_names(&shifted), collect_names(&reference));
641 }
642}