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, the candidate
75/// indices into it (`base` — already in `SearchListBuilder::order_by` order, so
76/// ranking may fall back on it for ties), and the current query string; returns
77/// display indices in preferred order (absent = hidden).
78pub type RankFn<R> = std::sync::Arc<dyn Fn(&[R], &[usize], &str) -> Vec<usize> + Send + Sync>;
79
80/// Total order over rows for `SearchListBuilder::order_by`: applied to the
81/// row set before any local filter, so streamed rows land in place as they
82/// arrive and a sort change is a recompute, not a reload.
83pub type OrderFn<R> = std::sync::Arc<dyn Fn(&R, &R) -> std::cmp::Ordering + Send + Sync>;
84
85/// How a loaded row set is narrowed/ordered for display. Three known
86/// strategies; none need test substitution, so folded in here.
87pub enum Filter<R: SearchRow> {
88 /// Trust the source's order (server-side filter already applied).
89 SourceOrder,
90 /// Local nucleo fuzzy over `match_text`.
91 Fuzzy,
92 /// Local rank: `(rows, base, query) -> display indices` (lower = better;
93 /// absent = hidden). `base` carries the `order_by` order, so honoring it
94 /// for equally-ranked rows is what makes the two compose.
95 Rank(RankFn<R>),
96}
97
98/// The sink a `RowSource` writes rows into. Cheap to clone; carries the load
99/// generation so the engine can drop results from a superseded load.
100#[derive(Clone)]
101pub struct Emit<R> {
102 tx: std::sync::mpsc::Sender<(u64, Loaded<R>)>,
103 generation: u64,
104 redraw: Arc<dyn Fn() + Send + Sync>,
105}
106
107impl<R> Emit<R> {
108 pub(super) fn new(
109 tx: std::sync::mpsc::Sender<(u64, Loaded<R>)>,
110 generation: u64,
111 redraw: Arc<dyn Fn() + Send + Sync>,
112 ) -> Self {
113 Self {
114 tx,
115 generation,
116 redraw,
117 }
118 }
119
120 /// One-shot: deliver the whole set.
121 pub fn replace(&self, rows: Vec<R>) {
122 let _ = self.tx.send((self.generation, Loaded::Replace(rows)));
123 (self.redraw)();
124 }
125
126 /// Streamed: one row at a time.
127 pub fn push(&self, row: R) {
128 let _ = self.tx.send((self.generation, Loaded::Push(row)));
129 (self.redraw)();
130 }
131
132 /// Streamed: no more rows for this generation.
133 pub fn done(&self) {
134 let _ = self.tx.send((self.generation, Loaded::Done));
135 (self.redraw)();
136 }
137
138 /// Test sink: an `Emit` whose every event lands on the returned receiver,
139 /// so a source's delivery sequence can be asserted without an engine.
140 #[cfg(test)]
141 pub(crate) fn capture() -> (Self, std::sync::mpsc::Receiver<(u64, Loaded<R>)>) {
142 let (tx, rx) = std::sync::mpsc::channel();
143 (Self::new(tx, 0, Arc::new(|| {})), rx)
144 }
145}
146
147/// One autocomplete candidate: the inserted/display text plus an optional
148/// secondary line shown muted in the popup (a note path, a tag usage count).
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct SuggestionItem {
151 pub display: String,
152 pub secondary: Option<String>,
153}
154
155impl SuggestionItem {
156 pub fn plain(display: impl Into<String>) -> Self {
157 Self {
158 display: display.into(),
159 secondary: None,
160 }
161 }
162}
163
164/// Autocomplete candidates for the query input, kept separate from the vault
165/// so the autocomplete host is testable in isolation.
166#[allow(clippy::double_must_use)]
167#[async_trait]
168pub trait SuggestionSource: Send + Sync + 'static {
169 async fn notes_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem>;
170 async fn tags_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem>;
171
172 /// Saved searches whose name matches `prefix` (case-insensitive). Each
173 /// item's `display` is the name and `secondary` the stored query — the
174 /// popup preview AND the text inserted on accept.
175 /// Defaults to empty so non-search-box suggestion sources opt out.
176 async fn saved_searches_by_prefix(&self, _prefix: &str, _limit: usize) -> Vec<SuggestionItem> {
177 Vec::new()
178 }
179}
180
181/// Production adapter over the vault. Formats the secondary line (note path,
182/// tag usage count) so the popup looks exactly as before.
183pub struct VaultSuggestions {
184 pub vault: std::sync::Arc<kimun_core::NoteVault>,
185}
186
187#[async_trait]
188impl SuggestionSource for VaultSuggestions {
189 async fn notes_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem> {
190 self.vault
191 .suggest_notes_by_prefix(prefix, limit)
192 .await
193 .map(|v| {
194 v.into_iter()
195 .map(|n| SuggestionItem {
196 display: n.name,
197 secondary: Some(n.path.to_string()),
198 })
199 .collect()
200 })
201 .unwrap_or_default()
202 }
203 async fn tags_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem> {
204 self.vault
205 .suggest_tags_by_prefix(prefix, limit)
206 .await
207 .map(|v| {
208 v.into_iter()
209 .map(|t| SuggestionItem {
210 display: t.label,
211 secondary: Some(format!("{}×", t.usage_count)),
212 })
213 .collect()
214 })
215 .unwrap_or_default()
216 }
217 async fn saved_searches_by_prefix(&self, prefix: &str, limit: usize) -> Vec<SuggestionItem> {
218 // Prefix matching + casing live in core (`NoteVault`), like the
219 // notes/tags suggestion sources. Here we only adapt to `SuggestionItem`:
220 // the name is the popup row, the stored query is the muted preview AND
221 // the text inserted on accept.
222 self.vault
223 .suggest_saved_searches_by_prefix(prefix, limit)
224 .await
225 .map(|v| {
226 v.into_iter()
227 .map(|s| SuggestionItem {
228 display: s.name,
229 secondary: Some(s.query),
230 })
231 .collect()
232 })
233 .unwrap_or_default()
234 }
235}
236
237/// Where a `SearchList`'s rows come from. Vault-backed in the app, in-memory
238/// in tests. Streaming vs one-shot is a delivery detail of the SAME seam.
239#[allow(clippy::double_must_use)]
240#[async_trait]
241pub trait RowSource<R: SearchRow>: Send + Sync + 'static {
242 /// Called on construction and on every committed query change. Empty query
243 /// = initial state. Write rows into `emit`. Cancel-safe: the engine drops
244 /// the prior load on requery, so a slow source may be left unfinished.
245 async fn load(&self, query: &str, emit: Emit<R>);
246
247 /// An optional synthetic leading row (the `Create: <q>` affordance),
248 /// prepended and exempt from local filtering. Keeps create-policy here.
249 fn leading_row(&self, _query: &str) -> Option<R> {
250 None
251 }
252
253 /// `true` (default): `load` is re-run on every query keystroke (server-side
254 /// filter). `false`: `load` runs once with `""`, then a local `Filter`
255 /// narrows the set per keystroke.
256 fn reload_on_query(&self) -> bool {
257 true
258 }
259}
260
261/// A [`RowSource`] for the synchronous build path: its rows are supplied at
262/// build time via [`SearchListBuilder::build_with_rows`], so its async `load`
263/// is never called. Pairs with any static, in-memory row set
264/// (`reload_on_query() == false` — the query is a local filter over the built
265/// rows), replacing a hand-rolled one-shot `emit.replace(rows.clone())` source.
266///
267/// [`SearchListBuilder::build_with_rows`]: super::SearchListBuilder::build_with_rows
268pub struct StaticRowSource;
269
270#[async_trait]
271impl<R: SearchRow> RowSource<R> for StaticRowSource {
272 async fn load(&self, _query: &str, _emit: Emit<R>) {}
273 fn reload_on_query(&self) -> bool {
274 false
275 }
276}
277
278#[cfg(test)]
279mod suggestion_tests {
280 use super::*;
281 struct Mem {
282 notes: Vec<SuggestionItem>,
283 tags: Vec<SuggestionItem>,
284 }
285 #[async_trait]
286 impl SuggestionSource for Mem {
287 async fn notes_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
288 self.notes
289 .iter()
290 .filter(|x| x.display.starts_with(p))
291 .cloned()
292 .collect()
293 }
294 async fn tags_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
295 self.tags
296 .iter()
297 .filter(|x| x.display.starts_with(p))
298 .cloned()
299 .collect()
300 }
301 }
302 #[tokio::test]
303 async fn mem_suggestions_filter_by_prefix() {
304 let m = Mem {
305 notes: vec![SuggestionItem {
306 display: "projects".into(),
307 secondary: Some("work/projects".into()),
308 }],
309 tags: vec![SuggestionItem::plain("todo")],
310 };
311 assert_eq!(m.notes_by_prefix("pro", 9).await.len(), 1);
312 assert_eq!(m.notes_by_prefix("pro", 9).await[0].display, "projects");
313 assert_eq!(m.tags_by_prefix("to", 9).await[0].display, "todo");
314 }
315}