summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 16:07:14 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 16:07:14 +0200
commit0321ea57f5c561215a8dea111ed7f3ea7b05594e (patch)
tree75ce332f8985907fcd0118aa2c3f4aafb8587ad7
parentf48b79d8860e4c4616821ceeeea091ab4b838197 (diff)
downloadlectio-0321ea57f5c561215a8dea111ed7f3ea7b05594e.tar.gz
lectio-0321ea57f5c561215a8dea111ed7f3ea7b05594e.zip
tui reader: bookmarks (m=mark w/ note, b=list/jump/delete, per-verse via top-visible verse) + remember last place; v0.18.0
-rw-r--r--cmd/lectio-ui/main.go3
-rw-r--r--internal/bookmarks/bookmarks.go10
-rw-r--r--internal/bookmarks/place.go66
-rw-r--r--internal/bookmarks/place_test.go17
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/i18n/i18n.go151
-rw-r--r--internal/tui/reader.go318
-rw-r--r--internal/tui/reader_test.go78
8 files changed, 553 insertions, 92 deletions
diff --git a/cmd/lectio-ui/main.go b/cmd/lectio-ui/main.go
index d8e2f27..dd91117 100644
--- a/cmd/lectio-ui/main.go
+++ b/cmd/lectio-ui/main.go
@@ -11,6 +11,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lukaszkasprzak/lectio/internal/bible"
+ "github.com/lukaszkasprzak/lectio/internal/bookmarks"
"github.com/lukaszkasprzak/lectio/internal/config"
"github.com/lukaszkasprzak/lectio/internal/tui"
)
@@ -120,7 +121,7 @@ func run(args []string, stdout, stderr io.Writer) int {
var mdl tea.Model = tui.New(cfg, date, bibleVer)
if reader {
tbl, _ := bible.LoadBookTable(config.UserBooksTOML())
- mdl = tui.NewReader(cfg, tbl)
+ mdl = tui.NewReader(cfg, tbl, bookmarks.Open())
}
if _, err := tea.NewProgram(mdl, tea.WithAltScreen()).Run(); err != nil {
fmt.Fprintln(stderr, err)
diff --git a/internal/bookmarks/bookmarks.go b/internal/bookmarks/bookmarks.go
index ac8926d..55a9e55 100644
--- a/internal/bookmarks/bookmarks.go
+++ b/internal/bookmarks/bookmarks.go
@@ -35,15 +35,7 @@ type Store struct {
// bookmarks.json, mirroring the sigla store) and returns a ready Store. It never
// fails: a missing file simply reads as an empty list.
func Open() *Store {
- base := os.Getenv("XDG_DATA_HOME")
- if base == "" {
- home, err := os.UserHomeDir()
- if err != nil {
- home = "."
- }
- base = filepath.Join(home, ".local", "share")
- }
- return &Store{path: filepath.Join(base, "lectio", "bookmarks.json")}
+ return &Store{path: dataFile("bookmarks.json")}
}
// ParseTags splits a comma-separated tag string into trimmed, non-empty tags.
diff --git a/internal/bookmarks/place.go b/internal/bookmarks/place.go
new file mode 100644
index 0000000..2aae058
--- /dev/null
+++ b/internal/bookmarks/place.go
@@ -0,0 +1,66 @@
+package bookmarks
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+)
+
+// dataFile resolves ${XDG_DATA_HOME:-~/.local/share}/lectio/<name> -- the same
+// data-dir rule as the sigla store and the bookmarks file.
+func dataFile(name string) string {
+ base := os.Getenv("XDG_DATA_HOME")
+ if base == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ home = "."
+ }
+ base = filepath.Join(home, ".local", "share")
+ }
+ return filepath.Join(base, "lectio", name)
+}
+
+// Place is the reader's last position, so the TUI reader can reopen where it
+// left off (book + chapter + the top-visible verse).
+type Place struct {
+ Book string `json:"book"` // canonical English name (Bible-corpus key)
+ Chapter int `json:"chapter"`
+ Verse int `json:"verse"`
+}
+
+// SavePlace persists the reader's last position (atomic temp+rename).
+func SavePlace(p Place) error {
+ path := dataFile("reader-place.json")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(p, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, data, 0o644); err != nil {
+ return err
+ }
+ return os.Rename(tmp, path)
+}
+
+// LoadPlace reads the reader's last position; ok is false when none is saved
+// (or the file names no book).
+func LoadPlace() (Place, bool, error) {
+ data, err := os.ReadFile(dataFile("reader-place.json"))
+ if err != nil {
+ if os.IsNotExist(err) {
+ return Place{}, false, nil
+ }
+ return Place{}, false, err
+ }
+ var p Place
+ if err := json.Unmarshal(data, &p); err != nil {
+ return Place{}, false, err
+ }
+ if p.Book == "" {
+ return Place{}, false, nil
+ }
+ return p, true, nil
+}
diff --git a/internal/bookmarks/place_test.go b/internal/bookmarks/place_test.go
new file mode 100644
index 0000000..420db0e
--- /dev/null
+++ b/internal/bookmarks/place_test.go
@@ -0,0 +1,17 @@
+package bookmarks
+
+import "testing"
+
+func TestPlaceRoundTrip(t *testing.T) {
+ t.Setenv("XDG_DATA_HOME", t.TempDir())
+ if _, ok, _ := LoadPlace(); ok {
+ t.Fatal("empty data dir should have no saved place")
+ }
+ if err := SavePlace(Place{Book: "John", Chapter: 3, Verse: 16}); err != nil {
+ t.Fatal(err)
+ }
+ p, ok, err := LoadPlace()
+ if err != nil || !ok || p.Book != "John" || p.Chapter != 3 || p.Verse != 16 {
+ t.Errorf("place = %+v ok=%v err=%v", p, ok, err)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 4293bd2..214602b 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.17.0"
+const Version = "0.18.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 f0e14ec..baede1a 100644
--- a/internal/i18n/i18n.go
+++ b/internal/i18n/i18n.go
@@ -29,6 +29,9 @@ type UI struct {
// Reader-mode (lectio-ui --reader) chrome.
ReaderTitle, ReaderPickKeys, ReaderReadKeys, ReaderNoText, ReaderNoMatch string
+ // Reader bookmarks: the mark-note prompt, the bookmarks-list title/keybar,
+ // and the empty-list note.
+ ReaderMarkPrompt, ReaderBookmarksTitle, ReaderBookmarksKeys, ReaderNoBookmarks string
// CLI banner label words and connective: the banner is
// "<word> <BannerConnective> <date>" (e.g. "Gospel for 2026-07-22" /
@@ -86,41 +89,45 @@ var enUI = UI{
"aklamacja": "Acclamation",
"ewangelia": "Gospel",
},
- FooterKeys: "tab/⇧tab version ←/→ day d date j/k scroll space/b page g/G top/bottom r refresh q quit",
- Loading: "loading…",
- NoReadingsFor: "no readings for ",
- ErrorPrefix: "error: ",
- ErrorHint: "change date (←/→) or refresh (r)",
- JumpPrompt: "go to date (YYYY-MM-DD)",
- ReaderTitle: "reader — pick a book",
- ReaderPickKeys: "type to filter ↑/↓ move enter open esc quit",
- ReaderReadKeys: "n/p chapter tab/⇧tab version j/k scroll space page g/G ends esc books q quit",
- ReaderNoText: "(no text in this version)",
- ReaderNoMatch: "(no matching books)",
- BannerGospel: "Gospel",
- BannerReadings: "Readings",
- BannerConnective: "for",
- Lectionary: "lectionary",
- OptModern: "modern",
- OptTraditional: "traditional",
- Parts: "parts",
- OptGospel: "Gospel",
- OptAll: "all parts",
- Layout: "layout",
- OptHorizontal: "horizontal",
- OptColumns: "columns",
- OptInterlinear: "interlinear",
- Theme: "theme",
- Mono: "mono",
- NoInterlinearVerses: "(no verses to align)",
- NavReader: "reader",
- WebBook: "book",
- WebChapter: "chapter",
- NoReadingsDay: "no readings for this day",
- NoVersion: "(not in %s)",
- NoVersionPartial: "(not in %s: %s)",
- NoReference: "(no reference)",
- NoReferenceErr: "(no reference: %v)",
+ FooterKeys: "tab/⇧tab version ←/→ day d date j/k scroll space/b page g/G top/bottom r refresh q quit",
+ Loading: "loading…",
+ NoReadingsFor: "no readings for ",
+ ErrorPrefix: "error: ",
+ ErrorHint: "change date (←/→) or refresh (r)",
+ JumpPrompt: "go to date (YYYY-MM-DD)",
+ ReaderTitle: "reader — pick a book",
+ ReaderPickKeys: "type to filter ↑/↓ move enter open esc quit",
+ 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)",
+ ReaderBookmarksTitle: "bookmarks",
+ ReaderBookmarksKeys: "j/k move enter open d delete esc back q quit",
+ ReaderNoBookmarks: "no bookmarks yet — press m while reading",
+ BannerGospel: "Gospel",
+ BannerReadings: "Readings",
+ BannerConnective: "for",
+ Lectionary: "lectionary",
+ OptModern: "modern",
+ OptTraditional: "traditional",
+ Parts: "parts",
+ OptGospel: "Gospel",
+ OptAll: "all parts",
+ Layout: "layout",
+ OptHorizontal: "horizontal",
+ OptColumns: "columns",
+ OptInterlinear: "interlinear",
+ Theme: "theme",
+ Mono: "mono",
+ NoInterlinearVerses: "(no verses to align)",
+ NavReader: "reader",
+ WebBook: "book",
+ WebChapter: "chapter",
+ NoReadingsDay: "no readings for this day",
+ NoVersion: "(not in %s)",
+ NoVersionPartial: "(not in %s: %s)",
+ NoReference: "(no reference)",
+ NoReferenceErr: "(no reference: %v)",
}
var plUI = UI{
@@ -138,39 +145,43 @@ var plUI = UI{
"aklamacja": "Aklamacja",
"ewangelia": "Ewangelia",
},
- FooterKeys: "tab/⇧tab wersja ←/→ dzień d data j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście",
- Loading: "ładowanie…",
- NoReadingsFor: "brak czytań na ",
- ErrorPrefix: "błąd: ",
- ErrorHint: "zmień datę (←/→) lub odśwież (r)",
- JumpPrompt: "przejdź do daty (RRRR-MM-DD)",
- ReaderTitle: "czytnik — wybierz księgę",
- ReaderPickKeys: "wpisz, by filtrować ↑/↓ ruch enter otwórz esc wyjście",
- ReaderReadKeys: "n/p rozdział tab/⇧tab wersja j/k przewiń spacja strona g/G końce esc księgi q wyjście",
- ReaderNoText: "(brak tekstu w tej wersji)",
- ReaderNoMatch: "(brak pasujących ksiąg)",
- BannerGospel: "Ewangelia",
- BannerReadings: "Czytania",
- BannerConnective: "na",
- Lectionary: "lekcjonarz",
- OptModern: "nowy",
- OptTraditional: "tradycyjny",
- Parts: "zakres",
- OptGospel: "Ewangelia",
- OptAll: "wszystkie części",
- Layout: "układ",
- OptHorizontal: "poziomo",
- OptColumns: "kolumny",
- OptInterlinear: "interlinearnie",
- Theme: "motyw",
- Mono: "mono",
- NoInterlinearVerses: "(brak wersetów do zestawienia interlinearnego)",
- NavReader: "czytnik",
- WebBook: "księga",
- WebChapter: "rozdział",
- NoReadingsDay: "brak czytań na ten dzień",
- NoVersion: "(brak w „%s”)",
- NoVersionPartial: "(brak w „%s”: %s)",
- NoReference: "(brak odwołania)",
- NoReferenceErr: "(brak odwołania: %w)",
+ FooterKeys: "tab/⇧tab wersja ←/→ dzień d data j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście",
+ Loading: "ładowanie…",
+ NoReadingsFor: "brak czytań na ",
+ ErrorPrefix: "błąd: ",
+ ErrorHint: "zmień datę (←/→) lub odśwież (r)",
+ JumpPrompt: "przejdź do daty (RRRR-MM-DD)",
+ ReaderTitle: "czytnik — wybierz księgę",
+ ReaderPickKeys: "wpisz, by filtrować ↑/↓ ruch enter otwórz esc wyjście",
+ 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)",
+ 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",
+ BannerGospel: "Ewangelia",
+ BannerReadings: "Czytania",
+ BannerConnective: "na",
+ Lectionary: "lekcjonarz",
+ OptModern: "nowy",
+ OptTraditional: "tradycyjny",
+ Parts: "zakres",
+ OptGospel: "Ewangelia",
+ OptAll: "wszystkie części",
+ Layout: "układ",
+ OptHorizontal: "poziomo",
+ OptColumns: "kolumny",
+ OptInterlinear: "interlinearnie",
+ Theme: "motyw",
+ Mono: "mono",
+ NoInterlinearVerses: "(brak wersetów do zestawienia interlinearnego)",
+ NavReader: "czytnik",
+ WebBook: "księga",
+ WebChapter: "rozdział",
+ NoReadingsDay: "brak czytań na ten dzień",
+ NoVersion: "(brak w „%s”)",
+ NoVersionPartial: "(brak w „%s”: %s)",
+ NoReference: "(brak odwołania)",
+ NoReferenceErr: "(brak odwołania: %w)",
}
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()
diff --git a/internal/tui/reader_test.go b/internal/tui/reader_test.go
index 167e546..514f892 100644
--- a/internal/tui/reader_test.go
+++ b/internal/tui/reader_test.go
@@ -7,11 +7,13 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lukaszkasprzak/lectio/internal/bible"
+ "github.com/lukaszkasprzak/lectio/internal/bookmarks"
"github.com/lukaszkasprzak/lectio/internal/config"
)
func enReader(t *testing.T) ReaderModel {
t.Helper()
+ t.Setenv("XDG_DATA_HOME", t.TempDir()) // isolate bookmarks + last-place files
tbl, err := bible.LoadBookTable(nil)
if err != nil {
t.Fatal(err)
@@ -20,7 +22,7 @@ func enReader(t *testing.T) ReaderModel {
cfg.UILanguage = "en"
cfg.SiglaStyle = "english"
cfg.Versions = []string{"bt", "wuj", "vul", "grb", "drb"}
- return NewReader(cfg, tbl)
+ return NewReader(cfg, tbl, bookmarks.Open())
}
func key(m ReaderModel, k tea.KeyMsg) ReaderModel {
@@ -30,6 +32,11 @@ func key(m ReaderModel, k tea.KeyMsg) ReaderModel {
func runes(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} }
+func win(m ReaderModel, w, h int) ReaderModel {
+ nm, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: h})
+ return nm.(ReaderModel)
+}
+
func TestReaderInit(t *testing.T) {
m := enReader(t)
if m.mode != modePick {
@@ -98,3 +105,72 @@ func TestReaderFuzzyScore(t *testing.T) {
t.Error("gss should subsequence-match")
}
}
+
+func TestReaderBookmarkFlow(t *testing.T) {
+ m := enReader(t)
+ m = win(m, 80, 24)
+ m = key(m, runes("jn"))
+ m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) // open John 1
+ if m.mode != modeRead {
+ t.Fatal("did not enter reading")
+ }
+ // mark: m -> type note -> enter
+ m = key(m, runes("m"))
+ if m.mode != modeMark {
+ t.Fatal("m did not enter modeMark")
+ }
+ m = key(m, runes("hi"))
+ m = key(m, tea.KeyMsg{Type: tea.KeyEnter})
+ if m.mode != modeRead {
+ t.Fatal("enter did not save + return to reading")
+ }
+ // list: b
+ m = key(m, runes("b"))
+ 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 !strings.Contains(m.View(), "John") {
+ t.Errorf("bookmarks view missing John:\n%s", m.View())
+ }
+ // open the bookmark
+ m = key(m, tea.KeyMsg{Type: tea.KeyEnter})
+ 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
+ m = key(m, runes("b"))
+ m = key(m, runes("d"))
+ if len(m.marks) != 0 {
+ t.Errorf("delete failed, %d remaining", len(m.marks))
+ }
+}
+
+func TestReaderRemembersPlace(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_DATA_HOME", dir)
+ tbl, _ := bible.LoadBookTable(nil)
+ cfg := config.Default()
+ cfg.UILanguage = "en"
+ cfg.SiglaStyle = "english"
+ cfg.Versions = []string{"wuj", "vul", "grb", "drb"}
+
+ m := NewReader(cfg, tbl, bookmarks.Open())
+ m = win(m, 80, 24)
+ m = key(m, runes("jn"))
+ m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) // John 1 (openBook saves place)
+ m = key(m, runes("n")) // -> John 2 (chapterStep saves place)
+
+ m2 := NewReader(cfg, tbl, bookmarks.Open())
+ if m2.mode != modeRead {
+ t.Fatalf("did not restore reading mode")
+ }
+ if m2.books[m2.bookIdx].Canonical != "John" {
+ t.Errorf("restored book = %q want John", m2.books[m2.bookIdx].Canonical)
+ }
+ if m2.currentChapter() != 2 {
+ t.Errorf("restored chapter = %d want 2", m2.currentChapter())
+ }
+}