1use std::cmp::Ordering;
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use kimun_core::nfs::VaultPath;
6use kimun_core::{PropertySortValue, ResultType, SearchResult};
7use ratatui::style::{Modifier, Style};
8use ratatui::widgets::ListItem;
9
10use crate::components::rich_row::RichRow;
11use crate::components::search_list::OrderFn;
12use crate::settings::icons::Icons;
13use crate::settings::themes::Theme;
14use crate::settings::{SortFieldSetting, SortOrderSetting};
15
16#[derive(Clone, PartialEq, Debug)]
21pub enum SortField {
22 Name,
23 Title,
24 Property(String),
28}
29
30#[derive(Clone, Copy, PartialEq, Debug)]
31pub enum SortOrder {
32 Ascending,
33 Descending,
34}
35
36impl From<SortFieldSetting> for SortField {
37 fn from(s: SortFieldSetting) -> Self {
38 match s {
39 SortFieldSetting::Name => Self::Name,
40 SortFieldSetting::Title => Self::Title,
41 }
42 }
43}
44
45impl From<SortOrderSetting> for SortOrder {
46 fn from(s: SortOrderSetting) -> Self {
47 match s {
48 SortOrderSetting::Ascending => Self::Ascending,
49 SortOrderSetting::Descending => Self::Descending,
50 }
51 }
52}
53
54impl From<SortField> for SortFieldSetting {
55 fn from(s: SortField) -> Self {
56 match s {
57 SortField::Name | SortField::Property(_) => Self::Name,
58 SortField::Title => Self::Title,
59 }
60 }
61}
62
63impl From<SortOrder> for SortOrderSetting {
64 fn from(s: SortOrder) -> Self {
65 match s {
66 SortOrder::Ascending => Self::Ascending,
67 SortOrder::Descending => Self::Descending,
68 }
69 }
70}
71
72impl SortField {
73 pub fn label(&self) -> String {
74 match self {
75 Self::Name => "N".to_string(),
76 Self::Title => "T".to_string(),
77 Self::Property(key) => key.clone(),
78 }
79 }
80
81 pub fn cycle(&self, allow_property: bool) -> Self {
84 match self {
85 Self::Name => Self::Title,
86 Self::Title if allow_property => Self::Property(String::new()),
87 Self::Title | Self::Property(_) => Self::Name,
88 }
89 }
90}
91
92impl SortOrder {
93 pub fn label(self) -> char {
94 match self {
95 Self::Ascending => '↑',
96 Self::Descending => '↓',
97 }
98 }
99
100 pub fn toggle(self) -> Self {
101 match self {
102 Self::Ascending => Self::Descending,
103 Self::Descending => Self::Ascending,
104 }
105 }
106}
107
108pub type PropertyValues = Arc<HashMap<VaultPath, PropertySortValue>>;
111
112pub fn entry_order(
119 field: SortField,
120 order: SortOrder,
121 group_dirs: bool,
122 values: Option<PropertyValues>,
123) -> OrderFn<FileListEntry> {
124 let directed = move |ord: Ordering| match order {
125 SortOrder::Ascending => ord,
126 SortOrder::Descending => ord.reverse(),
127 };
128 Arc::new(move |a: &FileListEntry, b: &FileListEntry| {
129 let rank = |e: &FileListEntry| match e {
130 FileListEntry::Up { .. } => 0,
131 FileListEntry::Directory { .. } if group_dirs => 1,
132 _ => 2,
133 };
134 rank(a).cmp(&rank(b)).then_with(|| {
135 let by_name = || a.sort_key(&field).cmp(&b.sort_key(&field));
136 if !matches!(field, SortField::Property(_)) {
137 return directed(by_name());
138 }
139 let value = |e: &FileListEntry| match e {
140 FileListEntry::Note { path, .. } => values.as_ref().and_then(|v| v.get(path)),
141 _ => None,
142 };
143 match (value(a), value(b)) {
144 (Some(x), Some(y)) => directed(x.cmp(y)).then_with(by_name),
145 (Some(_), None) => Ordering::Less,
146 (None, Some(_)) => Ordering::Greater,
147 (None, None) => by_name(),
148 }
149 })
150 })
151}
152
153#[derive(Clone)]
158pub enum FileListEntry {
159 Up {
160 parent: VaultPath,
161 },
162 Note {
163 path: VaultPath,
164 title: String,
165 filename: String,
166 journal_date: Option<String>,
167 is_open: bool,
171 },
172 Directory {
173 path: VaultPath,
174 name: String,
175 },
176 Attachment {
177 path: VaultPath,
178 filename: String,
179 },
180 CreateNote {
181 filename: String,
182 path: VaultPath,
183 },
184}
185
186impl FileListEntry {
187 pub fn from_result(result: SearchResult, journal_date: Option<String>) -> Self {
188 let filename = result.path.get_parent_path().1;
189 match result.rtype {
190 ResultType::Note(data) => Self::Note {
191 path: result.path,
192 title: Self::display_title(data.title),
193 filename,
194 journal_date,
195 is_open: false,
196 },
197 ResultType::Directory => Self::Directory {
198 path: result.path,
199 name: filename,
200 },
201 ResultType::Attachment => Self::Attachment {
202 path: result.path,
203 filename,
204 },
205 }
206 }
207
208 pub fn display_title(raw: String) -> String {
212 if raw.trim().is_empty() {
213 "<no title>".to_string()
214 } else {
215 raw
216 }
217 }
218
219 pub fn path(&self) -> &VaultPath {
220 match self {
221 Self::Up { parent } => parent,
222 Self::Note { path, .. } => path,
223 Self::Directory { path, .. } => path,
224 Self::Attachment { path, .. } => path,
225 Self::CreateNote { path, .. } => path,
226 }
227 }
228
229 pub(crate) fn sort_key(&self, field: &SortField) -> String {
231 match self {
232 Self::Up { .. } => String::new(),
233 Self::Note {
234 title, filename, ..
235 } => match field {
236 SortField::Title => title.to_lowercase(),
237 SortField::Name | SortField::Property(_) => filename.to_lowercase(),
240 },
241 Self::Directory { name, .. } => name.to_lowercase(),
242 Self::Attachment { filename, .. } => filename.to_lowercase(),
243 Self::CreateNote { filename, .. } => filename.to_lowercase(),
244 }
245 }
246
247 pub fn visual_height(&self) -> u16 {
249 match self {
250 Self::Note { journal_date, .. } => {
251 if journal_date.is_some() {
252 3
253 } else {
254 2
255 }
256 }
257 _ => 1,
258 }
259 }
260
261 pub fn to_list_item(&self, theme: &Theme, icons: &Icons) -> ListItem<'static> {
262 match self {
263 Self::Up { .. } => RichRow::new(icons.directory_up, "[UP] ..")
264 .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
265 .title_style(Style::default().fg(theme.gray.to_ratatui()))
266 .into_list_item(theme),
267 Self::Note {
268 title,
269 filename,
270 journal_date,
271 is_open,
272 ..
273 } => {
274 let glyph = if journal_date.is_some() {
275 icons.journal
276 } else {
277 icons.note
278 };
279 let mut row = RichRow::new(glyph, title.clone()).filename(filename.clone());
280 if *is_open {
281 row = row.glyph_style(Style::default().fg(theme.accent.to_ratatui()));
283 }
284 if let Some(date) = journal_date {
285 row = row.secondary(
286 date.clone(),
287 Some(Style::default().fg(theme.color_journal_date.to_ratatui())),
288 );
289 }
290 row.into_list_item(theme)
291 }
292 Self::Directory { name, .. } => {
293 let dir_style = Style::default().fg(theme.color_directory.to_ratatui());
294 RichRow::new(icons.directory, name.clone())
295 .glyph_style(dir_style)
296 .title_style(dir_style)
297 .into_list_item(theme)
298 }
299 Self::Attachment { filename, .. } => {
300 let style = Style::default()
301 .add_modifier(Modifier::ITALIC)
302 .fg(theme.fg_secondary.to_ratatui());
303 RichRow::new(icons.attachment, filename.clone())
304 .glyph_style(style)
305 .title_style(style)
306 .into_list_item(theme)
307 }
308 Self::CreateNote { filename, .. } => {
309 let style = Style::default().fg(theme.accent.to_ratatui());
310 RichRow::new("+", format!("Create: {}", filename))
311 .glyph_style(style)
312 .title_style(style)
313 .into_list_item(theme)
314 }
315 }
316 }
317}
318
319impl crate::components::search_list::SearchRow for FileListEntry {
320 fn to_list_item(&self, theme: &Theme, icons: &Icons, _selected: bool) -> ListItem<'static> {
321 FileListEntry::to_list_item(self, theme, icons)
323 }
324
325 fn visual_height(&self) -> u16 {
326 FileListEntry::visual_height(self)
327 }
328
329 fn match_text(&self) -> Option<&str> {
330 match self {
331 Self::Note { filename, .. } | Self::CreateNote { filename, .. } => Some(filename),
332 Self::Directory { name, .. } => Some(name),
335 _ => None,
336 }
337 }
338
339 fn yank_target(&self) -> Option<crate::components::search_list::YankTarget> {
340 use crate::components::search_list::YankTarget;
341 match self {
342 Self::Up { .. } | Self::CreateNote { .. } => None,
346 Self::Note { path, .. }
347 | Self::Directory { path, .. }
348 | Self::Attachment { path, .. } => Some(YankTarget::path(path.to_string())),
349 }
350 }
351}
352
353#[cfg(test)]
354mod open_marker_tests {
355 use super::*;
356 use ratatui::style::Style;
357 use ratatui::text::{Line, Span, Text};
358 use ratatui::widgets::ListItem;
359
360 #[test]
361 fn display_title_substitutes_placeholder_for_empty() {
362 assert_eq!(
363 FileListEntry::display_title(" ".to_string()),
364 "<no title>"
365 );
366 assert_eq!(FileListEntry::display_title("Real".to_string()), "Real");
367 }
368
369 fn glyph_fg_of_note(is_open: bool) -> ratatui::style::Color {
378 let theme = Theme::default();
379 let icons = Icons::new(false);
380 let note = FileListEntry::Note {
381 path: kimun_core::nfs::VaultPath::note_path_from("a.md"),
382 title: "A".to_string(),
383 filename: "a.md".to_string(),
384 journal_date: None,
385 is_open,
386 };
387 let fg = theme.fg.to_ratatui();
390 let accent = theme.accent.to_ratatui();
391 let glyph_style = if is_open {
392 Style::default().fg(accent)
393 } else {
394 Style::default().fg(fg)
395 };
396 let title_style = Style::default().fg(fg);
397 let secondary_style = Style::default()
398 .fg(theme.fg_secondary.to_ratatui())
399 .add_modifier(ratatui::style::Modifier::ITALIC);
400
401 let expected_lines = vec![
402 Line::from(vec![
403 Span::styled(format!("{} ", icons.note), glyph_style),
404 Span::styled("A", title_style),
405 ]),
406 Line::from(Span::styled(" a.md", secondary_style)),
407 ];
408 let expected = ListItem::new(Text::from(expected_lines));
409 let actual = note.to_list_item(&theme, &icons);
410 assert_eq!(actual, expected, "ListItem mismatch for is_open={is_open}");
411 glyph_style.fg.expect("glyph style must have an fg color")
413 }
414
415 #[test]
416 fn open_note_glyph_is_accent_colored() {
417 let theme = Theme::default();
418 let accent = theme.accent.to_ratatui();
419 let actual_fg = glyph_fg_of_note(true);
420 assert_eq!(
421 actual_fg, accent,
422 "is_open=true: glyph span fg should be theme.accent"
423 );
424 }
425
426 #[test]
427 fn closed_note_glyph_is_not_accent_colored() {
428 let theme = Theme::default();
429 let accent = theme.accent.to_ratatui();
430 let actual_fg = glyph_fg_of_note(false);
431 assert_ne!(
432 actual_fg, accent,
433 "is_open=false: glyph span fg should NOT be theme.accent"
434 );
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::components::search_list::SearchRow;
442 use std::collections::HashMap;
443 use std::sync::Arc;
444
445 #[test]
446 fn sort_field_cycle_gates_property() {
447 assert_eq!(SortField::Name.cycle(false), SortField::Title);
448 assert_eq!(SortField::Title.cycle(false), SortField::Name);
449 assert_eq!(
450 SortField::Property(String::new()).cycle(false),
451 SortField::Name
452 );
453 let p = SortField::Name.cycle(true).cycle(true);
454 assert_eq!(p, SortField::Property(String::new()));
455 assert_eq!(p.cycle(true), SortField::Name);
456 }
457
458 fn note(name: &str) -> FileListEntry {
459 FileListEntry::Note {
460 path: VaultPath::note_path_from(name),
461 title: name.to_string(),
462 filename: format!("{name}.md"),
463 journal_date: None,
464 is_open: false,
465 }
466 }
467
468 fn dir(name: &str) -> FileListEntry {
469 FileListEntry::Directory {
470 path: VaultPath::new(name),
471 name: name.to_string(),
472 }
473 }
474
475 fn names(rows: &[FileListEntry]) -> Vec<String> {
476 rows.iter()
477 .map(|r| match r {
478 FileListEntry::Up { .. } => "..".to_string(),
479 FileListEntry::Note { title, .. } => title.clone(),
480 FileListEntry::Directory { name, .. } => format!("{name}/"),
481 FileListEntry::Attachment { filename, .. } => filename.clone(),
482 FileListEntry::CreateNote { filename, .. } => filename.clone(),
483 })
484 .collect()
485 }
486
487 fn sorted(order: SortOrder, group_dirs: bool, values: Option<PropertyValues>) -> Vec<String> {
489 let mut rows = vec![
490 note("delta"),
491 dir("zdir"),
492 note("alpha"),
493 FileListEntry::Attachment {
494 path: VaultPath::new("pic.png"),
495 filename: "pic.png".into(),
496 },
497 note("charlie"),
498 FileListEntry::Up {
499 parent: VaultPath::root(),
500 },
501 dir("adir"),
502 note("bravo"),
503 ];
504 let cmp = entry_order(
505 SortField::Property("rank".into()),
506 order,
507 group_dirs,
508 values,
509 );
510 rows.sort_by(|a, b| cmp(a, b));
511 names(&rows)
512 }
513
514 fn rank_values() -> PropertyValues {
515 use kimun_core::PropertySortValue::{Number, Text};
516 Arc::new(HashMap::from([
517 (VaultPath::note_path_from("delta"), Number(1.0)),
518 (VaultPath::note_path_from("bravo"), Number(5.0)),
519 (VaultPath::note_path_from("charlie"), Text("x".into())),
520 ]))
521 }
522
523 #[test]
524 fn property_order_ascending_puts_missing_last_in_name_order() {
525 assert_eq!(
526 sorted(SortOrder::Ascending, false, Some(rank_values())),
527 [
528 "..", "delta", "bravo", "charlie", "adir/", "alpha", "pic.png", "zdir/"
529 ]
530 );
531 }
532
533 #[test]
534 fn property_order_descending_still_puts_missing_last() {
535 assert_eq!(
536 sorted(SortOrder::Descending, false, Some(rank_values())),
537 [
538 "..", "charlie", "bravo", "delta", "adir/", "alpha", "pic.png", "zdir/"
539 ]
540 );
541 }
542
543 #[test]
544 fn property_order_groups_directories_first_by_name() {
545 assert_eq!(
546 sorted(SortOrder::Descending, true, Some(rank_values())),
547 [
548 "..", "adir/", "zdir/", "charlie", "bravo", "delta", "alpha", "pic.png"
549 ]
550 );
551 }
552
553 #[test]
554 fn property_order_without_values_is_name_order() {
555 assert_eq!(
556 sorted(SortOrder::Descending, false, None),
557 [
558 "..", "adir/", "alpha", "bravo", "charlie", "delta", "pic.png", "zdir/"
559 ]
560 );
561 }
562
563 #[test]
564 fn name_order_still_follows_the_direction() {
565 let mut rows = vec![note("alpha"), dir("bdir"), note("charlie")];
566 let cmp = entry_order(SortField::Name, SortOrder::Descending, false, None);
567 rows.sort_by(|a, b| cmp(a, b));
568 assert_eq!(names(&rows), ["charlie", "bdir/", "alpha"]);
569 }
570
571 #[test]
572 fn directory_match_text_is_some_name() {
573 let dir = FileListEntry::Directory {
574 path: VaultPath::note_path_from("projects"),
575 name: "projects".to_string(),
576 };
577 assert_eq!(SearchRow::match_text(&dir), Some("projects"));
578 }
579
580 #[test]
581 fn up_match_text_is_none() {
582 let up = FileListEntry::Up {
583 parent: VaultPath::root(),
584 };
585 assert_eq!(SearchRow::match_text(&up), None);
586 }
587
588 #[test]
589 fn sort_field_setting_roundtrip() {
590 use crate::settings::SortFieldSetting;
591 assert_eq!(
592 SortFieldSetting::from(SortField::Name),
593 SortFieldSetting::Name
594 );
595 assert_eq!(
596 SortFieldSetting::from(SortField::Title),
597 SortFieldSetting::Title
598 );
599 assert_eq!(SortField::from(SortFieldSetting::Title), SortField::Title);
600 }
601
602 #[test]
603 fn sort_order_setting_roundtrip() {
604 use crate::settings::SortOrderSetting;
605 assert_eq!(
606 SortOrderSetting::from(SortOrder::Ascending),
607 SortOrderSetting::Ascending
608 );
609 assert_eq!(
610 SortOrderSetting::from(SortOrder::Descending),
611 SortOrderSetting::Descending
612 );
613 }
614}