feat: add interactive TUI with bubbletea and lipgloss - Add TUI framework using `bubbletea` v0.24.0 for interactive terminal interface - New `internal/tui/model.go` with bubbletea model and lipgloss styling - Styled output with rounded borders, adaptive colours (light/dark terminal) - Show "Press q to quit..." message below verse box - Add smart TTY detection in `cmd/bibel.go` - Launch interactive TUI when stdout is a terminal - Fall back to formatted ANSI output in non‑interactive environments - Preserve `-p/--plain` flag to output plain verse text only - Update dependencies: add `bubbletea` and `lipgloss`, remove indirect `go-toml` - Revise `README.md` to document TUI features, controls, and multiple output modes - Update `CHANGELOG.md` with version 1.2.0 and detailed changes - Clean up formatting and add missing newlines at EOF in several internal files
@@ -8,6 +8,31 @@ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] +## [1.2.0] - 2026-04-16 + +### Added + +- **TUI framework** using `bubbletea` for interactive terminal user interface +- **Styled output** with `lipgloss` for terminal-adaptive colours and borders +- **Interactive mode**: Verse displayed in a rounded border box with "Press q +to quit..." message +- **Smart TTY detection**: Automatically falls back to formatted output in +non-interactive environments +- **Enhanced argument handling**: `-p/--plain` now outputs plain text without +TUI or formatting +- **New package structure**: `internal/tui/` containing TUI model and styling +components + +### Changed + +- **Program flow**: Default mode now launches interactive TUI when run in a +terminal +- **Plain mode behaviour**: `--plain` flag now outputs only verse text (no +header or TUI) +- **Terminal detection**: Non-TTY environments show formatted output with ANSI +colours +- **Dependencies**: Added `bubbletea` and `lipgloss` for TUI functionality + ## [1.1.0] - 2026-04-16 ### Added@@ -52,9 +77,9 @@ - Validation of verse ranges and error handling
### Removed -- OCaml implementation (`old_code.ml` kept for reference) +- OCaml implementation - Dependency on `bookmark.toml` file for progression -- Regex-based verse parsing (replaced with direct JSON indexing) +- Regex-based verse parsing (replaced with JSON Bible data) ## [0.1.0] - 2026-04-16@@ -71,8 +96,3 @@
- Rewritten from OCaml to Go programming language - Changed data format from custom to JSON - Improved error handling and validation - -[unreleased]: https://github.com/maxwelljensen/bibel/compare/v1.1.0...HEAD -[1.1.0]: https://github.com/maxwelljensen/bibel/compare/v1.0.0...v1.1.0 -[1.0.0]: https://github.com/maxwelljensen/bibel/compare/v0.1.0...v1.0.0 -[0.1.0]: https://github.com/maxwelljensen/bibel/releases/tag/v0.1.0
@@ -1,19 +1,21 @@
-# `bibel` - Bible Verse CLI Utility +# `bibel` - Bible Verse CLI/TUI Utility -A Go utility for displaying Bible verses. This tool shows Bible verses based on -the current date, displaying 12 verses per day through the four Gospels. +A Go utility for displaying Bible verses with interactive terminal interface. +This tool shows Bible verses based on the current date, displaying 12 verses +per day through the four Gospels. ## Features +- **Interactive TUI**: Terminal User Interface using `bubbletea` with styled boxes - **Date-Based Progression**: Automatically calculates position based on day of year (1 January = Matthew 1:1-12) - **Smart Sizing**: Default snippet size is 12 verses, extends to end of chapter if less than 12 verses remain -- **Colour Output**: ANSI color-coded output (green for book/chapter, yellow -for verse range) +- **Adaptive Styling**: Terminal-adaptive colours and borders using `lipgloss` +- **Interactive Controls**: Press `q` to quit (MOTD-like behaviour) - **Yearly Cycle**: Progresses through all four Gospels each year, restarting on 1 January -- **Simple CLI**: Run to display today's Bible snippet +- **Multiple Output Modes**: Interactive TUI, formatted ANSI, or plain text ## Installation@@ -76,13 +78,17 @@
``` . ├── cmd/bibel.go # Main CLI entry point -├── internal/bible/ -│ ├── verse.go # Data structures -│ ├── loader.go # JSON loading and indexing -│ ├── dateprogression.go # Date-based position calculation -│ ├── formatter.go # Output formatting -│ └── bookmark.go # Legacy bookmark management (optional) +├── internal/ +│ ├── bible/ # Core Bible functionality +│ │ ├── verse.go # Data structures +│ │ ├── loader.go # JSON loading and indexing +│ │ ├── dateprogression.go # Date-based position calculation +│ │ └── formatter.go # Output formatting +│ └── tui/ # Terminal User Interface +│ └── model.go # bubbletea TUI model and styling ├── books/pol_nbg.json # Bible data +├── go.mod # Go module dependencies +├── go.sum # Go dependency checksums └── old_code.ml # Original OCaml implementation ```@@ -99,9 +105,6 @@ ```bash
# Build go build ./cmd/bibel.go -# Run with today's date +# Run ./bibel - -# Test with specific date (environment variable) -GOOSE_TEST_DATE=2026-01-01 ./bibel ```
@@ -6,12 +6,14 @@ "os"
"time" "github.com/alexflint/go-arg" + "golang.org/x/term" "maxwelljensen/bibel/internal" + "maxwelljensen/bibel/internal/tui" ) // Args defines the command line arguments type Args struct { - Plain bool `arg:"-p,--plain" help:"output plain text without chapter name or verse numbers"` + Plain bool `arg:"-p,--plain" help:"output plain text without formatting or TUI"` } // Description returns a description of the program@@ -45,15 +47,28 @@ fmt.Fprintf(os.Stderr, "Error calculating date position: %v\n", err)
os.Exit(1) } - // Initialise formatter - formatter := bible.NewFormatter() + // If plain mode, just print plain text (no TUI) + if args.Plain { + formatter := bible.NewFormatter() + // In plain mode, we don't print the header + fmt.Println(formatter.ExtractAndFormat(bibleData, todayBookmark)) + return + } - // Print header (unless plain mode) - if !args.Plain { + // Check if we're running in a terminal + // If not, fall back to formatted output (similar to plain mode but with header) + if !term.IsTerminal(int(os.Stdout.Fd())) { + formatter := bible.NewFormatter() fmt.Println(formatter.FormatHeader(todayBookmark)) + fmt.Println(formatter.ExtractAndFormat(bibleData, todayBookmark)) + return } - // Print snippet - fmt.Println(formatter.ExtractAndFormat(bibleData, todayBookmark)) + // Create and run TUI program + program := tui.CreateTUIProgram(bibleData, todayBookmark, false) + if err := program.Start(); err != nil { + fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err) + os.Exit(1) + } }
@@ -3,6 +3,35 @@
go 1.25.8 require ( - github.com/alexflint/go-arg v1.6.1 // indirect + github.com/alexflint/go-arg v1.6.1 + github.com/charmbracelet/bubbletea v0.24.0 + github.com/charmbracelet/lipgloss v0.8.0 +) + +require ( github.com/alexflint/go-scalar v1.2.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.4.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.23 // 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 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.6.0 // indirect + golang.org/x/text v0.3.8 // indirect )
@@ -2,8 +2,76 @@ github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA=
github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc= github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +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/bubbletea v0.24.0 h1:l8PHrft/GIeikDPCUhQe53AJrDD8xGSn0Agirh8xbe8= +github.com/charmbracelet/bubbletea v0.24.0/go.mod h1:rK3g/2+T8vOSEkNHvtq40umJpeVYDn6bLaqbgzhL/hg= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +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.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/lipgloss v0.8.0 h1:IS00fk4XAHcf8uZKc3eHeMUTCxUH6NkaTrdyCQk84RU= +github.com/charmbracelet/lipgloss v0.8.0/go.mod h1:p4eYUZZJ/0oXTuCQKFF8mqyKCz0ja6y+7DniDDw5KKU= +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.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +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.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +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/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= -github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.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.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/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/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/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/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -19,10 +19,10 @@ // GetPositionForDate calculates the verse range for a given date
func (dp *DateProgression) GetPositionForDate(date time.Time) (*Bookmark, error) { // Calculate day of year (1-366) dayOfYear := date.YearDay() - + // Each day shows 12 verses targetVerseOffset := (dayOfYear - 1) * 12 // Zero-indexed - + // Walk through Gospels to find the position return dp.findPositionForOffset(targetVerseOffset) }@@ -39,16 +39,16 @@ maxChapter = chapter - 1
break } } - + // Iterate through chapters for chapter := 1; chapter <= maxChapter; chapter++ { versesInChapter := dp.bible.CountVersesInChapter(book, chapter) - + // Check if offset falls within this chapter if offset < versesInChapter { // Offset is within this chapter firstVerse := offset + 1 // Convert from 0-indexed to 1-indexed - + // Create initial bookmark (like AdvanceBookmark does) secondVerse := min(firstVerse+11, versesInChapter) bookmark := &Bookmark{@@ -57,26 +57,26 @@ Chapter: chapter,
FirstVerse: firstVerse, SecondVerse: secondVerse, } - + // Apply lookahead rule (like AdjustForLookahead) return dp.applyLookahead(bookmark), nil } - + // Move to next chapter, subtracting this chapter's verses offset -= versesInChapter } } - - // If we've gone through all Gospels and offset is still positive, - // wrap around to beginning (start over) - // Calculate modulo offset within total Gospel verses + + // If we've gone through all Gospels and offset is still positive, wrap + // around to beginning (start over) Calculate modulo offset within total + // Gospel verses totalVerses := dp.GetTotalGospelVerses() if offset >= 0 { adjustedOffset := offset % totalVerses // Recursively find position for adjusted offset return dp.findPositionForOffset(adjustedOffset) } - + return nil, fmt.Errorf("could not find position for offset %d", offset) }@@ -84,7 +84,7 @@ // applyLookahead applies the lookahead rule (same as BookmarkManager.AdjustForLookahead)
func (dp *DateProgression) applyLookahead(bookmark *Bookmark) *Bookmark { versesInChapter := dp.bible.CountVersesInChapter(int(bookmark.Book), bookmark.Chapter) versesRemaining := versesInChapter - bookmark.SecondVerse - + // If we're not at the end of the chapter and less than 12 verses remain for NEXT snippet if versesRemaining > 0 && versesRemaining < 12 { return &Bookmark{@@ -94,7 +94,7 @@ FirstVerse: bookmark.FirstVerse,
SecondVerse: versesInChapter, } } - + // Otherwise return the original bookmark return bookmark }@@ -112,4 +112,5 @@ total += verses
} } return total -}+} +
@@ -18,18 +18,18 @@ // FormatHeader formats the header for a bookmark with ANSI colors
func (f *Formatter) FormatHeader(bookmark *Bookmark) string { // ANSI color codes const ( - reset = "\033[0m" - green = "\033[32m" - yellow = "\033[33m" + reset = "\033[0m" + green = "\033[32m" + yellow = "\033[33m" ) - - return fmt.Sprintf("%s%s %d%s\n%s w. %d-%d%s", + + return fmt.Sprintf("%s%s %d%s\n%s w. %d-%d%s", green, - bookmark.Book.String(), + bookmark.Book.String(), bookmark.Chapter, reset, yellow, - bookmark.FirstVerse, + bookmark.FirstVerse, bookmark.SecondVerse, reset) }@@ -39,7 +39,7 @@ func (f *Formatter) FormatSnippet(verses []*Verse) string {
if len(verses) == 0 { return "Error: No text matched" } - + var sb strings.Builder for i, verse := range verses { if i > 0 {@@ -54,13 +54,14 @@ text = text[1:]
} sb.WriteString(text) } - + return sb.String() } // ExtractAndFormat extracts verses for a bookmark and formats them func (f *Formatter) ExtractAndFormat(bible *Bible, bookmark *Bookmark) string { - verses := bible.GetVerseRange(int(bookmark.Book), bookmark.Chapter, + verses := bible.GetVerseRange(int(bookmark.Book), bookmark.Chapter, bookmark.FirstVerse, bookmark.SecondVerse) return f.FormatSnippet(verses) -}+} +
@@ -8,11 +8,11 @@
// Bible represents the complete Bible data type Bible struct { Metadata struct { - Name string `json:"name"` - Lang string `json:"lang_short"` + Name string `json:"name"` + Lang string `json:"lang_short"` } `json:"metadata"` Verses []Verse `json:"verses"` - + // Index for quick lookups byBookChapterVerse map[int]map[int]map[int]*Verse }@@ -23,12 +23,12 @@ data, err := os.ReadFile(filePath)
if err != nil { return nil, err } - + var bible Bible if err := json.Unmarshal(data, &bible); err != nil { return nil, err } - + bible.buildIndex() return &bible, nil }@@ -36,18 +36,18 @@
// buildIndex creates a lookup index for verses func (b *Bible) buildIndex() { b.byBookChapterVerse = make(map[int]map[int]map[int]*Verse) - + for i := range b.Verses { verse := &b.Verses[i] - + if b.byBookChapterVerse[verse.Book] == nil { b.byBookChapterVerse[verse.Book] = make(map[int]map[int]*Verse) } - + if b.byBookChapterVerse[verse.Book][verse.Chapter] == nil { b.byBookChapterVerse[verse.Book][verse.Chapter] = make(map[int]*Verse) } - + b.byBookChapterVerse[verse.Book][verse.Chapter][verse.VerseNum] = verse } }@@ -98,7 +98,7 @@
// GetVerseRange retrieves verses in a range func (b *Bible) GetVerseRange(book, chapter, firstVerse, lastVerse int) []*Verse { var verses []*Verse - + if chapterMap, ok := b.byBookChapterVerse[book]; ok { if verseMap, ok := chapterMap[chapter]; ok { for i := firstVerse; i <= lastVerse; i++ {@@ -108,6 +108,6 @@ }
} } } - + return verses -}+}
@@ -0,0 +1,197 @@
+package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + bible "maxwelljensen/bibel/internal" +) + +// Model represents the TUI application state +type Model struct { + bibleData *bible.Bible + bookmark *bible.Bookmark + width int + height int + quitting bool + plainMode bool + styles *Styles + formatter *bible.Formatter +} + +// Styles contains the lipgloss styles for the TUI +type Styles struct { + box lipgloss.Style + header lipgloss.Style + content lipgloss.Style + quitMessage lipgloss.Style +} + +// NewModel creates a new TUI model with the given Bible data and bookmark +func NewModel(bibleData *bible.Bible, bookmark *bible.Bookmark, plainMode bool) Model { + m := Model{ + bibleData: bibleData, + bookmark: bookmark, + plainMode: plainMode, + styles: createStyles(), + formatter: &bible.Formatter{}, + } + return m +} + +// createStyles creates the lipgloss styles, adapting to terminal background color +func createStyles() *Styles { + // Detect if terminal has dark background + hasDarkBG := lipgloss.HasDarkBackground() + + // Helper function for light/dark colors + lightDark := func(light, dark lipgloss.TerminalColor) lipgloss.TerminalColor { + if hasDarkBG { + return dark + } + return light + } + + // Colors that match the existing formatter colors (green/yellow) + // but adapted for light/dark backgrounds + green := lightDark(lipgloss.Color("#00AA00"), lipgloss.Color("#00FF00")) + borderColor := lightDark(lipgloss.Color("#666666"), lipgloss.Color("#999999")) + textColor := lightDark(lipgloss.Color("#000000"), lipgloss.Color("#FFFFFF")) + quitColor := lightDark(lipgloss.Color("#555555"), lipgloss.Color("#AAAAAA")) + + return &Styles{ + box: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(1, 2). + Margin(1, 0), + + header: lipgloss.NewStyle(). + Foreground(green). + Bold(true). + MarginBottom(1), + + content: lipgloss.NewStyle(). + Foreground(textColor), + + quitMessage: lipgloss.NewStyle(). + Foreground(quitColor). + Italic(true). + MarginTop(1), + } +} + +// Init initializes the model (required by bubbletea) +func (m Model) Init() tea.Cmd { + // No initial command needed + return nil +} + +// Update handles messages and updates the model +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "Q", "ctrl+c", "esc": + m.quitting = true + return m, tea.Quit + } + case tea.WindowSizeMsg: + // Update window dimensions + m.width = msg.Width + m.height = msg.Height + } + + return m, nil +} + +// View renders the UI +func (m Model) View() string { + if m.quitting { + return "" + } + + // Get formatted content using the formatter + header := m.formatter.FormatHeader(m.bookmark) + content := m.formatter.ExtractAndFormat(m.bibleData, m.bookmark) + + // In plain mode, just return the original formatted text (with ANSI colors) + if m.plainMode { + var output strings.Builder + output.WriteString(header) + output.WriteString("\n") + output.WriteString(content) + return output.String() + } + + // For TUI mode, we need to parse the header to extract the book/chapter and verse range + // The header from formatter has ANSI codes, but we want to style with lipgloss + // Let's extract just the text without ANSI codes + headerText := stripANSI(header) + + // Build the box content + var boxContent strings.Builder + + // Add header styled with lipgloss + boxContent.WriteString(m.styles.header.Render(headerText)) + boxContent.WriteString("\n\n") + + // Add verse content with wrapping based on available width + // Calculate available width for content + // Box has: Padding(1, 2) = 2 left + 2 right = 4 + // Border = 1 left + 1 right = 2 + // Total horizontal frame = 6 + // Use window width with some margin + availableWidth := m.width - 10 // Leave some terminal margin + if availableWidth < 30 { + availableWidth = 30 // Minimum reasonable width + } + contentWidth := availableWidth - 6 // Account for box frame + + // Apply width constraint for text wrapping + wrappedContent := m.styles.content.Width(contentWidth).Render(content) + boxContent.WriteString(wrappedContent) + + // Create the box with max width constraint + box := m.styles.box.MaxWidth(availableWidth).Render(boxContent.String()) + + // Add quit message below the box + quitMsg := m.styles.quitMessage.Render("Press q to quit...") + + // Combine box and quit message + return lipgloss.JoinVertical(lipgloss.Center, box, quitMsg) +} + +// CreateTUIProgram creates and returns a bubbletea program for the Bible verse +func CreateTUIProgram(bible *bible.Bible, bookmark *bible.Bookmark, plainMode bool) *tea.Program { + model := NewModel(bible, bookmark, plainMode) + return tea.NewProgram(model) +} + +// stripANSI removes ANSI escape codes from a string +func stripANSI(str string) string { + var result strings.Builder + inEscape := false + + for i := 0; i < len(str); i++ { + if str[i] == '\033' && i+1 < len(str) && str[i+1] == '[' { + inEscape = true + i++ // Skip the '[' + continue + } + + if inEscape { + // Look for the end of the escape sequence (letter m) + if str[i] >= 'A' && str[i] <= 'Z' || str[i] >= 'a' && str[i] <= 'z' { + inEscape = false + } + continue + } + + result.WriteByte(str[i]) + } + + return result.String() +} +
@@ -2,19 +2,19 @@ package bible
// Verse represents a single Bible verse type Verse struct { - BookName string `json:"book_name"` - Book int `json:"book"` - Chapter int `json:"chapter"` - VerseNum int `json:"verse"` - Text string `json:"text"` + BookName string `json:"book_name"` + Book int `json:"book"` + Chapter int `json:"chapter"` + VerseNum int `json:"verse"` + Text string `json:"text"` } // VerseRange represents a range of verses (inclusive) type VerseRange struct { - Book int - Chapter int - FirstVerse int - LastVerse int + Book int + Chapter int + FirstVerse int + LastVerse int } // BookIndex represents the four Gospels@@ -48,4 +48,5 @@ Book BookIndex `toml:"book"`
Chapter int `toml:"chapter"` FirstVerse int `toml:"first_verse"` SecondVerse int `toml:"second_verse"` -}+} +