From 0321ea57f5c561215a8dea111ed7f3ea7b05594e Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 24 Jul 2026 16:07:14 +0200 Subject: tui reader: bookmarks (m=mark w/ note, b=list/jump/delete, per-verse via top-visible verse) + remember last place; v0.18.0 --- internal/tui/reader.go | 318 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 308 insertions(+), 10 deletions(-) (limited to 'internal/tui/reader.go') 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() -- cgit v1.3