Skip to main content

kimun_notes/components/search_list/
seams.rs

1//! The seams a `SearchList` varies across (see CONTEXT.md: SearchList, Row
2//! source, Search row, Suggestion source). Everything else is folded into the
3//! engine.
4
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use ratatui::widgets::ListItem;
9
10use crate::settings::icons::Icons;
11use crate::settings::themes::Theme;
12
13/// What a single row must tell its `SearchList` to be listed, filtered,
14/// navigated and drawn. The only thing that varies with the row's type.
15pub trait SearchRow: Clone + Send + Sync + 'static {
16    /// Collapsed one-or-few-line rendering. `selected` lets a row self-style.
17    fn to_list_item(&self, theme: &Theme, icons: &Icons, selected: bool) -> ListItem<'static>;
18
19    /// Terminal rows this collapsed item occupies (mouse hit-testing / scroll).
20    fn visual_height(&self) -> u16 {
21        1
22    }
23
24    /// Haystack a LOCAL filter (`Filter::Fuzzy`/`Rank`) matches against.
25    /// `None` => never removed by a local filter (e.g. an "Up .." / "Create"
26    /// / pinned virtual row); ignored entirely by `Filter::SourceOrder`.
27    fn match_text(&self) -> Option<&str> {
28        None
29    }
30
31    /// What this row offers to the OS clipboard, or `None` when it has nothing
32    /// worth copying (a command entry, a virtual "Up .." row).
33    ///
34    /// Declared by the row rather than by the surface displaying it, so every
35    /// list built on [`SearchList`](super::SearchList) inherits the yank instead
36    /// of each panel wiring its own key — which is how the note browser ended up
37    /// without one while the Query panel had it.
38    fn yank_target(&self) -> Option<YankTarget> {
39        None
40    }
41}
42
43/// A row's contribution to the OS clipboard: the text, plus the noun naming it
44/// so the confirmation says *what* was copied ("path copied", "tag copied")
45/// rather than a bare "copied" that would be a lie where nothing was.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct YankTarget {
48    pub text: String,
49    pub noun: &'static str,
50}
51
52impl YankTarget {
53    pub fn new(text: impl Into<String>, noun: &'static str) -> Self {
54        Self {
55            text: text.into(),
56            noun,
57        }
58    }
59
60    /// The overwhelmingly common case: a row that stands for a note.
61    pub fn path(text: impl Into<String>) -> Self {
62        Self::new(text, "path")
63    }
64}
65
66/// How rows arrive from a source. One-shot sources send one `Replace`;
67/// streamed sources send many `Push` then `Done`.
68pub enum Loaded<R> {
69    Replace(Vec<R>),
70    Push(R),
71    Done,
72}
73
74/// Ranking function for `Filter::Rank`: takes the full row slice and the current
75/// query string, returns display indices in preferred order (absent = hidden).
76pub type RankFn<R> = std::sync::Arc<dyn Fn(&[R], &str) -> Vec<usize> + Send + Sync>;
77
78/// How a loaded row set is narrowed/ordered for display. Three known
79/// strategies; none need test substitution, so folded in here.
80pub enum Filter<R: SearchRow> {
81    /// Trust the source's order (server-side filter already applied).
82    SourceOrder,
83    /// Local nucleo fuzzy over `match_text`.
84    Fuzzy,
85    /// Local rank: `(rows, query) -> display indices` (lower = better; absent = hidden).
86    Rank(RankFn<R>),
87}
88
89/// The sink a `RowSource` writes rows into. Cheap to clone; carries the load
90/// generation so the engine can drop results from a superseded load.
91#[derive(Clone)]
92pub struct Emit<R> {
93    tx: std::sync::mpsc::Sender<(u64, Loaded<R>)>,
94    generation: u64,
95    redraw: Arc<dyn Fn() + Send + Sync>,
96}
97
98impl<R> Emit<R> {
99    pub(super) fn new(
100        tx: std::sync::mpsc::Sender<(u64, Loaded<R>)>,
101        generation: u64,
102        redraw: Arc<dyn Fn() + Send + Sync>,
103    ) -> Self {
104        Self {
105            tx,
106            generation,
107            redraw,
108        }
109    }
110
111    /// One-shot: deliver the whole set.
112    pub fn replace(&self, rows: Vec<R>) {
113        let _ = self.tx.send((self.generation, Loaded::Replace(rows)));
114        (self.redraw)();
115    }
116
117    /// Streamed: one row at a time.
118    pub fn push(&self, row: R) {
119        let _ = self.tx.send((self.generation, Loaded::Push(row)));
120        (self.redraw)();
121    }
122
123    /// Streamed: no more rows for this generation.
124    pub fn done(&self) {
125        let _ = self.tx.send((self.generation, Loaded::Done));
126        (self.redraw)();
127    }
128}
129
130/// One autocomplete candidate: the inserted/display text plus an optional
131/// secondary line shown muted in the popup (a note path, a tag usage count).
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct SuggestionItem {
134    pub display: String,
135    pub secondary: Option<String>,
136}
137
138impl SuggestionItem {
139    pub fn plain(display: impl Into<String>) -> Self {
140        Self {
141            display: display.into(),
142            secondary: None,
143        }
144    }
145}
146
147/// Autocomplete candidates for the query input, kept separate from the vault
148/// so the autocomplete host is testable in isolation.
149#[async_trait]
150pub trait SuggestionSource: Send + Sync + 'static {
151    async fn notes_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem>;
152    async fn tags_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem>;
153
154    /// Saved searches whose name matches `prefix` (case-insensitive). Each
155    /// item's `display` is the name and `secondary` the stored query — the
156    /// popup preview AND the text inserted on accept.
157    /// Defaults to empty so non-search-box suggestion sources opt out.
158    async fn saved_searches_by_prefix(&self, _prefix: &str, _limit: usize) -> Vec<SuggestionItem> {
159        Vec::new()
160    }
161}
162
163/// Production adapter over the vault. Formats the secondary line (note path,
164/// tag usage count) so the popup looks exactly as before.
165pub struct VaultSuggestions {
166    pub vault: std::sync::Arc<kimun_core::NoteVault>,
167}
168
169#[async_trait]
170impl SuggestionSource for VaultSuggestions {
171    async fn notes_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem> {
172        self.vault
173            .suggest_notes_by_prefix(prefix, limit)
174            .await
175            .map(|v| {
176                v.into_iter()
177                    .map(|n| SuggestionItem {
178                        display: n.name,
179                        secondary: Some(n.path.to_string()),
180                    })
181                    .collect()
182            })
183            .unwrap_or_default()
184    }
185    async fn tags_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem> {
186        self.vault
187            .suggest_tags_by_prefix(prefix, limit)
188            .await
189            .map(|v| {
190                v.into_iter()
191                    .map(|t| SuggestionItem {
192                        display: t.label,
193                        secondary: Some(format!("{}×", t.usage_count)),
194                    })
195                    .collect()
196            })
197            .unwrap_or_default()
198    }
199    async fn saved_searches_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem> {
200        // Prefix matching + casing live in core (`NoteVault`), like the
201        // notes/tags suggestion sources. Here we only adapt to `SuggestionItem`:
202        // the name is the popup row, the stored query is the muted preview AND
203        // the text inserted on accept.
204        self.vault
205            .suggest_saved_searches_by_prefix(prefix, limit)
206            .await
207            .map(|v| {
208                v.into_iter()
209                    .map(|s| SuggestionItem {
210                        display: s.name,
211                        secondary: Some(s.query),
212                    })
213                    .collect()
214            })
215            .unwrap_or_default()
216    }
217}
218
219/// Where a `SearchList`'s rows come from. Vault-backed in the app, in-memory
220/// in tests. Streaming vs one-shot is a delivery detail of the SAME seam.
221#[async_trait]
222pub trait RowSource<R: SearchRow>: Send + Sync + 'static {
223    /// Called on construction and on every committed query change. Empty query
224    /// = initial state. Write rows into `emit`. Cancel-safe: the engine drops
225    /// the prior load on requery, so a slow source may be left unfinished.
226    async fn load(&self, query: &str, emit: Emit<R>);
227
228    /// An optional synthetic leading row (the `Create: <q>` affordance),
229    /// prepended and exempt from local filtering. Keeps create-policy here.
230    fn leading_row(&self, _query: &str) -> Option<R> {
231        None
232    }
233
234    /// `true` (default): `load` is re-run on every query keystroke (server-side
235    /// filter). `false`: `load` runs once with `""`, then a local `Filter`
236    /// narrows the set per keystroke.
237    fn reload_on_query(&self) -> bool {
238        true
239    }
240}
241
242/// A [`RowSource`] for the synchronous build path: its rows are supplied at
243/// build time via [`SearchListBuilder::build_with_rows`], so its async `load`
244/// is never called. Pairs with any static, in-memory row set
245/// (`reload_on_query() == false` — the query is a local filter over the built
246/// rows), replacing a hand-rolled one-shot `emit.replace(rows.clone())` source.
247///
248/// [`SearchListBuilder::build_with_rows`]: super::SearchListBuilder::build_with_rows
249pub struct StaticRowSource;
250
251#[async_trait]
252impl<R: SearchRow> RowSource<R> for StaticRowSource {
253    async fn load(&self, _query: &str, _emit: Emit<R>) {}
254    fn reload_on_query(&self) -> bool {
255        false
256    }
257}
258
259#[cfg(test)]
260mod suggestion_tests {
261    use super::*;
262    struct Mem {
263        notes: Vec<SuggestionItem>,
264        tags: Vec<SuggestionItem>,
265    }
266    #[async_trait]
267    impl SuggestionSource for Mem {
268        async fn notes_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
269            self.notes
270                .iter()
271                .filter(|x| x.display.starts_with(p))
272                .cloned()
273                .collect()
274        }
275        async fn tags_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
276            self.tags
277                .iter()
278                .filter(|x| x.display.starts_with(p))
279                .cloned()
280                .collect()
281        }
282    }
283    #[tokio::test]
284    async fn mem_suggestions_filter_by_prefix() {
285        let m = Mem {
286            notes: vec![SuggestionItem {
287                display: "projects".into(),
288                secondary: Some("work/projects".into()),
289            }],
290            tags: vec![SuggestionItem::plain("todo")],
291        };
292        assert_eq!(m.notes_by_prefix("pro", 9).await.len(), 1);
293        assert_eq!(m.notes_by_prefix("pro", 9).await[0].display, "projects");
294        assert_eq!(m.tags_by_prefix("to", 9).await[0].display, "todo");
295    }
296}