aboutsummaryrefslogtreecommitdiff
path: root/internal
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
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')
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/i18n/i18n.go9
-rw-r--r--internal/tui/reader.go217
-rw-r--r--internal/tui/reader_test.go24
4 files changed, 198 insertions, 54 deletions
diff --git a/internal/config/config.go b/internal/config/config.go
index 214602b..4adf44e 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -25,7 +25,7 @@ var seedTOML []byte
// Version is lectio's release version, shared by every binary's
// -v/--version output (lectio, lectio-ui, lectio-web).
-const Version = "0.18.0"
+const Version = "0.19.0"
// validVersions are the five scripture versions lectio understands.
var validVersions = map[string]bool{
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
index baede1a..92fec55 100644
--- a/internal/i18n/i18n.go
+++ b/internal/i18n/i18n.go
@@ -32,6 +32,7 @@ type UI struct {
// Reader bookmarks: the mark-note prompt, the bookmarks-list title/keybar,
// and the empty-list note.
ReaderMarkPrompt, ReaderBookmarksTitle, ReaderBookmarksKeys, ReaderNoBookmarks string
+ ReaderMarkVerseKeys, ReaderConfirmDelete string
// CLI banner label words and connective: the banner is
// "<word> <BannerConnective> <date>" (e.g. "Gospel for 2026-07-22" /
@@ -100,10 +101,12 @@ var enUI = UI{
ReaderReadKeys: "n/p chapter tab version j/k scroll m mark b bookmarks esc books q quit",
ReaderNoText: "(no text in this version)",
ReaderNoMatch: "(no matching books)",
- ReaderMarkPrompt: "note (optional)",
+ ReaderMarkPrompt: "note (enter to save, esc to cancel)",
ReaderBookmarksTitle: "bookmarks",
ReaderBookmarksKeys: "j/k move enter open d delete esc back q quit",
ReaderNoBookmarks: "no bookmarks yet — press m while reading",
+ ReaderMarkVerseKeys: "↑/↓ pick verse enter add note esc cancel",
+ ReaderConfirmDelete: "delete this bookmark? y/n",
BannerGospel: "Gospel",
BannerReadings: "Readings",
BannerConnective: "for",
@@ -156,10 +159,12 @@ var plUI = UI{
ReaderReadKeys: "n/p rozdział tab wersja j/k przewiń m zakładka b zakładki esc księgi q wyjście",
ReaderNoText: "(brak tekstu w tej wersji)",
ReaderNoMatch: "(brak pasujących ksiąg)",
- ReaderMarkPrompt: "notatka (opcjonalnie)",
+ ReaderMarkPrompt: "notatka (enter zapisz, esc anuluj)",
ReaderBookmarksTitle: "zakładki",
ReaderBookmarksKeys: "j/k ruch enter otwórz d usuń esc wróć q wyjście",
ReaderNoBookmarks: "brak zakładek — naciśnij m podczas czytania",
+ ReaderMarkVerseKeys: "↑/↓ wybierz werset enter notatka esc anuluj",
+ ReaderConfirmDelete: "usunąć zakładkę? y/n",
BannerGospel: "Ewangelia",
BannerReadings: "Czytania",
BannerConnective: "na",
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))
}