1use kimun_core::nfs::VaultPath;
2use kimun_core::{ResultType, SearchResult};
3use ratatui::style::{Modifier, Style};
4use ratatui::widgets::ListItem;
5
6use crate::components::rich_row::RichRow;
7use crate::settings::icons::Icons;
8use crate::settings::themes::Theme;
9use crate::settings::{SortFieldSetting, SortOrderSetting};
10
11#[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#[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 is_open: bool,
113 },
114 Directory {
115 path: VaultPath,
116 name: String,
117 },
118 Attachment {
119 path: VaultPath,
120 filename: String,
121 },
122 CreateNote {
123 filename: String,
124 path: VaultPath,
125 },
126}
127
128impl FileListEntry {
129 pub fn from_result(result: SearchResult, journal_date: Option<String>) -> Self {
130 let filename = result.path.get_parent_path().1;
131 match result.rtype {
132 ResultType::Note(data) => Self::Note {
133 path: result.path,
134 title: Self::display_title(data.title),
135 filename,
136 journal_date,
137 is_open: false,
138 },
139 ResultType::Directory => Self::Directory {
140 path: result.path,
141 name: filename,
142 },
143 ResultType::Attachment => Self::Attachment {
144 path: result.path,
145 filename,
146 },
147 }
148 }
149
150 pub fn display_title(raw: String) -> String {
154 if raw.trim().is_empty() {
155 "<no title>".to_string()
156 } else {
157 raw
158 }
159 }
160
161 pub fn path(&self) -> &VaultPath {
162 match self {
163 Self::Up { parent } => parent,
164 Self::Note { path, .. } => path,
165 Self::Directory { path, .. } => path,
166 Self::Attachment { path, .. } => path,
167 Self::CreateNote { path, .. } => path,
168 }
169 }
170
171 pub(crate) fn sort_key(&self, field: SortField) -> String {
173 match self {
174 Self::Up { .. } => String::new(),
175 Self::Note {
176 title, filename, ..
177 } => match field {
178 SortField::Title => title.to_lowercase(),
179 SortField::Name => filename.to_lowercase(),
180 },
181 Self::Directory { name, .. } => name.to_lowercase(),
182 Self::Attachment { filename, .. } => filename.to_lowercase(),
183 Self::CreateNote { filename, .. } => filename.to_lowercase(),
184 }
185 }
186
187 pub fn visual_height(&self) -> u16 {
189 match self {
190 Self::Note { journal_date, .. } => {
191 if journal_date.is_some() {
192 3
193 } else {
194 2
195 }
196 }
197 _ => 1,
198 }
199 }
200
201 pub fn to_list_item(&self, theme: &Theme, icons: &Icons) -> ListItem<'static> {
202 match self {
203 Self::Up { .. } => RichRow::new(icons.directory_up, "[UP] ..")
204 .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
205 .title_style(Style::default().fg(theme.gray.to_ratatui()))
206 .into_list_item(theme),
207 Self::Note {
208 title,
209 filename,
210 journal_date,
211 is_open,
212 ..
213 } => {
214 let glyph = if journal_date.is_some() {
215 icons.journal
216 } else {
217 icons.note
218 };
219 let mut row = RichRow::new(glyph, title.clone()).filename(filename.clone());
220 if *is_open {
221 row = row.glyph_style(Style::default().fg(theme.accent.to_ratatui()));
223 }
224 if let Some(date) = journal_date {
225 row = row.secondary(
226 date.clone(),
227 Some(Style::default().fg(theme.color_journal_date.to_ratatui())),
228 );
229 }
230 row.into_list_item(theme)
231 }
232 Self::Directory { name, .. } => {
233 let dir_style = Style::default().fg(theme.color_directory.to_ratatui());
234 RichRow::new(icons.directory, name.clone())
235 .glyph_style(dir_style)
236 .title_style(dir_style)
237 .into_list_item(theme)
238 }
239 Self::Attachment { filename, .. } => {
240 let style = Style::default()
241 .add_modifier(Modifier::ITALIC)
242 .fg(theme.fg_secondary.to_ratatui());
243 RichRow::new(icons.attachment, filename.clone())
244 .glyph_style(style)
245 .title_style(style)
246 .into_list_item(theme)
247 }
248 Self::CreateNote { filename, .. } => {
249 let style = Style::default().fg(theme.accent.to_ratatui());
250 RichRow::new("+", format!("Create: {}", filename))
251 .glyph_style(style)
252 .title_style(style)
253 .into_list_item(theme)
254 }
255 }
256 }
257}
258
259impl crate::components::search_list::SearchRow for FileListEntry {
260 fn to_list_item(&self, theme: &Theme, icons: &Icons, _selected: bool) -> ListItem<'static> {
261 FileListEntry::to_list_item(self, theme, icons)
263 }
264
265 fn visual_height(&self) -> u16 {
266 FileListEntry::visual_height(self)
267 }
268
269 fn match_text(&self) -> Option<&str> {
270 match self {
271 Self::Note { filename, .. } | Self::CreateNote { filename, .. } => Some(filename),
272 Self::Directory { name, .. } => Some(name),
275 _ => None,
276 }
277 }
278}
279
280#[cfg(test)]
281mod open_marker_tests {
282 use super::*;
283 use ratatui::style::Style;
284 use ratatui::text::{Line, Span, Text};
285 use ratatui::widgets::ListItem;
286
287 #[test]
288 fn display_title_substitutes_placeholder_for_empty() {
289 assert_eq!(
290 FileListEntry::display_title(" ".to_string()),
291 "<no title>"
292 );
293 assert_eq!(FileListEntry::display_title("Real".to_string()), "Real");
294 }
295
296 fn glyph_fg_of_note(is_open: bool) -> ratatui::style::Color {
305 let theme = Theme::default();
306 let icons = Icons::new(false);
307 let note = FileListEntry::Note {
308 path: kimun_core::nfs::VaultPath::note_path_from("a.md"),
309 title: "A".to_string(),
310 filename: "a.md".to_string(),
311 journal_date: None,
312 is_open,
313 };
314 let fg = theme.fg.to_ratatui();
317 let accent = theme.accent.to_ratatui();
318 let glyph_style = if is_open {
319 Style::default().fg(accent)
320 } else {
321 Style::default().fg(fg)
322 };
323 let title_style = Style::default().fg(fg);
324 let secondary_style = Style::default()
325 .fg(theme.fg_secondary.to_ratatui())
326 .add_modifier(ratatui::style::Modifier::ITALIC);
327
328 let expected_lines = vec![
329 Line::from(vec![
330 Span::styled(format!("{} ", icons.note), glyph_style),
331 Span::styled("A", title_style),
332 ]),
333 Line::from(Span::styled(" a.md", secondary_style)),
334 ];
335 let expected = ListItem::new(Text::from(expected_lines));
336 let actual = note.to_list_item(&theme, &icons);
337 assert_eq!(actual, expected, "ListItem mismatch for is_open={is_open}");
338 glyph_style.fg.expect("glyph style must have an fg color")
340 }
341
342 #[test]
343 fn open_note_glyph_is_accent_colored() {
344 let theme = Theme::default();
345 let accent = theme.accent.to_ratatui();
346 let actual_fg = glyph_fg_of_note(true);
347 assert_eq!(
348 actual_fg, accent,
349 "is_open=true: glyph span fg should be theme.accent"
350 );
351 }
352
353 #[test]
354 fn closed_note_glyph_is_not_accent_colored() {
355 let theme = Theme::default();
356 let accent = theme.accent.to_ratatui();
357 let actual_fg = glyph_fg_of_note(false);
358 assert_ne!(
359 actual_fg, accent,
360 "is_open=false: glyph span fg should NOT be theme.accent"
361 );
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::components::search_list::SearchRow;
369
370 #[test]
371 fn directory_match_text_is_some_name() {
372 let dir = FileListEntry::Directory {
373 path: VaultPath::note_path_from("projects"),
374 name: "projects".to_string(),
375 };
376 assert_eq!(SearchRow::match_text(&dir), Some("projects"));
377 }
378
379 #[test]
380 fn up_match_text_is_none() {
381 let up = FileListEntry::Up {
382 parent: VaultPath::root(),
383 };
384 assert_eq!(SearchRow::match_text(&up), None);
385 }
386
387 #[test]
388 fn sort_field_setting_roundtrip() {
389 use crate::settings::SortFieldSetting;
390 assert_eq!(
391 SortFieldSetting::from(SortField::Name),
392 SortFieldSetting::Name
393 );
394 assert_eq!(
395 SortFieldSetting::from(SortField::Title),
396 SortFieldSetting::Title
397 );
398 assert_eq!(SortField::from(SortFieldSetting::Title), SortField::Title);
399 }
400
401 #[test]
402 fn sort_order_setting_roundtrip() {
403 use crate::settings::SortOrderSetting;
404 assert_eq!(
405 SortOrderSetting::from(SortOrder::Ascending),
406 SortOrderSetting::Ascending
407 );
408 assert_eq!(
409 SortOrderSetting::from(SortOrder::Descending),
410 SortOrderSetting::Descending
411 );
412 }
413}