Skip to main content

kimun_notes/components/
sortable.rs

1//! One contract for every list the sort dialog can sort — the sidebar, the
2//! Query panel, the Ctrl+K search browser and the Ctrl+O file finder. The dialog is built from a
3//! list's [`SortState`] and [`SortableList::allows_property`], and every
4//! selection it emits is applied through [`SortableList::apply_sort`], so the
5//! targets cannot drift apart in how a sort lands.
6
7use std::sync::Arc;
8use std::sync::mpsc::{Receiver, TryRecvError};
9
10use kimun_core::note::property_search_key;
11use kimun_core::{NoteVault, OrderBy, OrderField, SearchTerms, with_order_directive};
12
13use crate::components::events::{AppEvent, AppTx};
14use crate::components::file_list::{PropertyValues, SortField, SortOrder};
15
16/// The clickable sort chip a sortable panel draws right-aligned on its
17/// search box border: ` Name ↑ `, ` Title ↓ `, ` due ↑ ` (a property sort
18/// shows its key). Plain text, so it reads the same in every font. A click
19/// on it opens the sort dialog — the mouse's `Ctrl+R`. A property key longer
20/// than [`SORT_CHIP_MAX_NAME`] cells is cut with `…`, so it never crowds the
21/// search box's own title.
22pub fn sort_chip_label(field: &SortField, order: SortOrder) -> String {
23    let name = match field {
24        SortField::Name => "Name",
25        SortField::Title => "Title",
26        SortField::Property(key) => key.as_str(),
27    };
28    format!(
29        " {} {} ",
30        truncate_cells(name, SORT_CHIP_MAX_NAME),
31        order.label()
32    )
33}
34
35/// Widest field name the sort chip shows, in terminal cells (`…` included).
36pub const SORT_CHIP_MAX_NAME: usize = 12;
37
38/// `s` cut to at most `max` display cells, ending in `…` when cut.
39fn truncate_cells(s: &str, max: usize) -> std::borrow::Cow<'_, str> {
40    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
41    if s.width() <= max {
42        return std::borrow::Cow::Borrowed(s);
43    }
44    let mut out = String::new();
45    let mut w = 0;
46    for c in s.chars() {
47        let cw = c.width().unwrap_or(0);
48        if w + cw > max.saturating_sub(1) {
49            break;
50        }
51        out.push(c);
52        w += cw;
53    }
54    out.push('…');
55    std::borrow::Cow::Owned(out)
56}
57
58/// [`sort_chip_label`] as a border-title line in the theme's action style,
59/// ready for a [`BorderChip`](crate::components::clickable::BorderChip).
60pub fn sort_chip_line(
61    field: &SortField,
62    order: SortOrder,
63    theme: &crate::settings::themes::Theme,
64) -> ratatui::text::Line<'static> {
65    ratatui::text::Line::from(ratatui::text::Span::styled(
66        sort_chip_label(field, order),
67        theme.action(),
68    ))
69}
70
71/// What the sort dialog shows and changes for one list.
72#[derive(Debug, Clone, PartialEq)]
73pub struct SortState {
74    pub field: SortField,
75    pub order: SortOrder,
76    /// `Some` only for lists that can group directories (the sidebar).
77    pub group_dirs: Option<bool>,
78}
79
80/// A list the sort dialog can sort.
81pub trait SortableList {
82    fn sort_state(&self) -> SortState;
83    /// Apply a selection from the sort dialog. Property sorts are only
84    /// offered where [`Self::allows_property`] says so.
85    fn apply_sort(&mut self, state: &SortState, tx: &AppTx);
86    /// Whether the dialog offers Property. Query-backed lists put the key in
87    /// the query; listings fetch values with [`PropertySort`].
88    fn allows_property(&self) -> bool;
89    /// The rows are in the list's natural order (recency, match rank), not
90    /// any sort the dialog offers — it then shows "Unsorted" until a pick.
91    /// [`Self::sort_state`] still names the field / order the first toggle
92    /// starts from. Only the file finder and the Ctrl+K recents say so.
93    fn is_unsorted(&self) -> bool {
94        false
95    }
96}
97
98/// One values fetch as the task delivers it. `failed`: the read errored and
99/// `values` is the empty stand-in (every row sorts as missing).
100struct FetchResult {
101    key: String,
102    values: PropertyValues,
103    failed: bool,
104}
105
106/// The property values a listing (sidebar, file finder) orders by: one async
107/// fetch per key, cached only while the list keeps sorting by that key (an
108/// Order / Group toggle reuses them; leaving the property sort or a failed
109/// read does not count as a cache). Results arrive on a channel
110/// the owner polls each frame; a result for a key the list no longer sorts
111/// by is dropped.
112#[derive(Default)]
113pub struct PropertySort {
114    /// The key the cached / pending values are for.
115    key: Option<String>,
116    values: Option<PropertyValues>,
117    rx: Option<Receiver<FetchResult>>,
118    /// The last read for `key` failed: `values` is the empty stand-in, so
119    /// the next [`Self::ensure`] retries.
120    failed: bool,
121    /// Fetches started, for tests that pin when a refetch happens.
122    #[cfg(test)]
123    pub(crate) fetches: usize,
124}
125
126impl PropertySort {
127    /// The cached values, if they are for `key` (compared in core's search
128    /// form, so `Rank` and `rank` share them).
129    pub fn values_for(&self, key: &str) -> Option<PropertyValues> {
130        let key = property_search_key(key)?;
131        (self.key.as_deref() == Some(key.as_str()))
132            .then(|| self.values.clone())
133            .flatten()
134    }
135
136    /// The cached values a listing sorted by `field` orders with: `None` for
137    /// Name / Title, or while a property sort's values are still awaited.
138    pub fn values_for_field(&self, field: &SortField) -> Option<PropertyValues> {
139        property_key(field).and_then(|k| self.values_for(k))
140    }
141
142    /// A property sort whose values have not landed yet — the listing keeps
143    /// its current order until they do.
144    pub fn is_awaiting(&self, field: &SortField) -> bool {
145        property_key(field).is_some_and(|k| self.values_for(k).is_none())
146    }
147
148    /// Fetch `key`'s values in the background (a redraw follows the result).
149    /// Values cached for another key are dropped; for the same key they stay
150    /// in use until the fresh ones land. A blank key fetches nothing.
151    pub fn fetch(&mut self, vault: &Arc<NoteVault>, key: &str, tx: &AppTx) {
152        let Some(key) = property_search_key(key) else {
153            return;
154        };
155        if self.key.as_deref() != Some(key.as_str()) {
156            self.values = None;
157            self.key = Some(key.clone());
158        }
159        self.failed = false;
160        #[cfg(test)]
161        {
162            self.fetches += 1;
163        }
164        let (result_tx, result_rx) = std::sync::mpsc::channel();
165        self.rx = Some(result_rx);
166        let vault = Arc::clone(vault);
167        let tx = tx.clone();
168        tokio::spawn(async move {
169            let (values, failed) = match vault.property_sort_values(&key).await {
170                Ok(values) => (values, false),
171                Err(e) => {
172                    report_fetch_error(&key, &e, &tx);
173                    (Default::default(), true)
174                }
175            };
176            result_tx
177                .send(FetchResult {
178                    key,
179                    values: Arc::new(values),
180                    failed,
181                })
182                .ok();
183            tx.send(AppEvent::Redraw).ok();
184        });
185    }
186
187    /// Make `key`'s values available: fetch them unless a read for that key
188    /// is in flight or succeeded while the list kept sorting by it (so
189    /// Order / Group toggles reuse what was read). A list that reloads
190    /// calls [`Self::fetch`] instead, so edits since the last read show up.
191    pub fn ensure(&mut self, vault: &Arc<NoteVault>, key: &str, tx: &AppTx) {
192        let Some(folded) = property_search_key(key) else {
193            return;
194        };
195        let same_key = self.key.as_deref() == Some(folded.as_str());
196        if same_key && (self.rx.is_some() || (self.values.is_some() && !self.failed)) {
197            return;
198        }
199        self.fetch(vault, key, tx);
200    }
201
202    /// Follow a sort the list just applied: a property sort makes its values
203    /// available ([`Self::ensure`]); Name / Title forget the cache, so coming
204    /// back to a property later reads fresh values.
205    pub fn sync(&mut self, vault: &Arc<NoteVault>, field: &SortField, tx: &AppTx) {
206        match property_key(field) {
207            Some(key) => self.ensure(vault, key, tx),
208            None => self.clear(),
209        }
210    }
211
212    /// Drop the cached values and any fetch in flight.
213    pub fn clear(&mut self) {
214        self.key = None;
215        self.values = None;
216        self.rx = None;
217        self.failed = false;
218    }
219
220    /// Take a fetched result if one has landed, for the owner to hand to
221    /// [`Self::receive`] (through its own re-sorting entry point).
222    pub fn poll(&mut self) -> Option<(String, PropertyValues)> {
223        let rx = self.rx.as_ref()?;
224        match rx.try_recv() {
225            Ok(FetchResult {
226                key,
227                values,
228                failed,
229            }) => {
230                self.rx = None;
231                self.failed = failed;
232                Some((key, values))
233            }
234            Err(TryRecvError::Disconnected) => {
235                self.rx = None;
236                None
237            }
238            Err(TryRecvError::Empty) => None,
239        }
240    }
241
242    /// Accept `values` if they are for the key the list sorts by.
243    pub fn receive(&mut self, key: &str, values: PropertyValues) -> bool {
244        let key = property_search_key(key);
245        if key.is_none() || self.key != key {
246            return false;
247        }
248        self.values = Some(values);
249        true
250    }
251
252    /// `true` while a fetch is in flight.
253    #[cfg(test)]
254    pub fn is_pending(&self) -> bool {
255        self.rx.is_some()
256    }
257}
258
259/// A failed values fetch: log it and tell the user. Every row then sorts as
260/// missing (the fetch delivers an empty map).
261fn report_fetch_error(key: &str, e: &dyn std::fmt::Display, tx: &AppTx) {
262    tracing::warn!("property sort values for {key}: {e}");
263    tx.send(AppEvent::FlashMessage(format!(
264        "couldn't load property values: {e}"
265    )))
266    .ok();
267}
268
269/// The key of a property sort, `None` for Name / Title.
270pub fn property_key(field: &SortField) -> Option<&str> {
271    match field {
272        SortField::Property(key) => Some(key.as_str()),
273        _ => None,
274    }
275}
276
277/// A property sort with no key yet — not a usable order.
278pub fn is_blank_property(field: &SortField) -> bool {
279    matches!(field, SortField::Property(key) if key.trim().is_empty())
280}
281
282/// The sort a query string asks for, read from its first order directive.
283/// `(Name, Ascending)` when the query has none. Shared by every query-backed
284/// list so the dialog opens on what the query actually says.
285pub fn order_of_query(query: &str) -> (SortField, SortOrder) {
286    directive_of_query(query).unwrap_or((SortField::Name, SortOrder::Ascending))
287}
288
289/// The sort a query's first usable order directive asks for; `None` when it
290/// has none (a bare `or:prop:` with no key is not usable).
291pub fn directive_of_query(query: &str) -> Option<(SortField, SortOrder)> {
292    let st = SearchTerms::from_query_string(query);
293    let (field, asc) = match st.order_by.first()? {
294        OrderBy::Title { asc } => (SortField::Title, *asc),
295        OrderBy::FileName { asc } => (SortField::Name, *asc),
296        OrderBy::Property { key, asc } => (SortField::Property(key.clone()), *asc),
297    };
298    let order = if asc {
299        SortOrder::Ascending
300    } else {
301        SortOrder::Descending
302    };
303    Some((field, order))
304}
305
306/// `query` with its order directive replaced by `field` / `order` — the query
307/// string is the single source of truth for a query-backed list's sort.
308/// `None` for a property sort with no key yet (not a usable order).
309pub fn query_with_sort(query: &str, field: &SortField, order: SortOrder) -> Option<String> {
310    if is_blank_property(field) {
311        return None;
312    }
313    let order_field = match field {
314        SortField::Name => OrderField::FileName,
315        SortField::Title => OrderField::Title,
316        SortField::Property(key) => OrderField::Property(key.clone()),
317    };
318    let asc = matches!(order, SortOrder::Ascending);
319    Some(with_order_directive(query, order_field, asc))
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn sort_chip_names_the_field_and_cuts_long_keys() {
328        assert_eq!(
329            sort_chip_label(&SortField::Name, SortOrder::Ascending),
330            " Name ↑ "
331        );
332        assert_eq!(
333            sort_chip_label(&SortField::Property("due".into()), SortOrder::Descending),
334            " due ↓ "
335        );
336        let long = sort_chip_label(
337            &SortField::Property("a-very-long-property-name".into()),
338            SortOrder::Ascending,
339        );
340        assert_eq!(long, " a-very-long… ↑ ");
341        let name = long.trim().trim_end_matches(" ↑");
342        assert_eq!(
343            unicode_width::UnicodeWidthStr::width(name),
344            SORT_CHIP_MAX_NAME
345        );
346    }
347
348    #[test]
349    fn order_of_query_reads_the_directive() {
350        assert_eq!(
351            order_of_query("widget -or:title"),
352            (SortField::Title, SortOrder::Descending)
353        );
354        assert_eq!(
355            order_of_query("#work or:prop:due"),
356            (SortField::Property("due".into()), SortOrder::Ascending)
357        );
358        assert_eq!(
359            order_of_query("widget"),
360            (SortField::Name, SortOrder::Ascending)
361        );
362    }
363
364    #[test]
365    fn query_with_sort_round_trips_and_skips_empty_keys() {
366        let q = query_with_sort("x", &SortField::Name, SortOrder::Descending).unwrap();
367        assert_eq!(order_of_query(&q), (SortField::Name, SortOrder::Descending));
368        assert_eq!(
369            query_with_sort("x", &SortField::Property(" ".into()), SortOrder::Ascending),
370            None
371        );
372    }
373
374    #[test]
375    fn property_sort_cache_folds_the_key_like_core() {
376        use kimun_core::PropertySortValue::Number;
377        let mut sort = PropertySort {
378            key: Some("rank".into()),
379            ..Default::default()
380        };
381        let values: PropertyValues = Arc::new(std::collections::HashMap::from([(
382            kimun_core::nfs::VaultPath::note_path_from("/a"),
383            Number(1.0),
384        )]));
385        assert!(sort.receive(" Rank ", values));
386        assert!(sort.values_for("RANK").is_some());
387        assert!(sort.values_for("rank").is_some());
388        assert!(sort.values_for("other").is_none());
389        assert!(sort.values_for(" ").is_none());
390        assert!(!sort.receive("other", Arc::default()));
391    }
392
393    #[tokio::test(flavor = "multi_thread")]
394    async fn fetch_reuses_the_cache_for_another_casing() {
395        use kimun_core::PropertySortValue::Number;
396        let vault = crate::test_support::temp_vault("prop-sort-casing").await;
397        vault.validate_and_init().await.unwrap();
398        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
399        let mut sort = PropertySort::default();
400        sort.fetch(&vault, "Rank", &tx);
401        let values: PropertyValues = Arc::new(std::collections::HashMap::from([(
402            kimun_core::nfs::VaultPath::note_path_from("/a"),
403            Number(1.0),
404        )]));
405        assert!(sort.receive("rank", values));
406        sort.fetch(&vault, "RANK", &tx);
407        assert!(
408            sort.values_for("rank").is_some(),
409            "a refetch under another casing keeps the cached values"
410        );
411    }
412
413    /// `ensure` fetches only for a new key: the same key (in any casing)
414    /// reuses the cache or the fetch already in flight.
415    #[tokio::test(flavor = "multi_thread")]
416    async fn ensure_fetches_only_when_the_key_changes() {
417        let vault = crate::test_support::temp_vault("prop-sort-ensure").await;
418        vault.validate_and_init().await.unwrap();
419        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
420        let mut sort = PropertySort::default();
421        sort.ensure(&vault, "rank", &tx);
422        assert_eq!(sort.fetches, 1);
423        sort.ensure(&vault, "Rank", &tx);
424        assert_eq!(sort.fetches, 1, "same key while in flight: no refetch");
425        assert!(sort.receive("rank", Arc::default()));
426        sort.rx = None;
427        sort.ensure(&vault, " RANK ", &tx);
428        assert_eq!(sort.fetches, 1, "same key with cached values: no refetch");
429        sort.ensure(&vault, "due", &tx);
430        assert_eq!(sort.fetches, 2, "another key fetches");
431        sort.fetch(&vault, "due", &tx);
432        assert_eq!(sort.fetches, 3, "fetch always refetches (list reloads)");
433    }
434
435    /// A failed fetch still orders (every row missing) but is not a cache:
436    /// applying the same key again retries.
437    #[tokio::test(flavor = "multi_thread")]
438    async fn a_failed_fetch_is_retried_on_the_same_key() {
439        let vault = crate::test_support::temp_vault("prop-sort-retry").await;
440        vault.validate_and_init().await.unwrap();
441        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
442        let mut sort = PropertySort::default();
443        sort.ensure(&vault, "rank", &tx);
444        // Stand in for the task's failed read.
445        let (result_tx, result_rx) = std::sync::mpsc::channel();
446        sort.rx = Some(result_rx);
447        result_tx
448            .send(FetchResult {
449                key: "rank".into(),
450                values: Arc::default(),
451                failed: true,
452            })
453            .unwrap();
454        let (key, values) = sort.poll().expect("a result");
455        assert!(sort.receive(&key, values));
456        assert!(sort.values_for("rank").is_some(), "rows still order");
457        sort.ensure(&vault, "rank", &tx);
458        assert_eq!(sort.fetches, 2, "a failed fetch is retried");
459    }
460
461    /// Switching to Name / Title forgets the values: coming back fetches.
462    #[tokio::test(flavor = "multi_thread")]
463    async fn sync_clears_the_cache_for_a_non_property_sort() {
464        let vault = crate::test_support::temp_vault("prop-sort-sync").await;
465        vault.validate_and_init().await.unwrap();
466        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
467        let mut sort = PropertySort::default();
468        let rank = SortField::Property("rank".into());
469        sort.sync(&vault, &rank, &tx);
470        assert!(sort.receive("rank", Arc::default()));
471        sort.rx = None;
472        sort.sync(&vault, &rank, &tx);
473        assert_eq!(sort.fetches, 1, "same key: reused");
474        sort.sync(&vault, &SortField::Name, &tx);
475        assert!(sort.values_for("rank").is_none());
476        sort.sync(&vault, &rank, &tx);
477        assert_eq!(sort.fetches, 2, "back from Name: refetched");
478    }
479
480    #[test]
481    fn a_fetch_error_is_flashed() {
482        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
483        report_fetch_error("rank", &"db gone", &tx);
484        match rx.try_recv() {
485            Ok(AppEvent::FlashMessage(msg)) => {
486                assert_eq!(msg, "couldn't load property values: db gone")
487            }
488            other => panic!("expected a flash, got {other:?}"),
489        }
490    }
491}