summaryrefslogtreecommitdiff
path: root/internal/tui/reader.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 16:07:14 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 16:07:14 +0200
commit0321ea57f5c561215a8dea111ed7f3ea7b05594e (patch)
tree75ce332f8985907fcd0118aa2c3f4aafb8587ad7 /internal/tui/reader.go
parentf48b79d8860e4c4616821ceeeea091ab4b838197 (diff)
downloadlectio-0321ea57f5c561215a8dea111ed7f3ea7b05594e.tar.gz
lectio-0321ea57f5c561215a8dea111ed7f3ea7b05594e.zip
tui reader: bookmarks (m=mark w/ note, b=list/jump/delete, per-verse via top-visible verse) + remember last place; v0.18.0
Diffstat (limited to 'internal/tui/reader.go')
-rw-r--r--internal/tui/reader.go318
1 files changed, 308 insertions, 10 deletions
diff --git a/internal/tui/reader.go b/internal/tui/reader.go
index 8e33534..dbc79b9 100644
--- a/internal/tui/reader.go
+++ b/internal/tui/reader.go
@@ -9,16 +9,20 @@ import (
"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 or the chapter view.
+// 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).
@@ -50,25 +54,54 @@ type ReaderModel struct {
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.
-func NewReader(cfg config.Config, tbl *bible.BookTable) ReaderModel {
+// 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.
@@ -149,10 +182,16 @@ func (m ReaderModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyMsg:
- if m.mode == modePick {
+ 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.updateRead(msg)
}
return m, nil
}
@@ -204,12 +243,26 @@ func (m ReaderModel) updatePick(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
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":
@@ -240,13 +293,199 @@ func (m ReaderModel) updateRead(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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
- return m.loadVerses()
+ m = m.loadVerses()
+ m.savePlace()
+ return m
}
func (m ReaderModel) loadVerses() ReaderModel {
@@ -274,7 +513,9 @@ func (m ReaderModel) chapterStep(d int) ReaderModel {
}
m.chapPos = np
m.scroll = 0
- return m.loadVerses()
+ m = m.loadVerses()
+ m.savePlace()
+ return m
}
// cycleVersion moves the active version by d, wrapping. In reading mode it
@@ -350,10 +591,61 @@ func (m ReaderModel) readBody(w int) []string {
}
func (m ReaderModel) View() string {
- if m.mode == modePick {
+ 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()
}
- 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 {
@@ -432,7 +724,13 @@ func (m ReaderModel) viewRead() string {
label = l
}
header := headerStyle.Width(w).Render(fmt.Sprintf("%s %d [%s]", b.Name, chap, label))
- footer := footerStyle.Width(w).Render(ui.ReaderReadKeys)
+ 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()