diff --git a/README.md b/README.md index 52ccce2..743053c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Feed me -`nom` is a terminal based RSS feed reader using [Glow](https://github.com/charmbracelet/glow) styled markdown to improve the reading experience and a simple TUI using [Bubbletea](https://github.com/charmbracelet/bubbletea). +`nom` is a terminal based RSS feed reader that shows articles as styled markdown, with their pictures, in a simple TUI built with [Limoni](https://github.com/thebanri/limoni). - Local sync and offline reading - Backend connections (miniflux, freshrss supported) @@ -125,7 +125,7 @@ refreshinterval: 5 ### Theme -Theme allows some basic color overrides in the feed view and then setting a custom markdown render theme for the overall markdown view. `theme.glamour` can be one of "dark", "dracula", "light", "pink", "ascii" or "notty". See [here](https://github.com/charmbracelet/glamour/tree/master/styles/gallery) for previews and more info. +Theme allows some basic color overrides in the feed view and then setting a custom markdown render theme for the overall markdown view. `theme.glamour` can be one of "dark", "dracula", "light", "pink", "ascii" or "notty", the names of [glamour's styles](https://github.com/charmbracelet/glamour/tree/master/styles/gallery), whose colours the article view uses. With "custom", the colours are read from the glamour style file at `theme.customPath`. Colors can be hex or ASCII codes, they will be coerced depending on your terminal color settings. ```yaml @@ -137,6 +137,14 @@ theme: filterColor: "#555555" ``` +### Images (default: true) + +An article's pictures are downloaded and shown in the article view: with the kitty, iTerm2 or Sixel image protocol where the terminal has one, and in coloured half blocks where it does not. Set `images: false` to keep nom from downloading them; their alt text is shown instead. + +```yaml +images: false +``` + ### Backends As well as adding feeds directly, you can pull in feeds from another source. You can add multiple backends and the feeds will all be added. diff --git a/go.mod b/go.mod index 7241946..64d91dc 100644 --- a/go.mod +++ b/go.mod @@ -1,56 +1,32 @@ module github.com/guyfedwards/nom/v2 -go 1.24.0 +go 1.25.0 require ( github.com/JohannesKaufmann/html-to-markdown v1.6.0 - github.com/charmbracelet/bubbles v0.18.0 - github.com/charmbracelet/bubbletea v1.2.4 - github.com/charmbracelet/glamour v0.10.0 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/jessevdk/go-flags v1.5.0 github.com/mattn/go-sqlite3 v1.14.22 github.com/mmcdole/gofeed v1.3.0 github.com/sahilm/fuzzy v0.1.1 - golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 miniflux.app/v2 v2.2.17 ) require ( - github.com/PuerkitoBio/goquery v1.11.0 // indirect - github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + golang.org/x/image v0.45.0 +) + +require ( + github.com/PuerkitoBio/goquery v1.11.0 github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/dlclark/regexp2 v1.11.0 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/gorilla/css v1.0.1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mmcdole/goxpp v1.1.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/thebanri/limoni v0.10.0 github.com/yuin/goldmark v1.7.8 // indirect - github.com/yuin/goldmark-emoji v1.0.5 // indirect golang.org/x/net v0.50.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 6653b09..8c1df03 100644 --- a/go.sum +++ b/go.sum @@ -3,56 +3,14 @@ github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/ github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= -github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= -github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= -github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= -github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= -github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= -github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= -github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -65,19 +23,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8= @@ -87,21 +34,9 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= @@ -114,14 +49,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/thebanri/limoni v0.10.0 h1:OjR5uALHkGZ01vpCX+OBPSWM7kmE2XOhwT+ebs7fyU8= +github.com/thebanri/limoni v0.10.0/go.mod h1:aHH0MYLRJrv8GBKn0HPI68H3KSTdgu6MHqBfkvXWZVA= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= -github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -129,8 +62,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -156,17 +89,13 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -174,8 +103,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -187,8 +116,6 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -198,8 +125,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/commands/commands.go b/internal/commands/commands.go index a7467e7..efc4c93 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -1,7 +1,7 @@ package commands import ( - "encoding/json" + "context" "fmt" "io" "log" @@ -16,11 +16,8 @@ import ( "time" md "github.com/JohannesKaufmann/html-to-markdown" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/glamour/ansi" - "github.com/charmbracelet/glamour/styles" + "github.com/PuerkitoBio/goquery" + "github.com/thebanri/limoni" "gopkg.in/yaml.v3" "github.com/guyfedwards/nom/v2/internal/config" @@ -37,8 +34,8 @@ func New(config *config.Config, store store.Store) *Commands { return &Commands{config, store} } -func convertItems(its []store.Item) []list.Item { - var items []list.Item +func convertItems(its []store.Item) []TUIItem { + var items []TUIItem for _, item := range its { items = append(items, ItemToTUIItem(item)) @@ -264,33 +261,34 @@ func (c Commands) fetchAllFeeds() ([]store.Item, []ErrorItem, error) { return items, errorItems, nil } -func (c Commands) Monitor(prog *tea.Program) { +// Monitor refreshes the feeds every RefreshInterval minutes, sending the +// TUI what it found, until ctx ends. +func (c Commands) Monitor(ctx context.Context, send func(limoni.Msg)) { if c.config.RefreshInterval == 0 { return } go func() { t := time.NewTicker(time.Duration(c.config.RefreshInterval) * time.Minute) - for range t.C { + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } err := c.Refresh() if err != nil { log.Println("Refresh failed: ", err) - prog.Send(statusUpdate{ - status: "Refresh failed", - }) - } else { - items, err := c.GetAllFeeds() - if err != nil { - log.Println("Refresh failed: ", err) - prog.Send(statusUpdate{ - status: "Refresh failed", - }) - } - prog.Send(listUpdate{ - items: convertItems(items), - status: "Refreshed.", - }) + send(statusUpdate{status: "Refresh failed"}) + continue + } + items, err := c.GetAllFeeds() + if err != nil { + log.Println("Refresh failed: ", err) + send(statusUpdate{status: "Refresh failed"}) } + send(listUpdate{items: convertItems(items), status: "Refreshed."}) } }() } @@ -303,61 +301,25 @@ func (c Commands) CountUnread() int { return count } -func (c Commands) GetGlamourisedArticle(ID int) (string, error) { +// GetArticleMarkdown is the article as the article view shows it: its +// title, author, date and link, then its content as Markdown. +func (c Commands) GetArticleMarkdown(ID int) (string, error) { article, err := c.store.GetItemByID(ID) if err != nil { - return "", fmt.Errorf("commands.FindGlamourisedArticle: %w", err) + return "", fmt.Errorf("commands.GetArticleMarkdown: %w", err) } if c.config.AutoRead && !article.Read() { err = c.store.ToggleRead(article.ID) if err != nil { - return "", fmt.Errorf("[commands.go] GetGlamourisedArticle: %w", err) + return "", fmt.Errorf("[commands.go] GetArticleMarkdown: %w", err) } } - content, err := glamouriseItem(article, c.config.Theme) - if err != nil { - return "", fmt.Errorf("[commands.go] GetGlamourisedArticle: %w", err) - } - - return content, nil + return itemMarkdown(article, c.config.Theme), nil } -func getStyleConfigWithOverrides(theme config.Theme) (sc ansi.StyleConfig) { - switch theme.Glamour { - case "light": - sc = styles.LightStyleConfig - case "dracula": - sc = styles.DraculaStyleConfig - case "pink": - sc = styles.PinkStyleConfig - case "ascii": - sc = styles.ASCIIStyleConfig - case "notty": - sc = styles.NoTTYStyleConfig - case "custom": - sc = styles.DarkStyleConfig - data, err := os.ReadFile(theme.CustomPath) - if err != nil { - log.Println(err) - sc = styles.DarkStyleConfig - } - if err := json.Unmarshal(data, &sc); err != nil { - log.Println(err) - sc = styles.DarkStyleConfig - } - default: - sc = styles.DarkStyleConfig - } - - sc.H1.BackgroundColor = &theme.TitleColor - sc.H1.Color = &theme.TitleColorFg - - return sc -} - -func glamouriseItem(item store.Item, theme config.Theme) (string, error) { +func itemMarkdown(item store.Item, theme config.Theme) string { var mdown string title := item.Title @@ -375,22 +337,25 @@ func glamouriseItem(item store.Item, theme config.Theme) (string, error) { mdown += "\n\n" mdown += item.Link mdown += "\n\n" - mdown += htmlToMd(item.Content) - - r, _ := glamour.NewTermRenderer( - glamour.WithStyles(getStyleConfigWithOverrides(theme)), - ) + mdown += htmlToMd(item.Content, item.Link) - out, err := r.Render(mdown) - if err != nil { - return "", fmt.Errorf("GlamouriseItem: %w", err) - } - - return out, nil + return mdown } -func htmlToMd(html string) string { - converter := md.NewConverter("", true, nil) +// htmlToMd converts an article's HTML to Markdown. Relative addresses in it +// are resolved against base, the article's own address, so that its links +// work and its pictures can be fetched. +func htmlToMd(html string, base string) string { + baseURL, _ := url.Parse(base) + converter := md.NewConverter(md.DomainFromURL(base), true, &md.Options{ + GetAbsoluteURL: func(_ *goquery.Selection, raw string, _ string) string { + ref, err := url.Parse(raw) + if err != nil || baseURL == nil || baseURL.Host == "" || strings.HasPrefix(raw, "#") || ref.Scheme == "data" { + return raw + } + return baseURL.ResolveReference(ref).String() + }, + }) mdown, err := converter.ConvertString(html) if err != nil { diff --git a/internal/commands/filter.go b/internal/commands/filter.go index 5e57ea9..95e0882 100644 --- a/internal/commands/filter.go +++ b/internal/commands/filter.go @@ -7,7 +7,6 @@ import ( "sort" "strings" - "github.com/charmbracelet/bubbles/list" "github.com/sahilm/fuzzy" "github.com/guyfedwards/nom/v2/internal/config" @@ -19,7 +18,7 @@ type FilterTerm struct { Tags []string } -// Struct to aid in filtering items into ranks for BubbleTea +// Struct to aid in filtering items into ranks for the TUI type Filterer struct { FeedNames []string Tags []string @@ -165,18 +164,17 @@ func NewFilterer(term string, config config.Config) Filterer { return f } -func CustomFilter(config config.Config) list.FilterFunc { - return func(term string, targets []string) []list.Rank { +// CustomFilter ranks targets — TUIItem.FilterValue strings — against a +// filter term, best match first, and returns their indices. +func CustomFilter(config config.Config) func(term string, targets []string) []int { + return func(term string, targets []string) []int { filterer := NewFilterer(term, config) ranks := filterer.Filter(targets) - result := make([]list.Rank, len(ranks)) + result := make([]int, len(ranks)) for i, rank := range ranks { - result[i] = list.Rank{ - Index: rank.Index, - MatchedIndexes: rank.MatchedIndexes, - } + result[i] = rank.Index } return result diff --git a/internal/commands/images.go b/internal/commands/images.go new file mode 100644 index 0000000..43193c6 --- /dev/null +++ b/internal/commands/images.go @@ -0,0 +1,131 @@ +package commands + +import ( + "context" + "fmt" + "image" + _ "image/gif" // decoders for the pictures articles carry + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "time" + + "github.com/thebanri/limoni" + "github.com/thebanri/limoni/graphics" + _ "golang.org/x/image/webp" +) + +const ( + // maxImageBytes is the most of a picture nom downloads. + maxImageBytes = 10 << 20 + // maxImageWidth is the widest a picture is kept: wider ones are scaled + // down once on arrival, rather than by the terminal on every frame. + maxImageWidth = 1200 + // imageFetches is how many pictures download at once. + imageFetches = 4 +) + +// imageLoaded carries a downloaded picture, or why there is none. +type imageLoaded struct { + src string + img image.Image + err error +} + +// images holds the article view's pictures by address. Update writes it and +// View reads it, both under the program's lock. +type images struct { + client *http.Client + slots chan struct{} + loaded map[string]image.Image + pending map[string]bool + failed map[string]bool +} + +func newImages(client *http.Client) *images { + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + return &images{ + client: client, + slots: make(chan struct{}, imageFetches), + loaded: map[string]image.Image{}, + pending: map[string]bool{}, + failed: map[string]bool{}, + } +} + +// get is Markdown.Images: the picture at src, or nil. +func (im *images) get(src string) image.Image { return im.loaded[src] } + +// fetch returns commands downloading the pictures in sources that are not +// here, on their way, or known to fail. +func (im *images) fetch(sources []string, userAgent string) []limoni.Cmd { + var cmds []limoni.Cmd + for _, src := range sources { + if im.loaded[src] != nil || im.pending[src] || im.failed[src] { + continue + } + im.pending[src] = true + cmds = append(cmds, im.download(src, userAgent)) + } + return cmds +} + +func (im *images) download(src, userAgent string) limoni.Cmd { + return func(ctx context.Context) limoni.Msg { + select { + case im.slots <- struct{}{}: + defer func() { <-im.slots }() + case <-ctx.Done(): + return imageLoaded{src: src, err: ctx.Err()} + } + img, err := im.decode(ctx, src, userAgent) + return imageLoaded{src: src, img: img, err: err} + } +} + +func (im *images) decode(ctx context.Context, src, userAgent string) (image.Image, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, src, nil) + if err != nil { + return nil, err + } + if userAgent != "" { + req.Header.Set("User-Agent", userAgent) + } + res, err := im.client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %s", src, res.Status) + } + img, _, err := image.Decode(io.LimitReader(res.Body, maxImageBytes)) + if err != nil { + return nil, fmt.Errorf("%s: %w", src, err) + } + if b := img.Bounds(); b.Dx() > maxImageWidth { + h := b.Dy() * maxImageWidth / b.Dx() + img = graphics.ResizeImage(img, maxImageWidth, max(h, 1)) + } + return img, nil +} + +// arrived records a download. +func (im *images) arrived(msg imageLoaded) { + delete(im.pending, msg.src) + if msg.err != nil || msg.img == nil { + im.failed[msg.src] = true + return + } + im.loaded[msg.src] = msg.img +} + +// forget drops the pictures, as the article they belong to closes: a reader +// going through a feed would otherwise keep every picture it opened. +func (im *images) forget() { + clear(im.loaded) + clear(im.failed) +} diff --git a/internal/commands/key.go b/internal/commands/key.go index 6d8468d..5cceaa1 100644 --- a/internal/commands/key.go +++ b/internal/commands/key.go @@ -1,219 +1,208 @@ package commands import ( - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/viewport" + "slices" + "strings" + + "github.com/thebanri/limoni" ) -// ListKeyMapT shows either (o)verrides or new keybinds -type ListKeyMapT struct { - Open key.Binding - Read key.Binding - Favourite key.Binding - ToggleReads key.Binding - MarkAllRead key.Binding - ToggleFavourites key.Binding - Refresh key.Binding - OpenInBrowser key.Binding - Sort key.Binding - oQuit key.Binding - oForceQuit key.Binding - oClearFilter key.Binding - oCancelWhileFiltering key.Binding - oNextPage key.Binding - oPrevPage key.Binding - EditConfig key.Binding - Suspend key.Binding +// binding is a set of keys for one action, and how the help shows it. +type binding struct { + keys []string + key string // the keys as the help writes them + desc string } -// ViewportKeyMapT shows *all* keybinds, pulling from viewport.DefaultKeyMap() -type ViewportKeyMapT struct { - Quit key.Binding - Escape key.Binding - OpenInBrowser key.Binding - Favourite key.Binding - Read key.Binding - GotoStart key.Binding - GotoEnd key.Binding - Next key.Binding - Prev key.Binding - ShowFullHelp key.Binding - CloseFullHelp key.Binding - Suspend key.Binding +func bind(help, desc string, keys ...string) binding { + return binding{keys: keys, key: help, desc: desc} } -// ListKeyMap shows either (o)verrides or new keybinds -var ListKeyMap = ListKeyMapT{ - Open: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "open"), - ), - Favourite: key.NewBinding( - key.WithKeys("f"), - key.WithHelp("f", "favourite"), - ), - ToggleFavourites: key.NewBinding( - key.WithKeys("F"), - key.WithHelp("F", "toggle show favourite"), - ), - Read: key.NewBinding( - key.WithKeys("m"), - key.WithHelp("m", "mark read"), - ), - ToggleReads: key.NewBinding( - key.WithKeys("M"), - key.WithHelp("M", "toggle show read"), - ), - MarkAllRead: key.NewBinding( - key.WithKeys("alt+m"), - key.WithHelp("alt+m", "mark all read"), - ), - Refresh: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("r", "refresh"), - ), - OpenInBrowser: key.NewBinding( - key.WithKeys("o"), - key.WithHelp("o", "open in browser"), - ), - Sort: key.NewBinding( - key.WithKeys("s"), - key.WithHelp("s", "sort"), - ), - EditConfig: key.NewBinding( - key.WithKeys("E"), - key.WithHelp("E", "edit config in $EDITOR"), - ), - Suspend: key.NewBinding( - key.WithKeys("ctrl+z"), - key.WithHelp("ctrl+z", "suspend"), - ), - // o for override - oQuit: key.NewBinding( - key.WithKeys("q", "esc"), - key.WithHelp("q/esc", "quit"), - ), - oForceQuit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - oClearFilter: key.NewBinding( - key.WithKeys("esc", "q"), - key.WithHelp("esc/q", "clear filter"), - ), - oCancelWhileFiltering: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), - oPrevPage: key.NewBinding( - key.WithKeys("left", "h", "pgup"), - key.WithHelp("←/h/pgup", "prev page"), - ), - oNextPage: key.NewBinding( - key.WithKeys("right", "l", "pgdown"), - key.WithHelp("→/l/pgdn", "next page"), - ), +func (b binding) matches(name string) bool { return slices.Contains(b.keys, name) } + +// keyName names a key press the way the bindings do: "j", "enter", +// "ctrl+c", "alt+m", "pgdown". +func keyName(k limoni.KeyEvent) string { + var name string + switch k.Type { + case limoni.KeyRune: + name = string(k.Ch) + if k.Ctrl { + return "ctrl+" + strings.ToLower(name) + } + case limoni.KeySpace: + name = " " + case limoni.KeyEnter: + name = "enter" + case limoni.KeyEsc: + name = "esc" + case limoni.KeyBackspace: + name = "backspace" + case limoni.KeyTab: + name = "tab" + case limoni.KeyUp: + name = "up" + case limoni.KeyDown: + name = "down" + case limoni.KeyLeft: + name = "left" + case limoni.KeyRight: + name = "right" + case limoni.KeyHome: + name = "home" + case limoni.KeyEnd: + name = "end" + case limoni.KeyPageUp: + name = "pgup" + case limoni.KeyPageDown: + name = "pgdown" + case limoni.KeyDelete: + name = "delete" + } + if k.Ctrl && k.Type != limoni.KeyRune { + name = "ctrl+" + name + } + if k.Alt { + name = "alt+" + name + } + return name } -// ViewportKeyMapT shows *all* keybinds, pulling from viewport.DefaultKeyMap() -var ViewportKeyMap = ViewportKeyMapT{ - Next: key.NewBinding( - key.WithKeys("l", "right"), - key.WithHelp("l/→", "next"), - ), - Prev: key.NewBinding( - key.WithKeys("h", "left"), - key.WithHelp("h/←", "prev"), - ), - Quit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - Escape: key.NewBinding( - key.WithKeys("esc", "q"), - key.WithHelp("q/esc", "escape"), - ), - Suspend: key.NewBinding( - key.WithKeys("ctrl+z"), - key.WithHelp("ctrl+z", "suspend"), - ), - OpenInBrowser: key.NewBinding( - key.WithKeys("o"), - key.WithHelp("o", "open in browser"), - ), - Favourite: key.NewBinding( - key.WithKeys("f"), - key.WithHelp("f", "favourite"), - ), - Read: key.NewBinding( - key.WithKeys("m"), - key.WithHelp("m", "mark read"), - ), - GotoStart: key.NewBinding( - key.WithKeys("g", "home"), - key.WithHelp("g", "top"), - ), - GotoEnd: key.NewBinding( - key.WithKeys("G", "end"), - key.WithHelp("G", "bottom"), - ), - ShowFullHelp: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "more"), - ), - CloseFullHelp: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "close help"), - ), +// listKeys are the list's keys: the ones it always had, and nom's own. +var listKeys = struct { + Up, Down, PrevPage, NextPage, Top, Bottom binding + Filter, ClearFilter, CancelFilter, Accept binding + Open, Read, Favourite, ToggleFavourites binding + ToggleReads, MarkAllRead, Refresh binding + OpenInBrowser, Sort, EditConfig, Suspend binding + Quit, ForceQuit, ShowFullHelp, CloseFullHelp binding +}{ + Up: bind("↑/k", "up", "up", "k"), + Down: bind("↓/j", "down", "down", "j"), + PrevPage: bind("←/h/pgup", "prev page", "left", "h", "pgup"), + NextPage: bind("→/l/pgdn", "next page", "right", "l", "pgdown"), + Top: bind("g/home", "go to start", "home", "g"), + Bottom: bind("G/end", "go to end", "end", "G"), + Filter: bind("/", "filter", "/"), + ClearFilter: bind("esc/q", "clear filter", "esc", "q"), + CancelFilter: bind("esc", "cancel", "esc"), + Accept: bind("enter", "apply filter", "enter"), + Open: bind("enter", "open", "enter"), + Read: bind("m", "mark read", "m"), + Favourite: bind("f", "favourite", "f"), + ToggleFavourites: bind("F", "toggle show favourite", "F"), + ToggleReads: bind("M", "toggle show read", "M"), + MarkAllRead: bind("alt+m", "mark all read", "alt+m"), + Refresh: bind("r", "refresh", "r"), + OpenInBrowser: bind("o", "open in browser", "o"), + Sort: bind("s", "sort", "s"), + EditConfig: bind("E", "edit config in $EDITOR", "E"), + Suspend: bind("ctrl+z", "suspend", "ctrl+z"), + Quit: bind("q/esc", "quit", "q", "esc"), + ForceQuit: bind("ctrl+c", "quit", "ctrl+c"), + ShowFullHelp: bind("?", "more", "?"), + CloseFullHelp: bind("?", "close help", "?"), } -// This show *all* keybinds, as bubbles/viewport doesn't provide a help function -func (k ViewportKeyMapT) FullHelp() [][]key.Binding { - v := viewport.DefaultKeyMap() - return [][]key.Binding{ - {v.Up, v.Down, v.HalfPageUp, v.HalfPageDown}, - {k.GotoStart, k.GotoEnd, v.PageUp, v.PageDown}, - {k.Next, k.Prev, k.OpenInBrowser, k.Favourite, k.Read}, - {k.Escape, k.Quit, k.CloseFullHelp}, +// articleKeys are the article view's keys. +var articleKeys = struct { + Up, Down, PageUp, PageDown, HalfPageUp, HalfPageDown binding + GotoStart, GotoEnd, Next, Prev binding + OpenInBrowser, Favourite, Read binding + Escape, Quit, Suspend, ShowFullHelp, CloseFullHelp binding +}{ + Up: bind("↑/k", "up", "up", "k"), + Down: bind("↓/j", "down", "down", "j"), + PageUp: bind("b/pgup", "page up", "pgup", "b"), + PageDown: bind("pgdn/space", "page down", "pgdown", " "), + HalfPageUp: bind("u", "½ page up", "u", "ctrl+u"), + HalfPageDown: bind("d", "½ page down", "d", "ctrl+d"), + GotoStart: bind("g", "top", "g", "home"), + GotoEnd: bind("G", "bottom", "G", "end"), + Next: bind("l/→", "next", "l", "right"), + Prev: bind("h/←", "prev", "h", "left"), + OpenInBrowser: bind("o", "open in browser", "o"), + Favourite: bind("f", "favourite", "f"), + Read: bind("m", "mark read", "m"), + Escape: bind("q/esc", "escape", "esc", "q"), + Quit: bind("ctrl+c", "quit", "ctrl+c"), + Suspend: bind("ctrl+z", "suspend", "ctrl+z"), + ShowFullHelp: bind("?", "more", "?"), + CloseFullHelp: bind("?", "close help", "?"), +} + +// helpLine is bindings written as one line, "key desc • key desc". +func helpLine(bs ...binding) string { + parts := make([]string, len(bs)) + for i, b := range bs { + parts[i] = b.key + " " + b.desc } + return strings.Join(parts, " • ") } -// This show *all* keybinds, as bubbles/viewport doesn't provide a help function -func (k ViewportKeyMapT) ShortHelp() []key.Binding { - v := viewport.DefaultKeyMap() - return []key.Binding{ - k.Next, k.Prev, v.Down, v.Up, k.Escape, k.ShowFullHelp, +// helpColumns is bindings written in columns, one binding per row of each. +func helpColumns(columns ...[]binding) []string { + var rows []string + widths := make([]int, len(columns)) + for c, col := range columns { + for _, b := range col { + widths[c] = max(widths[c], limoni.StringWidth(b.key+" "+b.desc)) + } + } + for r := 0; ; r++ { + var line strings.Builder + any := false + for c, col := range columns { + cellText := "" + if r < len(col) { + cellText = col[r].key + " " + col[r].desc + any = true + } + line.WriteString(cellText) + if c < len(columns)-1 { + line.WriteString(strings.Repeat(" ", widths[c]-limoni.StringWidth(cellText)+4)) + } + } + if !any { + return rows + } + rows = append(rows, strings.TrimRight(line.String(), " ")) } } -// This shows *additional* (or overridden) keybinds alongside built-ins, which *must* take []key.Binding unfortunately. -func (k ListKeyMapT) FullHelp() []key.Binding { - return []key.Binding{ - k.Open, k.Read, k.Favourite, k.Refresh, - k.OpenInBrowser, k.Sort, k.ToggleFavourites, k.ToggleReads, - k.MarkAllRead, k.EditConfig, +func (m *model) listShortHelp() string { + k := listKeys + if m.filtering { + return helpLine(k.CancelFilter, k.Accept) + } + quit := k.Quit + if m.filterTerm != "" { + quit = k.ClearFilter } + return helpLine(k.Up, k.Down, k.Filter, k.Open, quit, k.ShowFullHelp) +} + +func (m *model) listFullHelp() []string { + k := listKeys + return helpColumns( + []binding{k.Up, k.Down, k.PrevPage, k.NextPage, k.Top, k.Bottom}, + []binding{k.Filter, k.ClearFilter, k.Quit, k.ForceQuit, k.CloseFullHelp}, + []binding{k.Open, k.Read, k.Favourite, k.Refresh, k.OpenInBrowser}, + []binding{k.Sort, k.ToggleFavourites, k.ToggleReads, k.MarkAllRead, k.EditConfig}, + ) } -// This shows *additional* (or overridden) keybinds alongside built-ins -func (k ListKeyMapT) ShortHelp() []key.Binding { - return []key.Binding{k.Open} +func articleShortHelp() string { + k := articleKeys + return helpLine(k.Next, k.Prev, k.Down, k.Up, k.Escape, k.ShowFullHelp) } -func (k ListKeyMapT) SetOverrides(l *list.Model) { - l.AdditionalFullHelpKeys = ListKeyMap.FullHelp - l.AdditionalShortHelpKeys = ListKeyMap.ShortHelp - l.KeyMap.Quit.SetKeys(k.oQuit.Keys()...) - l.KeyMap.Quit.SetHelp(k.oQuit.Help().Key, k.oQuit.Help().Desc) - l.KeyMap.ForceQuit.SetKeys(k.oForceQuit.Keys()...) - l.KeyMap.ForceQuit.SetHelp(k.oForceQuit.Help().Key, k.oForceQuit.Help().Desc) - l.KeyMap.ClearFilter.SetKeys(k.oClearFilter.Keys()...) - l.KeyMap.ClearFilter.SetHelp(k.oClearFilter.Help().Key, k.oClearFilter.Help().Desc) - l.KeyMap.CancelWhileFiltering.SetKeys(k.oCancelWhileFiltering.Keys()...) - l.KeyMap.CancelWhileFiltering.SetHelp(k.oCancelWhileFiltering.Help().Key, k.oCancelWhileFiltering.Help().Desc) - l.KeyMap.NextPage.SetKeys(k.oNextPage.Keys()...) - l.KeyMap.NextPage.SetHelp(k.oNextPage.Help().Key, k.oNextPage.Help().Desc) - l.KeyMap.PrevPage.SetKeys(k.oPrevPage.Keys()...) - l.KeyMap.PrevPage.SetHelp(k.oPrevPage.Help().Key, k.oPrevPage.Help().Desc) +func articleFullHelp() []string { + k := articleKeys + return helpColumns( + []binding{k.Up, k.Down, k.HalfPageUp, k.HalfPageDown}, + []binding{k.GotoStart, k.GotoEnd, k.PageUp, k.PageDown}, + []binding{k.Next, k.Prev, k.OpenInBrowser, k.Favourite, k.Read}, + []binding{k.Escape, k.Quit, k.CloseFullHelp}, + ) } diff --git a/internal/commands/list.go b/internal/commands/list.go index 305128a..8a1d028 100644 --- a/internal/commands/list.go +++ b/internal/commands/list.go @@ -1,99 +1,136 @@ package commands import ( + "context" "fmt" - "io" "os" "os/exec" "strings" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "github.com/thebanri/limoni" + "github.com/thebanri/limoni/widgets" - "github.com/guyfedwards/nom/v2/internal/config" "github.com/guyfedwards/nom/v2/internal/constants" "github.com/guyfedwards/nom/v2/internal/store" ) -var ( - itemStyle = lipgloss.NewStyle().PaddingLeft(4) - selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2) - readStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240")) - selectedReadStyle = lipgloss.NewStyle().PaddingLeft(2) - favouriteStyle = itemStyle.PaddingLeft(2).Bold(true) - selectedFavouriteStyle = selectedItemStyle.Bold(true) - helpStyle = list.DefaultStyles(). - HelpStyle. - PaddingLeft(4). - PaddingBottom(1). - Foreground(lipgloss.Color("#4A4A4A")) -) - -type itemDelegate struct { - theme config.Theme +// setItems replaces the list's items, keeping the filter and, as far as it +// can, the selection. +func (m *model) setItems(items []TUIItem) { + m.items = items + m.applyFilter() } -func (d itemDelegate) Height() int { return 1 } -func (d itemDelegate) Spacing() int { return 0 } -func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } -func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i, ok := listItem.(TUIItem) - if !ok { - return +// applyFilter works out which items the filter lets through, in the order it +// ranks them, and builds their rows. +func (m *model) applyFilter() { + term := m.filterTerm + if m.filtering { + term = m.filter.Value() } - - var str string - if i.FeedName == "" { - str = fmt.Sprintf("%3d. %s", index+1, i.Title) + m.visible = m.visible[:0] + if term == "" { + for i := range m.items { + m.visible = append(m.visible, i) + } } else { - str = fmt.Sprintf("%3d. %s: %s", index+1, i.FeedName, i.Title) + targets := make([]string, len(m.items)) + for i, it := range m.items { + targets[i] = it.FilterValue() + } + m.visible = append(m.visible, CustomFilter(*m.cfg)(term, targets)...) } + m.rows, m.selectedRows = m.rows[:0], m.selectedRows[:0] + for n, i := range m.visible { + it := m.items[i] + var row string + if it.FeedName == "" { + row = fmt.Sprintf("%3d. %s", n+1, it.Title) + } else { + row = fmt.Sprintf("%3d. %s: %s", n+1, it.FeedName, it.Title) + } + // A favourite's star takes the column the selection's arrow does. + mark := " " + if it.Favourite { + mark = "* " + } + m.rows = append(m.rows, mark+row) + m.selectedRows = append(m.selectedRows, "> "+row) + } + // The first row is selected from the start, as it always was. + m.list.Select(max(min(m.list.Selected, len(m.visible)-1), 0)) +} - fn := itemStyle.Render - - if i.Read { - fn = readStyle.Render +// index is the selected row; -1 when the list is empty. +func (m *model) index() int { + if len(m.visible) == 0 { + return -1 } + return m.list.Selected +} - if i.Favourite { - fn = func(s ...string) string { - if i.Read { - return favouriteStyle.Foreground(lipgloss.Color("240")).Render("* " + strings.Join(s, " ")) - } - return favouriteStyle.Render("* " + strings.Join(s, " ")) - } +// selectedItem is the item on the selected row. +func (m *model) selectedItem() (TUIItem, bool) { + i := m.index() + if i < 0 { + return TUIItem{}, false } + return m.items[m.visible[i]], true +} - if index == m.Index() { - fn = func(s ...string) string { - if i.Favourite { - return selectedFavouriteStyle.Foreground(lipgloss.Color(d.theme.SelectedItemColor)).Render("> " + strings.Join(s, " ")) - } - if i.Read { - return selectedReadStyle.Foreground(lipgloss.Color(d.theme.SelectedItemColor)).Render("> " + strings.Join(s, " ")) - } - return selectedItemStyle.Foreground(lipgloss.Color(d.theme.SelectedItemColor)).Render("> " + strings.Join(s, " ")) - } +// removeRow takes the item on row i out of the list. +func (m *model) removeRow(i int) TUIItem { + at := m.visible[i] + it := m.items[at] + m.items = append(m.items[:at], m.items[at+1:]...) + m.applyFilter() + return it +} + +// insertRow puts an item back in the list, on row i. +func (m *model) insertRow(i int, it TUIItem) { + at := len(m.items) + if i < len(m.visible) { + at = m.visible[i] } + m.items = append(m.items[:at], append([]TUIItem{it}, m.items[at:]...)...) + m.applyFilter() +} + +// listRows is the list's rows for the List widget: read items dimmed, +// favourites in bold. +type listRows struct{ m *model } - fmt.Fprint(w, fn(str)) +func (r listRows) Len() int { return len(r.m.rows) } +func (r listRows) ItemAt(i int) string { + if i == r.m.list.Selected { + return r.m.selectedRows[i] + } + return r.m.rows[i] +} +func (r listRows) StyleAt(i int) limoni.Style { + it := r.m.items[r.m.visible[i]] + var s limoni.Style + if it.Read { + s = r.m.readStyle + } + if it.Favourite { + s.Modifier |= limoni.ModifierBold + } + return s } -func (m *model) UpdateList() tea.Cmd { +func (m *model) UpdateList() limoni.Cmd { fs, err := m.commands.GetAllFeeds() if err != nil { - return m.list.NewStatusMessage(fmt.Sprintf("Error: %s", err)) + return m.setStatus(fmt.Sprintf("Error: %s", err)) } - - cmd := m.list.SetItems(convertItems(fs)) - - return cmd + m.setItems(convertItems(fs)) + return nil } -func sortList(m model) func() tea.Msg { - return func() tea.Msg { +func sortList(m *model) limoni.Cmd { + return func(context.Context) limoni.Msg { // reverse sorting order switch m.commands.config.Ordering { case constants.AscendingOrdering: @@ -106,21 +143,19 @@ func sortList(m model) func() tea.Msg { items, err := m.commands.GetAllFeeds() if err != nil { - m.errors = []string{err.Error()} - } - return listUpdate{ - items: convertItems(items), + return statusUpdate{status: err.Error()} } + return listUpdate{items: convertItems(items)} } } type refreshDone struct { - items []list.Item + items []TUIItem errors []string } -func refreshList(m model) func() tea.Msg { - return func() tea.Msg { +func refreshList(m *model) limoni.Cmd { + return func(context.Context) limoni.Msg { var errorItems []ErrorItem es := []string{} var err error @@ -158,226 +193,292 @@ func refreshList(m model) func() tea.Msg { type listUpdate struct { status string - items []list.Item + items []TUIItem } type statusUpdate struct { status string } -func updateList(msg tea.Msg, m model) (tea.Model, tea.Cmd) { - var ( - cmd tea.Cmd - cmds []tea.Cmd - ) +// configEdited is the editor E opened closing. +type configEdited struct{ err error } + +func updateList(msg limoni.Msg, m *model) limoni.UpdateResult { + var cmds []limoni.Cmd switch msg := msg.(type) { case statusUpdate: - cmds = append(cmds, m.list.NewStatusMessage(msg.status)) + cmds = append(cmds, m.setStatus(msg.status)) case refreshDone: m.isRefreshing = false - m.list.Title = defaultTitle - m.list.Styles.Title = m.list.Styles.Title.Width(lipgloss.Width(defaultTitle) + 2) - if !m.list.SettingFilter() { - m.list.SetItems(msg.items) + if !m.filtering { + m.setItems(msg.items) } m.errors = msg.errors - cmds = append(cmds, m.list.NewStatusMessage("Refreshed.")) + cmds = append(cmds, m.setStatus("Refreshed.")) case listUpdate: - if m.isRefreshing { - m.isRefreshing = false + m.isRefreshing = false + if m.filtering { + break } - if m.list.SettingFilter() { + m.setItems(msg.items) + cmds = append(cmds, m.setStatus(msg.status)) + case configEdited: + if msg.err != nil { + cmds = append(cmds, m.setStatus(msg.err.Error())) break } - m.list.SetItems(msg.items) - cmds = append(cmds, m.list.NewStatusMessage(msg.status)) - - case tea.ResumeMsg: - return m, nil + if err := m.cfg.Load(); err != nil { + cmds = append(cmds, m.setStatus(err.Error())) + break + } + m.applyTheme() case tickLoadMsg: if m.isRefreshing { loadchar := []rune{'⏐', '/', 'ー', '\\'} frame := int(msg) nextFrame := (frame + 1) % len(loadchar) - status := fmt.Sprintf("Refreshing... %c", loadchar[frame]) - - cmds = append(cmds, - m.list.NewStatusMessage(status), - m.TickLoad(nextFrame), - ) + m.status = fmt.Sprintf("Refreshing... %c", loadchar[frame]) + cmds = append(cmds, m.TickLoad(nextFrame)) } - case tea.KeyMsg: - switch { - case key.Matches(msg, ListKeyMap.oQuit): - // if help is showing, close help don't quit - if m.list.Help.ShowAll { - m.list.Help.ShowAll = false - return m, m.UpdateList() - } - - case key.Matches(msg, ListKeyMap.Suspend): - return m, tea.Suspend - case key.Matches(msg, ListKeyMap.Refresh): - if m.isRefreshing || m.list.SettingFilter() || m.list.IsFiltered() { - break - } - - m.isRefreshing = true - cmds = append(cmds, refreshList(m), m.TickLoad(0)) + case limoni.KeyPressMsg: + return listKey(m, msg.Key) + case limoni.PasteMsg: + if m.filtering { + m.filter.SetValue(m.filter.Value() + msg.Text) + m.applyFilter() + } + case limoni.MouseWheelMsg, limoni.MousePressMsg: + // The list scrolls and selects with the mouse by itself. + default: + return limoni.UpdateResult{} + } + return redraw(cmds...) +} - case key.Matches(msg, ListKeyMap.Read): - if cmd := markReadList(&m, &cmds); cmd != nil { - cmds = append(cmds, cmd) - } +// listKey handles a key press in the list. +func listKey(m *model, key limoni.KeyEvent) limoni.UpdateResult { + k := listKeys + name := keyName(key) - case key.Matches(msg, ListKeyMap.ToggleReads): - if m.list.SettingFilter() { - break - } - - m.commands.config.ToggleShowRead() - cmds = append(cmds, m.UpdateList()) + if k.ForceQuit.matches(name) { + return limoni.UpdateResult{Quit: true} + } + if m.filtering { + switch { + case k.CancelFilter.matches(name): + m.filtering, m.filterTerm = false, "" + m.filter.SetValue("") + case k.Accept.matches(name): + m.filtering = false + m.filterTerm = m.filter.Value() + default: + m.filter.HandleKey(key) + } + m.applyFilter() + return redraw() + } - case key.Matches(msg, ListKeyMap.MarkAllRead): - if m.list.SettingFilter() { - break - } + var cmds []limoni.Cmd + switch { + case m.fullHelp && (k.CloseFullHelp.matches(name) || k.Quit.matches(name)): + // if help is showing, close help don't quit + m.fullHelp = false + + case m.filterTerm != "" && k.ClearFilter.matches(name): + m.filterTerm = "" + m.filter.SetValue("") + m.applyFilter() + + case k.Quit.matches(name): + return limoni.UpdateResult{Quit: true} + + case k.ShowFullHelp.matches(name): + m.fullHelp = true + + case k.Filter.matches(name): + // Filtering starts from the top, where the best match will be. + m.filtering = true + m.filter.SetValue(m.filterTerm) + m.list.Select(0) + m.list.Offset = 0 + + case k.Up.matches(name): + m.list.Select(max(m.list.Selected-1, 0)) + case k.Down.matches(name): + m.list.Select(min(m.list.Selected+1, max(len(m.visible)-1, 0))) + case k.PrevPage.matches(name): + m.list.Select(max(m.list.Selected-max(m.listH, 1), 0)) + case k.NextPage.matches(name): + m.list.Select(min(m.list.Selected+max(m.listH, 1), max(len(m.visible)-1, 0))) + case k.Top.matches(name): + m.list.Select(0) + case k.Bottom.matches(name): + m.list.Select(max(len(m.visible)-1, 0)) + + case k.Suspend.matches(name): + return redraw(limoni.SuspendCmd()) + + case k.Refresh.matches(name): + if m.isRefreshing || m.filterTerm != "" { + break + } + m.isRefreshing = true + cmds = append(cmds, refreshList(m), m.TickLoad(0)) - m.commands.store.MarkAllRead() - cmds = append(cmds, m.UpdateList()) + case k.Read.matches(name): + if cmd := markReadList(m); cmd != nil { + cmds = append(cmds, cmd) + } - case key.Matches(msg, ListKeyMap.Favourite): - if m.list.SettingFilter() { - break - } + case k.ToggleReads.matches(name): + m.commands.config.ToggleShowRead() + cmds = append(cmds, m.UpdateList()) - if len(m.list.Items()) == 0 { - return m, m.list.NewStatusMessage("No items to favourite.") - } + case k.MarkAllRead.matches(name): + m.commands.store.MarkAllRead() + cmds = append(cmds, m.UpdateList()) - item := m.list.SelectedItem() - if item == nil { - return m, m.list.NewStatusMessage("No item selected.") - } - - current := item.(TUIItem) - err := m.commands.store.ToggleFavourite(current.ID) - if err != nil { - return m, m.list.NewStatusMessage(fmt.Sprintf("Error toggling favourite: %s", err)) - } + case k.Favourite.matches(name): + current, ok := m.selectedItem() + if !ok { + return redraw(m.setStatus("No items to favourite.")) + } + if err := m.commands.store.ToggleFavourite(current.ID); err != nil { + return redraw(m.setStatus(fmt.Sprintf("Error toggling favourite: %s", err))) + } + cmds = append(cmds, m.UpdateList()) - cmds = append(cmds, m.UpdateList()) + case k.ToggleFavourites.matches(name): + if m.commands.config.ShowFavourites { + cmds = append(cmds, m.setStatus("")) + } else { + cmds = append(cmds, m.setStatus("favourites")) + } + m.commands.config.ToggleShowFavourites() + cmds = append(cmds, m.UpdateList()) - case key.Matches(msg, ListKeyMap.ToggleFavourites): - if m.list.SettingFilter() { - break + case k.OpenInBrowser.matches(name): + current, ok := m.selectedItem() + if !ok { + return redraw(m.setStatus("No link selected.")) + } + cmds = append(cmds, m.setStatus("Opening..."), m.OpenLink(current.URL)) + if !current.Read && m.commands.config.AutoRead { + if cmd := markReadList(m); cmd != nil { + cmds = append(cmds, cmd) } + } - if m.commands.config.ShowFavourites { - m.list.NewStatusMessage("") - } else { - m.list.NewStatusMessage("favourites") - } + case k.Sort.matches(name): + if m.filterTerm != "" { + break + } + if len(m.items) == 0 { + return redraw(m.setStatus("No items to sort.")) + } + cmds = append(cmds, sortList(m)) - m.commands.config.ToggleShowFavourites() + case k.Open.matches(name): + current, ok := m.selectedItem() + if !ok { + break + } + id := current.ID + m.selectedArticle = &id + cmds = append(cmds, m.openArticle()...) + if m.selectedArticle != nil { cmds = append(cmds, m.UpdateList()) + } - case key.Matches(msg, ViewportKeyMap.OpenInBrowser): - cmds = append(cmds, m.list.NewStatusMessage("Opening...")) - if m.list.SettingFilter() { - break - } - - item := m.list.SelectedItem() - if item == nil { - return m, m.list.NewStatusMessage("No link selected.") - } - - current := item.(TUIItem) - cmd = m.OpenLink(current.URL) - cmds = append(cmds, cmd) - if !current.Read && m.commands.config.AutoRead { - if cmd := markReadList(&m, &cmds); cmd != nil { - cmds = append(cmds, cmd) - } - } - - case key.Matches(msg, ListKeyMap.Sort): - if m.list.SettingFilter() || m.list.IsFiltered() { - break - } - - if len(m.list.Items()) == 0 { - return m, m.list.NewStatusMessage("No items to sort.") - } - - cmds = append(cmds, sortList(m)) - - case key.Matches(msg, ListKeyMap.Open): - if m.list.SettingFilter() { - m.list.FilterInput.Blur() - break - } - - i, ok := m.list.SelectedItem().(TUIItem) - if ok { - m.selectedArticle = &i.ID - - m.viewport.GotoTop() - - content, err := m.commands.GetGlamourisedArticle(*m.selectedArticle) - if err != nil { - m.selectedArticle = nil - return m, m.list.NewStatusMessage(fmt.Sprintf("Error opening article: %s", err)) - } - - m.viewport.SetContent(content) - - cmds = append(cmds, m.UpdateList()) - } - - case key.Matches(msg, ListKeyMap.EditConfig): - if m.list.SettingFilter() { - break - } + case k.EditConfig.matches(name): + cmd := strings.Split(getEditor("NOMEDITOR", "VISUAL", "EDITOR"), " ") + cmd = append(cmd, m.cfg.ConfigPath) + execCmd := exec.Command(cmd[0], cmd[1:]...) + return redraw(limoni.ExecCmd(execCmd, func(err error) limoni.Msg { + return configEdited{err} + })) - filePath := m.cfg.ConfigPath - - cmd := strings.Split(getEditor("NOMEDITOR", "VISUAL", "EDITOR"), " ") - cmd = append(cmd, filePath) - - execCmd := exec.Command(cmd[0], cmd[1:]...) - return m, tea.ExecProcess(execCmd, func(err error) tea.Msg { - if err != nil { - m.list.NewStatusMessage(err.Error()) - return nil - } - - err = m.cfg.Load() - if err != nil { - m.list.NewStatusMessage(err.Error()) - return nil - } - return nil - }) - } + default: + return limoni.UpdateResult{} } - m.list, cmd = m.list.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) + return redraw(cmds...) } -func listView(m model) string { +// statusText is what the status line says: the first error, the applied +// filter, or the last message. +func (m *model) statusText() string { if len(m.errors) > 0 { - m.list.NewStatusMessage(m.errors[0]) - } else if m.list.IsFiltered() { - m.list.NewStatusMessage("filtering: " + m.list.FilterInput.Value()) + return m.errors[0] + } + if m.filterTerm != "" && !m.filtering && m.status == "" { + return "filtering: " + m.filterTerm + } + return m.status +} + +func listView(m *model, f *limoni.Frame) { + area := f.Area() + if area.Width < 8 || area.Height < 5 { + return + } + help := []string{m.listShortHelp()} + if m.fullHelp { + help = m.listFullHelp() + } + help = append(help, "") // a blank row below, as the help always had + body := drawHelp(f, area, help) + if body.Height > 0 { + body.Height-- // and one above + } + + // The title, or the filter being typed, with the status beside it. + titleRow := limoni.NewRect(area.X+2, area.Y+1, area.Width-2, 1) + if m.filtering { + f.RenderWidget(&limoni.Paragraph{Text: "Filter: ", Style: m.filterStyle}, limoni.NewRect(titleRow.X, titleRow.Y, 8, 1)) + f.RenderWidget(&widgets.TextInput{ID: "filter", State: m.filter, Focused: true}, + limoni.NewRect(titleRow.X+8, titleRow.Y, titleRow.Width-8, 1)) + } else { + title := " " + defaultTitle + " " + f.RenderWidget(&limoni.Paragraph{ID: "title", Text: title, Style: m.titleStyle}, limoni.NewRect(titleRow.X, titleRow.Y, uint16(len(title)), 1)) + status := m.statusText() + x := titleRow.X + uint16(len(title)) + 2 + f.RenderWidget(&limoni.Paragraph{ID: "status", Text: status, Style: limoni.Style{Fg: limoni.Hex("#04B575")}}, + limoni.NewRect(x, titleRow.Y, area.Width-min(x, area.Width), 1)) } - return "\n" + m.list.View() + // The items, and where the selection is among them. + top := area.Y + 3 + // The list, a blank row, and the position under it. + if body.Y+body.Height <= top+2 { + return + } + listArea := limoni.NewRect(area.X+2, top, area.Width-2, body.Y+body.Height-top-2) + focus := "items" + if m.filtering { + focus = "filter" + } + f.FocusManager.SetFocused(focus) + m.listH = int(listArea.Height) + if len(m.visible) == 0 { + empty := "No items." + if m.filterTerm != "" || m.filtering { + empty = "Nothing matched." + } + f.RenderWidget(&limoni.Paragraph{Text: empty, Style: m.readStyle}, limoni.NewRect(listArea.X+2, listArea.Y, listArea.Width-2, 1)) + return + } + f.RenderWidget(&widgets.List{ + ID: "items", + Label: "Items", + Provider: listRows{m}, + State: m.list, + SelectedStyle: m.selectedStyle, + }, listArea) + position := fmt.Sprintf("%d/%d", m.list.Selected+1, len(m.visible)) + f.RenderWidget(&limoni.Paragraph{ID: "position", Text: position, Style: m.readStyle}, + limoni.NewRect(area.X+4, listArea.Y+listArea.Height+1, area.Width-min(4, area.Width), 1)) } func getEditor(vars ...string) string { @@ -391,26 +492,13 @@ func getEditor(vars ...string) string { return "nano" } -func markReadList(m *model, cmds *[]tea.Cmd) tea.Cmd { - if m.list.SettingFilter() { - return nil - } - - if len(m.list.Items()) == 0 { - return m.list.NewStatusMessage("No items to mark.") - } - - item := m.list.SelectedItem() - if item == nil { - return m.list.NewStatusMessage("No item selected.") +func markReadList(m *model) limoni.Cmd { + current, ok := m.selectedItem() + if !ok { + return m.setStatus("No items to mark.") } - - current := item.(TUIItem) - err := m.commands.store.ToggleRead(current.ID) - if err != nil { - return m.list.NewStatusMessage(fmt.Sprintf("Error marking read: %s", err)) + if err := m.commands.store.ToggleRead(current.ID); err != nil { + return m.setStatus(fmt.Sprintf("Error marking read: %s", err)) } - - *cmds = append(*cmds, m.UpdateList()) - return nil + return m.UpdateList() } diff --git a/internal/commands/theme.go b/internal/commands/theme.go new file mode 100644 index 0000000..74cc175 --- /dev/null +++ b/internal/commands/theme.go @@ -0,0 +1,221 @@ +package commands + +import ( + "encoding/json" + "log" + "os" + "strconv" + "strings" + + "github.com/thebanri/limoni" + "github.com/thebanri/limoni/widgets" + + "github.com/guyfedwards/nom/v2/internal/config" +) + +// parseColor reads a colour the way the config always has: an ANSI 256 +// number ("62") or a hex value ("#5A56E0"). Anything else is the terminal's +// default colour. +func parseColor(s string) limoni.Color { + s = strings.TrimSpace(s) + if s == "" { + return limoni.ColorDefault + } + if strings.HasPrefix(s, "#") { + return limoni.Hex(s) + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 || n > 255 { + return limoni.ColorDefault + } + return limoni.ANSI(uint8(n)) +} + +// glamourBlock is the part of a glamour style entry the article view reads. +type glamourBlock struct { + Color *string `json:"color"` + BackgroundColor *string `json:"background_color"` + Bold *bool `json:"bold"` + Italic *bool `json:"italic"` + Underline *bool `json:"underline"` +} + +// glamourStyle is the part of a glamour style file the article view reads: +// the colours. Margins, prefixes and the chroma theme have no equivalent. +type glamourStyle struct { + Document glamourBlock `json:"document"` + Heading glamourBlock `json:"heading"` + H1 glamourBlock `json:"h1"` + H2 glamourBlock `json:"h2"` + H3 glamourBlock `json:"h3"` + H4 glamourBlock `json:"h4"` + H5 glamourBlock `json:"h5"` + H6 glamourBlock `json:"h6"` + Link glamourBlock `json:"link"` + LinkText glamourBlock `json:"link_text"` + Code glamourBlock `json:"code"` + CodeBlock glamourBlock `json:"code_block"` + BlockQuote glamourBlock `json:"block_quote"` + HR glamourBlock `json:"hr"` + ImageText glamourBlock `json:"image_text"` +} + +func str(s string) *string { return &s } +func yes() *bool { b := true; return &b } + +// The glamour styles nom has always offered, by the colours glamour v0.10 +// gives them. +var glamourStyles = map[string]glamourStyle{ + "dark": { + Document: glamourBlock{Color: str("252")}, + Heading: glamourBlock{Color: str("39"), Bold: yes()}, + H1: glamourBlock{Color: str("228"), BackgroundColor: str("63"), Bold: yes()}, + Link: glamourBlock{Color: str("30"), Underline: yes()}, + LinkText: glamourBlock{Color: str("35"), Bold: yes()}, + Code: glamourBlock{Color: str("203"), BackgroundColor: str("236")}, + HR: glamourBlock{Color: str("240")}, + ImageText: glamourBlock{Color: str("243")}, + }, + "light": { + Document: glamourBlock{Color: str("234")}, + Heading: glamourBlock{Color: str("27"), Bold: yes()}, + H1: glamourBlock{Color: str("228"), BackgroundColor: str("63"), Bold: yes()}, + Link: glamourBlock{Color: str("36"), Underline: yes()}, + LinkText: glamourBlock{Color: str("29"), Bold: yes()}, + Code: glamourBlock{Color: str("203"), BackgroundColor: str("254")}, + HR: glamourBlock{Color: str("249")}, + ImageText: glamourBlock{Color: str("243")}, + }, + "dracula": { + Document: glamourBlock{Color: str("#f8f8f2")}, + Heading: glamourBlock{Color: str("#bd93f9"), Bold: yes()}, + Link: glamourBlock{Color: str("#8be9fd"), Underline: yes()}, + LinkText: glamourBlock{Color: str("#ff79c6")}, + Code: glamourBlock{Color: str("#50fa7b")}, + BlockQuote: glamourBlock{Color: str("#f1fa8c"), Italic: yes()}, + HR: glamourBlock{Color: str("#6272A4")}, + ImageText: glamourBlock{Color: str("#ff79c6")}, + }, + "pink": { + Heading: glamourBlock{Color: str("212"), Bold: yes()}, + Link: glamourBlock{Color: str("99"), Underline: yes()}, + LinkText: glamourBlock{Bold: yes()}, + Code: glamourBlock{Color: str("212"), BackgroundColor: str("236")}, + HR: glamourBlock{Color: str("212")}, + ImageText: glamourBlock{Underline: yes()}, + }, + // ascii and notty are glamour's styles without colour. + "ascii": {}, + "notty": {}, +} + +// over is s with every block o sets laid over it. +func (s glamourStyle) over(o glamourStyle) glamourStyle { + s.Document = s.Document.over(o.Document) + s.Heading = s.Heading.over(o.Heading) + s.H1 = s.H1.over(o.H1) + s.H2 = s.H2.over(o.H2) + s.H3 = s.H3.over(o.H3) + s.H4 = s.H4.over(o.H4) + s.H5 = s.H5.over(o.H5) + s.H6 = s.H6.over(o.H6) + s.Link = s.Link.over(o.Link) + s.LinkText = s.LinkText.over(o.LinkText) + s.Code = s.Code.over(o.Code) + s.CodeBlock = s.CodeBlock.over(o.CodeBlock) + s.BlockQuote = s.BlockQuote.over(o.BlockQuote) + s.HR = s.HR.over(o.HR) + s.ImageText = s.ImageText.over(o.ImageText) + return s +} + +func (b glamourBlock) style() limoni.Style { + var s limoni.Style + if b.Color != nil { + s.Fg = parseColor(*b.Color) + } + if b.BackgroundColor != nil { + s.Bg = parseColor(*b.BackgroundColor) + } + if b.Bold != nil && *b.Bold { + s.Modifier |= limoni.ModifierBold + } + if b.Italic != nil && *b.Italic { + s.Modifier |= limoni.ModifierItalic + } + if b.Underline != nil && *b.Underline { + s.Modifier |= limoni.ModifierUnderline + } + return s +} + +// over is b with the fields o sets taken from o: a glamour level style +// (h2) refines the shared heading style. +func (b glamourBlock) over(o glamourBlock) glamourBlock { + if o.Color != nil { + b.Color = o.Color + } + if o.BackgroundColor != nil { + b.BackgroundColor = o.BackgroundColor + } + if o.Bold != nil { + b.Bold = o.Bold + } + if o.Italic != nil { + b.Italic = o.Italic + } + if o.Underline != nil { + b.Underline = o.Underline + } + return b +} + +// articleTheme is the article view's colours for the configured theme: +// theme.glamour names one of glamour's styles, or "custom" a glamour style +// file at theme.customPath, whose colours are read. The first heading is the +// title, in titleColor and titleColorFg as before. +func articleTheme(theme config.Theme) (widgets.MarkdownTheme, limoni.Style) { + gs, ok := glamourStyles[theme.Glamour] + if theme.Glamour == "custom" { + // Read into a style of its own and lay it over dark: decoding into a + // copy of dark would write through the pointers it shares with it. + gs = glamourStyles["dark"] + var custom glamourStyle + data, err := os.ReadFile(theme.CustomPath) + if err != nil { + log.Println(err) + } else if err := json.Unmarshal(data, &custom); err != nil { + log.Println(err) + } else { + gs = gs.over(custom) + } + } else if !ok { + gs = glamourStyles["dark"] + } + + t := widgets.MarkdownTheme{ + Link: gs.Link.over(gs.LinkText).style(), + Code: gs.Code.style(), + CodeBlock: limoni.Style{Bg: gs.CodeBlock.style().Bg}, + Quote: gs.BlockQuote.style(), + Rule: gs.HR.style(), + Image: gs.ImageText.style(), + Bullet: gs.Document.style(), + } + for i, h := range []glamourBlock{gs.H1, gs.H2, gs.H3, gs.H4, gs.H5, gs.H6} { + t.Headings[i] = gs.Heading.over(h).style() + } + if theme.Glamour == "ascii" || theme.Glamour == "notty" { + for i := range t.Headings { + t.Headings[i] = limoni.Style{Modifier: limoni.ModifierBold} + } + t.Link = limoni.Style{Modifier: limoni.ModifierUnderline} + return t, limoni.Style{} + } + t.Headings[0] = limoni.Style{ + Fg: parseColor(theme.TitleColorFg), + Bg: parseColor(theme.TitleColor), + Modifier: limoni.ModifierBold, + } + return t, gs.Document.style() +} diff --git a/internal/commands/theme_test.go b/internal/commands/theme_test.go new file mode 100644 index 0000000..38d18ff --- /dev/null +++ b/internal/commands/theme_test.go @@ -0,0 +1,68 @@ +package commands + +import ( + "os" + "path/filepath" + "testing" + + "github.com/thebanri/limoni" + + "github.com/guyfedwards/nom/v2/internal/config" +) + +// Colours are read as they always were: ANSI 256 numbers and hex values. +func TestParseColor(t *testing.T) { + for in, want := range map[string]limoni.Color{ + "62": limoni.ANSI(62), + "#5A56E0": limoni.Hex("#5A56E0"), + "": limoni.ColorDefault, + "blue": limoni.ColorDefault, + "300": limoni.ColorDefault, + } { + if got := parseColor(in); got != want { + t.Errorf("parseColor(%q) = %v, want %v", in, got, want) + } + } +} + +// The first heading is the article's title, in titleColor and titleColorFg; +// the other headings and the links take the glamour style's colours. +func TestArticleThemeFromGlamourStyles(t *testing.T) { + theme := config.DefaultTheme + mt, doc := articleTheme(theme) + if mt.Headings[0].Bg != limoni.ANSI(62) || mt.Headings[0].Fg != limoni.ANSI(231) { + t.Errorf("the title is %+v, want titleColorFg on titleColor", mt.Headings[0]) + } + if mt.Headings[1].Fg != limoni.ANSI(39) || mt.Link.Fg != limoni.ANSI(35) || doc.Fg != limoni.ANSI(252) { + t.Errorf("dark: heading %v, link %v, text %v", mt.Headings[1].Fg, mt.Link.Fg, doc.Fg) + } + + theme.Glamour = "dracula" + if mt, _ := articleTheme(theme); mt.Headings[2].Fg != limoni.Hex("#bd93f9") { + t.Errorf("dracula heading %v", mt.Headings[2].Fg) + } + theme.Glamour = "notty" + if mt, doc := articleTheme(theme); mt.Headings[0].Bg != limoni.ColorDefault || doc != (limoni.Style{}) { + t.Errorf("notty has colour: title %+v, text %+v", mt.Headings[0], doc) + } +} + +// "custom" reads the colours from a glamour style file; one that cannot be +// read falls back to dark. +func TestArticleThemeCustom(t *testing.T) { + path := filepath.Join(t.TempDir(), "style.json") + if err := os.WriteFile(path, []byte(`{"heading": {"color": "#ff0000", "bold": true}, "link_text": {"color": "#00ff00"}}`), 0o644); err != nil { + t.Fatal(err) + } + theme := config.DefaultTheme + theme.Glamour, theme.CustomPath = "custom", path + mt, _ := articleTheme(theme) + if mt.Headings[1].Fg != limoni.Hex("#ff0000") || mt.Link.Fg != limoni.Hex("#00ff00") { + t.Errorf("custom: heading %v, link %v", mt.Headings[1].Fg, mt.Link.Fg) + } + + theme.CustomPath = filepath.Join(t.TempDir(), "missing.json") + if mt, _ := articleTheme(theme); mt.Headings[1].Fg != limoni.ANSI(39) { + t.Errorf("a missing custom file did not fall back to dark: %v", mt.Headings[1].Fg) + } +} diff --git a/internal/commands/tui.go b/internal/commands/tui.go index 19036b1..fd525cc 100644 --- a/internal/commands/tui.go +++ b/internal/commands/tui.go @@ -1,6 +1,7 @@ package commands import ( + "context" "fmt" "log" "os" @@ -10,12 +11,8 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "golang.org/x/term" + "github.com/thebanri/limoni" + "github.com/thebanri/limoni/widgets" "github.com/guyfedwards/nom/v2/internal/config" "github.com/guyfedwards/nom/v2/internal/store" @@ -23,11 +20,8 @@ import ( const defaultTitle = "nom" -var ( - appStyle = lipgloss.NewStyle().Padding(1, 0, 0, 0).Margin(0) - titleStyle = list.DefaultStyles().Title.Margin(0).Width(5) - paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) -) +// statusLifetime is how long a status message stays. +const statusLifetime = time.Second type TUIItem struct { Title string @@ -44,103 +38,234 @@ func (i TUIItem) FilterValue() string { } type model struct { + cfg *config.Config + commands *Commands + errors []string + + // The list: every item, the ones the filter lets through (indices + // into items, in the filter's order), and their rows as drawn. + items []TUIItem + visible []int + rows []string // as drawn: a favourite's star, or room for the arrow + // selectedRows are the rows as drawn when selected, behind the arrow. + selectedRows []string + list *widgets.ListState + listH int // rows the list had when last drawn: a page + + filter *widgets.TextInputState + filtering bool // the filter is being typed + filterTerm string // the filter applied, "" for none + + status string + statusExpiry int // which status message an expiry belongs to + isRefreshing bool + fullHelp bool + + // The article: which, its text, and how far it is scrolled. selectedArticle *int - cfg *config.Config - commands *Commands - errors []string - list list.Model - help help.Model - viewport viewport.Model - lastRead *list.Item - lastReadIndex int - isRefreshing bool + article *widgets.Markdown + articleOffset int + articleH int + images *images + + lastRead *TUIItem + lastReadIndex int + + titleStyle, selectedStyle, readStyle, filterStyle limoni.Style } -func (m model) Init() tea.Cmd { - return nil +func newModel(items []TUIItem, cmds *Commands, errors []string, cfg *config.Config) *model { + m := &model{ + cfg: cfg, + commands: cmds, + errors: errors, + list: widgets.NewListState(), + filter: widgets.NewTextInputState(), + } + m.article = widgets.NewMarkdown("").WithID("article").WithScrollOffset(&m.articleOffset) + m.applyTheme() + if cfg.ShowImages() { + m.images = newImages(cfg.HTTPClient()) + m.article.Images = m.images.get + } + m.setItems(items) + return m } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // resize all views regardless of which is showing to keep consistent - // when switching - switch msg := msg.(type) { - case tea.WindowSizeMsg: - x, y := appStyle.GetFrameSize() +// applyTheme reads the colours from the config, which E can change. +func (m *model) applyTheme() { + theme := m.cfg.Theme + m.titleStyle = limoni.Style{Fg: parseColor(theme.TitleColorFg), Bg: parseColor(theme.TitleColor)} + m.selectedStyle = limoni.Style{Fg: parseColor(theme.SelectedItemColor)} + m.readStyle = limoni.Style{Fg: limoni.ANSI(240)} + m.filterStyle = limoni.Style{Fg: parseColor(theme.FilterColor)} + mt, doc := articleTheme(theme) + m.article.Theme = &mt + m.article.Style = doc +} - m.list.SetSize(msg.Width-x, msg.Height-y) +func (m *model) Init() []limoni.Cmd { return nil } - m.viewport.Width = msg.Width - x - footerHeight := lipgloss.Height(m.viewportHelp()) - m.viewport.Height = msg.Height - footerHeight +// statusExpired clears a status message once it has been shown long enough. +type statusExpired int - return m, nil +// setStatus shows a message for a second, as the list always did. +func (m *model) setStatus(s string) limoni.Cmd { + m.status = s + m.statusExpiry++ + n := m.statusExpiry + return func(ctx context.Context) limoni.Msg { + select { + case <-time.After(statusLifetime): + case <-ctx.Done(): + } + return statusExpired(n) } +} +func (m *model) Update(msg limoni.Msg) limoni.UpdateResult { + switch msg := msg.(type) { + case statusExpired: + if int(msg) == m.statusExpiry && !m.isRefreshing { + m.status = "" + } + return redraw() + case imageLoaded: + if m.images != nil { + m.images.arrived(msg) + } + return redraw() + } if m.selectedArticle != nil { return updateViewport(msg, m) } - return updateList(msg, m) } -func (m model) View() string { - var s string +func redraw(cmds ...limoni.Cmd) limoni.UpdateResult { + return limoni.UpdateResult{Redraw: true, Commands: cmds} +} +func (m *model) View(f *limoni.Frame) { if m.selectedArticle == nil { - s = listView(m) + listView(m, f) } else { - s = viewportView(m) + viewportView(m, f) + } +} + +// The help's colours, as the help always had them on a dark background. +var ( + helpDescStyle = limoni.Style{Fg: limoni.Hex("#4A4A4A")} + helpKeyStyle = limoni.Style{Fg: limoni.Hex("#626262")} + helpSepStyle = limoni.Style{Fg: limoni.Hex("#3C3C3C")} +) + +// drawHelp draws the help at the bottom of area and returns the rows above +// it. The text is one Paragraph, which is what a screen reader or an agent +// reads; the keys are then drawn over it a shade lighter, as before. +func drawHelp(f *limoni.Frame, area limoni.Rect, lines []string) limoni.Rect { + h := uint16(min(len(lines), int(area.Height))) + if h == 0 { + return area } + box := limoni.NewRect(area.X+4, area.Y+area.Height-h, area.Width-min(4, area.Width), h) + f.RenderWidget(&limoni.Paragraph{ID: "help", Text: strings.Join(lines, "\n"), Style: helpDescStyle}, box) + for row, line := range lines[:h] { + if limoni.StringWidth(line) > int(box.Width) { + continue // wrapped: the columns are not where they were written + } + y := box.Y + uint16(row) + col := 0 + for _, field := range helpFields(line) { + x := box.X + uint16(col+field.at) + if field.sep { + f.Buffer.SetString(x, y, field.text, helpSepStyle) + } else { + f.Buffer.SetString(x, y, field.text, helpKeyStyle) + } + } + } + return limoni.NewRect(area.X, area.Y, area.Width, area.Height-h) +} + +// helpField is a key, or a separator, in a line of help, and its column. +type helpField struct { + text string + at int + sep bool +} - return appStyle.Render(s) +// helpFields finds the keys in a line of help — the first word of each +// entry, entries being separated by " • " or by a gap between columns — and +// the separators. +func helpFields(line string) []helpField { + var fields []helpField + col, start := 0, true + for i, r := range line { + switch { + case r == '•': + fields = append(fields, helpField{text: "•", at: col, sep: true}) + start = true + case r == ' ': + if i+1 < len(line) && line[i+1] == ' ' { + start = true + } + default: + if start { + end := strings.IndexByte(line[i:], ' ') + if end < 0 { + end = len(line) - i + } + fields = append(fields, helpField{text: line[i : i+end], at: col}) + start = false + } + } + col += limoni.RuneWidth(r) + } + return fields } -func (m model) OpenLink(url string) tea.Cmd { - hasOpener := false +func (m *model) OpenLink(url string) limoni.Cmd { for _, o := range m.cfg.Openers { match, err := regexp.MatchString(o.Regex, url) if err != nil { log.Printf("[tui.go] OpenLink: invalid regex pattern: %v", err) continue } + if !match { + continue + } + cmdStr := fmt.Sprintf(o.Cmd, url) + parts := strings.Fields(cmdStr) + cmd := exec.Command(parts[0], parts[1:]...) - if match { - hasOpener = true - cmdStr := fmt.Sprintf(o.Cmd, url) - parts := strings.Fields(cmdStr) - cmd := exec.Command(parts[0], parts[1:]...) - - if o.Takeover { - return tea.ExecProcess(cmd, func(err error) tea.Msg { + if o.Takeover { + return limoni.ExecCmd(cmd, func(err error) limoni.Msg { + if err != nil { log.Println("OpenLink: takeover exec:", err) - return nil - }) - } else { - return func() tea.Msg { - if err := cmd.Run(); err != nil { - log.Println("OpenLink: exec: ", err) - return statusUpdate{ - status: err.Error(), - } - } - return nil + return statusUpdate{status: err.Error()} } + return nil + }) + } + return func(context.Context) limoni.Msg { + if err := cmd.Run(); err != nil { + log.Println("OpenLink: exec: ", err) + return statusUpdate{status: err.Error()} } + return nil } } // if no opener, default to browser - if !hasOpener { - err := m.OpenInBrowser(url) - if err != nil { - log.Println(err) - } + if err := m.OpenInBrowser(url); err != nil { + log.Println(err) } - return nil } -func (m model) OpenInBrowser(url string) error { +func (m *model) OpenInBrowser(url string) error { var cmd string var args []string @@ -170,10 +295,14 @@ func (m model) OpenInBrowser(url string) error { type tickLoadMsg int -func (m model) TickLoad(frame int) tea.Cmd { - return tea.Tick(time.Millisecond*150, func(t time.Time) tea.Msg { +func (m *model) TickLoad(frame int) limoni.Cmd { + return func(ctx context.Context) limoni.Msg { + select { + case <-time.After(150 * time.Millisecond): + case <-ctx.Done(): + } return tickLoadMsg(frame) - }) + } } func ItemToTUIItem(i store.Item) TUIItem { @@ -189,14 +318,15 @@ func ItemToTUIItem(i store.Item) TUIItem { } func (c *Commands) TUI() error { - debug := os.Getenv("DEBUGNOM") - if debug != "" { - f, err := tea.LogToFile(debug, "debug") + if debug := os.Getenv("DEBUGNOM"); debug != "" { + f, err := os.OpenFile(debug, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o600) if err != nil { fmt.Println("fatal:", err) os.Exit(1) } defer f.Close() + log.SetOutput(f) + log.SetPrefix("debug ") } its, err := c.GetAllFeeds() @@ -218,63 +348,23 @@ func (c *Commands) TUI() error { } } - items := convertItems(its) - es := []string{} for _, e := range errorItems { es = append(es, fmt.Sprintf("Error fetching %s: %s", e.FeedURL, e.Err)) } - prog, err := Render(items, c, es, c.config) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + term, err := limoni.New() if err != nil { return fmt.Errorf("commands.TUI: %w", err) } + defer term.Close() + prog := limoni.NewProgram(newModel(convertItems(its), c, es, c.config)) + c.Monitor(ctx, func(msg limoni.Msg) { _ = prog.Send(ctx, msg) }) - c.Monitor(prog) - - if _, err := prog.Run(); err != nil { + if err := prog.RunTerminal(ctx, term, term.Backend()); err != nil { return fmt.Errorf("tui.Render: %w", err) } - return nil } - -func Render(items []list.Item, cmds *Commands, errors []string, cfg *config.Config) (*tea.Program, error) { - const defaultWidth = 20 - _, ts, _ := term.GetSize(int(os.Stdout.Fd())) - _, y := appStyle.GetFrameSize() - height := ts - y - - appStyle.Height(height) - - l := list.New(items, itemDelegate{theme: cfg.Theme}, defaultWidth, height) - l.SetShowStatusBar(false) - l.Title = defaultTitle - l.Styles.Title = titleStyle. - Background(lipgloss.Color(cfg.Theme.TitleColor)). - Foreground(lipgloss.Color(cfg.Theme.TitleColorFg)) - - l.Styles.PaginationStyle = paginationStyle - l.Styles.HelpStyle = helpStyle - - l.FilterInput.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.Theme.FilterColor)) - - l.Filter = CustomFilter(*cfg) - - ListKeyMap.SetOverrides(&l) - - vp := viewport.New(78, height) - - m := model{ - cfg: cfg, - commands: cmds, - errors: errors, - help: help.New(), - list: l, - viewport: vp, - } - - prog := tea.NewProgram(m, tea.WithAltScreen()) - - return prog, nil -} diff --git a/internal/commands/tui_test.go b/internal/commands/tui_test.go new file mode 100644 index 0000000..fce1bd2 --- /dev/null +++ b/internal/commands/tui_test.go @@ -0,0 +1,235 @@ +package commands + +import ( + "bytes" + "image" + "image/png" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/thebanri/limoni/uitest" + + "github.com/guyfedwards/nom/v2/internal/config" + "github.com/guyfedwards/nom/v2/internal/store" +) + +const testFeed = "https://blog.example/feed.xml" + +// newTestTUI is the TUI over an in-memory store holding items, run through +// Limoni's real message loop on an 80×24 terminal. Tests drive it as a user +// does, and read it through its semantic tree: the list's rows, the article's +// text, the status line. +func newTestTUI(t *testing.T, cfg *config.Config, items ...store.Item) (*uitest.Page, *Commands) { + t.Helper() + s, err := store.NewInMemorySQLiteStore() + if err != nil { + t.Fatal(err) + } + now := time.Now() + for i := range items { + items[i].FeedURL = testFeed + if items[i].Link == "" { + items[i].Link = "https://blog.example/" + strings.ReplaceAll(strings.ToLower(items[i].Title), " ", "-") + } + items[i].PublishedAt = now.Add(-time.Duration(i) * time.Hour) + items[i].CreatedAt = items[i].PublishedAt + if err := s.UpsertItem(&items[i]); err != nil { + t.Fatal(err) + } + } + if cfg == nil { + cfg = &config.Config{} + } + cfg.Feeds = []config.Feed{{URL: testFeed, Name: "Blog"}} + cfg.Theme = config.DefaultTheme + if cfg.Ordering == "" { + cfg.Ordering = "desc" + } + cmds := New(cfg, s) + all, err := cmds.GetAllFeeds() + if err != nil { + t.Fatal(err) + } + return uitest.Program(t, 80, 24, newModel(convertItems(all), cmds, nil, cfg)), cmds +} + +func rows(page *uitest.Page) uitest.Locator { + return page.GetByRole("list-item", "").Within(page.GetByID("items")) +} + +func threeItems() []store.Item { + return []store.Item{ + {Title: "First post", Content: "
Hello from the first post.
"}, + {Title: "Second post", Content: "The second one.
"}, + {Title: "Third post", Content: "And a third.
"}, + } +} + +// The list shows the items newest first, as "n. Feed: Title"; Enter opens +// the selected one, and Esc goes back to the list. +func TestTUIOpensAnArticleFromTheList(t *testing.T) { + page, _ := newTestTUI(t, nil, threeItems()...) + + page.Expect(rows(page)).ToHaveCount(3) + page.Expect(rows(page).Nth(0)).ToHaveLabel("> 1. Blog: First post") + page.Expect(rows(page).Nth(1)).ToHaveLabel(" 2. Blog: Second post") + page.Expect(rows(page).Nth(0)).ToBeSelected() + + page.Press("j") + page.Expect(rows(page).Nth(1)).ToBeSelected() + page.Press("enter") + page.Expect(page.GetByID("article")).ToContainValue("Second post") + page.Expect(page.GetByID("article")).ToContainValue("The second one.") + + page.Press("l") + page.Expect(page.GetByID("article")).ToContainValue("And a third.") + page.Press("h") + page.Expect(page.GetByID("article")).ToContainValue("The second one.") + + page.Press("esc") + page.Expect(page.GetByID("article")).Not().ToBeVisible() + page.Expect(rows(page).Nth(1)).ToBeSelected() +} + +// "/" filters as it is typed, Enter keeps the filter and says so, and Esc +// clears it. +func TestTUIFiltersTheList(t *testing.T) { + page, _ := newTestTUI(t, nil, threeItems()...) + + page.Press("G") // the selection is on the last row, as filtering starts + page.Press("/") + page.Expect(page.GetByID("filter")).ToBeFocused() + page.Type("post") + page.Expect(rows(page).Nth(0)).ToBeSelected() + page.Press("ctrl+u") + page.Type("third") + page.Expect(rows(page)).ToHaveCount(1) + page.Expect(rows(page).Nth(0)).ToContainLabel("Third post") + + page.Press("enter") + page.Expect(page.GetByID("status")).ToContainValue("filtering: third") + page.Press("enter") // opens the one row left + page.Expect(page.GetByID("article")).ToContainValue("And a third.") + page.Press("esc") + + page.Press("esc") + page.Expect(rows(page)).ToHaveCount(3) +} + +// m marks the selected item read, which takes it out of the default view; +// M shows read items again. +func TestTUIMarksItemsRead(t *testing.T) { + page, cmds := newTestTUI(t, nil, threeItems()...) + + page.Press("m") + page.Expect(rows(page)).ToHaveCount(2) + page.Expect(rows(page).Nth(0)).ToContainLabel("Second post") + if n := cmds.CountUnread(); n != 2 { + t.Fatalf("the store has %d unread, want 2", n) + } + + page.Press("M") + page.Expect(rows(page)).ToHaveCount(3) +} + +// f favourites the selected item: it is marked with a star. +func TestTUIFavourites(t *testing.T) { + page, _ := newTestTUI(t, nil, threeItems()...) + + page.Press("f") + page.Press("j") + page.Expect(rows(page).Nth(0)).ToHaveLabel("* 1. Blog: First post") + page.Press("F") // favourites only + page.Expect(rows(page)).ToHaveCount(1) +} + +// The help line lists the keys; ? shows them all and closes again. +func TestTUIHelp(t *testing.T) { + page, _ := newTestTUI(t, nil, threeItems()...) + + page.Expect(page.GetByID("help")).ToContainValue("/ filter") + page.Press("?") + page.Expect(page.GetByID("help")).ToContainValue("edit config in $EDITOR") + page.Press("?") + page.Expect(page.GetByID("help")).Not().ToContainValue("edit config in $EDITOR") + page.Press("q") + page.ExpectExit() +} + +// E hands the terminal to $EDITOR. A test's terminal has nothing to hand +// over, and says so on the status line instead of hanging. +func TestTUIEditConfigWithoutATerminal(t *testing.T) { + t.Setenv("NOMEDITOR", "true") + page, _ := newTestTUI(t, &config.Config{ConfigPath: t.TempDir() + "/config.yml"}, threeItems()...) + + page.Press("E") + page.Expect(page.GetByID("status")).ToContainValue("cannot be handed to another program") +} + +// An article's pictures are downloaded — a relative address resolved +// against the article's own — and drawn: here in half blocks, the test +// terminal having no image protocol. +func TestTUIShowsAnArticlesPictures(t *testing.T) { + t.Setenv("LIMONI_GRAPHICS", "halfblock") + pic := image.NewRGBA(image.Rect(0, 0, 200, 100)) + for i := 0; i < len(pic.Pix); i += 4 { + pic.Pix[i], pic.Pix[i+3] = 255, 255 // red + } + var buf bytes.Buffer + if err := png.Encode(&buf, pic); err != nil { + t.Fatal(err) + } + fetched := make(chan string, 4) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetched <- r.URL.Path + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(buf.Bytes()) + })) + defer srv.Close() + + page, _ := newTestTUI(t, nil, store.Item{ + Title: "With a picture", + Link: srv.URL + "/posts/one", + Content: `Before.

After.
`, + }) + page.Press("enter") + page.Expect(page.GetByID("article")).ToContainValue("[image: a red box]") + select { + case path := <-fetched: + if path != "/img/red.png" { + t.Fatalf("fetched %q, want the address resolved against the article", path) + } + case <-time.After(5 * time.Second): + t.Fatal("the picture was never fetched") + } + + deadline := time.Now().Add(5 * time.Second) + for !strings.Contains(page.Screen(), "▄") { + if time.Now().After(deadline) { + t.Fatalf("the picture was not drawn:\n%s", page.Screen()) + } + time.Sleep(20 * time.Millisecond) + } + if strings.Contains(page.Screen(), "▣ a red box") { + t.Error("the alt text is still drawn in place of the picture") + } +} + +// With images: false nothing is downloaded, and the alt text stands in. +func TestTUIImagesOff(t *testing.T) { + off := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("fetched %s with images off", r.URL) + })) + defer srv.Close() + page, _ := newTestTUI(t, &config.Config{Images: &off}, store.Item{ + Title: "With a picture", + Content: `