Skip to main content

dartboard_picker_core/
lib.rs

1mod nerd_fonts;
2pub mod sources;
3
4use std::borrow::Cow;
5use std::cell::RefCell;
6use std::mem::size_of;
7
8/// Lowercase `query` only when it actually contains ASCII uppercase or any
9/// non-ASCII bytes. Pure-ASCII-lowercase queries — the common case for this
10/// picker — borrow instead of allocating.
11fn 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    /// Heap bytes owned by this entry (string capacities; does not include the
52    /// `size_of::<IconEntry>()` stack fields).
53    pub fn heap_footprint(&self) -> usize {
54        self.icon.capacity() + self.name.capacity() + self.name_lower.capacity()
55    }
56}
57
58/// A consumer-defined section: a title and the entries that belong in it.
59/// The catalog owns these; `SectionView` borrows from them.
60pub 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
74/// A view over a section's entries: either a borrowed slice (unfiltered) or a
75/// Vec of references (filtered by a search query). Either way no entry data is
76/// cloned.
77pub 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
107/// A catalog of tabs, where each tab owns an ordered list of sections. Tabs
108/// are addressed by index; the consumer owns whatever enum or label scheme
109/// they like and maps it to an index at call time.
110pub 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    /// Estimate heap bytes owned by the catalog. Sums string and Vec
128    /// capacities; does not account for allocator overhead.
129    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    /// Return section views for the given tab, filtered by `query`. Empty
145    /// sections are dropped so lone headers don't appear. Out-of-range
146    /// `tab_idx` yields an empty Vec.
147    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
249/// Wraps an [`IconCatalogData`] with a **trail-stack** cache: each successful
250/// narrowing step pushes a new entry whose query is a strict extension of the
251/// previous. Lookups pop entries whose query is not a prefix of the new query
252/// (which handles backspaces naturally — the stack rewinds to the matching
253/// prefix) and either return an exact hit, narrow from the longest cached
254/// prefix, or fall all the way back to a cold scan.
255///
256/// Empty queries bypass the cache since the no-filter path already returns
257/// borrowed slices. Tab switches invalidate the trail entirely.
258pub struct MemoizedCatalog {
259    inner: IconCatalogData,
260    cache: RefCell<CacheState>,
261}
262
263#[derive(Default)]
264struct CacheState {
265    /// Which tab the current trail belongs to. A tab switch resets the trail.
266    tab_idx: Option<usize>,
267    /// Successive narrowings. Invariant: each entry's query strictly extends
268    /// the previous entry's query.
269    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
282/// Soft cap on trail depth. Real human queries never get close; this only
283/// matters to bound memory if something weird is happening.
284const 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    /// Clear the memoization cache. Useful if the underlying catalog is ever
307    /// mutated (not currently exposed, but kept for symmetry).
308    pub fn invalidate(&self) {
309        let mut cache = self.cache.borrow_mut();
310        cache.tab_idx = None;
311        cache.trail.clear();
312    }
313
314    /// Heap bytes owned by the catalog plus the cache state.
315    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    /// Filtered section views for `(tab_idx, query)`. Semantically identical
330    /// to [`IconCatalogData::sections`] but reuses prior narrowing steps.
331    pub fn sections(&self, tab_idx: usize, query: &str) -> Vec<SectionView<'_>> {
332        // Empty-query path: no filter needed, borrowed slices only. Don't
333        // mutate the trail — the user is likely mid-edit and will extend or
334        // re-type.
335        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            // Tab switch invalidates the entire trail.
349            if cache.tab_idx != Some(tab_idx) {
350                cache.tab_idx = Some(tab_idx);
351                cache.trail.clear();
352            }
353
354            // Pop any trail entries whose query isn't a prefix of the new
355            // query. After this loop the top (if any) is either an exact
356            // match or the longest cached prefix of `q`. This is the
357            // backspace path.
358            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                        // Narrow from the longest cached prefix.
375                        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                        // Cold scan — no prior prefix to narrow from.
394                        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                // Soft cap — drop the oldest (shortest-prefix) entries first.
420                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        // Tab 1 contains one empty section — unfiltered view drops it entirely.
492        assert!(catalog.sections(1, "").is_empty());
493        // A query matching nothing also yields no sections (no lone headers).
494        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(&sections, 0), Some(1));
520        assert_eq!(selectable_to_flat(&sections, 2), Some(4));
521        assert_eq!(flat_to_selectable(&sections, 0), None);
522        assert_eq!(flat_to_selectable(&sections, 4), Some(2));
523        assert_eq!(entry_at_selectable(&sections, 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        // "a" matches a0, a1, b0 arrow, b1 arrow; narrow to "arrow" should
581        // match only b0 arrow and b1 arrow.
582        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        // Compare against the un-memoized path to be sure.
590        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        // Type "a" → "arrow". Now backspace back to "a".
598        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        // Jump straight to a long query, then shrink. The trail has only
610        // "arrow"; backspacing to "a" should pop everything and cold-scan.
611        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        // Switch to tab 1 (only has an empty section — everything drops).
622        let other = memo.sections(1, "anything");
623        let reference = memo.inner().sections(1, "anything");
624        assert_eq!(collect_names(&other), collect_names(&reference));
625        // Switch back — must still produce correct results.
626        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        // "arrow" then "b" — "b" isn't a prefix of "arrow", trail pops empty,
635        // cold scan.
636        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}