summaryrefslogtreecommitdiff
path: root/internal/tui/reader.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/reader.go')
-rw-r--r--internal/tui/reader.go445
1 files changed, 445 insertions, 0 deletions
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
+}