From ed34fd574b4fc2e0a52c655ececddd1be29b271e Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 24 Jul 2026 12:26:23 +0200 Subject: reader: lectio-ui --reader fuzzy book picker + chapter reader over the corpora; v0.8.0 --- internal/tui/reader.go | 445 ++++++++++++++++++++++++++++++++++++++++++++ internal/tui/reader_test.go | 100 ++++++++++ 2 files changed, 545 insertions(+) create mode 100644 internal/tui/reader.go create mode 100644 internal/tui/reader_test.go (limited to 'internal/tui') 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") + } +} -- cgit v1.3