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/bookmarks" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/i18n" ) // readerMode is the reader's screen: the book picker, the chapter view, the // note prompt when adding a bookmark, or the bookmarks list. type readerMode int const ( modePick readerMode = iota modeRead modeMark // typing a note while adding a bookmark of the current place modeBookmarks // the saved-bookmarks list ) // 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 // bookmarks store *bookmarks.Store markNote string // note being typed in modeMark marks []bookmarks.Bookmark // loaded list for modeBookmarks markSel int // selection in the bookmarks list markTop int // list scroll offset flash string // transient status line (e.g. "bookmarked ...") 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. store // persists bookmarks and the last-read position: if a saved place names a book // this dialect knows, the reader reopens there; otherwise it starts in the // book picker. func NewReader(cfg config.Config, tbl *bible.BookTable, store *bookmarks.Store) ReaderModel { dialect := cfg.SiglaLang() m := ReaderModel{ cfg: cfg, dialect: dialect, books: tbl.Books(dialect), versions: corpusVersions(cfg), store: store, mode: modePick, } m.refilter() if p, ok, _ := bookmarks.LoadPlace(); ok { if idx := m.bookIndex(p.Book); idx >= 0 { m.bookIdx = idx m = m.openAt(p.Chapter, p.Verse) m.mode = modeRead } } return m } // bookIndex returns the index of the book with the given canonical name, or -1. func (m ReaderModel) bookIndex(canonical string) int { for i, b := range m.books { if b.Canonical == canonical { return i } } return -1 } // 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: switch m.mode { case modePick: return m.updatePick(msg) case modeMark: return m.updateMark(msg) case modeBookmarks: return m.updateBookmarks(msg) default: 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) { m.flash = "" switch msg.String() { case "q", "ctrl+c": m.savePlace() return m, tea.Quit case "esc", "backspace": m.mode = modePick return m, nil case "m": m.mode = modeMark m.markNote = "" return m, nil case "b": if marks, err := m.store.List(""); err == nil { m.marks = marks } m.markSel = 0 m.markTop = 0 m.mode = modeBookmarks 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 } // updateMark handles the note prompt shown while adding a bookmark of the // current place: type an optional note, Enter saves, Esc cancels. func (m ReaderModel) updateMark(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyCtrlC: return m, tea.Quit case tea.KeyEsc: m.mode = modeRead m.markNote = "" return m, nil case tea.KeyEnter: m = m.saveBookmark(m.markNote) m.markNote = "" m.mode = modeRead return m, nil case tea.KeyBackspace: if r := []rune(m.markNote); len(r) > 0 { m.markNote = string(r[:len(r)-1]) } return m, nil case tea.KeySpace: m.markNote += " " return m, nil case tea.KeyRunes: m.markNote += string(msg.Runes) return m, nil } return m, nil } // updateBookmarks handles the saved-bookmarks list: navigate, open (jump to that // book+chapter+verse), delete, or go back. func (m ReaderModel) updateBookmarks(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": m.savePlace() return m, tea.Quit case "esc", "b": m.mode = modeRead return m, nil case "j", "down": if m.markSel < len(m.marks)-1 { m.markSel++ } return m, nil case "k", "up": if m.markSel > 0 { m.markSel-- } return m, nil case "d": if m.markSel < len(m.marks) && m.store != nil { _ = m.store.Delete(m.marks[m.markSel].ID) if marks, err := m.store.List(""); err == nil { m.marks = marks } if m.markSel >= len(m.marks) { m.markSel = len(m.marks) - 1 } if m.markSel < 0 { m.markSel = 0 } } return m, nil case "enter": if m.markSel < len(m.marks) { bm := m.marks[m.markSel] if idx := m.bookIndex(bm.Book); idx >= 0 { m.bookIdx = idx m = m.openAt(bm.Chapter, bm.Verse) m.savePlace() } m.mode = modeRead } return m, nil } return m, nil } // currentChapter is the chapter number currently shown (0 if none loaded). func (m ReaderModel) currentChapter() int { if len(m.chapters) > 0 && m.chapPos < len(m.chapters) { return m.chapters[m.chapPos] } return 0 } // verseLineStarts returns the starting body-line index of each verse (parallel // to m.verses), for mapping between the scroll offset and a verse number. func (m ReaderModel) verseLineStarts(w int) []int { starts := make([]int, len(m.verses)) 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) line := 0 for i, b := range blocks { starts[i] = line line += len(styleBlock(b, false, w, numW)) } return starts } // topVerse is the verse number at the top of the current viewport. func (m ReaderModel) topVerse() int { if len(m.verses) == 0 { return 0 } starts := m.verseLineStarts(m.innerW()) sel := 0 for i, st := range starts { if st <= m.scroll { sel = i } else { break } } return m.verses[sel].Verse } // scrollToVerse is the scroll offset that brings verse to the top (0 for verse // <= 0 or when absent). func (m ReaderModel) scrollToVerse(verse int) int { if verse <= 0 || len(m.verses) == 0 { return 0 } starts := m.verseLineStarts(m.innerW()) for i, v := range m.verses { if v.Verse >= verse { return starts[i] } } return starts[len(starts)-1] } // saveBookmark stores a bookmark of the current book+chapter+top-visible verse // with note, and sets a flash message. func (m ReaderModel) saveBookmark(note string) ReaderModel { if m.store == nil || len(m.verses) == 0 { return m } book := m.books[m.bookIdx] chap := m.currentChapter() verse := m.topVerse() _, _ = m.store.Add(bookmarks.Bookmark{ Book: book.Canonical, Chapter: chap, Verse: verse, Note: strings.TrimSpace(note), }) m.flash = fmt.Sprintf("bookmarked %s %d:%d", book.Name, chap, verse) return m } // savePlace persists the current reading position (book+chapter+top verse) so // the reader reopens there next time. func (m ReaderModel) savePlace() { if m.store == nil || len(m.books) == 0 || len(m.verses) == 0 { return } _ = bookmarks.SavePlace(bookmarks.Place{ Book: m.books[m.bookIdx].Canonical, Chapter: m.currentChapter(), Verse: m.topVerse(), }) } // openAt opens the current book (bookIdx) at a specific chapter and scrolls to // the given verse (used by the last-place restore and bookmark jumps). func (m ReaderModel) openAt(chap, verse int) ReaderModel { m.chapters = bible.Chapters(m.version(), m.books[m.bookIdx].Canonical) m.chapPos = 0 for i, c := range m.chapters { if c == chap { m.chapPos = i break } } m = m.loadVerses() m.scroll = m.clampRead(m.scrollToVerse(verse)) return m } // 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 m = m.loadVerses() m.savePlace() return m } 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 m = m.loadVerses() m.savePlace() return m } // 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 { switch m.mode { case modePick: return m.viewPick() case modeBookmarks: return m.viewBookmarks() default: // modeRead + modeMark (the mark prompt overlays the reading footer) return m.viewRead() } } // viewBookmarks renders the saved-bookmarks list. func (m ReaderModel) viewBookmarks() string { w := m.width if w <= 0 { w = 80 } ui := i18n.Get(m.cfg.UILanguage) header := headerStyle.Width(w).Render(ui.ReaderBookmarksTitle) footer := footerStyle.Width(w).Render(ui.ReaderBookmarksKeys) if len(m.marks) == 0 { return header + "\n" + citationStyle.Render(ui.ReaderNoBookmarks) + "\n" + footer } visible := m.height - 3 if visible < 3 { visible = 3 } top := 0 if m.markSel >= visible { top = m.markSel - visible + 1 } var rows []string for i := top; i < len(m.marks) && i < top+visible; i++ { bm := m.marks[i] name := bm.Book if idx := m.bookIndex(bm.Book); idx >= 0 { name = m.books[idx].Name } ref := fmt.Sprintf("%s %d", name, bm.Chapter) if bm.Verse > 0 { ref += fmt.Sprintf(":%d", bm.Verse) } row := ref if bm.Note != "" { row += " — " + bm.Note } if i == m.markSel { row = selStyle.Render("› " + row) } else { row = " " + row } rows = append(rows, row) } return header + "\n" + strings.Join(rows, "\n") + "\n" + footer } 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)) footerText := ui.ReaderReadKeys if m.mode == modeMark { footerText = ui.ReaderMarkPrompt + ": " + m.markNote } else if m.flash != "" { footerText = m.flash } footer := footerStyle.Width(w).Render(footerText) 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 }