diff --git a/README.md b/README.md
index df879aa..ee80220 100644
--- a/README.md
+++ b/README.md
@@ -98,7 +98,9 @@ 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
-`?` shows every key for where you are.
+`h`/`l` or `←`/`→` scroll a long file name or query sideways, `0` and `$` to
+either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?`
+shows every key for where you are.
### Daemon mode
diff --git a/fixtures/demo.svg b/fixtures/demo.svg
index 0f07359..dde3801 100644
--- a/fixtures/demo.svg
+++ b/fixtures/demo.svg
@@ -1 +1 @@
-
\ No newline at end of file
+╭Status────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│soulseek-rs🦀v14.1.2Downloads:0active,0completed,0failed,0que│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╭[2]Results───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│soulseek-rsv14.1.2││││Noresultsyet.││Pickasearchin[1]Searches,orstartanewone.│╭[1]Searches────────────────────╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮│StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││Selectaresultortransferfordetails│││││││╰──────────────────────────────────╯╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯╭Shortcuts·Sharing:~/Music──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│[s→search][Enter→results][d→remove][m→message][i→inbox][c→chat][b→browseuser][h/l→scrollquery][Tab/1-3→pane]││[z→zoom][w→hide][?→keys][q→quit]│╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│search:│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│[Type→searchterm][←→→movecursor][Backspace/Del→edit][Enter→search][Esc→cancel]││search:public││search:publicdomain││search:publicdomainfield││search:publicdomainfieldrecordings│╭[2]Results:publicdomainfieldrecordings───────────────────────────────────────────────────────────────────────────────────────────────────────╮│soulseek-rsv14.1.2││││Noresultsyet.││Pickasearchin[1]Searches,orstartanewone.│╭[1]Searches────────────────────╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮│StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││Selectaresultortransferfordetails││›Activepublic domain fi…│││││││││││╰──────────────────────────────────╯╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[Space→select][Enter→download][b→browseowner][c→chat][/→filter][a/A→selectall/none][h/l→scrollname][g/G→top/end]││[Tab/1-3→pane][z→zoom][w→hide][?→keys][q→quit]││✓FilenameSizeUserBitrateSpeedSlots││›[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\01 - Coastal D30.5 MBloopdigger675 kbps3.35 MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\02-Reservoir22.6MBloopdigger569kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-Marshat48.0MBloopdigger920kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepo32.2MBloopdigger725kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\05-HarbourB52.5MBloopdigger902kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\06-RainonZ30.4MBloopdigger804kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\cover.jpg0.4MBloopdigger-3.35MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││01-CoastalDawn(SunriseTake,Long││›Activepublic domain fi7││││Version).flac││││││@@gufoo/FieldRecordings/CoastalDawn││││││Sessions(2019)[FLAC24-96]││││││Userloopdigger││││││Size30.5MB││││││Bitrate675kbps││││││Length6m52s││││││Freeslots1available││││││Uploadspeed3.4MB/s││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0117.4MBtapehiss836kbps2.1MB/s2││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0238.0MBtapehiss741kbps2.1MB/s2││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0334.6MBtapehiss753kbps2.1MB/s2││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0418.7MBtapehiss723kbps2.1MB/s2││›Activepublic domain fi11││││Version).flac││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\01-CoastalD30.5MBloopdigger675kbps3.35MB/s1││›[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\02 - Reservoir22.6 MBloopdigger569 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││02-ReservoirWind.flac││›Activepublic domain fi11││││@@gufoo/FieldRecordings/CoastalDawn││││││Size22.6MB││││││Bitrate569kbps││││││Length5m18s││›[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\03 - Marsh at48.0 MBloopdigger920 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││03-MarshatNight(Long).flac││││││Size48.0MB││││││Bitrate920kbps││││││Length8m21s││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-9.4MBnectoUA320kbps1.05MB/s0││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-5.1MBnectoUA320kbps1.05MB/s0││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-19.5MBnectoUA320kbps1.05MB/s0││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-0.1MBnectoUA-1.05MB/s0││›Activepublic domain fi15││││@@gufoo/FieldRecordings/CoastalDawn││›[✓]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\03 - Marsh at48.0 MBloopdigger920 kbps3.35 MB/s1││[✓]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-Marshat48.0MBloopdigger920kbps3.35MB/s1││›[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\04 - Tram Depo32.2 MBloopdigger725 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││04-TramDepot,06h.flac││││││Size32.2MB││││││Bitrate725kbps││││││Length6m06s││›[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour B52.5 MBloopdigger902 kbps3.35 MB/s1││StatusQueryResults││Notransfers.Selectfilesin[2]ResultsandpressEnter.││05-HarbourBells.flac││││││Size52.5MB││││││Bitrate902kbps││││││Length9m33s││›[✓]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour B52.5 MBloopdigger902 kbps3.35 MB/s1││›Donepublic domain fi15││││@@gufoo/FieldRecordings/CoastalDawn││soulseek-rs🦀v14.1.2Downloads:0active,0completed,0failed,2que││StatusQueryResults││StatusFilenameUserProgressSpeed││05-HarbourBells.flac││›Donepublic domain fi15││›⋯@@gufoo\FloopdiggerQueued-││@@gufoo/FieldRecordings/CoastalDawn││││⋯@@gufoo\FloopdiggerQueued-││Sessions(2019)[FLAC24-96]│╭[2]Results:publicdomainfieldrecordings───────────────────────────────────────────────────────────────────────────────────────────────────────╮│✓FilenameSizeUserBitrateSpeedSlots││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\01-CoastalD30.5MBloopdigger675kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\02-Reservoir22.6MBloopdigger569kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-Marshat48.0MBloopdigger920kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepo32.2MBloopdigger725kbps3.35MB/s1││›[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour B52.5 MBloopdigger902 kbps3.35 MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\06-RainonZ30.4MBloopdigger804kbps3.35MB/s1││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\cover.jpg0.4MBloopdigger-3.35MB/s1││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0117.4MBtapehiss836kbps2.1MB/s2││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0238.0MBtapehiss741kbps2.1MB/s2││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0334.6MBtapehiss753kbps2.1MB/s2││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0418.7MBtapehiss723kbps2.1MB/s2││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-9.4MBnectoUA320kbps1.05MB/s0││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-5.1MBnectoUA320kbps1.05MB/s0││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-19.5MBnectoUA320kbps1.05MB/s0││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-0.1MBnectoUA-1.05MB/s0│╭[1]Searches────────────────────╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮│StatusQueryResults││StatusFilenameUserProgressSpeed││05-HarbourBells.flac││›Donepublic domain fi15││›⋯@@gufoo\FloopdiggerQueued-││@@gufoo/FieldRecordings/CoastalDawn││││⋯@@gufoo\FloopdiggerQueued-││Sessions(2019)[FLAC24-96]││││││││││││Userloopdigger││││││Size52.5MB││││││StatusQueued││││││Saveto││││││/home/demo/Music/Soulseek││││││Bitrate902kbps││││││Length9m33s│╰──────────────────────────────────╯╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[p→pause/resume][x→cancel][r→retryfailed][d→deletequeued/done][c→clearfinished][b→browseuser][h/l→scrollname]││││││StatusInprogress││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que5.6/100.5MB·18.61MB/s·[█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]5%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger2.9 MB/52.5 MB (5%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger2.7MB/48.0MB(5%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que11.2/100.5MB·18.61MB/s·[███░░░░░░░░░░░░░░░░░░░░░░░░░░░░]11%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger5.8 MB/52.5 MB (11%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger5.3MB/48.0MB(11%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que16.8/100.5MB·18.61MB/s·[█████░░░░░░░░░░░░░░░░░░░░░░░░░░]16%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger8.8 MB/52.5 MB (16%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger8.0MB/48.0MB(16%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que25.1/100.5MB·18.61MB/s·[███████░░░░░░░░░░░░░░░░░░░░░░░░]25%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger13.1 MB/52.5 MB (25%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger12.0MB/48.0MB(25%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que30.7/100.5MB·18.61MB/s·[█████████░░░░░░░░░░░░░░░░░░░░░░]30%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger16.0 MB/52.5 MB (30%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger14.7MB/48.0MB(30%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que36.3/100.5MB·18.61MB/s·[███████████░░░░░░░░░░░░░░░░░░░░]36%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger19.0 MB/52.5 MB (36%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger17.3MB/48.0MB(36%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que41.9/100.5MB·18.61MB/s·[████████████░░░░░░░░░░░░░░░░░░░]41%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger21.9 MB/52.5 MB (41%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger20.0MB/48.0MB(41%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer5.6MB/41.0MB(13%)3.7MB/s│││╭[3]Downloads/Uploads─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│StatusFilenameUserProgressSpeed││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger21.9 MB/52.5 MB (41%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger20.0MB/48.0MB(41%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer6.0MB/41.0MB(14%)3.7MB/s││[Tab/1-3→pane][z→unzoom][w→hide][?→keys][q→quit]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que44.7/100.5MB·18.61MB/s·[█████████████░░░░░░░░░░░░░░░░░░]44%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger23.3 MB/52.5 MB (44%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger21.3MB/48.0MB(44%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que50.2/100.5MB·18.61MB/s·[███████████████░░░░░░░░░░░░░░░░]50%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger26.2 MB/52.5 MB (50%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger24.0MB/48.0MB(50%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que55.8/100.5MB·18.61MB/s·[█████████████████░░░░░░░░░░░░░░]55%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger29.2 MB/52.5 MB (55%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger26.7MB/48.0MB(55%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que61.4/100.5MB·18.61MB/s·[██████████████████░░░░░░░░░░░░░]61%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger32.1 MB/52.5 MB (61%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger29.3MB/48.0MB(61%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que67.0/100.5MB·18.61MB/s·[████████████████████░░░░░░░░░░░]66%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger35.0 MB/52.5 MB (66%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger32.0MB/48.0MB(66%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que72.6/100.5MB·18.61MB/s·[██████████████████████░░░░░░░░░]72%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger37.9 MB/52.5 MB (72%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger34.7MB/48.0MB(72%)8.9MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que78.2/100.5MB·18.61MB/s·[████████████████████████░░░░░░░]77%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger40.8 MB/52.5 MB (77%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger37.3MB/48.0MB(77%)8.9MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger38.7MB/48.0MB(80%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer13.6MB/41.0MB(33%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que83.8/100.5MB·18.61MB/s·[█████████████████████████░░░░░░]83%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger43.8 MB/52.5 MB (83%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger40.0MB/48.0MB(83%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer14.0MB/41.0MB(34%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que89.3/100.5MB·18.61MB/s·[███████████████████████████░░░░]88%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger46.7 MB/52.5 MB (88%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger42.7MB/48.0MB(88%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que94.9/100.5MB·18.61MB/s·[█████████████████████████████░░]94%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger49.6 MB/52.5 MB (94%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger45.3MB/48.0MB(94%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que97.7/100.5MB·18.61MB/s·[██████████████████████████████░]97%││StatusQueryResults││StatusFilenameUserProgressSpeed││05-HarbourBells.flac││›Donepublic domain fi15││›↯@@gufoo\Floopdigger51.0 MB/52.5 MB (97%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger46.7MB/48.0MB(97%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer17.0MB/41.0MB(41%)3.7MB/s││││││││Userloopdigger││││││Size52.5MB││││││StatusInprogress││││││Saveto││││││/home/demo/Music/Soulseek││││││Bitrate902kbps││││││Length9m33s││soulseek-rs🦀v14.1.2Downloads:0active,2completed,0failed,0que││›Donepublic domain fi15││›✓@@gufoo\FloopdiggerCompleted-││@@gufoo/FieldRecordings/CoastalDawn││││✓@@gufoo\FloopdiggerCompleted-││Sessions(2019)[FLAC24-96]││││││StatusCompleted││││↑↯HarbourBcrateminer19.9MB/41.0MB(48%)3.7MB/s│││╭[3]Downloads/Uploads─────────────────────────────────────────────────────────────────────╮╭[Info]──────────────────────────────────────────────╮│StatusFilenameUserProgressSpeed││05-HarbourBells.flac││›✓@@gufoo\Field Recordings\CoastalloopdiggerCompleted-││@@gufoo/FieldRecordings/CoastalDawnSessions││✓@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-││(2019)[FLAC24-96]││↑↯HarbourBells(LiveattheQuay)crateminer20.3MB/41.0MB(49%)3.7MB/s││││││Userloopdigger││││Size52.5MB││││Bitrate902kbps││││Length9m33s││││││││Freeslots1available││││Uploadspeed3.4MB/s│╰────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────╯│↑↯HarbourBells(LiveattheQuay)crateminer27.6MB/41.0MB(67%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer29.2MB/41.0MB(71%)3.7MB/s││││[]…eldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\01-CoastalDawn(Sunr30.5MBloopdigger675kbps3.35MB/s1││[]…eldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\02-ReservoirWind.fla22.6MBloopdigger569kbps3.35MB/s1││[]…eldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(Lo48.0MBloopdigger920kbps3.35MB/s1││[]…eldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepot,06h.fl32.2MBloopdigger725kbps3.35MB/s1││›[ ]…eld Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flac52.5 MBloopdigger902 kbps3.35 MB/s1││[]…eldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\06-RainonZincRoof.30.4MBloopdigger804kbps3.35MB/s1││[]…less\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\01-EmptyC17.4MBtapehiss836kbps2.1MB/s2││[]…less\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\02-Forest38.0MBtapehiss741kbps2.1MB/s2││[]…less\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\03-Quarry34.6MBtapehiss753kbps2.1MB/s2││[]…less\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\04-NightF18.7MBtapehiss723kbps2.1MB/s2││[]…mplete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-BY)\01-9.4MBnectoUA320kbps1.05MB/s0││[]…mplete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-BY)\02-5.1MBnectoUA320kbps1.05MB/s0││[]…mplete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-BY)\03-19.5MBnectoUA320kbps1.05MB/s0││[]…mplete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-BY)\readm0.1MBnectoUA-1.05MB/s0││↑↯HarbourBells(LiveattheQuay)crateminer35.3MB/41.0MB(86%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer38.2MB/41.0MB(93%)3.7MB/s││││││↑↯HarbourBcrateminer38.6MB/41.0MB(94%)3.7MB/s││││││↑✓HarbourBcrateminerCompleted-││││[]@@gufoo\Field╭Keys───────────────────────────────────────────────────────────────────────────────────────────────────╮3.35MB/s1││[]@@gufoo\Field│PanesSearches│3.35MB/s1││›[ ]@@gufoo\Field│Tab/Shift-Tabnext/previouspaneEntershowitsresults│3.35 MB/s1││[]@@gufoo\Field│123focusapane,hiddenornotdremovethesearch│3.35MB/s1││[]@@gufoo\Field│zzoomthefocusedpaneCcleareverysearch│3.35MB/s1││[]Music\lossless│whidethefocusedpanecchatrooms│2.1MB/s2││[]Music\lossless│Escleavezoom│2.1MB/s2││[]Music\lossless│clickfocusapaneResults│2.1MB/s2││[]Music\lossless│Spaceselect/deselect│2.1MB/s2││[]@@hnttf\comple│Anywherea/Aselectall/none│1.05MB/s0││[]@@hnttf\comple│ssearchthenetworkEnterdownloadtheselection│1.05MB/s0││[]@@hnttf\comple│bbrowseauser'sfiles/filter,Enterkeepsit,Esccle│1.05MB/s0││[]@@hnttf\comple│mcomposeaprivatemessagebbrowsetheowner│1.05MB/s0│││iinboxcchatrooms││││osettings││││?thislistDownloads/Uploads││││qquitppause/resume││╰────────────────────│xcancel│─────────────────────╯╭[1]Searches──────│Anylistrretryafailedone│─────────────────────╮│StatusQuery│↑↓/kjmoveonerowddeleteaqueuedorfinishedone│flac││›Donepublic doma│HomeEnd/gGfirst/lastrowccleareveryfinishedone│dings/CoastalDawn│││PgUpPgDn/^b^fonepage│AC24-96]│││^u^dhalfapageChatandbrowsepopups││││hl/←→scrollalongnamesidewaysPgUpPgDn/^b^fapageofmessages,orofthet│loopdigger│││0/$itsstart/itsend^u^dhalfapageofthem│52.5MB│││HomeEnd/gGoldest/newest,orfirst/las│Completed│││Tab/Shift-Tabnextroom,chatoruser│││╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ulseek││[?/Esc→close]│╰────────────────────────────────────────────────────────────────╭[1]Searches────────────────────╮╭[3]Downloads/Uploads───│││││╭Shortcuts·Sharing:~/Music───────────────────────────────────────────────────────────────────────────│[s→search][Enter→results][d→remove][m→message][i→inbox][c→chat][b→browseuser][h/l→scrollquery╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│StatusQueryResults╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮╭│││││╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│[Type→searchterm][←→→movecursor][Backspace/Del→edit][Enter→search]││search:p││search:pu││search:pub││search:publ││search:publi││search:publicd││search:publicdo││search:publicdom││search:publicdoma││search:publicdomai││search:publicdomainf││search:publicdomainfi││search:publicdomainfie││search:publicdomainfiel││search:publicdomainfieldr││search:publicdomainfieldre││search:publicdomainfieldrec││search:publicdomainfieldreco││search:publicdomainfieldrecor││search:publicdomainfieldrecord││search:publicdomainfieldrecordi││search:publicdomainfieldrecordin││search:publicdomainfieldrecording│╰────────────s────────────────────╮╭[3]Downloads/Uploads──────────────────────────────────────────────╮╭[Info]─────────────────────────────────╮│StatusQueryResults│││││││││││╰──────────────────────────────────╯╰──────────────────────││[Space→select][Enter→download]───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│[Tab/1-3→pane][z][←→→movecursor][Backspace/Del→edit][Enter→search][Esc→cancel]││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2││││││Length││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\041││›Activepublic domain fi11││││@@gufoo/FieldRecordings/CoastalDa││[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-0│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│[p→pause/resume][x→cancel][r→retryfailed][d→deletequeued/done][c[a/A→selectall/none][h/l→scrollname][g/G→top/end]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que2.8/100.5MB·18.61MB/s·[░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]2%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger1.5 MB/52.5 MB (2%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger1.3MB/48.0MB(2%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que8.4/100.5MB·18.61MB/s·[██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]8%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger4.4 MB/52.5 MB (8%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger4.0MB/48.0MB(8%)8.9MB/s││Sessions(2019)[FLAC24-96]││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que14.0/100.5MB·18.61MB/s·[████░░░░░░░░░░░░░░░░░░░░░░░░░░░]13%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger7.3 MB/52.5 MB (13%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger6.7MB/48.0MB(13%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer0.2MB/41.0MB(0%)3.7MB/s││││││↑↯HarbourBcrateminer0.5MB/41.0MB(1%)3.7MB/s││││││↑↯HarbourBcrateminer0.9MB/41.0MB(2%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que19.5/100.5MB·18.61MB/s·[██████░░░░░░░░░░░░░░░░░░░░░░░░░]19%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger10.2 MB/52.5 MB (19%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger9.3MB/48.0MB(19%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer1.3MB/41.0MB(3%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que22.3/100.5MB·18.61MB/s·[██████░░░░░░░░░░░░░░░░░░░░░░░░░]22%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger11.7 MB/52.5 MB (22%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger10.7MB/48.0MB(22%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer1.7MB/41.0MB(4%)3.7MB/s││││││↑↯HarbourBcrateminer2.1MB/41.0MB(5%)3.7MB/s││││││↑↯HarbourBcrateminer2.5MB/41.0MB(6%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que27.9/100.5MB·18.61MB/s·[████████░░░░░░░░░░░░░░░░░░░░░░░]27%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger14.6 MB/52.5 MB (27%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger13.3MB/48.0MB(27%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer2.9MB/41.0MB(7%)3.7MB/s││││││↑↯HarbourBcrateminer3.3MB/41.0MB(7%)3.7MB/s││││││↑↯HarbourBcrateminer3.6MB/41.0MB(8%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que33.5/100.5MB·18.61MB/s·[██████████░░░░░░░░░░░░░░░░░░░░░]33%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger17.5 MB/52.5 MB (33%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger16.0MB/48.0MB(33%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer4.0MB/41.0MB(9%)3.7MB/s││││││↑↯HarbourBcrateminer4.4MB/41.0MB(10%)3.7MB/s││││││↑↯HarbourBcrateminer4.8MB/41.0MB(11%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que39.1/100.5MB·18.61MB/s·[████████████░░░░░░░░░░░░░░░░░░░]38%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger20.4 MB/52.5 MB (38%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger18.7MB/48.0MB(38%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer5.2MB/41.0MB(12%)3.7MB/s││││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]-CoastalD30.5MBloopdigger675kbps3.35MB/s1││ieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepo32.2MBloopdigger725kbps3.35MB/s1││17.4MBtapehiss836kbps2.1MB/s2││oUA320kbps1.05MB/s0││───────────────────────────────────────────────────────╯│5.6MB/41.0MB(13%)3.7MB/s││││[Tab/1-3→pane][z→unzoom]→hide][?→keys][q→quit]││↑↯HarbourBells(LiveattheQuay).flaccrateminer6.4MB/41.0MB(15%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que47.5/100.5MB·18.61MB/s·[██████████████░░░░░░░░░░░░░░░░░]47%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger24.8 MB/52.5 MB (47%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger22.7MB/48.0MB(47%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer6.8MB/41.0MB(16%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer7.1MB/41.0MB(17%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer7.5MB/41.0MB(18%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que53.0/100.5MB·18.61MB/s·[████████████████░░░░░░░░░░░░░░░]52%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger27.7 MB/52.5 MB (52%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger25.3MB/48.0MB(52%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer7.9MB/41.0MB(19%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer8.3MB/41.0MB(20%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer8.7MB/41.0MB(21%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que58.6/100.5MB·18.61MB/s·[██████████████████░░░░░░░░░░░░░]58%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger30.6 MB/52.5 MB (58%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger28.0MB/48.0MB(58%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer9.0MB/41.0MB(22%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer9.4MB/41.0MB(22%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer9.8MB/41.0MB(23%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que64.2/100.5MB·18.61MB/s·[███████████████████░░░░░░░░░░░░]63%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger33.5 MB/52.5 MB (63%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger30.7MB/48.0MB(63%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer10.2MB/41.0MB(24%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer10.6MB/41.0MB(25%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer11.0MB/41.0MB(26%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que69.8/100.5MB·18.61MB/s·[█████████████████████░░░░░░░░░░]69%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger36.5 MB/52.5 MB (69%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger33.3MB/48.0MB(69%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer11.3MB/41.0MB(27%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer11.7MB/41.0MB(28%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer12.1MB/41.0MB(29%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que75.4/100.5MB·18.61MB/s·[███████████████████████░░░░░░░░]75%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger39.4 MB/52.5 MB (75%)9.7 MB/s││↯@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger36.0MB/48.0MB(75%)8.9MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer12.5MB/41.0MB(30%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer12.9MB/41.0MB(31%)3.7MB/s││↑↯HarbourBells(LiveattheQuay).flaccrateminer13.2MB/41.0MB(32%)3.7MB/s││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que81.0/100.5MB·18.61MB/s·[████████████████████████░░░░░░░]80%││›↯@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger42.3 MB/52.5 MB (80%)9.7 MB/s││[]@@gufoo\FieldRecordings\gs\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger42.3 MB/52.5 MB (80%)9.7 MB/s││[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\06-RainonZ││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0238.0MBtapehiss741kbps││[]@@hnttf\compl│╰──────────────────────────────││StatusQueryResults││││││[Tab/1-3→pane][z→zoom][w[w→hide][?→keys][q→quit]││││↑↯HarbourBcrateminer14.2MB/41.0MB(34%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que86.5/100.5MB·18.61MB/s·[██████████████████████████░░░░░]86%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger45.2 MB/52.5 MB (86%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger41.3MB/48.0MB(86%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer14.6MB/41.0MB(35%)3.7MB/s││││││↑↯HarbourBcrateminer15.0MB/41.0MB(36%)3.7MB/s││││││↑↯HarbourBcrateminer15.4MB/41.0MB(37%)3.7MB/s││││soulseek-rs🦀v14.1.2Downloads:2active,0completed,0failed,0que92.1/100.5MB·18.61MB/s·[████████████████████████████░░░]91%││›Donepublic domain fi15││›↯@@gufoo\Floopdigger48.1 MB/52.5 MB (91%)9.7 MB/s││@@gufoo/FieldRecordings/CoastalDawn││││↯@@gufoo\Floopdigger44.0MB/48.0MB(91%)8.9MB/s││Sessions(2019)[FLAC24-96]││││↑↯HarbourBcrateminer15.8MB/41.0MB(38%)3.7MB/s││││││↑↯HarbourBcrateminer16.2MB/41.0MB(39%)3.7MB/s││││││↑↯HarbourBcrateminer16.6MB/41.0MB(40%)3.7MB/s││││││↑↯HarbourBcrateminer16.6MB/41.0MB(40%)3.7MB/s│││╰──────────────────────────────────╯╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[s→search][Enter→results][d→remove][m→message][i→inbox]done][c→clearfinished][b→browseuser][h/l→scrollname]││[z→zoom][w→hide][?→keys][q→quit→keys][q→quit]││││↑↯HarbourBcrateminer17.2MB/41.0MB(41%)3.7MB/s││││││↑↯HarbourBcrateminer17.6MB/41.0MB(42%)3.7MB/s││││││↑↯HarbourBcrateminer18.0MB/41.0MB(43%)3.7MB/s││││││↑↯HarbourBcrateminer18.4MB/41.0MB(44%)3.7MB/s││││││↑↯HarbourBcrateminer18.8MB/41.0MB(45%)3.7MB/s││││││↑↯HarbourBcrateminer19.2MB/41.0MB(46%)3.7MB/s││││││↑↯HarbourBcrateminer19.6MB/41.0MB(47%)3.7MB/s│││╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│StatusFilenameUserProgressSpeed││05d││05-HarbourBells.flac││✓@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-││(2019)[FLAC24-96]ns(2019)[FLAC24-96]││││StatusCompleted││││Uploadspeed9m33s││[Space→select][Enter→download][b→browseowner][c[i→inbox][c→chat][b→browseuser][h/l→scrollquery][Tab/1-3→pane]││[Tab/1-3→pane][z→zoom][w→hide][it]││↑↯HarbourBells(LiveattheQuay)crateminer20.6MB/41.0MB(50%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer21.0MB/41.0MB(51%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer21.4MB/41.0MB(52%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer21.8MB/41.0MB(53%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer22.1MB/41.0MB(53%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer22.5MB/41.0MB(54%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer22.9MB/41.0MB(55%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer23.3MB/41.0MB(56%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer23.7MB/41.0MB(57%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer24.1MB/41.0MB(58%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer24.5MB/41.0MB(59%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer24.9MB/41.0MB(60%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer25.3MB/41.0MB(61%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer25.6MB/41.0MB(62%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer26.0MB/41.0MB(63%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer26.4MB/41.0MB(64%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer26.8MB/41.0MB(65%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer27.2MB/41.0MB(66%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer28.0MB/41.0MB(68%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer28.4MB/41.0MB(69%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer28.8MB/41.0MB(70%)3.7MB/s││││[]…less\PublicDomainFieldRecordings,Vol.2(CreativeCreativeCommonsZero)\0334.6MBtapehiss753kbps2.1MB/s2││↑↯HarbourBells(LiveattheQuay)crateminer29.5MB/41.0MB(72%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer29.9MB/41.0MB(72%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer30.2MB/41.0MB(73%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer30.6MB/41.0MB(74%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer31.0MB/41.0MB(75%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer31.4MB/41.0MB(76%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer31.8MB/41.0MB(77%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer32.2MB/41.0MB(78%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer32.6MB/41.0MB(79%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer33.0MB/41.0MB(80%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer33.4MB/41.0MB(81%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer33.7MB/41.0MB(82%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer34.1MB/41.0MB(83%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer34.5MB/41.0MB(84%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer34.9MB/41.0MB(85%)3.7MB/s││││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommons-Quarry34.6MBtapehiss753kbps2.1MB/s2││↑↯HarbourBells(LiveattheQuay)crateminer35.7MB/41.0MB(87%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer35.8MB/41.0MB(87%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer36.2MB/41.0MB(88%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer36.6MB/41.0MB(89%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer37.0MB/41.0MB(90%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer37.4MB/41.0MB(91%)3.7MB/s││││↑↯HarbourBells(LiveattheQuay)crateminer37.8MB/41.0MB(92%)3.7MB/s││││StatusQueryResults││StatusFilenameUserSpeed││05-HarbourBells.flac││✓@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-││(2019)[FLAC24-96]││││││User││[s→e→select][Enter→download][b→browseowner][c→chat][/→filter][a/A→selectall/none][h/l→scrollname][g/G→top/end]││[s→search][Enter→results][d→remove][m→message][i→inbox][c→chat][b→browseuser][h/l→scrollquery][][g/G→top/end]││││↑↯HarbourBcrateminer38.8MB/41.0MB(94%)3.7MB/s││││││↑↯HarbourBcrateminer39.2MB/41.0MB(95%)3.7MB/s││││││↑↯HarbourBcrateminer39.6MB/41.0MB(96%)3.7MB/s││││││↑↯HarbourBcrateminer40.0MB/41.0MB(97%)3.7MB/s││││││↑↯HarbourBcrateminer40.4MB/41.0MB(98%)3.7MB/s││││││↑↯HarbourBcrateminer40.7MB/41.0MB(99%)3.7MB/s││││[]Music\lossless│clickfocusapaneResults34.6MBtapehiss753kbps2.1MB/s2││[]@@hnttf\comple│bbrowseauser'sfiles/filter,Enter320kbps1.05MB/s0│││qquit─────────────────────────────────────────────────────────────────────────────────╯│Statpublic domain fi15││›✓@@gufoo\FloopdiggerCompleted-││@@gufoo/FieldRecordings/CoastalDawn│││PgUpPgDn/^b^fonepage-│││││││││Saveto│││╰─────────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────╯│[]@@gufoo\FieldRecordingszoomthefocusedpaneCcleareverysearch│3.35MB/s1││[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0418.7MB/deselect│2.1MB/s2│╭[1]Searches────────────────────╮╭[3]Downloads/Uploads───────────────────cancel│─────────────────────╯│›Donepublic domain fi15││›moveonerowddeleteaqueuedorfinishedone│flac││││↑✓HarbourB│AC24-96]││HomeEnd/gGoldest/newest,orfirst/las│Completed│╰──────────────────────────────────╯╰───────────────────────────────││││[s→search][Enter→results][d→remove][m→message][i→inbox]─────────────────────────────────────────────────────────────────────────────╮│[z→zoom][w→hide][?→keys][q→quit│
\ No newline at end of file
diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs
index c0b6061..0030668 100644
--- a/soulseek-rs/src/models/app_state.rs
+++ b/soulseek-rs/src/models/app_state.rs
@@ -108,6 +108,75 @@ impl PaneLayout {
}
}
+/// Where a scrolling log is being read.
+///
+/// Either following its newest line, or held at a row of its history so
+/// that arriving messages do not push the reader along. The renderer records
+/// the log's size at each draw, which is what lets the keys move by a
+/// screenful and stop at the ends.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct LogView {
+ /// The first visible row while held in the history; `None` follows the
+ /// tail.
+ top: Option,
+ /// Rows the log had, and rows the window showed, at the last draw.
+ rows: usize,
+ height: usize,
+}
+
+impl LogView {
+ /// Whether the newest line is on screen, so new messages are being seen.
+ #[must_use]
+ pub const fn following(&self) -> bool {
+ self.top.is_none()
+ }
+
+ /// Rows a page key moves: what the window showed last time.
+ #[must_use]
+ pub const fn page(&self) -> usize {
+ self.height
+ }
+
+ const fn last_top(&self) -> usize {
+ self.rows.saturating_sub(self.height)
+ }
+
+ /// The rows of a log `len` long to draw in a window `height` tall. A
+ /// held position that has reached the tail goes back to following it.
+ pub fn window(
+ &mut self,
+ len: usize,
+ height: usize,
+ ) -> std::ops::Range {
+ self.rows = len;
+ self.height = height.max(1);
+ let top = match self.top {
+ Some(top) if top < self.last_top() => top,
+ _ => {
+ self.top = None;
+ self.last_top()
+ }
+ };
+ top..(top + self.height).min(len)
+ }
+
+ /// Move the window `rows` down the log (up, when negative), holding
+ /// wherever it lands unless that is the tail.
+ pub fn scroll(&mut self, rows: isize) {
+ let current = self.top.unwrap_or_else(|| self.last_top());
+ let next = current.saturating_add_signed(rows);
+ self.top = (next < self.last_top()).then_some(next);
+ }
+
+ pub const fn to_oldest(&mut self) {
+ self.top = Some(0);
+ }
+
+ pub const fn to_newest(&mut self) {
+ self.top = None;
+ }
+}
+
/// What the shared command bar is currently capturing input for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandBarMode {
@@ -139,6 +208,8 @@ pub struct AppState {
pub searches: Vec,
pub searches_table_state: TableState,
pub selected_search_index: Option,
+ /// Cells the query column is scrolled to the left.
+ pub searches_query_offset: usize,
// Results
pub results_items: Vec,
@@ -148,11 +219,14 @@ pub struct AppState {
pub results_selected_indices: std::collections::HashSet,
pub results_filter_query: String,
pub results_is_filtering: bool,
+ /// Cells the file-name column is scrolled to the left.
pub results_name_offset: usize,
// Downloads
pub downloads: Vec,
pub downloads_table_state: TableState,
+ /// Cells the transfers' file-name column is scrolled to the left.
+ pub downloads_name_offset: usize,
pub downloads_receiver_channel:
Option)>>,
pub downloads_sender_channel:
@@ -183,6 +257,8 @@ pub struct AppState {
/// Compose buffer for the active conversation.
pub chat_input: String,
pub chat_composing: bool,
+ /// Where the conversation is being read.
+ pub chat_view: LogView,
// Browse users' shared files (one tab per user)
pub browse: BrowseTabs,
@@ -204,6 +280,9 @@ pub struct AppState {
pub searches_pane_area: Option,
pub results_pane_area: Option,
pub downloads_pane_area: Option,
+ /// Where the open popup was last drawn, for its page size. Only one is
+ /// ever open, so one record serves them all.
+ pub popup_area: Option,
}
impl AppState {
@@ -222,6 +301,7 @@ impl AppState {
searches: Vec::new(),
searches_table_state,
selected_search_index: None,
+ searches_query_offset: 0,
results_items: Vec::new(),
results_filtered_items: Vec::new(),
@@ -234,6 +314,7 @@ impl AppState {
downloads: Vec::new(),
downloads_table_state,
+ downloads_name_offset: 0,
downloads_receiver_channel: None,
downloads_sender_channel: None,
active_downloads_count: 0,
@@ -254,6 +335,7 @@ impl AppState {
chat_peer: None,
chat_input: String::new(),
chat_composing: false,
+ chat_view: LogView::default(),
browse: BrowseTabs::new(),
show_browse: false,
@@ -270,6 +352,16 @@ impl AppState {
searches_pane_area: None,
results_pane_area: None,
downloads_pane_area: None,
+ popup_area: None,
+ }
+ }
+
+ /// How far the focused list's long column is scrolled sideways.
+ pub const fn name_offset_mut(&mut self, pane: FocusedPane) -> &mut usize {
+ match pane {
+ FocusedPane::Searches => &mut self.searches_query_offset,
+ FocusedPane::Results => &mut self.results_name_offset,
+ FocusedPane::Downloads => &mut self.downloads_name_offset,
}
}
@@ -302,6 +394,7 @@ impl AppState {
pub fn open_chat(&mut self, peer: String) {
self.chat_peer = Some(peer);
self.chat_input.clear();
+ self.chat_view.to_newest();
self.chat_composing = true;
self.show_messages = true;
self.unread_messages = 0;
@@ -351,6 +444,10 @@ impl AppState {
(current + peers.len() - 1) % peers.len()
};
let peer = peers[next].to_string();
+ // A different conversation opens at its end; the same one stays put.
+ if next != current {
+ self.chat_view.to_newest();
+ }
self.chat_peer = Some(peer);
}
}
@@ -486,6 +583,55 @@ mod tests {
assert!(!state.layout.is_visible(FocusedPane::Searches));
}
+ #[test]
+ fn a_log_view_follows_the_tail_until_held_and_holds_against_new_rows() {
+ let mut view = LogView::default();
+ assert_eq!(view.window(100, 10), 90..100);
+ assert!(view.following());
+
+ view.scroll(-10);
+ assert_eq!(view.window(100, 10), 80..90);
+ assert!(!view.following());
+
+ // Five more rows arrive: the held window does not move.
+ assert_eq!(view.window(105, 10), 80..90);
+
+ // Back down to the tail resumes following, including new rows.
+ view.scroll(10);
+ view.scroll(10);
+ assert_eq!(view.window(105, 10), 95..105);
+ assert!(view.following());
+
+ view.to_oldest();
+ assert_eq!(view.window(105, 10), 0..10);
+ view.scroll(-10);
+ assert_eq!(view.window(105, 10), 0..10, "clamped at the top");
+ view.to_newest();
+ assert_eq!(view.window(105, 10), 95..105);
+ }
+
+ #[test]
+ fn a_log_shorter_than_its_window_is_never_held() {
+ let mut view = LogView::default();
+ assert_eq!(view.window(3, 10), 0..3);
+ view.to_oldest();
+ assert_eq!(view.window(3, 10), 0..3);
+ assert!(view.following(), "nothing to hold");
+ }
+
+ #[test]
+ fn cycling_to_the_same_chat_keeps_the_place() {
+ let mut state = AppState::new();
+ say(&mut state, "alice");
+ state.chat_view.window(50, 10);
+ state.chat_view.scroll(-10);
+ state.cycle_chat_peer(true);
+ assert!(!state.chat_view.following(), "only one chat: nothing moved");
+ say(&mut state, "bob");
+ state.cycle_chat_peer(true);
+ assert!(state.chat_view.following(), "a new chat opens at its end");
+ }
+
#[test]
fn opening_a_chat_pins_a_peer_with_no_messages_yet() {
let mut state = AppState::new();
diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs
index 554639d..bbe1a7b 100644
--- a/soulseek-rs/src/models/mod.rs
+++ b/soulseek-rs/src/models/mod.rs
@@ -5,7 +5,7 @@ mod rooms;
mod settings;
pub use app_state::{
- AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
+ AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView,
MessageDirection, PaneLayout, SearchEntry, SearchStatus,
};
pub use browse::{
diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs
index f3cee71..b4f3db1 100644
--- a/soulseek-rs/src/models/rooms.rs
+++ b/soulseek-rs/src/models/rooms.rs
@@ -1,3 +1,4 @@
+use crate::models::LogView;
use chrono::{DateTime, Local};
use soulseek_rs::types::{RoomEvent, RoomInfo};
@@ -38,6 +39,8 @@ pub struct OpenRoom {
pub unread: usize,
/// Per-room compose buffer.
pub input: String,
+ /// Where the log is being read.
+ pub view: LogView,
}
impl OpenRoom {
@@ -160,6 +163,11 @@ impl RoomsState {
.and_then(|r| r.users.get(self.user_selected).cloned())
}
+ /// Where the active room's log is being read, to move it.
+ pub fn active_view_mut(&mut self) -> Option<&mut LogView> {
+ self.open.get_mut(self.active).map(|room| &mut room.view)
+ }
+
pub const fn select_user_up(&mut self) {
self.user_selected = self.user_selected.saturating_sub(1);
}
diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs
index c3cbb23..525a063 100644
--- a/soulseek-rs/src/ui/main_tui/browse.rs
+++ b/soulseek-rs/src/ui/main_tui/browse.rs
@@ -1,6 +1,7 @@
use super::MainTui;
+use super::input::{jumped, list_jump};
use crate::models::{BrowseStatus, files_under, find_node};
-use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::{thread, time::Duration};
/// How long to wait for a browse response before showing a timeout notice.
@@ -12,6 +13,11 @@ impl MainTui {
self.state.show_browse = false;
return;
}
+ let jump = list_jump(key, self.popup_page());
+ // Control combinations are only ever jumps: ctrl-d pages, d downloads.
+ if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
+ return;
+ }
// Tab management: switch between browsed users or close the active tab.
match key.code {
@@ -60,6 +66,14 @@ impl MainTui {
(rows, sel, row)
};
+ if let Some(jump) = jump {
+ if let Some(browse) = self.state.browse.active_tab_mut() {
+ browse.selected_row = jumped(sel, rows.len(), jump);
+ }
+ self.sync_browse_selection();
+ return;
+ }
+
// Downloads need `&self.client` free of the browse borrow.
match key.code {
KeyCode::Enter if !row.is_folder => {
diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs
index 4d7871d..2baa211 100644
--- a/soulseek-rs/src/ui/main_tui/input.rs
+++ b/soulseek-rs/src/ui/main_tui/input.rs
@@ -1,7 +1,10 @@
use super::MainTui;
-use crate::models::{CommandBarMode, FocusedPane};
+use crate::models::{CommandBarMode, FocusedPane, LogView};
use crate::ui::pane_block;
-use crate::ui::panes::name_end_offset;
+use crate::ui::panes::{
+ InfoSubject, name_end_offset, query_end_offset, selected_transfer,
+ transfer_name_end_offset, upload_display_name,
+};
use ratatui::crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
@@ -195,6 +198,14 @@ impl MainTui {
return;
}
+ // Paging keys move through the conversation's history.
+ if scroll_log(&mut self.state.chat_view, key) {
+ return;
+ }
+ if key.modifiers.contains(KeyModifiers::CONTROL) {
+ return;
+ }
+
match key.code {
KeyCode::Char('i' | 'q') | KeyCode::Esc => {
self.state.show_messages = false;
@@ -375,6 +386,15 @@ 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) -> isize {
+ let rows = self.state.popup_area.map_or(1, |area| {
+ pane_block(false).inner(area).height.saturating_sub(2)
+ });
+ isize::try_from(rows.max(1)).unwrap_or(isize::MAX)
+ }
+
/// Rows a pane shows at once: its inner height less the table header.
fn page_size(&self, pane: FocusedPane) -> isize {
let area = match pane {
@@ -389,7 +409,7 @@ impl MainTui {
}
fn handle_searches_input(&mut self, key: KeyEvent) {
- if self.navigate_focused_list(key) {
+ if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
return;
}
match key.code {
@@ -426,20 +446,10 @@ impl MainTui {
}
fn handle_results_input(&mut self, key: KeyEvent) {
- if self.navigate_focused_list(key) {
+ if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
return;
}
match key.code {
- KeyCode::Right | KeyCode::Char('l') => {
- self.scroll_names(NAME_SCROLL_STEP);
- }
- KeyCode::Left | KeyCode::Char('h') => {
- self.scroll_names(-NAME_SCROLL_STEP);
- }
- KeyCode::Char('0') => self.state.results_name_offset = 0,
- KeyCode::Char('$') => {
- self.state.results_name_offset = self.highlighted_name_end();
- }
KeyCode::Char(' ') => {
let index = self
.state
@@ -475,24 +485,82 @@ impl MainTui {
}
}
- fn scroll_names(&mut self, delta: isize) {
- let end = self.highlighted_name_end();
- let current = self.state.results_name_offset.min(end);
- self.state.results_name_offset =
- current.saturating_add_signed(delta).min(end);
+ /// The keys that scroll the focused list's long column sideways, the
+ /// same in every list: a file name in Results and Downloads, the query
+ /// in Searches. Says whether `key` was one of them.
+ fn scroll_focused_name(&mut self, key: KeyEvent) -> bool {
+ match key.code {
+ KeyCode::Right | KeyCode::Char('l') => {
+ self.scroll_names(NAME_SCROLL_STEP);
+ }
+ KeyCode::Left | KeyCode::Char('h') => {
+ self.scroll_names(-NAME_SCROLL_STEP);
+ }
+ KeyCode::Char('0') => {
+ *self.state.name_offset_mut(self.state.focused_pane) = 0;
+ }
+ KeyCode::Char('$') => {
+ let end = self.highlighted_name_end();
+ *self.state.name_offset_mut(self.state.focused_pane) = end;
+ }
+ _ => return false,
+ }
+ true
}
+ fn scroll_names(&mut self, delta: isize) {
+ let end = self.highlighted_name_end();
+ let offset = self.state.name_offset_mut(self.state.focused_pane);
+ let current = (*offset).min(end);
+ *offset = current.saturating_add_signed(delta).min(end);
+ }
+
+ /// How far the highlighted row's long column can scroll before its end
+ /// is in view. Zero when nothing is highlighted or the pane is not drawn.
fn highlighted_name_end(&self) -> usize {
- let Some(area) = self.state.results_pane_area else {
- return 0;
- };
- self.highlighted_result()
- .map_or(0, |file| name_end_offset(&file.filename, area))
+ match self.state.focused_pane {
+ FocusedPane::Searches => {
+ let Some(area) = self.state.searches_pane_area else {
+ return 0;
+ };
+ self.state
+ .searches_table_state
+ .selected()
+ .and_then(|row| self.state.searches.get(row))
+ .map_or(0, |search| query_end_offset(&search.query, area))
+ }
+ FocusedPane::Results => {
+ let Some(area) = self.state.results_pane_area else {
+ return 0;
+ };
+ self.highlighted_result()
+ .map_or(0, |file| name_end_offset(&file.filename, area))
+ }
+ FocusedPane::Downloads => {
+ let Some(area) = self.state.downloads_pane_area else {
+ return 0;
+ };
+ let shown = match selected_transfer(
+ self.state.downloads_table_state.selected(),
+ &self.state.downloads,
+ &self.state.uploads,
+ ) {
+ Some(InfoSubject::Download(entry)) => {
+ entry.download.filename.as_str()
+ }
+ Some(InfoSubject::Upload(upload)) => {
+ upload_display_name(upload)
+ }
+ Some(InfoSubject::Result(_)) | None => return 0,
+ };
+ transfer_name_end_offset(shown, area)
+ }
+ }
}
fn handle_downloads_input(&mut self, key: KeyEvent) {
// The pane lists downloads first, then uploads; navigation spans both.
- if self.navigate_focused_list(key) {
+ if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
return;
}
match key.code {
@@ -539,9 +607,58 @@ impl MainTui {
}
}
-/// The keys every list answers alike: a row at a time wrapping at either
-/// end, a page or half a page at a time stopping there, and the two ends.
+/// A move over a whole list at once: to either end, or by a number of rows
+/// (negative is up).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum ListJump {
+ First,
+ Last,
+ Rows(isize),
+}
+
+/// The keys every list and log answers alike for moving further than a row:
+/// the two ends, a page, half a page. `page` is how many rows the list shows.
+pub(super) fn list_jump(key: KeyEvent, page: isize) -> Option {
+ let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
+ let half = (page / 2).max(1);
+ Some(match key.code {
+ KeyCode::Home | KeyCode::Char('g') if !ctrl => ListJump::First,
+ KeyCode::End | KeyCode::Char('G') if !ctrl => ListJump::Last,
+ KeyCode::PageDown => ListJump::Rows(page),
+ KeyCode::PageUp => ListJump::Rows(-page),
+ KeyCode::Char('f') if ctrl => ListJump::Rows(page),
+ KeyCode::Char('b') if ctrl => ListJump::Rows(-page),
+ KeyCode::Char('d') if ctrl => ListJump::Rows(half),
+ KeyCode::Char('u') if ctrl => ListJump::Rows(-half),
+ _ => return None,
+ })
+}
+
+/// Apply the jump keys to a log, moving by the screenful it last showed.
/// Says whether `key` was one of them.
+pub(super) fn scroll_log(view: &mut LogView, key: KeyEvent) -> bool {
+ let page = isize::try_from(view.page()).unwrap_or(isize::MAX);
+ match list_jump(key, page) {
+ Some(ListJump::First) => view.to_oldest(),
+ Some(ListJump::Last) => view.to_newest(),
+ Some(ListJump::Rows(rows)) => view.scroll(rows),
+ None => return false,
+ }
+ true
+}
+
+/// `selected` moved by `jump` over a list `len` long, stopping at the ends.
+pub(super) fn jumped(selected: usize, len: usize, jump: ListJump) -> usize {
+ let last = len.saturating_sub(1);
+ match jump {
+ ListJump::First => 0,
+ ListJump::Last => last,
+ ListJump::Rows(rows) => selected.saturating_add_signed(rows).min(last),
+ }
+}
+
+/// The keys every list answers alike: a row at a time wrapping at either
+/// end, and the jumps of [`list_jump`]. Says whether `key` was one of them.
fn navigate_list(
key: KeyEvent,
table: &mut TableState,
@@ -549,23 +666,16 @@ fn navigate_list(
page: isize,
) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
- let half = (page / 2).max(1);
match key.code {
KeyCode::Up | KeyCode::Char('k') if !ctrl => cycle(table, len, false),
KeyCode::Down | KeyCode::Char('j') if !ctrl => cycle(table, len, true),
- KeyCode::Home | KeyCode::Char('g') if !ctrl => {
- select_row(table, len, 0);
- }
- KeyCode::End | KeyCode::Char('G') if !ctrl => {
- select_row(table, len, len.saturating_sub(1));
- }
- KeyCode::PageDown => scroll_rows(table, len, page),
- KeyCode::PageUp => scroll_rows(table, len, -page),
- KeyCode::Char('f') if ctrl => scroll_rows(table, len, page),
- KeyCode::Char('b') if ctrl => scroll_rows(table, len, -page),
- KeyCode::Char('d') if ctrl => scroll_rows(table, len, half),
- KeyCode::Char('u') if ctrl => scroll_rows(table, len, -half),
- _ => return false,
+ _ => match list_jump(key, page) {
+ Some(jump) => {
+ let current = table.selected().unwrap_or(0);
+ select_row(table, len, jumped(current, len, jump));
+ }
+ None => return false,
+ },
}
true
}
@@ -590,9 +700,3 @@ fn select_row(table: &mut TableState, len: usize, target: usize) {
table.select(Some(target.min(len - 1)));
}
}
-
-/// Move the selection `delta` rows, stopping at either end.
-fn scroll_rows(table: &mut TableState, len: usize, delta: isize) {
- let current = table.selected().unwrap_or(0);
- select_row(table, len, current.saturating_add_signed(delta));
-}
diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs
index 65de6ad..bfffd32 100644
--- a/soulseek-rs/src/ui/main_tui/mod.rs
+++ b/soulseek-rs/src/ui/main_tui/mod.rs
@@ -1548,12 +1548,12 @@ mod tests {
let panes = row_with(&screen, "Panes");
assert!(!panes.contains("Searches"), "one column: {panes}");
assert!(screen.contains("focus a pane, hidden or not"), "{screen}");
- assert!(!screen.contains("clear every finished one"), "{screen}");
+ assert!(!screen.contains("next room, chat or user"), "{screen}");
press(&mut tui, KeyCode::End);
let screen = screen_sized(&mut tui, 80, 24);
- assert!(screen.contains("clear every finished one"), "{screen}");
- assert!(!screen.contains("Tab / Shift-Tab"), "{screen}");
+ assert!(screen.contains("next room, chat or user"), "{screen}");
+ assert!(!screen.contains("next / previous pane"), "{screen}");
press(&mut tui, KeyCode::Home);
let screen = screen_sized(&mut tui, 80, 24);
@@ -1587,6 +1587,208 @@ mod tests {
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
}
+ #[test]
+ fn l_and_h_scroll_a_transfer_name_and_a_query_too() {
+ let mut tui = furnished_tui();
+ tui.state.downloads[0].download.filename = "y".repeat(120);
+ tui.state.searches[0].query = "z".repeat(60);
+ let _ = screen_of(&mut tui);
+
+ tui.state.focused_pane = FocusedPane::Downloads;
+ press(&mut tui, KeyCode::Char('l'));
+ assert_eq!(tui.state.downloads_name_offset, 8);
+ press(&mut tui, KeyCode::Char('$'));
+ let end = tui.state.downloads_name_offset;
+ assert!(end > 8, "the end is past a step: {end}");
+ let screen = screen_of(&mut tui);
+ assert!(row_with(&screen, "…yyyy").contains("…yyyy"), "{screen}");
+ press(&mut tui, KeyCode::Char('0'));
+ assert_eq!(tui.state.downloads_name_offset, 0);
+
+ tui.state.focused_pane = FocusedPane::Searches;
+ press(&mut tui, KeyCode::Right);
+ assert_eq!(tui.state.searches_query_offset, 8);
+ press(&mut tui, KeyCode::End);
+ assert_eq!(
+ tui.state.searches_table_state.selected(),
+ Some(0),
+ "End is still a row key, $ is the name key"
+ );
+ press(&mut tui, KeyCode::Char('$'));
+ assert!(tui.state.searches_query_offset > 8);
+ let screen = screen_of(&mut tui);
+ assert!(screen.contains("…zzzz"), "{screen}");
+ assert!(
+ tui.state.results_name_offset == 0,
+ "each list scrolls on its own"
+ );
+ }
+
+ fn browsed(tui: &mut MainTui, files: usize) {
+ tui.state.browse.open("bob");
+ let listing = vec![soulseek_rs::SharedDirectory {
+ name: "Music".to_string(),
+ files: (0..files).map(|i| (format!("{i:02}.mp3"), 1)).collect(),
+ }];
+ tui.state
+ .browse
+ .active_tab_mut()
+ .expect("tab")
+ .load(&listing);
+ tui.state.show_browse = true;
+ let _ = screen_of(tui);
+ }
+
+ #[test]
+ fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() {
+ let session = Arc::new(TalkativeSession::default());
+ let mut tui = attach(session);
+ browsed(&mut tui, 40);
+ let rows = tui.state.browse.active_tab().expect("tab").rows().len();
+ assert_eq!(rows, 41, "the folder and its files");
+
+ press(&mut tui, KeyCode::End);
+ let selected = |tui: &MainTui| {
+ tui.state.browse.active_tab().expect("tab").selected_row
+ };
+ assert_eq!(selected(&tui), 40);
+ press(&mut tui, KeyCode::Home);
+ assert_eq!(selected(&tui), 0);
+ press(&mut tui, KeyCode::PageDown);
+ let page = selected(&tui);
+ assert!(page > 1 && page < 40, "a page of rows: {page}");
+ ctrl(&mut tui, 'd');
+ assert!(selected(&tui) > page, "ctrl-d pages");
+ assert!(
+ tui.state.downloads.is_empty()
+ && tui.state.downloads_receiver_channel.is_none(),
+ "and does not download the folder"
+ );
+ press(&mut tui, KeyCode::Char('G'));
+ assert_eq!(selected(&tui), 40);
+ assert!(tui.state.show_browse, "still open");
+ }
+
+ fn in_a_room(tui: &mut MainTui, messages: usize) {
+ tui.state.rooms.apply_event(
+ soulseek_rs::RoomEvent::Joined {
+ room: "jazz".to_string(),
+ users: (0..30).map(|i| format!("user{i:02}")).collect(),
+ },
+ None,
+ );
+ tui.state.rooms.focus_or_open("jazz");
+ for i in 0..messages {
+ tui.state.rooms.apply_event(
+ soulseek_rs::RoomEvent::Message {
+ room: "jazz".to_string(),
+ username: "alice".to_string(),
+ message: format!("line {i:03}"),
+ },
+ Some("jazz"),
+ );
+ }
+ tui.state.show_rooms = true;
+ let _ = screen_of(tui);
+ }
+
+ #[test]
+ fn page_up_scrolls_a_room_log_back_and_end_returns_to_the_newest() {
+ let mut tui = with_session(TalkativeSession::default());
+ in_a_room(&mut tui, 120);
+
+ let screen = screen_of(&mut tui);
+ assert!(screen.contains("line 119"), "tails by default: {screen}");
+ assert!(!screen.contains("line 000"), "{screen}");
+
+ press(&mut tui, KeyCode::PageUp);
+ let screen = screen_of(&mut tui);
+ assert!(!screen.contains("line 119"), "scrolled back: {screen}");
+
+ press(&mut tui, KeyCode::Home);
+ let screen = screen_of(&mut tui);
+ assert!(screen.contains("line 000"), "the oldest: {screen}");
+ press(&mut tui, KeyCode::PageUp);
+ let screen = screen_of(&mut tui);
+ assert!(screen.contains("line 000"), "clamped at the top: {screen}");
+
+ press(&mut tui, KeyCode::End);
+ let screen = screen_of(&mut tui);
+ assert!(screen.contains("line 119"), "{screen}");
+
+ // The member list keeps its own keys, and ctrl-x is not x.
+ press(&mut tui, KeyCode::Down);
+ assert_eq!(tui.state.rooms.user_selected, 1);
+ ctrl(&mut tui, 'x');
+ assert_eq!(tui.state.rooms.open.len(), 1, "still in the room");
+ }
+
+ #[test]
+ fn page_keys_move_through_the_room_list() {
+ let mut tui = with_session(TalkativeSession::default());
+ tui.state.rooms.apply_event(
+ soulseek_rs::RoomEvent::List(
+ (0..60)
+ .map(|i| soulseek_rs::types::RoomInfo {
+ name: format!("room{i:02}"),
+ user_count: 60 - i,
+ })
+ .collect(),
+ ),
+ None,
+ );
+ tui.state.show_rooms = true;
+ let _ = screen_of(&mut tui);
+
+ press(&mut tui, KeyCode::End);
+ assert_eq!(tui.state.rooms.list_selected, 59);
+ press(&mut tui, KeyCode::PageUp);
+ assert!(tui.state.rooms.list_selected < 59);
+ press(&mut tui, KeyCode::Char('g'));
+ assert_eq!(tui.state.rooms.list_selected, 0);
+ ctrl(&mut tui, 'f');
+ assert!(tui.state.rooms.list_selected > 1);
+ }
+
+ #[test]
+ fn page_up_scrolls_a_conversation_and_switching_chats_resets_it() {
+ let mut tui = tui((0..80)
+ .map(|i| ChatMessageDto {
+ peer: "bob".into(),
+ outgoing: i % 2 == 0,
+ text: format!("msg {i:03}"),
+ at: i,
+ })
+ .chain(std::iter::once(ChatMessageDto {
+ peer: "carol".into(),
+ outgoing: false,
+ text: "hello".into(),
+ at: 100,
+ }))
+ .collect());
+ tui.state.chat_peer = Some("bob".to_string());
+ tui.state.show_messages = true;
+ let screen = screen_of(&mut tui);
+ assert!(screen.contains("msg 079"), "{screen}");
+
+ press(&mut tui, KeyCode::PageUp);
+ let screen = screen_of(&mut tui);
+ assert!(!screen.contains("msg 079"), "scrolled back: {screen}");
+ press(&mut tui, KeyCode::Char('g'));
+ let screen = screen_of(&mut tui);
+ assert!(screen.contains("msg 000"), "{screen}");
+
+ press(&mut tui, KeyCode::Tab);
+ assert_eq!(tui.state.active_chat_peer(), Some("carol"));
+ assert!(
+ tui.state.chat_view.following(),
+ "a fresh chat starts at its end"
+ );
+ press(&mut tui, KeyCode::BackTab);
+ let screen = screen_of(&mut tui);
+ assert!(screen.contains("msg 079"), "{screen}");
+ }
+
#[test]
fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() {
let mut tui = furnished_tui();
diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs
index 06f7b9c..c7ca5b2 100644
--- a/soulseek-rs/src/ui/main_tui/render.rs
+++ b/soulseek-rs/src/ui/main_tui/render.rs
@@ -50,6 +50,8 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[
("Home End / g G", "first / last row"),
("PgUp PgDn / ^b ^f", "one page"),
("^u ^d", "half a page"),
+ ("h l / ← →", "scroll a long name sideways"),
+ ("0 / $", "its start / its end"),
],
),
];
@@ -71,8 +73,6 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
("a / A", "select all / none"),
("Enter", "download the selection"),
("/", "filter, Enter keeps it, Esc clears"),
- ("h l / ← →", "scroll the file name"),
- ("0 / $", "start / end of the file name"),
("b", "browse the owner"),
("c", "chat rooms"),
],
@@ -87,6 +87,15 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
("c", "clear every finished one"),
],
),
+ (
+ "Chat and browse popups",
+ &[
+ ("PgUp PgDn / ^b ^f", "a page of messages, or of the tree"),
+ ("^u ^d", "half a page of them"),
+ ("Home End / g G", "oldest / newest, or first / last row"),
+ ("Tab / Shift-Tab", "next room, chat or user"),
+ ],
+ ),
];
impl MainTui {
@@ -192,6 +201,7 @@ impl MainTui {
&self.state.searches,
&mut self.state.searches_table_state,
focused,
+ self.state.searches_query_offset,
);
}
FocusedPane::Results => {
@@ -207,6 +217,7 @@ impl MainTui {
&self.state.uploads,
&mut self.state.downloads_table_state,
focused,
+ self.state.downloads_name_offset,
);
}
}
@@ -329,6 +340,7 @@ impl MainTui {
// Browse tree overlays everything when open.
if self.state.show_browse && !self.state.browse.is_empty() {
let area = centered_rect(80, 80, frame.area());
+ self.state.popup_area = Some(area);
frame.render_widget(ratatui::widgets::Clear, area);
render_browse_pane(
frame,
@@ -347,11 +359,12 @@ impl MainTui {
// Chat rooms overlay everything when open.
if self.state.show_rooms {
let area = centered_rect(85, 80, frame.area());
+ self.state.popup_area = Some(area);
frame.render_widget(ratatui::widgets::Clear, area);
render_rooms_pane(
frame,
area,
- &self.state.rooms,
+ &mut self.state.rooms,
&mut self.state.rooms_list_table_state,
);
}
@@ -430,12 +443,14 @@ impl MainTui {
);
}
- fn render_messages_popup(&self, frame: &mut Frame) {
+ fn render_messages_popup(&mut self, frame: &mut Frame) {
// The per-conversation chat box supersedes the old flat message list;
// it renders the messages and the compose line itself.
let area = centered_rect(70, 60, frame.area());
frame.render_widget(ratatui::widgets::Clear, area);
- render_chat_pane(frame, area, &self.state, &self.client.username());
+ self.state.popup_area = Some(area);
+ let own = self.client.username();
+ render_chat_pane(frame, area, &mut self.state, &own);
}
/// Context shortcuts for the chat-rooms popup.
@@ -467,6 +482,7 @@ impl MainTui {
}
RoomsView::Chat => vec![
("Enter", "say"),
+ ("PgUp/PgDn", "scroll"),
("↑↓", "pick user"),
("b", "browse user"),
("m", "message user"),
@@ -520,6 +536,7 @@ impl MainTui {
} else {
vec![
("Enter", "type"),
+ ("PgUp/PgDn", "scroll"),
("↑↓/Tab", "switch chat"),
("m", "new chat"),
("i/Esc", "close"),
@@ -530,6 +547,7 @@ impl MainTui {
} else if self.state.show_browse {
vec![
("↑↓", "move"),
+ ("PgUp/PgDn", "page"),
("→←", "expand/collapse"),
("Enter", "open/download"),
("d", "download folder"),
@@ -579,6 +597,7 @@ impl MainTui {
("i", "inbox"),
("c", "chat"),
("b", "browse user"),
+ ("h/l", "scroll query"),
],
FocusedPane::Results => vec![
("Space", "select"),
@@ -597,6 +616,7 @@ impl MainTui {
("d", "delete queued/done"),
("c", "clear finished"),
("b", "browse user"),
+ ("h/l", "scroll name"),
],
};
keys.extend(self.pane_shortcuts());
diff --git a/soulseek-rs/src/ui/main_tui/rooms.rs b/soulseek-rs/src/ui/main_tui/rooms.rs
index bd048aa..86c033a 100644
--- a/soulseek-rs/src/ui/main_tui/rooms.rs
+++ b/soulseek-rs/src/ui/main_tui/rooms.rs
@@ -1,4 +1,5 @@
use super::MainTui;
+use super::input::{jumped, list_jump, scroll_log};
use crate::models::RoomsView;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
@@ -38,6 +39,16 @@ impl MainTui {
fn handle_rooms_list_input(&mut self, key: KeyEvent) {
let len = self.state.rooms.filtered_rooms().len();
+ if let Some(jump) = list_jump(key, self.popup_page()) {
+ self.state.rooms.list_selected =
+ jumped(self.state.rooms.list_selected, len, jump);
+ return;
+ }
+ // Control combinations are only ever jumps; the letters below are
+ // not theirs to trigger.
+ if key.modifiers.contains(KeyModifiers::CONTROL) {
+ return;
+ }
match key.code {
// Esc peels back one level: clear a lingering filter first (as the
// title's "Esc: clear" promises), otherwise close the popup.
@@ -92,6 +103,16 @@ impl MainTui {
}
fn handle_rooms_chat_input(&mut self, key: KeyEvent) {
+ // Paging keys move through the room's history; the member list is a
+ // row at a time.
+ if let Some(view) = self.state.rooms.active_view_mut()
+ && scroll_log(view, key)
+ {
+ return;
+ }
+ if key.modifiers.contains(KeyModifiers::CONTROL) {
+ return;
+ }
match key.code {
KeyCode::Char('q') => self.state.show_rooms = false,
KeyCode::Esc | KeyCode::Char('l') => {
@@ -208,15 +229,23 @@ impl MainTui {
self.state.rooms.composing = false;
}
+ /// The room whose newest messages are on screen, if any: the active one
+ /// in the chat view, unless the reader is held back in its history, when
+ /// what arrives still counts as unread.
+ pub(super) fn viewed_room(&self) -> Option {
+ if !self.state.show_rooms || self.state.rooms.view != RoomsView::Chat {
+ return None;
+ }
+ self.state
+ .rooms
+ .active_room()
+ .filter(|room| room.view.following())
+ .map(|room| room.name.clone())
+ }
+
/// Drain chat-room events into the rooms state, tracking unread badges.
pub(super) fn poll_room_events(&mut self) {
- let viewing = if self.state.show_rooms
- && self.state.rooms.view == RoomsView::Chat
- {
- self.state.rooms.active_room().map(|r| r.name.clone())
- } else {
- None
- };
+ let viewing = self.viewed_room();
for event in self.client.take_room_events() {
self.state.rooms.apply_event(event, viewing.as_deref());
}
diff --git a/soulseek-rs/src/ui/panes/chat_pane.rs b/soulseek-rs/src/ui/panes/chat_pane.rs
index 80bfc23..5a2ff83 100644
--- a/soulseek-rs/src/ui/panes/chat_pane.rs
+++ b/soulseek-rs/src/ui/panes/chat_pane.rs
@@ -1,4 +1,4 @@
-use crate::models::{AppState, MessageDirection};
+use crate::models::{AppState, ChatMessage, LogView, MessageDirection};
use crate::ui::{
accent_style, border_style, dimmed_style, highlight_style, info_style,
primary_style, wrap_chat_line,
@@ -17,10 +17,11 @@ use ratatui::{
pub fn render_chat_pane(
frame: &mut Frame,
area: Rect,
- state: &AppState,
+ state: &mut AppState,
own_username: &str,
) {
- let peer = state.active_chat_peer();
+ let peer = state.active_chat_peer().map(str::to_string);
+ let peer = peer.as_deref();
let title = peer.map_or_else(
|| " Messages (m: compose, i/Esc: close) ".to_string(),
|peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "),
@@ -51,7 +52,14 @@ pub fn render_chat_pane(
return;
};
- render_messages(frame, body[0], state, peer, own_username);
+ render_messages(
+ frame,
+ body[0],
+ &state.messages,
+ &mut state.chat_view,
+ peer,
+ own_username,
+ );
render_compose(frame, chunks[1], state);
}
@@ -94,13 +102,13 @@ fn render_peers(
fn render_messages(
frame: &mut Frame,
area: Rect,
- state: &AppState,
+ messages: &[ChatMessage],
+ view: &mut LogView,
peer: &str,
own_username: &str,
) {
let width = area.width as usize;
- let lines: Vec = state
- .messages
+ let lines: Vec = messages
.iter()
.filter(|m| m.peer == peer)
.flat_map(|m| {
@@ -123,9 +131,9 @@ fn render_messages(
})
.collect();
- // Auto-scroll: tail by rendered rows so the newest message stays visible.
- let start = lines.len().saturating_sub((area.height as usize).max(1));
- frame.render_widget(Paragraph::new(lines[start..].to_vec()), area);
+ // The newest rows, unless the reader is holding a place in the history.
+ let window = view.window(lines.len(), usize::from(area.height));
+ frame.render_widget(Paragraph::new(lines[window].to_vec()), area);
}
fn render_compose(frame: &mut Frame, area: Rect, state: &AppState) {
diff --git a/soulseek-rs/src/ui/panes/downloads_pane.rs b/soulseek-rs/src/ui/panes/downloads_pane.rs
index 2684fda..e624279 100644
--- a/soulseek-rs/src/ui/panes/downloads_pane.rs
+++ b/soulseek-rs/src/ui/panes/downloads_pane.rs
@@ -1,3 +1,4 @@
+use super::name_scroll::{column_width, end_offset, scroll_text};
use crate::models::DownloadEntry;
use crate::ui::{
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL,
@@ -8,12 +9,43 @@ use crate::ui::{
};
use ratatui::{
Frame,
- layout::Rect,
+ layout::{Constraint, Rect},
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
};
use soulseek_rs::DownloadStatus;
use soulseek_rs::types::{UploadInfo, UploadStatus};
+// Sized to the widest value each column shows in ordinary use — a
+// three-digit megabyte count on both sides of the progress — so the file
+// name keeps whatever width the pane has beyond that.
+const WIDTHS: [Constraint; 5] = [
+ Constraint::Length(6),
+ Constraint::Fill(1),
+ Constraint::Length(12),
+ Constraint::Length(25),
+ Constraint::Length(10),
+];
+
+/// Filename is the second column.
+const NAME_COLUMN: usize = 1;
+
+/// The offset that brings the end of a transfer's name into view.
+#[must_use]
+pub fn name_end_offset(name: &str, area: Rect) -> usize {
+ end_offset(name, area, &WIDTHS, NAME_COLUMN)
+}
+
+/// What the list shows for an upload: the peer-facing path is ours, so only
+/// the file's own name is worth a column.
+#[must_use]
+pub fn upload_display_name(upload: &UploadInfo) -> &str {
+ upload
+ .filename
+ .rsplit(['\\', '/'])
+ .next()
+ .unwrap_or(&upload.filename)
+}
+
pub fn render_downloads_pane(
frame: &mut Frame,
area: Rect,
@@ -21,6 +53,7 @@ pub fn render_downloads_pane(
uploads: &[UploadInfo],
table_state: &mut TableState,
focused: bool,
+ name_offset: usize,
) {
if downloads.is_empty() && uploads.is_empty() {
let empty_block = pane_block(focused).title(pane_title(
@@ -47,6 +80,7 @@ pub fn render_downloads_pane(
])
.height(1);
+ let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
let mut rows: Vec = downloads
.iter()
.map(|download_entry| {
@@ -106,7 +140,12 @@ pub fn render_downloads_pane(
Row::new(vec![
Cell::from(status_icon).style(status_style),
- Cell::from(download.filename.clone()).style(body_style()),
+ Cell::from(scroll_text(
+ &download.filename,
+ name_offset,
+ name_width,
+ ))
+ .style(body_style()),
Cell::from(download.username.clone()).style(info_style()),
Cell::from(progress_text).style(progress_style),
Cell::from(speed_text).style(warning_style()),
@@ -149,32 +188,21 @@ pub fn render_downloads_pane(
}
_ => "-".to_string(),
};
- let basename = upload
- .filename
- .rsplit(['\\', '/'])
- .next()
- .unwrap_or(&upload.filename);
Row::new(vec![
Cell::from(format!("↑ {status_icon}")).style(status_style),
- Cell::from(basename.to_string()).style(body_style()),
+ Cell::from(scroll_text(
+ upload_display_name(upload),
+ name_offset,
+ name_width,
+ ))
+ .style(body_style()),
Cell::from(upload.username.clone()).style(info_style()),
Cell::from(progress_text).style(primary_style()),
Cell::from(speed_text).style(warning_style()),
])
}));
- // Sized to the widest value each column shows in ordinary use — a
- // three-digit megabyte count on both sides of the progress — so the file
- // name keeps whatever width the pane has beyond that.
- let widths = [
- ratatui::layout::Constraint::Length(6),
- ratatui::layout::Constraint::Fill(1),
- ratatui::layout::Constraint::Length(12),
- ratatui::layout::Constraint::Length(25),
- ratatui::layout::Constraint::Length(10),
- ];
-
- let table = Table::new(rows, widths)
+ let table = Table::new(rows, WIDTHS)
.header(header)
.row_highlight_style(row_highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL)
diff --git a/soulseek-rs/src/ui/panes/mod.rs b/soulseek-rs/src/ui/panes/mod.rs
index cc31cd9..357df0a 100644
--- a/soulseek-rs/src/ui/panes/mod.rs
+++ b/soulseek-rs/src/ui/panes/mod.rs
@@ -2,6 +2,7 @@ mod browse_pane;
mod chat_pane;
mod download_info_pane;
mod downloads_pane;
+mod name_scroll;
mod results_pane;
mod rooms_pane;
mod searches_pane;
@@ -11,9 +12,12 @@ pub use chat_pane::render_chat_pane;
pub use download_info_pane::{
InfoSubject, render_download_info_pane, selected_transfer,
};
-pub use downloads_pane::render_downloads_pane;
+pub use downloads_pane::{
+ name_end_offset as transfer_name_end_offset, render_downloads_pane,
+ upload_display_name,
+};
pub use results_pane::{
ResultsPaneParams, name_end_offset, render_results_pane,
};
pub use rooms_pane::render_rooms_pane;
-pub use searches_pane::render_searches_pane;
+pub use searches_pane::{query_end_offset, render_searches_pane};
diff --git a/soulseek-rs/src/ui/panes/name_scroll.rs b/soulseek-rs/src/ui/panes/name_scroll.rs
new file mode 100644
index 0000000..99c81f1
--- /dev/null
+++ b/soulseek-rs/src/ui/panes/name_scroll.rs
@@ -0,0 +1,126 @@
+//! Sideways scrolling for a table column that holds text wider than it is:
+//! a file name, a query. Every list scrolls the same way, so the arithmetic
+//! lives here and each pane only says which of its columns it applies to.
+
+use crate::ui::{HIGHLIGHT_SYMBOL, pane_block};
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ text::{Line, Span},
+};
+use unicode_width::UnicodeWidthChar;
+
+/// Cells column `column` of a table laid out with `widths` gets inside a pane
+/// drawn over `area`: what the pane keeps for its border, padding and the
+/// highlight symbol comes off first.
+#[must_use]
+pub fn column_width(area: Rect, widths: &[Constraint], column: usize) -> usize {
+ let inner = pane_block(false).inner(area);
+ let symbol = Line::from(HIGHLIGHT_SYMBOL).width() as u16;
+ let [_, columns] =
+ Layout::horizontal([Constraint::Length(symbol), Constraint::Fill(0)])
+ .areas(inner);
+ usize::from(
+ Layout::horizontal(widths).spacing(1).split(columns)[column].width,
+ )
+}
+
+/// How far a text of `cells` can scroll before its tail is in view, keeping
+/// one cell for the ellipsis that marks the cut.
+const fn overflow(cells: usize, width: usize) -> usize {
+ if cells <= width { 0 } else { cells + 1 - width }
+}
+
+/// The offset that brings the end of `text` into view in the column.
+#[must_use]
+pub fn end_offset(
+ text: &str,
+ area: Rect,
+ widths: &[Constraint],
+ column: usize,
+) -> usize {
+ overflow(Span::raw(text).width(), column_width(area, widths, column))
+}
+
+/// `text` scrolled `offset` cells to the left, with an ellipsis where it was
+/// cut. Scrolls by cells, so a wide glyph counts double, and never starts on
+/// a combining mark. Beyond the end it shows the tail.
+#[must_use]
+pub fn scroll_text(text: &str, offset: usize, width: usize) -> Line<'_> {
+ if offset == 0 {
+ return Line::from(text);
+ }
+ let mut tail_cells = 0;
+ let mut furthest = text.len();
+ for (index, ch) in text.char_indices().rev() {
+ let cells = ch.width().unwrap_or(0);
+ if tail_cells + cells >= width {
+ if index == 0 && tail_cells + cells == width {
+ return Line::from(text);
+ }
+ break;
+ }
+ tail_cells += cells;
+ furthest = index;
+ }
+ if furthest == 0 {
+ return Line::from(text);
+ }
+ let mut skipped = 0;
+ let mut start = furthest;
+ for (index, ch) in text.char_indices() {
+ if index >= furthest || skipped >= offset {
+ start = index;
+ break;
+ }
+ skipped += ch.width().unwrap_or(0);
+ }
+ while let Some(ch) = text[start..].chars().next()
+ && ch.width() == Some(0)
+ {
+ start += ch.len_utf8();
+ }
+ Line::from(vec![Span::raw("…"), Span::raw(&text[start..])])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn text_of(line: &Line<'_>) -> String {
+ line.spans.iter().map(|s| s.content.as_ref()).collect()
+ }
+
+ #[test]
+ fn a_text_that_fits_never_scrolls() {
+ assert_eq!(text_of(&scroll_text("short", 8, 10)), "short");
+ assert_eq!(
+ end_offset(
+ "short",
+ Rect::new(0, 0, 40, 5),
+ &[Constraint::Fill(1)],
+ 0
+ ),
+ 0
+ );
+ }
+
+ #[test]
+ fn the_end_offset_leaves_room_for_the_ellipsis() {
+ // Column of 40 - 2 border - 2 padding - 1 symbol = 35 cells.
+ let area = Rect::new(0, 0, 40, 5);
+ let widths = [Constraint::Fill(1)];
+ let fits = "x".repeat(35);
+ let over = "x".repeat(36);
+ assert_eq!(end_offset(&fits, area, &widths, 0), 0);
+ assert_eq!(end_offset(&over, area, &widths, 0), 2);
+ let line = scroll_text(&over, 2, 35);
+ assert_eq!(text_of(&line).chars().count(), 35);
+ assert!(text_of(&line).starts_with('…'));
+ }
+
+ #[test]
+ fn scrolling_past_the_end_shows_the_tail() {
+ let line = scroll_text("abcdefghij", usize::MAX, 5);
+ assert_eq!(text_of(&line), "…ghij");
+ }
+}
diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs
index 1634047..039c7bf 100644
--- a/soulseek-rs/src/ui/panes/results_pane.rs
+++ b/soulseek-rs/src/ui/panes/results_pane.rs
@@ -1,3 +1,4 @@
+use super::name_scroll::{column_width, end_offset, scroll_text};
use crate::models::FileDisplayData;
use crate::ui::{
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
@@ -6,12 +7,11 @@ use crate::ui::{
};
use ratatui::{
Frame,
- layout::{Constraint, Layout, Rect},
+ layout::{Constraint, Rect},
text::{Line, Span},
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
};
use std::collections::HashSet;
-use unicode_width::UnicodeWidthChar;
const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -39,59 +39,12 @@ pub struct ResultsPaneParams<'a> {
pub name_offset: usize,
}
-fn name_column_width(area: Rect) -> usize {
- let inner = pane_block(false).inner(area);
- let symbol = Line::from(HIGHLIGHT_SYMBOL).width() as u16;
- let [_, columns] =
- Layout::horizontal([Constraint::Length(symbol), Constraint::Fill(0)])
- .areas(inner);
- usize::from(Layout::horizontal(WIDTHS).spacing(1).split(columns)[1].width)
-}
-
-const fn overflow(cells: usize, width: usize) -> usize {
- if cells <= width { 0 } else { cells + 1 - width }
-}
+/// Filename is the second column.
+const NAME_COLUMN: usize = 1;
#[must_use]
pub fn name_end_offset(name: &str, area: Rect) -> usize {
- overflow(Span::raw(name).width(), name_column_width(area))
-}
-
-fn scroll_name(name: &str, offset: usize, width: usize) -> Line<'_> {
- if offset == 0 {
- return Line::from(name);
- }
- let mut tail_cells = 0;
- let mut furthest = name.len();
- for (index, ch) in name.char_indices().rev() {
- let cells = ch.width().unwrap_or(0);
- if tail_cells + cells >= width {
- if index == 0 && tail_cells + cells == width {
- return Line::from(name);
- }
- break;
- }
- tail_cells += cells;
- furthest = index;
- }
- if furthest == 0 {
- return Line::from(name);
- }
- let mut skipped = 0;
- let mut start = furthest;
- for (index, ch) in name.char_indices() {
- if index >= furthest || skipped >= offset {
- start = index;
- break;
- }
- skipped += ch.width().unwrap_or(0);
- }
- while let Some(ch) = name[start..].chars().next()
- && ch.width() == Some(0)
- {
- start += ch.len_utf8();
- }
- Line::from(vec![Span::raw("…"), Span::raw(&name[start..])])
+ end_offset(name, area, &WIDTHS, NAME_COLUMN)
}
/// Whether the rendered row `display_idx` is selected. `selected_indices` holds
@@ -173,7 +126,7 @@ pub fn render_results_pane(
])
.height(1);
- let name_width = name_column_width(area);
+ let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
let rows: Vec = items
.iter()
.enumerate()
@@ -206,7 +159,7 @@ pub fn render_results_pane(
Row::new(vec![
Cell::from(checkbox).style(checkbox_style),
- Cell::from(scroll_name(
+ Cell::from(scroll_text(
&file.filename,
name_offset,
name_width,
diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs
index 53cd407..18624dd 100644
--- a/soulseek-rs/src/ui/panes/rooms_pane.rs
+++ b/soulseek-rs/src/ui/panes/rooms_pane.rs
@@ -1,4 +1,4 @@
-use crate::models::{RoomsState, RoomsView};
+use crate::models::{LogView, RoomsState, RoomsView};
use crate::ui::{
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style,
highlight_style, info_style, pane_block, plain_title, primary_style,
@@ -20,7 +20,7 @@ use ratatui::{
pub fn render_rooms_pane(
frame: &mut Frame,
area: Rect,
- rooms: &RoomsState,
+ rooms: &mut RoomsState,
list_table_state: &mut TableState,
) {
match rooms.view {
@@ -94,7 +94,7 @@ fn render_list(
frame.render_stateful_widget(table, area, table_state);
}
-fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
+fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
let block = pane_block(true).title(
" Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ",
);
@@ -110,7 +110,10 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
render_tab_bar(frame, chunks[0], rooms);
- let Some(active) = rooms.active_room() else {
+ let user_selected = rooms.user_selected;
+ let composing = rooms.composing;
+ let active_index = rooms.active;
+ let Some(active) = rooms.open.get_mut(active_index) else {
frame.render_widget(
Paragraph::new("No open rooms. Press l for the room list.")
.style(dimmed_style()),
@@ -124,11 +127,11 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)])
.split(chunks[1]);
- render_messages(frame, body[0], active.lines.as_slice());
- render_users(frame, body[1], &active.users, rooms.user_selected);
+ render_messages(frame, body[0], active.lines.as_slice(), &mut active.view);
+ render_users(frame, body[1], &active.users, user_selected);
// Compose line or hint.
- if rooms.composing {
+ if composing {
let line = Line::from(vec![
Span::styled("› ", accent_style()),
Span::styled(active.input.clone(), primary_style()),
@@ -173,9 +176,10 @@ fn render_messages(
frame: &mut Frame,
area: Rect,
lines: &[crate::models::RoomLine],
+ view: &mut LogView,
) {
- // Wrap each message to the pane width, then auto-scroll: tail by rendered
- // rows so the newest message is always fully visible.
+ // Wrap each message to the pane width, then show the rows the reader is
+ // at: the newest ones, unless they are holding a place in the history.
let width = area.width as usize;
let rendered: Vec = lines
.iter()
@@ -198,8 +202,8 @@ fn render_messages(
}
})
.collect();
- let start = rendered.len().saturating_sub((area.height as usize).max(1));
- frame.render_widget(Paragraph::new(rendered[start..].to_vec()), area);
+ let window = view.window(rendered.len(), usize::from(area.height));
+ frame.render_widget(Paragraph::new(rendered[window].to_vec()), area);
}
fn render_users(
diff --git a/soulseek-rs/src/ui/panes/searches_pane.rs b/soulseek-rs/src/ui/panes/searches_pane.rs
index 8942d83..03d45eb 100644
--- a/soulseek-rs/src/ui/panes/searches_pane.rs
+++ b/soulseek-rs/src/ui/panes/searches_pane.rs
@@ -1,3 +1,4 @@
+use super::name_scroll::{column_width, end_offset, scroll_text};
use crate::models::{SearchEntry, SearchStatus};
use crate::ui::{
HIGHLIGHT_SYMBOL, body_style, dimmed_style, header_style, pane_block,
@@ -5,16 +6,34 @@ use crate::ui::{
};
use ratatui::{
Frame,
- layout::Rect,
+ layout::{Constraint, Rect},
widgets::{Cell, HighlightSpacing, Row, Table, TableState},
};
+// The pane shares a row with two others, so the fixed columns are as
+// narrow as their widest word: the query gets the rest.
+const WIDTHS: [Constraint; 3] = [
+ Constraint::Length(6),
+ Constraint::Fill(1),
+ Constraint::Length(7),
+];
+
+/// Query is the second column.
+const QUERY_COLUMN: usize = 1;
+
+/// The offset that brings the end of a query into view.
+#[must_use]
+pub fn query_end_offset(query: &str, area: Rect) -> usize {
+ end_offset(query, area, &WIDTHS, QUERY_COLUMN)
+}
+
pub fn render_searches_pane(
frame: &mut Frame,
area: Rect,
searches: &[SearchEntry],
table_state: &mut TableState,
focused: bool,
+ query_offset: usize,
) {
let header = Row::new(vec![
Cell::from("Status").style(header_style()),
@@ -23,6 +42,7 @@ pub fn render_searches_pane(
])
.height(1);
+ let query_width = column_width(area, &WIDTHS, QUERY_COLUMN);
let rows: Vec = searches
.iter()
.map(|search| {
@@ -55,21 +75,18 @@ pub fn render_searches_pane(
Row::new(vec![
status_cell,
- Cell::from(search.query.clone()).style(body_style()),
+ Cell::from(scroll_text(
+ &search.query,
+ query_offset,
+ query_width,
+ ))
+ .style(body_style()),
Cell::from(results_text).style(dimmed_style()),
])
})
.collect();
- // The pane shares a row with two others, so the fixed columns are as
- // narrow as their widest word: the query gets the rest.
- let widths = [
- ratatui::layout::Constraint::Length(6),
- ratatui::layout::Constraint::Fill(1),
- ratatui::layout::Constraint::Length(7),
- ];
-
- let table = Table::new(rows, widths)
+ let table = Table::new(rows, WIDTHS)
.header(header)
.row_highlight_style(row_highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL)
diff --git a/website/src/pages/docs.tsx b/website/src/pages/docs.tsx
index 0142507..90691ed 100644
--- a/website/src/pages/docs.tsx
+++ b/website/src/pages/docs.tsx
@@ -36,7 +36,8 @@ const PANE_KEYS: readonly (readonly [string, string])[] = [
['w', 'hide the focused pane; the others widen'],
['↑ ↓ · j k', 'move a row in any list'],
['Home End · g G', 'first / last row'],
- ['PgUp PgDn · ^u ^d', 'a page, or half a page'],
+ ['PgUp PgDn · ^u ^d', 'a page, or half a page; in a chat, back through the log'],
+ ['h l · ← →', 'scroll a long name or query sideways, 0 and $ to either end'],
]
const ROOM_KEYS: readonly (readonly [string, string])[] = [