From 552caff9b880c9b7a81d739b00bb5057ff6b1fd7 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Thu, 5 Mar 2026 13:33:05 -0500 Subject: [PATCH] Fix to tui and multi-artist --- src/main.rs | 20 ++++---- src/tui.rs | 145 +++++++++++++++++++++++++++++++++------------------- 2 files changed, 102 insertions(+), 63 deletions(-) diff --git a/src/main.rs b/src/main.rs index 43ffd80..b64916b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -297,10 +297,11 @@ fn cmd_build(opts: BuildOptions, artist_args: Vec) { } let seeds: Vec<(String, String)> = if artist_args.is_empty() { - match tui::run_artist_picker(&all_artists) { - Some(selection) => vec![selection], - None => std::process::exit(0), + let picked = tui::run_artist_picker(&all_artists); + if picked.is_empty() { + std::process::exit(0); } + picked } else { artist_args .iter() @@ -327,14 +328,14 @@ fn build_playlist( seeds: &[(String, String)], opts: &BuildOptions, ) { - // Merge similar artists from all seeds: mbid → (name, total_score, count) - let mut merged: HashMap = HashMap::new(); + // Merge similar artists from all seeds: mbid → (name, total_score) + let mut merged: HashMap = HashMap::new(); + let num_seeds = seeds.len() as f64; for (seed_mbid, seed_name) in seeds { // 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.2 += 1; let similar = match db::get_available_similar_artists(conn, seed_mbid) { Ok(a) => a, @@ -345,15 +346,14 @@ fn build_playlist( }; 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.2 += 1; } } let artists: Vec<(String, String, f64)> = merged .into_iter() - .map(|(mbid, (name, total, count))| (mbid, name, total / count as f64)) + .map(|(mbid, (name, total))| (mbid, name, total / num_seeds)) .collect(); let scored = playlist::score_tracks(conn, &artists, opts.popularity_bias); diff --git a/src/tui.rs b/src/tui.rs index 8be07fa..d5e48d6 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::io::{self, Write}; use crossterm::{ @@ -23,67 +24,80 @@ pub fn fuzzy_match(query: &str, name: &str) -> bool { 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(); - terminal::enable_raw_mode().ok()?; - execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide).ok()?; + if terminal::enable_raw_mode().is_err() { + return Vec::new(); + } + let _ = execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide); let result = picker_loop(&mut stdout, artists); - execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen).ok(); - terminal::disable_raw_mode().ok(); + let _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen); + let _ = terminal::disable_raw_mode(); 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 selected: usize = 0; + let mut cursor: usize = 0; let mut scroll: usize = 0; + let mut toggled: HashSet = HashSet::new(); // indices into `artists` loop { - let (w, h) = terminal::size().ok()?; - let w = w as usize; - let h = h as usize; + let Some((w, h)) = terminal::size().ok().map(|(w, h)| (w as usize, h as usize)) else { + return Vec::new(); + }; if h < 2 { continue; } let q = query.to_lowercase(); - let filtered: Vec<&(String, String)> = if q.is_empty() { - artists.iter().collect() + // Each entry is (original index, &(mbid, name)) + let filtered: Vec<(usize, &(String, String))> = if q.is_empty() { + artists.iter().enumerate().collect() } else { - artists.iter().filter(|(_, name)| fuzzy_match(&q, name)).collect() + artists + .iter() + .enumerate() + .filter(|(_, (_, name))| fuzzy_match(&q, name)) + .collect() }; if filtered.is_empty() { - selected = 0; + cursor = 0; } else { - selected = selected.min(filtered.len() - 1); + cursor = cursor.min(filtered.len() - 1); } // Scroll bounds let list_h = h - 1; - if selected < scroll { - scroll = selected; + if cursor < scroll { + scroll = cursor; } - if selected >= scroll + list_h { - scroll = selected - list_h + 1; + if cursor >= scroll + list_h { + scroll = cursor - list_h + 1; } // Draw - execute!(stdout, terminal::Clear(ClearType::All)).ok(); + let _ = execute!(stdout, terminal::Clear(ClearType::All)); // Prompt line 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()); - execute!( + let _ = execute!( stdout, cursor::MoveTo(0, 0), style::PrintStyledContent(prompt.as_str().cyan().bold()), + style::PrintStyledContent(sel_str.as_str().yellow()), style::PrintStyledContent(count_str.as_str().dark_grey()), - ) - .ok(); + ); // Artist list 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() { break; } - let (_, name) = filtered[idx]; - let display: String = if name.len() >= w { - name[..w].to_string() + let (orig_idx, (_, name)) = filtered[idx]; + let marker = if toggled.contains(&orig_idx) { "*" } else { " " }; + let display: String = if name.len() + 2 >= w { + format!("{marker}{}", &name[..w.saturating_sub(2)]) } else { - format!(" {name}") + format!("{marker} {name}") }; - execute!(stdout, cursor::MoveTo(0, (i + 1) as u16)).ok(); - if idx == selected { - execute!( + let _ = execute!(stdout, cursor::MoveTo(0, (i + 1) as u16)); + if idx == cursor { + let _ = execute!( stdout, 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 { - execute!(stdout, style::Print(&display)).ok(); + let _ = execute!(stdout, style::Print(&display)); } } - stdout.flush().ok(); + let _ = stdout.flush(); // Input - let Event::Key(KeyEvent { code, modifiers, .. }) = event::read().ok()? else { + let Ok(Event::Key(KeyEvent { code, modifiers, .. })) = event::read() else { continue; }; match code { - KeyCode::Esc => return None, + KeyCode::Esc => return Vec::new(), KeyCode::Enter => { if !filtered.is_empty() { - let (mbid, name) = filtered[selected]; - return Some((mbid.clone(), name.clone())); - } - } - KeyCode::Up => { - selected = selected.saturating_sub(1); - } - KeyCode::Down => { - if !filtered.is_empty() { - selected = selected.min(filtered.len().saturating_sub(1)); - if selected + 1 < filtered.len() { - selected += 1; + if toggled.is_empty() { + // No toggles — return just the highlighted item + let (_, (mbid, name)) = filtered[cursor]; + return vec![(mbid.clone(), name.clone())]; + } else { + // Return all toggled items in original order + let mut indices: Vec = toggled.into_iter().collect(); + indices.sort(); + return indices + .into_iter() + .map(|i| artists[i].clone()) + .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) => { - selected = selected.saturating_sub(1); + cursor = cursor.saturating_sub(1); } KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => { - if !filtered.is_empty() && selected + 1 < filtered.len() { - selected += 1; + if !filtered.is_empty() && cursor + 1 < filtered.len() { + cursor += 1; } } KeyCode::Backspace => { query.pop(); - selected = 0; + cursor = 0; scroll = 0; } KeyCode::Char(ch) => { query.push(ch); - selected = 0; + cursor = 0; scroll = 0; } _ => {}