From 36999cd825b28e1796656496887de46d643fd46a Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 20:07:45 -0400 Subject: [PATCH] feat(tui): add bubbletea terminal UI with entity list, detail, and capture Adds `nib tui` command and `make tui` target. Scrollable entity list with j/k navigation, enter for detail view, `a` to capture new entries using the existing parse grammar, and `d` to delete. --- Makefile | 5 +- cmd/tui.go | 25 ++++++ go.mod | 23 ++++- go.sum | 45 ++++++++++ internal/tui/commands.go | 51 ++++++++++++ internal/tui/detail.go | 106 +++++++++++++++++++++++ internal/tui/input.go | 76 +++++++++++++++++ internal/tui/keys.go | 33 ++++++++ internal/tui/list.go | 174 ++++++++++++++++++++++++++++++++++++++ internal/tui/model.go | 176 +++++++++++++++++++++++++++++++++++++++ internal/tui/styles.go | 57 +++++++++++++ internal/tui/tui.go | 20 +++++ 12 files changed, 789 insertions(+), 2 deletions(-) create mode 100644 cmd/tui.go create mode 100644 internal/tui/commands.go create mode 100644 internal/tui/detail.go create mode 100644 internal/tui/input.go create mode 100644 internal/tui/keys.go create mode 100644 internal/tui/list.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/styles.go create mode 100644 internal/tui/tui.go diff --git a/Makefile b/Makefile index f4e1981..fe9c81b 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ BINARY := nib MODULE := github.com/lerko/nib GOFLAGS := -trimpath -.PHONY: build dev watch test lint fmt vet clean run cert help +.PHONY: build dev tui watch test lint fmt vet clean run cert help ## —— Build —————————————————————————————————— @@ -12,6 +12,9 @@ build: ## Build production binary dev: ## Build and run with default serve go run . serve +tui: ## Launch the terminal UI + go run . tui + watch: ## Live-reload dev server (requires air) air diff --git a/cmd/tui.go b/cmd/tui.go new file mode 100644 index 0000000..9d43eab --- /dev/null +++ b/cmd/tui.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "github.com/lerko/nib/internal/tui" + "github.com/spf13/cobra" +) + +var tuiCmd = &cobra.Command{ + Use: "tui", + Short: "launch the terminal UI", + RunE: runTUI, +} + +func init() { + rootCmd.AddCommand(tuiCmd) +} + +func runTUI(_ *cobra.Command, _ []string) error { + store, err := openStore() + if err != nil { + return err + } + defer store.Close() + return tui.Run(store) +} diff --git a/go.mod b/go.mod index 9972a46..ec47185 100644 --- a/go.mod +++ b/go.mod @@ -11,15 +11,36 @@ require ( ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // 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/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/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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // 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 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // 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 d394f26..696989a 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,32 @@ 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/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/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +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/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= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +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/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= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -11,8 +35,20 @@ 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/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= +github.com/lucasb-eyer/go-colorful v1.3.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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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/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= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= @@ -20,11 +56,15 @@ 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.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= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 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= 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= @@ -32,9 +72,14 @@ 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/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 0000000..7a14e8d --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,51 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" +) + +type entitiesLoadedMsg struct { + entities []*db.Entity +} + +type entityCreatedMsg struct { + entity *db.Entity +} + +type entityDeletedMsg struct { + id string +} + +type errMsg struct { + err error +} + +func loadEntities(store *db.Store, params db.ListParams) tea.Cmd { + return func() tea.Msg { + entities, err := store.List(params) + if err != nil { + return errMsg{err} + } + return entitiesLoadedMsg{entities} + } +} + +func createEntity(store *db.Store, e *db.Entity) tea.Cmd { + return func() tea.Msg { + if err := store.Create(e); err != nil { + return errMsg{err} + } + return entityCreatedMsg{e} + } +} + +func deleteEntity(store *db.Store, id string) tea.Cmd { + return func() tea.Msg { + if _, err := store.SoftDelete(id); err != nil { + return errMsg{err} + } + return entityDeletedMsg{id} + } +} diff --git a/internal/tui/detail.go b/internal/tui/detail.go new file mode 100644 index 0000000..2bf1073 --- /dev/null +++ b/internal/tui/detail.go @@ -0,0 +1,106 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" +) + +type detailModel struct { + entity *db.Entity + scroll int + height int + width int +} + +func newDetailModel() detailModel { + return detailModel{} +} + +func (d *detailModel) setEntity(e *db.Entity) { + d.entity = e + d.scroll = 0 +} + +func (d *detailModel) setSize(width, height int) { + d.width = width + d.height = height +} + +func (d detailModel) update(msg tea.KeyMsg) detailModel { + switch msg.String() { + case "up", "k": + if d.scroll > 0 { + d.scroll-- + } + case "down", "j": + d.scroll++ + } + return d +} + +func (d detailModel) view(width int) string { + if d.entity == nil { + return "" + } + + e := d.entity + var b strings.Builder + + glyph := display.DisplayGlyph(e.Glyph, e.CardType) + header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID)) + b.WriteString(detailHeaderStyle.Render(header)) + b.WriteString("\n\n") + + if e.Title != nil { + b.WriteString(detailBodyStyle.Render("title: " + *e.Title)) + b.WriteString("\n") + } + + b.WriteString(detailBodyStyle.Render(e.Body)) + b.WriteString("\n") + + if len(e.Tags) > 0 { + tagParts := make([]string, len(e.Tags)) + for i, t := range e.Tags { + tagParts[i] = tagStyle.Render("#" + t) + } + b.WriteString("\n") + b.WriteString(detailBodyStyle.Render(strings.Join(tagParts, " "))) + b.WriteString("\n") + } + + b.WriteString("\n") + meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime)) + if e.ModifiedAt != e.CreatedAt { + meta += fmt.Sprintf("\nmodified %s", e.ModifiedAt.Format(time.DateTime)) + } + if e.TimeAnchor != nil { + meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor) + } + if e.Pinned { + meta += "\npinned" + } + if e.CardType != nil { + meta += fmt.Sprintf("\ncard %s", *e.CardType) + } + if e.CompletedAt != nil { + meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime)) + } + b.WriteString(idStyle.Render(meta)) + + lines := strings.Split(b.String(), "\n") + if d.scroll > 0 && d.scroll < len(lines) { + lines = lines[d.scroll:] + } + if d.height > 0 && len(lines) > d.height { + lines = lines[:d.height] + } + + return strings.Join(lines, "\n") +} diff --git a/internal/tui/input.go b/internal/tui/input.go new file mode 100644 index 0000000..485f937 --- /dev/null +++ b/internal/tui/input.go @@ -0,0 +1,76 @@ +package tui + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/parse" +) + +type inputModel struct { + ti textinput.Model + active bool +} + +func newInputModel() inputModel { + ti := textinput.New() + ti.Placeholder = "capture a thought…" + ti.Prompt = inputPromptStyle.Render("› ") + ti.CharLimit = 500 + return inputModel{ti: ti} +} + +func (i *inputModel) focus() { + i.active = true + i.ti.Focus() +} + +func (i *inputModel) reset() { + i.active = false + i.ti.SetValue("") + i.ti.Blur() +} + +func (i inputModel) submit() *db.Entity { + val := i.ti.Value() + if val == "" { + return nil + } + + parsed, err := parse.Parse(val) + if err != nil { + return nil + } + + e := &db.Entity{ + Body: parsed.Body, + Title: parsed.Title, + Glyph: db.Glyph(parsed.Glyph), + Tags: parsed.Tags, + } + if parsed.TimeAnchor != nil { + e.TimeAnchor = parsed.TimeAnchor + } + if parsed.CardSuffix != nil { + ct := db.CardType(*parsed.CardSuffix) + e.CardType = &ct + } + if parsed.Pin { + e.Pinned = true + } + if parsed.Description != nil { + e.Description = parsed.Description + } + + return e +} + +func (i inputModel) updateKey(msg tea.KeyMsg) inputModel { + i.ti, _ = i.ti.Update(msg) + return i +} + +func (i inputModel) view(width int) string { + return i.ti.View() +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..b779956 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,33 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Back key.Binding + Add key.Binding + Delete key.Binding + Quit key.Binding + Help key.Binding + PageUp key.Binding + PageDn key.Binding + Top key.Binding + Bottom key.Binding +} + +var keys = keyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")), + Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), + Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")), + PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")), + Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")), + Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")), +} diff --git a/internal/tui/list.go b/internal/tui/list.go new file mode 100644 index 0000000..03f615f --- /dev/null +++ b/internal/tui/list.go @@ -0,0 +1,174 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" +) + +type listModel struct { + entities []*db.Entity + cursor int + offset int + height int + width int +} + +func newListModel() listModel { + return listModel{} +} + +func (l *listModel) setEntities(entities []*db.Entity) { + l.entities = entities + if l.cursor >= len(entities) { + l.cursor = max(0, len(entities)-1) + } +} + +func (l *listModel) setSize(width, height int) { + l.width = width + l.height = height +} + +func (l listModel) selected() *db.Entity { + if len(l.entities) == 0 || l.cursor >= len(l.entities) { + return nil + } + return l.entities[l.cursor] +} + +func (l listModel) update(msg tea.KeyMsg) listModel { + switch msg.String() { + case "up", "k": + if l.cursor > 0 { + l.cursor-- + if l.cursor < l.offset { + l.offset = l.cursor + } + } + case "down", "j": + if l.cursor < len(l.entities)-1 { + l.cursor++ + visible := l.visibleCount() + if l.cursor >= l.offset+visible { + l.offset = l.cursor - visible + 1 + } + } + case "home", "g": + l.cursor = 0 + l.offset = 0 + case "end", "G": + l.cursor = max(0, len(l.entities)-1) + visible := l.visibleCount() + if l.cursor >= visible { + l.offset = l.cursor - visible + 1 + } + case "pgup", "ctrl+u": + l.cursor = max(0, l.cursor-l.visibleCount()) + if l.cursor < l.offset { + l.offset = l.cursor + } + case "pgdown", "ctrl+d": + l.cursor = min(len(l.entities)-1, l.cursor+l.visibleCount()) + visible := l.visibleCount() + if l.cursor >= l.offset+visible { + l.offset = l.cursor - visible + 1 + } + } + return l +} + +func (l listModel) view(width int) string { + if len(l.entities) == 0 { + return statusStyle.Render("no entities") + } + + var b strings.Builder + visible := l.visibleCount() + end := min(l.offset+visible, len(l.entities)) + + for i := l.offset; i < end; i++ { + e := l.entities[i] + line := renderEntity(e, width-4) + + if i == l.cursor { + b.WriteString(selectedItemStyle.Render(" " + line)) + } else { + b.WriteString(listItemStyle.Render(line)) + } + if i < end-1 { + b.WriteString("\n") + } + } + + return b.String() +} + +func (l listModel) visibleCount() int { + if l.height <= 0 { + return 20 + } + return l.height +} + +func renderEntity(e *db.Entity, maxWidth int) string { + glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType)) + id := idStyle.Render("[" + display.FormatID(e.ID) + "]") + + body := e.Body + if e.Title != nil { + body = *e.Title + } + + var tags string + if len(e.Tags) > 0 { + tagParts := make([]string, len(e.Tags)) + for i, t := range e.Tags { + tagParts[i] = tagStyle.Render("#" + t) + } + tags = " " + strings.Join(tagParts, " ") + } + + line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id) + + if maxWidth > 0 && len(stripAnsi(line)) > maxWidth { + body = truncate(body, maxWidth-20) + line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id) + } + + return line +} + +func truncate(s string, maxLen int) string { + if maxLen <= 3 { + return "…" + } + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen-1]) + "…" +} + +func stripAnsi(s string) string { + var b strings.Builder + inEsc := false + for _, r := range s { + if r == '\x1b' { + inEsc = true + continue + } + if inEsc { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + inEsc = false + } + continue + } + b.WriteRune(r) + } + return b.String() +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..30a6571 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,176 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" +) + +type viewState int + +const ( + stateList viewState = iota + stateDetail + stateInput +) + +type model struct { + store *db.Store + state viewState + width int + height int + + list listModel + detail detailModel + input inputModel + + status string + err error +} + +func newModel(store *db.Store) model { + return model{ + store: store, + state: stateList, + list: newListModel(), + detail: newDetailModel(), + input: newInputModel(), + } +} + +func (m model) Init() tea.Cmd { + return loadEntities(m.store, db.DefaultListParams()) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.list.setSize(m.width, m.contentHeight()) + m.detail.setSize(m.width, m.contentHeight()) + return m, nil + + case entitiesLoadedMsg: + m.list.setEntities(msg.entities) + m.status = "" + m.err = nil + return m, nil + + case entityCreatedMsg: + m.state = stateList + m.input.reset() + m.status = "created" + return m, loadEntities(m.store, db.DefaultListParams()) + + case entityDeletedMsg: + m.status = "deleted" + return m, loadEntities(m.store, db.DefaultListParams()) + + case errMsg: + m.err = msg.err + return m, nil + + case tea.KeyMsg: + if m.state == stateInput { + return m.updateInput(msg) + } + return m.updateKeys(msg) + } + + return m, nil +} + +func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case msg.String() == "q" || msg.String() == "ctrl+c": + return m, tea.Quit + + case msg.String() == "a" && m.state == stateList: + m.state = stateInput + m.input.focus() + return m, m.input.ti.Focus() + + case msg.String() == "esc": + if m.state == stateDetail { + m.state = stateList + } + return m, nil + + case msg.String() == "enter" && m.state == stateList: + if e := m.list.selected(); e != nil { + m.detail.setEntity(e) + m.state = stateDetail + } + return m, nil + + case msg.String() == "d" && m.state == stateList: + if e := m.list.selected(); e != nil { + return m, deleteEntity(m.store, e.ID) + } + return m, nil + } + + switch m.state { + case stateList: + m.list = m.list.update(msg) + case stateDetail: + m.detail = m.detail.update(msg) + } + return m, nil +} + +func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.state = stateList + m.input.reset() + return m, nil + case "enter": + if e := m.input.submit(); e != nil { + return m, createEntity(m.store, e) + } + return m, nil + } + + m.input = m.input.updateKey(msg) + return m, nil +} + +func (m model) View() string { + var content string + + switch m.state { + case stateList: + content = m.list.view(m.width) + case stateDetail: + content = m.detail.view(m.width) + case stateInput: + content = m.list.view(m.width) + } + + header := titleStyle.Render("nib") + footer := m.footerView() + + return header + "\n" + content + "\n" + footer +} + +func (m model) footerView() string { + if m.state == stateInput { + return m.input.view(m.width) + } + + if m.err != nil { + return errorStyle.Render("error: " + m.err.Error()) + } + + if m.status != "" { + return statusStyle.Render(m.status) + } + + return helpStyle.Render("a:add enter:view d:delete q:quit ?:help") +} + +func (m model) contentHeight() int { + return m.height - 3 +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..97432ac --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,57 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} + highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} + dim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"} + + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(highlight). + PaddingLeft(1) + + statusStyle = lipgloss.NewStyle(). + Foreground(dim). + PaddingLeft(1) + + listItemStyle = lipgloss.NewStyle(). + PaddingLeft(2) + + selectedItemStyle = lipgloss.NewStyle(). + PaddingLeft(1). + Bold(true). + Foreground(highlight). + SetString("›") + + glyphStyle = lipgloss.NewStyle(). + Width(2) + + tagStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) + + idStyle = lipgloss.NewStyle(). + Foreground(dim) + + inputPromptStyle = lipgloss.NewStyle(). + Foreground(highlight). + Bold(true) + + detailHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(highlight). + MarginBottom(1) + + detailBodyStyle = lipgloss.NewStyle(). + PaddingLeft(2). + PaddingTop(1) + + helpStyle = lipgloss.NewStyle(). + Foreground(dim). + PaddingLeft(1) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")). + PaddingLeft(1) +) diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..c29d615 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,20 @@ +package tui + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" +) + +func Run(store *db.Store) error { + m := newModel(store) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + return err + } + return nil +}