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
32/// How rows arrive from a source. One-shot sources send one `Replace`;
33/// streamed sources send many `Push` then `Done`.
34pub enum Loaded<R> {
35 Replace(Vec<R>),
36 Push(R),
37 Done,
38}
39
40/// Ranking function for `Filter::Rank`: takes the full row slice and the current
41/// query string, returns display indices in preferred order (absent = hidden).
42pub type RankFn<R> = std::sync::Arc<dyn Fn(&[R], &str) -> Vec<usize> + Send + Sync>;
43
44/// How a loaded row set is narrowed/ordered for display. Three known
45/// strategies; none need test substitution, so folded in here.
46pub enum Filter<R: SearchRow> {
47 /// Trust the source's order (server-side filter already applied).
48 SourceOrder,
49 /// Local nucleo fuzzy over `match_text`.
50 Fuzzy,
51 /// Local rank: `(rows, query) -> display indices` (lower = better; absent = hidden).
52 Rank(RankFn<R>),
53}
54
55/// The sink a `RowSource` writes rows into. Cheap to clone; carries the load
56/// generation so the engine can drop results from a superseded load.
57#[derive(Clone)]
58pub struct Emit<R> {
59 tx: std::sync::mpsc::Sender<(u64, Loaded<R>)>,
60 generation: u64,
61 redraw: Arc<dyn Fn() + Send + Sync>,
62}
63
64impl<R> Emit<R> {
65 pub(super) fn new(
66 tx: std::sync::mpsc::Sender<(u64, Loaded<R>)>,
67 generation: u64,
68 redraw: Arc<dyn Fn() + Send + Sync>,
69 ) -> Self {
70 Self {
71 tx,
72 generation,
73 redraw,
74 }
75 }
76
77 /// One-shot: deliver the whole set.
78 pub fn replace(&self, rows: Vec<R>) {
79 let _ = self.tx.send((self.generation, Loaded::Replace(rows)));
80 (self.redraw)();
81 }
82
83 /// Streamed: one row at a time.
84 pub fn push(&self, row: R) {
85 let _ = self.tx.send((self.generation, Loaded::Push(row)));
86 (self.redraw)();
87 }
88
89 /// Streamed: no more rows for this generation.
90 pub fn done(&self) {
91 let _ = self.tx.send((self.generation, Loaded::Done));
92 (self.redraw)();
93 }
94}
95
96/// One autocomplete candidate: the inserted/display text plus an optional
97/// secondary line shown muted in the popup (a note path, a tag usage count).
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct SuggestionItem {
100 pub display: String,
101 pub secondary: Option<String>,
102}
103
104impl SuggestionItem {
105 pub fn plain(display: impl Into<String>) -> Self {
106 Self {
107 display: display.into(),
108 secondary: None,
109 }
110 }
111}
112
113/// Autocomplete candidates for the query input, kept separate from the vault
114/// so the autocomplete host is testable in isolation.
115#[async_trait]
116pub trait SuggestionSource: Send + Sync + 'static {
117 async fn notes_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem>;
118 async fn tags_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem>;
119
120 /// Saved searches whose name matches `prefix` (case-insensitive). Each
121 /// item's `display` is the name and `secondary` the stored query — the
122 /// popup preview AND the text inserted on accept.
123 /// Defaults to empty so non-search-box suggestion sources opt out.
124 async fn saved_searches_by_prefix(&self, _prefix: &str, _limit: usize) -> Vec<SuggestionItem> {
125 Vec::new()
126 }
127}
128
129/// Production adapter over the vault. Formats the secondary line (note path,
130/// tag usage count) so the popup looks exactly as before.
131pub struct VaultSuggestions {
132 pub vault: std::sync::Arc<kimun_core::NoteVault>,
133}
134
135#[async_trait]
136impl SuggestionSource for VaultSuggestions {
137 async fn notes_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem> {
138 self.vault
139 .suggest_notes_by_prefix(prefix, limit)
140 .await
141 .map(|v| {
142 v.into_iter()
143 .map(|n| SuggestionItem {
144 display: n.name,
145 secondary: Some(n.path.to_string()),
146 })
147 .collect()
148 })
149 .unwrap_or_default()
150 }
151 async fn tags_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem> {
152 self.vault
153 .suggest_tags_by_prefix(prefix, limit)
154 .await
155 .map(|v| {
156 v.into_iter()
157 .map(|t| SuggestionItem {
158 display: t.label,
159 secondary: Some(format!("{}×", t.usage_count)),
160 })
161 .collect()
162 })
163 .unwrap_or_default()
164 }
165 async fn saved_searches_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem> {
166 // Prefix matching + casing live in core (`NoteVault`), like the
167 // notes/tags suggestion sources. Here we only adapt to `SuggestionItem`:
168 // the name is the popup row, the stored query is the muted preview AND
169 // the text inserted on accept.
170 self.vault
171 .suggest_saved_searches_by_prefix(prefix, limit)
172 .await
173 .map(|v| {
174 v.into_iter()
175 .map(|s| SuggestionItem {
176 display: s.name,
177 secondary: Some(s.query),
178 })
179 .collect()
180 })
181 .unwrap_or_default()
182 }
183}
184
185/// Where a `SearchList`'s rows come from. Vault-backed in the app, in-memory
186/// in tests. Streaming vs one-shot is a delivery detail of the SAME seam.
187#[async_trait]
188pub trait RowSource<R: SearchRow>: Send + Sync + 'static {
189 /// Called on construction and on every committed query change. Empty query
190 /// = initial state. Write rows into `emit`. Cancel-safe: the engine drops
191 /// the prior load on requery, so a slow source may be left unfinished.
192 async fn load(&self, query: &str, emit: Emit<R>);
193
194 /// An optional synthetic leading row (the "Create: <q>" affordance),
195 /// prepended and exempt from local filtering. Keeps create-policy here.
196 fn leading_row(&self, _query: &str) -> Option<R> {
197 None
198 }
199
200 /// `true` (default): `load` is re-run on every query keystroke (server-side
201 /// filter). `false`: `load` runs once with `""`, then a local `Filter`
202 /// narrows the set per keystroke.
203 fn reload_on_query(&self) -> bool {
204 true
205 }
206}
207
208/// A [`RowSource`] for the synchronous build path: its rows are supplied at
209/// build time via [`SearchListBuilder::build_with_rows`], so its async `load`
210/// is never called. Pairs with any static, in-memory row set
211/// (`reload_on_query() == false` — the query is a local filter over the built
212/// rows), replacing a hand-rolled one-shot `emit.replace(rows.clone())` source.
213///
214/// [`SearchListBuilder::build_with_rows`]: super::SearchListBuilder::build_with_rows
215pub struct StaticRowSource;
216
217#[async_trait]
218impl<R: SearchRow> RowSource<R> for StaticRowSource {
219 async fn load(&self, _query: &str, _emit: Emit<R>) {}
220 fn reload_on_query(&self) -> bool {
221 false
222 }
223}
224
225#[cfg(test)]
226mod suggestion_tests {
227 use super::*;
228 struct Mem {
229 notes: Vec<SuggestionItem>,
230 tags: Vec<SuggestionItem>,
231 }
232 #[async_trait]
233 impl SuggestionSource for Mem {
234 async fn notes_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
235 self.notes
236 .iter()
237 .filter(|x| x.display.starts_with(p))
238 .cloned()
239 .collect()
240 }
241 async fn tags_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
242 self.tags
243 .iter()
244 .filter(|x| x.display.starts_with(p))
245 .cloned()
246 .collect()
247 }
248 }
249 #[tokio::test]
250 async fn mem_suggestions_filter_by_prefix() {
251 let m = Mem {
252 notes: vec![SuggestionItem {
253 display: "projects".into(),
254 secondary: Some("work/projects".into()),
255 }],
256 tags: vec![SuggestionItem::plain("todo")],
257 };
258 assert_eq!(m.notes_by_prefix("pro", 9).await.len(), 1);
259 assert_eq!(m.notes_by_prefix("pro", 9).await[0].display, "projects");
260 assert_eq!(m.tags_by_prefix("to", 9).await[0].display, "todo");
261 }
262}