Provide search functionality through the TUI #134
@ -48,7 +48,6 @@ pub struct TrackSelection {
|
||||
pub state: WidgetState,
|
||||
}
|
||||
|
||||
// FIXME: should be with browse state (maybe?)
|
||||
pub enum Delta {
|
||||
Line,
|
||||
Page,
|
||||
@ -85,6 +84,10 @@ impl Selection {
|
||||
self.artist.select(artists, index);
|
||||
}
|
||||
|
||||
pub fn selected_artist(&self) -> Option<usize> {
|
||||
self.artist.selected()
|
||||
}
|
||||
|
||||
pub fn reset_artist(&mut self, artists: &[Artist]) {
|
||||
if self.artist.state.list.selected() != Some(0) {
|
||||
self.select_by_id(artists, IdSelection { artist: None });
|
||||
@ -107,16 +110,6 @@ impl Selection {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn incremental_artist_search(
|
||||
&mut self,
|
||||
collection: &Collection,
|
||||
artist_name: &str,
|
||||
next: bool,
|
||||
) -> Option<usize> {
|
||||
self.artist
|
||||
.incremental_search(collection, artist_name, next)
|
||||
}
|
||||
|
||||
pub fn increment_selection(&mut self, collection: &Collection, delta: Delta) {
|
||||
match self.active {
|
||||
Category::Artist => self.increment_artist(collection, delta),
|
||||
@ -200,6 +193,10 @@ impl ArtistSelection {
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(&self) -> Option<usize> {
|
||||
self.state.list.selected()
|
||||
}
|
||||
|
||||
fn select(&mut self, artists: &[Artist], to: Option<usize>) {
|
||||
match to {
|
||||
Some(to) => self.select_to(artists, to),
|
||||
@ -215,91 +212,6 @@ impl ArtistSelection {
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: use aho_corasick for normalization - AhoCorasick does not implement PartialEq. It
|
||||
// makes more sense to be places in app.rs as it would make ArtistSelection non-trivial.
|
||||
fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String {
|
||||
let normalized = if lowercase {
|
||||
search.to_lowercase()
|
||||
} else {
|
||||
search.to_owned()
|
||||
};
|
||||
|
||||
// Unlikely that this covers all possible strings, but it should at least cover strings
|
||||
// relevant for music (at least in English). The list of characters handled is based on
|
||||
// https://wiki.musicbrainz.org/User:Yurim/Punctuation_and_Special_Characters.
|
||||
if asciify {
|
||||
normalized
|
||||
// U+2010 hyphen, U+2012 figure dash, U+2013 en dash, U+2014 em dash,
|
||||
// U+2015 horizontal bar
|
||||
.replace(['‐', '‒', '–', '—', '―'], "-")
|
||||
.replace(['‘', '’'], "'") // U+2018, U+2019
|
||||
.replace(['“', '”'], "\"") // U+201C, U+201D
|
||||
.replace('…', "...") // U+2026
|
||||
.replace('−', "-") // U+2212 minus sign
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn is_case_sensitive(artist_name: &str) -> bool {
|
||||
artist_name
|
||||
.chars()
|
||||
.any(|ch| ch.is_alphabetic() && ch.is_uppercase())
|
||||
}
|
||||
|
||||
fn is_char_sensitive(artist_name: &str) -> bool {
|
||||
let special_chars: &[char] = &['‐', '‒', '–', '—', '―', '‘', '’', '“', '”', '…', '−'];
|
||||
artist_name.chars().any(|ch| special_chars.contains(&ch))
|
||||
}
|
||||
|
||||
fn incremental_search_predicate(
|
||||
case_sensitive: bool,
|
||||
char_sensitive: bool,
|
||||
search_name: &String,
|
||||
probe: &Artist,
|
||||
) -> bool {
|
||||
let name = Self::normalize_search(&probe.id.name, !case_sensitive, !char_sensitive);
|
||||
let mut result = name.starts_with(search_name);
|
||||
|
||||
if let Some(ref probe_sort) = probe.sort {
|
||||
let name = Self::normalize_search(&probe_sort.name, !case_sensitive, !char_sensitive);
|
||||
result = result || name.starts_with(search_name);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// FIXME: search logic should be with the search state
|
||||
fn incremental_search(
|
||||
&mut self,
|
||||
artists: &[Artist],
|
||||
artist_name: &str,
|
||||
next: bool,
|
||||
) -> Option<usize> {
|
||||
let previous = self.state.list.selected();
|
||||
|
||||
if let Some(mut index) = self.state.list.selected() {
|
||||
let case_sensitive = Self::is_case_sensitive(artist_name);
|
||||
let char_sensitive = Self::is_char_sensitive(artist_name);
|
||||
let search = Self::normalize_search(artist_name, !case_sensitive, !char_sensitive);
|
||||
|
||||
if next && ((index + 1) < artists.len()) {
|
||||
index += 1;
|
||||
}
|
||||
let slice = &artists[index..];
|
||||
|
||||
let result = slice.iter().position(|probe| {
|
||||
Self::incremental_search_predicate(case_sensitive, char_sensitive, &search, probe)
|
||||
});
|
||||
|
||||
if let Some(slice_index) = result {
|
||||
self.select_to(artists, index + slice_index);
|
||||
}
|
||||
}
|
||||
|
||||
previous
|
||||
}
|
||||
|
||||
fn increment_by(&mut self, artists: &[Artist], by: usize) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
let result = index.saturating_add(by);
|
||||
@ -904,83 +816,4 @@ mod tests {
|
||||
sel.reinitialise(&[], active_artist);
|
||||
assert_eq!(sel, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_incremental_search() {
|
||||
let artists = &COLLECTION;
|
||||
|
||||
// Empty collection.
|
||||
let mut sel = ArtistSelection::initialise(&[]);
|
||||
assert_eq!(sel.state.list.selected(), None);
|
||||
|
||||
sel.incremental_search(artists, "album_artist 'a'", false);
|
||||
assert_eq!(sel.state.list.selected(), None);
|
||||
|
||||
// Basic test, first element.
|
||||
let mut sel = ArtistSelection::initialise(artists);
|
||||
assert_eq!(sel.state.list.selected(), Some(0));
|
||||
|
||||
sel.incremental_search(artists, "", false);
|
||||
assert_eq!(sel.state.list.selected(), Some(0));
|
||||
|
||||
sel.incremental_search(artists, "album_artist ", false);
|
||||
assert_eq!(sel.state.list.selected(), Some(0));
|
||||
|
||||
sel.incremental_search(artists, "album_artist 'a'", false);
|
||||
assert_eq!(sel.state.list.selected(), Some(0));
|
||||
|
||||
// Basic test, non-first element.
|
||||
sel.reinitialise(artists, None);
|
||||
|
||||
sel.incremental_search(artists, "album_artist ", false);
|
||||
assert_eq!(sel.state.list.selected(), Some(0));
|
||||
|
||||
sel.incremental_search(artists, "album_artist 'c'", false);
|
||||
assert_eq!(sel.state.list.selected(), Some(2));
|
||||
|
||||
// Non-lowercase.
|
||||
sel.reinitialise(artists, None);
|
||||
|
||||
sel.incremental_search(artists, "Album_Artist ", false);
|
||||
assert_eq!(sel.state.list.selected(), Some(0));
|
||||
|
||||
sel.incremental_search(artists, "Album_Artist 'C'", false);
|
||||
assert_eq!(sel.state.list.selected(), Some(2));
|
||||
|
||||
// Non-ascii.
|
||||
sel.reinitialise(artists, None);
|
||||
|
||||
sel.incremental_search(artists, "album_artist ", false);
|
||||
assert_eq!(sel.state.list.selected(), Some(0));
|
||||
|
||||
sel.incremental_search(artists, "album_artist ‘c’", false);
|
||||
assert_eq!(sel.state.list.selected(), Some(2));
|
||||
|
||||
// Stop at name, not sort name.
|
||||
sel.reinitialise(artists, None);
|
||||
|
||||
sel.incremental_search(artists, "the", false);
|
||||
assert_eq!(sel.state.list.selected(), Some(2));
|
||||
|
||||
sel.incremental_search(artists, "the album_artist 'c'", false);
|
||||
assert_eq!(sel.state.list.selected(), Some(2));
|
||||
|
||||
// Search next with common prefix.
|
||||
sel.reinitialise(artists, None);
|
||||
|
||||
sel.incremental_search(artists, "album_artist ", false);
|
||||
assert_eq!(sel.state.list.selected(), Some(0));
|
||||
|
||||
sel.incremental_search(artists, "album_artist ", true);
|
||||
assert_eq!(sel.state.list.selected(), Some(1));
|
||||
|
||||
sel.incremental_search(artists, "album_artist ", true);
|
||||
assert_eq!(sel.state.list.selected(), Some(2));
|
||||
|
||||
sel.incremental_search(artists, "album_artist ", true);
|
||||
assert_eq!(sel.state.list.selected(), Some(3));
|
||||
|
||||
sel.incremental_search(artists, "album_artist ", true);
|
||||
assert_eq!(sel.state.list.selected(), Some(3));
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
use musichoard::collection::artist::Artist;
|
||||
|
||||
use crate::tui::{
|
||||
app::{
|
||||
app::App,
|
||||
@ -51,25 +53,18 @@ impl<MH: IMusicHoard> IAppInteractSearch for AppMachine<MH, AppSearch> {
|
||||
type APP = App<MH>;
|
||||
|
||||
fn append_character(mut self, ch: char) -> Self::APP {
|
||||
let collection = self.inner.music_hoard.get_collection();
|
||||
self.state.string.push(ch);
|
||||
let index =
|
||||
self.inner
|
||||
.selection
|
||||
.incremental_artist_search(collection, &self.state.string, false);
|
||||
let index = self.inner.selection.artist.state.list.selected();
|
||||
self.state.memo.push(AppSearchMemo { index, char: true });
|
||||
self.incremental_search(false);
|
||||
self.into()
|
||||
}
|
||||
|
||||
fn search_next(mut self) -> Self::APP {
|
||||
let collection = self.inner.music_hoard.get_collection();
|
||||
if !self.state.string.is_empty() {
|
||||
let index = self.inner.selection.incremental_artist_search(
|
||||
collection,
|
||||
&self.state.string,
|
||||
true,
|
||||
);
|
||||
let index = self.inner.selection.artist.state.list.selected();
|
||||
self.state.memo.push(AppSearchMemo { index, char: false });
|
||||
self.incremental_search(true);
|
||||
}
|
||||
self.into()
|
||||
}
|
||||
@ -99,6 +94,103 @@ impl<MH: IMusicHoard> IAppInteractSearch for AppMachine<MH, AppSearch> {
|
||||
}
|
||||
}
|
||||
|
||||
trait IAppInteractSearchPrivate {
|
||||
fn incremental_search(&mut self, next: bool);
|
||||
fn incremental_search_predicate(
|
||||
case_sensitive: bool,
|
||||
char_sensitive: bool,
|
||||
search_name: &String,
|
||||
probe: &Artist,
|
||||
) -> bool;
|
||||
|
||||
fn is_case_sensitive(artist_name: &str) -> bool;
|
||||
fn is_char_sensitive(artist_name: &str) -> bool;
|
||||
fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String;
|
||||
}
|
||||
|
||||
impl<MH: IMusicHoard> IAppInteractSearchPrivate for AppMachine<MH, AppSearch> {
|
||||
fn incremental_search(&mut self, next: bool) {
|
||||
let artists = self.inner.music_hoard.get_collection();
|
||||
let artist_name = &self.state.string;
|
||||
|
||||
let sel = &mut self.inner.selection;
|
||||
if let Some(mut index) = sel.selected_artist() {
|
||||
let case_sensitive = Self::is_case_sensitive(artist_name);
|
||||
let char_sensitive = Self::is_char_sensitive(artist_name);
|
||||
let search = Self::normalize_search(artist_name, !case_sensitive, !char_sensitive);
|
||||
|
||||
if next && ((index + 1) < artists.len()) {
|
||||
index += 1;
|
||||
}
|
||||
let slice = &artists[index..];
|
||||
|
||||
let result = slice.iter().position(|probe| {
|
||||
Self::incremental_search_predicate(case_sensitive, char_sensitive, &search, probe)
|
||||
});
|
||||
|
||||
if let Some(slice_index) = result {
|
||||
sel.select_artist(artists, Some(index + slice_index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn incremental_search_predicate(
|
||||
case_sensitive: bool,
|
||||
char_sensitive: bool,
|
||||
search_name: &String,
|
||||
probe: &Artist,
|
||||
) -> bool {
|
||||
let name = Self::normalize_search(&probe.id.name, !case_sensitive, !char_sensitive);
|
||||
let mut result = name.starts_with(search_name);
|
||||
|
||||
if let Some(ref probe_sort) = probe.sort {
|
||||
if !result {
|
||||
let name =
|
||||
Self::normalize_search(&probe_sort.name, !case_sensitive, !char_sensitive);
|
||||
result = name.starts_with(search_name);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn is_case_sensitive(artist_name: &str) -> bool {
|
||||
artist_name
|
||||
.chars()
|
||||
.any(|ch| ch.is_alphabetic() && ch.is_uppercase())
|
||||
}
|
||||
|
||||
fn is_char_sensitive(artist_name: &str) -> bool {
|
||||
let special_chars: &[char] = &['‐', '‒', '–', '—', '―', '−', '‘', '’', '“', '”', '…'];
|
||||
artist_name.chars().any(|ch| special_chars.contains(&ch))
|
||||
}
|
||||
|
||||
// FIXME: use aho_corasick for normalization - AhoCorasick does not implement PartialEq. It
|
||||
// makes more sense to be places in app.rs as it would make ArtistSelection non-trivial.
|
||||
fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String {
|
||||
let normalized = if lowercase {
|
||||
search.to_lowercase()
|
||||
} else {
|
||||
search.to_owned()
|
||||
};
|
||||
|
||||
// Unlikely that this covers all possible strings, but it should at least cover strings
|
||||
// relevant for music (at least in English). The list of characters handled is based on
|
||||
// https://wiki.musicbrainz.org/User:Yurim/Punctuation_and_Special_Characters.
|
||||
if asciify {
|
||||
normalized
|
||||
// U+2010 hyphen, U+2012 figure dash, U+2013 en dash, U+2014 em dash,
|
||||
// U+2015 horizontal bar, U+2212 minus sign
|
||||
.replace(['‐', '‒', '–', '—', '―', '−'], "-")
|
||||
.replace(['‘', '’'], "'") // U+2018, U+2019
|
||||
.replace(['“', '”'], "\"") // U+201C, U+201D
|
||||
.replace('…', "...") // U+2026
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ratatui::widgets::ListState;
|
||||
@ -121,6 +213,107 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_incremental_search() {
|
||||
// Empty collection.
|
||||
let mut search = AppMachine::search(inner(music_hoard(vec![])), orig(None));
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), None);
|
||||
|
||||
search.state.string = String::from("album_artist 'a'");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), None);
|
||||
|
||||
// Basic test, first element.
|
||||
let mut search =
|
||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist ");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist 'a'");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
// Basic test, non-first element.
|
||||
let mut search =
|
||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist ");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist 'c'");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
||||
|
||||
// Non-lowercase.
|
||||
let mut search =
|
||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("Album_Artist ");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("Album_Artist 'C'");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
||||
|
||||
// Non-ascii.
|
||||
let mut search =
|
||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist ");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist ‘c’");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
||||
|
||||
// Stop at name, not sort name.
|
||||
let mut search =
|
||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("the ");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
||||
|
||||
search.state.string = String::from("the album_artist 'c'");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
||||
|
||||
// Search next with common prefix.
|
||||
let mut search =
|
||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
||||
|
||||
search.incremental_search(true);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(1));
|
||||
|
||||
search.incremental_search(true);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
||||
|
||||
search.incremental_search(true);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(3));
|
||||
|
||||
search.incremental_search(true);
|
||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search() {
|
||||
let search = AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
|
||||
|
Loading…
Reference in New Issue
Block a user