diff --git a/README.md b/README.md index 0621669..ee80220 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,17 @@ soulseek-rs Everything else is a one-shot command that runs headless. +### The TUI + +Results run across the top, with the whole width for long file names; +Searches, Downloads and Info share the row underneath. `Tab`/`Shift-Tab` or +`1`–`3` move the focus between panes, `z` zooms the focused pane to the whole +window, and `w` hides it so the others widen (its number brings it back). +Every list takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and +`h`/`l` or `←`/`→` scroll a long file name or query sideways, `0` and `$` to +either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?` +shows every key for where you are. + ### Daemon mode A one-shot command logs in, does its job, and exits. Run a daemon instead and diff --git a/fixtures/demo.svg b/fixtures/demo.svg index 00c714c..dde3801 100644 --- a/fixtures/demo.svg +++ b/fixtures/demo.svg @@ -1 +1 @@ -╭soulseek-rslogin───────────────────────────────╮│Username:jackass44││Password:•••••••••││││Connecting…││Newusernamesareregisteredautomatically.││Tab:switch·Enter:login·Esc:quit│╰──────────────────────────────────────────────────╯╭Status────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│soulseek-rs🦀v7.0.0Downloads:0active,0completed,0fail│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╭[1]Searches───────────────────────╮╭[2]Results────────────────────────────────────────────────────────────────────────────╮│StatusQueryResults││soulseek-rsv7.0.0││›Done tiesto 0││││Donethexx0││Noresultsyet.││Donedjbount0││Pickasearchin[1]Searches,orstartanewone.│││││││╰─────────────────────────────────────────────────────────────────────────────────────────╯││╭[3]Downloads/Uploads──────────────────────────────╮╭[Info]──────────────────────────╮│││StatUserProgressSpeed││RavingGeorgefeat.Oscar&The││││›⋯ Dancemaniako Queued -││Wolf-TwoClocks(MaceoPlex││││⋯DancemaniakoQueued-││Remix).mp3││││⋯DancemaniakoQueued-││@@kpmeh/-2013-/Electro,Deep,││││││TechnoHouse&TranceCollection││││││byLORDEGRAFvol.12││││││││││││UserDancemaniako││││││Size9.4MB││││││StatusQueued│╰─────────────────────────────────────╯╰─────────────────────────────────────────────────────╯╰──────────────────────────────────╯╭Shortcuts·Sharing:2folders────────────────────────────────────────────────────────────────────────────────────────────────╮│[s→search][m→message][i→inbox][c→chat][b→browseuser][1-3→focuspane][↑↓→navigate][Enter→results⟨✉1⟩││[s→search][m→message][i→inbox][c→chat][b→browseuser][1-3→focuspane][↑↓→navigate][Enter→results⟨✉1⟩│╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│search:│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│[Type→searchterm][←→→movecursor][Backspace/Del→edit][Enter→search][Esc→cancel]⟨✉1⟩││[Type→searchterm][←→→movecursor][Backspace/Del→edit][Enter→search][Esc→cancel]⟨✉1⟩│╭[1]Searches───────────────────────╮╭[2]Results:SaveYourAtoll───────────────────────────────────────────────────────────╮│StatusQueryResults││soulseek-rsv7.0.0││Donetiesto0││││Donethexx0││Noresultsyet.││Donedjbount0││Pickasearchin[1]Searches,orstartanewone.││›Active Save Yo Searching.│││││││││╰─────────────────────────────────────────────────────────────────────────────────────────╯││╭[3]Downloads/Uploads──────────────────────────────╮╭[Info]──────────────────────────╮│││StatUserProgressSpeed││RavingGeorgefeat.Oscar&The││││›⋯ Dancemaniako Queued -││Wolf-TwoClocks(MaceoPlex││││⋯DancemaniakoQueued-││Remix).mp3││││⋯DancemaniakoQueued-││@@kpmeh/-2013-/Electro,Deep,││││││TechnoHouse&TranceCollection││││││byLORDEGRAFvol.12││││││││││││UserDancemaniako││││││Size9.4MB││││││StatusQueued│╰─────────────────────────────────────╯╰─────────────────────────────────────────────────────╯╰──────────────────────────────────╯│[Space→select][Enter→download][b→browseowner][c→chat][/→filter][a/A→selectall/none][1-3→focuspan⟨✉1⟩││[Space→select][Enter→download][b→browseowner][c→chat][/→filter][a/A→selectall/none][1-3→focuspan⟨✉1⟩││StatusQueryResults││✓FilenameSizeUserBitrateSpeedSlots││Donetiesto0││›[ ] @@kpmeh\- 2021 -\The P 16.5 MB Dancemaniako 320 kbps 0.46 MB/s 1││Donethexx0││[]@@vqmcr\20241128\Save56.0MBQadavre-1.69MB/s1││Donedjbount0││[]@@vqmcr\20241128\Save70.0MBQadavre-1.69MB/s1││││[]@@vqmcr\20241128\Save80.4MBQadavre-1.69MB/s1││›Active Save Yo 219││[]@@vqmcr\20241128\Save60.7MBQadavre-1.69MB/s1││││[]@@vqmcr\20241128\Save64.3MBQadavre-1.69MB/s1││││[]@@vqmcr\20241128\Save64.6MBQadavre-1.69MB/s1││││[]@@vqmcr\20241128\Save57.9MBQadavre-1.69MB/s1││││[]@@vqmcr\20241128\Save67.2MBQadavre-1.69MB/s1││││[]@@vqmcr\20241128\Save68.8MBQadavre-1.69MB/s1││││[]@@vqmcr\20241128\Save59.1MBQadavre-1.69MB/s1││││[]@@vqmcr\20241128\Save68.0MBQadavre-1.69MB/s1││││[]@@vqmcr\20241128\Save59.7MBQadavre-1.69MB/s1│╭[1]Searches───────────────────────╮╭[2]Results:MaceoPlex────────────────────────────────────────────────────────────────╮│ActiveSaveYo219││││›Active Maceo P Searching.││││││││StatusInprogress││Donetiesto0││›[ ] Music Sorted\#Beatport 17.8 MB beatrice.songbi 320 kbps 1.87 MB/s 1││Donethexx0││[]Music\#DJPools\2026\218.1MBbeatrice.songbi320kbps1.87MB/s1││Donedjbount0││[]Music\0dayMP3\2026\200.0MBbeatrice.songbi-1.87MB/s1│╭[1]Searches───────────────────────╮╭[2]Results:MaceoPlex────────────────────────────────────────────────────────────────╮│StatusQueryResults││✓FilenameSizeUserBitrateSpeedSlots││Donetiesto0││›[ ] Music Sorted\#Beatport 17.8 MB beatrice.songbi 320 kbps 1.87 MB/s 1││Donethexx0││[]Music\#DJPools\2026\218.1MBbeatrice.songbi320kbps1.87MB/s1││Donedjbount0││[]Music\0dayMP3\2026\200.0MBbeatrice.songbi-1.87MB/s1││││[]Music\0dayMP3\2026\20207.7MBbeatrice.songbi320kbps1.87MB/s1││││[]@@jlksj\new_music\Ali14.0MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Ellu16.9MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Ellu15.1MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Ellu16.1MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Ellu17.5MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Gabr23.6MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Gabr12.9MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Gabr18.0MBQadavre320kbps1.69MB/s1││││││StatusInprogress││›Active Save Yo 227││[]@@vqmcr\20241128\Save60.7MBQadavre-1.69MB/s1││││[]@@vqmcr\20241128\VA-29.6MBQadavre-1.69MB/s1││││[]@@vqmcr\20241128\VA-27.0MBQadavre-1.69MB/s1││ActiveMaceoP589││[]@@vqmcr\20241128\Save80.4MBQadavre-1.69MB/s1││Donetiesto0││[]@@lustt\complete\ashur14.5MBvivio320kbps1.16MB/s1││Donethexx0││[]@@lustt\complete\cyber15.7MBvivio320kbps1.16MB/s1││Donedjbount0││[]@@lustt\complete\cyber14.7MBvivio320kbps1.16MB/s1││›Active Save Yo 227││[]@@lustt\complete\cyber15.7MBvivio320kbps1.16MB/s1││ActiveMaceoP589││[]@@lustt\complete\cyber14.4MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\cyber16.3MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\koala13.4MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\koala13.3MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\koala18.3MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\koala15.8MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\koala15.7MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\koala14.7MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\koala13.0MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\panin16.7MBvivio320kbps1.16MB/s1││Donetiesto0││[]@@qxibn\music2024\Sav80.4MBsillysillysilly-0.64MB/s0││Donethexx0││[]@@qxibn\music2024\Sav64.3MBsillysillysilly-0.64MB/s0││Donedjbount0││[]@@qxibn\music2024\Sav64.6MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav68.8MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav59.1MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav68.0MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav59.7MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav1.9MBsillysillysilly-0.64MB/s0││││[]@@wztnu\MUZYKA\FLAC\TE26.9MBDozel-0.02MB/s1││││[]@@puclh\Marzo2024\05-60.8MBneska1411kbps1.77MB/s1││││[]Music\0dayFLAC\2026\20.4MBbeatrice.songbi-1.87MB/s1││›Done Save Yo 229││[]@@qxibn\music2024\Sav57.9MBsillysillysilly-0.64MB/s0││ActiveMaceoP612││[]@@qxibn\music2024\Sav67.2MBsillysillysilly-0.64MB/s0││││›[ ] @@wztnu\MUZYKA\FLAC\TE 26.9 MB Dozel - 0.02 MB/s 1││││[✓]@@qxibn\music2024\Sav59.7MBsillysillysilly-0.64MB/s0││││›[✓] @@wztnu\MUZYKA\FLAC\TE 26.9 MB Dozel - 0.02 MB/s 1││DoneMaceoP612││[]@@qxibn\music2024\Sav67.2MBsillysillysilly-0.64MB/s0││││⋯DozelQueued-││TechnoHouse&TranceCollection││││⋯QadavreQueued-││byLORDEGRAFvol.12││││⋯vivioQueued-││││││⋯sillysillysillyQueued-││UserDancemaniako││││↯Dozel0.0MB/26.9MB0.3MB/s││TechnoHouse&TranceCollection│╭[1]Searches───────────────────────╮╭[2]Results:SaveYourAtoll───────────────────────────────────────────────────────────╮│Donetiesto0││[]@@qxibn\music2024\Sav80.4MBsillysillysilly-0.64MB/s0││Donethexx0││[]@@qxibn\music2024\Sav64.3MBsillysillysilly-0.64MB/s0││Donedjbount0││[]@@qxibn\music2024\Sav64.6MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav68.8MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav59.1MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav68.0MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav59.7MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav1.9MBsillysillysilly-0.64MB/s0││││[]@@wztnu\MUZYKA\FLAC\TE26.9MBDozel-0.02MB/s1││││›[ ] @@wztnu\MUZYKA\FLAC\TE 26.9 MB Dozel - 0.02 MB/s 1││││[]@@puclh\Marzo2024\05-60.8MBneska1411kbps1.77MB/s1││││[]Music\0dayFLAC\2026\20.4MBbeatrice.songbi-1.87MB/s1││││⋯sillysillysillyQueued-││UserDancemaniako││DoneSaveYo229││[]Music\0dayMP3\2026\200.0MBbeatrice.songbi-1.87MB/s1││›Done Maceo P 612││[]Music\0dayMP3\2026\200.0MBbeatrice.songbi-1.87MB/s1││││[]Music\0dayMP3\2026\20207.7MBbeatrice.songbi320kbps1.87MB/s1││││[]@@jlksj\new_music\Ali14.0MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Ellu16.9MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Ellu15.1MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Ellu16.1MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Ellu17.5MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Gabr23.6MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Gabr12.9MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\Gabr18.0MBQadavre320kbps1.69MB/s1││Donetiesto0││[]MusicSorted\#Beatport17.8MBbeatrice.songbi320kbps1.87MB/s1││││↯Dozel0.2MB/26.9MB0.4MB/s││TechnoHouse&TranceCollection││││✓vivioCompleted-││││Donethexx0││[✓]Music\#DJPools\2026\218.1MBbeatrice.songbi320kbps1.87MB/s1││Donedjbount0││[✓]Music\0dayMP3\2026\200.0MBbeatrice.songbi-1.87MB/s1││││›[ ] Music\0day MP3\2026\20 207.7 MB beatrice.songbi 320 kbps 1.87 MB/s 1││││›✓ Dancemaniako Completed -││Wolf-TwoClocks(MaceoPlex││││││StatusCompleted││││↯Dozel0.2MB/26.9MB0.2MB/s││TechnoHouse&TranceCollection││││↯Dancemaniako0.0MB/11.1MB0.0MB/s││Remix).mp3││││[✓]@@jlksj\new_music\Ellu17.5MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\july16.0MBQadavre320kbps1.69MB/s1││││↯Dozel0.3MB/26.9MB0.5MB/s││TechnoHouse&TranceCollection││Donetiesto0││[]@@kpmeh\-2013-\2013.10.4MBDancemaniako192kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2013-\2013.7.3MBDancemaniako192kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2013-\AllG40.0MBDancemaniako-0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2013-\Azari50.9MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2013-\Beatp18.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\Beatp17.4MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\Butch57.5MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2013-\Deep15.6MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\Defec19.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\Defec17.2MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\Defec18.4MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\Defec54.6MBDancemaniako-0.46MB/s1││││›[ ] @@kpmeh\- 2013 -\E-Dan 11.1 MB Dancemaniako 320 kbps 0.46 MB/s 1││DoneSaveYo245││[]@@kpmeh\-2013-\Armad17.5MBDancemaniako320kbps0.46MB/s1││││[✓]@@kpmeh\-2013-\E-Dan11.1MBDancemaniako320kbps0.46MB/s1││││↯Dozel0.4MB/26.9MB0.3MB/s││TechnoHouse&TranceCollection││││↯Dozel0.4MB/26.9MB0.1MB/s││TechnoHouse&TranceCollection││││[]@@kpmeh\-2014-\Armad15.3MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\Armad10.5MBDancemaniako320kbps0.46MB/s1││││↯Dozel0.5MB/26.9MB0.4MB/s││TechnoHouse&TranceCollection││││[]@@kpmeh\-2015-\VA-16.9MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2016-\Top114.7MBDancemaniako320kbps0.46MB/s1││││↯Dozel0.5MB/26.9MB0.3MB/s││TechnoHouse&TranceCollection││││[]@@kpmeh\-2017-\Beatp16.5MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2017-\Beatp16.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2017-\Beatp14.9MBDancemaniako320kbps0.46MB/s1││││↯Dozel0.6MB/26.9MB0.3MB/s││TechnoHouse&TranceCollection││││[]@@kpmeh\-2022-\Defec19.0MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]Music\0dayMP3\2026\200.0MBbeatrice.songbi-1.87MB/s1││││[✓]@@jlksj\new_music\Gabr23.6MBQadavre320kbps1.69MB/s1││││↯Dozel0.7MB/26.9MB0.4MB/s││TechnoHouse&TranceCollection││││✓QadavreCompleted-││byLORDEGRAFvol.12││Donetiesto0││[]music3\MaceoPlex-DJ35.2MBcinstitute2-4.4MB/s1││Donethexx0││[]music3\MaceoPlex-DJ35.9MBcinstitute2-4.4MB/s1││Donedjbount0││[]music3\MaceoPlex-DJ27.7MBcinstitute2-4.4MB/s1││DoneSaveYo245││[]music3\MaceoPlex-DJ37.7MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ30.0MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ24.5MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ43.8MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ26.3MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ26.8MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ40.8MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ0.0MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ0.2MBcinstitute2-4.4MB/s1││›Done Maceo P 612││[✓]music3\MaceoPlex-DJ26.2MBcinstitute2-4.4MB/s1││││↯Dozel0.8MB/26.9MB0.1MB/s││TechnoHouse&TranceCollection││││↯Dozel0.8MB/26.9MB0.4MB/s││TechnoHouse&TranceCollection││›Done Maceo P 612││[]music3\MaceoPlex-DJ26.2MBcinstitute2-4.4MB/s1││││›[ ] music3\Maceo Plex - DJ 26.3 MB cinstitute2 - 4.4 MB/s 1││││⋯cinstitute2Queued-││Size9.4MB││││⋯cinstitute2Queued-││StatusCompleted│╭[1]Searches───────────────────────╮╭[2]Results:MaceoPlex────────────────────────────────────────────────────────────────╮│StatusQueryResults││✓FilenameSizeUserBitrateSpeedSlots││Donetiesto0││[]music3\MaceoPlex-DJ35.2MBcinstitute2-4.4MB/s1││Donethexx0││[]music3\MaceoPlex-DJ35.9MBcinstitute2-4.4MB/s1││Donedjbount0││[]music3\MaceoPlex-DJ27.7MBcinstitute2-4.4MB/s1││DoneSaveYo245││[]music3\MaceoPlex-DJ37.7MBcinstitute2-4.4MB/s1││›Done Maceo P 612││[]music3\MaceoPlex-DJ26.2MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ30.0MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ24.5MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ43.8MBcinstitute2-4.4MB/s1││││›[ ] music3\Maceo Plex - DJ 26.3 MB cinstitute2 - 4.4 MB/s 1││││[]music3\MaceoPlex-DJ26.8MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ40.8MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ0.0MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ0.2MBcinstitute2-4.4MB/s1│││╰─────────────────────────────────────────────────────────────────────────────────────────╯││╭[3]Downloads/Uploads──────────────────────────────╮╭[Info]──────────────────────────╮│││StatUserProgressSpeed││RavingGeorgefeat.Oscar&The││││›✓ Dancemaniako Completed -││Wolf-TwoClocks(MaceoPlex││││⋯DancemaniakoQueued-││@@kpmeh/-2013-/Electro,Deep,││││↯Dozel0.9MB/26.9MB0.3MB/s││TechnoHouse&TranceCollection││││✓QadavreCompleted-││byLORDEGRAFvol.12││││✓vivioCompleted-││││││⋯sillysillysillyQueued-││UserDancemaniako││││⋯cinstitute2Queued-││StatusCompleted│╰─────────────────────────────────────╯╰─────────────────────────────────────────────────────╯╰──────────────────────────────────╯│[p→pause/resume][r→retryfailed][d→deletequeued][c→clearfinished][b→browseuser][1-3→focuspane][q⟨✉1⟩││[p→pause/resume][r→retryfailed][d→deletequeued][c→clearfinished][b→browseuser][1-3→focuspane][q⟨✉1⟩││││⋯cinstitute2Queued-││Saveto││││StatUserProgressSpeed││020.MaceoPlex-Insomnia││││⋯sillysillysillyQueued-││2022.mp3││││⋯cinstitute2Queued-││ClubbingElectroHouseVol.12││││✓beatrice.songbiCompleted-││││││›⋯ Dancemaniako Queued -││/Users/micheldegraaf/Downloads/S││[p→pause/resume][r→retryfailed][d→deletequeued][c→clearfinished][b→browseuser][1-3→focuspane][q⟨✉2⟩││││⋯cinstitute2Queued-││││││✓beatrice.songbiCompleted-││UserDancemaniako││││⋯DancemaniakoQueued-││Saveto││││⋯DancemaniakoQueued-││/Users/micheldegraaf/Downloads/S││[p→pause/resume][r→retryfailed][d→deletequeued][c→clearfinished][b→browseuser][1-3→focuspane][q⟨✉2⟩││││StatUserProgressSpeed││OddParents,MaceoPlex-Learn││││✓cinstitute2Completed-││ToFly(Maceo's808Dub).mp3││││✓beatrice.songbiCompleted-││#BeatportdecadeTechHouse││││✓beatrice.songbiCompleted-││UserQadavre││││›✓ Qadavre Completed -││Size17.5MB││││StatUserProgressSpeed││OddParents,MaceoPlex-Learn││││✓cinstitute2Completed-││ToFly(Maceo's808Dub).mp3││││✓beatrice.songbiCompleted-││#BeatportdecadeTechHouse││││⋯cinstitute2Queued-││││││✓beatrice.songbiCompleted-││UserQadavre││││›✓ Qadavre Completed -││Size17.5MB││││⋯DancemaniakoQueued-││Saveto││││⋯DancemaniakoQueued-││/Users/micheldegraaf/Downloads/S││[Space→select][Enter→download][b→browseowner][c→chat][/→filter][a/A→selectall/none][1-3→focuspan⟨✉2⟩││[Space→select][Enter→download][b→browseowner][c→chat][/→filter][a/A→selectall/none][1-3→focuspan⟨✉2⟩││││✓cinstitute2Completed-││@@jlksj/new_music/EllumAudio││Status╭Rooms(Enter:join,/:filter,Tab:openrooms,Esc:close)───────────────────────────────────────────────╮Slots││Done│roomusers│1││Done│› ! ! LGBTQ+ ! ! 461│1││Done│RareMusic320│1││Done│Linux262│1││›Done│SoulSync229│1│││lossless199│1│││indie180│1│││+BlackMetal+153│1│││drum'n'bass150│1│││/mu/148│1│││what.cd138│1│││Furry135│1│││:3126│1│││TECHNO,MixesandTunes122│1│││r/musichoarder119│1│││japanesemusic113│─────────╯││MOVIES105│─────────╮││Ambient89│-Learn│││Anime79│.mp3│││!!!cumeaters!!!77│Audio│││EBooks77│se│││HOUSEMUSICLOVERS(AG)69││││+Autism+62│vre│││Hardcore/punk62│MB│││!meowchat:361│leted│││programming61│││╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯nloads/S││[↑↓→move][Enter→join][/→filter][Tab→openrooms][Esc→close]⟨✉2⟩││[↑↓→move][Enter→join][/→filter][Tab→openrooms][Esc→close]⟨✉2⟩││Done│nicotine1270│1││Done│!!LGBTQ+!!466│1││Done│› Rare Music 327│1││›Done│Linux264│1│││SoulSync230│1│││TheLobby215│1│││lossless200│1│││indie181│1│││+BlackMetal+155│1│││/mu/151│1│││drum'n'bass150│1│││what.cd139│1│││Furry137│1│││TECHNO,MixesandTunes122│─────────╯││r/musichoarder120│─────────╮││japanesemusic117│-Learn│││MOVIES106│.mp3│││Ambient89│Audio│││Anime79│se│││EBooks77││││!!!cumeaters!!!76│vre│││HOUSEMUSICLOVERS(AG)68│MB│││!meowchat:362│leted│││+Autism+62│││Status╭Chatrooms(Tab:switch,l:roomlist,x:leave,Esc:back)───────────────────────────────────────────────╮Slots││Done│Rare Music│1││Done│04:59→jackass44│Users(1)│1││Done││▸ jackass44│1││Done│││1││›Done│││1│││││1│││││1│││││─────────╯││││─────────╮││││-Learn│││││.mp3│││││Audio│││││se││││││││││vre│││││MB│││││leted│││Enter:typeamessage│││[Enter→say][↑↓→pickuser][b→browseuser][m→messageuser][Tab→switchroom][l→rooms][x→leave]⟨✉2⟩││[Enter→say][↑↓→pickuser][b→browseuser][m→messageuser][Tab→switchroom][l→rooms][x→leave]⟨✉2⟩││[Type→message][Enter→send][Esc→cancel]⟨✉2⟩││Done│04:59→jackass44│Users(328)│1││Done│04:59—joined—│▸ mayonerio│1││Done││bradybunch668│1││›Done││splashdown│1││││1800thrownaway│1││││baza333│1││││Galaxion│1││││enricopoliman│1││││smilerrr│1││││DJ_Hannibalistic│1││││toddomg│1││││niet│1││││kebnimqojom│1││││beautysleep│1││││sSsSs│─────────╯│││ratgirrl│─────────╮│││v3rtual│-Learn││││professionalspeed│.mp3││││kasmanga1│Audio││││uvizj4yt8f75│se││││SBORRANET123│││││xeroxeom│vre││││osofer│MB││││craigp56│leted││[Type→message][Enter→send][Esc→cancel]⟨✉2⟩│││›helloworld▏│││Done│04:59←stupidmusichoarder│bradybunch668│1││Done│04:59→jackass44│Users(326)│1││›Done│04:59←Bipolar213│splashdown│1│││04:59<jackass44>helloworld!│1800thrownaway│1││Done│04:59→jackass44│Users(325)│1│││04:59←dudebro│baza333│1│││04:59→martitoci│Galaxion│1││Done│04:59—joined—│mayonerio│1││›Done│04:59←Bipolar213│▸ splashdown│1││Status╭Browsesplashdown───────────────────────────────────────────────────────────────────────────────────╮Slots││Done│⠋Requestingsharedfilesfromsplashdown…│1││Done││1││›Done││1││││1││││1││││────────────╯│││────────────╮│││ex-Learn││││ub).mp3││││umAudio││││House││││││││adavre││││7.5MB││││ompleted││╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯Downloads/S││[↑↓→move][→←→expand/collapse][Enter→open/download][d→downloadfolder][Tab→switchuser][r→retry][w→c⟨✉2⟩││Done│⠴Requestingsharedfilesfromsplashdown…│1││[↑↓→move][→←→expand/collapse][Enter→open/download][d→downloadfolder][Tab→switchuser][r→retry][w→c⟨✉2⟩││││✓cinstitute2Completed-││││││✓QadavreCompleted-││StatusCompleted││Doned╭Settings───────────────────────────────────────────────────────────────────────────────╮4MB/s1││DoneS│>Downloadfolder:/Users/micheldegraaf/Downloads/Soulseek│4MB/s1││›Done M││4MB/s1│││Sharedfolders(2):│4MB/s1│││/Users/micheldegraaf/Downloads/Soulseek│4MB/s1│││/Users/micheldegraaf/Downloads│4MB/s1││││4 MB/s 1││││4MB/s1││││──────────────────╯│││──────────────────╮│││ceoPlex-Learn││││808Dub).mp3││││ic/EllumAudio││││TechHouse││││││││Qadavre││╰─────────────────────────────────────────────────────────────────────────────────────────╯17.5MB││[↑↓→move][Enter/e→editdownloaddir][a→addshare][d→removeshare][r→re-index][Esc→close]⟨✉2⟩││[↑↓→move][Enter/e→editdownloaddir][a→addshare][d→removeshare][r→re-index][Esc→close]⟨✉2⟩││search:SaveYourAtoll││›Active Save Yo 85││[]@@vqmcr\20241128\Save60.7MBQadavre-1.69MB/s1││││[]@@vqmcr\2││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail0.0/9.4MB·0MB/s·[░░░░░░░░░░░░░░░░░░░░░]0%││││›↯ Dancemaniako 0.0 MB/9.4 MB ( 0.0 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail0.1/9.4MB·0.11MB/s·[░░░░░░░░░░░░░░░░░░░░░]0%││││›↯ Dancemaniako 0.1 MB/9.4 MB ( 0.1 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail0.4/9.4MB·0.56MB/s·[░░░░░░░░░░░░░░░░░░░░░]3%││││›↯ Dancemaniako 0.4 MB/9.4 MB ( 0.6 MB/s││Wolf-TwoClocks(MaceoPlex││search:MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail0.6/9.4MB·0.61MB/s·[█░░░░░░░░░░░░░░░░░░░░]6%││││›↯ Dancemaniako 0.6 MB/9.4 MB ( 0.6 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail0.9/9.4MB·0.62MB/s·[█░░░░░░░░░░░░░░░░░░░░]9%││││›↯ Dancemaniako 0.9 MB/9.4 MB ( 0.6 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail1.1/9.4MB·0.48MB/s·[██░░░░░░░░░░░░░░░░░░░]11%││ActiveSaveYo219││[]Music\0dayMP3\2026\20││││›↯ Dancemaniako 1.0 MB/9.4 MB ( 0.8 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail1.3/9.4MB·0.47MB/s·[██░░░░░░░░░░░░░░░░░░░]14%││ActiveSaveYo219││[]Music\0dayMP3\2026\200.0MBbeatrice.songbi-1.87MB/s1││›Active Maceo P 56││[]Music\0dayMP3\2026\200.0MBbeatrice.songbi-1.87MB/s1││││›↯ Dancemaniako 1.3 MB/9.4 MB ( 0.5 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail1.7/9.4MB·1.6MB/s·[███░░░░░░░░░░░░░░░░░░]17%││›Active Save Yo 225││[]Music\0dayMP3\2026\200.0MBbeatrice.songbi-1.87MB/s1││ActiveMaceoP56││[]Music\0dayMP3\2026\200.0MBbeatrice.songbi-1.87MB/s1││││›↯ Dancemaniako 1.7 MB/9.4 MB ( 1.6 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail1.9/9.4MB·0.46MB/s·[████░░░░░░░░░░░░░░░░░]19%││ActiveMaceoP56││[]@@vqmcr\20241128\Save80.4MBQadavre-1.69MB/s1││││›↯ Dancemaniako 1.9 MB/9.4 MB ( 0.5 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail2.1/9.4MB·0.48MB/s·[████░░░░░░░░░░░░░░░░░]21%││││›↯ Dancemaniako 2.1 MB/9.4 MB ( 0.5 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail2.3/9.4MB·1.26MB/s·[█████░░░░░░░░░░░░░░░░]24%││Donetiesto0││[]@@kpmeh\-2021-\TheP16.5MBDancemaniako320kbps0.46MB/s1││Donedjbount0││›[✓] @@vqmcr\20241128\Save 70.0 MB Qadavre - 1.69 MB/s 1││││›↯ Dancemaniako 2.3 MB/9.4 MB ( 1.3 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail2.6/9.4MB·0.49MB/s·[█████░░░░░░░░░░░░░░░░]27%││Donetiesto0││[]@@vqmcr\20241128\Save56.0MBQadavre-1.69MB/s1││Donethexx0││[✓]@@vqmcr\20241128\Save70.0MBQadavre-1.69MB/s1││Donedjbount0││[]@@vqmcr\20241128\Save60.7MBQadavre-1.69MB/s1││›Active Save Yo 227││[]@@vqmcr\20241128\Save80.4MBQadavre-1.69MB/s1││ActiveMaceoP589││[]@@vqmcr\20241128\Save64.3MBQadavre-1.69MB/s1││││›[ ] @@vqmcr\20241128\VA-Pr 37.0 MB Qadavre - 1.69 MB/s 1││││›↯ Dancemaniako 2.6 MB/9.4 MB ( 0.5 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail2.9/9.4MB·1.24MB/s·[██████░░░░░░░░░░░░░░░]30%││││[]@@lustt\complete\koala13.6MBvivio320kbps1.16MB/s1││││›[ ] @@lustt\complete\koala 14.4 MB vivio 320 kbps 1.16 MB/s 1││││›↯ Dancemaniako 2.9 MB/9.4 MB ( 1.2 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail3.1/9.4MB·1.13MB/s·[██████░░░░░░░░░░░░░░░]32%││││›[✓] @@lustt\complete\koala 13.6 MB vivio 320 kbps 1.16 MB/s 1││││[]@@lustt\complete\koala14.4MBvivio320kbps1.16MB/s1││││›↯ Dancemaniako 3.1 MB/9.4 MB ( 1.1 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail3.3/9.4MB·0.44MB/s·[███████░░░░░░░░░░░░░░]35%││Donetiesto0││[]@@lustt\complete\koala14.4MBvivio320kbps1.16MB/s1││Donethexx0││[]@@lustt\complete\koala16.3MBvivio320kbps1.16MB/s1││Donedjbount0││[]@@lustt\complete\koala14.7MBvivio320kbps1.16MB/s1││›Done Save Yo 227││[]@@lustt\complete\koala14.8MBvivio320kbps1.16MB/s1││ActiveMaceoP589││[]@@lustt\complete\koala16.5MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\koala15.6MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\koala15.3MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\liuzi15.2MBvivio-1.16MB/s1││││[]@@lustt\complete\liuzi16.3MBvivio-1.16MB/s1││││[]@@lustt\complete\panin16.8MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\panin19.9MBvivio320kbps1.16MB/s1││││›[ ] @@lustt\complete\panin 17.9 MB vivio 320 kbps 1.16 MB/s 1││││›↯ Dancemaniako 3.3 MB/9.4 MB ( 0.4 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail3.6/9.4MB·0.82MB/s·[███████░░░░░░░░░░░░░░]37%││Donetiesto0││[]@@lustt\complete\panin16.2MBvivio320kbps1.16MB/s1││Donethexx0││[]@@lustt\complete\panin15.4MBvivio320kbps1.16MB/s1││Donedjbount0││[]@@lustt\complete\panin19.4MBvivio320kbps1.16MB/s1││›Done Save Yo 227││[]@@lustt\complete\panin19.4MBvivio320kbps1.16MB/s1││ActiveMaceoP589││[]@@lustt\complete\panin14.3MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\panin19.0MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\panin15.8MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\panin16.9MBvivio320kbps1.16MB/s1││││[]@@lustt\complete\panin15.2MBvivio320kbps1.16MB/s1││││[]@@qxibn\music2024\Osl64.9MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav56.0MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav70.0MBsillysillysilly-0.64MB/s0││││[]@@qxibn\music2024\Sav60.7MBsillysillysilly-0.64MB/s0││││›[ ] @@qxibn\music 2024\Sav 80.4 MB sillysillysilly - 0.64 MB/s 0││││›↯ Dancemaniako 3.6 MB/9.4 MB ( 0.8 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail3.7/9.4MB·0.5MB/s·[████████░░░░░░░░░░░░░]39%││›Done Save Yo 227││[]@@qxibn\music2024\Sav57.9MBsillysillysilly-0.64MB/s0││ActiveMaceoP589││[]@@qxibn\music2024\Sav67.2MBsillysillysilly-0.64MB/s0││││›[ ] @@puclh\Marzo 2024\05- 60.8 MB neska 1411 kbps 1.77 MB/s 1││││›↯ Dancemaniako 3.7 MB/9.4 MB ( 0.5 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail4.1/9.4MB·0.56MB/s·[█████████░░░░░░░░░░░░]43%││││›↯ Dancemaniako 4.1 MB/9.4 MB ( 0.6 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail4.3/9.4MB·2.07MB/s·[█████████░░░░░░░░░░░░]45%││││›[✓] @@qxibn\music 2024\Sav 68.0 MB sillysillysilly - 0.64 MB/s 0││││›↯ Dancemaniako 4.3 MB/9.4 MB ( 2.1 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail4.6/9.4MB·0.52MB/s·[██████████░░░░░░░░░░░]49%││││›[ ] @@qxibn\music 2024\Sav 59.7 MB sillysillysilly - 0.64 MB/s 0││││›↯ Dancemaniako 4.6 MB/9.4 MB ( 0.5 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail4.9/9.4MB·3.49MB/s·[██████████░░░░░░░░░░░]52%││││›[ ] @@qxibn\music 2024\Sav 1.9 MB sillysillysilly - 0.64 MB/s 0││││›↯ Dancemaniako 4.9 MB/9.4 MB ( 3.5 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail5.2/9.4MB·2.3MB/s·[███████████░░░░░░░░░░]54%││││›↯ Dancemaniako 5.2 MB/9.4 MB ( 2.3 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail5.4/9.4MB·1.79MB/s·[████████████░░░░░░░░░]57%││││›↯ Dancemaniako 5.4 MB/9.4 MB ( 1.8 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail5.6/9.4MB·0.45MB/s·[████████████░░░░░░░░░]59%││││›↯ Dancemaniako 5.6 MB/9.4 MB ( 0.4 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail5.9/9.4MB·1.69MB/s·[█████████████░░░░░░░░]62%││││›↯ Dancemaniako 5.9 MB/9.4 MB ( 1.7 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail6.1/9.4MB·0.48MB/s·[█████████████░░░░░░░░]64%││││›↯ Dancemaniako 6.1 MB/9.4 MB ( 0.5 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:1active,0completed,0fail6.4/9.4MB·0.54MB/s·[██████████████░░░░░░░]67%││││›↯ Dancemaniako 6.4 MB/9.4 MB ( 0.5 MB/s││Wolf-TwoClocks(MaceoPlex││soulseek-rs🦀v7.0.0Downloads:4active,0completed,0fail8.3/120.1MB·42.32MB/s·[█░░░░░░░░░░░░░░░░░░░░]6%││││›↯ Dancemaniako 6.6 MB/9.4 MB ( 0.6 MB/s││Wolf-TwoClocks(MaceoPlex││││↯Qadavre1.0MB/70.0MB5.1MB/s││byLORDEGRAFvol.12││││↯vivio0.6MB/13.6MB36.3MB/s││││soulseek-rs🦀v7.0.0Downloads:4active,0completed,0fail11.5/120.1MB·10.84MB/s·[██░░░░░░░░░░░░░░░░░░░]9%││││›↯ Dancemaniako 6.8 MB/9.4 MB ( 0.5 MB/s││Wolf-TwoClocks(MaceoPlex││││↯Qadavre2.6MB/70.0MB6.7MB/s││byLORDEGRAFvol.12││││↯vivio2.1MB/13.6MB3.3MB/s││││soulseek-rs🦀v7.0.0Downloads:4active,0completed,0fail14.7/120.1MB·9.05MB/s·[██░░░░░░░░░░░░░░░░░░░]12%││││›↯ Dancemaniako 7.1 MB/9.4 MB ( 0.5 MB/s││Wolf-TwoClocks(MaceoPlex││││↯Qadavre4.2MB/70.0MB5.1MB/s││byLORDEGRAFvol.12││││↯vivio3.4MB/13.6MB3.2MB/s││││soulseek-rs🦀v7.0.0Downloads:4active,0completed,0fail18.7/120.1MB·10.55MB/s·[███░░░░░░░░░░░░░░░░░░]15%││││›↯ Dancemaniako 7.4 MB/9.4 MB ( 2.8 MB/s││Wolf-TwoClocks(MaceoPlex││││↯Qadavre6.0MB/70.0MB4.5MB/s││byLORDEGRAFvol.12││││↯vivio5.2MB/13.6MB3.0MB/s││││soulseek-rs🦀v7.0.0Downloads:4active,0completed,0fail21.9/120.1MB·11.36MB/s·[███░░░░░░░░░░░░░░░░░░]18%││││›↯ Dancemaniako 7.7 MB/9.4 MB ( 2.5 MB/s││Wolf-TwoClocks(MaceoPlex││││↯Qadavre7.6MB/70.0MB5.0MB/s││byLORDEGRAFvol.12││││↯vivio6.7MB/13.6MB3.6MB/s││││soulseek-rs🦀v7.0.0Downloads:4active,0completed,0fail25.1/120.1MB·10.33MB/s·[████░░░░░░░░░░░░░░░░░]20%││││›↯ Dancemaniako 7.9 MB/9.4 MB ( 2.1 MB/s││Wolf-TwoClocks(MaceoPlex││││↯Qadavre9.1MB/70.0MB4.9MB/s││byLORDEGRAFvol.12││││↯vivio8.0MB/13.6MB3.0MB/s││││soulseek-rs🦀v7.0.0Downloads:4active,0completed,0fail28.6/120.1MB·11.11MB/s·[█████░░░░░░░░░░░░░░░░]23%││›Done Save Yo 229││[]@@qxibn\music2024\Sav57.9MBsillysillysilly-0.64MB/s0││DoneMaceoP612││[]@@qxibn\music2024\Sav67.2MBsillysillysilly-0.64MB/s0││││›↯ Dancemaniako 8.2 MB/9.4 MB ( 1.7 MB/s││Wolf-TwoClocks(MaceoPlex││││↯Dozel0.0MB/26.9MB0.3MB/s││TechnoHouse&TranceCollection││││↯Qadavre10.8MB/70.0MB6.0MB/s││byLORDEGRAFvol.12││││↯vivio9.6MB/13.6MB3.1MB/s││││soulseek-rs🦀v7.0.0Downloads:4active,0completed,0fail32.5/120.1MB·9.85MB/s·[█████░░░░░░░░░░░░░░░░]27%││DoneSaveYo229││[]@@qxibn\music2024\Sav57.9MBsillysillysilly-0.64MB/s0││›Done Maceo P 612││[]@@qxibn\music2024\Sav67.2MBsillysillysilly-0.64MB/s0││││›↯ Dancemaniako 8.4 MB/9.4 MB ( 2.0 MB/s││Wolf-TwoClocks(MaceoPlex││││↯Dozel0.1MB/26.9MB0.3MB/s││TechnoHouse&TranceCollection││││↯Qadavre12.6MB/70.0MB4.7MB/s││byLORDEGRAFvol.12││││↯vivio11.3MB/13.6MB2.8MB/s││││soulseek-rs🦀v7.0.0Downloads:4active,0completed,0fail35.8/120.1MB·13.79MB/s·[██████░░░░░░░░░░░░░░░]29%││││›↯ Dancemaniako 8.7 MB/9.4 MB ( 2.4 MB/s││Wolf-TwoClocks(MaceoPlex││││↯Dozel0.1MB/26.9MB0.5MB/s││TechnoHouse&TranceCollection││││↯Qadavre14.4MB/70.0MB5.8MB/s││byLORDEGRAFvol.12││││↯vivio12.5MB/13.6MB5.1MB/s││││soulseek-rs🦀v7.0.0Downloads:3active,1completed,0fail25.2/106.4MB·7.35MB/s·[████░░░░░░░░░░░░░░░░░]23%││Donethexx0││›[ ] Music\#DJ Pools\2026\2 18.1 MB beatrice.songbi 320 kbps 1.87 MB/s 1││││›↯ Dancemaniako 8.9 MB/9.4 MB ( 2.1 MB/s││Wolf-TwoClocks(MaceoPlex││││↯Qadavre16.1MB/70.0MB4.8MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,1completed,0fail27.1/106.4MB·1.94MB/s·[█████░░░░░░░░░░░░░░░░]25%││Donedjbount0││›[ ] Music\0day MP3\2026\20 0.0 MB beatrice.songbi - 1.87 MB/s 1││││›↯ Dancemaniako 9.2 MB/9.4 MB ( 0.5 MB/s││Wolf-TwoClocks(MaceoPlex││││↯Qadavre17.7MB/70.0MB1.1MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:2active,2completed,0fail19.8/97.0MB·3.44MB/s·[████░░░░░░░░░░░░░░░░░]20%││││↯Dozel0.2MB/26.9MB0.1MB/s││TechnoHouse&TranceCollection││││↯Qadavre19.6MB/70.0MB3.4MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:2active,2completed,0fail21.4/97.0MB·4.71MB/s·[████░░░░░░░░░░░░░░░░░]22%││││›[ ] @@jlksj\new_music\Gabr 23.6 MB Qadavre 320 kbps 1.69 MB/s 1││││↯Qadavre21.2MB/70.0MB4.5MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail22.9/108.1MB·7.48MB/s·[████░░░░░░░░░░░░░░░░░]21%││││›[✓] @@jlksj\new_music\Ellu 17.5 MB Qadavre 320 kbps 1.69 MB/s 1││││↯Qadavre22.7MB/70.0MB7.3MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail24.6/108.1MB·6.01MB/s·[████░░░░░░░░░░░░░░░░░]22%││││›[✓] @@jlksj\new_music\Gabr 23.6 MB Qadavre 320 kbps 1.69 MB/s 1││││↯Qadavre24.4MB/70.0MB5.8MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail26.6/108.1MB·7.02MB/s·[█████░░░░░░░░░░░░░░░░]24%││Donetiesto0││[]@@jlksj\new_music\Mace16.6MBQadavre320kbps1.69MB/s1││Donethexx0││[]@@jlksj\new_music\Mace17.1MBQadavre320kbps1.69MB/s1││Donedjbount0││[]@@jlksj\new_music\Mace17.3MBQadavre320kbps1.69MB/s1││DoneSaveYo229││[]@@jlksj\new_music\VA-12.9MBQadavre320kbps1.69MB/s1││›Done Maceo P 612││[]@@jlksj\new_music\WhoM15.3MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\WhoM17.9MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\augu0.1MBQadavre-1.69MB/s1││││[]@@jlksj\new_music\augu18.3MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\june17.7MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\marc16.9MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\marc15.1MBQadavre320kbps1.69MB/s1││││[]@@jlksj\new_music\marc15.2MBQadavre320kbps1.69MB/s1││││›[ ] @@jlksj\new_music\marc 17.2 MB Qadavre 320 kbps 1.69 MB/s 1││││↯Dancemaniako0.1MB/11.1MB0.1MB/s││Remix).mp3││││↯Dozel0.3MB/26.9MB0.3MB/s││TechnoHouse&TranceCollection││││↯Qadavre26.2MB/70.0MB6.6MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail28.3/108.1MB·7.29MB/s·[█████░░░░░░░░░░░░░░░░]26%││Donetiesto0││[]@@qgkbx\tmp\GabrielAn12.9MBQadavre320kbps1.69MB/s1││Donethexx0││[]@@qgkbx\tmp\MaceoPlex20.6MBQadavre320kbps1.69MB/s1││Donedjbount0││[]@@qgkbx\tmp\MaceoPlex17.3MBQadavre320kbps1.69MB/s1││DoneSaveYo229││[]@@qgkbx\tmp\MaribouSt13.5MBQadavre320kbps1.69MB/s1││›Done Maceo P 612││[]@@qgkbx\tmp\Rebolledo16.5MBQadavre-1.69MB/s1││││[]@@qgkbx\tmp\Royksopp-S18.3MBQadavre320kbps1.69MB/s1││││[]@@vqmcr\SeagatePortabl13.6MBQadavre320kbps1.69MB/s1││││[]@@vqmcr\SeagatePortabl21.1MBQadavre320kbps1.69MB/s1││││[]@@vqmcr\SeagatePortabl11.4MBQadavre320kbps1.69MB/s1││││[]@@vqmcr\SeagatePortabl8.5MBQadavre320kbps1.69MB/s1││││[]@@vqmcr\SeagatePortabl17.4MBQadavre320kbps1.69MB/s1││││[]@@vqmcr\SeagatePortabl9.3MBQadavre320kbps1.69MB/s1││││[]@@vqmcr\SeagatePortabl15.6MBQadavre320kbps1.69MB/s1││││[]@@vqmcr\SeagatePortabl16.4MBQadavre320kbps1.69MB/s1││││›[ ] @@vqmcr\SeagatePortabl 17.5 MB Qadavre 320 kbps 1.69 MB/s 1││││↯Dancemaniako0.4MB/11.1MB0.4MB/s││Remix).mp3││││↯Qadavre27.7MB/70.0MB6.3MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail30.3/108.1MB·5.16MB/s·[█████░░░░░░░░░░░░░░░░]27%││DoneSaveYo229││[]@@kpmeh\-2013-\Armad17.5MBDancemaniako320kbps0.46MB/s1││││↯Dancemaniako0.6MB/11.1MB0.5MB/s││Remix).mp3││││↯Qadavre29.3MB/70.0MB4.1MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail32.1/108.1MB·5.73MB/s·[██████░░░░░░░░░░░░░░░]29%││││↯Dancemaniako0.9MB/11.1MB0.4MB/s││Remix).mp3││││↯Qadavre30.9MB/70.0MB4.8MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail34.2/108.1MB·11.34MB/s·[██████░░░░░░░░░░░░░░░]31%││││›[ ] @@kpmeh\- 2013 -\Defec 54.6 MB Dancemaniako - 0.46 MB/s 1││││↯Dancemaniako1.2MB/11.1MB5.9MB/s││Remix).mp3││││↯Qadavre32.6MB/70.0MB5.2MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail36.0/108.1MB·6.22MB/s·[██████░░░░░░░░░░░░░░░]33%││││›[✓] @@kpmeh\- 2013 -\Defec 54.6 MB Dancemaniako - 0.46 MB/s 1││││↯Dancemaniako1.3MB/11.1MB0.5MB/s││Remix).mp3││││↯Qadavre34.3MB/70.0MB5.5MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail38.2/108.1MB·5.47MB/s·[███████░░░░░░░░░░░░░░]35%││Donetiesto0││[]@@kpmeh\-2013-\AllG40.0MBDancemaniako-0.46MB/s1││Donethexx0││[]@@kpmeh\-2013-\Armad17.5MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2013-\Azari50.9MBDancemaniako-0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2013-\Beatp18.1MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2013-\Beatp17.4MBDancemaniako320kbps0.46MB/s1││││[✓]@@kpmeh\-2013-\Defec54.6MBDancemaniako-0.46MB/s1││││[✓]@@kpmeh\-2013-\E-Dec54.6MBDancemaniako-0.46MB/s1││││›[ ] @@kpmeh\- 2013 -\Elect 9.4 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako1.6MB/11.1MB0.5MB/s││Remix).mp3││││↯Qadavre36.2MB/70.0MB4.7MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail40.0/108.1MB·4.45MB/s·[███████░░░░░░░░░░░░░░]36%││Donetiesto0││[]@@kpmeh\-2013-\VA-13.3MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2013-\VA-12.2MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2013-\VA-16.2MBDancemaniako257kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2013-\VA-13.4MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2013-\VA-16.7MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\VA-15.6MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\VA-9.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\VA-17.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\VA-20.4MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\VA-17.5MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\VA-19.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\VA-8.7MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2013-\VA-14.1MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2013-\VA-12.9MBDancemaniako-0.46MB/s1││││›[ ] @@kpmeh\- 2013 -\VA - 7.2 MB Dancemaniako - 0.46 MB/s 1││││↯Dancemaniako1.9MB/11.1MB0.5MB/s││Remix).mp3││││↯Qadavre37.7MB/70.0MB3.9MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail41.8/108.1MB·6.75MB/s·[████████░░░░░░░░░░░░░]38%││Donetiesto0││[]@@kpmeh\-2013-\VA-13.7MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2013-\VA-20.0MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2013-\VA-37.4MBDancemaniako-0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2013-\VA-12.9MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2013-\VA-17.4MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\VA-De14.1MBDancemaniako256kbps0.46MB/s1││││[]@@kpmeh\-2013-\VA-Se16.6MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\VA-Sp18.3MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\VA-Ul14.9MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\Vario54.6MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2013-\Vario22.3MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\Vario15.2MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\Vocal19.8MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2013-\Vocal18.2MBDancemaniako320kbps0.46MB/s1││││›[ ] @@kpmeh\- 2013 -\va - 18.9 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako2.2MB/11.1MB3.8MB/s││Remix).mp3││││↯Qadavre39.2MB/70.0MB2.7MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail43.6/108.1MB·8.12MB/s·[████████░░░░░░░░░░░░░]40%││Donetiesto0││[]@@kpmeh\-2014-\ARDI317.4MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2014-\Armad19.2MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2014-\Armad17.5MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2014-\Armad17.3MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2014-\Armad19.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\Bargr13.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\Beatp19.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\Beatp17.9MBDancemaniako320kbps0.46MB/s1││││›[ ] @@kpmeh\- 2014 -\Beatp 17.6 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako2.4MB/11.1MB3.0MB/s││Remix).mp3││││↯Qadavre40.7MB/70.0MB4.8MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail45.3/108.1MB·4.98MB/s·[████████░░░░░░░░░░░░░]41%││Donetiesto0││[]@@kpmeh\-2014-\Ellum16.0MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2014-\Ellum17.4MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2014-\Ellum17.4MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2014-\Ellum16.8MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2014-\Ellum15.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\GusGu17.5MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\Kompa16.5MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\Maceo13.3MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\Maceo17.8MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\Melod20.6MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\Melod18.4MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\Minis17.5MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\Minis20.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\Pete13.8MBDancemaniako320kbps0.46MB/s1││││›[ ] @@kpmeh\- 2014 -\Resid 17.3 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako2.6MB/11.1MB0.5MB/s││Remix).mp3││││↯Qadavre42.3MB/70.0MB4.2MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail47.2/108.1MB·5.41MB/s·[█████████░░░░░░░░░░░░]43%││Donetiesto0││[]@@kpmeh\-2014-\VA-13.4MBDancemaniako249kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2014-\VA-10.0MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2014-\VA-17.2MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2014-\VA-47.0MBDancemaniako-0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2014-\VA-17.6MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\VA-20.2MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\VA-17.3MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\VA-17.4MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\VA-14.9MBDancemaniako275kbps0.46MB/s1││││[]@@kpmeh\-2014-\VA-17.2MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\VA-22.1MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2014-\VA-Az19.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2014-\VA-De51.5MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2014-\Vario15.2MBDancemaniako320kbps0.46MB/s1││││›[ ] @@kpmeh\- 2014 -\Vario 15.2 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako2.9MB/11.1MB0.5MB/s││Remix).mp3││││↯Qadavre43.8MB/70.0MB5.1MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail49.1/108.1MB·5.33MB/s·[█████████░░░░░░░░░░░░]45%││Donetiesto0││[]@@kpmeh\-2015-\2015\20.5MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2015-\2015\17.2MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2015-\2015\20.5MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2015-\2015\14.7MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2015-\About49.4MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2015-\Armad17.8MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\Armad8.8MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\BT_Be17.7MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\BT_De15.2MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\BT_Te17.7MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\BT_Te20.6MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\Beatp17.7MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\Beatp17.9MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\Beatp17.5MBDancemaniako320kbps0.46MB/s1││││›[ ] @@kpmeh\- 2015 -\Beatp 17.8 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako3.1MB/11.1MB0.5MB/s││Remix).mp3││││↯Qadavre45.5MB/70.0MB4.5MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail50.9/108.1MB·6.48MB/s·[█████████░░░░░░░░░░░░]47%││Donetiesto0││[]@@kpmeh\-2015-\Club18.2MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2015-\Dance17.1MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2015-\Deep11.7MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2015-\Deep18.2MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2015-\Deep17.3MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\Deep16.7MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\Deep9.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\Deep19.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\Deep16.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\Deep16.2MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\Defec16.9MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\Defec17.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\Globa54.3MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2015-\Globa47.0MBDancemaniako-0.46MB/s1││││›[ ] @@kpmeh\- 2015 -\Grace 20.8 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako3.4MB/11.1MB0.5MB/s││Remix).mp3││││↯Qadavre47.0MB/70.0MB5.7MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail52.9/108.1MB·7.52MB/s·[██████████░░░░░░░░░░░]48%││Donetiesto0││[]@@kpmeh\-2015-\Toolr16.9MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2015-\VA-17.2MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2015-\VA-49.5MBDancemaniako-0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2015-\VA-46.4MBDancemaniako-0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2015-\VA-17.8MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\VA-47.1MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2015-\VA-18.6MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\VA-54.8MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2015-\VA-9.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\VA-20.4MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\VA-20.6MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\VA-19.7MBDancemaniako320kbps0.46MB/s1││││›[ ] @@kpmeh\- 2015 -\VA - 18.5 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako3.7MB/11.1MB2.6MB/s││Remix).mp3││││↯Qadavre48.7MB/70.0MB4.6MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail54.7/108.1MB·7.78MB/s·[██████████░░░░░░░░░░░]50%││Donetiesto0││[]@@kpmeh\-2015-\beatp17.7MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2015-\beatp17.9MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2015-\beatp18.0MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2015-\beatp20.5MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2015-\beatp17.7MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2015-\theb16.9MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2016-\01-2017.8MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2016-\201526.5MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2016-\201651.4MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2016-\201651.6MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2016-\201641.2MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2016-\2016.15.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2016-\2016.9.0MBDancemaniako192kbps0.46MB/s1││││[]@@kpmeh\-2016-\2016.10.0MBDancemaniako192kbps0.46MB/s1││││›[ ] @@kpmeh\- 2016 -\BT To 15.2 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako3.9MB/11.1MB3.0MB/s││Remix).mp3││││↯Dozel0.5MB/26.9MB0.1MB/s││TechnoHouse&TranceCollection││││↯Qadavre50.2MB/70.0MB4.7MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail56.4/108.1MB·5.2MB/s·[██████████░░░░░░░░░░░]52%││Donetiesto0││[]@@kpmeh\-2016-\Dutch15.1MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2016-\Minis52.0MBDancemaniako-0.46MB/s1││Donedjbount0││[]@@kpmeh\-2016-\Resid18.8MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2016-\Spinn15.6MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2016-\Tech18.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2016-\Techn20.6MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2016-\Techn18.6MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2016-\Toolr15.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2016-\Top116.3MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2016-\Top117.9MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2016-\Top118.8MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2016-\VA-19.4MBDancemaniako-0.46MB/s1││││›[ ] @@kpmeh\- 2016 -\VA - 19.4 MB Dancemaniako - 0.46 MB/s 1││││↯Dancemaniako4.1MB/11.1MB0.5MB/s││Remix).mp3││││↯Qadavre51.7MB/70.0MB4.4MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail58.3/108.1MB·5.69MB/s·[███████████░░░░░░░░░░]53%││Donetiesto0││[]@@kpmeh\-2016-\remix18.6MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2017-\100E18.4MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2017-\100E18.4MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2017-\2017.11.0MBDancemaniako192kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2017-\2017.10.8MBDancemaniako192kbps0.46MB/s1││││[]@@kpmeh\-2017-\Beatp0.7MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2017-\Beatp17.9MBDancemaniako320kbps0.46MB/s1││││›[ ] @@kpmeh\- 2017 -\Beatp 16.5 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako4.4MB/11.1MB0.5MB/s││Remix).mp3││││↯Qadavre53.4MB/70.0MB5.0MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail60.2/108.1MB·6.04MB/s·[███████████░░░░░░░░░░]55%││Donetiesto0││[]@@kpmeh\-2017-\IP6(212.5MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2017-\Resid16.2MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2017-\Resid18.8MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2017-\This51.8MBDancemaniako-0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2017-\This17.6MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2017-\This51.8MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2017-\VA-16.6MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2017-\VA-17.4MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2017-\VA-16.7MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2017-\VA-9.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2017-\VA-19.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2017-\VA-17.9MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2017-\VA-16.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2017-\VA-16.2MBDancemaniako320kbps0.46MB/s1││││›[ ] @@kpmeh\- 2017 -\VA - 11.0 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako4.6MB/11.1MB0.5MB/s││Remix).mp3││││↯Qadavre55.0MB/70.0MB5.3MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail62.0/108.1MB·3.57MB/s·[████████████░░░░░░░░░]57%││Donetiesto0││[]@@kpmeh\-2018-\21PRE13.0MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2018-\Beatp16.1MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2018-\Beatp20.5MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2018-\Beatp14.5MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2018-\Beatp16.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2018-\Beatp16.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2018-\Beatp13.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2018-\Beatp13.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2018-\Beatp14.9MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2018-\Beatp17.2MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2018-\Beatp16.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2018-\Indie16.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2018-\July5.9MBDancemaniako128kbps0.46MB/s1││││[]@@kpmeh\-2018-\Maceo36.0MBDancemaniako-0.46MB/s1││││›[ ] @@kpmeh\- 2018 -\Maceo 38.6 MB Dancemaniako - 0.46 MB/s 1││││↯Dancemaniako4.9MB/11.1MB0.5MB/s││Remix).mp3││││↯Dozel0.6MB/26.9MB0.1MB/s││TechnoHouse&TranceCollection││││↯Qadavre56.5MB/70.0MB3.0MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail63.9/108.1MB·9.66MB/s·[████████████░░░░░░░░░]59%││Donetiesto0││[]@@kpmeh\-2018-\TheS37.5MBDancemaniako-0.46MB/s1││Donethexx0││[]@@kpmeh\-2018-\Toolr13.5MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2018-\Top516.1MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2018-\VA-13.4MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2018-\VA-40.6MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2018-\Volum19.5MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2018-\Volum21.3MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2018-\tech16.8MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2019-\Beatp17.4MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2019-\Beatp16.3MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2019-\Beatp14.8MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2019-\Fresh14.3MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2019-\Mayda14.3MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2019-\Mayda17.5MBDancemaniako320kbps0.46MB/s1││││›[ ] @@kpmeh\- 2019 -\TOP N 14.0 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako5.1MB/11.1MB0.4MB/s││Remix).mp3││││↯Qadavre58.0MB/70.0MB1.1MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail65.6/108.1MB·3.54MB/s·[████████████░░░░░░░░░]60%││Donetiesto0││[]@@kpmeh\-2021-\VA-17.2MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2021-\VA-18.9MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2021-\VA-Se14.7MBDancemaniako265kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2021-\defec19.0MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2021-\defec17.2MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2022-\Best19.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2022-\Best17.4MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2022-\Defec17.2MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2022-\Defec18.9MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2022-\E-Dan11.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2022-\NewE14.5MBDancemaniako-0.46MB/s1││││[]@@kpmeh\-2022-\VA-19.2MBDancemaniako320kbps0.46MB/s1││││›[ ] @@kpmeh\- 2022 -\VA - 18.9 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako5.3MB/11.1MB2.2MB/s││Remix).mp3││││↯Qadavre59.6MB/70.0MB1.1MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail67.4/108.1MB·1.79MB/s·[█████████████░░░░░░░░]62%││Donetiesto0││[]@@kpmeh\-2023-\TECHN18.4MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\-2023-\TECHN13.5MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\-2023-\TECHN17.5MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\-2023-\VA-20.5MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\-2023-\VA-17.4MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2023-\VA-19.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2023-\VA-17.2MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2024-\Defec19.0MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2024-\Defec18.9MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\-2024-\Hard17.9MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\VariousYears13.5MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\VariousYears16.7MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\VariousYears9.1MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\VariousYears17.2MBDancemaniako320kbps0.46MB/s1││││›[ ] @@kpmeh\Various Years 19.0 MB Dancemaniako 320 kbps 0.46 MB/s 1││││↯Dancemaniako5.6MB/11.1MB0.4MB/s││Remix).mp3││││↯Qadavre61.2MB/70.0MB1.1MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail69.3/108.1MB·2.11MB/s·[█████████████░░░░░░░░]64%││Donetiesto0││[]@@kpmeh\VariousYears17.4MBDancemaniako320kbps0.46MB/s1││Donethexx0││[]@@kpmeh\VariousYears18.2MBDancemaniako320kbps0.46MB/s1││Donedjbount0││[]@@kpmeh\VariousYears15.6MBDancemaniako320kbps0.46MB/s1││DoneSaveYo245││[]@@kpmeh\VariousYears17.5MBDancemaniako320kbps0.46MB/s1││›Done Maceo P 612││[]@@kpmeh\VariousYears13.8MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\VariousYears18.2MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\VariousYears18.3MBDancemaniako320kbps0.46MB/s1││││[]@@kpmeh\VariousYears19.4MBDancemaniako320kbps0.46MB/s1││││[]music3\Chromatics-Sh33.1MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ30.6MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ22.2MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ10.9MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ24.9MBcinstitute2-4.4MB/s1││││[]music3\MaceoPlex-DJ33.6MBcinstitute2-4.4MB/s1││││›[ ] music3\Maceo Plex - DJ 32.9 MB cinstitute2 - 4.4 MB/s 1││││↯Dancemaniako5.8MB/11.1MB0.4MB/s││Remix).mp3││││↯Dozel0.6MB/26.9MB0.6MB/s││TechnoHouse&TranceCollection││││↯Qadavre62.8MB/70.0MB1.1MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail71.1/108.1MB·5.61MB/s·[█████████████░░░░░░░░]65%││││›[ ] @@jlksj\new_music\Ali 14.0 MB Qadavre 320 kbps 1.69 MB/s 1││││↯Dancemaniako6.0MB/11.1MB0.4MB/s││Remix).mp3││││↯Qadavre64.4MB/70.0MB4.8MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail72.8/108.1MB·4.47MB/s·[██████████████░░░░░░░]67%││││↯Dancemaniako6.3MB/11.1MB0.4MB/s││Remix).mp3││││↯Qadavre65.8MB/70.0MB3.6MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail75.0/108.1MB·5.85MB/s·[██████████████░░░░░░░]69%││›Done Maceo P 612││›[ ] Music\0day MP3\2026\20 0.0 MB beatrice.songbi - 1.87 MB/s 1││││↯Dancemaniako6.6MB/11.1MB0.6MB/s││Remix).mp3││││↯Qadavre67.7MB/70.0MB4.8MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:3active,2completed,0fail76.9/108.1MB·5.41MB/s·[██████████████░░░░░░░]71%││›Done Maceo P 612││›[✓] Music\0day MP3\2026\20 0.0 MB beatrice.songbi - 1.87 MB/s 1││││↯Dancemaniako6.9MB/11.1MB0.6MB/s││Remix).mp3││││↯Dozel0.7MB/26.9MB0.5MB/s││TechnoHouse&TranceCollection││││↯Qadavre69.3MB/70.0MB4.3MB/s││byLORDEGRAFvol.12││soulseek-rs🦀v7.0.0Downloads:2active,3completed,0fail7.8/38.0MB·0.91MB/s·[████░░░░░░░░░░░░░░░░░]20%││DoneSaveYo245││›[ ] Music\0day MP3\2026\20 0.0 MB beatrice.songbi - 1.87 MB/s 1││││↯Dancemaniako7.1MB/11.1MB0.5MB/s││Remix).mp3││soulseek-rs🦀v7.0.0Downloads:2active,3completed,0fail8.1/38.0MB·0.84MB/s·[████░░░░░░░░░░░░░░░░░]21%││›Done Maceo P 612││›[ ] music3\Maceo Plex - DJ 26.2 MB cinstitute2 - 4.4 MB/s 1││││↯Dancemaniako7.4MB/11.1MB0.5MB/s││Remix).mp3││soulseek-rs🦀v7.0.0Downloads:2active,3completed,0fail8.4/38.0MB·2.22MB/s·[████░░░░░░░░░░░░░░░░░]22%││││›[ ] music3\Maceo Plex - DJ 24.5 MB cinstitute2 - 4.4 MB/s 1││││↯Dancemaniako7.7MB/11.1MB2.1MB/s││Remix).mp3││soulseek-rs🦀v7.0.0Downloads:2active,3completed,0fail8.7/38.0MB·3.93MB/s·[████░░░░░░░░░░░░░░░░░]22%││││›[ ] music3\Maceo Plex - DJ 43.8 MB cinstitute2 - 4.4 MB/s 1││││↯Dancemaniako7.9MB/11.1MB3.6MB/s││Remix).mp3││soulseek-rs🦀v7.0.0Downloads:2active,3completed,0fail8.9/38.0MB·0.83MB/s·[████░░░░░░░░░░░░░░░░░]23%││││[✓]music3\MaceoPlex-DJ43.8MBcinstitute2-4.4MB/s1││││›[✓] music3\Maceo Plex - DJ 26.3 MB cinstitute2 - 4.4 MB/s 1││││↯Dancemaniako8.1MB/11.1MB0.5MB/s││Remix).mp3││soulseek-rs🦀v7.0.0Downloads:2active,3completed,0fail9.4/38.0MB·4.05MB/s·[█████░░░░░░░░░░░░░░░░]24%││││↯Dancemaniako8.5MB/11.1MB3.7MB/s││Remix).mp3││soulseek-rs🦀v7.0.0Downloads:4active,3completed,0fail10.5/73.6MB·7.42MB/s·[██░░░░░░░░░░░░░░░░░░░]14%││││↯Dancemaniako8.8MB/11.1MB2.7MB/s││Remix).mp3││soulseek-rs🦀v7.0.0Downloads:5active,3completed,0fail15.5/117.4MB·33.69MB/s·[██░░░░░░░░░░░░░░░░░░░]13%││││↯Dancemaniako9.1MB/11.1MB1.8MB/s││Remix).mp3││││↯Dozel0.9MB/26.9MB0.3MB/s││TechnoHouse&TranceCollection││││↯cinstitute20.3MB/43.8MB3.3MB/s││Size9.4MB││soulseek-rs🦀v7.0.0Downloads:5active,3completed,0fail26.6/117.4MB·32.45MB/s·[████░░░░░░░░░░░░░░░░░]22%││││↯Dancemaniako9.3MB/11.1MB2.7MB/s││Remix).mp3││││↯cinstitute20.5MB/43.8MB7.2MB/s││Size9.4MB││soulseek-rs🦀v7.0.0Downloads:4active,4completed,0fail17.2/99.3MB·8.19MB/s·[███░░░░░░░░░░░░░░░░░░]17%││││↯Dancemaniako9.6MB/11.1MB2.8MB/s││Remix).mp3││││↯cinstitute20.9MB/43.8MB0.7MB/s││Size9.4MB││soulseek-rs🦀v7.0.0Downloads:5active,4completed,0fail21.5/99.3MB·23.03MB/s·[████░░░░░░░░░░░░░░░░░]21%││││StatUserProgressSpeed││20SaveYourAtoll-││││✓DancemaniakoCompleted-││Mediterranea.flac││││↯Dancemaniako9.8MB/11.1MB1.7MB/s││@@wztnu/MUZYKA/FLAC/TECHNO/The││││⋯DancemaniakoQueued-││GodsPlanet-X(FLACWEB)[The││││›↯ Dozel 0.9 MB/26.9 MB 0.3 MB/s││GodsPlanet]2020-12-10││││✓QadavreCompleted-││││││✓vivioCompleted-││UserDozel││││⋯sillysillysillyQueued-││Size26.9MB││││↯cinstitute23.2MB/43.8MB15.2MB/s││StatusInprogress││soulseek-rs🦀v7.0.0Downloads:4active,5completed,0fail27.1/99.3MB·41.66MB/s·[█████░░░░░░░░░░░░░░░░]27%││││StatUserProgressSpeed││1-15.Jaydee-Jaydee-PP.flac││││✓DancemaniakoCompleted-││music3/MaceoPlex-DJ-Kicks││││↯Dancemaniako10.1MB/11.1MB2.1MB/s││(2013)[CD-FLAC]││││⋯DancemaniakoQueued-││││││↯Dozel0.9MB/26.9MB0.3MB/s││Usercinstitute2││││✓QadavreCompleted-││Size43.8MB││││✓vivioCompleted-││StatusInprogress││││⋯sillysillysillyQueued-││Saveto││││›↯ cinstitute2 6.9 MB/43.8 MB 33.8 MB/s││/Users/micheldegraaf/Downloads/S││││⋯cinstitute2Queued-││oulseek││soulseek-rs🦀v7.0.0Downloads:4active,5completed,0fail32.9/99.3MB·171.54MB/s·[██████░░░░░░░░░░░░░░░]33%││││↯cinstitute210.9MB/43.8MB166.9MB/s││@@kpmeh/-2013-/E-Dance.││││⋯cinstitute2Queued-││UserDancemaniako││││✓beatrice.songbiCompleted-││Size11.1MB││││↯Qadavre10.9MB/17.5MB3.9MB/s││StatusQueued││││⋯QadavreQueued-││Saveto││soulseek-rs🦀v7.0.0Downloads:4active,5completed,0fail45.0/99.3MB·39.69MB/s·[█████████░░░░░░░░░░░░]45%││││StatUserProgressSpeed││113.SaveYourAtoll-El││││✓DancemaniakoCompleted-││Ritual.mp3││││↯Dancemaniako10.5MB/11.1MB0.5MB/s││@@kpmeh/-2021-/ThePlastic││││›⋯ Dancemaniako Queued -││Moon.TechnoSet││││↯Dozel1.0MB/26.9MB0.1MB/s││││││✓QadavreCompleted-││UserDancemaniako││││✓vivioCompleted-││Size16.5MB││││⋯sillysillysillyQueued-││StatusQueued││││↯cinstitute221.3MB/43.8MB34.9MB/s││Saveto││││⋯cinstitute2Queued-││/Users/micheldegraaf/Downloads/S││soulseek-rs🦀v7.0.0Downloads:4active,5completed,0fail60.2/99.3MB·118.74MB/s·[████████████░░░░░░░░░]60%││││StatUserProgressSpeed││9.AzioneAntropica.flac││││✓DancemaniakoCompleted-││@@qxibn/music2024/SaveYour││││↯Dancemaniako10.7MB/11.1MB0.4MB/s││Atoll/SaveYourAtoll-2023-││││⋯DancemaniakoQueued-││TheBigBreath[24bit]││││↯Dozel1.0MB/26.9MB0.5MB/s││││││✓QadavreCompleted-││User││││✓vivioCompleted-││sillysillysilly││││›⋯ sillysillysilly Queued -││Size59.7MB││││↯cinstitute235.0MB/43.8MB114.0MB/s││StatusQueued││soulseek-rs🦀v7.0.0Downloads:3active,6completed,0fail27.0/55.5MB·2.07MB/s·[██████████░░░░░░░░░░░]48%││││StatUserProgressSpeed││1-16.S.A.M.-Nangijala(Maceo││││⋯DancemaniakoQueued-││PlexReggaeEdit).flac││││↯Dozel1.0MB/26.9MB0.5MB/s││music3/MaceoPlex-DJ-Kicks││││✓QadavreCompleted-││(2013)[CD-FLAC]││││⋯sillysillysillyQueued-││Usercinstitute2││││✓cinstitute2Completed-││Size26.3MB││││⋯cinstitute2Queued-││StatusQueued││││✓beatrice.songbiCompleted-││Saveto││││›⋯ cinstitute2 Queued -││/Users/micheldegraaf/Downloads/S││soulseek-rs🦀v7.0.0Downloads:2active,7completed,0fail17.8/44.4MB·5.28MB/s·[████████░░░░░░░░░░░░░]40%││││✓cinstitute2Completed-││@@kpmeh/-2013-/E-Dance.││││⋯cinstitute2Queued-││User││││⋯cinstitute2Queued-││UserQadavre││││✓beatrice.songbiCompleted-││Size23.6MB││││↯Qadavre16.5MB/17.5MB4.1MB/s││StatusQueued││││›⋯ Qadavre Queued -││Saveto││soulseek-rs🦀v7.0.0Downloads:2active,8completed,0fail1.2/53.2MB·1.03MB/s·[░░░░░░░░░░░░░░░░░░░░░]2%││││StatUserProgressSpeed││08.MaceoPlex-StayHigh││││✓cinstitute2Completed-││Baby.flac││││↯cinstitute20.2MB/26.2MB1.0MB/s││@@kpmeh/-2013-/Defected││││✓beatrice.songbiCompleted-││PresentsMostRated2013││││✓QadavreCompleted-││Size54.6MB││││⋯QadavreQueued-││StatusQueued││soulseek-rs🦀v7.0.0Downloads:4active,8completed,0fail3.1/93.2MB·12.57MB/s·[░░░░░░░░░░░░░░░░░░░░░]3%││││✓cinstitute2Completed-││2022.mp3││││↯cinstitute20.9MB/26.2MB7.4MB/s││@@kpmeh/-2013-/E-Dance.││││✓beatrice.songbiCompleted-││ClubbingElectroHouseVol.12││││✓QadavreCompleted-││Size11.1MB││││↯Qadavre1.2MB/23.6MB4.9MB/s││StatusQueued││││›⋯ Dancemaniako Queued -││Saveto││soulseek-rs🦀v7.0.0Downloads:4active,8completed,0fail5.5/93.2MB·4.75MB/s·[█░░░░░░░░░░░░░░░░░░░░]5%││││↯cinstitute21.4MB/26.2MB0.3MB/s││@@jlksj/new_music/EllumAudio││││↯Qadavre3.1MB/23.6MB4.1MB/s││StatusCompleted││soulseek-rs🦀v7.0.0Downloads:4active,8completed,0fail11.6/93.2MB·138.39MB/s·[██░░░░░░░░░░░░░░░░░░░]12%││││↯cinstitute25.4MB/26.2MB132.9MB/s││@@jlksj/new_music/EllumAudio││││↯Qadavre4.6MB/23.6MB6.4MB/s││StatusCompleted││soulseek-rs🦀v7.0.0Downloads:4active,8completed,0fail20.4/93.2MB·228.76MB/s·[████░░░░░░░░░░░░░░░░░]21%││││↯cinstitute212.6MB/26.2MB223.4MB/s││@@jlksj/new_music/EllumAudio││││↯Qadavre6.4MB/23.6MB4.5MB/s││StatusCompleted││soulseek-rs🦀v7.0.0Downloads:4active,8completed,0fail23.6/93.2MB·130.5MB/s·[█████░░░░░░░░░░░░░░░░]25%││││↯cinstitute213.8MB/26.2MB125.7MB/s││@@jlksj/new_music/EllumAudio││││↯Qadavre8.0MB/23.6MB4.0MB/s││StatusCompleted││soulseek-rs🦀v7.0.0Downloads:3active,9completed,0fail11.3/67.0MB·4.72MB/s·[███░░░░░░░░░░░░░░░░░░]16%││││↯Qadavre9.2MB/23.6MB3.9MB/s││StatusCompleted││soulseek-rs🦀v7.0.0Downloads:3active,9completed,0fail13.0/67.0MB·9.41MB/s·[████░░░░░░░░░░░░░░░░░]19%││││↯Qadavre10.7MB/23.6MB4.9MB/s││StatusCompleted││soulseek-rs🦀v7.0.0Downloads:4active,9completed,0fail15.3/93.3MB·12.98MB/s·[███░░░░░░░░░░░░░░░░░░]16%││soulseek-rs🦀v7.0.0Downloads:4active,9completed,0fail17.9/93.3MB·14.03MB/s·[████░░░░░░░░░░░░░░░░░]19%││soulseek-rs🦀v7.0.0Downloads:4active,9completed,0fail21.0/93.3MB·15.71MB/s·[████░░░░░░░░░░░░░░░░░]22%││soulseek-rs🦀v7.0.0Downloads:4active,9completed,0fail26.6/93.3MB·45.35MB/s·[█████░░░░░░░░░░░░░░░░]28%││soulseek-rs🦀v7.0.0Downloads:4active,9completed,0fail39.0/93.3MB·82.45MB/s·[████████░░░░░░░░░░░░░]41%││soulseek-rs🦀v7.0.0Downloads:3active,10completed,0fai24.8/67.0MB·5.38MB/s·[███████░░░░░░░░░░░░░░]37%││soulseek-rs🦀v7.0.0Downloads:3active,10completed,0fai27.0/67.0MB·5.87MB/s·[████████░░░░░░░░░░░░░]40%│││›▏│││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai4.6/43.4MB·0.71MB/s·[██░░░░░░░░░░░░░░░░░░░]10%│││›h▏│││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai4.9/43.4MB·3.01MB/s·[██░░░░░░░░░░░░░░░░░░░]11%│││›hello▏│││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai5.1/43.4MB·2.4MB/s·[██░░░░░░░░░░░░░░░░░░░]11%│││›hellowo▏│││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai5.4/43.4MB·1.62MB/s·[██░░░░░░░░░░░░░░░░░░░]12%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai5.7/43.4MB·2.2MB/s·[██░░░░░░░░░░░░░░░░░░░]13%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai5.9/43.4MB·2MB/s·[██░░░░░░░░░░░░░░░░░░░]13%││Done│04:59→jackass44│Users(327)│1│││›helloworld!▏│││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai6.1/43.4MB·0.76MB/s·[██░░░░░░░░░░░░░░░░░░░]14%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai6.4/43.4MB·2.28MB/s·[███░░░░░░░░░░░░░░░░░░]14%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai6.7/43.4MB·0.91MB/s·[███░░░░░░░░░░░░░░░░░░]15%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai7.0/43.4MB·8.28MB/s·[███░░░░░░░░░░░░░░░░░░]16%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai7.2/43.4MB·3.15MB/s·[███░░░░░░░░░░░░░░░░░░]16%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai7.5/43.4MB·6.2MB/s·[███░░░░░░░░░░░░░░░░░░]17%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai7.7/43.4MB·0.93MB/s·[███░░░░░░░░░░░░░░░░░░]17%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai8.0/43.4MB·4.37MB/s·[███░░░░░░░░░░░░░░░░░░]18%││Done│04:59←stupidmusichoarder│▸ bradybunch668│1││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai8.2/43.4MB·0.45MB/s·[███░░░░░░░░░░░░░░░░░░]18%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai8.5/43.4MB·0.84MB/s·[████░░░░░░░░░░░░░░░░░]19%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai8.8/43.4MB·0.95MB/s·[████░░░░░░░░░░░░░░░░░]20%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai9.1/43.4MB·3.67MB/s·[████░░░░░░░░░░░░░░░░░]20%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai9.4/43.4MB·2.19MB/s·[████░░░░░░░░░░░░░░░░░]21%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai9.6/43.4MB·0.89MB/s·[████░░░░░░░░░░░░░░░░░]22%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai9.9/43.4MB·0.84MB/s·[████░░░░░░░░░░░░░░░░░]22%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai10.1/43.4MB·0.81MB/s·[████░░░░░░░░░░░░░░░░░]23%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai10.4/43.4MB·0.54MB/s·[█████░░░░░░░░░░░░░░░░]23%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai10.7/43.4MB·0.78MB/s·[█████░░░░░░░░░░░░░░░░]24%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai11.0/43.4MB·3.37MB/s·[█████░░░░░░░░░░░░░░░░]25%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai11.2/43.4MB·3.19MB/s·[█████░░░░░░░░░░░░░░░░]25%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai11.5/43.4MB·2.22MB/s·[█████░░░░░░░░░░░░░░░░]26%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai11.7/43.4MB·1.76MB/s·[█████░░░░░░░░░░░░░░░░]26%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai11.9/43.4MB·1.89MB/s·[█████░░░░░░░░░░░░░░░░]27%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai12.2/43.4MB·0.83MB/s·[█████░░░░░░░░░░░░░░░░]28%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai12.5/43.4MB·0.54MB/s·[██████░░░░░░░░░░░░░░░]28%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai12.8/43.4MB·1.13MB/s·[██████░░░░░░░░░░░░░░░]29%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai13.0/43.4MB·1.06MB/s·[██████░░░░░░░░░░░░░░░]30%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai13.3/43.4MB·0.97MB/s·[██████░░░░░░░░░░░░░░░]30%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai13.6/43.4MB·1.02MB/s·[██████░░░░░░░░░░░░░░░]31%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai13.8/43.4MB·7.92MB/s·[██████░░░░░░░░░░░░░░░]31%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai14.1/43.4MB·2.01MB/s·[██████░░░░░░░░░░░░░░░]32%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai14.3/43.4MB·2.31MB/s·[██████░░░░░░░░░░░░░░░]32%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai14.6/43.4MB·0.78MB/s·[███████░░░░░░░░░░░░░░]33%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai14.9/43.4MB·2.39MB/s·[███████░░░░░░░░░░░░░░]34%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai15.2/43.4MB·0.78MB/s·[███████░░░░░░░░░░░░░░]35%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai15.5/43.4MB·3.12MB/s·[███████░░░░░░░░░░░░░░]35%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai15.7/43.4MB·0.81MB/s·[███████░░░░░░░░░░░░░░]36%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai16.0/43.4MB·0.81MB/s·[███████░░░░░░░░░░░░░░]36%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai16.3/43.4MB·0.79MB/s·[███████░░░░░░░░░░░░░░]37%││soulseek-rs🦀v7.0.0Downloads:2active,11completed,0fai16.3/43.4MB·0.54MB/s·[███████░░░░░░░░░░░░░░]37%││[Space→select][Enter→download][b→browseowner][c→chat][/→filter[r→re-index][Esc→close]⟨✉2⟩│ \ No newline at end of file +╭Status────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│soulseek-rs🦀v14.1.2Downloads:0active,0completed,0failed,0que│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╭[2]Results───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│soulseek-rsv14.1.2││││Noresultsyet.││Pickasearchin[1]Searches,orstartanewone.│╭[1]Searches────────────────────╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮│StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││Selectaresultortransferfordetails│││││││╰──────────────────────────────────╯╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯╭Shortcuts·Sharing:~/Music──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│[s→search][Enter→results][d→remove][m→message][i→inbox][c→chat][b→browseuser][h/l→scrollquery][Tab/1-3→pane]││[z→zoom][w→hide][?→keys][q→quit]│╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│search:│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│[Type→searchterm][←→→movecursor][Backspace/Del→edit][Enter→search][Esc→cancel]││search:public││search:publicdomain││search:publicdomainfield││search:publicdomainfieldrecordings│╭[2]Results:publicdomainfieldrecordings───────────────────────────────────────────────────────────────────────────────────────────────────────╮│soulseek-rsv14.1.2││││Noresultsyet.││Pickasearchin[1]Searches,orstartanewone.│╭[1]Searches────────────────────╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮│StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││Selectaresultortransferfordetails││›Activepublic domain fi…│││││││││││╰──────────────────────────────────╯╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[Space→select][Enter→download][b→browseowner][c→chat][/→filter][a/A→selectall/none][h/l→scrollname][g/G→top/end]││[Tab/1-3→pane][z→zoom][w→hide][?→keys][q→quit]││✓FilenameSizeUserBitrateSpeedSlots││›[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\01 - Coastal D30.5 MBloopdigger675 kbps3.35 MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\02-Reservoir22.6MBloopdigger569kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-Marshat48.0MBloopdigger920kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepo32.2MBloopdigger725kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\05-HarbourB52.5MBloopdigger902kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\06-RainonZ30.4MBloopdigger804kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\cover.jpg0.4MBloopdigger-3.35MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││01-CoastalDawn(SunriseTake,Long││›Activepublic domain fi7││││Version).flac││││││@@gufoo/FieldRecordings/CoastalDawn││││││Sessions(2019)[FLAC24-96]││││││Userloopdigger││││││Size30.5MB││││││Bitrate675kbps││││││Length6m52s││││││Freeslots1available││││││Uploadspeed3.4MB/s││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0117.4MBtapehiss836kbps2.1MB/s2││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0238.0MBtapehiss741kbps2.1MB/s2││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0334.6MBtapehiss753kbps2.1MB/s2││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0418.7MBtapehiss723kbps2.1MB/s2││›Activepublic domain fi11││││Version).flac││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\01-CoastalD30.5MBloopdigger675kbps3.35MB/s1││›[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\02 - Reservoir22.6 MBloopdigger569 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││02-ReservoirWind.flac││›Activepublic domain fi11││││@@gufoo/FieldRecordings/CoastalDawn││││││Size22.6MB││││││Bitrate569kbps││││││Length5m18s││›[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\03 - Marsh at48.0 MBloopdigger920 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││03-MarshatNight(Long).flac││││││Size48.0MB││││││Bitrate920kbps││││││Length8m21s││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-9.4MBnectoUA320kbps1.05MB/s0││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-5.1MBnectoUA320kbps1.05MB/s0││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-19.5MBnectoUA320kbps1.05MB/s0││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-0.1MBnectoUA-1.05MB/s0││›Activepublic domain fi15││││@@gufoo/FieldRecordings/CoastalDawn││›[✓]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\03 - Marsh at48.0 MBloopdigger920 kbps3.35 MB/s1││[✓]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-Marshat48.0MBloopdigger920kbps3.35MB/s1││›[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\04 - Tram Depo32.2 MBloopdigger725 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││04-TramDepot,06h.flac││││││Size32.2MB││││││Bitrate725kbps││││││Length6m06s││›[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour B52.5 MBloopdigger902 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││05-HarbourBells.flac││││││Size52.5MB││││││Bitrate902kbps││││││Length9m33s││›[✓]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour B52.5 MBloopdigger902 kbps3.35 MB/s1││›Donepublic domain fi15││││@@gufoo/FieldRecordings/CoastalDawn││soulseek-rs🦀v14.1.2Downloads:0active,0completed,0failed,2que││StatusQueryResults││StatusFilenameUserProgressSpeed││05-HarbourBells.flac││›Donepublic domain fi15││›⋯@@gufoo\FloopdiggerQueued-││@@gufoo/FieldRecordings/CoastalDawn││││⋯@@gufoo\FloopdiggerQueued-││Sessions(2019)[FLAC24-96]│╭[2]Results:publicdomainfieldrecordings───────────────────────────────────────────────────────────────────────────────────────────────────────╮│✓FilenameSizeUserBitrateSpeedSlots││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\01-CoastalD30.5MBloopdigger675kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\02-Reservoir22.6MBloopdigger569kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-Marshat48.0MBloopdigger920kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepo32.2MBloopdigger725kbps3.35MB/s1││›[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour B52.5 MBloopdigger902 kbps3.35 MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\06-RainonZ30.4MBloopdigger804kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\cover.jpg0.4MBloopdigger-3.35MB/s1││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0117.4MBtapehiss836kbps2.1MB/s2││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0238.0MBtapehiss741kbps2.1MB/s2││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0334.6MBtapehiss753kbps2.1MB/s2││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0418.7MBtapehiss723kbps2.1MB/s2││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-9.4MBnectoUA320kbps1.05MB/s0││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-5.1MBnectoUA320kbps1.05MB/s0││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-19.5MBnectoUA320kbps1.05MB/s0││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-0.1MBnectoUA-1.05MB/s0│╭[1]Searches────────────────────╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮│StatusQueryResults││StatusFilenameUserProgressSpeed││05-HarbourBells.flac││›Donepublic domain fi15││›⋯@@gufoo\FloopdiggerQueued-││@@gufoo/FieldRecordings/CoastalDawn││││⋯@@gufoo\FloopdiggerQueued-││Sessions(2019)[FLAC24-96]││││││││││││Userloopdigger││││││Size52.5MB││││││StatusQueued││││││Saveto││││││/home/demo/Music/Soulseek││││││Bitrate902kbps││││││Length9m33s│╰──────────────────────────────────╯╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[p→pause/resume][x→cancel][r→retryfailed][d→deletequeued/done][c→clearfinished][b→browseuser][h/l→scrollname]││││││StatusInprogress││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que5.6/100.5MB·18.61MB/s·[█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]5%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger2.9 MB/52.5 MB (5%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger2.7MB/48.0MB(5%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que11.2/100.5MB·18.61MB/s·[███░░░░░░░░░░░░░░░░░░░░░░░░░░░░]11%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger5.8 MB/52.5 MB (11%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger5.3MB/48.0MB(11%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que16.8/100.5MB·18.61MB/s·[█████░░░░░░░░░░░░░░░░░░░░░░░░░░]16%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger8.8 MB/52.5 MB (16%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger8.0MB/48.0MB(16%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que25.1/100.5MB·18.61MB/s·[███████░░░░░░░░░░░░░░░░░░░░░░░░]25%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger13.1 MB/52.5 MB (25%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger12.0MB/48.0MB(25%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que30.7/100.5MB·18.61MB/s·[█████████░░░░░░░░░░░░░░░░░░░░░░]30%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger16.0 MB/52.5 MB (30%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger14.7MB/48.0MB(30%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que36.3/100.5MB·18.61MB/s·[███████████░░░░░░░░░░░░░░░░░░░░]36%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger19.0 MB/52.5 MB (36%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger17.3MB/48.0MB(36%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que41.9/100.5MB·18.61MB/s·[████████████░░░░░░░░░░░░░░░░░░░]41%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger21.9 MB/52.5 MB (41%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger20.0MB/48.0MB(41%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer5.6MB/41.0MB(13%)3.7MB/s│││╭[3]Downloads/Uploads─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│StatusFilenameUserProgressSpeed││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger21.9 MB/52.5 MB (41%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger20.0MB/48.0MB(41%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer6.0MB/41.0MB(14%)3.7MB/s││[Tab/1-3→pane][z→unzoom][w→hide][?→keys][q→quit]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que44.7/100.5MB·18.61MB/s·[█████████████░░░░░░░░░░░░░░░░░░]44%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger23.3 MB/52.5 MB (44%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger21.3MB/48.0MB(44%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que50.2/100.5MB·18.61MB/s·[███████████████░░░░░░░░░░░░░░░░]50%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger26.2 MB/52.5 MB (50%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger24.0MB/48.0MB(50%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que55.8/100.5MB·18.61MB/s·[█████████████████░░░░░░░░░░░░░░]55%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger29.2 MB/52.5 MB (55%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger26.7MB/48.0MB(55%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que61.4/100.5MB·18.61MB/s·[██████████████████░░░░░░░░░░░░░]61%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger32.1 MB/52.5 MB (61%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger29.3MB/48.0MB(61%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que67.0/100.5MB·18.61MB/s·[████████████████████░░░░░░░░░░░]66%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger35.0 MB/52.5 MB (66%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger32.0MB/48.0MB(66%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que72.6/100.5MB·18.61MB/s·[██████████████████████░░░░░░░░░]72%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger37.9 MB/52.5 MB (72%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger34.7MB/48.0MB(72%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que78.2/100.5MB·18.61MB/s·[████████████████████████░░░░░░░]77%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger40.8 MB/52.5 MB (77%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger37.3MB/48.0MB(77%)8.9MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger38.7MB/48.0MB(80%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer13.6MB/41.0MB(33%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que83.8/100.5MB·18.61MB/s·[█████████████████████████░░░░░░]83%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger43.8 MB/52.5 MB (83%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger40.0MB/48.0MB(83%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer14.0MB/41.0MB(34%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que89.3/100.5MB·18.61MB/s·[███████████████████████████░░░░]88%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger46.7 MB/52.5 MB (88%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger42.7MB/48.0MB(88%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que94.9/100.5MB·18.61MB/s·[█████████████████████████████░░]94%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger49.6 MB/52.5 MB (94%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger45.3MB/48.0MB(94%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que97.7/100.5MB·18.61MB/s·[██████████████████████████████░]97%││StatusQueryResults││StatusFilenameUserProgressSpeed││05-HarbourBells.flac││›Donepublic domain fi15││›↯@@gufoo\Floopdigger51.0 MB/52.5 MB (97%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger46.7MB/48.0MB(97%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer17.0MB/41.0MB(41%)3.7MB/s││││││││Userloopdigger││││││Size52.5MB││││││StatusInprogress││││││Saveto││││││/home/demo/Music/Soulseek││││││Bitrate902kbps││││││Length9m33s││soulseek-rs🦀v14.1.2Downloads:0active,2completed,0failed,0que││›Donepublic domain fi15││›✓@@gufoo\FloopdiggerCompleted-││@@gufoo/FieldRecordings/CoastalDawn││││✓@@gufoo\FloopdiggerCompleted-││Sessions(2019)[FLAC24-96]││││││StatusCompleted││││↑↯HarbourBcrateminer19.9MB/41.0MB(48%)3.7MB/s│││╭[3]Downloads/Uploads─────────────────────────────────────────────────────────────────────╮╭[Info]──────────────────────────────────────────────╮│StatusFilenameUserProgressSpeed││05-HarbourBells.flac││›✓@@gufoo\Field Recordings\CoastalloopdiggerCompleted-││@@gufoo/FieldRecordings/CoastalDawnSessions││✓@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-││(2019)[FLAC24-96]││↑↯HarbourBells(LiveattheQuay)crateminer20.3MB/41.0MB(49%)3.7MB/s││││││Userloopdigger││││Size52.5MB││││Bitrate902kbps││││Length9m33s││││││││Freeslots1available││││Uploadspeed3.4MB/s│╰────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────╯│↑↯HarbourBells(LiveattheQuay)crateminer27.6MB/41.0MB(67%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer29.2MB/41.0MB(71%)3.7MB/s││││[]…eldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\01-CoastalDawn(Sunr30.5MBloopdigger675kbps3.35MB/s1││[]…eldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\02-ReservoirWind.fla22.6MBloopdigger569kbps3.35MB/s1││[]…eldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(Lo48.0MBloopdigger920kbps3.35MB/s1││[]…eldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepot,06h.fl32.2MBloopdigger725kbps3.35MB/s1││›[ ]…eld Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flac52.5 MBloopdigger902 kbps3.35 MB/s1││[]…eldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\06-RainonZincRoof.30.4MBloopdigger804kbps3.35MB/s1││[]…less\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\01-EmptyC17.4MBtapehiss836kbps2.1MB/s2││[]…less\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\02-Forest38.0MBtapehiss741kbps2.1MB/s2││[]…less\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\03-Quarry34.6MBtapehiss753kbps2.1MB/s2││[]…less\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\04-NightF18.7MBtapehiss723kbps2.1MB/s2││[]…mplete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-BY)\01-9.4MBnectoUA320kbps1.05MB/s0││[]…mplete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-BY)\02-5.1MBnectoUA320kbps1.05MB/s0││[]…mplete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-BY)\03-19.5MBnectoUA320kbps1.05MB/s0││[]…mplete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-BY)\readm0.1MBnectoUA-1.05MB/s0││↑↯HarbourBells(LiveattheQuay)crateminer35.3MB/41.0MB(86%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer38.2MB/41.0MB(93%)3.7MB/s││││││↑↯HarbourBcrateminer38.6MB/41.0MB(94%)3.7MB/s││││││↑✓HarbourBcrateminerCompleted-││││[]@@gufoo\Field╭Keys───────────────────────────────────────────────────────────────────────────────────────────────────╮3.35MB/s1││[]@@gufoo\Field│PanesSearches│3.35MB/s1││›[ ]@@gufoo\Field│Tab/Shift-Tabnext/previouspaneEntershowitsresults│3.35 MB/s1││[]@@gufoo\Field│123focusapane,hiddenornotdremovethesearch│3.35MB/s1││[]@@gufoo\Field│zzoomthefocusedpaneCcleareverysearch│3.35MB/s1││[]Music\lossless│whidethefocusedpanecchatrooms│2.1MB/s2││[]Music\lossless│Escleavezoom│2.1MB/s2││[]Music\lossless│clickfocusapaneResults│2.1MB/s2││[]Music\lossless│Spaceselect/deselect│2.1MB/s2││[]@@hnttf\comple│Anywherea/Aselectall/none│1.05MB/s0││[]@@hnttf\comple│ssearchthenetworkEnterdownloadtheselection│1.05MB/s0││[]@@hnttf\comple│bbrowseauser'sfiles/filter,Enterkeepsit,Esccle│1.05MB/s0││[]@@hnttf\comple│mcomposeaprivatemessagebbrowsetheowner│1.05MB/s0│││iinboxcchatrooms││││osettings││││?thislistDownloads/Uploads││││qquitppause/resume││╰────────────────────│xcancel│─────────────────────╯╭[1]Searches──────│Anylistrretryafailedone│─────────────────────╮│StatusQuery│↑↓/kjmoveonerowddeleteaqueuedorfinishedone│flac││›Donepublic doma│HomeEnd/gGfirst/lastrowccleareveryfinishedone│dings/CoastalDawn│││PgUpPgDn/^b^fonepage│AC24-96]│││^u^dhalfapageChatandbrowsepopups││││hl/←→scrollalongnamesidewaysPgUpPgDn/^b^fapageofmessages,orofthet│loopdigger│││0/$itsstart/itsend^u^dhalfapageofthem│52.5MB│││HomeEnd/gGoldest/newest,orfirst/las│Completed│││Tab/Shift-Tabnextroom,chatoruser│││╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ulseek││[?/Esc→close]│╰────────────────────────────────────────────────────────────────╭[1]Searches────────────────────╮╭[3]Downloads/Uploads───│││││╭Shortcuts·Sharing:~/Music───────────────────────────────────────────────────────────────────────────│[s→search][Enter→results][d→remove][m→message][i→inbox][c→chat][b→browseuser][h/l→scrollquery╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│StatusQueryResults╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮╭│││││╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│[Type→searchterm][←→→movecursor][Backspace/Del→edit][Enter→search]││search:p││search:pu││search:pub││search:publ││search:publi││search:publicd││search:publicdo││search:publicdom││search:publicdoma││search:publicdomai││search:publicdomainf││search:publicdomainfi││search:publicdomainfie││search:publicdomainfiel││search:publicdomainfieldr││search:publicdomainfieldre││search:publicdomainfieldrec││search:publicdomainfieldreco││search:publicdomainfieldrecor││search:publicdomainfieldrecord││search:publicdomainfieldrecordi││search:publicdomainfieldrecordin││search:publicdomainfieldrecording│╰────────────s────────────────────╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮│StatusQueryResults│││││││││││╰──────────────────────────────────╯╰──────────────────────││[Space→select][Enter→download]───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│[Tab/1-3→pane][z][←→→movecursor][Backspace/Del→edit][Enter→search][Esc→cancel]││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2││││││Length││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\041││›Activepublic domain fi11││││@@gufoo/FieldRecordings/CoastalDa││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-0│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│[p→pause/resume][x→cancel][r→retryfailed][d→deletequeued/done][c[a/A→selectall/none][h/l→scrollname][g/G→top/end]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que2.8/100.5MB·18.61MB/s·[░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]2%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger1.5 MB/52.5 MB (2%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger1.3MB/48.0MB(2%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que8.4/100.5MB·18.61MB/s·[██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]8%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger4.4 MB/52.5 MB (8%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger4.0MB/48.0MB(8%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que14.0/100.5MB·18.61MB/s·[████░░░░░░░░░░░░░░░░░░░░░░░░░░░]13%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger7.3 MB/52.5 MB (13%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger6.7MB/48.0MB(13%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer0.2MB/41.0MB(0%)3.7MB/s││││││↑↯HarbourBcrateminer0.5MB/41.0MB(1%)3.7MB/s││││││↑↯HarbourBcrateminer0.9MB/41.0MB(2%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que19.5/100.5MB·18.61MB/s·[██████░░░░░░░░░░░░░░░░░░░░░░░░░]19%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger10.2 MB/52.5 MB (19%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger9.3MB/48.0MB(19%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer1.3MB/41.0MB(3%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que22.3/100.5MB·18.61MB/s·[██████░░░░░░░░░░░░░░░░░░░░░░░░░]22%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger11.7 MB/52.5 MB (22%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger10.7MB/48.0MB(22%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer1.7MB/41.0MB(4%)3.7MB/s││││││↑↯HarbourBcrateminer2.1MB/41.0MB(5%)3.7MB/s││││││↑↯HarbourBcrateminer2.5MB/41.0MB(6%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que27.9/100.5MB·18.61MB/s·[████████░░░░░░░░░░░░░░░░░░░░░░░]27%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger14.6 MB/52.5 MB (27%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger13.3MB/48.0MB(27%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer2.9MB/41.0MB(7%)3.7MB/s││││││↑↯HarbourBcrateminer3.3MB/41.0MB(7%)3.7MB/s││││││↑↯HarbourBcrateminer3.6MB/41.0MB(8%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que33.5/100.5MB·18.61MB/s·[██████████░░░░░░░░░░░░░░░░░░░░░]33%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger17.5 MB/52.5 MB (33%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger16.0MB/48.0MB(33%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer4.0MB/41.0MB(9%)3.7MB/s││││││↑↯HarbourBcrateminer4.4MB/41.0MB(10%)3.7MB/s││││││↑↯HarbourBcrateminer4.8MB/41.0MB(11%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que39.1/100.5MB·18.61MB/s·[████████████░░░░░░░░░░░░░░░░░░░]38%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger20.4 MB/52.5 MB (38%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger18.7MB/48.0MB(38%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer5.2MB/41.0MB(12%)3.7MB/s││││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]-CoastalD30.5MBloopdigger675kbps3.35MB/s1││ieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepo32.2MBloopdigger725kbps3.35MB/s1││17.4MBtapehiss836kbps2.1MB/s2││oUA320kbps1.05MB/s0││───────────────────────────────────────────────────────╯│5.6MB/41.0MB(13%)3.7MB/s││││[Tab/1-3→pane][z→unzoom]→hide][?→keys][q→quit]││↑↯HarbourBells(LiveattheQuay).flaccrateminer6.4MB/41.0MB(15%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que47.5/100.5MB·18.61MB/s·[██████████████░░░░░░░░░░░░░░░░░]47%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger24.8 MB/52.5 MB (47%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger22.7MB/48.0MB(47%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer6.8MB/41.0MB(16%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer7.1MB/41.0MB(17%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer7.5MB/41.0MB(18%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que53.0/100.5MB·18.61MB/s·[████████████████░░░░░░░░░░░░░░░]52%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger27.7 MB/52.5 MB (52%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger25.3MB/48.0MB(52%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer7.9MB/41.0MB(19%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer8.3MB/41.0MB(20%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer8.7MB/41.0MB(21%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que58.6/100.5MB·18.61MB/s·[██████████████████░░░░░░░░░░░░░]58%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger30.6 MB/52.5 MB (58%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger28.0MB/48.0MB(58%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer9.0MB/41.0MB(22%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer9.4MB/41.0MB(22%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer9.8MB/41.0MB(23%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que64.2/100.5MB·18.61MB/s·[███████████████████░░░░░░░░░░░░]63%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger33.5 MB/52.5 MB (63%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger30.7MB/48.0MB(63%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer10.2MB/41.0MB(24%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer10.6MB/41.0MB(25%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer11.0MB/41.0MB(26%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que69.8/100.5MB·18.61MB/s·[█████████████████████░░░░░░░░░░]69%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger36.5 MB/52.5 MB (69%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger33.3MB/48.0MB(69%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer11.3MB/41.0MB(27%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer11.7MB/41.0MB(28%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer12.1MB/41.0MB(29%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que75.4/100.5MB·18.61MB/s·[███████████████████████░░░░░░░░]75%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger39.4 MB/52.5 MB (75%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger36.0MB/48.0MB(75%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer12.5MB/41.0MB(30%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer12.9MB/41.0MB(31%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer13.2MB/41.0MB(32%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que81.0/100.5MB·18.61MB/s·[████████████████████████░░░░░░░]80%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger42.3 MB/52.5 MB (80%)9.7 MB/s││[]@@gufoo\FieldRecordings\gs\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger42.3 MB/52.5 MB (80%)9.7 MB/s││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\06-RainonZ││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0238.0MBtapehiss741kbps││[]@@hnttf\compl│╰──────────────────────────────││StatusQueryResults││││││[Tab/1-3→pane][z→zoom][w[w→hide][?→keys][q→quit]││││↑↯HarbourBcrateminer14.2MB/41.0MB(34%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que86.5/100.5MB·18.61MB/s·[██████████████████████████░░░░░]86%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger45.2 MB/52.5 MB (86%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger41.3MB/48.0MB(86%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer14.6MB/41.0MB(35%)3.7MB/s││││││↑↯HarbourBcrateminer15.0MB/41.0MB(36%)3.7MB/s││││││↑↯HarbourBcrateminer15.4MB/41.0MB(37%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que92.1/100.5MB·18.61MB/s·[████████████████████████████░░░]91%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger48.1 MB/52.5 MB (91%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger44.0MB/48.0MB(91%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer15.8MB/41.0MB(38%)3.7MB/s││││││↑↯HarbourBcrateminer16.2MB/41.0MB(39%)3.7MB/s││││││↑↯HarbourBcrateminer16.6MB/41.0MB(40%)3.7MB/s││││││↑↯HarbourBcrateminer16.6MB/41.0MB(40%)3.7MB/s│││╰──────────────────────────────────╯╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[s→search][Enter→results][d→remove][m→message][i→inbox]done][c→clearfinished][b→browseuser][h/l→scrollname]││[z→zoom][w→hide][?→keys][q→quit→keys][q→quit]││││↑↯HarbourBcrateminer17.2MB/41.0MB(41%)3.7MB/s││││││↑↯HarbourBcrateminer17.6MB/41.0MB(42%)3.7MB/s││││││↑↯HarbourBcrateminer18.0MB/41.0MB(43%)3.7MB/s││││││↑↯HarbourBcrateminer18.4MB/41.0MB(44%)3.7MB/s││││││↑↯HarbourBcrateminer18.8MB/41.0MB(45%)3.7MB/s││││││↑↯HarbourBcrateminer19.2MB/41.0MB(46%)3.7MB/s││││││↑↯HarbourBcrateminer19.6MB/41.0MB(47%)3.7MB/s│││╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│StatusFilenameUserProgressSpeed││05d││05-HarbourBells.flac││✓@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-││(2019)[FLAC24-96]ns(2019)[FLAC24-96]││││StatusCompleted││││Uploadspeed9m33s││[Space→select][Enter→download][b→browseowner][c[i→inbox][c→chat][b→browseuser][h/l→scrollquery][Tab/1-3→pane]││[Tab/1-3→pane][z→zoom][w→hide][it]││↑↯HarbourBells(LiveattheQuay)crateminer20.6MB/41.0MB(50%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer21.0MB/41.0MB(51%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer21.4MB/41.0MB(52%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer21.8MB/41.0MB(53%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer22.1MB/41.0MB(53%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer22.5MB/41.0MB(54%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer22.9MB/41.0MB(55%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer23.3MB/41.0MB(56%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer23.7MB/41.0MB(57%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer24.1MB/41.0MB(58%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer24.5MB/41.0MB(59%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer24.9MB/41.0MB(60%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer25.3MB/41.0MB(61%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer25.6MB/41.0MB(62%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer26.0MB/41.0MB(63%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer26.4MB/41.0MB(64%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer26.8MB/41.0MB(65%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer27.2MB/41.0MB(66%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer28.0MB/41.0MB(68%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer28.4MB/41.0MB(69%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer28.8MB/41.0MB(70%)3.7MB/s││││[]…less\PublicDomainFieldRecordings,Vol.2(CreativeCreativeCommonsZero)\0334.6MBtapehiss753kbps2.1MB/s2││↑↯HarbourBells(LiveattheQuay)crateminer29.5MB/41.0MB(72%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer29.9MB/41.0MB(72%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer30.2MB/41.0MB(73%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer30.6MB/41.0MB(74%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer31.0MB/41.0MB(75%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer31.4MB/41.0MB(76%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer31.8MB/41.0MB(77%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer32.2MB/41.0MB(78%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer32.6MB/41.0MB(79%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer33.0MB/41.0MB(80%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer33.4MB/41.0MB(81%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer33.7MB/41.0MB(82%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer34.1MB/41.0MB(83%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer34.5MB/41.0MB(84%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer34.9MB/41.0MB(85%)3.7MB/s││││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommons-Quarry34.6MBtapehiss753kbps2.1MB/s2││↑↯HarbourBells(LiveattheQuay)crateminer35.7MB/41.0MB(87%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer35.8MB/41.0MB(87%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer36.2MB/41.0MB(88%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer36.6MB/41.0MB(89%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer37.0MB/41.0MB(90%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer37.4MB/41.0MB(91%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer37.8MB/41.0MB(92%)3.7MB/s││││StatusQueryResults││StatusFilenameUserSpeed││05-HarbourBells.flac││✓@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-││(2019)[FLAC24-96]││││││User││[s→e→select][Enter→download][b→browseowner][c→chat][/→filter][a/A→selectall/none][h/l→scrollname][g/G→top/end]││[s→search][Enter→results][d→remove][m→message][i→inbox][c→chat][b→browseuser][h/l→scrollquery][][g/G→top/end]││││↑↯HarbourBcrateminer38.8MB/41.0MB(94%)3.7MB/s││││││↑↯HarbourBcrateminer39.2MB/41.0MB(95%)3.7MB/s││││││↑↯HarbourBcrateminer39.6MB/41.0MB(96%)3.7MB/s││││││↑↯HarbourBcrateminer40.0MB/41.0MB(97%)3.7MB/s││││││↑↯HarbourBcrateminer40.4MB/41.0MB(98%)3.7MB/s││││││↑↯HarbourBcrateminer40.7MB/41.0MB(99%)3.7MB/s││││[]Music\lossless│clickfocusapaneResults34.6MBtapehiss753kbps2.1MB/s2││[]@@hnttf\comple│bbrowseauser'sfiles/filter,Enter320kbps1.05MB/s0│││qquit─────────────────────────────────────────────────────────────────────────────────╯│Statpublic domain fi15││›✓@@gufoo\FloopdiggerCompleted-││@@gufoo/FieldRecordings/CoastalDawn│││PgUpPgDn/^b^fonepage-│││││││││Saveto│││╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[]@@gufoo\FieldRecordingszoomthefocusedpaneCcleareverysearch│3.35MB/s1││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0418.7MB/deselect│2.1MB/s2│╭[1]Searches────────────────────╮╭[3]Downloads/Uploads───────────────────cancel│─────────────────────╯│›Donepublic domain fi15││›moveonerowddeleteaqueuedorfinishedone│flac││││↑✓HarbourB│AC24-96]││HomeEnd/gGoldest/newest,orfirst/las│Completed│╰──────────────────────────────────╯╰───────────────────────────────││││[s→search][Enter→results][d→remove][m→message][i→inbox]─────────────────────────────────────────────────────────────────────────────╮│[z→zoom][w→hide][?→keys][q→quit│ \ No newline at end of file diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index 8cf44f2..3c8a786 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -39,6 +39,141 @@ pub enum FocusedPane { Downloads, } +impl FocusedPane { + /// Every focusable pane, in the order `Tab` walks them: the number each + /// carries in its legend, which is also declaration order. + pub const ALL: [Self; 3] = [Self::Searches, Self::Results, Self::Downloads]; + + /// Its position in [`Self::ALL`]. + const fn index(self) -> usize { + self as usize + } +} + +/// Which panes are on screen. A hidden pane gives its space to the others; a +/// zoomed one takes the whole content area for itself, so `Tab` through the +/// panes while zoomed reads as switching windows. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PaneLayout { + hidden: [bool; FocusedPane::ALL.len()], + pub zoomed: bool, +} + +impl PaneLayout { + #[must_use] + pub const fn is_visible(&self, pane: FocusedPane) -> bool { + !self.hidden[pane.index()] + } + + /// The panes still on screen, in `Tab` order. + pub fn visible(&self) -> impl Iterator + '_ { + FocusedPane::ALL + .into_iter() + .filter(move |pane| self.is_visible(*pane)) + } + + /// Take `pane` off the screen. Refuses when it is the last one showing — + /// an empty window has nothing to press a key in — and says so. + pub fn hide(&mut self, pane: FocusedPane) -> bool { + if self.visible().count() <= 1 { + return false; + } + self.hidden[pane.index()] = true; + true + } + + pub const fn show(&mut self, pane: FocusedPane) { + self.hidden[pane.index()] = false; + } + + /// The visible pane after (or before) `from`, wrapping around. `from` + /// itself may already be hidden, which is how focus leaves a pane that + /// was just hidden. + #[must_use] + pub fn neighbour(&self, from: FocusedPane, forward: bool) -> FocusedPane { + let count = FocusedPane::ALL.len(); + let step = if forward { 1 } else { count - 1 }; + let mut index = from.index(); + for _ in 0..count { + index = (index + step) % count; + let candidate = FocusedPane::ALL[index]; + if self.is_visible(candidate) { + return candidate; + } + } + from + } +} + +/// Where a scrolling log is being read. +/// +/// Either following its newest line, or held at a row of its history so +/// that arriving messages do not push the reader along. The renderer records +/// the log's size at each draw, which is what lets the keys move by a +/// screenful and stop at the ends. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct LogView { + /// The first visible row while held in the history; `None` follows the + /// tail. + top: Option, + /// Rows the log had, and rows the window showed, at the last draw. + rows: usize, + height: usize, +} + +impl LogView { + /// Whether the newest line is on screen, so new messages are being seen. + #[must_use] + pub const fn following(&self) -> bool { + self.top.is_none() + } + + /// Rows a page key moves: what the window showed last time. + #[must_use] + pub const fn page(&self) -> usize { + self.height + } + + const fn last_top(&self) -> usize { + self.rows.saturating_sub(self.height) + } + + /// The rows of a log `len` long to draw in a window `height` tall. A + /// held position that has reached the tail goes back to following it. + pub fn window( + &mut self, + len: usize, + height: usize, + ) -> std::ops::Range { + self.rows = len; + self.height = height.max(1); + let top = match self.top { + Some(top) if top < self.last_top() => top, + _ => { + self.top = None; + self.last_top() + } + }; + top..(top + self.height).min(len) + } + + /// Move the window `rows` down the log (up, when negative), holding + /// wherever it lands unless that is the tail. + pub fn scroll(&mut self, rows: isize) { + let current = self.top.unwrap_or_else(|| self.last_top()); + let next = current.saturating_add_signed(rows); + self.top = (next < self.last_top()).then_some(next); + } + + pub const fn to_oldest(&mut self) { + self.top = Some(0); + } + + pub const fn to_newest(&mut self) { + self.top = None; + } +} + /// What the shared command bar is currently capturing input for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandBarMode { @@ -70,6 +205,8 @@ pub struct AppState { pub searches: Vec, pub searches_table_state: TableState, pub selected_search_index: Option, + /// Cells the query column is scrolled to the left. + pub searches_query_offset: usize, // Results pub results_items: Vec, @@ -79,11 +216,14 @@ pub struct AppState { pub results_selected_indices: std::collections::HashSet, pub results_filter_query: String, pub results_is_filtering: bool, + /// Cells the file-name column is scrolled to the left. pub results_name_offset: usize, // Downloads pub downloads: Vec, pub downloads_table_state: TableState, + /// Cells the transfers' file-name column is scrolled to the left. + pub downloads_name_offset: usize, pub downloads_receiver_channel: Option)>>, pub downloads_sender_channel: @@ -92,6 +232,12 @@ pub struct AppState { // UI State pub focused_pane: FocusedPane, + pub layout: PaneLayout, + /// The keys overlay (`?`) is open. + pub show_help: bool, + /// Where the keys overlay is being read, for a terminal too short to + /// show it whole. + pub help_view: LogView, pub should_exit: bool, pub command_bar_active: bool, pub command_bar_input: String, @@ -108,6 +254,8 @@ pub struct AppState { /// Compose buffer for the active conversation. pub chat_input: String, pub chat_composing: bool, + /// Where the conversation is being read. + pub chat_view: LogView, // Browse users' shared files (one tab per user) pub browse: BrowseTabs, @@ -129,6 +277,9 @@ pub struct AppState { pub searches_pane_area: Option, pub results_pane_area: Option, pub downloads_pane_area: Option, + /// Where the open popup was last drawn, for its page size. Only one is + /// ever open, so one record serves them all. + pub popup_area: Option, } impl AppState { @@ -147,6 +298,7 @@ impl AppState { searches: Vec::new(), searches_table_state, selected_search_index: None, + searches_query_offset: 0, results_items: Vec::new(), results_filtered_items: Vec::new(), @@ -159,11 +311,15 @@ impl AppState { downloads: Vec::new(), downloads_table_state, + downloads_name_offset: 0, downloads_receiver_channel: None, downloads_sender_channel: None, active_downloads_count: 0, focused_pane: FocusedPane::Searches, + layout: PaneLayout::default(), + show_help: false, + help_view: LogView::default(), should_exit: false, command_bar_active: false, command_bar_input: String::new(), @@ -176,6 +332,7 @@ impl AppState { chat_peer: None, chat_input: String::new(), chat_composing: false, + chat_view: LogView::default(), browse: BrowseTabs::new(), show_browse: false, @@ -192,13 +349,59 @@ impl AppState { searches_pane_area: None, results_pane_area: None, downloads_pane_area: None, + popup_area: None, } } + /// How far the focused list's long column is scrolled sideways. + pub const fn name_offset_mut(&mut self, pane: FocusedPane) -> &mut usize { + match pane { + FocusedPane::Searches => &mut self.searches_query_offset, + FocusedPane::Results => &mut self.results_name_offset, + FocusedPane::Downloads => &mut self.downloads_name_offset, + } + } + + /// Where `pane` was last drawn; `None` while it is hidden. + #[must_use] + pub const fn pane_area(&self, pane: FocusedPane) -> Option { + match pane { + FocusedPane::Searches => self.searches_pane_area, + FocusedPane::Results => self.results_pane_area, + FocusedPane::Downloads => self.downloads_pane_area, + } + } + + /// Put the focus on `pane`, bringing it back on screen if it was hidden: + /// pressing a pane's number is also how it comes back. + pub const fn focus_pane(&mut self, pane: FocusedPane) { + self.layout.show(pane); + self.focused_pane = pane; + } + + /// Move the focus to the next (or previous) visible pane. + pub fn cycle_focus(&mut self, forward: bool) { + self.focused_pane = self.layout.neighbour(self.focused_pane, forward); + } + + /// Hide the focused pane and move the focus to its neighbour. The last + /// visible pane stays; there would be nowhere for the focus to go. + pub fn hide_focused_pane(&mut self) { + let pane = self.focused_pane; + if self.layout.hide(pane) { + self.focused_pane = self.layout.neighbour(pane, true); + } + } + + pub const fn toggle_zoom(&mut self) { + self.layout.zoomed = !self.layout.zoomed; + } + /// Open the chat popup on a conversation with `peer`, ready to type. pub fn open_chat(&mut self, peer: String) { self.chat_peer = Some(peer); self.chat_input.clear(); + self.chat_view.to_newest(); self.chat_composing = true; self.show_messages = true; self.unread_messages = 0; @@ -248,6 +451,10 @@ impl AppState { (current + peers.len() - 1) % peers.len() }; let peer = peers[next].to_string(); + // A different conversation opens at its end; the same one stays put. + if next != current { + self.chat_view.to_newest(); + } self.chat_peer = Some(peer); } } @@ -290,6 +497,148 @@ mod tests { assert_eq!(state.active_chat_peer(), Some("bob")); } + fn visible(layout: &PaneLayout) -> Vec { + layout.visible().collect() + } + + #[test] + fn every_pane_starts_visible_and_unzoomed() { + let state = AppState::new(); + assert_eq!(visible(&state.layout), FocusedPane::ALL); + assert!(!state.layout.zoomed); + assert!(!state.show_help); + } + + #[test] + fn tab_cycles_the_focus_through_the_visible_panes_and_wraps() { + let mut state = AppState::new(); + assert_eq!(state.focused_pane, FocusedPane::Searches); + state.cycle_focus(true); + assert_eq!(state.focused_pane, FocusedPane::Results); + state.cycle_focus(true); + assert_eq!(state.focused_pane, FocusedPane::Downloads); + state.cycle_focus(true); + assert_eq!(state.focused_pane, FocusedPane::Searches, "wraps"); + state.cycle_focus(false); + assert_eq!(state.focused_pane, FocusedPane::Downloads, "and back"); + } + + #[test] + fn hiding_the_focused_pane_moves_the_focus_past_it() { + let mut state = AppState::new(); + state.focus_pane(FocusedPane::Results); + state.hide_focused_pane(); + assert_eq!( + visible(&state.layout), + [FocusedPane::Searches, FocusedPane::Downloads] + ); + assert_eq!(state.focused_pane, FocusedPane::Downloads); + + // Tab now skips the hidden pane in both directions. + state.cycle_focus(true); + assert_eq!(state.focused_pane, FocusedPane::Searches); + state.cycle_focus(false); + assert_eq!(state.focused_pane, FocusedPane::Downloads); + } + + #[test] + fn hiding_wraps_the_focus_to_the_first_pane() { + let mut state = AppState::new(); + state.focus_pane(FocusedPane::Downloads); + state.hide_focused_pane(); + assert_eq!(state.focused_pane, FocusedPane::Searches); + } + + #[test] + fn the_last_visible_pane_cannot_be_hidden() { + let mut state = AppState::new(); + state.hide_focused_pane(); + state.hide_focused_pane(); + assert_eq!(visible(&state.layout), [FocusedPane::Downloads]); + state.hide_focused_pane(); + assert_eq!( + visible(&state.layout), + [FocusedPane::Downloads], + "one pane always stays on screen" + ); + assert_eq!(state.focused_pane, FocusedPane::Downloads); + } + + #[test] + fn focusing_a_hidden_pane_by_number_brings_it_back() { + let mut state = AppState::new(); + state.focus_pane(FocusedPane::Results); + state.hide_focused_pane(); + assert!(!state.layout.is_visible(FocusedPane::Results)); + + state.focus_pane(FocusedPane::Results); + + assert!(state.layout.is_visible(FocusedPane::Results)); + assert_eq!(state.focused_pane, FocusedPane::Results); + } + + #[test] + fn zoom_toggles_and_leaves_hidden_panes_alone() { + let mut state = AppState::new(); + state.focus_pane(FocusedPane::Searches); + state.hide_focused_pane(); + state.toggle_zoom(); + assert!(state.layout.zoomed); + assert!(!state.layout.is_visible(FocusedPane::Searches)); + state.toggle_zoom(); + assert!(!state.layout.zoomed); + assert!(!state.layout.is_visible(FocusedPane::Searches)); + } + + #[test] + fn a_log_view_follows_the_tail_until_held_and_holds_against_new_rows() { + let mut view = LogView::default(); + assert_eq!(view.window(100, 10), 90..100); + assert!(view.following()); + + view.scroll(-10); + assert_eq!(view.window(100, 10), 80..90); + assert!(!view.following()); + + // Five more rows arrive: the held window does not move. + assert_eq!(view.window(105, 10), 80..90); + + // Back down to the tail resumes following, including new rows. + view.scroll(10); + view.scroll(10); + assert_eq!(view.window(105, 10), 95..105); + assert!(view.following()); + + view.to_oldest(); + assert_eq!(view.window(105, 10), 0..10); + view.scroll(-10); + assert_eq!(view.window(105, 10), 0..10, "clamped at the top"); + view.to_newest(); + assert_eq!(view.window(105, 10), 95..105); + } + + #[test] + fn a_log_shorter_than_its_window_is_never_held() { + let mut view = LogView::default(); + assert_eq!(view.window(3, 10), 0..3); + view.to_oldest(); + assert_eq!(view.window(3, 10), 0..3); + assert!(view.following(), "nothing to hold"); + } + + #[test] + fn cycling_to_the_same_chat_keeps_the_place() { + let mut state = AppState::new(); + say(&mut state, "alice"); + state.chat_view.window(50, 10); + state.chat_view.scroll(-10); + state.cycle_chat_peer(true); + assert!(!state.chat_view.following(), "only one chat: nothing moved"); + say(&mut state, "bob"); + state.cycle_chat_peer(true); + assert!(state.chat_view.following(), "a new chat opens at its end"); + } + #[test] fn opening_a_chat_pins_a_peer_with_no_messages_yet() { let mut state = AppState::new(); diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs index f62e346..bbe1a7b 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -5,8 +5,8 @@ mod rooms; mod settings; pub use app_state::{ - AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, - MessageDirection, SearchEntry, SearchStatus, + AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView, + MessageDirection, PaneLayout, SearchEntry, SearchStatus, }; pub use browse::{ BrowseState, BrowseStatus, BrowseTabs, files_under, find_node, diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs index f3cee71..b4f3db1 100644 --- a/soulseek-rs/src/models/rooms.rs +++ b/soulseek-rs/src/models/rooms.rs @@ -1,3 +1,4 @@ +use crate::models::LogView; use chrono::{DateTime, Local}; use soulseek_rs::types::{RoomEvent, RoomInfo}; @@ -38,6 +39,8 @@ pub struct OpenRoom { pub unread: usize, /// Per-room compose buffer. pub input: String, + /// Where the log is being read. + pub view: LogView, } impl OpenRoom { @@ -160,6 +163,11 @@ impl RoomsState { .and_then(|r| r.users.get(self.user_selected).cloned()) } + /// Where the active room's log is being read, to move it. + pub fn active_view_mut(&mut self) -> Option<&mut LogView> { + self.open.get_mut(self.active).map(|room| &mut room.view) + } + pub const fn select_user_up(&mut self) { self.user_selected = self.user_selected.saturating_sub(1); } diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs index c3cbb23..525a063 100644 --- a/soulseek-rs/src/ui/main_tui/browse.rs +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -1,6 +1,7 @@ use super::MainTui; +use super::input::{jumped, list_jump}; use crate::models::{BrowseStatus, files_under, find_node}; -use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::{thread, time::Duration}; /// How long to wait for a browse response before showing a timeout notice. @@ -12,6 +13,11 @@ impl MainTui { self.state.show_browse = false; return; } + let jump = list_jump(key, self.popup_page()); + // Control combinations are only ever jumps: ctrl-d pages, d downloads. + if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) { + return; + } // Tab management: switch between browsed users or close the active tab. match key.code { @@ -60,6 +66,14 @@ impl MainTui { (rows, sel, row) }; + if let Some(jump) = jump { + if let Some(browse) = self.state.browse.active_tab_mut() { + browse.selected_row = jumped(sel, rows.len(), jump); + } + self.sync_browse_selection(); + return; + } + // Downloads need `&self.client` free of the browse borrow. match key.code { KeyCode::Enter if !row.is_folder => { diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index 444f8b2..2b7038b 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -1,11 +1,14 @@ use super::MainTui; -use crate::models::{CommandBarMode, FocusedPane}; +use crate::models::{CommandBarMode, FocusedPane, LogView}; use crate::ui::pane_block; -use crate::ui::panes::name_end_offset; +use crate::ui::panes::{ + InfoSubject, name_end_offset, query_end_offset, selected_transfer, + transfer_name_end_offset, upload_display_name, +}; use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; -use ratatui::layout::Position; +use ratatui::layout::{Position, Rect}; use ratatui::widgets::TableState; const NAME_SCROLL_STEP: isize = 8; @@ -17,6 +20,23 @@ impl MainTui { return self.handle_command_bar_input(key); } + // The keys list closes on the key that opened it, or the usual two, + // and scrolls like any log for a terminal too short to show it all. + if self.state.show_help { + let view = &mut self.state.help_view; + match key.code { + KeyCode::Char('?' | 'q') | KeyCode::Esc => { + self.state.show_help = false; + } + KeyCode::Down | KeyCode::Char('j') => view.scroll(1), + KeyCode::Up | KeyCode::Char('k') => view.scroll(-1), + _ => { + scroll_log(view, key); + } + } + return; + } + // Chat popup takes over navigation while open. if self.state.show_messages { return self.handle_chat_input(key); @@ -33,10 +53,10 @@ impl MainTui { return self.handle_settings_input(key); } + // Control combinations only ever page a list, so they never reach + // the letter keys below: ctrl-d is not d. if key.modifiers.contains(KeyModifiers::CONTROL) { - if self.state.focused_pane == FocusedPane::Results { - self.handle_results_input(key); - } + self.navigate_focused_list(key); return; } @@ -53,16 +73,35 @@ impl MainTui { self.state.should_exit = true; return; } - KeyCode::Char('1') => { - self.state.focused_pane = FocusedPane::Searches; + KeyCode::Char('?') => { + self.state.show_help = true; + self.state.help_view.to_oldest(); return; } - KeyCode::Char('2') => { - self.state.focused_pane = FocusedPane::Results; + // A pane's number is its place in the legend order. + KeyCode::Char(digit @ '1'..='3') => { + let index = usize::from(digit as u8 - b'1'); + self.state.focus_pane(FocusedPane::ALL[index]); return; } - KeyCode::Char('3') => { - self.state.focused_pane = FocusedPane::Downloads; + KeyCode::Tab => { + self.state.cycle_focus(true); + return; + } + KeyCode::BackTab => { + self.state.cycle_focus(false); + return; + } + KeyCode::Char('z') => { + self.state.toggle_zoom(); + return; + } + KeyCode::Char('w') => { + self.state.hide_focused_pane(); + return; + } + KeyCode::Esc if self.state.layout.zoomed => { + self.state.layout.zoomed = false; return; } KeyCode::Char('s') => { @@ -147,6 +186,14 @@ impl MainTui { return; } + // Paging keys move through the conversation's history. + if scroll_log(&mut self.state.chat_view, key) { + return; + } + if key.modifiers.contains(KeyModifiers::CONTROL) { + return; + } + match key.code { KeyCode::Char('i' | 'q') | KeyCode::Esc => { self.state.show_messages = false; @@ -306,15 +353,44 @@ impl MainTui { } } + /// The list the focus is on and how far one page moves it. + fn navigate_focused_list(&mut self, key: KeyEvent) -> bool { + let focused = self.state.focused_pane; + let page = self.page_size(); + let (table, len) = match focused { + FocusedPane::Searches => ( + &mut self.state.searches_table_state, + self.state.searches.len(), + ), + FocusedPane::Results => { + let len = self.visible_results().len(); + (&mut self.state.results_table_state, len) + } + FocusedPane::Downloads => ( + &mut self.state.downloads_table_state, + self.state.downloads.len() + self.state.uploads.len(), + ), + }; + navigate_list(key, table, len, page) + } + + /// Rows a popup shows at once: its inner height less a tab bar and a + /// compose line, which is what its list or log has left. + pub(super) fn popup_page(&self) -> usize { + page_of(self.state.popup_area, 2) + } + + /// Rows the focused pane shows at once: its inner height less the table + /// header. + fn page_size(&self) -> usize { + page_of(self.state.pane_area(self.state.focused_pane), 1) + } + fn handle_searches_input(&mut self, key: KeyEvent) { - let rows = self.state.searches.len(); + if self.navigate_focused_list(key) || self.scroll_focused_name(key) { + return; + } match key.code { - KeyCode::Up | KeyCode::Char('k') => { - cycle(&mut self.state.searches_table_state, rows, false); - } - KeyCode::Down | KeyCode::Char('j') => { - cycle(&mut self.state.searches_table_state, rows, true); - } KeyCode::Enter => { if let Some(selected) = self.state.searches_table_state.selected() @@ -329,7 +405,7 @@ impl MainTui { self.state.results_selected_indices.clear(); self.state.results_name_offset = 0; self.state.results_table_state.select(Some(0)); - self.state.focused_pane = FocusedPane::Results; + self.state.focus_pane(FocusedPane::Results); } } } @@ -348,41 +424,10 @@ impl MainTui { } fn handle_results_input(&mut self, key: KeyEvent) { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - if ctrl && !matches!(key.code, KeyCode::Char('f' | 'b' | 'd' | 'u')) { + if self.navigate_focused_list(key) || self.scroll_focused_name(key) { return; } - let items_count = self.visible_results().len(); - let page = self.results_page_size(); - let half = (page / 2).max(1); - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - cycle(&mut self.state.results_table_state, items_count, false); - } - KeyCode::Down | KeyCode::Char('j') => { - cycle(&mut self.state.results_table_state, items_count, true); - } - KeyCode::Home | KeyCode::Char('g') => self.select_row(0), - KeyCode::End | KeyCode::Char('G') => { - self.select_row(items_count.saturating_sub(1)); - } - KeyCode::PageDown => self.scroll_rows(page), - KeyCode::PageUp => self.scroll_rows(-page), - KeyCode::Char('f') if ctrl => self.scroll_rows(page), - KeyCode::Char('b') if ctrl => self.scroll_rows(-page), - KeyCode::Char('d') if ctrl => self.scroll_rows(half), - KeyCode::Char('u') if ctrl => self.scroll_rows(-half), - KeyCode::Right | KeyCode::Char('l') => { - self.scroll_names(NAME_SCROLL_STEP); - } - KeyCode::Left | KeyCode::Char('h') => { - self.scroll_names(-NAME_SCROLL_STEP); - } - KeyCode::Char('0') => self.state.results_name_offset = 0, - KeyCode::Char('$') => { - self.state.results_name_offset = self.highlighted_name_end(); - } KeyCode::Char(' ') => { let index = self .state @@ -418,52 +463,78 @@ impl MainTui { } } - fn select_row(&mut self, target: usize) { - let len = self.visible_results().len(); - if len > 0 { - self.state - .results_table_state - .select(Some(target.min(len - 1))); + /// The keys that scroll the focused list's long column sideways, the + /// same in every list: a file name in Results and Downloads, the query + /// in Searches. Says whether `key` was one of them. + fn scroll_focused_name(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Right | KeyCode::Char('l') => { + self.scroll_names(NAME_SCROLL_STEP); + } + KeyCode::Left | KeyCode::Char('h') => { + self.scroll_names(-NAME_SCROLL_STEP); + } + KeyCode::Char('0') => { + *self.state.name_offset_mut(self.state.focused_pane) = 0; + } + KeyCode::Char('$') => { + let end = self.highlighted_name_end(); + *self.state.name_offset_mut(self.state.focused_pane) = end; + } + _ => return false, } - } - - fn scroll_rows(&mut self, delta: isize) { - let current = self.state.results_table_state.selected().unwrap_or(0); - self.select_row(current.saturating_add_signed(delta)); + true } fn scroll_names(&mut self, delta: isize) { let end = self.highlighted_name_end(); - let current = self.state.results_name_offset.min(end); - self.state.results_name_offset = - current.saturating_add_signed(delta).min(end); + let offset = self.state.name_offset_mut(self.state.focused_pane); + let current = (*offset).min(end); + *offset = current.saturating_add_signed(delta).min(end); } + /// How far the highlighted row's long column can scroll before its end + /// is in view. Zero when nothing is highlighted or the pane is not drawn. fn highlighted_name_end(&self) -> usize { - let Some(area) = self.state.results_pane_area else { + let pane = self.state.focused_pane; + let Some(area) = self.state.pane_area(pane) else { return 0; }; - self.highlighted_result() - .map_or(0, |file| name_end_offset(&file.filename, area)) - } - - fn results_page_size(&self) -> isize { - let rows = self.state.results_pane_area.map_or(1, |area| { - pane_block(false).inner(area).height.saturating_sub(1) - }); - rows.max(1) as isize + match pane { + FocusedPane::Searches => self + .state + .searches_table_state + .selected() + .and_then(|row| self.state.searches.get(row)) + .map_or(0, |search| query_end_offset(&search.query, area)), + FocusedPane::Results => self + .highlighted_result() + .map_or(0, |file| name_end_offset(&file.filename, area)), + FocusedPane::Downloads => { + let shown = match selected_transfer( + self.state.downloads_table_state.selected(), + &self.state.downloads, + &self.state.uploads, + ) { + Some(InfoSubject::Download(entry)) => { + entry.download.filename.as_str() + } + Some(InfoSubject::Upload(upload)) => { + upload_display_name(upload) + } + Some(InfoSubject::Result(_)) | None => return 0, + }; + transfer_name_end_offset(shown, area) + } + } } fn handle_downloads_input(&mut self, key: KeyEvent) { // The pane lists downloads first, then uploads; navigation spans both. - let rows = self.state.downloads.len() + self.state.uploads.len(); + if self.navigate_focused_list(key) || self.scroll_focused_name(key) { + return; + } match key.code { - KeyCode::Up | KeyCode::Char('k') => { - cycle(&mut self.state.downloads_table_state, rows, false); - } - KeyCode::Down | KeyCode::Char('j') => { - cycle(&mut self.state.downloads_table_state, rows, true); - } KeyCode::Char('x') => { self.cancel_selected_transfer(); } @@ -489,18 +560,104 @@ impl MainTui { } let clicked = Position::new(mouse.column, mouse.row); - let panes = [ - (self.state.searches_pane_area, FocusedPane::Searches), - (self.state.results_pane_area, FocusedPane::Results), - (self.state.downloads_pane_area, FocusedPane::Downloads), - ]; - for (area, pane) in panes { - if area.is_some_and(|area| area.contains(clicked)) { - self.state.focused_pane = pane; - return; + // The areas are from the last draw. A pane hidden since then, in the + // same batch of events, still has one, and is not there to click. + let hit = FocusedPane::ALL.into_iter().find(|pane| { + self.state.layout.is_visible(*pane) + && self + .state + .pane_area(*pane) + .is_some_and(|area| area.contains(clicked)) + }); + if let Some(pane) = hit { + self.state.focused_pane = pane; + } + } +} + +/// Rows a list in `area` shows at once, less the `chrome` rows above it: a +/// table header, a popup's tab bar and compose line. At least one, so a page +/// key always moves, even before the first draw. +fn page_of(area: Option, chrome: u16) -> usize { + area.map_or(0, |area| { + usize::from(pane_block(false).inner(area).height.saturating_sub(chrome)) + }) + .max(1) +} + +/// A move over a whole list at once: to either end, or by a number of rows +/// (negative is up). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ListJump { + First, + Last, + Rows(isize), +} + +/// The keys every list and log answers alike for moving further than a row: +/// the two ends, a page, half a page. `page` is how many rows the list shows. +pub(super) fn list_jump(key: KeyEvent, page: usize) -> Option { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let page = isize::try_from(page).unwrap_or(isize::MAX); + let half = (page / 2).max(1); + Some(match key.code { + KeyCode::Home | KeyCode::Char('g') if !ctrl => ListJump::First, + KeyCode::End | KeyCode::Char('G') if !ctrl => ListJump::Last, + KeyCode::PageDown => ListJump::Rows(page), + KeyCode::PageUp => ListJump::Rows(-page), + KeyCode::Char('f') if ctrl => ListJump::Rows(page), + KeyCode::Char('b') if ctrl => ListJump::Rows(-page), + KeyCode::Char('d') if ctrl => ListJump::Rows(half), + KeyCode::Char('u') if ctrl => ListJump::Rows(-half), + _ => return None, + }) +} + +/// Apply the jump keys to a log, moving by the screenful it last showed. +/// Says whether `key` was one of them. +pub(super) fn scroll_log(view: &mut LogView, key: KeyEvent) -> bool { + match list_jump(key, view.page()) { + Some(ListJump::First) => view.to_oldest(), + Some(ListJump::Last) => view.to_newest(), + Some(ListJump::Rows(rows)) => view.scroll(rows), + None => return false, + } + true +} + +/// `selected` moved by `jump` over a list `len` long, stopping at the ends. +pub(super) fn jumped(selected: usize, len: usize, jump: ListJump) -> usize { + let last = len.saturating_sub(1); + match jump { + ListJump::First => 0, + ListJump::Last => last, + ListJump::Rows(rows) => selected.saturating_add_signed(rows).min(last), + } +} + +/// The keys every list answers alike: a row at a time wrapping at either +/// end, and the jumps of [`list_jump`]. Says whether `key` was one of them. +fn navigate_list( + key: KeyEvent, + table: &mut TableState, + len: usize, + page: usize, +) -> bool { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Up | KeyCode::Char('k') if !ctrl => cycle(table, len, false), + KeyCode::Down | KeyCode::Char('j') if !ctrl => cycle(table, len, true), + _ => { + let Some(jump) = list_jump(key, page) else { + return false; + }; + if len > 0 { + let current = table.selected().unwrap_or(0); + table.select(Some(jumped(current, len, jump))); } } } + true } /// Move a table's selection one row, wrapping at either end. diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index ea8389c..bfffd32 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -1341,13 +1341,522 @@ mod tests { assert!(tui.state.results_selected_indices.is_empty()); } - fn screen_of(tui: &mut MainTui) -> String { - let backend = ratatui::backend::TestBackend::new(160, 40); + fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String { + let backend = ratatui::backend::TestBackend::new(width, height); let mut terminal = ratatui::Terminal::new(backend).expect("backend"); terminal.draw(|frame| tui.render(frame)).expect("draw"); terminal.backend().to_string() } + fn screen_of(tui: &mut MainTui) -> String { + screen_sized(tui, 160, 40) + } + + /// The screen row carrying a pane's legend, `[2] Results` say, without + /// the quotes the test backend wraps each row in. + fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str { + let line = screen + .lines() + .find(|line| line.contains(needle)) + .unwrap_or_else(|| panic!("no row with {needle:?} in\n{screen}")); + let start = line.find('"').map_or(0, |i| i + 1); + let end = line.rfind('"').unwrap_or(line.len()); + &line[start..end] + } + + /// A window with a search, its results, and a transfer, so every pane + /// has something to show. + fn furnished_tui() -> MainTui { + let mut tui = results_tui(3); + tui.state.searches.push(crate::models::SearchEntry { + query: "aphex twin".to_string(), + status: SearchStatus::Completed, + results: results(3), + known_files: 3, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + tui.state.selected_search_index = Some(0); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "@@x\\Music\\00.mp3"), + receiver: None, + }); + tui + } + + #[test] + fn the_results_pane_has_the_whole_width_to_itself() { + let mut tui = furnished_tui(); + let screen = screen_of(&mut tui); + + let results = row_with(&screen, "[2] Results"); + assert!(results.starts_with('╭'), "{results}"); + assert!(results.trim_end().ends_with('╮'), "{results}"); + assert!(!results.contains("[1]"), "nothing beside it: {results}"); + + // The other three share the row underneath, in this order. + let row = row_with(&screen, "[1] Searches"); + let searches = row.find("[1]").expect("searches"); + let downloads = row.find("[3]").expect("downloads"); + let info = row.find("[Info]").expect("info"); + assert!(searches < downloads && downloads < info, "{row}"); + } + + #[test] + fn tab_and_shift_tab_walk_the_panes_in_legend_order() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); + press(&mut tui, KeyCode::BackTab); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); + } + + #[test] + fn w_hides_the_focused_pane_and_its_number_brings_it_back() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + + press(&mut tui, KeyCode::Char('w')); + + let screen = screen_of(&mut tui); + assert!(!screen.contains("[1] Searches"), "{screen}"); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(screen.contains("[3] Downloads"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + assert_eq!(tui.state.searches_pane_area, None, "nothing to click"); + // The transfers widen into the space. + let row = row_with(&screen, "[3] Downloads"); + assert!(row.starts_with('╭'), "{row}"); + + press(&mut tui, KeyCode::Char('1')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("[1] Searches"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); + } + + #[test] + fn hiding_the_results_gives_the_row_the_whole_height() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + let screen = screen_of(&mut tui); + assert!(!screen.contains("[2] Results"), "{screen}"); + // The row now starts right under the status bar. + let status = screen + .lines() + .position(|line| line.contains("Status")) + .expect("status bar"); + let row = screen + .lines() + .position(|line| line.contains("[1] Searches")) + .expect("searches"); + assert_eq!(row, status + 3, "{screen}"); + } + + #[test] + fn the_last_pane_standing_cannot_be_hidden() { + // Results goes, then Downloads; Searches is what is left, and a + // third press leaves it there. + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('w')); + let screen = screen_of(&mut tui); + assert!(screen.contains("[1] Searches"), "{screen}"); + assert!(!screen.contains("[3] Downloads"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); + } + + #[test] + fn z_zooms_the_focused_pane_and_esc_or_z_leaves_it() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('z')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(!screen.contains("[1] Searches"), "{screen}"); + assert!(!screen.contains("[3] Downloads"), "{screen}"); + assert!(!screen.contains("[Info]"), "{screen}"); + assert!(screen.contains("[z → unzoom]"), "{screen}"); + + // Tab while zoomed switches which pane fills the window. + press(&mut tui, KeyCode::Tab); + let screen = screen_of(&mut tui); + assert!(screen.contains("[3] Downloads"), "{screen}"); + assert!(!screen.contains("[2] Results"), "{screen}"); + + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.layout.zoomed); + let screen = screen_of(&mut tui); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(screen.contains("[Info]"), "{screen}"); + + press(&mut tui, KeyCode::Char('z')); + press(&mut tui, KeyCode::Char('z')); + assert!(!tui.state.layout.zoomed, "z toggles"); + } + + #[test] + fn a_search_brings_a_hidden_results_pane_back() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + assert!(!tui.state.layout.is_visible(FocusedPane::Results)); + + press(&mut tui, KeyCode::Char('s')); + press(&mut tui, KeyCode::Char('x')); + press(&mut tui, KeyCode::Enter); + + assert!(tui.state.layout.is_visible(FocusedPane::Results)); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + } + + #[test] + fn question_mark_opens_the_keys_list_and_closes_it_again() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('?')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("Keys"), "{screen}"); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); + assert!(screen.contains("clear every finished one"), "{screen}"); + + // Other keys are swallowed while it is open. + press(&mut tui, KeyCode::Char('q')); + assert!(!tui.state.should_exit, "q closes the list, not the app"); + assert!(!tui.state.show_help); + + press(&mut tui, KeyCode::Char('?')); + press(&mut tui, KeyCode::Char('w')); + assert!(tui.state.show_help, "w did not reach the panes"); + assert!(tui.state.layout.is_visible(FocusedPane::Results)); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_help); + } + + #[test] + fn on_a_small_terminal_the_keys_list_stacks_and_scrolls() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('?')); + + let screen = screen_sized(&mut tui, 80, 24); + let panes = row_with(&screen, "Panes"); + assert!(!panes.contains("Searches"), "one column: {panes}"); + assert!(screen.contains("focus a pane, hidden or not"), "{screen}"); + assert!(!screen.contains("next room, chat or user"), "{screen}"); + + press(&mut tui, KeyCode::End); + let screen = screen_sized(&mut tui, 80, 24); + assert!(screen.contains("next room, chat or user"), "{screen}"); + assert!(!screen.contains("next / previous pane"), "{screen}"); + + press(&mut tui, KeyCode::Home); + let screen = screen_sized(&mut tui, 80, 24); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); + + // A wide window shows both columns at once, nothing to scroll. + press(&mut tui, KeyCode::End); + let screen = screen_of(&mut tui); + let panes = row_with(&screen, "Panes"); + assert!(panes.contains("Searches"), "two columns: {panes}"); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); + } + + #[test] + fn a_click_where_a_pane_just_hid_does_not_focus_it() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + // Hidden, but not yet redrawn: the old area is still on record. + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Char('w')); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { + kind: ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left, + ), + column: searches.x + 1, + row: searches.y + 1, + modifiers: KeyModifiers::NONE, + }); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + } + + #[test] + fn l_and_h_scroll_a_transfer_name_and_a_query_too() { + let mut tui = furnished_tui(); + tui.state.downloads[0].download.filename = "y".repeat(120); + tui.state.searches[0].query = "z".repeat(60); + let _ = screen_of(&mut tui); + + tui.state.focused_pane = FocusedPane::Downloads; + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.downloads_name_offset, 8); + press(&mut tui, KeyCode::Char('$')); + let end = tui.state.downloads_name_offset; + assert!(end > 8, "the end is past a step: {end}"); + let screen = screen_of(&mut tui); + assert!(row_with(&screen, "…yyyy").contains("…yyyy"), "{screen}"); + press(&mut tui, KeyCode::Char('0')); + assert_eq!(tui.state.downloads_name_offset, 0); + + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Right); + assert_eq!(tui.state.searches_query_offset, 8); + press(&mut tui, KeyCode::End); + assert_eq!( + tui.state.searches_table_state.selected(), + Some(0), + "End is still a row key, $ is the name key" + ); + press(&mut tui, KeyCode::Char('$')); + assert!(tui.state.searches_query_offset > 8); + let screen = screen_of(&mut tui); + assert!(screen.contains("…zzzz"), "{screen}"); + assert!( + tui.state.results_name_offset == 0, + "each list scrolls on its own" + ); + } + + fn browsed(tui: &mut MainTui, files: usize) { + tui.state.browse.open("bob"); + let listing = vec![soulseek_rs::SharedDirectory { + name: "Music".to_string(), + files: (0..files).map(|i| (format!("{i:02}.mp3"), 1)).collect(), + }]; + tui.state + .browse + .active_tab_mut() + .expect("tab") + .load(&listing); + tui.state.show_browse = true; + let _ = screen_of(tui); + } + + #[test] + fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session); + browsed(&mut tui, 40); + let rows = tui.state.browse.active_tab().expect("tab").rows().len(); + assert_eq!(rows, 41, "the folder and its files"); + + press(&mut tui, KeyCode::End); + let selected = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").selected_row + }; + assert_eq!(selected(&tui), 40); + press(&mut tui, KeyCode::Home); + assert_eq!(selected(&tui), 0); + press(&mut tui, KeyCode::PageDown); + let page = selected(&tui); + assert!(page > 1 && page < 40, "a page of rows: {page}"); + ctrl(&mut tui, 'd'); + assert!(selected(&tui) > page, "ctrl-d pages"); + assert!( + tui.state.downloads.is_empty() + && tui.state.downloads_receiver_channel.is_none(), + "and does not download the folder" + ); + press(&mut tui, KeyCode::Char('G')); + assert_eq!(selected(&tui), 40); + assert!(tui.state.show_browse, "still open"); + } + + fn in_a_room(tui: &mut MainTui, messages: usize) { + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::Joined { + room: "jazz".to_string(), + users: (0..30).map(|i| format!("user{i:02}")).collect(), + }, + None, + ); + tui.state.rooms.focus_or_open("jazz"); + for i in 0..messages { + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::Message { + room: "jazz".to_string(), + username: "alice".to_string(), + message: format!("line {i:03}"), + }, + Some("jazz"), + ); + } + tui.state.show_rooms = true; + let _ = screen_of(tui); + } + + #[test] + fn page_up_scrolls_a_room_log_back_and_end_returns_to_the_newest() { + let mut tui = with_session(TalkativeSession::default()); + in_a_room(&mut tui, 120); + + let screen = screen_of(&mut tui); + assert!(screen.contains("line 119"), "tails by default: {screen}"); + assert!(!screen.contains("line 000"), "{screen}"); + + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(!screen.contains("line 119"), "scrolled back: {screen}"); + + press(&mut tui, KeyCode::Home); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 000"), "the oldest: {screen}"); + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 000"), "clamped at the top: {screen}"); + + press(&mut tui, KeyCode::End); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 119"), "{screen}"); + + // The member list keeps its own keys, and ctrl-x is not x. + press(&mut tui, KeyCode::Down); + assert_eq!(tui.state.rooms.user_selected, 1); + ctrl(&mut tui, 'x'); + assert_eq!(tui.state.rooms.open.len(), 1, "still in the room"); + } + + #[test] + fn page_keys_move_through_the_room_list() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::List( + (0..60) + .map(|i| soulseek_rs::types::RoomInfo { + name: format!("room{i:02}"), + user_count: 60 - i, + }) + .collect(), + ), + None, + ); + tui.state.show_rooms = true; + let _ = screen_of(&mut tui); + + press(&mut tui, KeyCode::End); + assert_eq!(tui.state.rooms.list_selected, 59); + press(&mut tui, KeyCode::PageUp); + assert!(tui.state.rooms.list_selected < 59); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(tui.state.rooms.list_selected, 0); + ctrl(&mut tui, 'f'); + assert!(tui.state.rooms.list_selected > 1); + } + + #[test] + fn page_up_scrolls_a_conversation_and_switching_chats_resets_it() { + let mut tui = tui((0..80) + .map(|i| ChatMessageDto { + peer: "bob".into(), + outgoing: i % 2 == 0, + text: format!("msg {i:03}"), + at: i, + }) + .chain(std::iter::once(ChatMessageDto { + peer: "carol".into(), + outgoing: false, + text: "hello".into(), + at: 100, + })) + .collect()); + tui.state.chat_peer = Some("bob".to_string()); + tui.state.show_messages = true; + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 079"), "{screen}"); + + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(!screen.contains("msg 079"), "scrolled back: {screen}"); + press(&mut tui, KeyCode::Char('g')); + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 000"), "{screen}"); + + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.active_chat_peer(), Some("carol")); + assert!( + tui.state.chat_view.following(), + "a fresh chat starts at its end" + ); + press(&mut tui, KeyCode::BackTab); + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 079"), "{screen}"); + } + + #[test] + fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() { + let mut tui = furnished_tui(); + let screen = screen_of(&mut tui); + assert!(screen.contains("[q → quit]"), "{screen}"); + let bar_rows = + screen.lines().filter(|line| line.contains(" → ")).count(); + assert_eq!(bar_rows, 2, "{screen}"); + + let wide = screen_sized(&mut tui, 300, 40); + let bar_rows = wide.lines().filter(|line| line.contains(" → ")).count(); + assert_eq!(bar_rows, 1, "{wide}"); + } + + #[test] + fn page_keys_work_in_the_transfers_and_searches_lists_too() { + let mut tui = furnished_tui(); + for i in 0..30 { + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", &format!("{i}.mp3")), + receiver: None, + }); + } + tui.state.focused_pane = FocusedPane::Downloads; + tui.state.downloads_table_state.select(Some(0)); + let _ = screen_of(&mut tui); // lays the panes out, which sets the page size + + press(&mut tui, KeyCode::End); + assert_eq!(tui.state.downloads_table_state.selected(), Some(30)); + press(&mut tui, KeyCode::Home); + assert_eq!(tui.state.downloads_table_state.selected(), Some(0)); + press(&mut tui, KeyCode::PageDown); + let page = tui.state.downloads_table_state.selected().expect("row"); + assert!(page > 1 && page < 30, "a pane of rows, not one: {page}"); + ctrl(&mut tui, 'd'); + assert_eq!(tui.state.downloads.len(), 31, "ctrl-d pages, d deletes"); + assert!(tui.state.downloads_table_state.selected() > Some(page)); + + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Char('G')); + assert_eq!(tui.state.searches_table_state.selected(), Some(0)); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(tui.state.searches_table_state.selected(), Some(0)); + assert_eq!(tui.state.searches.len(), 1, "g and G only move"); + } + + #[test] + fn clicking_where_a_hidden_pane_was_focuses_whatever_is_there_now() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + let inside = ratatui::crossterm::event::MouseEvent { + kind: ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left, + ), + column: searches.x + 1, + row: searches.y + 1, + modifiers: KeyModifiers::NONE, + }; + tui.handle_mouse_event(inside); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); + + press(&mut tui, KeyCode::Char('w')); + let _ = screen_of(&mut tui); + tui.handle_mouse_event(inside); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); + } + #[test] fn the_info_pane_describes_the_highlighted_result() { let mut tui = results_tui(3); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index c4abbfb..166283c 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -6,8 +6,9 @@ use crate::ui::panes::{ render_rooms_pane, render_searches_pane, selected_transfer, }; use crate::ui::{ - accent_style, dimmed_style, format_shortcuts_styled, pane_block, - plain_title, primary_style, render_download_stats, warning_style, + accent_style, body_style, dimmed_style, info_style, pack_shortcuts, + pane_block, plain_title, primary_style, render_download_stats, + warning_style, }; use ratatui::{ Frame, @@ -17,8 +18,90 @@ use ratatui::{ widgets::Paragraph, }; +/// The keys overlay, grouped by where a key applies. Two columns, because a +/// single list runs longer than most terminals are tall. +const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[ + ( + "Panes", + &[ + ("Tab / Shift-Tab", "next / previous pane"), + ("1 2 3", "focus a pane, hidden or not"), + ("z", "zoom the focused pane"), + ("w", "hide the focused pane"), + ("Esc", "leave zoom"), + ("click", "focus a pane"), + ], + ), + ( + "Anywhere", + &[ + ("s", "search the network"), + ("b", "browse a user's files"), + ("m", "compose a private message"), + ("i", "inbox"), + ("o", "settings"), + ("?", "this list"), + ("q", "quit"), + ], + ), + ( + "Any list", + &[ + ("↑ ↓ / k j", "move one row"), + ("Home End / g G", "first / last row"), + ("PgUp PgDn / ^b ^f", "one page"), + ("^u ^d", "half a page"), + ("h l / ← →", "scroll a long name sideways"), + ("0 / $", "its start / its end"), + ], + ), +]; + +const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ + ( + "Searches", + &[ + ("Enter", "show its results"), + ("d", "remove the search"), + ("C", "clear every search"), + ("c", "chat rooms"), + ], + ), + ( + "Results", + &[ + ("Space", "select / deselect"), + ("a / A", "select all / none"), + ("Enter", "download the selection"), + ("/", "filter, Enter keeps it, Esc clears"), + ("b", "browse the owner"), + ("c", "chat rooms"), + ], + ), + ( + "Downloads / Uploads", + &[ + ("p", "pause / resume"), + ("x", "cancel"), + ("r", "retry a failed one"), + ("d", "delete a queued or finished one"), + ("c", "clear every finished one"), + ], + ), + ( + "Chat and browse popups", + &[ + ("PgUp PgDn / ^b ^f", "a page of messages, or of the tree"), + ("^u ^d", "half a page of them"), + ("Home End / g G", "oldest / newest, or first / last row"), + ("Tab / Shift-Tab", "next room, chat or user"), + ], + ), +]; + impl MainTui { pub(super) fn render(&mut self, frame: &mut Frame) { + let shortcuts = self.shortcut_rows(frame.area().width); let mut constraints = vec![ Constraint::Length(3), // Status bar Constraint::Fill(1), // Main content @@ -26,7 +109,7 @@ impl MainTui { if self.state.command_bar_active { constraints.push(Constraint::Length(3)); // Command bar } - constraints.push(Constraint::Length(3)); // Shortcuts + constraints.push(Constraint::Length(shortcuts.len() as u16 + 2)); let main_chunks = Layout::vertical(constraints).split(frame.area()); let daemon = self.client.daemon_endpoint(); @@ -43,55 +126,109 @@ impl MainTui { // Render command bar if active (vim-style, above shortcuts) if self.state.command_bar_active { self.render_command_bar(frame, main_chunks[2]); - self.render_shortcuts(frame, main_chunks[3]); + self.render_shortcuts(frame, main_chunks[3], shortcuts); } else { - self.render_shortcuts(frame, main_chunks[2]); + self.render_shortcuts(frame, main_chunks[2], shortcuts); } self.render_overlays(frame); } + /// Results across the top, where a long file name has the whole width; + /// Searches, Downloads and Info in a row underneath. A hidden pane drops + /// out of its row and the rest widen; a zoomed one has the area alone. fn render_content(&mut self, frame: &mut Frame, area: Rect) { - // Split main content area - let content_chunks = Layout::horizontal([ - Constraint::Percentage(30), // Searches pane - Constraint::Percentage(70), // Results + Downloads - ]) - .split(area); + // Only what is drawn can be clicked. + self.state.searches_pane_area = None; + self.state.results_pane_area = None; + self.state.downloads_pane_area = None; - // Store searches pane area - self.state.searches_pane_area = Some(content_chunks[0]); + if self.state.layout.zoomed { + self.render_pane(frame, area, self.state.focused_pane); + return; + } - // Render Searches pane (left) - render_searches_pane( - frame, - content_chunks[0], - &self.state.searches, - &mut self.state.searches_table_state, - self.state.focused_pane == FocusedPane::Searches, - ); + // Transfers carry the most columns, so they get the most width. + let row_panes: Vec<(FocusedPane, u16)> = + [(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)] + .into_iter() + .filter(|(pane, _)| self.state.layout.is_visible(*pane)) + .collect(); - // Split right side into Results (top) and Downloads (bottom) - let right_chunks = Layout::vertical([ - Constraint::Percentage(60), // Results - Constraint::Percentage(40), // Downloads - ]) - .split(content_chunks[1]); + let row_area = if self.state.layout.is_visible(FocusedPane::Results) { + // With only Info left in the row, the results deserve more of + // the height. + let row_weight = if row_panes.is_empty() { 1 } else { 2 }; + let [top, bottom] = Layout::vertical([ + Constraint::Fill(3), + Constraint::Fill(row_weight), + ]) + .areas(area); + self.render_pane(frame, top, FocusedPane::Results); + bottom + } else { + area + }; - // Split bottom-right into Downloads list and Info pane - let downloads_chunks = Layout::horizontal([ - Constraint::Percentage(60), // Downloads list - Constraint::Percentage(40), // Info pane - ]) - .split(right_chunks[1]); + let mut constraints: Vec = row_panes + .iter() + .map(|(_, weight)| Constraint::Fill(*weight)) + .collect(); + constraints.push(Constraint::Fill(6)); // Info + let chunks = Layout::horizontal(constraints).split(row_area); + for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) { + self.render_pane(frame, *chunk, *pane); + } + self.render_info_pane(frame, chunks[row_panes.len()]); + } - // Store results and downloads pane areas - self.state.results_pane_area = Some(right_chunks[0]); - self.state.downloads_pane_area = Some(downloads_chunks[0]); + fn render_pane( + &mut self, + frame: &mut Frame, + area: Rect, + pane: FocusedPane, + ) { + let focused = self.state.focused_pane == pane; + match pane { + FocusedPane::Searches => { + self.state.searches_pane_area = Some(area); + render_searches_pane( + frame, + area, + &self.state.searches, + &mut self.state.searches_table_state, + focused, + self.state.searches_query_offset, + ); + } + FocusedPane::Results => { + self.state.results_pane_area = Some(area); + self.render_results_pane(frame, area, focused); + } + FocusedPane::Downloads => { + self.state.downloads_pane_area = Some(area); + render_downloads_pane( + frame, + area, + &self.state.downloads, + &self.state.uploads, + &mut self.state.downloads_table_state, + focused, + self.state.downloads_name_offset, + ); + } + } + } - // Render Results pane. When a filter is active the rendered rows are a - // subset, so the pane also needs the mapping back to unfiltered indices - // to render the selection checkboxes correctly. + fn render_results_pane( + &mut self, + frame: &mut Frame, + area: Rect, + focused: bool, + ) { + // When a filter is active the rendered rows are a subset, so the pane + // also needs the mapping back to unfiltered indices to render the + // selection checkboxes correctly. let (results_items, results_original_indices) = if self.state.results_filter_query.is_empty() { (&self.state.results_items, None) @@ -110,7 +247,7 @@ impl MainTui { render_results_pane( frame, - right_chunks[0], + area, ResultsPaneParams { items: results_items, table_state: &mut self.state.results_table_state, @@ -118,21 +255,16 @@ impl MainTui { original_indices: results_original_indices, filter_query: &self.state.results_filter_query, is_filtering: self.state.results_is_filtering, - focused: self.state.focused_pane == FocusedPane::Results, + focused, active_search_query, name_offset: self.state.results_name_offset, }, ); + } - render_downloads_pane( - frame, - downloads_chunks[0], - &self.state.downloads, - &self.state.uploads, - &mut self.state.downloads_table_state, - self.state.focused_pane == FocusedPane::Downloads, - ); - + /// The Info pane follows the focus: the highlighted result, or the + /// highlighted transfer. + fn render_info_pane(&self, frame: &mut Frame, area: Rect) { let selected = if self.state.focused_pane == FocusedPane::Results { self.highlighted_result().map(InfoSubject::Result) } else { @@ -144,7 +276,7 @@ impl MainTui { }; render_download_info_pane( frame, - downloads_chunks[1], + area, selected, matches!( self.state.focused_pane, @@ -153,6 +285,53 @@ impl MainTui { ); } + /// The keys list, two columns side by side where the terminal is wide + /// enough and one on top of the other where it is not, scrolling when + /// even that is taller than the window. + fn render_help_popup(&mut self, frame: &mut Frame) { + let screen = frame.area(); + // Borders and padding on either side of the text. + let frame_width = 4; + let columns = help_columns(screen.width.saturating_sub(frame_width)); + let gaps = HELP_GAP * (columns.len() as u16 - 1); + let text_width = columns.iter().map(|lines| widest(lines)).sum::(); + let width = screen.width.min(text_width + gaps + frame_width); + let tallest = columns.iter().map(Vec::len).max().unwrap_or(0); + let height = screen + .height + .min(u16::try_from(tallest + 2).unwrap_or(u16::MAX)); + let area = Rect::new( + screen.x + (screen.width - width) / 2, + screen.y + (screen.height - height) / 2, + width, + height, + ); + frame.render_widget(ratatui::widgets::Clear, area); + + let block = pane_block(true).title(plain_title("Keys", true)); + let inner = block.inner(area); + frame.render_widget(block, area); + + // Both columns scroll together, and no further than the taller one + // needs. + let window = self + .state + .help_view + .window(tallest, usize::from(inner.height)); + let scroll = u16::try_from(window.start).unwrap_or(u16::MAX); + + let areas = + Layout::horizontal(vec![Constraint::Fill(1); columns.len()]) + .spacing(HELP_GAP) + .split(inner); + for (area, lines) in areas.iter().zip(columns) { + frame.render_widget( + Paragraph::new(lines).scroll((scroll, 0)), + *area, + ); + } + } + fn render_overlays(&mut self, frame: &mut Frame) { // Messages inbox overlays everything when open. if self.state.show_messages { @@ -162,6 +341,7 @@ impl MainTui { // Browse tree overlays everything when open. if self.state.show_browse && !self.state.browse.is_empty() { let area = centered_rect(80, 80, frame.area()); + self.state.popup_area = Some(area); frame.render_widget(ratatui::widgets::Clear, area); render_browse_pane( frame, @@ -180,14 +360,21 @@ impl MainTui { // Chat rooms overlay everything when open. if self.state.show_rooms { let area = centered_rect(85, 80, frame.area()); + self.state.popup_area = Some(area); frame.render_widget(ratatui::widgets::Clear, area); render_rooms_pane( frame, area, - &self.state.rooms, + &mut self.state.rooms, &mut self.state.rooms_list_table_state, ); } + + // The keys list only opens from the main view, so it is on top of + // nothing; drawn last so that stays true if that ever changes. + if self.state.show_help { + self.render_help_popup(frame); + } } fn render_settings_popup(&self, frame: &mut Frame) { @@ -257,12 +444,14 @@ impl MainTui { ); } - fn render_messages_popup(&self, frame: &mut Frame) { + fn render_messages_popup(&mut self, frame: &mut Frame) { // The per-conversation chat box supersedes the old flat message list; // it renders the messages and the compose line itself. let area = centered_rect(70, 60, frame.area()); frame.render_widget(ratatui::widgets::Clear, area); - render_chat_pane(frame, area, &self.state, &self.client.username()); + self.state.popup_area = Some(area); + let own = self.client.username(); + render_chat_pane(frame, area, &mut self.state, &own); } /// Context shortcuts for the chat-rooms popup. @@ -294,6 +483,7 @@ impl MainTui { } RoomsView::Chat => vec![ ("Enter", "say"), + ("PgUp/PgDn", "scroll"), ("↑↓", "pick user"), ("b", "browse user"), ("m", "message user"), @@ -304,10 +494,31 @@ impl MainTui { } } + /// The keys every pane shares for moving between and resizing panes, + /// ending the bar the same way wherever the focus sits. + fn pane_shortcuts(&self) -> Vec<(&'static str, &'static str)> { + vec![ + ("Tab/1-3", "pane"), + ( + "z", + if self.state.layout.zoomed { + "unzoom" + } else { + "zoom" + }, + ), + ("w", "hide"), + ("?", "keys"), + ("q", "quit"), + ] + } + /// Which shortcuts the bar offers, which is purely a question of what is /// open and where the focus sits. Sibling of [`Self::rooms_shortcuts`]. fn shortcuts(&self) -> Vec<(&'static str, &'static str)> { - if self.state.settings.is_some() { + if self.state.show_help { + vec![("?/Esc", "close")] + } else if self.state.settings.is_some() { vec![ ("↑↓", "move"), ("Enter/e", "edit download dir"), @@ -326,6 +537,7 @@ impl MainTui { } else { vec![ ("Enter", "type"), + ("PgUp/PgDn", "scroll"), ("↑↓/Tab", "switch chat"), ("m", "new chat"), ("i/Esc", "close"), @@ -336,6 +548,7 @@ impl MainTui { } else if self.state.show_browse { vec![ ("↑↓", "move"), + ("PgUp/PgDn", "page"), ("→←", "expand/collapse"), ("Enter", "open/download"), ("d", "download folder"), @@ -364,29 +577,29 @@ impl MainTui { ("Esc", "cancel"), ], } + } else if self.state.results_is_filtering + && self.state.focused_pane == FocusedPane::Results + { + // Typing goes to the filter, so no letter keys are on offer here. + vec![ + ("Type", "filter"), + ("↑/↓", "navigate"), + ("PgUp/PgDn", "page"), + ("Enter", "keep filter"), + ("Esc", "clear filter"), + ] } else { - match self.state.focused_pane { + let mut keys = match self.state.focused_pane { FocusedPane::Searches => vec![ ("s", "search"), + ("Enter", "results"), + ("d", "remove"), ("m", "message"), ("i", "inbox"), ("c", "chat"), ("b", "browse user"), - ("1-3", "focus pane"), - ("↑↓", "navigate"), - ("Enter", "results"), - ("q", "quit"), + ("h/l", "scroll query"), ], - FocusedPane::Results if self.state.results_is_filtering => { - vec![ - ("Type", "filter"), - ("↑/↓", "navigate"), - ("Enter", "confirm"), - ("Esc", "clear filter"), - ("1-3", "focus pane"), - ("q", "quit"), - ] - } FocusedPane::Results => vec![ ("Space", "select"), ("Enter", "download"), @@ -394,29 +607,51 @@ impl MainTui { ("c", "chat"), ("/", "filter"), ("a/A", "select all/none"), - ("h/l", "scroll"), + ("h/l", "scroll name"), ("g/G", "top/end"), - ("1-3", "focus pane"), - ("q", "quit"), ], - FocusedPane::Downloads => { - vec![ - ("p", "pause/resume"), - ("x", "cancel"), - ("r", "retry failed"), - ("d", "delete queued/done"), - ("c", "clear finished"), - ("b", "browse user"), - ("1-3", "focus pane"), - ("q", "quit"), - ] - } - } + FocusedPane::Downloads => vec![ + ("p", "pause/resume"), + ("x", "cancel"), + ("r", "retry failed"), + ("d", "delete queued/done"), + ("c", "clear finished"), + ("b", "browse user"), + ("h/l", "scroll name"), + ], + }; + keys.extend(self.pane_shortcuts()); + keys } } - fn render_shortcuts(&self, frame: &mut Frame, area: Rect) { - let shortcuts_line = format_shortcuts_styled(&self.shortcuts()); + /// The legend packed into rows for a window `width` wide, so a narrow + /// terminal grows the bar instead of cutting keys off its right edge. + fn shortcut_rows(&self, width: u16) -> Vec> { + // Borders and padding, and the unread badge's column when it shows. + let badge = self.unread_badge().width() as u16; + let reserved = 4 + if badge > 0 { badge + 1 } else { 0 }; + pack_shortcuts(&self.shortcuts(), width.saturating_sub(reserved)) + } + + /// A prominent unread badge for the shortcuts bar. It rides in the + /// always-visible bar, so unread mail/chat shows in every pane, not just + /// some. The spinner tick (0..10, ~100ms each) drives a ~1s blink: bright + /// for the first half of the cycle, dim for the second. + fn unread_badge(&self) -> Line<'static> { + unread_indicator( + self.state.unread_messages, + self.state.rooms.total_unread(), + self.spinner_state < 5, + ) + } + + fn render_shortcuts( + &self, + frame: &mut Frame, + area: Rect, + rows: Vec>, + ) { // Surface our own sharing status in the block title. let shared = self.client.shared_directories(); let sharing = match shared.as_slice() { @@ -431,26 +666,20 @@ impl MainTui { let inner = block.inner(area); frame.render_widget(block, area); - // A prominent unread badge, right-aligned so the context shortcuts on - // the left never crowd it out. It rides in the always-visible shortcuts - // bar, so unread mail/chat shows in every pane, not just some. The - // spinner tick (0..10, ~100ms each) drives a ~1s blink: bright for the - // first half of the cycle, dim for the second. - let unread = unread_indicator( - self.state.unread_messages, - self.state.rooms.total_unread(), - self.spinner_state < 5, - ); + // The unread badge is right-aligned so the context shortcuts on the + // left never crowd it out. + let unread = self.unread_badge(); if unread.spans.is_empty() { - frame.render_widget(Paragraph::new(shortcuts_line), inner); + frame.render_widget(Paragraph::new(rows), inner); } else { let badge_width = unread.width() as u16; let cols = Layout::horizontal([ Constraint::Fill(1), Constraint::Length(badge_width), ]) + .spacing(1) .split(inner); - frame.render_widget(Paragraph::new(shortcuts_line), cols[0]); + frame.render_widget(Paragraph::new(rows), cols[0]); frame.render_widget( Paragraph::new(unread).alignment(Alignment::Right), cols[1], @@ -493,6 +722,60 @@ impl MainTui { } } +/// Columns between the two halves of the keys list. +const HELP_GAP: u16 = 2; + +/// The widest key in a column, so the descriptions line up. +fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize { + sections + .iter() + .flat_map(|(_, keys)| keys.iter()) + .map(|(key, _)| Span::raw(*key).width()) + .max() + .unwrap_or(0) +} + +/// Cells the widest of `lines` takes. +fn widest(lines: &[Line<'_>]) -> u16 { + let cells = lines.iter().map(Line::width).max().unwrap_or(0); + u16::try_from(cells).unwrap_or(u16::MAX) +} + +/// The keys list laid out for `width` cells of text: two columns when both +/// fit beside each other, otherwise the right half under the left. +fn help_columns(width: u16) -> Vec>> { + let left = help_lines(HELP_LEFT); + let right = help_lines(HELP_RIGHT); + if width >= widest(&left) + HELP_GAP + widest(&right) { + return vec![left, right]; + } + let mut stacked = left; + stacked.push(Line::from("")); + stacked.extend(right); + vec![stacked] +} + +fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec> { + let key_width = key_column_width(sections); + let mut lines = Vec::new(); + for (index, (heading, keys)) in sections.iter().enumerate() { + if index > 0 { + lines.push(Line::from("")); + } + lines.push(Line::styled( + (*heading).to_string(), + accent_style().add_modifier(Modifier::BOLD), + )); + for (key, action) in *keys { + lines.push(Line::from(vec![ + Span::styled(format!("{key: Rect { let vertical = Layout::vertical([ diff --git a/soulseek-rs/src/ui/main_tui/rooms.rs b/soulseek-rs/src/ui/main_tui/rooms.rs index bd048aa..86c033a 100644 --- a/soulseek-rs/src/ui/main_tui/rooms.rs +++ b/soulseek-rs/src/ui/main_tui/rooms.rs @@ -1,4 +1,5 @@ use super::MainTui; +use super::input::{jumped, list_jump, scroll_log}; use crate::models::RoomsView; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -38,6 +39,16 @@ impl MainTui { fn handle_rooms_list_input(&mut self, key: KeyEvent) { let len = self.state.rooms.filtered_rooms().len(); + if let Some(jump) = list_jump(key, self.popup_page()) { + self.state.rooms.list_selected = + jumped(self.state.rooms.list_selected, len, jump); + return; + } + // Control combinations are only ever jumps; the letters below are + // not theirs to trigger. + if key.modifiers.contains(KeyModifiers::CONTROL) { + return; + } match key.code { // Esc peels back one level: clear a lingering filter first (as the // title's "Esc: clear" promises), otherwise close the popup. @@ -92,6 +103,16 @@ impl MainTui { } fn handle_rooms_chat_input(&mut self, key: KeyEvent) { + // Paging keys move through the room's history; the member list is a + // row at a time. + if let Some(view) = self.state.rooms.active_view_mut() + && scroll_log(view, key) + { + return; + } + if key.modifiers.contains(KeyModifiers::CONTROL) { + return; + } match key.code { KeyCode::Char('q') => self.state.show_rooms = false, KeyCode::Esc | KeyCode::Char('l') => { @@ -208,15 +229,23 @@ impl MainTui { self.state.rooms.composing = false; } + /// The room whose newest messages are on screen, if any: the active one + /// in the chat view, unless the reader is held back in its history, when + /// what arrives still counts as unread. + pub(super) fn viewed_room(&self) -> Option { + if !self.state.show_rooms || self.state.rooms.view != RoomsView::Chat { + return None; + } + self.state + .rooms + .active_room() + .filter(|room| room.view.following()) + .map(|room| room.name.clone()) + } + /// Drain chat-room events into the rooms state, tracking unread badges. pub(super) fn poll_room_events(&mut self) { - let viewing = if self.state.show_rooms - && self.state.rooms.view == RoomsView::Chat - { - self.state.rooms.active_room().map(|r| r.name.clone()) - } else { - None - }; + let viewing = self.viewed_room(); for event in self.client.take_room_events() { self.state.rooms.apply_event(event, viewing.as_deref()); } diff --git a/soulseek-rs/src/ui/main_tui/search.rs b/soulseek-rs/src/ui/main_tui/search.rs index bea4d0f..d574d11 100644 --- a/soulseek-rs/src/ui/main_tui/search.rs +++ b/soulseek-rs/src/ui/main_tui/search.rs @@ -231,8 +231,8 @@ impl MainTui { self.state.results_table_state.select(Some(0)); self.state.results_name_offset = 0; - // Switch focus to Results pane - self.state.focused_pane = FocusedPane::Results; + // Switch focus to Results pane, bringing it back if it was hidden + self.state.focus_pane(FocusedPane::Results); let client = self.client.clone(); let timeout = self.search_timeout; diff --git a/soulseek-rs/src/ui/panes/chat_pane.rs b/soulseek-rs/src/ui/panes/chat_pane.rs index 80bfc23..5a2ff83 100644 --- a/soulseek-rs/src/ui/panes/chat_pane.rs +++ b/soulseek-rs/src/ui/panes/chat_pane.rs @@ -1,4 +1,4 @@ -use crate::models::{AppState, MessageDirection}; +use crate::models::{AppState, ChatMessage, LogView, MessageDirection}; use crate::ui::{ accent_style, border_style, dimmed_style, highlight_style, info_style, primary_style, wrap_chat_line, @@ -17,10 +17,11 @@ use ratatui::{ pub fn render_chat_pane( frame: &mut Frame, area: Rect, - state: &AppState, + state: &mut AppState, own_username: &str, ) { - let peer = state.active_chat_peer(); + let peer = state.active_chat_peer().map(str::to_string); + let peer = peer.as_deref(); let title = peer.map_or_else( || " Messages (m: compose, i/Esc: close) ".to_string(), |peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "), @@ -51,7 +52,14 @@ pub fn render_chat_pane( return; }; - render_messages(frame, body[0], state, peer, own_username); + render_messages( + frame, + body[0], + &state.messages, + &mut state.chat_view, + peer, + own_username, + ); render_compose(frame, chunks[1], state); } @@ -94,13 +102,13 @@ fn render_peers( fn render_messages( frame: &mut Frame, area: Rect, - state: &AppState, + messages: &[ChatMessage], + view: &mut LogView, peer: &str, own_username: &str, ) { let width = area.width as usize; - let lines: Vec = state - .messages + let lines: Vec = messages .iter() .filter(|m| m.peer == peer) .flat_map(|m| { @@ -123,9 +131,9 @@ fn render_messages( }) .collect(); - // Auto-scroll: tail by rendered rows so the newest message stays visible. - let start = lines.len().saturating_sub((area.height as usize).max(1)); - frame.render_widget(Paragraph::new(lines[start..].to_vec()), area); + // The newest rows, unless the reader is holding a place in the history. + let window = view.window(lines.len(), usize::from(area.height)); + frame.render_widget(Paragraph::new(lines[window].to_vec()), area); } fn render_compose(frame: &mut Frame, area: Rect, state: &AppState) { diff --git a/soulseek-rs/src/ui/panes/downloads_pane.rs b/soulseek-rs/src/ui/panes/downloads_pane.rs index 493b6e8..e624279 100644 --- a/soulseek-rs/src/ui/panes/downloads_pane.rs +++ b/soulseek-rs/src/ui/panes/downloads_pane.rs @@ -1,3 +1,4 @@ +use super::name_scroll::{column_width, end_offset, scroll_text}; use crate::models::DownloadEntry; use crate::ui::{ GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL, @@ -8,12 +9,43 @@ use crate::ui::{ }; use ratatui::{ Frame, - layout::Rect, + layout::{Constraint, Rect}, widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, }; use soulseek_rs::DownloadStatus; use soulseek_rs::types::{UploadInfo, UploadStatus}; +// Sized to the widest value each column shows in ordinary use — a +// three-digit megabyte count on both sides of the progress — so the file +// name keeps whatever width the pane has beyond that. +const WIDTHS: [Constraint; 5] = [ + Constraint::Length(6), + Constraint::Fill(1), + Constraint::Length(12), + Constraint::Length(25), + Constraint::Length(10), +]; + +/// Filename is the second column. +const NAME_COLUMN: usize = 1; + +/// The offset that brings the end of a transfer's name into view. +#[must_use] +pub fn name_end_offset(name: &str, area: Rect) -> usize { + end_offset(name, area, &WIDTHS, NAME_COLUMN) +} + +/// What the list shows for an upload: the peer-facing path is ours, so only +/// the file's own name is worth a column. +#[must_use] +pub fn upload_display_name(upload: &UploadInfo) -> &str { + upload + .filename + .rsplit(['\\', '/']) + .next() + .unwrap_or(&upload.filename) +} + pub fn render_downloads_pane( frame: &mut Frame, area: Rect, @@ -21,6 +53,7 @@ pub fn render_downloads_pane( uploads: &[UploadInfo], table_state: &mut TableState, focused: bool, + name_offset: usize, ) { if downloads.is_empty() && uploads.is_empty() { let empty_block = pane_block(focused).title(pane_title( @@ -47,6 +80,7 @@ pub fn render_downloads_pane( ]) .height(1); + let name_width = column_width(area, &WIDTHS, NAME_COLUMN); let mut rows: Vec = downloads .iter() .map(|download_entry| { @@ -106,7 +140,12 @@ pub fn render_downloads_pane( Row::new(vec![ Cell::from(status_icon).style(status_style), - Cell::from(download.filename.clone()).style(body_style()), + Cell::from(scroll_text( + &download.filename, + name_offset, + name_width, + )) + .style(body_style()), Cell::from(download.username.clone()).style(info_style()), Cell::from(progress_text).style(progress_style), Cell::from(speed_text).style(warning_style()), @@ -149,29 +188,21 @@ pub fn render_downloads_pane( } _ => "-".to_string(), }; - let basename = upload - .filename - .rsplit(['\\', '/']) - .next() - .unwrap_or(&upload.filename); Row::new(vec![ Cell::from(format!("↑ {status_icon}")).style(status_style), - Cell::from(basename.to_string()).style(body_style()), + Cell::from(scroll_text( + upload_display_name(upload), + name_offset, + name_width, + )) + .style(body_style()), Cell::from(upload.username.clone()).style(info_style()), Cell::from(progress_text).style(primary_style()), Cell::from(speed_text).style(warning_style()), ]) })); - let widths = [ - ratatui::layout::Constraint::Length(8), - ratatui::layout::Constraint::Fill(2), - ratatui::layout::Constraint::Length(15), - ratatui::layout::Constraint::Length(25), - ratatui::layout::Constraint::Length(12), - ]; - - let table = Table::new(rows, widths) + let table = Table::new(rows, WIDTHS) .header(header) .row_highlight_style(row_highlight_style()) .highlight_symbol(HIGHLIGHT_SYMBOL) diff --git a/soulseek-rs/src/ui/panes/mod.rs b/soulseek-rs/src/ui/panes/mod.rs index cc31cd9..357df0a 100644 --- a/soulseek-rs/src/ui/panes/mod.rs +++ b/soulseek-rs/src/ui/panes/mod.rs @@ -2,6 +2,7 @@ mod browse_pane; mod chat_pane; mod download_info_pane; mod downloads_pane; +mod name_scroll; mod results_pane; mod rooms_pane; mod searches_pane; @@ -11,9 +12,12 @@ pub use chat_pane::render_chat_pane; pub use download_info_pane::{ InfoSubject, render_download_info_pane, selected_transfer, }; -pub use downloads_pane::render_downloads_pane; +pub use downloads_pane::{ + name_end_offset as transfer_name_end_offset, render_downloads_pane, + upload_display_name, +}; pub use results_pane::{ ResultsPaneParams, name_end_offset, render_results_pane, }; pub use rooms_pane::render_rooms_pane; -pub use searches_pane::render_searches_pane; +pub use searches_pane::{query_end_offset, render_searches_pane}; diff --git a/soulseek-rs/src/ui/panes/name_scroll.rs b/soulseek-rs/src/ui/panes/name_scroll.rs new file mode 100644 index 0000000..1cd2b4f --- /dev/null +++ b/soulseek-rs/src/ui/panes/name_scroll.rs @@ -0,0 +1,126 @@ +//! Sideways scrolling for a table column that holds text wider than it is: +//! a file name, a query. Every list scrolls the same way, so the arithmetic +//! lives here and each pane only says which of its columns it applies to. + +use crate::ui::{HIGHLIGHT_SYMBOL, pane_block}; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, +}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +/// Cells column `column` of a table laid out with `widths` gets inside a pane +/// drawn over `area`: what the pane keeps for its border, padding and the +/// highlight symbol comes off first. +#[must_use] +pub fn column_width(area: Rect, widths: &[Constraint], column: usize) -> usize { + let inner = pane_block(false).inner(area); + let symbol = HIGHLIGHT_SYMBOL.width() as u16; + let [_, columns] = + Layout::horizontal([Constraint::Length(symbol), Constraint::Fill(0)]) + .areas(inner); + usize::from( + Layout::horizontal(widths).spacing(1).split(columns)[column].width, + ) +} + +/// How far a text of `cells` can scroll before its tail is in view, keeping +/// one cell for the ellipsis that marks the cut. +const fn overflow(cells: usize, width: usize) -> usize { + if cells <= width { 0 } else { cells + 1 - width } +} + +/// The offset that brings the end of `text` into view in the column. +#[must_use] +pub fn end_offset( + text: &str, + area: Rect, + widths: &[Constraint], + column: usize, +) -> usize { + overflow(text.width(), column_width(area, widths, column)) +} + +/// `text` scrolled `offset` cells to the left, with an ellipsis where it was +/// cut. Scrolls by cells, so a wide glyph counts double, and never starts on +/// a combining mark. Beyond the end it shows the tail. +#[must_use] +pub fn scroll_text(text: &str, offset: usize, width: usize) -> Line<'_> { + if offset == 0 { + return Line::from(text); + } + let mut tail_cells = 0; + let mut furthest = text.len(); + for (index, ch) in text.char_indices().rev() { + let cells = ch.width().unwrap_or(0); + if tail_cells + cells >= width { + if index == 0 && tail_cells + cells == width { + return Line::from(text); + } + break; + } + tail_cells += cells; + furthest = index; + } + if furthest == 0 { + return Line::from(text); + } + let mut skipped = 0; + let mut start = furthest; + for (index, ch) in text.char_indices() { + if index >= furthest || skipped >= offset { + start = index; + break; + } + skipped += ch.width().unwrap_or(0); + } + while let Some(ch) = text[start..].chars().next() + && ch.width() == Some(0) + { + start += ch.len_utf8(); + } + Line::from(vec![Span::raw("…"), Span::raw(&text[start..])]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_of(line: &Line<'_>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn a_text_that_fits_never_scrolls() { + assert_eq!(text_of(&scroll_text("short", 8, 10)), "short"); + assert_eq!( + end_offset( + "short", + Rect::new(0, 0, 40, 5), + &[Constraint::Fill(1)], + 0 + ), + 0 + ); + } + + #[test] + fn the_end_offset_leaves_room_for_the_ellipsis() { + // Column of 40 - 2 border - 2 padding - 1 symbol = 35 cells. + let area = Rect::new(0, 0, 40, 5); + let widths = [Constraint::Fill(1)]; + let fits = "x".repeat(35); + let over = "x".repeat(36); + assert_eq!(end_offset(&fits, area, &widths, 0), 0); + assert_eq!(end_offset(&over, area, &widths, 0), 2); + let line = scroll_text(&over, 2, 35); + assert_eq!(text_of(&line).chars().count(), 35); + assert!(text_of(&line).starts_with('…')); + } + + #[test] + fn scrolling_past_the_end_shows_the_tail() { + let line = scroll_text("abcdefghij", usize::MAX, 5); + assert_eq!(text_of(&line), "…ghij"); + } +} diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 1634047..039c7bf 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -1,3 +1,4 @@ +use super::name_scroll::{column_width, end_offset, scroll_text}; use crate::models::FileDisplayData; use crate::ui::{ BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes, @@ -6,12 +7,11 @@ use crate::ui::{ }; use ratatui::{ Frame, - layout::{Constraint, Layout, Rect}, + layout::{Constraint, Rect}, text::{Line, Span}, widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, }; use std::collections::HashSet; -use unicode_width::UnicodeWidthChar; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -39,59 +39,12 @@ pub struct ResultsPaneParams<'a> { pub name_offset: usize, } -fn name_column_width(area: Rect) -> usize { - let inner = pane_block(false).inner(area); - let symbol = Line::from(HIGHLIGHT_SYMBOL).width() as u16; - let [_, columns] = - Layout::horizontal([Constraint::Length(symbol), Constraint::Fill(0)]) - .areas(inner); - usize::from(Layout::horizontal(WIDTHS).spacing(1).split(columns)[1].width) -} - -const fn overflow(cells: usize, width: usize) -> usize { - if cells <= width { 0 } else { cells + 1 - width } -} +/// Filename is the second column. +const NAME_COLUMN: usize = 1; #[must_use] pub fn name_end_offset(name: &str, area: Rect) -> usize { - overflow(Span::raw(name).width(), name_column_width(area)) -} - -fn scroll_name(name: &str, offset: usize, width: usize) -> Line<'_> { - if offset == 0 { - return Line::from(name); - } - let mut tail_cells = 0; - let mut furthest = name.len(); - for (index, ch) in name.char_indices().rev() { - let cells = ch.width().unwrap_or(0); - if tail_cells + cells >= width { - if index == 0 && tail_cells + cells == width { - return Line::from(name); - } - break; - } - tail_cells += cells; - furthest = index; - } - if furthest == 0 { - return Line::from(name); - } - let mut skipped = 0; - let mut start = furthest; - for (index, ch) in name.char_indices() { - if index >= furthest || skipped >= offset { - start = index; - break; - } - skipped += ch.width().unwrap_or(0); - } - while let Some(ch) = name[start..].chars().next() - && ch.width() == Some(0) - { - start += ch.len_utf8(); - } - Line::from(vec![Span::raw("…"), Span::raw(&name[start..])]) + end_offset(name, area, &WIDTHS, NAME_COLUMN) } /// Whether the rendered row `display_idx` is selected. `selected_indices` holds @@ -173,7 +126,7 @@ pub fn render_results_pane( ]) .height(1); - let name_width = name_column_width(area); + let name_width = column_width(area, &WIDTHS, NAME_COLUMN); let rows: Vec = items .iter() .enumerate() @@ -206,7 +159,7 @@ pub fn render_results_pane( Row::new(vec![ Cell::from(checkbox).style(checkbox_style), - Cell::from(scroll_name( + Cell::from(scroll_text( &file.filename, name_offset, name_width, diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs index 53cd407..28a938f 100644 --- a/soulseek-rs/src/ui/panes/rooms_pane.rs +++ b/soulseek-rs/src/ui/panes/rooms_pane.rs @@ -1,4 +1,4 @@ -use crate::models::{RoomsState, RoomsView}; +use crate::models::{LogView, RoomsState, RoomsView}; use crate::ui::{ HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, highlight_style, info_style, pane_block, plain_title, primary_style, @@ -20,7 +20,7 @@ use ratatui::{ pub fn render_rooms_pane( frame: &mut Frame, area: Rect, - rooms: &RoomsState, + rooms: &mut RoomsState, list_table_state: &mut TableState, ) { match rooms.view { @@ -94,7 +94,7 @@ fn render_list( frame.render_stateful_widget(table, area, table_state); } -fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) { +fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { let block = pane_block(true).title( " Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ", ); @@ -110,7 +110,14 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) { render_tab_bar(frame, chunks[0], rooms); - let Some(active) = rooms.active_room() else { + let RoomsState { + open, + active: active_index, + user_selected, + composing, + .. + } = rooms; + let Some(active) = open.get_mut(*active_index) else { frame.render_widget( Paragraph::new("No open rooms. Press l for the room list.") .style(dimmed_style()), @@ -124,11 +131,11 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) { Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]) .split(chunks[1]); - render_messages(frame, body[0], active.lines.as_slice()); - render_users(frame, body[1], &active.users, rooms.user_selected); + render_messages(frame, body[0], active.lines.as_slice(), &mut active.view); + render_users(frame, body[1], &active.users, *user_selected); // Compose line or hint. - if rooms.composing { + if *composing { let line = Line::from(vec![ Span::styled("› ", accent_style()), Span::styled(active.input.clone(), primary_style()), @@ -173,9 +180,10 @@ fn render_messages( frame: &mut Frame, area: Rect, lines: &[crate::models::RoomLine], + view: &mut LogView, ) { - // Wrap each message to the pane width, then auto-scroll: tail by rendered - // rows so the newest message is always fully visible. + // Wrap each message to the pane width, then show the rows the reader is + // at: the newest ones, unless they are holding a place in the history. let width = area.width as usize; let rendered: Vec = lines .iter() @@ -198,8 +206,8 @@ fn render_messages( } }) .collect(); - let start = rendered.len().saturating_sub((area.height as usize).max(1)); - frame.render_widget(Paragraph::new(rendered[start..].to_vec()), area); + let window = view.window(rendered.len(), usize::from(area.height)); + frame.render_widget(Paragraph::new(rendered[window].to_vec()), area); } fn render_users( diff --git a/soulseek-rs/src/ui/panes/searches_pane.rs b/soulseek-rs/src/ui/panes/searches_pane.rs index a0aaeaf..03d45eb 100644 --- a/soulseek-rs/src/ui/panes/searches_pane.rs +++ b/soulseek-rs/src/ui/panes/searches_pane.rs @@ -1,3 +1,4 @@ +use super::name_scroll::{column_width, end_offset, scroll_text}; use crate::models::{SearchEntry, SearchStatus}; use crate::ui::{ HIGHLIGHT_SYMBOL, body_style, dimmed_style, header_style, pane_block, @@ -5,16 +6,34 @@ use crate::ui::{ }; use ratatui::{ Frame, - layout::Rect, + layout::{Constraint, Rect}, widgets::{Cell, HighlightSpacing, Row, Table, TableState}, }; +// The pane shares a row with two others, so the fixed columns are as +// narrow as their widest word: the query gets the rest. +const WIDTHS: [Constraint; 3] = [ + Constraint::Length(6), + Constraint::Fill(1), + Constraint::Length(7), +]; + +/// Query is the second column. +const QUERY_COLUMN: usize = 1; + +/// The offset that brings the end of a query into view. +#[must_use] +pub fn query_end_offset(query: &str, area: Rect) -> usize { + end_offset(query, area, &WIDTHS, QUERY_COLUMN) +} + pub fn render_searches_pane( frame: &mut Frame, area: Rect, searches: &[SearchEntry], table_state: &mut TableState, focused: bool, + query_offset: usize, ) { let header = Row::new(vec![ Cell::from("Status").style(header_style()), @@ -23,6 +42,7 @@ pub fn render_searches_pane( ]) .height(1); + let query_width = column_width(area, &WIDTHS, QUERY_COLUMN); let rows: Vec = searches .iter() .map(|search| { @@ -43,29 +63,30 @@ pub fn render_searches_pane( } else { search.results.len() }; + // Nothing yet from a search still out: the status column already + // says it is looking. let results_text = if results_count == 0 && search.status == SearchStatus::Active { - "Searching...".to_string() + "…".to_string() } else { format!("{results_count}") }; Row::new(vec![ status_cell, - Cell::from(search.query.clone()).style(body_style()), + Cell::from(scroll_text( + &search.query, + query_offset, + query_width, + )) + .style(body_style()), Cell::from(results_text).style(dimmed_style()), ]) }) .collect(); - let widths = [ - ratatui::layout::Constraint::Length(15), - ratatui::layout::Constraint::Fill(1), - ratatui::layout::Constraint::Length(10), - ]; - - let table = Table::new(rows, widths) + let table = Table::new(rows, WIDTHS) .header(header) .row_highlight_style(row_highlight_style()) .highlight_symbol(HIGHLIGHT_SYMBOL) diff --git a/soulseek-rs/src/ui/styles.rs b/soulseek-rs/src/ui/styles.rs index 2df9ab5..0acbaac 100644 --- a/soulseek-rs/src/ui/styles.rs +++ b/soulseek-rs/src/ui/styles.rs @@ -39,7 +39,8 @@ pub const GLYPH_FAILED: &str = "✗"; pub const GLYPH_TIMED_OUT: &str = "⧗"; pub const GLYPH_CURSOR: &str = "▮"; pub const HIGHLIGHT_SYMBOL: &str = "›"; -pub const SHORTCUT_ARROW: &str = "→"; +/// Between a key and its action in the legend, spaced. +pub const SHORTCUT_ARROW: &str = " → "; pub fn header_style() -> Style { Style::default().fg(DUST).add_modifier(Modifier::BOLD) @@ -158,22 +159,57 @@ pub fn download_status_glyph(status: &DownloadStatus) -> (&'static str, Style) { } } -pub fn format_shortcuts_styled(shortcuts: &[(&str, &str)]) -> Line<'static> { - let mut spans = Vec::new(); +/// One `[key → action]` legend entry. +fn shortcut_spans( + key: &'static str, + action: &'static str, +) -> [Span<'static>; 5] { + [ + Span::styled("[", dimmed_style()), + Span::styled(key, info_style()), + Span::styled(SHORTCUT_ARROW, dimmed_style()), + Span::styled(action, body_style()), + Span::styled("]", dimmed_style()), + ] +} - for (i, (key, action)) in shortcuts.iter().enumerate() { - if i > 0 { - spans.push(Span::raw(" ")); +/// The most rows the shortcuts bar grows to. Past that a terminal is too +/// narrow for the legend to be worth the content rows it would take. +pub const SHORTCUT_ROWS_MAX: usize = 3; + +/// Lay the legend out in rows no wider than `width`, never breaking inside an +/// entry: a `[key → action]` split over two rows reads as two keys. Entries +/// that do not fit in [`SHORTCUT_ROWS_MAX`] rows are dropped, so a bar always +/// has at least one row and never more than that. +pub fn pack_shortcuts( + shortcuts: &[(&'static str, &'static str)], + width: u16, +) -> Vec> { + let width = usize::from(width); + let mut rows: Vec> = Vec::new(); + let mut row: Vec> = Vec::new(); + let mut row_width = 0; + + for (key, action) in shortcuts { + let entry = shortcut_spans(key, action); + let entry_width: usize = entry.iter().map(Span::width).sum(); + if !row.is_empty() && row_width + 1 + entry_width > width { + rows.push(Line::from(std::mem::take(&mut row))); + if rows.len() == SHORTCUT_ROWS_MAX { + return rows; + } + row_width = 0; } - - spans.push(Span::styled("[", dimmed_style())); - spans.push(Span::styled(key.to_string(), Style::default().fg(SIGNAL))); - spans.push(Span::styled(format!(" {SHORTCUT_ARROW} "), dimmed_style())); - spans.push(Span::styled(action.to_string(), body_style())); - spans.push(Span::styled("]", dimmed_style())); + if !row.is_empty() { + row.push(Span::raw(" ")); + row_width += 1; + } + row.extend(entry); + row_width += entry_width; } - Line::from(spans) + rows.push(Line::from(row)); + rows } pub fn format_progress_bar( @@ -253,6 +289,46 @@ mod tests { assert_eq!(cursor.bg, Some(SELECTION)); } + fn texts(rows: &[Line<'static>]) -> Vec { + rows.iter() + .map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect()) + .collect() + } + + #[test] + fn shortcuts_stay_on_one_row_when_they_fit() { + let rows = pack_shortcuts(&[("q", "quit"), ("s", "search")], 80); + assert_eq!(texts(&rows), ["[q → quit] [s → search]"]); + } + + #[test] + fn shortcuts_wrap_between_entries_never_inside_one() { + let rows = pack_shortcuts( + &[("Space", "select"), ("Enter", "download"), ("q", "quit")], + 30, + ); + // "[Space → select] [Enter → download]" is 35 cells, over the 30 the + // bar has, so the second entry starts a new row whole. + assert_eq!( + texts(&rows), + ["[Space → select]", "[Enter → download] [q → quit]"] + ); + } + + #[test] + fn an_entry_wider_than_the_bar_still_gets_a_row() { + let rows = pack_shortcuts(&[("Enter", "download"), ("q", "quit")], 5); + assert_eq!(texts(&rows), ["[Enter → download]", "[q → quit]"]); + } + + #[test] + fn the_bar_never_grows_past_its_cap() { + let keys: Vec<(&str, &str)> = (0..10).map(|_| ("k", "act")).collect(); + let rows = pack_shortcuts(&keys, 12); + assert_eq!(rows.len(), SHORTCUT_ROWS_MAX); + assert_eq!(texts(&rows)[0], "[k → act]"); + } + #[test] fn progress_bar_splits_fill_and_track() { let bar = format_progress_bar(0.25, 20, 25); diff --git a/website/src/components/tui/data.ts b/website/src/components/tui/data.ts index 62e5f33..154510b 100644 --- a/website/src/components/tui/data.ts +++ b/website/src/components/tui/data.ts @@ -182,6 +182,11 @@ export const SHORTCUTS: readonly (readonly [string, string])[] = [ ['c', 'chat'], ['/', 'filter'], ['a/A', 'select all/none'], - ['1-3', 'focus pane'], + ['h/l', 'scroll name'], + ['g/G', 'top/end'], + ['Tab/1-3', 'pane'], + ['z', 'zoom'], + ['w', 'hide'], + ['?', 'keys'], ['q', 'quit'], ] diff --git a/website/src/components/tui/hero-terminal.tsx b/website/src/components/tui/hero-terminal.tsx index 5806214..02221bf 100644 --- a/website/src/components/tui/hero-terminal.tsx +++ b/website/src/components/tui/hero-terminal.tsx @@ -37,45 +37,44 @@ export const HeroTerminal = () => { > -
-
- +
+ + {demo.searching && ( +
+ searching the network + +
+ )}
-
-
- +
+ - {demo.searching && ( -
- searching the network - -
- )}
- -
-
- -
-
- -
+
+ +
+
+
diff --git a/website/src/components/tui/panes.tsx b/website/src/components/tui/panes.tsx index 4e5854b..7bb8344 100644 --- a/website/src/components/tui/panes.tsx +++ b/website/src/components/tui/panes.tsx @@ -6,7 +6,7 @@ import { TuiPane } from './chrome' import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data' const RESULT_COLS = '30px 1fr 74px 92px 78px 74px 42px' -const TRANSFER_COLS = '48px 1fr 96px 150px 74px' +const TRANSFER_COLS = '48px 1fr 96px 168px 74px' interface SearchesPaneProps { searches: readonly SearchRow[] diff --git a/website/src/pages/docs.tsx b/website/src/pages/docs.tsx index 0c23098..90691ed 100644 --- a/website/src/pages/docs.tsx +++ b/website/src/pages/docs.tsx @@ -25,10 +25,21 @@ const GLOBAL_KEYS: readonly (readonly [string, string])[] = [ ['i', 'inbox (unread counter)'], ['/', 'filter the current list'], ['a / A', 'select all / none'], - ['1–3', 'focus a pane'], + ['?', 'every key, grouped by pane'], ['q', 'quit'], ] +const PANE_KEYS: readonly (readonly [string, string])[] = [ + ['Tab · ⇧Tab', 'next / previous pane'], + ['1–3', 'focus a pane, and bring it back if hidden'], + ['z', 'zoom the focused pane to the whole window'], + ['w', 'hide the focused pane; the others widen'], + ['↑ ↓ · j k', 'move a row in any list'], + ['Home End · g G', 'first / last row'], + ['PgUp PgDn · ^u ^d', 'a page, or half a page; in a chat, back through the log'], + ['h l · ← →', 'scroll a long name or query sideways, 0 and $ to either end'], +] + const ROOM_KEYS: readonly (readonly [string, string])[] = [ ['Enter', 'join room / send message'], ['Tab · ⇧Tab', 'switch between open rooms'], @@ -40,16 +51,16 @@ const ROOM_KEYS: readonly (readonly [string, string])[] = [ ] const PANES: readonly { num?: number; title: string; body: string }[] = [ + { + num: 2, + title: 'Results', + body: 'Files from the network across the whole width: size, user, bitrate, speed, free slots. Space selects, Enter queues a download. h/l or ←/→ scroll a long name, 0 and $ jump to either end.', + }, { num: 1, title: 'Searches', body: 'Your queries and how many results each returned. Press s to search the network; each search keeps its own result set.', }, - { - num: 2, - title: 'Results', - body: 'Files from the network: size, user, bitrate, speed, free slots. Space selects, Enter queues a download. h/l or ←/→ scroll a long name, 0 and $ jump to either end; Home/End, g/G, PgUp/PgDn and Ctrl-f/b/d/u move through the list.', - }, { num: 3, title: 'Downloads / Uploads', @@ -106,8 +117,10 @@ const FirstRun = () => ( const Layout = () => (
- The whole client is these four boxes. Press 1–3 to focus - one; the focused pane’s legend turns green. + The whole client is these four boxes: results across the top, the rest in a row + underneath. Tab or 1–3 focuses one and its legend + turns green; z gives it the whole window, w hides it so the + others widen.
@@ -127,7 +140,8 @@ const Layout = () => ( const Keys = () => (
- The bottom bar always shows the keys for where you are. The essentials: + The bottom bar always shows the keys for where you are, and ? lists every + one. The essentials:
@@ -138,6 +152,16 @@ const Keys = () => ( ))}
+
+ panes and lists +
+ {PANE_KEYS.map(([combo, description]) => ( + + ))} +
+
+
+
in the chat-rooms popup (press c)