From 19e02a3290dc78de088f9a72fb9c16dbf6517ffc Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 24 Jul 2026 16:18:27 +0200 Subject: 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 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/reader.go | 217 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 171 insertions(+), 46 deletions(-) (limited to 'internal/tui/reader.go') 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 -- cgit v1.3