diff --git a/Cargo.lock b/Cargo.lock index 0f1f71b..9ff60a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,6 +991,7 @@ dependencies = [ "moku", "pad", "tek_tui", + "unicode-width 0.2.0", "walkdir", "xxhash-rust", ] diff --git a/Cargo.toml b/Cargo.toml index 5c9782a..bb377d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,4 @@ pad = "0.1" hex = "0.4" xxhash-rust = { version = "0.8.5", features = ["xxh3"] } #base64 = "0.22" +unicode-width = "0.2" diff --git a/src/keys.rs b/src/keys.rs index 9b2591a..437dc14 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -1,82 +1,29 @@ use crate::*; +macro_rules! press { + ($key:tt) => { + Event::Key(KeyEvent { + code: KeyCode::$key, + kind: KeyEventKind::Press, + modifiers: KeyModifiers::NONE, + state: KeyEventState::NONE + }) + } +} + impl Handle for Taggart { fn handle (&mut self, input: &TuiIn) -> Perhaps { let x_min = self.offset; let x_max = self.offset + self.size.h().saturating_sub(1); match &*input.event() { - Event::Key(KeyEvent { - code: KeyCode::Up, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - state: KeyEventState::NONE - }) => { - self.cursor = self.cursor.saturating_sub(1); - }, - Event::Key(KeyEvent { - code: KeyCode::Down, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - state: KeyEventState::NONE - }) => { - self.cursor = self.cursor + 1; - }, - Event::Key(KeyEvent { - code: KeyCode::PageUp, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - state: KeyEventState::NONE - }) => { - self.cursor = self.cursor.saturating_sub(PAGE_SIZE); - }, - Event::Key(KeyEvent { - code: KeyCode::PageDown, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - state: KeyEventState::NONE - }) => { - self.cursor += PAGE_SIZE; - }, - Event::Key(KeyEvent { - code: KeyCode::Left, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - state: KeyEventState::NONE - }) => { - self.column = self.column.saturating_sub(1); - }, - Event::Key(KeyEvent { - code: KeyCode::Right, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - state: KeyEventState::NONE - }) => { - self.column = self.column + 1; - }, - Event::Key(KeyEvent { - code: KeyCode::Enter, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - state: KeyEventState::NONE - }) => { - self.editing = Some((self.cursor, self.column)); - }, - Event::Key(KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - state: KeyEventState::NONE - }) => { - self.editing = None; - }, - Event::Key(KeyEvent { - code: KeyCode::Tab, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - state: KeyEventState::NONE - }) => { - self.show_hash = !self.show_hash; - }, + press!(Up) => { self.cursor = self.cursor.saturating_sub(1); }, + press!(Down) => { self.cursor = self.cursor + 1; }, + press!(PageUp) => { self.cursor = self.cursor.saturating_sub(PAGE_SIZE); }, + press!(PageDown) => { self.cursor += PAGE_SIZE; }, + press!(Left) => { self.column = self.column.saturating_sub(1); }, + press!(Right) => { self.column = self.column + 1; }, + press!(Enter) => { self.editing = Some((self.cursor, self.column)); }, + press!(Esc) => { self.editing = None; }, _ => {} } if self.cursor < x_min { @@ -88,8 +35,8 @@ impl Handle for Taggart { if self.cursor >= self.paths.len() { self.cursor = self.paths.len().saturating_sub(1) } - if self.column + 1 > COLUMN_COUNT { - self.column = COLUMN_COUNT.saturating_sub(1) + if self.column + 1 > self.columns.0.len() { + self.column = self.columns.0.len().saturating_sub(1) } Ok(None) } diff --git a/src/main.rs b/src/main.rs index c6ec4ad..644e5e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,15 +16,13 @@ use xxhash_rust::xxh3::xxh3_64; use file_type::FileType; mod keys; -mod view; +mod view; use self::view::*; mod model; pub(crate) use self::model::*; pub(crate) type Usually = std::result::Result>; pub(crate) type Perhaps = Usually>; pub(crate) const PAGE_SIZE: usize = 10; -pub(crate) const COLUMN_COUNT: usize = 6; -pub(crate) const COLUMN_WIDTHS: [u16; COLUMN_COUNT] = [16, 60, 20, 20, 5, 20]; fn cli () -> clap::Command { command!() @@ -38,7 +36,19 @@ fn main () -> Usually<()> { Tui::new()?.run(&state) } +pub struct Taggart { + pub root: PathBuf, + pub paths: Vec, + pub cursor: usize, + pub offset: usize, + pub column: usize, + pub columns: Columns, + pub size: Measure, + pub editing: Option<(usize, usize)>, +} + impl Taggart { + fn new (root: Option<&impl AsRef>) -> Usually { let root = if let Some(root) = root { root.as_ref().into() @@ -48,14 +58,15 @@ impl Taggart { Ok(Self { paths: Self::collect(&root)?, root, - cursor: 0, - offset: 0, - column: 0, - size: Measure::new(), + cursor: 0, + offset: 0, + column: 0, + size: Measure::new(), editing: None, - show_hash: false, + columns: Columns::default(), }) } + fn collect (root: &impl AsRef) -> Usually> { let mut paths = vec![]; for entry in WalkDir::new(&root).into_iter() @@ -72,4 +83,5 @@ impl Taggart { paths.sort(); Ok(paths) } + } diff --git a/src/model.rs b/src/model.rs index 42e490e..fe53650 100644 --- a/src/model.rs +++ b/src/model.rs @@ -3,17 +3,6 @@ use walkdir::DirEntry; use id3::{Tag, TagLike}; use std::io::Read; -pub struct Taggart { - pub root: PathBuf, - pub paths: Vec, - pub cursor: usize, - pub offset: usize, - pub column: usize, - pub size: Measure, - pub editing: Option<(usize, usize)>, - pub show_hash: bool, -} - #[derive(Ord, Eq, PartialEq, PartialOrd)] pub struct Entry { pub path: PathBuf, @@ -54,7 +43,6 @@ pub enum EntryInfo { } impl Entry { - pub fn new (root: &impl AsRef, entry: &DirEntry) -> Perhaps { if entry.path().is_dir() { Self::new_dir(root, entry) @@ -64,11 +52,10 @@ impl Entry { Ok(None) } } - fn new_dir (root: &impl AsRef, entry: &DirEntry) -> Perhaps { Ok(Some(Self { depth: entry.depth(), - path: entry.path().into(), + path: entry.path().strip_prefix(root.as_ref())?.into(), info: EntryInfo::Directory { hash_file: None, catalog_file: None, @@ -77,7 +64,6 @@ impl Entry { }, })) } - fn new_file (root: &impl AsRef, entry: &DirEntry) -> Perhaps { let bytes = read(entry.path())?; let hash = hex::encode(xxh3_64(&bytes).to_be_bytes()); @@ -85,7 +71,7 @@ impl Entry { let mime_type = file_type.media_types().get(0); return Ok(Some(Self { depth: entry.depth(), - path: entry.path().into(), + path: entry.path().strip_prefix(root.as_ref())?.into(), info: match mime_type { Some(&"audio/mpeg3") => { let id3 = Tag::read_from_path(entry.path())?; @@ -123,23 +109,18 @@ impl Entry { }, })) } - pub fn short_path (&self, root: &impl AsRef) -> Usually<&Path> { Ok(self.path.strip_prefix(root.as_ref())?) } - pub fn is_dir (&self) -> bool { matches!(self.info, EntryInfo::Directory { .. }) } - pub fn is_mus (&self) -> bool { matches!(self.info, EntryInfo::Music { .. }) } - pub fn is_img (&self) -> bool { matches!(self.info, EntryInfo::Image { .. }) } - pub fn hash (&self) -> Option> { match self.info { EntryInfo::Image { ref hash, .. } => Some(hash.clone()), @@ -165,4 +146,10 @@ impl Entry { _ => None } } + pub fn track (&self) -> Option> { + match self.info { + EntryInfo::Music { ref track, .. } => track.map(|t|format!("{t}").into()).clone(), + _ => None + } + } } diff --git a/src/view.rs b/src/view.rs index 60dcc44..96cf6d5 100644 --- a/src/view.rs +++ b/src/view.rs @@ -3,21 +3,63 @@ use tek_tui::ratatui::{style::{Color, Style}, prelude::Stylize}; use pad::PadStr; use std::fmt::Display; +pub struct Column { + title: Arc, + width: usize, + value: BoxOption> + Send + Sync>, +} + +impl Column { + pub fn new ( + title: &impl AsRef, + width: usize, + value: impl Fn(&T)->Option> + Send + Sync + 'static + ) -> Self { + Self { width, value: Box::new(value), title: title.as_ref().into() } + } +} + +pub struct Columns(pub Vec>); + +impl Default for Columns { + fn default () -> Self { + Self(vec![ + Column::new(&"HASH", 16, |entry: &Entry|entry.hash()), + Column::new(&"FILE", 80, |entry: &Entry|entry.name()), + Column::new(&"ARTIST", 30, |entry: &Entry|entry.artist()), + Column::new(&"RELEASE", 30, |entry: &Entry|entry.album()), + Column::new(&"TRACK", 5, |entry: &Entry|entry.track()), + Column::new(&"TITLE", 80, |entry: &Entry|entry.title()), + ]) + } +} + +impl Columns { + pub fn header (&self) -> Arc { + let mut output = String::new(); + for Column { width, value, title } in self.0.iter() { + let cell = title.pad_to_width(*width); + output = format!("{output}{cell}│"); + } + output.into() + } + pub fn row (&self, entry: &T) -> Arc { + let mut output = String::new(); + for Column { width, value, .. } in self.0.iter() { + let cell = value(entry).unwrap_or_default().pad_to_width(*width); + output = format!("{output}{cell}│"); + } + output.into() + } +} + impl Content for Taggart { fn content (&self) -> impl Render { let sizer = Fill::xy(&self.size); let size = format!("{}x{}", self.size.w(), self.size.h()); let size_bar = status_bar(Align::e(size)); - let titlebar = status_bar(Align::w(table_row( - Some("HASH".into()), - "FILE", - Some("ARTIST".into()), - Some("RELEASE".into()), - "TRACK", - Some("TITLE".into()) - ))); - let table = Fill::xy(TreeTable(self)); - Bsp::n(size_bar, Bsp::s(titlebar, Bsp::b(sizer, table))) + let titlebar = status_bar(Align::w(self.columns.header())); + Bsp::n(size_bar, Bsp::s(titlebar, Bsp::b(sizer, Fill::xy(TreeTable(self))))) } } @@ -28,8 +70,8 @@ impl<'a> Content for TreeTable<'a> { let area = to.area(); let Taggart { offset, paths, cursor, column, .. } = self.0; let mut x = 0; - for (index, _width) in COLUMN_WIDTHS.iter().enumerate() { - let w = COLUMN_WIDTHS[index] + 1; + for (index, Column { width, .. }) in self.0.columns.0.iter().enumerate() { + let w = *width as u16 + 1; if index == *column { to.fill_bg([area.x() + x, area.y(), w, area.h()], Color::Rgb(0, 0, 0)); break @@ -44,10 +86,8 @@ impl<'a> Content for TreeTable<'a> { for (index, fragment) in entry.path.iter().enumerate() { if index == entry.depth - 1 { let cursor = if selected { ">" } else { " " }; - let icon = entry.icon(); - let style = entry.style(); - let label = entry.label(icon, &fragment.display()); - to.blit(&label, area.x(), y, style); + let label = self.0.columns.row(&entry); + to.blit(&label, area.x(), y, entry.style()); if selected { let fill = [area.x(), y, area.w(), 1]; to.fill_fg(fill, Color::Rgb(0, 0, 0)); @@ -63,6 +103,12 @@ impl<'a> Content for TreeTable<'a> { } impl Entry { + pub fn name (&self) -> Option> { + let indent = "".pad_to_width((self.depth - 1) * 2); + let icon = self.icon(); + let name = self.path.iter().last().expect("empty path").display(); + Some(format!("{indent}{icon} {name}").into()) + } fn icon (&self) -> &'static str { if self.is_dir() { "" //"+" @@ -81,34 +127,6 @@ impl Entry { Some(Style::default().bold()) } } - fn label (&self, icon: &str, name: &impl Display) -> String { - let indent = "".pad_to_width((self.depth - 1) * 2); - table_row( - self.hash(), - &format!("{indent}{icon} {name}"), - self.artist(), - self.album(), - "", - self.title() - ) - } -} - -fn table_row ( - hash: Option>, - label: &str, - artist: Option>, - album: Option>, - track: &str, - title: Option>, -) -> String { - let hash = hash.unwrap_or_default().pad_to_width(COLUMN_WIDTHS[0] as usize); - let label = label.pad_to_width(COLUMN_WIDTHS[1] as usize); - let artist = artist.unwrap_or_default().pad_to_width(COLUMN_WIDTHS[2] as usize); - let album = album.unwrap_or_default().pad_to_width(COLUMN_WIDTHS[3] as usize); - let track = track.pad_to_width(COLUMN_WIDTHS[4] as usize); - let title = title.unwrap_or_default().pad_to_width(COLUMN_WIDTHS[5] as usize); - format!("{hash}│{label}│{artist}╎{album}╎{track}╎{title}") } fn status_bar (content: impl Content) -> impl Content {