Provide search functionality through the TUI #134

Merged
wojtek merged 35 commits from 24---provide-search-functionality-through-the-tui into main 2024-02-18 22:12:42 +01:00
2 changed files with 212 additions and 186 deletions
Showing only changes of commit 47bae4957d - Show all commits

View File

@ -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));
}
}

View File

@ -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)));