From 7a23324955b84c7e545c13ba82137fc8e59db468 Mon Sep 17 00:00:00 2001 From: thebanri Date: Sun, 4 Oct 2026 21:01:37 +0300 Subject: [PATCH] feat: show article images, move the TUI to Limoni Articles show their pictures (#99): kitty, iTerm2 or Sixel where the terminal has one, half blocks elsewhere. images: false turns it off. Links are clickable where the terminal supports OSC 8, relative links and images are resolved against the article, and the mouse wheel scrolls. Layout, keys, help and theme colours stay as they were. Adds the first TUI tests, driving the real program through uitest. --- README.md | 12 +- go.mod | 44 +-- go.sum | 89 +---- internal/commands/commands.go | 127 +++---- internal/commands/filter.go | 16 +- internal/commands/images.go | 131 +++++++ internal/commands/key.go | 377 ++++++++++--------- internal/commands/list.go | 620 ++++++++++++++++++-------------- internal/commands/theme.go | 221 ++++++++++++ internal/commands/theme_test.go | 68 ++++ internal/commands/tui.go | 330 ++++++++++------- internal/commands/tui_test.go | 235 ++++++++++++ internal/commands/viewport.go | 344 ++++++++++-------- internal/config/config.go | 10 + internal/config/http.go | 14 + 15 files changed, 1700 insertions(+), 938 deletions(-) create mode 100644 internal/commands/images.go create mode 100644 internal/commands/theme.go create mode 100644 internal/commands/theme_test.go create mode 100644 internal/commands/tui_test.go 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.

a red box

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: `

a box

`, + }) + page.Press("enter") + page.Expect(page.GetByID("article")).ToContainValue("[image: a box]") + time.Sleep(200 * time.Millisecond) +} diff --git a/internal/commands/viewport.go b/internal/commands/viewport.go index f84ced8..55cb4f2 100644 --- a/internal/commands/viewport.go +++ b/internal/commands/viewport.go @@ -1,147 +1,178 @@ package commands import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "fmt" + + "github.com/thebanri/limoni" + "github.com/thebanri/limoni/widgets" ) -func updateViewport(msg tea.Msg, m model) (tea.Model, tea.Cmd) { - var ( - cmd tea.Cmd - cmds []tea.Cmd - ) +// articleWidth is the widest the article's text runs, as glamour wrapped it. +const articleWidth = 80 - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.help.Width = msg.Width - - case tea.ResumeMsg: - return m, nil - case tea.KeyMsg: - switch { - case key.Matches(msg, ViewportKeyMap.Suspend): - return m, tea.Suspend - case key.Matches(msg, ViewportKeyMap.GotoStart): - m.viewport.GotoTop() - - case key.Matches(msg, ViewportKeyMap.GotoEnd): - m.viewport.GotoBottom() - - case key.Matches(msg, ViewportKeyMap.Escape): - // reset cursor if last post is read and quit - index := m.list.Index() - length := len(m.list.Items()) - if index >= length && length >= 1 { - m.list.Select(index - 1) - } - - m.selectedArticle = nil - cmds = append(cmds, m.UpdateList()) +// openArticle shows the selected article from its top, and starts fetching +// its pictures. +func (m *model) openArticle() []limoni.Cmd { + content, err := m.commands.GetArticleMarkdown(*m.selectedArticle) + if err != nil { + m.selectedArticle = nil + return []limoni.Cmd{m.setStatus(fmt.Sprintf("Error opening article: %s", err))} + } + m.articleOffset = 0 + if m.images != nil { + m.images.forget() + } + m.article.Content = content + return m.fetchImages() +} - case key.Matches(msg, ViewportKeyMap.OpenInBrowser): - current, err := m.commands.store.GetItemByID(*m.selectedArticle) - if err != nil { - m.selectedArticle = nil - return m, m.list.NewStatusMessage("Error: failed to get article") - } +// fetchImages starts downloading the pictures in the article. +func (m *model) fetchImages() []limoni.Cmd { + if m.images == nil { + return nil + } + return m.images.fetch(widgets.MarkdownImageSources(m.article.Content), "nom/"+m.cfg.Version) +} - it := ItemToTUIItem(current) - cmd = m.OpenLink(it.URL) +// refreshArticle draws the article again with its state changed (read, +// favourite), where it was. +func (m *model) refreshArticle() limoni.Cmd { + content, err := m.commands.GetArticleMarkdown(*m.selectedArticle) + if err != nil { + m.selectedArticle = nil + return m.setStatus("Error rendering article") + } + m.article.Content = content + return nil +} - cmds = append(cmds, cmd) - if !current.Read() && m.commands.config.AutoRead { - if cmd := markRead(&m); cmd != nil { - cmds = append(cmds, cmd) - } - } +func updateViewport(msg limoni.Msg, m *model) limoni.UpdateResult { + var cmds []limoni.Cmd + switch msg := msg.(type) { + case limoni.KeyPressMsg: + return articleKey(m, msg.Key) + case limoni.MouseWheelMsg, limoni.MousePressMsg, limoni.ResizeMsg: + // The article scrolls with the wheel by itself. + case statusUpdate: + cmds = append(cmds, m.setStatus(msg.status)) + default: + return limoni.UpdateResult{} + } + return redraw(cmds...) +} - case key.Matches(msg, ViewportKeyMap.Favourite): - current, err := m.commands.store.GetItemByID(*m.selectedArticle) - if err != nil { - m.selectedArticle = nil - return m, m.list.NewStatusMessage("Error: failed to get article") - } - err = m.commands.store.ToggleFavourite(current.ID) - if err != nil { - m.selectedArticle = nil - return m, m.list.NewStatusMessage("Error toggling favourite") - } +func articleKey(m *model, key limoni.KeyEvent) limoni.UpdateResult { + k := articleKeys + name := keyName(key) + page := max(m.articleH, 1) + var cmds []limoni.Cmd + + switch { + case k.Quit.matches(name): + return limoni.UpdateResult{Quit: true} + case k.Suspend.matches(name): + return redraw(limoni.SuspendCmd()) + + case k.Up.matches(name): + m.articleOffset = max(m.articleOffset-1, 0) + case k.Down.matches(name): + m.articleOffset++ // Markdown stops it at the end + case k.PageUp.matches(name): + m.articleOffset = max(m.articleOffset-page, 0) + case k.PageDown.matches(name): + m.articleOffset += page + case k.HalfPageUp.matches(name): + m.articleOffset = max(m.articleOffset-page/2, 0) + case k.HalfPageDown.matches(name): + m.articleOffset += page / 2 + case k.GotoStart.matches(name): + m.articleOffset = 0 + case k.GotoEnd.matches(name): + m.articleOffset = 1 << 30 + + case k.Escape.matches(name): + // reset cursor if last post is read and quit + index := m.list.Selected + length := len(m.visible) + if index >= length && length >= 1 { + m.list.Select(index - 1) + } + m.selectedArticle = nil + m.fullHelp = false + if m.images != nil { + m.images.forget() + } + cmds = append(cmds, m.UpdateList()) - case key.Matches(msg, ViewportKeyMap.Read): - if cmd := markRead(&m); cmd != nil { + case k.OpenInBrowser.matches(name): + current, err := m.commands.store.GetItemByID(*m.selectedArticle) + if err != nil { + m.selectedArticle = nil + return redraw(m.setStatus("Error: failed to get article")) + } + cmds = append(cmds, m.OpenLink(ItemToTUIItem(current).URL)) + if !current.Read() && m.commands.config.AutoRead { + if cmd := markRead(m); cmd != nil { cmds = append(cmds, cmd) } + } - case key.Matches(msg, ViewportKeyMap.Prev): - navIndex := m.getPrevIndex() - items := m.list.Items() - if m.isPrevOutOfBounds(navIndex) { - return m, nil - } - - m.list.Select(navIndex) - item := items[navIndex] - id := item.(TUIItem).ID - m.selectedArticle = &id - - content, err := m.commands.GetGlamourisedArticle(*m.selectedArticle) - if err != nil { - m.selectedArticle = nil - return m, m.list.NewStatusMessage("Error rendering article") - } - - m.viewport.SetContent(content) - m.viewport.GotoTop() - if m.commands.config.AutoRead && !m.commands.config.ShowRead { - m.list.RemoveItem(m.list.Index()) - } - - case key.Matches(msg, ViewportKeyMap.Next): - navIndex := m.getNextIndex() - items := m.list.Items() - if m.isNextOutOfBounds(navIndex, len(items)) { - return m, nil - } + case k.Favourite.matches(name): + current, err := m.commands.store.GetItemByID(*m.selectedArticle) + if err != nil { + m.selectedArticle = nil + return redraw(m.setStatus("Error: failed to get article")) + } + if err := m.commands.store.ToggleFavourite(current.ID); err != nil { + m.selectedArticle = nil + return redraw(m.setStatus("Error toggling favourite")) + } - m.list.Select(navIndex) - item := items[navIndex] - id := item.(TUIItem).ID - m.selectedArticle = &id + case k.Read.matches(name): + if cmd := markRead(m); cmd != nil { + cmds = append(cmds, cmd) + } - content, err := m.commands.GetGlamourisedArticle(*m.selectedArticle) - if err != nil { - m.selectedArticle = nil - return m, m.list.NewStatusMessage("Error rendering article") - } + case k.Prev.matches(name): + navIndex := m.getPrevIndex() + if m.isPrevOutOfBounds(navIndex) { + return limoni.UpdateResult{} + } + cmds = append(cmds, m.showRow(navIndex)...) - m.viewport.SetContent(content) - m.viewport.GotoTop() - if m.commands.config.AutoRead && !m.commands.config.ShowRead { - m.list.RemoveItem(m.list.Index()) - } + case k.Next.matches(name): + navIndex := m.getNextIndex() + if m.isNextOutOfBounds(navIndex, len(m.visible)) { + return limoni.UpdateResult{} + } + cmds = append(cmds, m.showRow(navIndex)...) - case key.Matches(msg, ViewportKeyMap.Quit): - return m, tea.Quit + case k.ShowFullHelp.matches(name): + m.fullHelp = !m.fullHelp - case key.Matches(msg, ViewportKeyMap.ShowFullHelp): - m.help.ShowAll = !m.help.ShowAll - if m.help.ShowAll { - m.viewport.Height = m.viewport.Height + lipgloss.Height(m.help.ShortHelpView(ViewportKeyMap.ShortHelp())) - lipgloss.Height(m.help.FullHelpView(ViewportKeyMap.FullHelp())) - } else { - m.viewport.Height = m.viewport.Height + lipgloss.Height(m.help.FullHelpView(ViewportKeyMap.FullHelp())) - lipgloss.Height(m.help.ShortHelpView(ViewportKeyMap.ShortHelp())) - } - } + default: + return limoni.UpdateResult{} } + return redraw(cmds...) +} - m.viewport, cmd = m.viewport.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) +// showRow opens the article on row i of the list, as next and prev do. +func (m *model) showRow(i int) []limoni.Cmd { + m.list.Select(i) + id := m.items[m.visible[i]].ID + m.selectedArticle = &id + cmds := m.openArticle() + if m.selectedArticle == nil { + return cmds + } + if m.commands.config.AutoRead && !m.commands.config.ShowRead { + m.removeRow(m.list.Selected) + } + return cmds } func (m *model) isPrevOutOfBounds(i int) bool { - if len(m.list.Items()) == 0 { + if len(m.visible) == 0 { return true } return i < 0 @@ -163,81 +194,92 @@ func (m *model) isNextOutOfBounds(i int, l int) bool { func (m *model) getNextIndex() int { if m.commands.config.AutoRead && !m.commands.config.ShowRead { - return m.list.Index() + return m.list.Selected } // check for favorite within post current, err := m.commands.store.GetItemByID(*m.selectedArticle) if err != nil { - return m.list.Index() + return m.list.Selected } if !m.commands.config.AutoRead && current.Read() && !m.commands.config.ShowRead { - return m.list.Index() + return m.list.Selected } - return m.list.Index() + 1 + return m.list.Selected + 1 } func (m *model) getPrevIndex() int { - current := m.list.Index() - if m.commands.config.AutoRead && !m.commands.config.ShowRead && current < len(m.list.Items()) { - return m.list.Index() + current := m.list.Selected + if m.commands.config.AutoRead && !m.commands.config.ShowRead && current < len(m.visible) { + return m.list.Selected } if current == 0 { return 0 } - return m.list.Index() - 1 -} - -func viewportView(m model) string { - return m.viewport.View() + "\n" + m.viewportHelp() + return m.list.Selected - 1 } -func (m model) viewportHelp() string { - return helpStyle.Render(m.help.View(ViewportKeyMap)) +func viewportView(m *model, f *limoni.Frame) { + area := f.Area() + if area.Width < 8 || area.Height < 3 { + return + } + help := []string{articleShortHelp()} + if m.fullHelp { + help = articleFullHelp() + } + help = append(help, "") + body := drawHelp(f, area, help) + if body.Height < 2 { + return + } + // One row of space above, and the text in a column no wider than + // glamour wrapped it, two columns in from the edge. + width := min(int(body.Width)-4, articleWidth) + text := limoni.NewRect(body.X+2, body.Y+1, uint16(max(width, 1)), body.Height-2) + m.articleH = int(text.Height) + f.FocusManager.SetFocused("article") + f.RenderWidget(m.article, text) + if m.status != "" { + f.RenderWidget(&limoni.Paragraph{ID: "status", Text: m.status, Style: limoni.Style{Fg: limoni.Hex("#04B575")}}, + limoni.NewRect(body.X+2, body.Y+body.Height-1, body.Width-min(2, body.Width), 1)) + } } -func markRead(m *model) tea.Cmd { +func markRead(m *model) limoni.Cmd { if m.commands.config.AutoRead { return nil } current, err := m.commands.store.GetItemByID(*m.selectedArticle) if err != nil { m.selectedArticle = nil - return m.list.NewStatusMessage("Error: failed to get article") + return m.setStatus("Error: failed to get article") } err = m.commands.store.ToggleRead(current.ID) if err != nil { m.selectedArticle = nil - return m.list.NewStatusMessage("Error marking read") + return m.setStatus("Error marking read") } if !m.commands.config.ShowRead { - index := m.list.Index() + index := m.list.Selected - if m.lastRead != nil && current.ID == (*m.lastRead).(TUIItem).ID { + if m.lastRead != nil && current.ID == m.lastRead.ID { // un-read re-add post back to list - m.list.InsertItem(index, *m.lastRead) + m.insertRow(index, *m.lastRead) m.lastReadIndex = index m.lastRead = nil - } else { + } else if index < len(m.visible) { // remove post and store backup for un-read - items := m.list.Items() - item := items[index] - m.list.RemoveItem(index) + item := m.removeRow(index) m.lastReadIndex = index m.lastRead = &item } } // trigger refresh to update read indication - content, err := m.commands.GetGlamourisedArticle(*m.selectedArticle) - if err != nil { - m.selectedArticle = nil - return m.list.NewStatusMessage("Error rendering article") - } - m.viewport.SetContent(content) - return nil + return m.refreshArticle() } diff --git a/internal/config/config.go b/internal/config/config.go index 995cf88..3d8e003 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -67,6 +67,15 @@ type Config struct { Theme Theme `yaml:"theme,omitempty"` HTTPOptions *HTTPOptions `yaml:"http,omitempty"` RefreshInterval int `yaml:"refreshinterval,omitempty"` + // Images says whether an article's pictures are downloaded and shown. + // Unset is on; see ShowImages. + Images *bool `yaml:"images,omitempty"` +} + +// ShowImages reports whether an article's pictures are downloaded and shown: +// yes unless the config says `images: false`. +func (c *Config) ShowImages() bool { + return c.Images == nil || *c.Images } var DefaultTheme = Theme{ @@ -181,6 +190,7 @@ func (c *Config) Load() error { c.ShowFavourites = fileConfig.ShowFavourites c.Filtering = fileConfig.Filtering c.RefreshInterval = fileConfig.RefreshInterval + c.Images = fileConfig.Images if fileConfig.HTTPOptions != nil { if _, err := TLSVersion(fileConfig.HTTPOptions.MinTLSVersion); err != nil { diff --git a/internal/config/http.go b/internal/config/http.go index 6693981..bcd669d 100644 --- a/internal/config/http.go +++ b/internal/config/http.go @@ -3,6 +3,8 @@ package config import ( "crypto/tls" "fmt" + "net/http" + "time" ) // CloudFlare blocks requests unless a minimum TLSVersion is specified. @@ -27,3 +29,15 @@ func TLSVersion(configStr string) (uint16, error) { } return 0, fmt.Errorf("unsupported tls version: %s", configStr) } + +// HTTPClient is a client set up as feeds are fetched: the proxy from the +// environment and the configured minimum TLS version. +func (c *Config) HTTPClient() *http.Client { + tr := &http.Transport{Proxy: http.ProxyFromEnvironment} + if c.HTTPOptions != nil { + if version, err := TLSVersion(c.HTTPOptions.MinTLSVersion); err == nil { + tr.TLSClientConfig = &tls.Config{MinVersion: version} + } + } + return &http.Client{Transport: tr, Timeout: 30 * time.Second} +}