kimun-notes 0.22.0

A terminal-based notes application
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
//! **QueryListPanel** — the one body shared by every list-shaped drawer view
//! (TAGS, LINKS, OUTLINE): an optional filter input over a [`SearchList`],
//! with submit / right-click behavior injected through [`ListPanelSpec`].
//!
//! The views vary only in their row type, what Enter does, and whether rows
//! are real notes (right-click context menu); everything about key routing,
//! mouse hit-testing, and layout is identical — so it lives exactly once
//! here, and a new drawer view is a spec + a source, not a copied panel.

use ratatui::Frame;
use ratatui::crossterm::event::KeyCode;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};

use crate::components::event_state::EventState;
use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
use crate::components::panel::panel_block;
use crate::components::search_list::{
    Filter, KeyReaction, RowSource, SearchList, SearchMouse, SearchRow,
};
use crate::settings::icons::Icons;
use crate::settings::themes::Theme;

/// What varies between list-shaped drawer views. The panel is the depth;
/// each view is a thin adapter of this seam.
pub trait ListPanelSpec {
    type Row: SearchRow + Clone + Send + Sync + 'static;

    /// Panel-block title.
    const TITLE: &'static str;
    /// Whether the top row is a typed filter input (`true`: every key goes
    /// to the list engine; `false`: only navigation keys reach the list —
    /// plain letters stay free for the host, e.g. LINKS' `b/o/u`).
    const HAS_FILTER: bool = true;

    /// When `true`, the filter is drawn as a bordered search box (like the note
    /// finder) with a right-aligned result/loading status, and the results area
    /// shows a dimmed "Searching…/No results" message. Suits server-backed views
    /// (semantic search) where the query round-trips over the network and the
    /// input deserves visual separation from the results. Default `false`: a
    /// bare one-row filter for the compact local drawer views (TAGS/LINKS/
    /// OUTLINE), whose behavior is unchanged.
    const BORDERED_INPUT: bool = false;

    /// Whether the typed query ALSO fuzzy-filters the loaded rows locally
    /// (`true`, default). Set `false` for server-backed sources whose `load`
    /// already applies the query (semantic search): the server returns ranked,
    /// conceptually-relevant notes that rarely contain the query words verbatim,
    /// so a local fuzzy pass over their titles would wrongly discard nearly all
    /// of them. `false` keeps the input (it drives the server reload) but shows
    /// every returned row in server rank order.
    const LOCAL_FILTER: bool = true;

    /// What Enter / click-activate does with the selected row.
    fn submit(row: &Self::Row, tx: &AppTx);

    /// The event a right-click on a row fires (rows that are real notes
    /// open the file-ops menu). `None` = right-click selects only.
    fn context_event(_row: &Self::Row) -> Option<AppEvent> {
        None
    }

    fn hints() -> Vec<(String, String)>;
}

/// The shared panel body. Hosts that need extra chrome (LINKS' tab bar) draw
/// it themselves and hand the remaining body rect to [`Self::render_in`].
pub struct QueryListPanel<S: ListPanelSpec> {
    icons: Icons,
    /// Handed to every rebuilt list, so the drawer views honour a rebound yank
    /// chord like the other list surfaces.
    yank_combos: Vec<crate::keys::key_combo::KeyCombo>,
    list: Option<SearchList<S::Row>>,
}

impl<S: ListPanelSpec> QueryListPanel<S> {
    pub fn new(icons: Icons, yank_combos: Vec<crate::keys::key_combo::KeyCombo>) -> Self {
        Self {
            icons,
            yank_combos,
            list: None,
        }
    }

    /// (Re)build the list over a fresh source — the engine-per-context
    /// pattern every drawer view uses.
    pub fn set_source(&mut self, source: impl RowSource<S::Row> + 'static, tx: &AppTx) {
        let mut builder = SearchList::builder(source, redraw_callback(tx.clone()));
        if S::HAS_FILTER {
            // A server-backed source (LOCAL_FILTER = false) already applied the
            // query in `load`; keep its ranked rows as-is (SourceOrder) instead of
            // fuzzy-filtering them again by the literal query text.
            builder = builder.filter(if S::LOCAL_FILTER {
                Filter::Fuzzy
            } else {
                Filter::SourceOrder
            });
        }
        self.list = Some(
            builder
                .yank_combos(self.yank_combos.clone())
                .icons(self.icons.clone())
                .build(),
        );
    }

    pub fn is_loaded(&self) -> bool {
        self.list.is_some()
    }

    pub fn selected_row(&self) -> Option<&S::Row> {
        self.list.as_ref().and_then(|l| l.selected_row())
    }

    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
        S::hints()
    }

    fn submit_selected(&self, tx: &AppTx) {
        if let Some(row) = self.selected_row() {
            S::submit(row, tx);
        }
    }

    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
        match event {
            InputEvent::Key(key) => {
                let Some(list) = &mut self.list else {
                    return EventState::NotConsumed;
                };
                if S::HAS_FILTER {
                    match list.handle_key(key) {
                        KeyReaction::Submit => {
                            self.submit_selected(tx);
                            EventState::Consumed
                        }
                        KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
                        KeyReaction::Yank(target) => {
                            crate::components::yank_row(target, tx);
                            EventState::Consumed
                        }
                        KeyReaction::Intercepted(_)
                        | KeyReaction::ListVerb(_)
                        | KeyReaction::Unhandled => EventState::NotConsumed,
                    }
                } else {
                    // No filter input: only navigation keys reach the list,
                    // so plain letters stay available to the host. The yank
                    // chord is forwarded explicitly — it is a chord, never a
                    // host letter, and its rows do declare yank targets.
                    if list.is_yank_chord(key) {
                        let reaction = list.handle_key(key);
                        if let KeyReaction::Yank(target) = reaction {
                            crate::components::yank_row(target, tx);
                        }
                        return EventState::Consumed;
                    }
                    match key.code {
                        KeyCode::Up
                        | KeyCode::Down
                        | KeyCode::PageUp
                        | KeyCode::PageDown
                        | KeyCode::Home
                        | KeyCode::End => {
                            list.handle_key(key);
                            EventState::Consumed
                        }
                        KeyCode::Enter => {
                            self.submit_selected(tx);
                            EventState::Consumed
                        }
                        _ => EventState::NotConsumed,
                    }
                }
            }
            InputEvent::Mouse(mouse) => {
                let Some(list) = &mut self.list else {
                    return EventState::NotConsumed;
                };
                match list.handle_mouse(mouse) {
                    SearchMouse::Activated(_) => self.submit_selected(tx),
                    SearchMouse::Context(_) => {
                        if let Some(event) = list.selected_row().and_then(S::context_event) {
                            tx.send(event).ok();
                        }
                    }
                    _ => {}
                }
                EventState::Consumed
            }
            _ => EventState::NotConsumed,
        }
    }

    /// Standard rendering: panel block + (filter input row) + list.
    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
        let block = panel_block(S::TITLE, theme, focused);
        let inner = block.inner(rect);
        f.render_widget(block, rect);
        self.render_in(f, inner, rect, theme, focused);
    }

    /// Render the body into `body` (a host that drew extra chrome — LINKS'
    /// tab bar — passes what remains). `panel` is the full panel rect, for
    /// wheel hit-testing.
    pub fn render_in(
        &mut self,
        f: &mut Frame,
        body: Rect,
        panel: Rect,
        theme: &Theme,
        focused: bool,
    ) {
        let Some(list) = &mut self.list else {
            return;
        };
        if S::HAS_FILTER && S::BORDERED_INPUT {
            // Bordered search box (Length 3) clearly separated from the results,
            // reusing the note finder's layout. A right-aligned status doubles as
            // the progress indicator: "Searching…" while a query is in flight,
            // otherwise the result count.
            // Drain any completed load NOW. `SearchList::render` is what normally
            // polls, but the placeholder path below renders a message instead of
            // the list — without this the loader never drains, so `is_loading`
            // sticks true forever ("Searching…" that never resolves) and typed
            // results never land.
            list.poll();

            let rows = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Length(3), Constraint::Min(0)])
                .split(body);

            let loading = list.is_loading();
            let count = list.match_count();
            let status = if loading {
                "Searching…".to_string()
            } else {
                format!("{count} results")
            };
            let dim = Style::default().fg(theme.gray.to_ratatui());
            let search_block = Block::default()
                .title(" Search ")
                .title(Line::from(Span::styled(format!(" {status} "), dim)).right_aligned())
                .borders(Borders::ALL)
                .border_style(theme.border_style(focused));
            let search_inner = search_block.inner(rows[0]);
            f.render_widget(search_block, rows[0]);
            list.render_query(f, search_inner, theme, focused);

            // Results: show a dimmed placeholder while a query is in flight with
            // nothing yet on screen, or when a completed query found nothing;
            // otherwise the (possibly stale) results stay visible.
            let query_empty = list.query().trim().is_empty();
            if count == 0 && (loading || !query_empty) {
                let msg = if loading {
                    "Searching…"
                } else {
                    "No results"
                };
                f.render_widget(
                    Paragraph::new(Line::from(Span::styled(msg, dim))).alignment(Alignment::Center),
                    rows[1],
                );
            } else {
                list.render(f, rows[1], theme, focused);
            }
            list.set_list_rect(rows[1]);
        } else if S::HAS_FILTER {
            let rows = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Length(1), Constraint::Min(0)])
                .split(body);
            list.render_query(f, rows[0], theme, focused);
            list.render(f, rows[1], theme, focused);
            list.set_list_rect(rows[1]);
        } else {
            list.render(f, body, theme, focused);
            list.set_list_rect(body);
        }
        list.set_panel_rect(panel);
    }

    /// Test access to the underlying list.
    #[cfg(test)]
    pub(crate) fn list_mut(&mut self) -> Option<&mut SearchList<S::Row>> {
        self.list.as_mut()
    }

    #[cfg(test)]
    pub(crate) fn list(&self) -> Option<&SearchList<S::Row>> {
        self.list.as_ref()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::components::search_list::{Emit, SearchRow};
    use crate::settings::themes::Theme;
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;
    use tokio::sync::mpsc::unbounded_channel;

    #[derive(Clone)]
    struct Row(String);
    impl SearchRow for Row {
        fn to_list_item(
            &self,
            _t: &Theme,
            _i: &Icons,
            _s: bool,
        ) -> ratatui::widgets::ListItem<'static> {
            ratatui::widgets::ListItem::new(self.0.clone())
        }
        fn visual_height(&self) -> u16 {
            1
        }
        fn match_text(&self) -> Option<&str> {
            Some(&self.0)
        }
        fn yank_target(&self) -> Option<crate::components::search_list::YankTarget> {
            Some(crate::components::search_list::YankTarget::path(
                self.0.clone(),
            ))
        }
    }

    /// A source that completes immediately with no rows — the "query found
    /// nothing" case.
    struct EmptySource;
    #[async_trait::async_trait]
    impl RowSource<Row> for EmptySource {
        async fn load(&self, _q: &str, emit: Emit<Row>) {
            emit.replace(Vec::new());
        }
    }

    /// A source whose load never resolves — stays `is_loading` forever, the
    /// "query in flight" case.
    struct PendingSource;
    #[async_trait::async_trait]
    impl RowSource<Row> for PendingSource {
        async fn load(&self, _q: &str, _emit: Emit<Row>) {
            std::future::pending::<()>().await;
        }
    }

    struct BorderedSpec;
    impl ListPanelSpec for BorderedSpec {
        type Row = Row;
        const TITLE: &'static str = "Semantic";
        const BORDERED_INPUT: bool = true;
        fn submit(_row: &Row, _tx: &AppTx) {}
        fn hints() -> Vec<(String, String)> {
            Vec::new()
        }
    }

    /// A server-backed source: returns the same three rows for any query (the
    /// server did the ranking; the query is not a local substring filter).
    struct ThreeSource;
    #[async_trait::async_trait]
    impl RowSource<Row> for ThreeSource {
        async fn load(&self, _q: &str, emit: Emit<Row>) {
            emit.replace(vec![
                Row("alpha".into()),
                Row("beta".into()),
                Row("gamma".into()),
            ]);
        }
    }

    /// Like `BorderedSpec` but opts out of local filtering (server-backed).
    struct NoFilterSpec;
    impl ListPanelSpec for NoFilterSpec {
        type Row = Row;
        const TITLE: &'static str = "Semantic";
        const BORDERED_INPUT: bool = true;
        const LOCAL_FILTER: bool = false;
        fn submit(_row: &Row, _tx: &AppTx) {}
        fn hints() -> Vec<(String, String)> {
            Vec::new()
        }
    }

    fn buffer_text<S: ListPanelSpec>(panel: &mut QueryListPanel<S>) -> String {
        let theme = Theme::default();
        let mut term = Terminal::new(TestBackend::new(40, 12)).unwrap();
        term.draw(|f| panel.render(f, Rect::new(0, 0, 40, 12), &theme, true))
            .unwrap();
        let buf = term.backend().buffer().clone();
        (0..buf.area.height)
            .map(|y| {
                (0..buf.area.width)
                    .map(|x| buf[(x, y)].symbol())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    /// Regression: a server-backed view (`LOCAL_FILTER = false`) must NOT drop
    /// the server's ranked rows just because their titles don't contain the
    /// typed query. This was the "semantic search shows one result" bug — the
    /// local fuzzy filter discarded every conceptually-relevant note whose title
    /// lacked the query words.
    /// A no-filter spec (the LINKS drawer's shape), where plain letters are the
    /// host's sub-view keys and only recognised keys reach the list.
    struct NoInputSpec;
    impl ListPanelSpec for NoInputSpec {
        type Row = Row;
        const TITLE: &'static str = "Links";
        const HAS_FILTER: bool = false;
        fn submit(_row: &Row, _tx: &AppTx) {}
        fn hints() -> Vec<(String, String)> {
            Vec::new()
        }
    }

    /// A view with no filter input forwards only what it recognises, so the
    /// yank chord has to be forwarded on purpose. Without that, LINKS rows would
    /// declare a yank target the panel could never deliver.
    #[tokio::test]
    async fn yank_chord_reaches_a_view_that_has_no_filter_input() {
        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        let (tx, mut rx) = unbounded_channel();
        let mut panel = QueryListPanel::<NoInputSpec>::new(
            Icons::new(false),
            vec![crate::keys::default_yank_combo()],
        );
        panel.set_source(ThreeSource, &tx);
        panel.list_mut().unwrap().poll_until_idle().await;

        let ctrl_y = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL);
        let state = panel.handle_input(&InputEvent::Key(ctrl_y), &tx);
        assert_eq!(state, EventState::Consumed);

        // The flash is the observable outcome: either the copy or a clipboard
        // error, but never silence.
        let flashed = std::iter::from_fn(|| rx.try_recv().ok()).any(|e| {
            matches!(e, AppEvent::FlashMessage(m)
                if m == "path copied" || m.starts_with("clipboard: "))
        });
        assert!(flashed, "the yank chord must reach the list and report");
    }

    /// A plain letter must still stay with the host in a no-filter view — the
    /// yank forwarding above must not open the floodgates.
    #[tokio::test]
    async fn no_filter_view_still_passes_plain_letters_to_the_host() {
        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        let (tx, _rx) = unbounded_channel();
        let mut panel = QueryListPanel::<NoInputSpec>::new(
            Icons::new(false),
            vec![crate::keys::default_yank_combo()],
        );
        panel.set_source(ThreeSource, &tx);
        panel.list_mut().unwrap().poll_until_idle().await;
        let b = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
        assert_eq!(
            panel.handle_input(&InputEvent::Key(b), &tx),
            EventState::NotConsumed,
            "`b` is LINKS' backlinks sub-view key, not the list's"
        );
    }

    #[tokio::test]
    async fn no_local_filter_keeps_server_rows_that_dont_match_query() {
        let (tx, _rx) = unbounded_channel();
        let mut panel = QueryListPanel::<NoFilterSpec>::new(
            Icons::new(false),
            vec![crate::keys::default_yank_combo()],
        );
        panel.set_source(ThreeSource, &tx);
        {
            let list = panel.list_mut().unwrap();
            list.poll_until_idle().await;
            // A query that matches none of the row titles verbatim.
            list.set_query("zzz-not-in-any-title");
            list.poll_until_idle().await;
        }
        assert_eq!(
            panel.list().unwrap().match_count(),
            3,
            "server rows must survive a non-matching query (no local filter)"
        );
        let text = buffer_text(&mut panel);
        assert!(
            text.contains("alpha") && text.contains("beta") && text.contains("gamma"),
            "all server rows shown:\n{text}"
        );
    }

    /// Contrast: a local-filter view (`LOCAL_FILTER = true`, the drawer default)
    /// DOES narrow rows by the typed query — the behavior semantic search must
    /// avoid but TAGS/LINKS/OUTLINE rely on.
    #[tokio::test]
    async fn local_filter_narrows_rows_by_query() {
        let (tx, _rx) = unbounded_channel();
        let mut panel = QueryListPanel::<BorderedSpec>::new(
            Icons::new(false),
            vec![crate::keys::default_yank_combo()],
        );
        panel.set_source(ThreeSource, &tx);
        {
            let list = panel.list_mut().unwrap();
            list.poll_until_idle().await;
            list.set_query("alpha");
            list.poll_until_idle().await;
        }
        assert_eq!(
            panel.list().unwrap().match_count(),
            1,
            "local fuzzy filter keeps only the matching row"
        );
    }

    #[tokio::test]
    async fn bordered_input_shows_searching_indicator_while_in_flight() {
        let (tx, _rx) = unbounded_channel();
        let mut panel = QueryListPanel::<BorderedSpec>::new(
            Icons::new(false),
            vec![crate::keys::default_yank_combo()],
        );
        panel.set_source(PendingSource, &tx);
        // The initial load is pending → is_loading stays true.
        let text = buffer_text(&mut panel);
        assert!(text.contains("Search"), "bordered search box:\n{text}");
        assert!(text.contains("Searching"), "in-flight indicator:\n{text}");
    }

    /// Regression: the placeholder path must still drain the loader. Render is
    /// the only thing that polls; if it renders a message *instead of* the list
    /// without polling, `is_loading` sticks true forever and typed results never
    /// land. Here the load completes (empty) off-thread; a single `render` — with
    /// NO manual `poll_until_idle` — must clear loading and show "No results".
    #[tokio::test]
    async fn render_drains_loader_in_placeholder_path() {
        let (tx, _rx) = unbounded_channel();
        let mut panel = QueryListPanel::<BorderedSpec>::new(
            Icons::new(false),
            vec![crate::keys::default_yank_combo()],
        );
        panel.set_source(EmptySource, &tx);
        panel.list_mut().unwrap().set_query("x"); // starts a load (reload_on_query)
        // Let the spawned load run and land on the channel — but do NOT poll it
        // in ourselves; render must be what drains it.
        tokio::time::sleep(std::time::Duration::from_millis(30)).await;

        let text = buffer_text(&mut panel); // render → must poll
        assert!(
            !panel.list().unwrap().is_loading(),
            "render must drain the loader; is_loading stuck:\n{text}"
        );
        assert!(text.contains("No results"), "resolved to empty:\n{text}");
        assert!(
            !text.contains("Searching"),
            "must not be stuck searching:\n{text}"
        );
    }

    #[tokio::test]
    async fn bordered_input_shows_no_results_for_empty_completed_query() {
        let (tx, _rx) = unbounded_channel();
        let mut panel = QueryListPanel::<BorderedSpec>::new(
            Icons::new(false),
            vec![crate::keys::default_yank_combo()],
        );
        panel.set_source(EmptySource, &tx);
        {
            let list = panel.list_mut().unwrap();
            list.poll_until_idle().await; // drain the initial empty-query load
            list.set_query("nothing-matches");
            list.poll_until_idle().await;
        }
        let text = buffer_text(&mut panel);
        assert!(text.contains("Search"), "bordered search box:\n{text}");
        assert!(text.contains("No results"), "empty-result message:\n{text}");
    }
}