Skip to main content

kimun_notes/components/
file_list.rs

1use kimun_core::nfs::VaultPath;
2use kimun_core::{ResultType, SearchResult};
3use ratatui::style::{Modifier, Style};
4use ratatui::text::{Line, Span, Text};
5use ratatui::widgets::ListItem;
6
7use crate::settings::icons::Icons;
8use crate::settings::themes::Theme;
9use crate::settings::{SortFieldSetting, SortOrderSetting};
10
11// ---------------------------------------------------------------------------
12// Sort options
13// ---------------------------------------------------------------------------
14
15#[derive(Clone, Copy, PartialEq, Debug)]
16pub enum SortField {
17    Name,
18    Title,
19}
20
21#[derive(Clone, Copy, PartialEq, Debug)]
22pub enum SortOrder {
23    Ascending,
24    Descending,
25}
26
27impl From<SortFieldSetting> for SortField {
28    fn from(s: SortFieldSetting) -> Self {
29        match s {
30            SortFieldSetting::Name => Self::Name,
31            SortFieldSetting::Title => Self::Title,
32        }
33    }
34}
35
36impl From<SortOrderSetting> for SortOrder {
37    fn from(s: SortOrderSetting) -> Self {
38        match s {
39            SortOrderSetting::Ascending => Self::Ascending,
40            SortOrderSetting::Descending => Self::Descending,
41        }
42    }
43}
44
45impl From<SortField> for SortFieldSetting {
46    fn from(s: SortField) -> Self {
47        match s {
48            SortField::Name => Self::Name,
49            SortField::Title => Self::Title,
50        }
51    }
52}
53
54impl From<SortOrder> for SortOrderSetting {
55    fn from(s: SortOrder) -> Self {
56        match s {
57            SortOrder::Ascending => Self::Ascending,
58            SortOrder::Descending => Self::Descending,
59        }
60    }
61}
62
63impl SortField {
64    pub fn label(self) -> char {
65        match self {
66            Self::Name => 'N',
67            Self::Title => 'T',
68        }
69    }
70
71    pub fn cycle(self) -> Self {
72        match self {
73            Self::Name => Self::Title,
74            Self::Title => Self::Name,
75        }
76    }
77}
78
79impl SortOrder {
80    pub fn label(self) -> char {
81        match self {
82            Self::Ascending => '↑',
83            Self::Descending => '↓',
84        }
85    }
86
87    pub fn toggle(self) -> Self {
88        match self {
89            Self::Ascending => Self::Descending,
90            Self::Descending => Self::Ascending,
91        }
92    }
93}
94
95// ---------------------------------------------------------------------------
96// FileListEntry
97// ---------------------------------------------------------------------------
98
99#[derive(Clone)]
100pub enum FileListEntry {
101    Up {
102        parent: VaultPath,
103    },
104    Note {
105        path: VaultPath,
106        title: String,
107        filename: String,
108        journal_date: Option<String>,
109    },
110    Directory {
111        path: VaultPath,
112        name: String,
113    },
114    Attachment {
115        path: VaultPath,
116        filename: String,
117    },
118    CreateNote {
119        filename: String,
120        path: VaultPath,
121    },
122}
123
124impl FileListEntry {
125    pub fn from_result(result: SearchResult, journal_date: Option<String>) -> Self {
126        let filename = result.path.get_parent_path().1;
127        match result.rtype {
128            ResultType::Note(data) => {
129                let title = if data.title.trim().is_empty() {
130                    "<no title>".to_string()
131                } else {
132                    data.title
133                };
134                Self::Note {
135                    path: result.path,
136                    title,
137                    filename,
138                    journal_date,
139                }
140            }
141            ResultType::Directory => Self::Directory {
142                path: result.path,
143                name: filename,
144            },
145            ResultType::Attachment => Self::Attachment {
146                path: result.path,
147                filename,
148            },
149        }
150    }
151
152    pub fn path(&self) -> &VaultPath {
153        match self {
154            Self::Up { parent } => parent,
155            Self::Note { path, .. } => path,
156            Self::Directory { path, .. } => path,
157            Self::Attachment { path, .. } => path,
158            Self::CreateNote { path, .. } => path,
159        }
160    }
161
162    /// Sort key for the given field.
163    pub(crate) fn sort_key(&self, field: SortField) -> String {
164        match self {
165            Self::Up { .. } => String::new(),
166            Self::Note {
167                title, filename, ..
168            } => match field {
169                SortField::Title => title.to_lowercase(),
170                SortField::Name => filename.to_lowercase(),
171            },
172            Self::Directory { name, .. } => name.to_lowercase(),
173            Self::Attachment { filename, .. } => filename.to_lowercase(),
174            Self::CreateNote { filename, .. } => filename.to_lowercase(),
175        }
176    }
177
178    /// Terminal rows this entry occupies when rendered.
179    pub fn visual_height(&self) -> u16 {
180        match self {
181            Self::Note { journal_date, .. } => {
182                if journal_date.is_some() {
183                    3
184                } else {
185                    2
186                }
187            }
188            _ => 1,
189        }
190    }
191
192    pub fn to_list_item(&self, theme: &Theme, icons: &Icons) -> ListItem<'static> {
193        let lines: Vec<Line> = match self {
194            Self::Up { .. } => vec![Line::from(Span::styled(
195                format!("{} [UP] ..", icons.directory_up),
196                Style::default().fg(theme.fg_muted.to_ratatui()),
197            ))],
198            Self::Note {
199                title,
200                filename,
201                journal_date,
202                ..
203            } => {
204                let mut lines = vec![];
205                if let Some(date) = journal_date {
206                    lines.push(Line::from(format!("{} {}", icons.journal, title)));
207                    lines.push(Line::from(Span::styled(
208                        format!(" {}", date),
209                        Style::default().fg(theme.color_journal_date.to_ratatui()),
210                    )));
211                } else {
212                    lines.push(Line::from(format!("{} {}", icons.note, title)));
213                }
214                lines.push(Line::from(Span::styled(
215                    format!(" {}", filename),
216                    Style::default()
217                        .add_modifier(Modifier::ITALIC)
218                        .fg(theme.fg_secondary.to_ratatui()),
219                )));
220                lines
221            }
222            Self::Directory { name, .. } => vec![Line::from(Span::styled(
223                format!("{} {}", icons.directory, name),
224                Style::default().fg(theme.color_directory.to_ratatui()),
225            ))],
226            Self::Attachment { filename, .. } => vec![Line::from(Span::styled(
227                format!("{} {}", icons.attachment, filename),
228                Style::default()
229                    .add_modifier(Modifier::ITALIC)
230                    .fg(theme.fg_secondary.to_ratatui()),
231            ))],
232            Self::CreateNote { filename, .. } => vec![Line::from(Span::styled(
233                format!("+ Create: {}", filename),
234                Style::default().fg(theme.accent.to_ratatui()),
235            ))],
236        };
237        ListItem::new(Text::from(lines))
238    }
239}
240
241impl crate::components::search_list::SearchRow for FileListEntry {
242    fn to_list_item(&self, theme: &Theme, icons: &Icons, _selected: bool) -> ListItem<'static> {
243        // Delegate to inherent method; engine applies selection highlight via `highlight_style`.
244        FileListEntry::to_list_item(self, theme, icons)
245    }
246
247    fn visual_height(&self) -> u16 {
248        FileListEntry::visual_height(self)
249    }
250
251    fn match_text(&self) -> Option<&str> {
252        match self {
253            Self::Note { filename, .. } | Self::CreateNote { filename, .. } => Some(filename),
254            // Directories participate in the fuzzy filter (matched on their
255            // name). `Up` stays exempt.
256            Self::Directory { name, .. } => Some(name),
257            _ => None,
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::components::search_list::SearchRow;
266
267    #[test]
268    fn directory_match_text_is_some_name() {
269        let dir = FileListEntry::Directory {
270            path: VaultPath::note_path_from("projects"),
271            name: "projects".to_string(),
272        };
273        assert_eq!(SearchRow::match_text(&dir), Some("projects"));
274    }
275
276    #[test]
277    fn up_match_text_is_none() {
278        let up = FileListEntry::Up {
279            parent: VaultPath::root(),
280        };
281        assert_eq!(SearchRow::match_text(&up), None);
282    }
283
284    #[test]
285    fn sort_field_setting_roundtrip() {
286        use crate::settings::SortFieldSetting;
287        assert_eq!(
288            SortFieldSetting::from(SortField::Name),
289            SortFieldSetting::Name
290        );
291        assert_eq!(
292            SortFieldSetting::from(SortField::Title),
293            SortFieldSetting::Title
294        );
295        assert_eq!(SortField::from(SortFieldSetting::Title), SortField::Title);
296    }
297
298    #[test]
299    fn sort_order_setting_roundtrip() {
300        use crate::settings::SortOrderSetting;
301        assert_eq!(
302            SortOrderSetting::from(SortOrder::Ascending),
303            SortOrderSetting::Ascending
304        );
305        assert_eq!(
306            SortOrderSetting::from(SortOrder::Descending),
307            SortOrderSetting::Descending
308        );
309    }
310}