Fix to tui and multi-artist
This commit is contained in:
20
src/main.rs
20
src/main.rs
@@ -297,10 +297,11 @@ fn cmd_build(opts: BuildOptions, artist_args: Vec<String>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let seeds: Vec<(String, String)> = if artist_args.is_empty() {
|
let seeds: Vec<(String, String)> = if artist_args.is_empty() {
|
||||||
match tui::run_artist_picker(&all_artists) {
|
let picked = tui::run_artist_picker(&all_artists);
|
||||||
Some(selection) => vec![selection],
|
if picked.is_empty() {
|
||||||
None => std::process::exit(0),
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
picked
|
||||||
} else {
|
} else {
|
||||||
artist_args
|
artist_args
|
||||||
.iter()
|
.iter()
|
||||||
@@ -327,14 +328,14 @@ fn build_playlist(
|
|||||||
seeds: &[(String, String)],
|
seeds: &[(String, String)],
|
||||||
opts: &BuildOptions,
|
opts: &BuildOptions,
|
||||||
) {
|
) {
|
||||||
// Merge similar artists from all seeds: mbid → (name, total_score, count)
|
// Merge similar artists from all seeds: mbid → (name, total_score)
|
||||||
let mut merged: HashMap<String, (String, f64, usize)> = HashMap::new();
|
let mut merged: HashMap<String, (String, f64)> = HashMap::new();
|
||||||
|
let num_seeds = seeds.len() as f64;
|
||||||
|
|
||||||
for (seed_mbid, seed_name) in seeds {
|
for (seed_mbid, seed_name) in seeds {
|
||||||
// Insert the seed itself with score 1.0
|
// Insert the seed itself with score 1.0
|
||||||
let entry = merged.entry(seed_mbid.clone()).or_insert_with(|| (seed_name.clone(), 0.0, 0));
|
let entry = merged.entry(seed_mbid.clone()).or_insert_with(|| (seed_name.clone(), 0.0));
|
||||||
entry.1 += 1.0;
|
entry.1 += 1.0;
|
||||||
entry.2 += 1;
|
|
||||||
|
|
||||||
let similar = match db::get_available_similar_artists(conn, seed_mbid) {
|
let similar = match db::get_available_similar_artists(conn, seed_mbid) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
@@ -345,15 +346,14 @@ fn build_playlist(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (mbid, name, score) in similar {
|
for (mbid, name, score) in similar {
|
||||||
let entry = merged.entry(mbid).or_insert_with(|| (name, 0.0, 0));
|
let entry = merged.entry(mbid).or_insert_with(|| (name, 0.0));
|
||||||
entry.1 += score;
|
entry.1 += score;
|
||||||
entry.2 += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let artists: Vec<(String, String, f64)> = merged
|
let artists: Vec<(String, String, f64)> = merged
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(mbid, (name, total, count))| (mbid, name, total / count as f64))
|
.map(|(mbid, (name, total))| (mbid, name, total / num_seeds))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let scored = playlist::score_tracks(conn, &artists, opts.popularity_bias);
|
let scored = playlist::score_tracks(conn, &artists, opts.popularity_bias);
|
||||||
|
|||||||
145
src/tui.rs
145
src/tui.rs
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
@@ -23,67 +24,80 @@ pub fn fuzzy_match(query: &str, name: &str) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_artist_picker(artists: &[(String, String)]) -> Option<(String, String)> {
|
pub fn run_artist_picker(artists: &[(String, String)]) -> Vec<(String, String)> {
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
terminal::enable_raw_mode().ok()?;
|
if terminal::enable_raw_mode().is_err() {
|
||||||
execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide).ok()?;
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let _ = execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide);
|
||||||
|
|
||||||
let result = picker_loop(&mut stdout, artists);
|
let result = picker_loop(&mut stdout, artists);
|
||||||
|
|
||||||
execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen).ok();
|
let _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen);
|
||||||
terminal::disable_raw_mode().ok();
|
let _ = terminal::disable_raw_mode();
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn picker_loop(stdout: &mut io::Stdout, artists: &[(String, String)]) -> Option<(String, String)> {
|
fn picker_loop(stdout: &mut io::Stdout, artists: &[(String, String)]) -> Vec<(String, String)> {
|
||||||
let mut query = String::new();
|
let mut query = String::new();
|
||||||
let mut selected: usize = 0;
|
let mut cursor: usize = 0;
|
||||||
let mut scroll: usize = 0;
|
let mut scroll: usize = 0;
|
||||||
|
let mut toggled: HashSet<usize> = HashSet::new(); // indices into `artists`
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (w, h) = terminal::size().ok()?;
|
let Some((w, h)) = terminal::size().ok().map(|(w, h)| (w as usize, h as usize)) else {
|
||||||
let w = w as usize;
|
return Vec::new();
|
||||||
let h = h as usize;
|
};
|
||||||
if h < 2 {
|
if h < 2 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let q = query.to_lowercase();
|
let q = query.to_lowercase();
|
||||||
let filtered: Vec<&(String, String)> = if q.is_empty() {
|
// Each entry is (original index, &(mbid, name))
|
||||||
artists.iter().collect()
|
let filtered: Vec<(usize, &(String, String))> = if q.is_empty() {
|
||||||
|
artists.iter().enumerate().collect()
|
||||||
} else {
|
} else {
|
||||||
artists.iter().filter(|(_, name)| fuzzy_match(&q, name)).collect()
|
artists
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, (_, name))| fuzzy_match(&q, name))
|
||||||
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
if filtered.is_empty() {
|
if filtered.is_empty() {
|
||||||
selected = 0;
|
cursor = 0;
|
||||||
} else {
|
} else {
|
||||||
selected = selected.min(filtered.len() - 1);
|
cursor = cursor.min(filtered.len() - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll bounds
|
// Scroll bounds
|
||||||
let list_h = h - 1;
|
let list_h = h - 1;
|
||||||
if selected < scroll {
|
if cursor < scroll {
|
||||||
scroll = selected;
|
scroll = cursor;
|
||||||
}
|
}
|
||||||
if selected >= scroll + list_h {
|
if cursor >= scroll + list_h {
|
||||||
scroll = selected - list_h + 1;
|
scroll = cursor - list_h + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw
|
// Draw
|
||||||
execute!(stdout, terminal::Clear(ClearType::All)).ok();
|
let _ = execute!(stdout, terminal::Clear(ClearType::All));
|
||||||
|
|
||||||
// Prompt line
|
// Prompt line
|
||||||
let prompt = format!(" > {query}");
|
let prompt = format!(" > {query}");
|
||||||
|
let sel_str = if toggled.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" [{}]", toggled.len())
|
||||||
|
};
|
||||||
let count_str = format!(" {}/{}", filtered.len(), artists.len());
|
let count_str = format!(" {}/{}", filtered.len(), artists.len());
|
||||||
execute!(
|
let _ = execute!(
|
||||||
stdout,
|
stdout,
|
||||||
cursor::MoveTo(0, 0),
|
cursor::MoveTo(0, 0),
|
||||||
style::PrintStyledContent(prompt.as_str().cyan().bold()),
|
style::PrintStyledContent(prompt.as_str().cyan().bold()),
|
||||||
|
style::PrintStyledContent(sel_str.as_str().yellow()),
|
||||||
style::PrintStyledContent(count_str.as_str().dark_grey()),
|
style::PrintStyledContent(count_str.as_str().dark_grey()),
|
||||||
)
|
);
|
||||||
.ok();
|
|
||||||
|
|
||||||
// Artist list
|
// Artist list
|
||||||
for i in 0..list_h {
|
for i in 0..list_h {
|
||||||
@@ -91,66 +105,91 @@ fn picker_loop(stdout: &mut io::Stdout, artists: &[(String, String)]) -> Option<
|
|||||||
if idx >= filtered.len() {
|
if idx >= filtered.len() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let (_, name) = filtered[idx];
|
let (orig_idx, (_, name)) = filtered[idx];
|
||||||
let display: String = if name.len() >= w {
|
let marker = if toggled.contains(&orig_idx) { "*" } else { " " };
|
||||||
name[..w].to_string()
|
let display: String = if name.len() + 2 >= w {
|
||||||
|
format!("{marker}{}", &name[..w.saturating_sub(2)])
|
||||||
} else {
|
} else {
|
||||||
format!(" {name}")
|
format!("{marker} {name}")
|
||||||
};
|
};
|
||||||
execute!(stdout, cursor::MoveTo(0, (i + 1) as u16)).ok();
|
let _ = execute!(stdout, cursor::MoveTo(0, (i + 1) as u16));
|
||||||
if idx == selected {
|
if idx == cursor {
|
||||||
execute!(
|
let _ = execute!(
|
||||||
stdout,
|
stdout,
|
||||||
style::PrintStyledContent(display.as_str().black().on_cyan()),
|
style::PrintStyledContent(display.as_str().black().on_cyan()),
|
||||||
)
|
);
|
||||||
.ok();
|
} else if toggled.contains(&orig_idx) {
|
||||||
|
let _ = execute!(
|
||||||
|
stdout,
|
||||||
|
style::PrintStyledContent(display.as_str().cyan()),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
execute!(stdout, style::Print(&display)).ok();
|
let _ = execute!(stdout, style::Print(&display));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout.flush().ok();
|
let _ = stdout.flush();
|
||||||
|
|
||||||
// Input
|
// Input
|
||||||
let Event::Key(KeyEvent { code, modifiers, .. }) = event::read().ok()? else {
|
let Ok(Event::Key(KeyEvent { code, modifiers, .. })) = event::read() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
match code {
|
match code {
|
||||||
KeyCode::Esc => return None,
|
KeyCode::Esc => return Vec::new(),
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if !filtered.is_empty() {
|
if !filtered.is_empty() {
|
||||||
let (mbid, name) = filtered[selected];
|
if toggled.is_empty() {
|
||||||
return Some((mbid.clone(), name.clone()));
|
// No toggles — return just the highlighted item
|
||||||
}
|
let (_, (mbid, name)) = filtered[cursor];
|
||||||
}
|
return vec![(mbid.clone(), name.clone())];
|
||||||
KeyCode::Up => {
|
} else {
|
||||||
selected = selected.saturating_sub(1);
|
// Return all toggled items in original order
|
||||||
}
|
let mut indices: Vec<usize> = toggled.into_iter().collect();
|
||||||
KeyCode::Down => {
|
indices.sort();
|
||||||
if !filtered.is_empty() {
|
return indices
|
||||||
selected = selected.min(filtered.len().saturating_sub(1));
|
.into_iter()
|
||||||
if selected + 1 < filtered.len() {
|
.map(|i| artists[i].clone())
|
||||||
selected += 1;
|
.collect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
KeyCode::Tab => {
|
||||||
|
if !filtered.is_empty() {
|
||||||
|
let (orig_idx, _) = filtered[cursor];
|
||||||
|
if !toggled.remove(&orig_idx) {
|
||||||
|
toggled.insert(orig_idx);
|
||||||
|
}
|
||||||
|
// Advance cursor
|
||||||
|
if cursor + 1 < filtered.len() {
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
cursor = cursor.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if !filtered.is_empty() && cursor + 1 < filtered.len() {
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Char('p') if modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('p') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
selected = selected.saturating_sub(1);
|
cursor = cursor.saturating_sub(1);
|
||||||
}
|
}
|
||||||
KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
if !filtered.is_empty() && selected + 1 < filtered.len() {
|
if !filtered.is_empty() && cursor + 1 < filtered.len() {
|
||||||
selected += 1;
|
cursor += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
query.pop();
|
query.pop();
|
||||||
selected = 0;
|
cursor = 0;
|
||||||
scroll = 0;
|
scroll = 0;
|
||||||
}
|
}
|
||||||
KeyCode::Char(ch) => {
|
KeyCode::Char(ch) => {
|
||||||
query.push(ch);
|
query.push(ch);
|
||||||
selected = 0;
|
cursor = 0;
|
||||||
scroll = 0;
|
scroll = 0;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
Reference in New Issue
Block a user