diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-24 16:18:27 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-24 16:18:27 +0200 |
| commit | 19e02a3290dc78de088f9a72fb9c16dbf6517ffc (patch) | |
| tree | 867b1a58fe6b40bae4abb83b5aca068065b45bcc /internal/tui | |
| parent | 0321ea57f5c561215a8dea111ed7f3ea7b05594e (diff) | |
| download | lectio-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')
| -rw-r--r-- | internal/tui/reader.go | 217 | ||||
| -rw-r--r-- | internal/tui/reader_test.go | 24 |
2 files changed, 190 insertions, 51 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 diff --git a/internal/tui/reader_test.go b/internal/tui/reader_test.go index 514f892..f3e43d5 100644 --- a/internal/tui/reader_test.go +++ b/internal/tui/reader_test.go @@ -114,10 +114,15 @@ func TestReaderBookmarkFlow(t *testing.T) { if m.mode != modeRead { t.Fatal("did not enter reading") } - // mark: m -> type note -> enter + // mark: m -> pick a verse -> note box -> enter m = key(m, runes("m")) + if m.mode != modeMarkVerse { + t.Fatal("m did not enter the verse picker") + } + m = key(m, runes("j")) // move the cursor down to John 1:2 + m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) if m.mode != modeMark { - t.Fatal("m did not enter modeMark") + t.Fatal("enter did not open the note box") } m = key(m, runes("hi")) m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) @@ -129,8 +134,8 @@ func TestReaderBookmarkFlow(t *testing.T) { if m.mode != modeBookmarks || len(m.marks) != 1 { t.Fatalf("bookmarks mode=%v n=%d", m.mode, len(m.marks)) } - if m.marks[0].Book != "John" || m.marks[0].Note != "hi" { - t.Errorf("mark = %+v", m.marks[0]) + if m.marks[0].Book != "John" || m.marks[0].Verse != 2 || m.marks[0].Note != "hi" { + t.Errorf("mark = %+v (want John 1:2 note hi)", m.marks[0]) } if !strings.Contains(m.View(), "John") { t.Errorf("bookmarks view missing John:\n%s", m.View()) @@ -140,9 +145,18 @@ func TestReaderBookmarkFlow(t *testing.T) { if m.mode != modeRead || m.books[m.bookIdx].Canonical != "John" { t.Errorf("jump failed: mode=%v book=%q", m.mode, m.books[m.bookIdx].Canonical) } - // delete via list + // delete via list, with confirmation m = key(m, runes("b")) m = key(m, runes("d")) + if !m.confirmDelete { + t.Fatal("d did not ask for confirmation") + } + m = key(m, runes("n")) // cancel + if m.confirmDelete || len(m.marks) != 1 { + t.Fatalf("n did not cancel (confirm=%v n=%d)", m.confirmDelete, len(m.marks)) + } + m = key(m, runes("d")) + m = key(m, runes("y")) // confirm if len(m.marks) != 0 { t.Errorf("delete failed, %d remaining", len(m.marks)) } |
