aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/lectio-ui/main.go13
-rw-r--r--internal/bible/bible.go13
-rw-r--r--internal/bible/bible_test.go16
-rw-r--r--internal/config/config.go17
-rw-r--r--internal/i18n/i18n.go13
-rw-r--r--internal/tui/reader.go445
-rw-r--r--internal/tui/reader_test.go100
7 files changed, 614 insertions, 3 deletions
diff --git a/cmd/lectio-ui/main.go b/cmd/lectio-ui/main.go
index 023cf66..d8e2f27 100644
--- a/cmd/lectio-ui/main.go
+++ b/cmd/lectio-ui/main.go
@@ -10,6 +10,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
+ "github.com/lukaszkasprzak/lectio/internal/bible"
"github.com/lukaszkasprzak/lectio/internal/config"
"github.com/lukaszkasprzak/lectio/internal/tui"
)
@@ -21,6 +22,7 @@ Usage:
Flags:
-b, --bible VER start on version: bt,wuj,vul,grb,drb
+ -R, --reader open the Bible reader (book picker) instead of the daily view
-a, --all show all parts (override config)
-o, --offline cache/sigla only, no network
-l, --lectionary WHICH new|trad (trad -> traditional)
@@ -64,7 +66,7 @@ func run(args []string, stdout, stderr io.Writer) int {
return 1
}
- var all, offline bool
+ var all, offline, reader bool
var bibleVer, lectionary, lang string
fs := flag.NewFlagSet("lectio-ui", flag.ContinueOnError)
@@ -73,6 +75,8 @@ func run(args []string, stdout, stderr io.Writer) int {
fs.StringVar(&bibleVer, "b", "", "start on version: bt,wuj,vul,grb,drb")
fs.StringVar(&bibleVer, "bible", "", "start on version: bt,wuj,vul,grb,drb")
+ fs.BoolVar(&reader, "R", false, "open the Bible reader (fuzzy book picker) instead of the daily view")
+ fs.BoolVar(&reader, "reader", false, "open the Bible reader (fuzzy book picker) instead of the daily view")
fs.BoolVar(&all, "a", cfg.All, "show all parts (override config)")
fs.BoolVar(&all, "all", cfg.All, "show all parts (override config)")
fs.BoolVar(&offline, "o", cfg.Offline, "cache/sigla only, no network")
@@ -113,7 +117,12 @@ func run(args []string, stdout, stderr io.Writer) int {
cfg.TraditionalLang = lang
}
- if _, err := tea.NewProgram(tui.New(cfg, date, bibleVer), tea.WithAltScreen()).Run(); err != nil {
+ var mdl tea.Model = tui.New(cfg, date, bibleVer)
+ if reader {
+ tbl, _ := bible.LoadBookTable(config.UserBooksTOML())
+ mdl = tui.NewReader(cfg, tbl)
+ }
+ if _, err := tea.NewProgram(mdl, tea.WithAltScreen()).Run(); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
diff --git a/internal/bible/bible.go b/internal/bible/bible.go
index 0254ceb..312daeb 100644
--- a/internal/bible/bible.go
+++ b/internal/bible/bible.go
@@ -2,6 +2,7 @@ package bible
import (
"embed"
+ "sort"
"strconv"
"strings"
"sync"
@@ -58,3 +59,15 @@ func load(version string) *corpus {
func Verses(version, book string, chap int) []Verse {
return load(version).books[book][chap]
}
+
+// Chapters returns the chapter numbers present for a book in a version, sorted
+// ascending (empty if the version has no such book).
+func Chapters(version, book string) []int {
+ chapMap := load(version).books[book]
+ chaps := make([]int, 0, len(chapMap))
+ for ch := range chapMap {
+ chaps = append(chaps, ch)
+ }
+ sort.Ints(chaps)
+ return chaps
+}
diff --git a/internal/bible/bible_test.go b/internal/bible/bible_test.go
index efd22d6..36223a3 100644
--- a/internal/bible/bible_test.go
+++ b/internal/bible/bible_test.go
@@ -28,3 +28,19 @@ func TestVerses(t *testing.T) {
}
func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p }
+
+func TestChapters(t *testing.T) {
+ ch := Chapters("wuj", "John")
+ if len(ch) == 0 || ch[0] != 1 {
+ t.Fatalf("John chapters = %v", ch)
+ }
+ for i := 1; i < len(ch); i++ {
+ if ch[i] <= ch[i-1] {
+ t.Errorf("chapters not sorted ascending: %v", ch)
+ break
+ }
+ }
+ if got := Chapters("wuj", "Nonesuch"); len(got) != 0 {
+ t.Errorf("missing book chapters = %v want empty", got)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 8ee15e1..9a41adc 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -24,7 +24,7 @@ var seedTOML []byte
// Version is lectio's release version, shared by every binary's
// -v/--version output (lectio, lectio-ui, lectio-web).
-const Version = "0.7.0"
+const Version = "0.8.0"
// validVersions are the five scripture versions lectio understands.
var validVersions = map[string]bool{
@@ -203,6 +203,21 @@ func BooksPath() (string, error) {
return filepath.Join(filepath.Dir(p), "books.toml"), nil
}
+// UserBooksTOML returns the bytes of the optional user books.toml (see
+// BooksPath), or nil when it is absent or unreadable -- callers then fall back
+// to bible's embedded default table.
+func UserBooksTOML() []byte {
+ p, err := BooksPath()
+ if err != nil {
+ return nil
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return nil
+ }
+ return b
+}
+
// seedIfMissing writes the embedded default config to path if nothing is
// there yet.
func seedIfMissing(path string) error {
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
index a108323..f752e9b 100644
--- a/internal/i18n/i18n.go
+++ b/internal/i18n/i18n.go
@@ -24,6 +24,9 @@ type UI struct {
// error string directly onto them, no separator added at the call site).
FooterKeys, Loading, NoReadingsFor, ErrorPrefix, ErrorHint string
+ // Reader-mode (lectio-ui --reader) chrome.
+ ReaderTitle, ReaderPickKeys, ReaderReadKeys, ReaderNoText, ReaderNoMatch string
+
// CLI banner label words and connective: the banner is
// "<word> <BannerConnective> <date>" (e.g. "Gospel for 2026-07-22" /
// "Ewangelia na 2026-07-22").
@@ -82,6 +85,11 @@ var enUI = UI{
NoReadingsFor: "no readings for ",
ErrorPrefix: "error: ",
ErrorHint: "change date (←/→) or refresh (r)",
+ ReaderTitle: "reader — pick a book",
+ ReaderPickKeys: "type to filter ↑/↓ move enter open esc quit",
+ ReaderReadKeys: "n/p chapter tab/⇧tab version j/k scroll space page g/G ends esc books q quit",
+ ReaderNoText: "(no text in this version)",
+ ReaderNoMatch: "(no matching books)",
BannerGospel: "Gospel",
BannerReadings: "Readings",
BannerConnective: "for",
@@ -125,6 +133,11 @@ var plUI = UI{
NoReadingsFor: "brak czytań na ",
ErrorPrefix: "błąd: ",
ErrorHint: "zmień datę (←/→) lub odśwież (r)",
+ ReaderTitle: "czytnik — wybierz księgę",
+ ReaderPickKeys: "wpisz, by filtrować ↑/↓ ruch enter otwórz esc wyjście",
+ ReaderReadKeys: "n/p rozdział tab/⇧tab wersja j/k przewiń spacja strona g/G końce esc księgi q wyjście",
+ ReaderNoText: "(brak tekstu w tej wersji)",
+ ReaderNoMatch: "(brak pasujących ksiąg)",
BannerGospel: "Ewangelia",
BannerReadings: "Czytania",
BannerConnective: "na",
diff --git a/internal/tui/reader.go b/internal/tui/reader.go
new file mode 100644
index 0000000..8e33534
--- /dev/null
+++ b/internal/tui/reader.go
@@ -0,0 +1,445 @@
+package tui
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/lukaszkasprzak/lectio/internal/bible"
+ "github.com/lukaszkasprzak/lectio/internal/config"
+ "github.com/lukaszkasprzak/lectio/internal/i18n"
+)
+
+// readerMode is the reader's screen: the book picker or the chapter view.
+type readerMode int
+
+const (
+ modePick readerMode = iota
+ modeRead
+)
+
+// selStyle marks the picker's selected row (reverse video, legible on any theme).
+var selStyle = lipgloss.NewStyle().Reverse(true)
+
+// ReaderModel is lectio-ui's --reader mode: a fuzzy book picker over the sigla
+// dialect's book names, then a scrolling view of a chapter from the embedded
+// corpora with chapter navigation and version cycling. It implements tea.Model
+// and reuses the package's styleBlock/styles. It never touches the network.
+type ReaderModel struct {
+ cfg config.Config
+ dialect string // sigla dialect / book-name language ("en"/"pl")
+ books []bible.BookInfo // dialect books, scriptural order
+ versions []string // corpus versions available (wuj/vul/grb/drb subset)
+ verIdx int
+
+ mode readerMode
+
+ // picker
+ query string
+ matches []int // indices into books, filtered + ranked
+ pickSel int // selected position within matches
+ pickTop int // first visible match (list scroll)
+
+ // reading
+ bookIdx int // index into books
+ chapters []int // sorted chapters for current book+version
+ chapPos int // index into chapters
+ verses []bible.Verse
+ scroll int
+
+ width, height int
+}
+
+// NewReader builds the reader. tbl supplies the dialect book names/abbrevs
+// (cfg.SiglaLang() picks the dialect); versions is the corpus-backed subset of
+// cfg.Versions (wuj/vul/grb/drb), since "bt" has no full text to read.
+func NewReader(cfg config.Config, tbl *bible.BookTable) ReaderModel {
+ dialect := cfg.SiglaLang()
+ m := ReaderModel{
+ cfg: cfg,
+ dialect: dialect,
+ books: tbl.Books(dialect),
+ versions: corpusVersions(cfg),
+ mode: modePick,
+ }
+ m.refilter()
+ return m
+}
+
+// corpusVersions returns the readable (corpus-backed) versions from cfg, in
+// config order, never empty: bt is dropped and, if nothing is left, all four
+// bundled corpora are offered.
+func corpusVersions(cfg config.Config) []string {
+ var out []string
+ for _, v := range cfg.Versions {
+ if config.ValidVersion(v) && v != "bt" {
+ out = append(out, v)
+ }
+ }
+ if len(out) == 0 {
+ out = []string{"wuj", "vul", "grb", "drb"}
+ }
+ return out
+}
+
+func (m ReaderModel) version() string {
+ if m.verIdx < 0 || m.verIdx >= len(m.versions) {
+ return ""
+ }
+ return m.versions[m.verIdx]
+}
+
+func (m ReaderModel) Init() tea.Cmd { return nil }
+
+// refilter rebuilds matches from query (fuzzy, ranked); empty query lists all
+// books in scriptural order.
+func (m *ReaderModel) refilter() {
+ m.matches = m.matches[:0]
+ q := strings.TrimSpace(m.query)
+ if q == "" {
+ for i := range m.books {
+ m.matches = append(m.matches, i)
+ }
+ } else {
+ type sc struct{ i, score int }
+ var scored []sc
+ for i, b := range m.books {
+ if s, ok := fuzzyScore(q, b.Shortcut+" "+b.Name); ok {
+ scored = append(scored, sc{i, s})
+ }
+ }
+ sort.SliceStable(scored, func(a, b int) bool { return scored[a].score > scored[b].score })
+ for _, s := range scored {
+ m.matches = append(m.matches, s.i)
+ }
+ }
+ if m.pickSel >= len(m.matches) {
+ m.pickSel = len(m.matches) - 1
+ }
+ if m.pickSel < 0 {
+ m.pickSel = 0
+ }
+ m.pickTop = 0
+}
+
+// fuzzyScore ranks target against query: a substring hit (prefix best) beats a
+// subsequence hit; ok=false when query is not even a subsequence.
+func fuzzyScore(q, target string) (int, bool) {
+ ql, tl := strings.ToLower(q), strings.ToLower(target)
+ if i := strings.Index(tl, ql); i >= 0 {
+ return 1000 - i, true
+ }
+ ti := 0
+ for _, qc := range ql {
+ idx := strings.IndexRune(tl[ti:], qc)
+ if idx < 0 {
+ return 0, false
+ }
+ ti += idx + len(string(qc))
+ }
+ return 100, true
+}
+
+func (m ReaderModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width, m.height = msg.Width, msg.Height
+ return m, nil
+ case tea.KeyMsg:
+ if m.mode == modePick {
+ return m.updatePick(msg)
+ }
+ return m.updateRead(msg)
+ }
+ return m, nil
+}
+
+func (m ReaderModel) updatePick(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyCtrlC:
+ return m, tea.Quit
+ case tea.KeyEsc:
+ if m.query != "" {
+ m.query = ""
+ m.refilter()
+ return m, nil
+ }
+ return m, tea.Quit
+ case tea.KeyEnter:
+ if len(m.matches) > 0 {
+ m.bookIdx = m.matches[m.pickSel]
+ m = m.openBook()
+ m.mode = modeRead
+ }
+ return m, nil
+ case tea.KeyUp:
+ if m.pickSel > 0 {
+ m.pickSel--
+ }
+ return m, nil
+ case tea.KeyDown:
+ if m.pickSel < len(m.matches)-1 {
+ m.pickSel++
+ }
+ return m, nil
+ case tea.KeyBackspace:
+ if r := []rune(m.query); len(r) > 0 {
+ m.query = string(r[:len(r)-1])
+ m.refilter()
+ }
+ return m, nil
+ case tea.KeySpace:
+ m.query += " "
+ m.refilter()
+ return m, nil
+ case tea.KeyRunes:
+ m.query += string(msg.Runes)
+ m.refilter()
+ return m, nil
+ }
+ return m, nil
+}
+
+func (m ReaderModel) updateRead(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "q", "ctrl+c":
+ return m, tea.Quit
+ case "esc", "backspace":
+ m.mode = modePick
+ return m, nil
+ case "tab":
+ return m.cycleVersion(+1), nil
+ case "shift+tab":
+ return m.cycleVersion(-1), nil
+ case "n", "]", "right", "l":
+ return m.chapterStep(+1), nil
+ case "p", "[", "left", "h":
+ return m.chapterStep(-1), nil
+ case "j", "down":
+ m.scroll = m.clampRead(m.scroll + 1)
+ return m, nil
+ case "k", "up":
+ m.scroll = m.clampRead(m.scroll - 1)
+ return m, nil
+ case " ", "f":
+ m.scroll = m.clampRead(m.scroll + m.readVisible())
+ return m, nil
+ case "u":
+ m.scroll = m.clampRead(m.scroll - m.readVisible())
+ return m, nil
+ case "g":
+ m.scroll = 0
+ return m, nil
+ case "G":
+ m.scroll = m.clampRead(1 << 30)
+ return m, nil
+ }
+ return m, nil
+}
+
+// openBook loads the chapter list + first chapter for the selected book in the
+// active version.
+func (m ReaderModel) openBook() ReaderModel {
+ m.chapters = bible.Chapters(m.version(), m.books[m.bookIdx].Canonical)
+ m.chapPos = 0
+ m.scroll = 0
+ return m.loadVerses()
+}
+
+func (m ReaderModel) loadVerses() ReaderModel {
+ if len(m.chapters) == 0 {
+ m.verses = nil
+ return m
+ }
+ if m.chapPos < 0 {
+ m.chapPos = 0
+ }
+ if m.chapPos >= len(m.chapters) {
+ m.chapPos = len(m.chapters) - 1
+ }
+ m.verses = bible.Verses(m.version(), m.books[m.bookIdx].Canonical, m.chapters[m.chapPos])
+ return m
+}
+
+func (m ReaderModel) chapterStep(d int) ReaderModel {
+ if len(m.chapters) == 0 {
+ return m
+ }
+ np := m.chapPos + d
+ if np < 0 || np >= len(m.chapters) || np == m.chapPos {
+ return m
+ }
+ m.chapPos = np
+ m.scroll = 0
+ return m.loadVerses()
+}
+
+// cycleVersion moves the active version by d, wrapping. In reading mode it
+// recomputes the book's chapter list for the new version and keeps the same
+// chapter NUMBER when that version has it (else clamps).
+func (m ReaderModel) cycleVersion(d int) ReaderModel {
+ n := len(m.versions)
+ if n == 0 {
+ return m
+ }
+ curChap := 0
+ if len(m.chapters) > 0 && m.chapPos < len(m.chapters) {
+ curChap = m.chapters[m.chapPos]
+ }
+ m.verIdx = ((m.verIdx+d)%n + n) % n
+ if m.mode == modeRead {
+ m.chapters = bible.Chapters(m.version(), m.books[m.bookIdx].Canonical)
+ m.chapPos = 0
+ for i, c := range m.chapters {
+ if c == curChap {
+ m.chapPos = i
+ break
+ }
+ }
+ m.scroll = 0
+ m = m.loadVerses()
+ }
+ return m
+}
+
+// readVisible is how many reading lines fit between the 1-line header and
+// 1-line footer, with a sane default before the first WindowSizeMsg.
+func (m ReaderModel) readVisible() int {
+ chrome := 3 // header + footer + margin
+ if m.height <= chrome {
+ return 10
+ }
+ return m.height - chrome
+}
+
+func (m ReaderModel) clampRead(s int) int {
+ return clampScroll(s, len(m.readBody(m.innerW())), m.readVisible())
+}
+
+func (m ReaderModel) innerW() int {
+ w := m.width
+ if w <= 0 {
+ w = 80
+ }
+ iw := w - 2
+ if iw < 20 {
+ iw = 20
+ }
+ return iw
+}
+
+// readBody returns the styled, wrapped verse lines for the current chapter.
+func (m ReaderModel) readBody(w int) []string {
+ ui := i18n.Get(m.cfg.UILanguage)
+ if len(m.chapters) == 0 || len(m.verses) == 0 {
+ return []string{citationStyle.Render(ui.ReaderNoText)}
+ }
+ blocks := make([]string, len(m.verses))
+ for i, v := range m.verses {
+ blocks[i] = fmt.Sprintf("%d:%d %s", v.Chapter, v.Verse, v.Text)
+ }
+ numW := maxNumWidth(blocks)
+ var lines []string
+ for _, b := range blocks {
+ lines = append(lines, styleBlock(b, false, w, numW)...)
+ }
+ return lines
+}
+
+func (m ReaderModel) View() string {
+ if m.mode == modePick {
+ return m.viewPick()
+ }
+ return m.viewRead()
+}
+
+func (m ReaderModel) viewPick() string {
+ w := m.width
+ if w <= 0 {
+ w = 80
+ }
+ ui := i18n.Get(m.cfg.UILanguage)
+ header := headerStyle.Width(w).Render(ui.ReaderTitle)
+ footer := footerStyle.Width(w).Render(ui.ReaderPickKeys)
+ prompt := headingStyle.Render("› ") + m.query
+
+ // visible list window (header + prompt + footer + margin = 4 chrome lines)
+ visible := m.height - 4
+ if visible < 3 {
+ visible = 3
+ }
+ // keep selection in view
+ top := m.pickTop
+ if m.pickSel < top {
+ top = m.pickSel
+ }
+ if m.pickSel >= top+visible {
+ top = m.pickSel - visible + 1
+ }
+ if top < 0 {
+ top = 0
+ }
+
+ col := shortcutCol(m.books)
+ var rows []string
+ if len(m.matches) == 0 {
+ rows = append(rows, citationStyle.Render(ui.ReaderNoMatch))
+ }
+ for i := top; i < len(m.matches) && i < top+visible; i++ {
+ b := m.books[m.matches[i]]
+ pad := col - len([]rune(b.Shortcut))
+ if pad < 1 {
+ pad = 1
+ }
+ row := b.Shortcut + strings.Repeat(" ", pad) + b.Name
+ if i == m.pickSel {
+ row = selStyle.Render("› " + row)
+ } else {
+ row = " " + row
+ }
+ rows = append(rows, row)
+ }
+ return header + "\n" + prompt + "\n" + strings.Join(rows, "\n") + "\n" + footer
+}
+
+// shortcutCol is the width of the shortcut column: widest shortcut + 2.
+func shortcutCol(books []bible.BookInfo) int {
+ w := 0
+ for _, b := range books {
+ if n := len([]rune(b.Shortcut)); n > w {
+ w = n
+ }
+ }
+ return w + 2
+}
+
+func (m ReaderModel) viewRead() string {
+ w := m.width
+ if w <= 0 {
+ w = 80
+ }
+ ui := i18n.Get(m.cfg.UILanguage)
+ b := m.books[m.bookIdx]
+ chap := 0
+ if len(m.chapters) > 0 && m.chapPos < len(m.chapters) {
+ chap = m.chapters[m.chapPos]
+ }
+ label := m.version()
+ if l, ok := ui.Version[m.version()]; ok {
+ label = l
+ }
+ header := headerStyle.Width(w).Render(fmt.Sprintf("%s %d [%s]", b.Name, chap, label))
+ footer := footerStyle.Width(w).Render(ui.ReaderReadKeys)
+
+ body := m.readBody(m.innerW())
+ visible := m.readVisible()
+ scroll := clampScroll(m.scroll, len(body), visible)
+ end := scroll + visible
+ if end > len(body) {
+ end = len(body)
+ }
+ return header + "\n" + strings.Join(body[scroll:end], "\n") + "\n" + footer
+}
diff --git a/internal/tui/reader_test.go b/internal/tui/reader_test.go
new file mode 100644
index 0000000..167e546
--- /dev/null
+++ b/internal/tui/reader_test.go
@@ -0,0 +1,100 @@
+package tui
+
+import (
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "github.com/lukaszkasprzak/lectio/internal/bible"
+ "github.com/lukaszkasprzak/lectio/internal/config"
+)
+
+func enReader(t *testing.T) ReaderModel {
+ t.Helper()
+ tbl, err := bible.LoadBookTable(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg := config.Default()
+ cfg.UILanguage = "en"
+ cfg.SiglaStyle = "english"
+ cfg.Versions = []string{"bt", "wuj", "vul", "grb", "drb"}
+ return NewReader(cfg, tbl)
+}
+
+func key(m ReaderModel, k tea.KeyMsg) ReaderModel {
+ nm, _ := m.Update(k)
+ return nm.(ReaderModel)
+}
+
+func runes(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} }
+
+func TestReaderInit(t *testing.T) {
+ m := enReader(t)
+ if m.mode != modePick {
+ t.Errorf("mode = %v want modePick", m.mode)
+ }
+ if len(m.matches) != len(m.books) || len(m.books) != 73 {
+ t.Errorf("matches=%d books=%d", len(m.matches), len(m.books))
+ }
+ if len(m.versions) == 0 || m.versions[0] == "bt" {
+ t.Errorf("versions = %v (bt must be excluded, non-empty)", m.versions)
+ }
+}
+
+func TestReaderFilterAndOpen(t *testing.T) {
+ m := enReader(t)
+ nm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ m = nm.(ReaderModel)
+ m = key(m, runes("jn"))
+ if len(m.matches) == 0 || m.books[m.matches[0]].Canonical != "John" {
+ t.Fatalf("filter 'jn' top = %v", m.books[m.matches[m.pickSel]])
+ }
+ m = key(m, tea.KeyMsg{Type: tea.KeyEnter})
+ if m.mode != modeRead {
+ t.Fatalf("did not enter read mode")
+ }
+ if m.books[m.bookIdx].Canonical != "John" || len(m.verses) == 0 {
+ t.Errorf("opened book=%q verses=%d", m.books[m.bookIdx].Canonical, len(m.verses))
+ }
+ if !strings.Contains(m.View(), "1") { // chapter 1 header
+ t.Errorf("read view missing chapter:\n%s", m.View())
+ }
+}
+
+func TestReaderChapterAndVersion(t *testing.T) {
+ m := enReader(t)
+ nm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ m = nm.(ReaderModel)
+ m = key(m, runes("jn"))
+ m = key(m, tea.KeyMsg{Type: tea.KeyEnter})
+ firstChap := m.chapters[m.chapPos]
+ m = key(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) // next chapter
+ if m.chapters[m.chapPos] <= firstChap {
+ t.Errorf("chapter did not advance: %d -> %d", firstChap, m.chapters[m.chapPos])
+ }
+ v0 := m.verIdx
+ m = key(m, tea.KeyMsg{Type: tea.KeyTab})
+ if m.verIdx == v0 {
+ t.Errorf("version did not cycle")
+ }
+ // esc returns to picker
+ m = key(m, tea.KeyMsg{Type: tea.KeyEsc})
+ if m.mode != modePick {
+ t.Errorf("esc did not return to picker")
+ }
+}
+
+func TestReaderFuzzyScore(t *testing.T) {
+ if s, ok := fuzzyScore("gen", "Gen Genesis"); !ok || s < 500 {
+ t.Errorf("substring score = %d,%v", s, ok)
+ }
+ if _, ok := fuzzyScore("xyz", "Gen Genesis"); ok {
+ t.Error("xyz should not match Genesis")
+ }
+ // subsequence: g..s..s across the string
+ if _, ok := fuzzyScore("gss", "Gen Genesis"); !ok {
+ t.Error("gss should subsequence-match")
+ }
+}