use std::cmp::Ordering;
use std::collections::HashMap;
use std::sync::Arc;
use kimun_core::nfs::VaultPath;
use kimun_core::{PropertySortValue, ResultType, SearchResult};
use ratatui::style::{Modifier, Style};
use ratatui::widgets::ListItem;
use crate::components::rich_row::RichRow;
use crate::components::search_list::OrderFn;
use crate::settings::icons::Icons;
use crate::settings::themes::Theme;
use crate::settings::{SortFieldSetting, SortOrderSetting};
#[derive(Clone, PartialEq, Debug)]
pub enum SortField {
Name,
Title,
Property(String),
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum SortOrder {
Ascending,
Descending,
}
impl From<SortFieldSetting> for SortField {
fn from(s: SortFieldSetting) -> Self {
match s {
SortFieldSetting::Name => Self::Name,
SortFieldSetting::Title => Self::Title,
}
}
}
impl From<SortOrderSetting> for SortOrder {
fn from(s: SortOrderSetting) -> Self {
match s {
SortOrderSetting::Ascending => Self::Ascending,
SortOrderSetting::Descending => Self::Descending,
}
}
}
impl From<SortField> for SortFieldSetting {
fn from(s: SortField) -> Self {
match s {
SortField::Name | SortField::Property(_) => Self::Name,
SortField::Title => Self::Title,
}
}
}
impl From<SortOrder> for SortOrderSetting {
fn from(s: SortOrder) -> Self {
match s {
SortOrder::Ascending => Self::Ascending,
SortOrder::Descending => Self::Descending,
}
}
}
impl SortField {
pub fn label(&self) -> String {
match self {
Self::Name => "N".to_string(),
Self::Title => "T".to_string(),
Self::Property(key) => key.clone(),
}
}
pub fn cycle(&self, allow_property: bool) -> Self {
match self {
Self::Name => Self::Title,
Self::Title if allow_property => Self::Property(String::new()),
Self::Title | Self::Property(_) => Self::Name,
}
}
}
impl SortOrder {
pub fn label(self) -> char {
match self {
Self::Ascending => '↑',
Self::Descending => '↓',
}
}
pub fn toggle(self) -> Self {
match self {
Self::Ascending => Self::Descending,
Self::Descending => Self::Ascending,
}
}
}
pub type PropertyValues = Arc<HashMap<VaultPath, PropertySortValue>>;
pub fn entry_order(
field: SortField,
order: SortOrder,
group_dirs: bool,
values: Option<PropertyValues>,
) -> OrderFn<FileListEntry> {
let directed = move |ord: Ordering| match order {
SortOrder::Ascending => ord,
SortOrder::Descending => ord.reverse(),
};
Arc::new(move |a: &FileListEntry, b: &FileListEntry| {
let rank = |e: &FileListEntry| match e {
FileListEntry::Up { .. } => 0,
FileListEntry::Directory { .. } if group_dirs => 1,
_ => 2,
};
rank(a).cmp(&rank(b)).then_with(|| {
let by_name = || a.sort_key(&field).cmp(&b.sort_key(&field));
if !matches!(field, SortField::Property(_)) {
return directed(by_name());
}
let value = |e: &FileListEntry| match e {
FileListEntry::Note { path, .. } => values.as_ref().and_then(|v| v.get(path)),
_ => None,
};
match (value(a), value(b)) {
(Some(x), Some(y)) => directed(x.cmp(y)).then_with(by_name),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => by_name(),
}
})
})
}
#[derive(Clone)]
pub enum FileListEntry {
Up {
parent: VaultPath,
},
Note {
path: VaultPath,
title: String,
filename: String,
journal_date: Option<String>,
is_open: bool,
},
Directory {
path: VaultPath,
name: String,
},
Attachment {
path: VaultPath,
filename: String,
},
CreateNote {
filename: String,
path: VaultPath,
},
}
impl FileListEntry {
pub fn from_result(result: SearchResult, journal_date: Option<String>) -> Self {
let filename = result.path.get_parent_path().1;
match result.rtype {
ResultType::Note(data) => Self::Note {
path: result.path,
title: Self::display_title(data.title),
filename,
journal_date,
is_open: false,
},
ResultType::Directory => Self::Directory {
path: result.path,
name: filename,
},
ResultType::Attachment => Self::Attachment {
path: result.path,
filename,
},
}
}
pub fn display_title(raw: String) -> String {
if raw.trim().is_empty() {
"<no title>".to_string()
} else {
raw
}
}
pub fn path(&self) -> &VaultPath {
match self {
Self::Up { parent } => parent,
Self::Note { path, .. } => path,
Self::Directory { path, .. } => path,
Self::Attachment { path, .. } => path,
Self::CreateNote { path, .. } => path,
}
}
pub(crate) fn sort_key(&self, field: &SortField) -> String {
match self {
Self::Up { .. } => String::new(),
Self::Note {
title, filename, ..
} => match field {
SortField::Title => title.to_lowercase(),
SortField::Name | SortField::Property(_) => filename.to_lowercase(),
},
Self::Directory { name, .. } => name.to_lowercase(),
Self::Attachment { filename, .. } => filename.to_lowercase(),
Self::CreateNote { filename, .. } => filename.to_lowercase(),
}
}
pub fn visual_height(&self) -> u16 {
match self {
Self::Note { journal_date, .. } => {
if journal_date.is_some() {
3
} else {
2
}
}
_ => 1,
}
}
pub fn to_list_item(&self, theme: &Theme, icons: &Icons) -> ListItem<'static> {
match self {
Self::Up { .. } => RichRow::new(icons.directory_up, "[UP] ..")
.glyph_style(Style::default().fg(theme.gray.to_ratatui()))
.title_style(Style::default().fg(theme.gray.to_ratatui()))
.into_list_item(theme),
Self::Note {
title,
filename,
journal_date,
is_open,
..
} => {
let glyph = if journal_date.is_some() {
icons.journal
} else {
icons.note
};
let mut row = RichRow::new(glyph, title.clone()).filename(filename.clone());
if *is_open {
row = row.glyph_style(Style::default().fg(theme.accent.to_ratatui()));
}
if let Some(date) = journal_date {
row = row.secondary(
date.clone(),
Some(Style::default().fg(theme.color_journal_date.to_ratatui())),
);
}
row.into_list_item(theme)
}
Self::Directory { name, .. } => {
let dir_style = Style::default().fg(theme.color_directory.to_ratatui());
RichRow::new(icons.directory, name.clone())
.glyph_style(dir_style)
.title_style(dir_style)
.into_list_item(theme)
}
Self::Attachment { filename, .. } => {
let style = Style::default()
.add_modifier(Modifier::ITALIC)
.fg(theme.fg_secondary.to_ratatui());
RichRow::new(icons.attachment, filename.clone())
.glyph_style(style)
.title_style(style)
.into_list_item(theme)
}
Self::CreateNote { filename, .. } => {
let style = Style::default().fg(theme.accent.to_ratatui());
RichRow::new("+", format!("Create: {}", filename))
.glyph_style(style)
.title_style(style)
.into_list_item(theme)
}
}
}
}
impl crate::components::search_list::SearchRow for FileListEntry {
fn to_list_item(&self, theme: &Theme, icons: &Icons, _selected: bool) -> ListItem<'static> {
FileListEntry::to_list_item(self, theme, icons)
}
fn visual_height(&self) -> u16 {
FileListEntry::visual_height(self)
}
fn match_text(&self) -> Option<&str> {
match self {
Self::Note { filename, .. } | Self::CreateNote { filename, .. } => Some(filename),
Self::Directory { name, .. } => Some(name),
_ => None,
}
}
fn yank_target(&self) -> Option<crate::components::search_list::YankTarget> {
use crate::components::search_list::YankTarget;
match self {
Self::Up { .. } | Self::CreateNote { .. } => None,
Self::Note { path, .. }
| Self::Directory { path, .. }
| Self::Attachment { path, .. } => Some(YankTarget::path(path.to_string())),
}
}
}
#[cfg(test)]
mod open_marker_tests {
use super::*;
use ratatui::style::Style;
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::ListItem;
#[test]
fn display_title_substitutes_placeholder_for_empty() {
assert_eq!(
FileListEntry::display_title(" ".to_string()),
"<no title>"
);
assert_eq!(FileListEntry::display_title("Real".to_string()), "Real");
}
fn glyph_fg_of_note(is_open: bool) -> ratatui::style::Color {
let theme = Theme::default();
let icons = Icons::new(false);
let note = FileListEntry::Note {
path: kimun_core::nfs::VaultPath::note_path_from("a.md"),
title: "A".to_string(),
filename: "a.md".to_string(),
journal_date: None,
is_open,
};
let fg = theme.fg.to_ratatui();
let accent = theme.accent.to_ratatui();
let glyph_style = if is_open {
Style::default().fg(accent)
} else {
Style::default().fg(fg)
};
let title_style = Style::default().fg(fg);
let secondary_style = Style::default()
.fg(theme.fg_secondary.to_ratatui())
.add_modifier(ratatui::style::Modifier::ITALIC);
let expected_lines = vec![
Line::from(vec![
Span::styled(format!("{} ", icons.note), glyph_style),
Span::styled("A", title_style),
]),
Line::from(Span::styled(" a.md", secondary_style)),
];
let expected = ListItem::new(Text::from(expected_lines));
let actual = note.to_list_item(&theme, &icons);
assert_eq!(actual, expected, "ListItem mismatch for is_open={is_open}");
glyph_style.fg.expect("glyph style must have an fg color")
}
#[test]
fn open_note_glyph_is_accent_colored() {
let theme = Theme::default();
let accent = theme.accent.to_ratatui();
let actual_fg = glyph_fg_of_note(true);
assert_eq!(
actual_fg, accent,
"is_open=true: glyph span fg should be theme.accent"
);
}
#[test]
fn closed_note_glyph_is_not_accent_colored() {
let theme = Theme::default();
let accent = theme.accent.to_ratatui();
let actual_fg = glyph_fg_of_note(false);
assert_ne!(
actual_fg, accent,
"is_open=false: glyph span fg should NOT be theme.accent"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::search_list::SearchRow;
use std::collections::HashMap;
use std::sync::Arc;
#[test]
fn sort_field_cycle_gates_property() {
assert_eq!(SortField::Name.cycle(false), SortField::Title);
assert_eq!(SortField::Title.cycle(false), SortField::Name);
assert_eq!(
SortField::Property(String::new()).cycle(false),
SortField::Name
);
let p = SortField::Name.cycle(true).cycle(true);
assert_eq!(p, SortField::Property(String::new()));
assert_eq!(p.cycle(true), SortField::Name);
}
fn note(name: &str) -> FileListEntry {
FileListEntry::Note {
path: VaultPath::note_path_from(name),
title: name.to_string(),
filename: format!("{name}.md"),
journal_date: None,
is_open: false,
}
}
fn dir(name: &str) -> FileListEntry {
FileListEntry::Directory {
path: VaultPath::new(name),
name: name.to_string(),
}
}
fn names(rows: &[FileListEntry]) -> Vec<String> {
rows.iter()
.map(|r| match r {
FileListEntry::Up { .. } => "..".to_string(),
FileListEntry::Note { title, .. } => title.clone(),
FileListEntry::Directory { name, .. } => format!("{name}/"),
FileListEntry::Attachment { filename, .. } => filename.clone(),
FileListEntry::CreateNote { filename, .. } => filename.clone(),
})
.collect()
}
fn sorted(order: SortOrder, group_dirs: bool, values: Option<PropertyValues>) -> Vec<String> {
let mut rows = vec![
note("delta"),
dir("zdir"),
note("alpha"),
FileListEntry::Attachment {
path: VaultPath::new("pic.png"),
filename: "pic.png".into(),
},
note("charlie"),
FileListEntry::Up {
parent: VaultPath::root(),
},
dir("adir"),
note("bravo"),
];
let cmp = entry_order(
SortField::Property("rank".into()),
order,
group_dirs,
values,
);
rows.sort_by(|a, b| cmp(a, b));
names(&rows)
}
fn rank_values() -> PropertyValues {
use kimun_core::PropertySortValue::{Number, Text};
Arc::new(HashMap::from([
(VaultPath::note_path_from("delta"), Number(1.0)),
(VaultPath::note_path_from("bravo"), Number(5.0)),
(VaultPath::note_path_from("charlie"), Text("x".into())),
]))
}
#[test]
fn property_order_ascending_puts_missing_last_in_name_order() {
assert_eq!(
sorted(SortOrder::Ascending, false, Some(rank_values())),
[
"..", "delta", "bravo", "charlie", "adir/", "alpha", "pic.png", "zdir/"
]
);
}
#[test]
fn property_order_descending_still_puts_missing_last() {
assert_eq!(
sorted(SortOrder::Descending, false, Some(rank_values())),
[
"..", "charlie", "bravo", "delta", "adir/", "alpha", "pic.png", "zdir/"
]
);
}
#[test]
fn property_order_groups_directories_first_by_name() {
assert_eq!(
sorted(SortOrder::Descending, true, Some(rank_values())),
[
"..", "adir/", "zdir/", "charlie", "bravo", "delta", "alpha", "pic.png"
]
);
}
#[test]
fn property_order_without_values_is_name_order() {
assert_eq!(
sorted(SortOrder::Descending, false, None),
[
"..", "adir/", "alpha", "bravo", "charlie", "delta", "pic.png", "zdir/"
]
);
}
#[test]
fn name_order_still_follows_the_direction() {
let mut rows = vec![note("alpha"), dir("bdir"), note("charlie")];
let cmp = entry_order(SortField::Name, SortOrder::Descending, false, None);
rows.sort_by(|a, b| cmp(a, b));
assert_eq!(names(&rows), ["charlie", "bdir/", "alpha"]);
}
#[test]
fn directory_match_text_is_some_name() {
let dir = FileListEntry::Directory {
path: VaultPath::note_path_from("projects"),
name: "projects".to_string(),
};
assert_eq!(SearchRow::match_text(&dir), Some("projects"));
}
#[test]
fn up_match_text_is_none() {
let up = FileListEntry::Up {
parent: VaultPath::root(),
};
assert_eq!(SearchRow::match_text(&up), None);
}
#[test]
fn sort_field_setting_roundtrip() {
use crate::settings::SortFieldSetting;
assert_eq!(
SortFieldSetting::from(SortField::Name),
SortFieldSetting::Name
);
assert_eq!(
SortFieldSetting::from(SortField::Title),
SortFieldSetting::Title
);
assert_eq!(SortField::from(SortFieldSetting::Title), SortField::Title);
}
#[test]
fn sort_order_setting_roundtrip() {
use crate::settings::SortOrderSetting;
assert_eq!(
SortOrderSetting::from(SortOrder::Ascending),
SortOrderSetting::Ascending
);
assert_eq!(
SortOrderSetting::from(SortOrder::Descending),
SortOrderSetting::Descending
);
}
}