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 }