diff --git a/README.md b/README.md index fc47d95..ac4d221 100644 --- a/README.md +++ b/README.md @@ -99,12 +99,13 @@ 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 +Results run across the top, each file name in a column of its own with the +folder it came from beside it; 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, `w` hides it so the others widen +(its number brings it back), and `W` puts the whole layout back. Every list +takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and `h`/`l` or +`←`/`→` scroll a long file name, folder 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. @@ -572,7 +573,13 @@ unread counter. `c` opens the chat-rooms popup: a `/`-filterable room list busiest first, `Enter` to join, several rooms open at once as tabs (`Tab`/`Shift-Tab` to switch, `x` to leave, `l` back to the list), and `↑`/`↓` through the member list with `b` to browse someone or `m` to message them. -Unread counts appear on the tabs and on the `c chat (n)` shortcut. +`/` searches the open room's log and `u` its member list; the inbox takes `/` +too. Unread counts appear on the tabs and on the `c chat (n)` shortcut. + +`b` browses a user's shared files as a tree: `←`/`→` close and open a folder, +`J`/`K` jump between folders, `H`/`L` close or open every folder, `/` filters +the whole tree by path, and `Enter` or `d` downloads a file or a folder's +files (under a filter, only the ones it shows). ### Connectivity diff --git a/fixtures/demo.svg b/fixtures/demo.svg index dde3801..92aa121 100644 --- a/fixtures/demo.svg +++ b/fixtures/demo.svg @@ -1 +1 @@ -╭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 +╭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]││✓FilenameFolderSizeUserBitrateSpeedSlots││›[ ]01 - Coastal Dawn (Sunrise Take, Long Version).f@@gufoo\Field Recordings\Coastal30.5 MBloopdigger675 kbps3.35 MB/s1││[]02-ReservoirWind.flac@@gufoo\FieldRecordings\Coastal22.6MBloopdigger569kbps3.35MB/s1││[]03-MarshatNight(Long).flac@@gufoo\FieldRecordings\Coastal48.0MBloopdigger920kbps3.35MB/s1││[]04-TramDepot,06h.flac@@gufoo\FieldRecordings\Coastal32.2MBloopdigger725kbps3.35MB/s1││[]05-HarbourBells.flac@@gufoo\FieldRecordings\Coastal52.5MBloopdigger902kbps3.35MB/s1││[]06-RainonZincRoof.flac@@gufoo\FieldRecordings\Coastal30.4MBloopdigger804kbps3.35MB/s1││[]cover.jpg@@gufoo\FieldRecordings\Coastal0.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││[]01-EmptyChapelTone.flacMusic\lossless\PublicDomainFie17.4MBtapehiss836kbps2.1MB/s2││[]02-ForestFloor.flacMusic\lossless\PublicDomainFie38.0MBtapehiss741kbps2.1MB/s2││[]03-QuarryEcho.flacMusic\lossless\PublicDomainFie34.6MBtapehiss753kbps2.1MB/s2││[]04-NightFerry.flacMusic\lossless\PublicDomainFie18.7MBtapehiss723kbps2.1MB/s2││›Activepublic domain fi11││││Version).flac││[]01-CoastalDawn(SunriseTake,LongVersion).f@@gufoo\FieldRecordings\Coastal30.5MBloopdigger675kbps3.35MB/s1││›[ ]02 - Reservoir Wind.flac@@gufoo\Field Recordings\Coastal22.6 MBloopdigger569 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││02-ReservoirWind.flac││›Activepublic domain fi11││││@@gufoo/FieldRecordings/CoastalDawn││││││Size22.6MB││││││Bitrate569kbps││││││Length5m18s││›[ ]03 - Marsh at Night (Long).flac@@gufoo\Field Recordings\Coastal48.0 MBloopdigger920 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││03-MarshatNight(Long).flac││││││Size48.0MB││││││Bitrate920kbps││││││Length8m21s││[]01-Opener.mp3@@hnttf\complete\Explorersofth9.4MBnectoUA320kbps1.05MB/s0││[]02-Interlude.mp3@@hnttf\complete\Explorersofth5.1MBnectoUA320kbps1.05MB/s0││[]03-Coda.mp3@@hnttf\complete\Explorersofth19.5MBnectoUA320kbps1.05MB/s0││[]readme.txt@@hnttf\complete\Explorersofth0.1MBnectoUA-1.05MB/s0││›Activepublic domain fi15││││@@gufoo/FieldRecordings/CoastalDawn││›[✓]03 - Marsh at Night (Long).flac@@gufoo\Field Recordings\Coastal48.0 MBloopdigger920 kbps3.35 MB/s1││[✓]03-MarshatNight(Long).flac@@gufoo\FieldRecordings\Coastal48.0MBloopdigger920kbps3.35MB/s1││›[ ]04 - Tram Depot, 06h.flac@@gufoo\Field Recordings\Coastal32.2 MBloopdigger725 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││04-TramDepot,06h.flac││││││Size32.2MB││││││Bitrate725kbps││││││Length6m06s││›[ ]05 - Harbour Bells.flac@@gufoo\Field Recordings\Coastal52.5 MBloopdigger902 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││05-HarbourBells.flac││││││Size52.5MB││││││Bitrate902kbps││││││Length9m33s││›[✓]05 - Harbour Bells.flac@@gufoo\Field Recordings\Coastal52.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───────────────────────────────────────────────────────────────────────────────────────────────────────╮│✓FilenameFolderSizeUserBitrateSpeedSlots││[]01-CoastalDawn(SunriseTake,LongVersion).f@@gufoo\FieldRecordings\Coastal30.5MBloopdigger675kbps3.35MB/s1││[]02-ReservoirWind.flac@@gufoo\FieldRecordings\Coastal22.6MBloopdigger569kbps3.35MB/s1││[]03-MarshatNight(Long).flac@@gufoo\FieldRecordings\Coastal48.0MBloopdigger920kbps3.35MB/s1││[]04-TramDepot,06h.flac@@gufoo\FieldRecordings\Coastal32.2MBloopdigger725kbps3.35MB/s1││›[ ]05 - Harbour Bells.flac@@gufoo\Field Recordings\Coastal52.5 MBloopdigger902 kbps3.35 MB/s1││[]06-RainonZincRoof.flac@@gufoo\FieldRecordings\Coastal30.4MBloopdigger804kbps3.35MB/s1││[]cover.jpg@@gufoo\FieldRecordings\Coastal0.4MBloopdigger-3.35MB/s1││[]01-EmptyChapelTone.flacMusic\lossless\PublicDomainFie17.4MBtapehiss836kbps2.1MB/s2││[]02-ForestFloor.flacMusic\lossless\PublicDomainFie38.0MBtapehiss741kbps2.1MB/s2││[]03-QuarryEcho.flacMusic\lossless\PublicDomainFie34.6MBtapehiss753kbps2.1MB/s2││[]04-NightFerry.flacMusic\lossless\PublicDomainFie18.7MBtapehiss723kbps2.1MB/s2││[]01-Opener.mp3@@hnttf\complete\Explorersofth9.4MBnectoUA320kbps1.05MB/s0││[]02-Interlude.mp3@@hnttf\complete\Explorersofth5.1MBnectoUA320kbps1.05MB/s0││[]03-Coda.mp3@@hnttf\complete\Explorersofth19.5MBnectoUA320kbps1.05MB/s0││[]readme.txt@@hnttf\complete\Explorersofth0.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││││[]…CoastalDawn(SunriseTake,LongVersion).flac…wnSessions(2019)[FLAC24-96]30.5MBloopdigger675kbps3.35MB/s1││[]02-ReservoirWind.flac…wnSessions(2019)[FLAC24-96]22.6MBloopdigger569kbps3.35MB/s1││[]03-MarshatNight(Long).flac…wnSessions(2019)[FLAC24-96]48.0MBloopdigger920kbps3.35MB/s1││[]04-TramDepot,06h.flac…wnSessions(2019)[FLAC24-96]32.2MBloopdigger725kbps3.35MB/s1││›[ ]05 - Harbour Bells.flac…wn Sessions (2019) [FLAC 24-96]52.5 MBloopdigger902 kbps3.35 MB/s1││[]06-RainonZincRoof.flac…wnSessions(2019)[FLAC24-96]30.4MBloopdigger804kbps3.35MB/s1││[]01-EmptyChapelTone.flac…Recordings,Vol.2(CreativeCo17.4MBtapehiss836kbps2.1MB/s2││[]02-ForestFloor.flac…Recordings,Vol.2(CreativeCo38.0MBtapehiss741kbps2.1MB/s2││[]03-QuarryEcho.flac…Recordings,Vol.2(CreativeCo34.6MBtapehiss753kbps2.1MB/s2││[]04-NightFerry.flac…Recordings,Vol.2(CreativeCo18.7MBtapehiss723kbps2.1MB/s2││[]01-Opener.mp3…nterior-FieldRecordings20049.4MBnectoUA320kbps1.05MB/s0││[]02-Interlude.mp3…nterior-FieldRecordings20045.1MBnectoUA320kbps1.05MB/s0││[]03-Coda.mp3…nterior-FieldRecordings200419.5MBnectoUA320kbps1.05MB/s0││[]readme.txt…nterior-FieldRecordings20040.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-││││[]03-Marshat╭Keys───────────────────────────────────────────────────────────────────────────────────────────────────╮3.35MB/s1││[]04-TramDepo│PanesSearches│3.35MB/s1││›[ ]05 - Harbour B│Tab/Shift-Tabnext/previouspaneEntershowitsresults│3.35 MB/s1││[]06-RainonZ│123focusapane,hiddenornotdremovethesearch│3.35MB/s1││[]cover.jpg│zzoomthefocusedpaneCcleareverysearch│3.35MB/s1││[]01-EmptyCha│whidethefocusedpanecchatrooms│2.1MB/s2││[]02-ForestFl│Escleavezoom│2.1MB/s2││[]03-QuarryEc│clickfocusapaneResults│2.1MB/s2││[]04-NightFer│Spaceselect/deselect│2.1MB/s2││[]01-Opener.mp│Anywherea/Aselectall/none│1.05MB/s0││[]02-Interlude│ssearchthenetworkEnterdownloadtheselection│1.05MB/s0││[]03-Coda.mp3│bbrowseauser'sfiles/filter,Enterkeepsit,Esccle│1.05MB/s0││[]readme.txt│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]││[]06-RainonZincRoof.flac││││││Length││[]04-NightFerry.flacMusic\lossless\PublicDomainFie1││›Activepublic domain fi11││││@@gufoo/FieldRecordings/CoastalDa││[]readme.txt@@hnttf\complete\Explorersofth0│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│[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││[]01-CoastalDawn(Sunrisgs\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger42.3 MB/52.5 MB (80%)9.7 MB/s││[]06-RainonZincRoof.flac@@gufoo\FieldRecordings\Coastal││[]02-ForestFloor.flacMusic\lossless\PublicDomainFie38.0MBtapehiss741kbps││[]02-Interlud│╰──────────────────────────────││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││││[]03-QuarryEcho.flac…RecorCreativeCommonsZero)\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││││[]03-QuarryEcho.flacMusic\lossless\PublicD-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││││[]03-QuarryEc│clickfocusapaneResults34.6MBtapehiss753kbps2.1MB/s2││[]03-Coda.mp3│bbrowseauser'sfiles/filter,Enter320kbps1.05MB/s0│││qquit─────────────────────────────────────────────────────────────────────────────────╯│Statpublic domain fi15││›✓@@gufoo\FloopdiggerCompleted-││@@gufoo/FieldRecordings/CoastalDawn│││PgUpPgDn/^b^fonepage-│││││││││Saveto│││╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[]cover.jpgzoomthefocusedpaneCcleareverysearch│3.35MB/s1││[]04-NightFerry.flacMusic\lossless\PublicDomainFie18.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 3c8a786..aa9c983 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -1,6 +1,6 @@ use crate::models::{BrowseTabs, FileDisplayData, RoomsState, SettingsState}; use chrono::{DateTime, Local}; -use ratatui::{layout::Rect, widgets::TableState}; +use ratatui::{layout::Rect, text::Line, widgets::TableState}; use soulseek_rs::{DownloadStatus, types::Download}; use std::sync::atomic::AtomicBool; use std::sync::{Arc, mpsc::Receiver, mpsc::Sender}; @@ -174,6 +174,46 @@ impl LogView { } } +/// A log wrapped to a width, kept between frames. +/// +/// Wrapping every line of a long log on every draw is what made a busy room +/// lag. Lines only ever arrive at the end, so a draw wraps the new ones and +/// starts over only when the width or `key` (what is being shown, a filter +/// say) changes. +#[derive(Debug, Clone, Default)] +pub struct WrappedLog { + width: usize, + key: String, + /// How many source lines the rows cover. + wrapped: usize, + rows: Vec>, +} + +impl WrappedLog { + /// The rows for `lines`, `wrap` turning one line into its rows (none, + /// for a line a filter drops). + pub fn rows( + &mut self, + width: usize, + key: &str, + lines: &[T], + wrap: impl Fn(&T) -> Vec>, + ) -> &[Line<'static>] { + if width != self.width || key != self.key || lines.len() < self.wrapped + { + self.rows.clear(); + self.wrapped = 0; + self.width = width; + self.key = key.to_string(); + } + for line in &lines[self.wrapped..] { + self.rows.extend(wrap(line)); + } + self.wrapped = lines.len(); + &self.rows + } +} + /// What the shared command bar is currently capturing input for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandBarMode { @@ -254,6 +294,11 @@ pub struct AppState { /// Compose buffer for the active conversation. pub chat_input: String, pub chat_composing: bool, + /// The open conversation wrapped for drawing. + pub chat_wrapped: WrappedLog, + /// Narrows the open conversation to messages mentioning it. + pub chat_filter: String, + pub chat_filtering: bool, /// Where the conversation is being read. pub chat_view: LogView, @@ -332,6 +377,9 @@ impl AppState { chat_peer: None, chat_input: String::new(), chat_composing: false, + chat_wrapped: WrappedLog::default(), + chat_filter: String::new(), + chat_filtering: false, chat_view: LogView::default(), browse: BrowseTabs::new(), @@ -501,6 +549,27 @@ mod tests { layout.visible().collect() } + #[test] + fn a_wrapped_log_only_wraps_what_arrived_and_starts_over_on_a_change() { + let wrap = |line: &String| vec![Line::raw(line.clone())]; + let mut log = WrappedLog::default(); + let mut lines = vec!["a".to_string(), "b".to_string()]; + assert_eq!(log.rows(40, "", &lines, wrap).len(), 2); + lines.push("c".to_string()); + assert_eq!(log.rows(40, "", &lines, wrap).len(), 3); + // A narrower pane or another filter rebuilds; fewer lines do too. + let only_c = |line: &String| { + (line == "c") + .then(|| Line::raw(line.clone())) + .into_iter() + .collect() + }; + assert_eq!(log.rows(40, "c", &lines, only_c).len(), 1); + assert_eq!(log.rows(30, "c", &lines, wrap).len(), 3); + lines.truncate(1); + assert_eq!(log.rows(30, "c", &lines, wrap).len(), 1); + } + #[test] fn every_pane_starts_visible_and_unzoomed() { let state = AppState::new(); diff --git a/soulseek-rs/src/models/browse.rs b/soulseek-rs/src/models/browse.rs index 305558f..59ac1db 100644 --- a/soulseek-rs/src/models/browse.rs +++ b/soulseek-rs/src/models/browse.rs @@ -154,23 +154,20 @@ pub struct BrowseRow { pub size: Option, } -/// Flatten the tree into visible rows, descending only into expanded folders. -#[must_use] -pub fn flatten_browse( - nodes: &[BrowseNode], - expanded: &HashSet, -) -> Vec { - let mut rows = Vec::new(); - flatten_into(nodes, 0, expanded, &mut rows); - rows -} - +/// Emit `nodes` as rows. Without a `needle`, every folder and file, a +/// folder's contents only while it is open. With one, only the paths that +/// contain it and the folders on the way there, open ones still deciding +/// whether their contents show. `emit` false only counts. Says whether +/// anything under `nodes` was kept. fn flatten_into( nodes: &[BrowseNode], depth: usize, expanded: &HashSet, + needle: Option<&str>, rows: &mut Vec, -) { + emit: bool, +) -> bool { + let mut kept = false; for node in nodes { match node { BrowseNode::Folder { @@ -178,36 +175,107 @@ fn flatten_into( path, children, } => { - let is_expanded = expanded.contains(path); - rows.push(BrowseRow { - depth, - is_folder: true, - expanded: is_expanded, - name: name.clone(), - path: path.clone(), - size: None, - }); - if is_expanded { - flatten_into(children, depth + 1, expanded, rows); + let open = expanded.contains(path); + let mark = rows.len(); + if emit { + rows.push(BrowseRow { + depth, + is_folder: true, + expanded: open, + name: name.clone(), + path: path.clone(), + size: None, + }); + } + let below = match needle { + None => { + if open { + flatten_into( + children, + depth + 1, + expanded, + None, + rows, + emit, + ); + } + true + } + Some(needle) => { + flatten_into( + children, + depth + 1, + expanded, + Some(needle), + rows, + emit && open, + ) || path_has(path, needle) + } + }; + if below { + kept = true; + } else { + rows.truncate(mark); } } BrowseNode::File { name, path, size } => { - rows.push(BrowseRow { - depth, - is_folder: false, - expanded: false, - name: name.clone(), - path: path.clone(), - size: Some(*size), - }); + if needle.is_none_or(|needle| path_has(path, needle)) { + kept = true; + if emit { + rows.push(BrowseRow { + depth, + is_folder: false, + expanded: false, + name: name.clone(), + path: path.clone(), + size: Some(*size), + }); + } + } } } } + kept +} + +/// Whether `path` contains `needle`, which is already ASCII-lowercase. Case +/// is folded for ASCII only, without allocating: a filter keystroke runs +/// this over every path in a share. A letter beyond ASCII matches as typed. +fn path_has(path: &str, needle: &str) -> bool { + needle.is_empty() + || path + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +/// Open every folder with a path containing `needle` somewhere under it, +/// so a filter shows all it matched. Says whether anything under `nodes` +/// matched. +fn open_matching( + nodes: &[BrowseNode], + needle: &str, + expanded: &mut HashSet, +) -> bool { + let mut any = false; + for node in nodes { + match node { + BrowseNode::Folder { path, children, .. } => { + if open_matching(children, needle, expanded) + || path_has(path, needle) + { + expanded.insert(path.clone()); + any = true; + } + } + BrowseNode::File { path, .. } => any |= path_has(path, needle), + } + } + any } /// Find a node by its full path. -#[must_use] -pub fn find_node<'a>( +fn find_node<'a>( nodes: &'a [BrowseNode], path: &str, ) -> Option<&'a BrowseNode> { @@ -235,8 +303,7 @@ pub fn find_node<'a>( /// All file leaves under `node` as `(full_path, size)`, descending the tree /// structurally (never string-prefix matching). -#[must_use] -pub fn files_under(node: &BrowseNode) -> Vec<(String, u64)> { +fn files_under(node: &BrowseNode) -> Vec<(String, u64)> { let mut out = Vec::new(); collect_files(node, &mut out); out @@ -267,7 +334,17 @@ pub struct BrowseState { pub username: String, pub status: BrowseStatus, pub tree: Vec, - pub expanded: HashSet, + expanded: HashSet, + /// The tree flattened for the current expansion, kept between frames: + /// a heavy sharer's tree runs to tens of thousands of rows. + rows: Vec, + /// Narrows the rows to paths containing it, case-insensitively. Setting + /// it opens every folder with a match; clearing it puts the folders back + /// the way they were, kept in `kept_expanded` meanwhile. + filter: String, + kept_expanded: Option>, + /// Whether typing goes to the filter. + pub filtering: bool, pub selected_row: usize, pub file_count: usize, pub folder_count: usize, @@ -282,6 +359,10 @@ impl BrowseState { status: BrowseStatus::Loading, tree: Vec::new(), expanded: HashSet::new(), + rows: Vec::new(), + filter: String::new(), + kept_expanded: None, + filtering: false, selected_row: 0, file_count: 0, folder_count: 0, @@ -310,12 +391,105 @@ impl BrowseState { self.file_count = built.file_count; self.folder_count = built.folder_count; self.selected_row = 0; + self.refresh(); } /// The flattened visible rows for the current expansion state. #[must_use] - pub fn rows(&self) -> Vec { - flatten_browse(&self.tree, &self.expanded) + pub fn rows(&self) -> &[BrowseRow] { + &self.rows + } + + /// Open or close the folder at `path`. + pub fn set_expanded(&mut self, path: &str, expanded: bool) { + if expanded { + self.expanded.insert(path.to_string()); + } else { + self.expanded.remove(path); + } + self.refresh(); + } + + pub fn expand_all(&mut self) { + folder_paths(&self.tree, &mut self.expanded); + self.refresh(); + } + + pub fn collapse_all(&mut self) { + self.expanded.clear(); + self.refresh(); + } + + #[must_use] + pub fn filter(&self) -> &str { + &self.filter + } + + /// Narrow the rows to `filter`, starting over from the top, with every + /// folder holding a match open. An emptied filter puts the folders back + /// as they were before it. + pub fn set_filter(&mut self, filter: String) { + if filter == self.filter { + return; + } + if filter.is_empty() { + if let Some(kept) = self.kept_expanded.take() { + self.expanded = kept; + } + } else { + if self.kept_expanded.is_none() { + self.kept_expanded = Some(self.expanded.clone()); + } + self.expanded.clear(); + open_matching( + &self.tree, + &filter.to_ascii_lowercase(), + &mut self.expanded, + ); + } + self.filter = filter; + self.selected_row = 0; + self.refresh(); + } + + /// The files under the folder at `path`, only those the filter lets + /// through: what `d` on a filtered folder downloads is what it shows. + #[must_use] + pub fn folder_files(&self, path: &str) -> Vec<(String, u64)> { + let needle = self.filter.to_ascii_lowercase(); + find_node(&self.tree, path) + .map(files_under) + .unwrap_or_default() + .into_iter() + .filter(|(path, _)| path_has(path, &needle)) + .collect() + } + + /// Re-flatten after the tree, the expansion or the filter changed under + /// `rows`. + fn refresh(&mut self) { + let needle = + (!self.filter.is_empty()).then(|| self.filter.to_ascii_lowercase()); + self.rows.clear(); + flatten_into( + &self.tree, + 0, + &self.expanded, + needle.as_deref(), + &mut self.rows, + true, + ); + self.selected_row = + self.selected_row.min(self.rows.len().saturating_sub(1)); + } +} + +fn folder_paths(nodes: &[BrowseNode], out: &mut HashSet) { + for node in nodes { + if let BrowseNode::Folder { path, children, .. } = node { + out.insert(path.clone()); + folder_paths(children, out); + } } } @@ -413,6 +587,16 @@ impl BrowseTabs { mod tests { use super::*; + /// The tree flattened for `expanded`, unfiltered. + fn flatten_browse( + nodes: &[BrowseNode], + expanded: &HashSet, + ) -> Vec { + let mut rows = Vec::new(); + flatten_into(nodes, 0, expanded, None, &mut rows, true); + rows + } + fn dir(name: &str, files: &[(&str, u64)]) -> SharedDirectory { SharedDirectory { name: name.to_string(), @@ -579,6 +763,48 @@ mod tests { assert_eq!(tabs.retry_active(), None); } + #[test] + fn expanding_and_collapsing_refresh_the_rows_and_keep_the_selection() { + let mut state = BrowseState::loading("bob".to_string()); + state.load(&[dir("music\\album", &[("a.mp3", 1), ("b.mp3", 2)])]); + assert_eq!(state.rows().len(), 2, "music open, album closed"); + state.set_expanded("music\\album", true); + assert_eq!(state.rows().len(), 4); + state.selected_row = 3; + state.set_expanded("music\\album", false); + assert_eq!(state.rows().len(), 2); + assert_eq!(state.selected_row, 1, "pulled back onto the last row"); + } + + #[test] + fn a_filter_opens_every_folder_with_a_match_and_folds_still_work() { + let mut state = BrowseState::loading("bob".to_string()); + state.load(&[ + dir("music\\alpha", &[("a1.mp3", 1), ("a2.flac", 2)]), + dir("music\\beta", &[("b1.mp3", 3)]), + ]); + let names = |state: &BrowseState| -> Vec { + state.rows().iter().map(|row| row.name.clone()).collect() + }; + state.set_filter("beta".to_string()); + assert_eq!(names(&state), ["music", "beta", "b1.mp3"]); + state.set_filter("FLAC".to_string()); + assert_eq!(names(&state), ["music", "alpha", "a2.flac"], "any case"); + assert_eq!( + state.folder_files("music"), + [("music\\alpha\\a2.flac".to_string(), 2)], + "a folder downloads what it shows" + ); + state.set_expanded("music\\alpha", false); + assert_eq!(names(&state), ["music", "alpha"], "folded under a filter"); + state.set_filter(String::new()); + assert_eq!(state.rows().len(), 3, "the folders as they were before"); + state.expand_all(); + assert_eq!(state.rows().len(), 6); + state.collapse_all(); + assert_eq!(state.rows().len(), 1); + } + #[test] fn browse_state_load_auto_expands_top_level() { let mut state = BrowseState::loading("bob".to_string()); diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs index bbe1a7b..2994800 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -6,11 +6,12 @@ mod settings; pub use app_state::{ AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView, - MessageDirection, PaneLayout, SearchEntry, SearchStatus, -}; -pub use browse::{ - BrowseState, BrowseStatus, BrowseTabs, files_under, find_node, + MessageDirection, PaneLayout, SearchEntry, SearchStatus, WrappedLog, }; +pub use browse::{BrowseState, BrowseStatus, BrowseTabs}; pub use file_display_data::FileDisplayData; -pub use rooms::{RoomLine, RoomsState, RoomsView}; +pub use rooms::{ + ChatFilter, OpenRoom, RoomLine, RoomsState, RoomsView, contains_filter, + matching_users, +}; pub use settings::{SettingsAction, SettingsMode, SettingsState}; diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs index b4f3db1..5250cdd 100644 --- a/soulseek-rs/src/models/rooms.rs +++ b/soulseek-rs/src/models/rooms.rs @@ -1,4 +1,4 @@ -use crate::models::LogView; +use crate::models::{LogView, WrappedLog}; use chrono::{DateTime, Local}; use soulseek_rs::types::{RoomEvent, RoomInfo}; @@ -41,6 +41,8 @@ pub struct OpenRoom { pub input: String, /// Where the log is being read. pub view: LogView, + /// The log wrapped for drawing. + pub wrapped: WrappedLog, } impl OpenRoom { @@ -77,8 +79,22 @@ pub struct RoomsState { pub view: RoomsView, /// Whether the compose input for the active room is capturing keys. pub composing: bool, - /// Selected index into the active room's member list (for browse/PM). + /// Selected index into the active room's member list (for browse/PM), + /// as narrowed by `user_filter`. pub user_selected: usize, + /// Narrows the active room's log to lines mentioning it. + pub log_filter: String, + /// Narrows the active room's member list to names containing it. + pub user_filter: String, + /// Which of the two, if either, typing goes to. + pub filtering: Option, +} + +/// A room's two filterable lists. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatFilter { + Log, + Users, } impl Default for RoomsState { @@ -100,6 +116,31 @@ impl RoomsState { view: RoomsView::List, composing: false, user_selected: 0, + log_filter: String::new(), + user_filter: String::new(), + filtering: None, + } + } + + /// The active room's members the filter lets through. + #[must_use] + pub fn visible_users(&self) -> Vec<&str> { + self.active_room().map_or_else(Vec::new, |room| { + matching_users(&room.users, &self.user_filter) + }) + } + + /// Narrow the member list, starting over from its top. + pub fn set_user_filter(&mut self, filter: String) { + self.user_filter = filter; + self.user_selected = 0; + } + + /// Narrow the log, back at its newest matching line. + pub fn set_log_filter(&mut self, filter: String) { + self.log_filter = filter; + if let Some(view) = self.active_view_mut() { + view.to_newest(); } } @@ -159,8 +200,9 @@ impl RoomsState { /// The username highlighted in the active room's member list, if any. #[must_use] pub fn selected_user(&self) -> Option { - self.active_room() - .and_then(|r| r.users.get(self.user_selected).cloned()) + self.visible_users() + .get(self.user_selected) + .map(|user| (*user).to_string()) } /// Where the active room's log is being read, to move it. @@ -173,11 +215,9 @@ impl RoomsState { } pub fn select_user_down(&mut self) { - if let Some(room) = self.active_room() - && !room.users.is_empty() - { - self.user_selected = - (self.user_selected + 1).min(room.users.len() - 1); + let len = self.visible_users().len(); + if len > 0 { + self.user_selected = (self.user_selected + 1).min(len - 1); } } @@ -305,7 +345,7 @@ impl RoomsState { /// Keep `user_selected` within the active room's member list. fn clamp_user_selected(&mut self) { - let len = self.active_room().map_or(0, |r| r.users.len()); + let len = self.visible_users().len(); self.user_selected = self.user_selected.min(len.saturating_sub(1)); } @@ -320,6 +360,25 @@ impl RoomsState { } } +/// Whether `text` contains `filter`, ignoring case; an empty filter matches. +#[must_use] +pub fn contains_filter(text: &str, filter: &str) -> bool { + filter.is_empty() || text.to_lowercase().contains(&filter.to_lowercase()) +} + +/// The names in `users` that contain `filter`, in their own order. +#[must_use] +pub fn matching_users<'a>(users: &'a [String], filter: &str) -> Vec<&'a str> { + let needle = filter.to_lowercase(); + users + .iter() + .filter(|user| { + needle.is_empty() || user.to_lowercase().contains(&needle) + }) + .map(String::as_str) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs index 525a063..e9fc1bd 100644 --- a/soulseek-rs/src/ui/main_tui/browse.rs +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -1,6 +1,6 @@ use super::MainTui; -use super::input::{jumped, list_jump}; -use crate::models::{BrowseStatus, files_under, find_node}; +use super::input::{FilterEdit, edit_filter, jumped, list_jump}; +use crate::models::BrowseStatus; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::{thread, time::Duration}; @@ -9,11 +9,27 @@ const BROWSE_TIMEOUT: Duration = Duration::from_secs(20); impl MainTui { pub(super) fn handle_browse_input(&mut self, key: KeyEvent) { + // Typing goes to the filter; the other keys still move around. + if self.state.browse.active_tab().is_some_and(|b| b.filtering) + && self.handle_browse_filter_input(key) + { + return; + } + // Esc peels back one level: a filter first, then the popup. + if key.code == KeyCode::Esc + && let Some(browse) = self.state.browse.active_tab_mut() + && !browse.filter().is_empty() + { + browse.set_filter(String::new()); + return; + } if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { self.state.show_browse = false; return; } - let jump = list_jump(key, self.popup_page()); + // The tab bar takes a row once more than one user is open. + let chrome = u16::from(self.state.browse.tabs.len() > 1); + let jump = list_jump(key, self.popup_page(chrome)); // Control combinations are only ever jumps: ctrl-d pages, d downloads. if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) { return; @@ -48,8 +64,8 @@ impl MainTui { _ => {} } - // Snapshot the flattened rows + current selection, then drop the borrow. - let (rows, sel, row) = { + // The highlighted row and where it sits, then drop the borrow. + let (len, sel, row) = { let Some(browse) = self.state.browse.active_tab() else { self.state.show_browse = false; return; @@ -62,13 +78,12 @@ impl MainTui { return; } let sel = browse.selected_row.min(rows.len() - 1); - let row = rows[sel].clone(); - (rows, sel, row) + (rows.len(), sel, rows[sel].clone()) }; if let Some(jump) = jump { if let Some(browse) = self.state.browse.active_tab_mut() { - browse.selected_row = jumped(sel, rows.len(), jump); + browse.selected_row = jumped(sel, len, jump); } self.sync_browse_selection(); return; @@ -85,7 +100,11 @@ impl MainTui { } KeyCode::Char('d') => { let files = if row.is_folder { - self.browse_folder_files(&row.path) + self.state + .browse + .active_tab() + .map(|b| b.folder_files(&row.path)) + .unwrap_or_default() } else { vec![(row.path.clone(), row.size.unwrap_or(0))] }; @@ -102,59 +121,76 @@ impl MainTui { browse.selected_row = sel.saturating_sub(1); } KeyCode::Down | KeyCode::Char('j') => { - browse.selected_row = (sel + 1).min(rows.len() - 1); + browse.selected_row = (sel + 1).min(len - 1); } + // The folder-sized steps: the next or previous folder row. + KeyCode::Char('J') => { + if let Some(next) = + (sel + 1..len).find(|&i| browse.rows()[i].is_folder) + { + browse.selected_row = next; + } + } + KeyCode::Char('K') => { + if let Some(previous) = + (0..sel).rev().find(|&i| browse.rows()[i].is_folder) + { + browse.selected_row = previous; + } + } + KeyCode::Char('H') => browse.collapse_all(), + KeyCode::Char('L') => browse.expand_all(), + KeyCode::Char('/') => browse.filtering = true, KeyCode::Right | KeyCode::Char('l') => { if row.is_folder && !row.expanded { - browse.expanded.insert(row.path.clone()); + browse.set_expanded(&row.path, true); } else if row.is_folder { - browse.selected_row = (sel + 1).min(rows.len() - 1); + browse.selected_row = (sel + 1).min(len - 1); } } KeyCode::Left | KeyCode::Char('h') => { if row.is_folder && row.expanded { - browse.expanded.remove(&row.path); - } else if let Some(parent) = - (0..sel).rev().find(|&i| rows[i].depth < row.depth) + browse.set_expanded(&row.path, false); + } else if let Some(parent) = (0..sel) + .rev() + .find(|&i| browse.rows()[i].depth < row.depth) { browse.selected_row = parent; } } KeyCode::Enter => { // Folder toggle (files handled above). - if row.expanded { - browse.expanded.remove(&row.path); - } else { - browse.expanded.insert(row.path.clone()); - } + browse.set_expanded(&row.path, !row.expanded); } _ => {} } - // Re-clamp against the new flattened length. - let new_len = browse.rows().len(); - browse.selected_row = - browse.selected_row.min(new_len.saturating_sub(1)); } self.sync_browse_selection(); } + /// The keys that edit the filter while it is being typed. Says whether + /// `key` was one of them; the rest move around the narrowed rows. + fn handle_browse_filter_input(&mut self, key: KeyEvent) -> bool { + let Some(browse) = self.state.browse.active_tab_mut() else { + return false; + }; + let mut filter = browse.filter().to_string(); + let Some(edit) = edit_filter(&mut filter, key) else { + return false; + }; + browse.filtering = edit == FilterEdit::Changed; + browse.set_filter(filter); + self.sync_browse_selection(); + true + } + /// Point the browse table cursor at the active tab's selected row. fn sync_browse_selection(&mut self) { let selected = self.state.browse.active_tab().map(|b| b.selected_row); self.state.browse_table_state.select(selected); } - /// Files (`path`, `size`) under the active browse tab's folder at `path`. - fn browse_folder_files(&self, path: &str) -> Vec<(String, u64)> { - self.state - .browse - .active_tab() - .and_then(|b| find_node(&b.tree, path)) - .map(files_under) - .unwrap_or_default() - } - /// Queue downloads of `files` (path, size) from the active browse tab's user. fn queue_browse_files(&mut self, files: Vec<(String, u64)>) { let Some(username) = diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index 2b7038b..c9c20e8 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -1,6 +1,6 @@ use super::MainTui; -use crate::models::{CommandBarMode, FocusedPane, LogView}; -use crate::ui::pane_block; +use crate::models::{CommandBarMode, FocusedPane, LogView, PaneLayout}; +use crate::ui::page_of; use crate::ui::panes::{ InfoSubject, name_end_offset, query_end_offset, selected_transfer, transfer_name_end_offset, upload_display_name, @@ -8,7 +8,7 @@ use crate::ui::panes::{ use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; -use ratatui::layout::{Position, Rect}; +use ratatui::layout::Position; use ratatui::widgets::TableState; const NAME_SCROLL_STEP: isize = 8; @@ -100,6 +100,10 @@ impl MainTui { self.state.hide_focused_pane(); return; } + KeyCode::Char('W') => { + self.state.layout = PaneLayout::default(); + return; + } KeyCode::Esc if self.state.layout.zoomed => { self.state.layout.zoomed = false; return; @@ -186,6 +190,20 @@ impl MainTui { return; } + // Typing a filter captures the keys that edit it; the rest still + // move around. + if self.state.chat_filtering + && let Some(edit) = edit_filter(&mut self.state.chat_filter, key) + { + if edit != FilterEdit::Changed { + self.state.chat_filtering = false; + } + if edit != FilterEdit::Kept { + self.state.chat_view.to_newest(); + } + return; + } + // Paging keys move through the conversation's history. if scroll_log(&mut self.state.chat_view, key) { return; @@ -195,9 +213,15 @@ impl MainTui { } match key.code { + // Esc peels back one level: a filter first, then the popup. + KeyCode::Esc if !self.state.chat_filter.is_empty() => { + self.state.chat_filter.clear(); + self.state.chat_view.to_newest(); + } KeyCode::Char('i' | 'q') | KeyCode::Esc => { self.state.show_messages = false; } + KeyCode::Char('/') => self.state.chat_filtering = true, // Move through the conversation list on the right. KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => { self.state.cycle_chat_peer(true); @@ -326,30 +350,16 @@ impl MainTui { } fn handle_filter_input(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.results_is_filtering = false; - self.state.results_filter_query.clear(); - self.state.results_filtered_items = - self.state.results_items.clone(); - self.state.results_filtered_indices = - (0..self.state.results_items.len()).collect(); - } - KeyCode::Char(c) => { - self.state.results_filter_query.push(c); - self.apply_filter(); - } - KeyCode::Backspace => { - self.state.results_filter_query.pop(); - self.apply_filter(); - } - // Confirm the filter: leave typing mode but keep the query, so - // the normal Results keys (j/k, space, enter) act on the - // filtered list. - KeyCode::Enter => { - self.state.results_is_filtering = false; - } - _ => self.handle_results_input(key), + let Some(edit) = edit_filter(&mut self.state.results_filter_query, key) + else { + return self.handle_results_input(key); + }; + // A kept query stays: the normal Results keys act on the filtered + // list. An empty one shows everything without a rebuild. + if edit == FilterEdit::Changed { + self.apply_filter(); + } else { + self.state.results_is_filtering = false; } } @@ -374,10 +384,10 @@ impl MainTui { 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 a popup's list shows at once: its inner height less the `chrome` + /// rows above the list, a tab bar or a header. + pub(super) fn popup_page(&self, chrome: u16) -> usize { + page_of(self.state.popup_area, chrome) } /// Rows the focused pane shows at once: its inner height less the table @@ -440,10 +450,7 @@ impl MainTui { self.state.results_selected_indices.insert(index); } } - KeyCode::Char('/') => { - self.state.results_is_filtering = true; - self.state.results_filter_query.clear(); - } + KeyCode::Char('/') => self.state.results_is_filtering = true, KeyCode::Char('a') => { let indices: Vec = if self.state.results_filter_query.is_empty() { @@ -575,14 +582,41 @@ impl MainTui { } } -/// 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) +/// What a key did to a filter being typed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum FilterEdit { + Changed, + Kept, + Cleared, +} + +/// Apply `key` to `filter` the way every typed filter takes it: characters +/// and Backspace edit it, Enter keeps it, Esc clears it. A control or alt +/// chord is not typing. `None` when the key was none of those. +pub(super) fn edit_filter( + filter: &mut String, + key: KeyEvent, +) -> Option { + match key.code { + KeyCode::Esc => { + filter.clear(); + Some(FilterEdit::Cleared) + } + KeyCode::Enter => Some(FilterEdit::Kept), + KeyCode::Backspace => { + filter.pop(); + Some(FilterEdit::Changed) + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + filter.push(c); + Some(FilterEdit::Changed) + } + _ => None, + } } /// A move over a whole list at once: to either end, or by a number of rows diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 75ee091..fa39086 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -265,7 +265,9 @@ pub fn launch_main_tui( mod tests { use super::*; use crate::daemon::proto::ChatMessageDto; - use crate::models::{FocusedPane, MessageDirection, SearchStatus}; + use crate::models::{ + FocusedPane, MessageDirection, RoomsView, SearchStatus, + }; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; /// A stand-in for a daemon: it already holds a conversation, a transfer @@ -1158,8 +1160,9 @@ mod tests { let mut tui = with_session(TalkativeSession::default()); tui.state.results_items = results(n); tui.state.focused_pane = FocusedPane::Results; + // 88 cells wide gives the name column 11 cells and the folder 7. tui.state.results_pane_area = - Some(ratatui::layout::Rect::new(0, 0, 80, 13)); + Some(ratatui::layout::Rect::new(0, 0, 88, 13)); tui } @@ -1675,6 +1678,147 @@ mod tests { assert!(tui.state.show_browse, "still open"); } + #[test] + fn folder_keys_and_the_filter_move_through_a_browsed_tree() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + tui.state.browse.open("bob"); + let listing = vec![ + soulseek_rs::SharedDirectory { + name: "music\\alpha".to_string(), + files: vec![ + soulseek_rs::SharedFileEntry { + name: "a1.mp3".to_string(), + size: 1, + attributes: Vec::new(), + }, + soulseek_rs::SharedFileEntry { + name: "a2.flac".to_string(), + size: 2, + attributes: Vec::new(), + }, + ], + }, + soulseek_rs::SharedDirectory { + name: "music\\beta".to_string(), + files: vec![soulseek_rs::SharedFileEntry { + name: "b1.mp3".to_string(), + size: 3, + attributes: Vec::new(), + }], + }, + ]; + tui.state + .browse + .active_tab_mut() + .expect("tab") + .load(&listing); + tui.state.show_browse = true; + let _ = screen_of(&mut tui); + let selected = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").selected_row + }; + let rows = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").rows().len() + }; + + press(&mut tui, KeyCode::Char('L')); + assert_eq!(rows(&tui), 6, "every folder open"); + press(&mut tui, KeyCode::Char('J')); + press(&mut tui, KeyCode::Char('J')); + assert_eq!(selected(&tui), 4, "beta, skipping alpha's files"); + press(&mut tui, KeyCode::Char('K')); + assert_eq!(selected(&tui), 1, "back to alpha"); + press(&mut tui, KeyCode::Char('H')); + assert_eq!(rows(&tui), 1, "just music"); + + press(&mut tui, KeyCode::Char('/')); + for c in "flac".chars() { + press(&mut tui, KeyCode::Char(c)); + } + assert_eq!(rows(&tui), 3, "music, alpha, a2.flac"); + let screen = screen_of(&mut tui); + assert!(screen.contains("filter: flac_"), "{screen}"); + ctrl(&mut tui, 'd'); + assert_eq!( + tui.state.browse.active_tab().expect("tab").filter(), + "flac", + "a control chord pages, it does not type" + ); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Char('j')); + press(&mut tui, KeyCode::Char('j')); + assert_eq!(selected(&tui), 2, "j moves again once the filter is kept"); + press(&mut tui, KeyCode::Esc); + assert_eq!(rows(&tui), 1, "Esc clears the filter first"); + assert!(tui.state.show_browse); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_browse, "then hides the popup"); + } + + #[test] + fn slash_and_u_filter_a_rooms_log_and_its_members() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + in_a_room(&mut tui, 30); + press(&mut tui, KeyCode::Char('u')); + for c in "user2".chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Char('j')); + assert_eq!( + tui.state.rooms.selected_user().as_deref(), + Some("user21"), + "j moves within the matching members" + ); + let screen = screen_of(&mut tui); + assert!(screen.contains("Users 10/30"), "{screen}"); + + press(&mut tui, KeyCode::Char('/')); + for c in "line 01".chars() { + press(&mut tui, KeyCode::Char(c)); + } + let screen = screen_of(&mut tui); + assert!(screen.contains("line 010"), "{screen}"); + assert!(!screen.contains("line 029"), "{screen}"); + assert!(screen.contains("filter: line 01_"), "{screen}"); + ctrl(&mut tui, 'u'); + assert_eq!(tui.state.rooms.log_filter, "line 01", "^u scrolls"); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Esc); + assert!(tui.state.rooms.log_filter.is_empty()); + assert!(tui.state.rooms.user_filter.is_empty()); + assert_eq!(tui.state.rooms.view, RoomsView::Chat, "still in the room"); + press(&mut tui, KeyCode::Esc); + assert_eq!(tui.state.rooms.view, RoomsView::List, "then the list"); + } + + #[test] + fn slash_filters_the_open_conversation() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + for text in ["hello there", "see you", "hello again"] { + tui.state.messages.push(crate::models::ChatMessage { + direction: MessageDirection::Incoming, + peer: "bob".to_string(), + text: text.to_string(), + at: chrono::Local::now(), + }); + } + tui.state.chat_peer = Some("bob".to_string()); + tui.state.show_messages = true; + press(&mut tui, KeyCode::Char('/')); + for c in "hello".chars() { + press(&mut tui, KeyCode::Char(c)); + } + let screen = screen_of(&mut tui); + assert!(screen.contains("hello again"), "{screen}"); + assert!(!screen.contains("see you"), "{screen}"); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Esc); + assert!(tui.state.chat_filter.is_empty() && tui.state.show_messages); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_messages); + } + fn in_a_room(tui: &mut MainTui, messages: usize) { tui.state.rooms.apply_event( soulseek_rs::RoomEvent::Joined { @@ -1809,6 +1953,34 @@ mod tests { assert_eq!(bar_rows, 1, "{wide}"); } + #[test] + fn the_searches_pane_lists_its_clear_all_key() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[C → clear all]"), "{screen}"); + } + + #[test] + fn shift_w_brings_every_pane_back_and_leaves_zoom() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + assert!(!screen_sized(&mut tui, 300, 40).contains("[W → ")); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('z')); + assert!(tui.state.layout.zoomed); + assert!(!tui.state.layout.is_visible(FocusedPane::Searches)); + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[W → reset layout]"), "{screen}"); + press(&mut tui, KeyCode::Char('W')); + assert!(!tui.state.layout.zoomed); + assert!( + FocusedPane::ALL + .iter() + .all(|p| tui.state.layout.is_visible(*p)) + ); + } + #[test] fn page_keys_work_in_the_transfers_and_searches_lists_too() { let mut tui = furnished_tui(); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index 166283c..a9389e0 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -1,5 +1,5 @@ use super::MainTui; -use crate::models::{CommandBarMode, FocusedPane, RoomsView}; +use crate::models::{CommandBarMode, FocusedPane, PaneLayout, RoomsView}; use crate::ui::panes::{ InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane, render_download_info_pane, render_downloads_pane, render_results_pane, @@ -29,6 +29,7 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[ ("z", "zoom the focused pane"), ("w", "hide the focused pane"), ("Esc", "leave zoom"), + ("W", "every pane back, zoom off"), ("click", "focus a pane"), ], ), @@ -95,10 +96,35 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ ("^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"), + ("/", "filter the messages, the room list or the tree"), + ("u", "filter a room's member list"), + ], + ), + ( + "Browse popup", + &[ + ("← → / h l", "close / open a folder, or step out / in"), + ("J K", "next / previous folder"), + ("H L", "close / open every folder"), + ("/", "filter by path, Enter keeps it, Esc clears"), + ("Enter", "open a folder, or download a file"), + ("d", "download a file, or a folder's files"), + ("r", "ask again after a timeout"), ], ), ]; +/// What the bar offers while a filter is being typed, wherever that is, +/// with the keys that still move around there. +fn filter_keys( + moves: &[(&'static str, &'static str)], +) -> Vec<(&'static str, &'static str)> { + let mut keys = vec![("Type", "filter")]; + keys.extend_from_slice(moves); + keys.extend([("Enter", "keep filter"), ("Esc", "clear filter")]); + keys +} + impl MainTui { pub(super) fn render(&mut self, frame: &mut Frame) { let shortcuts = self.shortcut_rows(frame.area().width); @@ -481,10 +507,15 @@ impl MainTui { ] } } + RoomsView::Chat if self.state.rooms.filtering.is_some() => { + filter_keys(&[("PgUp/PgDn", "scroll"), ("↑↓", "pick user")]) + } RoomsView::Chat => vec![ ("Enter", "say"), ("PgUp/PgDn", "scroll"), ("↑↓", "pick user"), + ("/", "find in chat"), + ("u", "find user"), ("b", "browse user"), ("m", "message user"), ("Tab", "switch room"), @@ -497,7 +528,7 @@ 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![ + let mut keys = vec![ ("Tab/1-3", "pane"), ( "z", @@ -508,9 +539,13 @@ impl MainTui { }, ), ("w", "hide"), - ("?", "keys"), - ("q", "quit"), - ] + ]; + // Only once a pane is hidden or zoomed is there a layout to reset. + if self.state.layout != PaneLayout::default() { + keys.push(("W", "reset layout")); + } + keys.extend([("?", "keys"), ("q", "quit")]); + keys } /// Which shortcuts the bar offers, which is purely a question of what is @@ -534,11 +569,14 @@ impl MainTui { ("Enter", "send"), ("Esc", "stop typing"), ] + } else if self.state.chat_filtering { + filter_keys(&[("PgUp/PgDn", "scroll")]) } else { vec![ ("Enter", "type"), ("PgUp/PgDn", "scroll"), ("↑↓/Tab", "switch chat"), + ("/", "find"), ("m", "new chat"), ("i/Esc", "close"), ] @@ -546,17 +584,24 @@ impl MainTui { } else if self.state.show_rooms { self.rooms_shortcuts() } else if self.state.show_browse { - vec![ - ("↑↓", "move"), - ("PgUp/PgDn", "page"), - ("→←", "expand/collapse"), - ("Enter", "open/download"), - ("d", "download folder"), - ("Tab", "switch user"), - ("r", "retry"), - ("w", "close tab"), - ("Esc", "hide"), - ] + if self.state.browse.active_tab().is_some_and(|b| b.filtering) { + // Typing goes to the filter, so no letter keys are on offer. + filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")]) + } else { + vec![ + ("↑↓", "move"), + ("J/K", "next/prev folder"), + ("→←", "expand/collapse"), + ("H/L", "collapse/expand all"), + ("/", "filter"), + ("Enter", "open/download"), + ("d", "download folder"), + ("Tab", "switch user"), + ("r", "retry"), + ("w", "close tab"), + ("Esc", "hide"), + ] + } } else if self.state.command_bar_active { match self.state.command_bar_mode { CommandBarMode::Search => vec![ @@ -581,19 +626,14 @@ impl MainTui { && 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"), - ] + filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")]) } else { let mut keys = match self.state.focused_pane { FocusedPane::Searches => vec![ ("s", "search"), ("Enter", "results"), ("d", "remove"), + ("C", "clear all"), ("m", "message"), ("i", "inbox"), ("c", "chat"), diff --git a/soulseek-rs/src/ui/main_tui/rooms.rs b/soulseek-rs/src/ui/main_tui/rooms.rs index 86c033a..a5cc811 100644 --- a/soulseek-rs/src/ui/main_tui/rooms.rs +++ b/soulseek-rs/src/ui/main_tui/rooms.rs @@ -1,6 +1,6 @@ use super::MainTui; -use super::input::{jumped, list_jump, scroll_log}; -use crate::models::RoomsView; +use super::input::{FilterEdit, edit_filter, jumped, list_jump, scroll_log}; +use crate::models::{ChatFilter, RoomsView}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; impl MainTui { @@ -30,6 +30,14 @@ impl MainTui { self.handle_room_filter_input(key); return; } + // So does typing a filter over the log or the member list; the + // other keys still move around. + if self.state.rooms.view == RoomsView::Chat + && self.state.rooms.filtering.is_some() + && self.handle_chat_filter_input(key) + { + return; + } match self.state.rooms.view { RoomsView::List => self.handle_rooms_list_input(key), @@ -39,7 +47,7 @@ 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()) { + if let Some(jump) = list_jump(key, self.popup_page(1)) { self.state.rooms.list_selected = jumped(self.state.rooms.list_selected, len, jump); return; @@ -80,25 +88,18 @@ impl MainTui { } fn handle_room_filter_input(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.rooms.list_is_filtering = false; - self.state.rooms.list_filter.clear(); + let Some(edit) = edit_filter(&mut self.state.rooms.list_filter, key) + else { + return self.handle_rooms_list_input(key); + }; + match edit { + FilterEdit::Changed | FilterEdit::Cleared => { self.state.rooms.list_selected = 0; } - KeyCode::Enter => { - self.state.rooms.list_is_filtering = false; - self.join_selected_room(); - } - KeyCode::Char(c) => { - self.state.rooms.list_filter.push(c); - self.state.rooms.list_selected = 0; - } - KeyCode::Backspace => { - self.state.rooms.list_filter.pop(); - self.state.rooms.list_selected = 0; - } - _ => {} + FilterEdit::Kept => self.join_selected_room(), + } + if edit != FilterEdit::Changed { + self.state.rooms.list_is_filtering = false; } } @@ -113,11 +114,24 @@ impl MainTui { if key.modifiers.contains(KeyModifiers::CONTROL) { return; } + let filtered = !self.state.rooms.log_filter.is_empty() + || !self.state.rooms.user_filter.is_empty(); match key.code { KeyCode::Char('q') => self.state.show_rooms = false, + // Esc peels back one level: the filters first, then the list. + KeyCode::Esc if filtered => { + self.state.rooms.set_log_filter(String::new()); + self.state.rooms.set_user_filter(String::new()); + } KeyCode::Esc | KeyCode::Char('l') => { self.state.rooms.view = RoomsView::List; } + KeyCode::Char('/') => { + self.state.rooms.filtering = Some(ChatFilter::Log); + } + KeyCode::Char('u') => { + self.state.rooms.filtering = Some(ChatFilter::Users); + } KeyCode::Tab => self.state.rooms.next_tab(), KeyCode::BackTab => self.state.rooms.prev_tab(), KeyCode::Char('x') => self.leave_active_room(), @@ -137,6 +151,33 @@ impl MainTui { } } + /// The keys that edit whichever filter is being typed, the log's or the + /// member list's. Says whether `key` was one of them. + fn handle_chat_filter_input(&mut self, key: KeyEvent) -> bool { + let rooms = &mut self.state.rooms; + let over_log = rooms.filtering == Some(ChatFilter::Log); + let mut filter = if over_log { + rooms.log_filter.clone() + } else { + rooms.user_filter.clone() + }; + let Some(edit) = edit_filter(&mut filter, key) else { + return false; + }; + if edit != FilterEdit::Changed { + rooms.filtering = None; + } + // Keeping a filter leaves the view where it is. + if edit != FilterEdit::Kept { + if over_log { + rooms.set_log_filter(filter); + } else { + rooms.set_user_filter(filter); + } + } + true + } + /// Browse the shares of the member highlighted in the active room. Closes /// the rooms popup so the browse tree is the sole overlay. fn browse_selected_room_user(&mut self) { diff --git a/soulseek-rs/src/ui/mod.rs b/soulseek-rs/src/ui/mod.rs index dbafefe..d84b850 100644 --- a/soulseek-rs/src/ui/mod.rs +++ b/soulseek-rs/src/ui/mod.rs @@ -1,11 +1,13 @@ mod downloads; pub mod login; mod main_tui; +mod paging; mod panes; mod styles; mod utils; pub use downloads::render_download_stats; pub use main_tui::launch_main_tui; +pub use paging::*; pub use styles::*; pub use utils::*; diff --git a/soulseek-rs/src/ui/paging.rs b/soulseek-rs/src/ui/paging.rs new file mode 100644 index 0000000..d91cb4a --- /dev/null +++ b/soulseek-rs/src/ui/paging.rs @@ -0,0 +1,104 @@ +//! How a pane's table shows one page of a list that may run to tens of +//! thousands of rows: which rows those are, and how ratatui is handed just +//! those. Paging and drawing both count rows here, so they agree on what a +//! page is. + +use crate::ui::pane_block; +use ratatui::{ + Frame, + layout::Rect, + widgets::{Table, TableState}, +}; +use std::ops::Range; + +/// Rows a list in `area` shows at once, less the `chrome` rows above it: a +/// table header, a popup's tab bar. At least one, so a page key always +/// moves, even before the first draw. +#[must_use] +pub 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) +} + +/// The page a table over `area` shows of `len` rows, `chrome` rows of the +/// area being above them: where the last frame left off, moved only as far +/// as keeps the selection in view. A selection past the end lands on the +/// last row, as the table would have put it. +pub fn page_window( + state: &mut TableState, + len: usize, + area: Rect, + chrome: u16, +) -> Range { + if let Some(selected) = state.selected_mut() { + *selected = (*selected).min(len.saturating_sub(1)); + } + visible_range( + state.offset(), + state.selected(), + len, + page_of(Some(area), chrome), + ) +} + +/// Draw `table`, holding the rows of the page that starts at `start`, with +/// `state` shifted onto that page. The page start is kept for the next +/// frame. +pub fn render_page( + frame: &mut Frame, + table: Table, + area: Rect, + state: &mut TableState, + start: usize, +) { + let mut page_state = TableState::default() + .with_selected(state.selected().map(|selected| selected - start)); + frame.render_stateful_widget(table, area, &mut page_state); + *state.offset_mut() = start; +} + +/// The page a `height`-tall table shows, by ratatui's rule (its +/// `visible_rows` is private): the offset, moved only as far as keeps the +/// selection in view. A selection past the end counts as the last row. +fn visible_range( + offset: usize, + selected: Option, + len: usize, + height: usize, +) -> Range { + let height = height.max(1); + let last = len.saturating_sub(1); + let start = match selected.map(|selected| selected.min(last)) { + Some(selected) => { + offset.clamp((selected + 1).saturating_sub(height), selected) + } + None => offset.min(last), + }; + start..(start + height).min(len) +} + +#[cfg(test)] +mod tests { + use super::{page_window, visible_range}; + use ratatui::{layout::Rect, widgets::TableState}; + + #[test] + fn the_page_follows_the_selection_and_is_never_longer_than_the_pane() { + assert_eq!(visible_range(500, Some(505), 10_000, 10), 500..510); + assert_eq!(visible_range(0, Some(9_999), 10_000, 10), 9_990..10_000); + assert_eq!(visible_range(500, Some(499), 10_000, 10), 499..509); + assert_eq!(visible_range(9_995, None, 10_000, 10), 9_995..10_000); + assert_eq!(visible_range(0, Some(9), 3, 2), 1..3); + } + + #[test] + fn a_window_pulls_a_selection_past_the_end_onto_the_last_row() { + // 6 rows tall: 2 border, 1 header, 3 of the list. + let area = Rect::new(0, 0, 40, 6); + let mut state = TableState::default().with_selected(Some(9)); + assert_eq!(page_window(&mut state, 5, area, 1), 2..5); + assert_eq!(state.selected(), Some(4)); + } +} diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index c5348d4..6da44c4 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -1,8 +1,8 @@ use crate::models::{BrowseState, BrowseStatus, BrowseTabs}; use crate::ui::{ - HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, format_bytes, - get_spinner_char, highlight_style, pane_block, primary_style, - row_highlight_style, warning_style, + HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, filter_title, + format_bytes, get_spinner_char, highlight_style, page_window, pane_block, + primary_style, render_page, row_highlight_style, warning_style, }; use ratatui::{ Frame, @@ -62,10 +62,22 @@ fn render_browse_one( spinner_state: usize, ) { let title = match browse.status { - BrowseStatus::Loaded => format!( - " Browse {} — {} files, {} folders (Enter/d: download, Tab: user, w: close, Esc: hide) ", - browse.username, browse.file_count, browse.folder_count - ), + BrowseStatus::Loaded => { + let shown = + browse.rows().iter().filter(|row| !row.is_folder).count(); + filter_title( + &format!( + "Browse {} — {shown}/{} files", + browse.username, browse.file_count + ), + browse.filter(), + browse.filtering, + &format!( + " Browse {} — {} files, {} folders (Enter/d: download, /: filter, Tab: user, w: close, Esc: hide) ", + browse.username, browse.file_count, browse.folder_count + ), + ) + } _ => format!(" Browse {} ", browse.username), }; let block = pane_block(true).title(title); @@ -105,9 +117,21 @@ fn render_browse_one( ]; frame.render_widget(Paragraph::new(text).block(block), area); } + BrowseStatus::Loaded + if browse.rows().is_empty() && !browse.filter().is_empty() => + { + let text = format!("Nothing here matches '{}'.", browse.filter()); + frame.render_widget( + Paragraph::new(text).style(dimmed_style()).block(block), + area, + ); + } BrowseStatus::Loaded => { - let rows: Vec = browse - .rows() + let all = browse.rows(); + table_state.select(Some(browse.selected_row)); + let window = page_window(table_state, all.len(), area, 0); + let start = window.start; + let rows: Vec = all[window] .iter() .map(|row| { let indent = " ".repeat(row.depth); @@ -142,8 +166,7 @@ fn render_browse_one( .highlight_spacing(HighlightSpacing::Always) .block(block); - table_state.select(Some(browse.selected_row)); - frame.render_stateful_widget(table, area, table_state); + render_page(frame, table, area, table_state, start); } } } diff --git a/soulseek-rs/src/ui/panes/chat_pane.rs b/soulseek-rs/src/ui/panes/chat_pane.rs index 5a2ff83..92b716b 100644 --- a/soulseek-rs/src/ui/panes/chat_pane.rs +++ b/soulseek-rs/src/ui/panes/chat_pane.rs @@ -1,7 +1,7 @@ -use crate::models::{AppState, ChatMessage, LogView, MessageDirection}; +use crate::models::{AppState, MessageDirection, contains_filter}; use crate::ui::{ - accent_style, border_style, dimmed_style, highlight_style, info_style, - primary_style, wrap_chat_line, + accent_style, border_style, dimmed_style, filter_title, highlight_style, + info_style, primary_style, wrap_chat_line, }; use ratatui::{ Frame, @@ -24,7 +24,16 @@ pub fn render_chat_pane( 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) "), + |peer| { + filter_title( + peer, + &state.chat_filter, + state.chat_filtering, + &format!( + " {peer} (↑↓/Tab: switch, /: find, m: to…, i/Esc: close) " + ), + ) + }, ); let block = Block::default() .borders(Borders::ALL) @@ -52,14 +61,7 @@ pub fn render_chat_pane( return; }; - render_messages( - frame, - body[0], - &state.messages, - &mut state.chat_view, - peer, - own_username, - ); + render_messages(frame, body[0], state, peer, own_username); render_compose(frame, chunks[1], state); } @@ -102,34 +104,37 @@ fn render_peers( fn render_messages( frame: &mut Frame, area: Rect, - messages: &[ChatMessage], - view: &mut LogView, + state: &mut AppState, peer: &str, own_username: &str, ) { + let AppState { + messages, + chat_view: view, + chat_wrapped: wrapped, + chat_filter: filter, + .. + } = state; let width = area.width as usize; - let lines: Vec = messages - .iter() - .filter(|m| m.peer == peer) - .flat_map(|m| { - let (sender, sender_style) = match m.direction { - MessageDirection::Incoming => (peer, info_style()), - MessageDirection::Outgoing => (own_username, accent_style()), - }; - wrap_chat_line( - vec![ - Span::styled( - m.at.format("%H:%M ").to_string(), - dimmed_style(), - ), - Span::styled(format!("<{sender}> "), sender_style), - ], - &m.text, - primary_style(), - width, - ) - }) - .collect(); + let key = format!("{peer}\n{filter}"); + let lines = wrapped.rows(width, &key, messages, |m| { + if m.peer != peer || !contains_filter(&m.text, filter) { + return Vec::new(); + } + let (sender, sender_style) = match m.direction { + MessageDirection::Incoming => (peer, info_style()), + MessageDirection::Outgoing => (own_username, accent_style()), + }; + wrap_chat_line( + vec![ + Span::styled(m.at.format("%H:%M ").to_string(), dimmed_style()), + Span::styled(format!("<{sender}> "), sender_style), + ], + &m.text, + primary_style(), + width, + ) + }); // The newest rows, unless the reader is holding a place in the history. let window = view.window(lines.len(), usize::from(area.height)); diff --git a/soulseek-rs/src/ui/panes/downloads_pane.rs b/soulseek-rs/src/ui/panes/downloads_pane.rs index e624279..98a7878 100644 --- a/soulseek-rs/src/ui/panes/downloads_pane.rs +++ b/soulseek-rs/src/ui/panes/downloads_pane.rs @@ -4,8 +4,8 @@ use crate::ui::{ GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL, accent_style, body_style, dimmed_style, download_status_glyph, error_style, format_bytes, format_speed, header_style, inactive_style, info_style, - pane_block, pane_title, primary_style, row_highlight_style, success_style, - warning_style, + page_window, pane_block, pane_title, primary_style, render_page, + row_highlight_style, success_style, warning_style, }; use ratatui::{ Frame, @@ -81,7 +81,15 @@ pub fn render_downloads_pane( .height(1); let name_width = column_width(area, &WIDTHS, NAME_COLUMN); - let mut rows: Vec = downloads + // Downloads come first, uploads after; one page of the two together. + let total = downloads.len() + uploads.len(); + let window = page_window(table_state, total, area, 1); + let start = window.start; + let split = downloads.len(); + let shown_downloads = &downloads[start.min(split)..window.end.min(split)]; + let shown_uploads = + &uploads[start.saturating_sub(split)..window.end.saturating_sub(split)]; + let mut rows: Vec = shown_downloads .iter() .map(|download_entry| { let download = &download_entry.download; @@ -153,7 +161,7 @@ pub fn render_downloads_pane( }) .collect(); - rows.extend(uploads.iter().map(|upload| { + rows.extend(shown_uploads.iter().map(|upload| { let (status_icon, status_style) = match &upload.status { UploadStatus::Queued(_) => (GLYPH_QUEUED, warning_style()), UploadStatus::InProgress => (GLYPH_ACTIVE, accent_style()), @@ -213,5 +221,5 @@ pub fn render_downloads_pane( focused, ))); - frame.render_stateful_widget(table, area, table_state); + render_page(frame, table, area, table_state, start); } diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 039c7bf..1b71f59 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -2,8 +2,8 @@ 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, - header_style, info_style, pane_block, pane_title, row_highlight_style, - success_style, warning_style, + header_style, info_style, page_window, pane_block, pane_title, render_page, + row_highlight_style, success_style, warning_style, }; use ratatui::{ Frame, @@ -15,9 +15,13 @@ use std::collections::HashSet; const VERSION: &str = env!("CARGO_PKG_VERSION"); -const WIDTHS: [Constraint; 7] = [ +// The file name and its folder share whatever width the fixed columns leave, +// the name getting the larger part: it is what a search is after, and a +// folder's tail is a scroll away. +const WIDTHS: [Constraint; 8] = [ Constraint::Length(3), Constraint::Fill(3), + Constraint::Fill(2), Constraint::Length(12), Constraint::Length(15), Constraint::Length(10), @@ -39,12 +43,28 @@ pub struct ResultsPaneParams<'a> { pub name_offset: usize, } -/// Filename is the second column. +/// The file name is the second column, the folder it sits in the third. const NAME_COLUMN: usize = 1; +const FOLDER_COLUMN: usize = 2; +/// A shared path as `(folder, name)`. Peers send backslashes, but a slash +/// counts too. +fn split_path(path: &str) -> (&str, &str) { + path.rsplit_once(['\\', '/']).unwrap_or(("", path)) +} + +/// The offset that brings the end of a result's longer column into view. +/// One offset scrolls the name and the folder together, so it runs until +/// both tails are showing. #[must_use] -pub fn name_end_offset(name: &str, area: Rect) -> usize { - end_offset(name, area, &WIDTHS, NAME_COLUMN) +pub fn name_end_offset(path: &str, area: Rect) -> usize { + let (folder, name) = split_path(path); + end_offset(name, area, &WIDTHS, NAME_COLUMN).max(end_offset( + folder, + area, + &WIDTHS, + FOLDER_COLUMN, + )) } /// Whether the rendered row `display_idx` is selected. `selected_indices` holds @@ -118,6 +138,7 @@ pub fn render_results_pane( let header = Row::new(vec![ Cell::from("✓").style(header_style()), Cell::from("Filename").style(header_style()), + Cell::from("Folder").style(header_style()), Cell::from("Size").style(header_style()), Cell::from("User").style(header_style()), Cell::from("Bitrate").style(header_style()), @@ -127,10 +148,13 @@ pub fn render_results_pane( .height(1); let name_width = column_width(area, &WIDTHS, NAME_COLUMN); - let rows: Vec = items - .iter() - .enumerate() + let folder_width = column_width(area, &WIDTHS, FOLDER_COLUMN); + let window = page_window(table_state, items.len(), area, 1); + let start = window.start; + let rows: Vec = (start..) + .zip(&items[window]) .map(|(idx, file)| { + let (folder, name) = split_path(&file.filename); let checkbox = if row_is_selected(idx, original_indices, selected_indices) { "[✓]" @@ -159,12 +183,10 @@ pub fn render_results_pane( Row::new(vec![ Cell::from(checkbox).style(checkbox_style), - Cell::from(scroll_text( - &file.filename, - name_offset, - name_width, - )) - .style(body_style()), + Cell::from(scroll_text(name, name_offset, name_width)) + .style(body_style()), + Cell::from(scroll_text(folder, name_offset, folder_width)) + .style(dimmed_style()), Cell::from(format_bytes(file.size)).style(warning_style()), Cell::from(file.username.clone()).style(info_style()), Cell::from(bitrate_str).style(dimmed_style()), @@ -189,7 +211,7 @@ pub fn render_results_pane( .highlight_spacing(HighlightSpacing::Always) .block(pane_block(focused).title(pane_title("2", &title, focused))); - frame.render_stateful_widget(table, area, table_state); + render_page(frame, table, area, table_state, start); } #[cfg(test)] @@ -202,8 +224,18 @@ mod tests { fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String { let mut state = TableState::default(); state.select(Some(0)); + render_with(items, &mut state, name_offset) + } + + fn render_with( + items: &[FileDisplayData], + state: &mut TableState, + name_offset: usize, + ) -> String { + // Wide enough for the name column to hold 11 cells, with 7 for the + // folder beside it, and tall enough for three rows. let mut terminal = - Terminal::new(TestBackend::new(80, 6)).expect("backend"); + Terminal::new(TestBackend::new(88, 6)).expect("backend"); terminal .draw(|frame| { render_results_pane( @@ -211,7 +243,7 @@ mod tests { frame.area(), ResultsPaneParams { items, - table_state: &mut state, + table_state: state, selected_indices: &HashSet::new(), original_indices: None, filter_query: "", @@ -267,6 +299,34 @@ mod tests { assert!(!screen.contains("…\u{301}"), "{screen}"); } + #[test] + fn a_path_shows_its_name_before_its_folder() { + let items = [file("@@abc\\Music\\Album\\01.flac"), file("cover.jpg")]; + let screen = render_rows(&items, 0); + assert!(screen.contains("01.flac @@abc\\M"), "{screen}"); + assert!(screen.contains("cover.jpg "), "{screen}"); + } + + #[test] + fn one_offset_scrolls_the_name_and_the_folder_together() { + let items = [file("@@abc\\Music\\Album\\abcdefghijklmnopqrstuvwxyz")]; + let screen = render_rows(&items, 4); + assert!(screen.contains("…efghijklmn …c\\Musi"), "{screen}"); + } + + #[test] + fn the_end_offset_is_the_longer_columns() { + // 88 cells wide: 11 for the name, 7 for the folder. + let area = ratatui::layout::Rect::new(0, 0, 88, 6); + let folder = "f".repeat(20); + assert_eq!(super::name_end_offset(&format!("{folder}\\a"), area), 14); + let name = "x".repeat(40); + assert_eq!( + super::name_end_offset(&format!("{folder}\\{name}"), area), + 30 + ); + } + #[test] fn an_unscrolled_name_starts_at_its_beginning() { let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")]; @@ -275,6 +335,20 @@ mod tests { assert!(!screen.contains("…"), "{screen}"); } + #[test] + fn a_far_selection_is_drawn_from_its_own_page() { + let items: Vec = + (0..10_000).map(|i| file(&format!("{i}.mp3"))).collect(); + let mut state = TableState::default(); + state.select(Some(9_999)); + let screen = render_with(&items, &mut state, 0); + assert!(screen.contains("›[ ] 9999.mp3"), "{screen}"); + assert!(screen.contains(" [ ] 9997.mp3"), "{screen}"); + assert!(!screen.contains(" [ ] 0.mp3"), "{screen}"); + assert_eq!(state.offset(), 9_997, "the next frame starts here"); + assert_eq!(state.selected(), Some(9_999)); + } + #[test] fn identity_mapping_used_when_no_filter() { let selected: HashSet = std::iter::once(1).collect(); diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs index 28a938f..dd95ed2 100644 --- a/soulseek-rs/src/ui/panes/rooms_pane.rs +++ b/soulseek-rs/src/ui/panes/rooms_pane.rs @@ -1,8 +1,11 @@ -use crate::models::{LogView, RoomsState, RoomsView}; +use crate::models::{ + ChatFilter, LogView, OpenRoom, RoomLine, RoomsState, RoomsView, WrappedLog, + contains_filter, matching_users, +}; use crate::ui::{ - HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, - highlight_style, info_style, pane_block, plain_title, primary_style, - row_highlight_style, wrap_chat_line, + HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, filter_title, + highlight_style, info_style, page_window, pane_block, plain_title, + primary_style, render_page, row_highlight_style, wrap_chat_line, }; use ratatui::{ Frame, @@ -61,7 +64,10 @@ fn render_list( Cell::from("room").style(dimmed_style()), Cell::from("users").style(dimmed_style()), ]); - let table_rows: Vec = filtered + table_state.select(Some(rooms.list_selected)); + let window = page_window(table_state, filtered.len(), area, 1); + let start = window.start; + let table_rows: Vec = filtered[window] .iter() .map(|r| { let open = rooms.open_index(&r.name).is_some(); @@ -90,14 +96,18 @@ fn render_list( .highlight_spacing(HighlightSpacing::Always) .block(block); - table_state.select(Some(rooms.list_selected.min(filtered.len() - 1))); - frame.render_stateful_widget(table, area, table_state); + render_page(frame, table, area, table_state, start); } 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) ", + let typing_log = rooms.filtering == Some(ChatFilter::Log); + let title = filter_title( + "Chat rooms", + &rooms.log_filter, + typing_log, + " Chat rooms (Tab: switch, /: find, u: find user, l: room list, x: leave, Esc: back) ", ); + let block = pane_block(true).title(title); let inner = block.inner(area); frame.render_widget(block, area); @@ -115,6 +125,9 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { active: active_index, user_selected, composing, + log_filter, + user_filter, + filtering, .. } = rooms; let Some(active) = open.get_mut(*active_index) else { @@ -131,8 +144,28 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]) .split(chunks[1]); - render_messages(frame, body[0], active.lines.as_slice(), &mut active.view); - render_users(frame, body[1], &active.users, *user_selected); + let OpenRoom { + lines, + view, + wrapped, + users, + .. + } = active; + render_messages(frame, body[0], lines, view, wrapped, log_filter); + let shown = matching_users(users, user_filter); + let typing_users = *filtering == Some(ChatFilter::Users); + let members = if typing_users || !user_filter.is_empty() { + format!( + "Users {}/{} · {}{}", + shown.len(), + users.len(), + user_filter, + if typing_users { "_" } else { "" } + ) + } else { + format!("Users ({})", users.len()) + }; + render_users(frame, body[1], &shown, *user_selected, members); // Compose line or hint. if *composing { @@ -179,33 +212,32 @@ fn render_tab_bar(frame: &mut Frame, area: Rect, rooms: &RoomsState) { fn render_messages( frame: &mut Frame, area: Rect, - lines: &[crate::models::RoomLine], + lines: &[RoomLine], view: &mut LogView, + wrapped: &mut WrappedLog, + filter: &str, ) { - // Wrap each message to the pane width, then show the rows the reader is + // Each message wrapped to the pane width, then 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() - .flat_map(|l| { - let time = - Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style()); - match &l.username { - Some(user) => wrap_chat_line( - vec![ - time, - Span::styled(format!("<{user}> "), info_style()), - ], - &l.text, - primary_style(), - width, - ), - None => { - wrap_chat_line(vec![time], &l.text, dimmed_style(), width) - } - } - }) - .collect(); + let rendered = wrapped.rows(width, filter, lines, |l| { + let sender = l.username.as_deref().unwrap_or_default(); + if !contains_filter(&l.text, filter) && !contains_filter(sender, filter) + { + return Vec::new(); + } + let time = + Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style()); + match &l.username { + Some(user) => wrap_chat_line( + vec![time, Span::styled(format!("<{user}> "), info_style())], + &l.text, + primary_style(), + width, + ), + None => wrap_chat_line(vec![time], &l.text, dimmed_style(), width), + } + }); let window = view.window(rendered.len(), usize::from(area.height)); frame.render_widget(Paragraph::new(rendered[window].to_vec()), area); } @@ -213,14 +245,15 @@ fn render_messages( fn render_users( frame: &mut Frame, area: Rect, - users: &[String], + users: &[&str], selected: usize, + title: String, ) { let block = Block::default() .borders(Borders::LEFT) .border_style(dimmed_style()) .padding(PANE_PADDING) - .title(plain_title(format!("Users ({})", users.len()), false)); + .title(plain_title(title, false)); let inner = block.inner(area); frame.render_widget(block, area); diff --git a/soulseek-rs/src/ui/styles.rs b/soulseek-rs/src/ui/styles.rs index 0acbaac..b807b88 100644 --- a/soulseek-rs/src/ui/styles.rs +++ b/soulseek-rs/src/ui/styles.rs @@ -138,6 +138,23 @@ pub fn plain_title(title: impl Into, focused: bool) -> Line<'static> { )) } +/// A popup's title: `idle` until its list is filtered, then what it is +/// filtered by, a caret after it while that is still being typed, and how +/// the filter ends. +#[must_use] +pub fn filter_title( + head: &str, + filter: &str, + typing: bool, + idle: &str, +) -> String { + if !typing && filter.is_empty() { + return idle.to_string(); + } + let caret = if typing { "_" } else { "" }; + format!(" {head} · filter: {filter}{caret} (Enter: keep, Esc: clear) ") +} + /// The standard bordered pane. Chain `.title(..)` for a legend. pub fn pane_block(focused: bool) -> Block<'static> { Block::default() diff --git a/website/src/components/tui/hero-terminal.tsx b/website/src/components/tui/hero-terminal.tsx index 02221bf..4cc2267 100644 --- a/website/src/components/tui/hero-terminal.tsx +++ b/website/src/components/tui/hero-terminal.tsx @@ -37,8 +37,9 @@ export const HeroTerminal = () => { > - {/* Results across the top with the whole width for long names; - Searches, Transfers and Info share the row underneath. */} + {/* Results across the top, file names in a column of their own + with the folder beside them; Searches, Transfers and Info + share the row underneath. */} ✓ Filename + Folder Size User Bitrate @@ -143,6 +144,7 @@ export const ResultsPane = ({ {rows.map((row, i) => { const { glyph, tone } = resultGlyph(row) + const parts = row.name.split('\\') const isSelected = selected === i return ( - {row.name} + {parts.at(-1)} + + + {parts.slice(0, -1).join('\\')} {row.size} {row.user} diff --git a/website/src/pages/docs.tsx b/website/src/pages/docs.tsx index 90691ed..0ab7b42 100644 --- a/website/src/pages/docs.tsx +++ b/website/src/pages/docs.tsx @@ -34,10 +34,11 @@ const PANE_KEYS: readonly (readonly [string, string])[] = [ ['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'], + ['W', 'every pane back on screen, zoom off'], ['↑ ↓ · 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'], + ['h l · ← →', 'scroll a long name, folder or query sideways, 0 and $ to either end'], ] const ROOM_KEYS: readonly (readonly [string, string])[] = [ @@ -46,6 +47,8 @@ const ROOM_KEYS: readonly (readonly [string, string])[] = [ ['x', 'leave the active room'], ['l', 'back to the room list'], ['↑ ↓', 'select a member'], + ['/', 'search the chat log; in the inbox, the conversation'], + ['u', 'filter the member list'], ['b', "browse the member's files"], ['m', 'message the member'], ] @@ -54,7 +57,7 @@ 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.', + body: 'Files from the network across the whole width, each name in a column of its own with its folder beside it, then size, user, bitrate, speed, free slots. Space selects, Enter queues a download. h/l or ←/→ scroll a long name and folder, 0 and $ jump to either end.', }, { num: 1,