diff --git a/go.mod b/go.mod index 58dcc91..c88043c 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/go-chi/chi/v5 v5.2.5 github.com/oklog/ulid/v2 v2.1.1 github.com/spf13/cobra v1.10.2 @@ -14,33 +14,45 @@ require ( ) require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/glamour v1.0.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.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.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // 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/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect modernc.org/libc v1.65.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 7252a8d..31b528c 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,29 @@ +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= 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/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 v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +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.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +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.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= @@ -23,6 +33,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -33,6 +45,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -41,12 +55,17 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE 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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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/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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -56,6 +75,8 @@ github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -65,21 +86,34 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= diff --git a/internal/tui/cards.go b/internal/tui/cards.go index f08038a..72416cc 100644 --- a/internal/tui/cards.go +++ b/internal/tui/cards.go @@ -64,9 +64,16 @@ func matchesIntent(e *db.Entity, i intent) bool { return false } +type cardGroup struct { + label string + start int + count int +} + type cardsModel struct { entities []*db.Entity filtered []*db.Entity + groups []cardGroup cursor int offset int height int @@ -91,24 +98,69 @@ func (c *cardsModel) setIntent(i intent) { } func (c *cardsModel) applyFilter() { - c.filtered = nil - var pinned, rest []*db.Entity - for _, e := range c.entities { - if !matchesIntent(e, c.intent) { - continue - } - if e.Pinned { - pinned = append(pinned, e) - } else { - rest = append(rest, e) - } - } - c.filtered = append(pinned, rest...) + c.filtered, c.groups = sortAndGroupCards(c.entities, c.intent) if c.cursor >= len(c.filtered) { c.cursor = max(0, len(c.filtered)-1) } } +func sortAndGroupCards(entities []*db.Entity, intentFilter intent) ([]*db.Entity, []cardGroup) { + if intentFilter != intentAll { + var pinned, rest []*db.Entity + for _, e := range entities { + if !matchesIntent(e, intentFilter) { + continue + } + if e.Pinned { + pinned = append(pinned, e) + } else { + rest = append(rest, e) + } + } + return append(pinned, rest...), nil + } + + var pinned, grab, read, fill []*db.Entity + for _, e := range entities { + if e.Pinned { + pinned = append(pinned, e) + } else { + switch { + case matchesIntent(e, intentGrab): + grab = append(grab, e) + case matchesIntent(e, intentRead): + read = append(read, e) + case matchesIntent(e, intentFill): + fill = append(fill, e) + } + } + } + + var filtered []*db.Entity + var groups []cardGroup + for _, bucket := range []struct { + label string + entities []*db.Entity + }{ + {"pinned", pinned}, + {"grab", grab}, + {"read", read}, + {"fill", fill}, + } { + if len(bucket.entities) == 0 { + continue + } + groups = append(groups, cardGroup{ + label: bucket.label, + start: len(filtered), + count: len(bucket.entities), + }) + filtered = append(filtered, bucket.entities...) + } + + return filtered, groups +} + func (c *cardsModel) setSize(width, height int) { c.width = width c.height = height @@ -166,6 +218,9 @@ func (c cardsModel) view(width int) string { if len(c.filtered) == 0 { return statusStyle.Render("no cards") } + if len(c.groups) > 0 { + return c.groupedView(width) + } var b strings.Builder visible := c.visibleCount() @@ -188,6 +243,55 @@ func (c cardsModel) view(width int) string { return b.String() } +func (c cardsModel) groupedView(width int) string { + entityWidth := width - 4 - dateGutterWidth + + type displayLine struct { + text string + entityIdx int + } + + var lines []displayLine + for _, g := range c.groups { + for i := 0; i < g.count; i++ { + eIdx := g.start + i + var gutter string + if i == 0 { + gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ") + } else { + gutter = gutterStyle.Render(" │ ") + } + line := gutter + renderCard(c.filtered[eIdx], entityWidth) + lines = append(lines, displayLine{text: line, entityIdx: eIdx}) + } + } + + visible := c.visibleCount() + offset := c.offset + if c.cursor < offset { + offset = c.cursor + } + if c.cursor >= offset+visible { + offset = c.cursor - visible + 1 + } + + var b strings.Builder + end := min(offset+visible, len(lines)) + for i := offset; i < end; i++ { + dl := lines[i] + if dl.entityIdx == c.cursor { + b.WriteString(selectedItemStyle.Render(" " + dl.text)) + } else { + b.WriteString(listItemStyle.Render(dl.text)) + } + if i < end-1 { + b.WriteString("\n") + } + } + + return b.String() +} + func (c cardsModel) visibleCount() int { if c.height <= 0 { return 20 diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 5e930f7..140f773 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -58,6 +58,8 @@ type tagsLoadedMsg struct { tags []db.TagCount } +type statusClearMsg struct{ seq int } + type editorFinishedMsg struct { err error } @@ -267,3 +269,9 @@ func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd { return templateCopiedMsg{} } } + +func clearStatusAfter(d time.Duration, seq int) tea.Cmd { + return tea.Tick(d, func(time.Time) tea.Msg { + return statusClearMsg{seq: seq} + }) +} diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 38e5f46..2130ddd 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -6,6 +6,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" "github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/display" @@ -61,6 +62,17 @@ func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) { } case "down", "j": d.scroll++ + case "pgdown", "ctrl+d": + d.scroll += d.height + case "pgup", "ctrl+u": + d.scroll -= d.height + if d.scroll < 0 { + d.scroll = 0 + } + case "home", "g": + d.scroll = 0 + case "end", "G": + d.scroll = 1<<31 - 1 } return d, nil } @@ -98,7 +110,20 @@ func (d detailModel) previewView(width int) string { b.WriteString("\n") } - b.WriteString(detailBodyStyle.Render(e.Body)) + bodyWidth := width - 4 + if bodyWidth < 20 { + bodyWidth = 20 + } + r, _ := glamour.NewTermRenderer( + glamour.WithStylePath("dark"), + glamour.WithWordWrap(bodyWidth), + ) + rendered, err := r.Render(e.Body) + if err != nil { + rendered = e.Body + } + rendered = strings.TrimRight(rendered, "\n") + b.WriteString(detailBodyStyle.Render(rendered)) b.WriteString("\n") if e.CardType != nil { @@ -142,8 +167,24 @@ func (d detailModel) previewView(width int) string { b.WriteString(idStyle.Render(meta)) lines := strings.Split(b.String(), "\n") - if d.scroll > 0 && d.scroll < len(lines) { - lines = lines[d.scroll:] + totalLines := len(lines) + + maxScroll := totalLines - d.height + if maxScroll < 0 { + maxScroll = 0 + } + scroll := d.scroll + if scroll > maxScroll { + scroll = maxScroll + } + + if totalLines > d.height && d.height > 0 && len(lines) > 0 { + indicator := idStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, totalLines)) + lines[0] = lines[0] + indicator + } + + if scroll > 0 && scroll < totalLines { + lines = lines[scroll:] } if d.height > 0 && len(lines) > d.height { lines = lines[:d.height] diff --git a/internal/tui/input.go b/internal/tui/input.go index 52027ff..5e38948 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -103,11 +103,23 @@ func (i inputModel) updateKey(msg tea.KeyMsg) inputModel { func (i inputModel) view(width int) string { var b strings.Builder - b.WriteString(drawerBorderStyle.Render(strings.Repeat("─", width))) + label := "capture" + prefix := "── " + suffix := " " + dashCount := width - len(prefix) - len(label) - len(suffix) + if dashCount < 0 { + dashCount = 0 + } + b.WriteString(drawerBorderStyle.Render(prefix) + + hintDescStyle.Render(label) + + drawerBorderStyle.Render(suffix+strings.Repeat("─", dashCount))) b.WriteString("\n") b.WriteString(i.ti.View()) b.WriteString("\n") - b.WriteString(drawerHintsStyle.Render("enter:submit esc:cancel ?:search -:todo @:event !:reminder")) + b.WriteString(drawerHintsStyle.Render(renderHints([]hint{ + {"enter", "submit"}, {"esc", "cancel"}, {"?", "search"}, + {"-", "todo"}, {"@", "event"}, {"!", "reminder"}, + }))) b.WriteString("\n") b.WriteString(i.renderPreview(width)) return b.String() diff --git a/internal/tui/model.go b/internal/tui/model.go index a5e7c5b..18c2bb5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -10,6 +11,8 @@ import ( "github.com/lerko/nib/internal/db" ) +const statusTimeout = 2 * time.Second + type viewState int const ( @@ -91,8 +94,9 @@ type model struct { searchQuery string searchTags []string - status string - err error + status string + statusSeq int + err error } func newModel(store *db.Store) model { @@ -108,6 +112,12 @@ func newModel(store *db.Store) model { } } +func (m *model) setStatus(msg string) tea.Cmd { + m.statusSeq++ + m.status = msg + return clearStatusAfter(statusTimeout, m.statusSeq) +} + func (m model) Init() tea.Cmd { return loadEntities(m.store, m.listParams()) } @@ -140,20 +150,14 @@ func (m model) hasSearch() bool { func (m *model) applySearch() { if m.mode == modeCards { - filtered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags) - m.cards.filtered = nil - var pinned, rest []*db.Entity - for _, e := range filtered { - if !matchesIntent(e, m.cards.intent) { - continue - } - if e.Pinned { - pinned = append(pinned, e) - } else { - rest = append(rest, e) + searchFiltered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags) + var intentFiltered []*db.Entity + for _, e := range searchFiltered { + if matchesIntent(e, m.cards.intent) { + intentFiltered = append(intentFiltered, e) } } - m.cards.filtered = append(pinned, rest...) + m.cards.filtered, m.cards.groups = sortAndGroupCards(intentFiltered, m.cards.intent) if m.cards.cursor >= len(m.cards.filtered) { m.cards.cursor = max(0, len(m.cards.filtered)-1) } @@ -191,38 +195,31 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = stateList m.input.reset() m.recalcSizes() - m.status = "created" - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("created")) case entityDeletedMsg: - m.status = "deleted" m.state = stateList - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("deleted")) case entityUpdatedMsg: - m.status = msg.action if m.state == stateDetail { m.detail.setEntity(msg.entity) } - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(msg.action)) case entityPromotedMsg: - m.status = fmt.Sprintf("promoted → %s", msg.cardType) m.state = stateList - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType))) case entityDemotedMsg: - m.status = "demoted → fluid" - return m, m.reloadDetail(msg.id) + return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid")) case entityCopiedMsg: - m.status = "copied" - return m, nil + return m, m.setStatus("copied") case entityAbsorbedMsg: - m.status = "absorbed" m.state = stateList - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("absorbed")) case absorbSourcesLoadedMsg: m.absorb = newAbsorbModel(msg.targetID) @@ -232,14 +229,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case stepsPersistedMsg: - m.status = "steps saved" m.detail.mode = detailPreview - return m, m.reloadDetail(m.detail.entity.ID) + return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), m.setStatus("steps saved")) case templateCopiedMsg: - m.status = "copied resolved" m.detail.mode = detailPreview - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("copied resolved")) case tagsLoadedMsg: m.filter.setTags(msg.tags) @@ -249,10 +244,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case editorFinishedMsg: if msg.err != nil { m.err = msg.err - } else { - m.status = "updated" + return m, m.reloadAfterEdit() } - return m, m.reloadAfterEdit() + return m, tea.Batch(m.reloadAfterEdit(), m.setStatus("updated")) case confirmTimeoutMsg: if m.state == stateConfirm { @@ -261,6 +255,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case statusClearMsg: + if msg.seq == m.statusSeq { + m.status = "" + } + return m, nil + case errMsg: m.err = msg.err return m, nil @@ -414,8 +414,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "s": if m.mode == modeCards && m.state == stateList { m.cardsSort = m.cardsSort.next() - m.status = "sort: " + m.cardsSort.String() - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("sort: "+m.cardsSort.String())) } return m, nil @@ -531,8 +530,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { e := m.selectedEntity() if e != nil { if e.CardType != nil { - m.status = "target must be fluid" - return m, nil + return m, m.setStatus("target must be fluid") } return m, loadAbsorbSources(m.store, e.ID) } @@ -542,8 +540,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { e := m.selectedEntity() if e != nil { if e.CardType != nil { - m.status = "already a card" - return m, nil + return m, m.setStatus("already a card") } m.promote = newPromoteModel(e.ID, e.Body) m.state = statePromote @@ -554,8 +551,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "D": if m.state == stateDetail && m.detail.entity != nil { if m.detail.entity.CardType == nil { - m.status = "already fluid" - return m, nil + return m, m.setStatus("already fluid") } return m, demoteEntity(m.store, m.detail.entity.ID) } @@ -758,6 +754,8 @@ func (m model) View() string { header := m.headerView() footer := m.footerView() + content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content) + return header + "\n" + content + "\n" + footer } @@ -777,16 +775,13 @@ func (m model) listContent() string { } func (m model) headerView() string { - header := titleStyle.Render("nib") - - modeName := "stream" - if m.mode == modeCards { - modeName = "cards" - } - header += " " + modeStyle.Render(modeName) + header := titleStyle.Render("nib") + " " + header += renderTab("stream", "1", m.mode == modeStream) + header += " " + separatorStyle.Render("│") + " " + header += renderTab("cards", "2", m.mode == modeCards) if m.filterTag != "" { - header += " " + filterPillStyle.Render("#"+m.filterTag) + header += " " + filterPillStyle.Render("#"+m.filterTag) } if m.hasSearch() { @@ -824,10 +819,6 @@ func (m model) footerView() string { return errorStyle.Render("error: " + m.err.Error()) } - if m.status != "" { - return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m)) - } - return renderStatusBar(m, m.width) } diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index bdbfb09..07ecf72 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -2,24 +2,54 @@ package tui import ( "fmt" + "strings" "github.com/charmbracelet/lipgloss" ) +type hint struct { + key string + desc string +} + +func renderHints(hints []hint) string { + parts := make([]string, len(hints)) + for i, h := range hints { + parts[i] = hintKeyStyle.Render(h.key) + " " + hintDescStyle.Render(h.desc) + } + return strings.Join(parts, " ") +} + +func renderTab(label, key string, active bool) string { + if active { + return hintKeyStyle.Render(label) + " " + hintDescStyle.Render(key) + } + return hintDescStyle.Render(label) + " " + hintKeyStyle.Render(key) +} + func renderStatusBar(m model, width int) string { - left := countText(m) - right := contextHints(m) + var leftParts []string - leftRendered := statusStyle.Render(left) - rightRendered := helpStyle.Render(right) + if m.state == stateList { + leftParts = append(leftParts, renderTab("capture", "a", false)) + } - gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(rightRendered) + if m.status != "" { + leftParts = append(leftParts, statusStyle.Render(m.status)) + } else { + leftParts = append(leftParts, statusStyle.Render(countText(m))) + } + + leftRendered := strings.Join(leftParts, " "+separatorStyle.Render("│")+" ") + right := renderHints(contextHints(m)) + + gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(right) if gap < 0 { gap = 0 } pad := lipgloss.NewStyle().Width(gap).Render("") - return leftRendered + pad + rightRendered + return leftRendered + pad + right } func countText(m model) string { @@ -35,37 +65,37 @@ func countText(m model) string { return fmt.Sprintf("%d entities", total) } -func contextHints(m model) string { +func contextHints(m model) []hint { switch m.state { case stateDetail: switch m.detail.mode { case detailRun: - return "space:toggle j/k:nav r:reset esc:save+exit" + return []hint{{"space", "toggle"}, {"j/k", "nav"}, {"r", "reset"}, {"esc", "save+exit"}} case detailFill: - return "tab:next shift+tab:prev enter:copy esc:cancel" + return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}} default: - return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back" + return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}} } case stateInput: - return "" + return nil case stateTagFilter: - return "j/k:nav enter:select esc:cancel" + return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} case stateConfirm: - return "y:confirm n:cancel" + return []hint{{"y", "confirm"}, {"n", "cancel"}} case statePromote: - return "j/k:nav enter:select esc:cancel" + return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} case stateAbsorb: - return "j/k:nav enter:absorb esc:cancel" + return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}} default: if m.splitDetail { if m.focus == focusDetail { - return "h:list c:copy e:edit p:promote D:demote !:pin esc:back" + return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"D", "demote"}, {"!", "pin"}, {"esc", "back"}} } - return "l:detail a:add d:del #:filter esc:close ?:help q:quit" + return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"esc", "close"}, {"?", "help"}, {"q", "quit"}} } if m.mode == modeCards { - return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit" + return []hint{{"s", "sort"}, {"tab", "intent"}, {"?", "help"}, {"q", "quit"}} } - return "1:stream 2:cards a:add/?search m:absorb d:del #:filter ?:help q:quit" + return []hint{{"?", "search"}, {"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"?", "help"}, {"q", "quit"}} } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index ef837a2..19c1773 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -122,4 +122,11 @@ var ( separatorStyle = lipgloss.NewStyle(). Foreground(dim) + + hintKeyStyle = lipgloss.NewStyle(). + Foreground(highlight). + Bold(true) + + hintDescStyle = lipgloss.NewStyle(). + Foreground(dim) )