aboutsummaryrefslogtreecommitdiff
path: root/internal/tui/reader.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 16:18:27 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 16:18:27 +0200
commit19e02a3290dc78de088f9a72fb9c16dbf6517ffc (patch)
tree867b1a58fe6b40bae4abb83b5aca068065b45bcc /internal/tui/reader.go
parent0321ea57f5c561215a8dea111ed7f3ea7b05594e (diff)
downloadlectio-19e02a3290dc78de088f9a72fb9c16dbf6517ffc.tar.gz
lectio-19e02a3290dc78de088f9a72fb9c16dbf6517ffc.zip
tui reader: m opens a verse cursor (↑/↓ pick the verse) then a note box (write or skip); d in bookmarks confirms before deleting; v0.19.0
Diffstat (limited to 'internal/tui/reader.go')
-rw-r--r--internal/tui/reader.go217
1 files changed, 171 insertions, 46 deletions
diff --git a/internal/tui/reader.go b/internal/tui/reader.go
index dbc79b9..09d3210 100644
--- a/internal/tui/reader.go
+++ b/internal/tui/reader.go
@@ -12,17 +12,20 @@ import (
"github.com/lukaszkasprzak/lectio/internal/bookmarks"
"github.com/lukaszkasprzak/lectio/internal/config"
"github.com/lukaszkasprzak/lectio/internal/i18n"
+ "github.com/lukaszkasprzak/lectio/internal/render"
)
// readerMode is the reader's screen: the book picker, the chapter view, the
-// note prompt when adding a bookmark, or the bookmarks list.
+// verse-picker (choosing which verse to bookmark), the note prompt, 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
+ modePick readerMode = iota
+ modeRead // scrolling chapter view
+ modeMarkVerse // picking the verse to bookmark (highlighted cursor)
+ modeMark // typing an optional note for the chosen verse
+ modeBookmarks // the saved-bookmarks list
)
// selStyle marks the picker's selected row (reverse video, legible on any theme).
@@ -55,12 +58,15 @@ type ReaderModel struct {
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 ...")
+ store *bookmarks.Store
+ markVerseIdx int // verse cursor in modeMarkVerse (index into verses)
+ markVerse int // the chosen verse number carried into modeMark
+ 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
+ confirmDelete bool // bookmarks list is awaiting delete confirmation
+ flash string // transient status line (e.g. "bookmarked ...")
width, height int
}
@@ -185,6 +191,8 @@ func (m ReaderModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.mode {
case modePick:
return m.updatePick(msg)
+ case modeMarkVerse:
+ return m.updateMarkVerse(msg)
case modeMark:
return m.updateMark(msg)
case modeBookmarks:
@@ -252,8 +260,10 @@ func (m ReaderModel) updateRead(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.mode = modePick
return m, nil
case "m":
- m.mode = modeMark
- m.markNote = ""
+ if len(m.verses) > 0 {
+ m.mode = modeMarkVerse
+ m.markVerseIdx = m.topVerseIdx()
+ }
return m, nil
case "b":
if marks, err := m.store.List(""); err == nil {
@@ -293,8 +303,40 @@ 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.
+// updateMarkVerse handles the verse cursor while choosing which verse to
+// bookmark: up/down move the highlighted verse (the view scrolls to keep it in
+// sight), Enter confirms it and opens the note box, Esc cancels.
+func (m ReaderModel) updateMarkVerse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "ctrl+c", "q":
+ m.savePlace()
+ return m, tea.Quit
+ case "esc":
+ m.mode = modeRead
+ return m, nil
+ case "j", "down":
+ if m.markVerseIdx < len(m.verses)-1 {
+ m.markVerseIdx++
+ }
+ return m.ensureVerseVisible(), nil
+ case "k", "up":
+ if m.markVerseIdx > 0 {
+ m.markVerseIdx--
+ }
+ return m.ensureVerseVisible(), nil
+ case "enter":
+ if m.markVerseIdx >= 0 && m.markVerseIdx < len(m.verses) {
+ m.markVerse = m.verses[m.markVerseIdx].Verse
+ m.markNote = ""
+ m.mode = modeMark
+ }
+ return m, nil
+ }
+ return m, nil
+}
+
+// updateMark handles the note box for the chosen verse: type an optional note,
+// Enter saves (an empty note is fine -- "skip"), Esc cancels.
func (m ReaderModel) updateMark(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
@@ -304,7 +346,7 @@ func (m ReaderModel) updateMark(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.markNote = ""
return m, nil
case tea.KeyEnter:
- m = m.saveBookmark(m.markNote)
+ m = m.saveBookmark(m.markVerse, m.markNote)
m.markNote = ""
m.mode = modeRead
return m, nil
@@ -326,6 +368,27 @@ func (m ReaderModel) updateMark(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// 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) {
+ if m.confirmDelete {
+ switch msg.String() {
+ case "y":
+ 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
+ }
+ }
+ m.confirmDelete = false
+ case "n", "esc":
+ m.confirmDelete = false
+ }
+ return m, nil
+ }
switch msg.String() {
case "q", "ctrl+c":
m.savePlace()
@@ -344,17 +407,8 @@ func (m ReaderModel) updateBookmarks(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
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
- }
+ if len(m.marks) > 0 {
+ m.confirmDelete = true
}
return m, nil
case "enter":
@@ -397,8 +451,9 @@ func (m ReaderModel) verseLineStarts(w int) []int {
return starts
}
-// topVerse is the verse number at the top of the current viewport.
-func (m ReaderModel) topVerse() int {
+// topVerseIdx is the index (into m.verses) of the verse at the top of the
+// current viewport.
+func (m ReaderModel) topVerseIdx() int {
if len(m.verses) == 0 {
return 0
}
@@ -411,7 +466,45 @@ func (m ReaderModel) topVerse() int {
break
}
}
- return m.verses[sel].Verse
+ return sel
+}
+
+// topVerse is the verse number at the top of the current viewport.
+func (m ReaderModel) topVerse() int {
+ if len(m.verses) == 0 {
+ return 0
+ }
+ return m.verses[m.topVerseIdx()].Verse
+}
+
+// verseIdx returns the index of the given verse number in m.verses, or -1.
+func (m ReaderModel) verseIdx(verse int) int {
+ for i, v := range m.verses {
+ if v.Verse == verse {
+ return i
+ }
+ }
+ return -1
+}
+
+// ensureVerseVisible scrolls so the verse-cursor (markVerseIdx) stays on screen.
+func (m ReaderModel) ensureVerseVisible() ReaderModel {
+ starts := m.verseLineStarts(m.innerW())
+ if m.markVerseIdx < 0 || m.markVerseIdx >= len(starts) {
+ return m
+ }
+ start := starts[m.markVerseIdx]
+ visible := m.readVisible()
+ if start < m.scroll {
+ m.scroll = start
+ }
+ if start >= m.scroll+visible {
+ m.scroll = start - visible + 1
+ }
+ if m.scroll < 0 {
+ m.scroll = 0
+ }
+ return m
}
// scrollToVerse is the scroll offset that brings verse to the top (0 for verse
@@ -429,15 +522,17 @@ func (m ReaderModel) scrollToVerse(verse int) int {
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 {
+// saveBookmark stores a bookmark of the current book+chapter at the chosen
+// verse with an optional note, and sets a flash message.
+func (m ReaderModel) saveBookmark(verse int, note string) ReaderModel {
if m.store == nil || len(m.verses) == 0 {
return m
}
book := m.books[m.bookIdx]
chap := m.currentChapter()
- verse := m.topVerse()
+ if verse <= 0 {
+ verse = m.topVerse()
+ }
_, _ = m.store.Add(bookmarks.Bookmark{
Book: book.Canonical,
Chapter: chap,
@@ -573,7 +668,11 @@ func (m ReaderModel) innerW() int {
}
// readBody returns the styled, wrapped verse lines for the current chapter.
-func (m ReaderModel) readBody(w int) []string {
+func (m ReaderModel) readBody(w int) []string { return m.readBodyHL(w, -1) }
+
+// readBodyHL is readBody with the verse at index hl (>= 0) highlighted in
+// reverse video -- the moving cursor while choosing a verse to bookmark.
+func (m ReaderModel) readBodyHL(w, hl int) []string {
ui := i18n.Get(m.cfg.UILanguage)
if len(m.chapters) == 0 || len(m.verses) == 0 {
return []string{citationStyle.Render(ui.ReaderNoText)}
@@ -584,7 +683,13 @@ func (m ReaderModel) readBody(w int) []string {
}
numW := maxNumWidth(blocks)
var lines []string
- for _, b := range blocks {
+ for i, b := range blocks {
+ if i == hl {
+ for _, ln := range strings.Split(render.Wrap(b, w), "\n") {
+ lines = append(lines, selStyle.Render(ln))
+ }
+ continue
+ }
lines = append(lines, styleBlock(b, false, w, numW)...)
}
return lines
@@ -609,7 +714,11 @@ func (m ReaderModel) viewBookmarks() string {
}
ui := i18n.Get(m.cfg.UILanguage)
header := headerStyle.Width(w).Render(ui.ReaderBookmarksTitle)
- footer := footerStyle.Width(w).Render(ui.ReaderBookmarksKeys)
+ footerKeys := ui.ReaderBookmarksKeys
+ if m.confirmDelete {
+ footerKeys = ui.ReaderConfirmDelete
+ }
+ footer := footerStyle.Width(w).Render(footerKeys)
if len(m.marks) == 0 {
return header + "\n" + citationStyle.Render(ui.ReaderNoBookmarks) + "\n" + footer
}
@@ -715,24 +824,40 @@ func (m ReaderModel) viewRead() string {
}
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]
- }
+ chap := m.currentChapter()
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))
+
+ hl := -1
+ switch m.mode {
+ case modeMarkVerse:
+ hl = m.markVerseIdx
+ case modeMark:
+ hl = m.verseIdx(m.markVerse)
+ }
+
+ head := fmt.Sprintf("%s %d [%s]", b.Name, chap, label)
+ if hl >= 0 && hl < len(m.verses) {
+ head = fmt.Sprintf("%s %d:%d [%s]", b.Name, chap, m.verses[hl].Verse, label)
+ }
+ header := headerStyle.Width(w).Render(head)
+
footerText := ui.ReaderReadKeys
- if m.mode == modeMark {
+ switch m.mode {
+ case modeMarkVerse:
+ footerText = ui.ReaderMarkVerseKeys
+ case modeMark:
footerText = ui.ReaderMarkPrompt + ": " + m.markNote
- } else if m.flash != "" {
- footerText = m.flash
+ default:
+ if m.flash != "" {
+ footerText = m.flash
+ }
}
footer := footerStyle.Width(w).Render(footerText)
- body := m.readBody(m.innerW())
+ body := m.readBodyHL(m.innerW(), hl)
visible := m.readVisible()
scroll := clampScroll(m.scroll, len(body), visible)
end := scroll + visible