From 77a5cfd59b23d66cf643a1247f560ecc54ae0222 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:11:54 -0500 Subject: [PATCH 1/2] fix(locallibrary): guard the player's capitals against quitting as their lowercase keys `Q` quit the player in terminals that report keys by their codes (CSI u, xterm's modifyOtherKeys, tmux extended-keys): they send it as `q` with shift, and the keymap took that for `q`. The player quit cleanly, which is why no panic text ever showed. #306 fixed it (ca71e8981), and on a release build `Q` sent each way now toggles quantize. This adds the regression test that would have caught it: a property over the deck's and the library's capitals, typed while a track plays, that any run of them leaves the same screen whether each comes as the capital or as its code, and that none of them quits. Fixes LOC-11 --- apps/locallibrary/src/tui/deck/tests.rs | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/locallibrary/src/tui/deck/tests.rs b/apps/locallibrary/src/tui/deck/tests.rs index 47302a10b..b2df31dc4 100644 --- a/apps/locallibrary/src/tui/deck/tests.rs +++ b/apps/locallibrary/src/tui/deck/tests.rs @@ -563,6 +563,33 @@ fn a_shifted_q_sent_as_its_code_quantizes_rather_than_quits() { assert!(app.quit); } +/// The deck's and the library's capitals, typed while a track plays, do the same sent either +/// way: as the capital, or as a terminal reporting keys by their codes sends them, the lowercase +/// letter with shift. Sent that way, `Q` once quit the player (LOC-11). Capitals that start work +/// in the background (`R`, `T`, `O`) are left out, as it ends when it ends. +#[hegel::test(test_cases = 50)] +fn prop_a_shifted_letter_sent_as_its_code_is_its_capital(tc: hegel::TestCase) { + use super::super::ui::screen; + use hegel::generators as gs; + use ratatui::crossterm::event::KeyModifiers; + let typed: Vec = "QSACNPGMBUXJKZ".chars().collect(); + let letters: Vec = tc.draw(gs::vecs(gs::sampled_from(&typed[..])).max_size(6)); + let (mut capitals, a) = playing(TrackAnalysis::default()); + let (mut codes, b) = playing(TrackAnalysis::default()); + // Each drive is a temporary folder of its own name. + let shown = |app: &mut App, dir: &tempfile::TempDir| { + screen(app, 120, 40).replace(dir.path().file_name().unwrap().to_str().unwrap(), "DRIVE") + }; + for c in letters { + capitals.on_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::SHIFT)); + codes.on_key(KeyEvent::new(KeyCode::Char(c.to_ascii_lowercase()), KeyModifiers::SHIFT)); + assert!(!codes.quit, "{c} quit"); + let (by_code, by_capital) = (shown(&mut codes, &b), shown(&mut capitals, &a)); + let differ: Vec<_> = by_code.lines().zip(by_capital.lines()).filter(|(x, y)| x != y).collect(); + assert!(differ.is_empty(), "after {c}: {differ:#?}"); + } +} + /// A beat grid as a damaged export can hold one: empty or one beat, out of order, beats at the /// same time or at the end of time, a tempo of 0 or barely more, beat numbers past 4. #[hegel::test_helper] From a0994a5db3cddccdfa7121de3efbbe37bccd33ce Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:11:56 -0500 Subject: [PATCH 2/2] fix(locallibrary): let each way of mixing in show in the mixes-into panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The player's "mixes into" panel took the first matches `harmony::suggestions` ranks, and it ranks every same-key track before any other. The keys shown were each track's own, but in a library of any size same-key tracks filled all four to six rows, so the panel never showed a ±1 or relative match: playing an F minor track showed nothing but 4A. `Matches::find` now takes each way of mixing in (same key, one step round the wheel, relative, energy boost) in turn, closest tempo first within each, so the first rows show one of each there is. `track matches` and the sidecar keep listing best first; docs/player.md says how the two differ. Fixes LOC-13 --- .../changes/loc-11-13-player-fixes.md | 5 +++ apps/locallibrary/docs/player.md | 3 ++ apps/locallibrary/src/tui/app/loaded.rs | 17 ++++++++-- apps/locallibrary/src/tui/app/props.rs | 32 +++++++++++++++++++ apps/locallibrary/src/tui/app/testing.rs | 7 +++- apps/locallibrary/src/tui/app/tests.rs | 22 +++++++++++++ 6 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 apps/locallibrary/changes/loc-11-13-player-fixes.md diff --git a/apps/locallibrary/changes/loc-11-13-player-fixes.md b/apps/locallibrary/changes/loc-11-13-player-fixes.md new file mode 100644 index 000000000..0d6e04dc8 --- /dev/null +++ b/apps/locallibrary/changes/loc-11-13-player-fixes.md @@ -0,0 +1,5 @@ +### Fixed + +- The player's "mixes into" panel shows each way a track can mix in. Same-key tracks came first, + so in a big library its few rows were all the playing track's key; now same key, ±1, relative + and energy boost take turns, closest tempo first. diff --git a/apps/locallibrary/docs/player.md b/apps/locallibrary/docs/player.md index 295ac651e..e0ebef2d0 100644 --- a/apps/locallibrary/docs/player.md +++ b/apps/locallibrary/docs/player.md @@ -167,6 +167,9 @@ needs. - From 34 rows, what mixes into the track (compatible key on the Camelot wheel, tempo within 6% or half/double time), the playlists it's in and a summary of its analysis are under the waveforms. A track with no detail waveform gets a line saying so, and these take its rows at any height. + The matches take turns by how they mix in (same key, ±1, relative, energy boost), closest tempo + first, so the first rows show each kind there is. `locallibrary track matches` lists the same + tracks best first instead: every same-key track, then the rest. `↑` `↓` pick one of those tracks and `enter` (or a double-click) queues it to play next, marked *next* in the list. - With 9 rows or more for the top of the screen and room to spare, `v` picks what's beside the diff --git a/apps/locallibrary/src/tui/app/loaded.rs b/apps/locallibrary/src/tui/app/loaded.rs index 11de1a551..c94b0feea 100644 --- a/apps/locallibrary/src/tui/app/loaded.rs +++ b/apps/locallibrary/src/tui/app/loaded.rs @@ -281,7 +281,9 @@ impl Loaded { } /// Tracks that mix into one track (from `harmony::suggestions`), as indexes into the library they -/// were found in, which they keep. +/// were found in, which they keep. Each way of mixing in takes its turn, closest tempo first: +/// same-key tracks come first in the suggestions, and in a big library they would fill the few +/// rows the panel has, every one in the playing track's key (LOC-13). pub struct Matches { pub library: Arc, pub root: PathBuf, @@ -297,9 +299,18 @@ pub struct Mix { impl Matches { pub(in crate::tui) fn find(library: Arc, root: PathBuf, track: &Track, limit: usize) -> Matches { - let items = api::matches_in(&library, track, limit) + let mut turns = [0usize; 4]; + let mut ranked: Vec<_> = api::matches_in(&library, track, usize::MAX) .into_iter() - .filter_map(|s| { + .map(|s| { + let turn = &mut turns[s.relation as usize]; + *turn += 1; + (*turn, s) + }) + .collect(); + ranked.sort_by_key(|(turn, s)| (*turn, s.relation)); + let items = (ranked.into_iter().take(limit)) + .filter_map(|(_, s)| { let index = library.tracks.iter().position(|t| t.id == s.track.id)?; Some(Mix { index, key: s.key, relation: s.relation, tempo_gap: s.tempo_gap }) }) diff --git a/apps/locallibrary/src/tui/app/props.rs b/apps/locallibrary/src/tui/app/props.rs index 575f97572..001804303 100644 --- a/apps/locallibrary/src/tui/app/props.rs +++ b/apps/locallibrary/src/tui/app/props.rs @@ -2386,3 +2386,35 @@ fn plugins_fields_of_every_kind_show_and_sort(tc: TestCase) { } } } + +/// For any library and any track in it, the matches found are the tracks that mix into it, +/// closest tempo first within each way of mixing in, and those ways take turns: the first rows +/// hold one of each there is, so the few rows the panel has never show only the playing track's +/// key while others mix in too (LOC-13). +#[hegel::test(test_cases = 200)] +fn prop_the_ways_of_mixing_in_take_turns(tc: TestCase) { + use crate::tui::keys::Relation; + let mut tracks = any_tracks(&tc, 14); + for t in &mut tracks { + t.key = tc.draw(strings_from(&["8B", "C", "7B", "9B", "8A", "10B", "3A", ""])); + t.bpm = tc.draw(gs::sampled_from(&[0.0, 121.0, 128.0, 129.0][..])); + } + let lib = Arc::new(Library { tracks, ..Default::default() }); + let Some(playing) = lib.tracks.first().cloned() else { return }; + let limit = tc.draw(gs::integers::().min_value(1).max_value(16)); + let found = Matches::find(lib.clone(), PathBuf::new(), &playing, limit); + let all = locallibrary::api::matches_in(&lib, &playing, usize::MAX); + assert_eq!(found.items.len(), all.len().min(limit)); + let ways: Vec = found.items.iter().map(|m| m.relation).collect(); + let mut kinds: Vec = all.iter().map(|s| s.relation).collect(); + kinds.dedup(); + // One of each, in the order the suggestions rank them. + let first = &ways[..kinds.len().min(ways.len())]; + assert_eq!(first, &kinds[..first.len()], "{ways:?}"); + for way in kinds { + let mine: Vec = (found.items.iter()).filter(|m| m.relation == way).map(|m| found.track(m).id).collect(); + let theirs: Vec = + (all.iter()).filter(|s| s.relation == way).map(|s| s.track.id).take(mine.len()).collect(); + assert_eq!(mine, theirs, "{way:?}"); + } +} diff --git a/apps/locallibrary/src/tui/app/testing.rs b/apps/locallibrary/src/tui/app/testing.rs index d3aa7c37f..7d7003d8b 100644 --- a/apps/locallibrary/src/tui/app/testing.rs +++ b/apps/locallibrary/src/tui/app/testing.rs @@ -186,7 +186,12 @@ pub fn blank(view: View) -> (App, TempDir) { /// [`app_with`] the test library, its first track playing with `analysis` (its audio still /// opening, so a jump shows as where playback will start), and no audio device. pub fn playing(analysis: TrackAnalysis) -> (App, TempDir) { - let (mut app, dir) = app_with(library()); + playing_in(library(), analysis) +} + +/// [`playing`], with `lib` in place of the test library. +pub fn playing_in(lib: Library, analysis: TrackAnalysis) -> (App, TempDir) { + let (mut app, dir) = app_with(lib); let l = app.loaded.as_ref().unwrap(); let (track, root) = (l.library.tracks[0].clone(), l.root().to_path_buf()); app.now = Some(NowPlaying { diff --git a/apps/locallibrary/src/tui/app/tests.rs b/apps/locallibrary/src/tui/app/tests.rs index 9c7f213d6..85b128efb 100644 --- a/apps/locallibrary/src/tui/app/tests.rs +++ b/apps/locallibrary/src/tui/app/tests.rs @@ -3263,6 +3263,28 @@ fn the_mixes_into_panel_shows_each_matchs_own_key() { assert!(shown.len() > 12, "keys shown: {shown:?}"); } +/// The few rows the "mixes into" panel has room for show the other keys that mix in too, where +/// same-key tracks alone would fill them and every row would read as the playing track's key +/// (LOC-13). +#[test] +fn the_mixes_into_panel_shows_more_than_the_playing_key() { + let at = |id, key: &str, title: &str| Track { key: key.into(), bpm: 128.0, ..track(id, "DJ", title) }; + let mut tracks = vec![at(1, "8B", "Playing")]; + tracks.extend((2..9).map(|id| at(id, "8B", &format!("Same {id}")))); + tracks.extend([at(9, "7B", "Neighbour"), at(10, "8A", "Relative")]); + let lib = Library { tracks, ..Default::default() }; + let (mut app, _dir) = playing_in(lib, TrackAnalysis::default()); + let l = app.loaded.as_ref().unwrap(); + let (root, lib) = (l.root().to_path_buf(), l.library.clone()); + let info = track_info(&root, &lib.tracks[0], Some(lib.clone()), &root.join("store")); + app.handle_msg(Msg::TrackInfo(1, Box::new(info))); + app.deck_layout = true; + let s = super::super::ui::screen(&mut app, 120, 40); + for row in ["8B 128.0 Same 2", "7B 128.0 Neighbour", "8A 128.0 Relative"] { + assert!(s.contains(row), "{row}:\n{s}"); + } +} + /// The player as `tui::run` makes it, with nothing to hear and the drives in [`plugins_once`] the /// only ones mounted. fn started(store: PathBuf, drive: Option) -> App {