diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-24 16:07:14 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-24 16:07:14 +0200 |
| commit | 0321ea57f5c561215a8dea111ed7f3ea7b05594e (patch) | |
| tree | 75ce332f8985907fcd0118aa2c3f4aafb8587ad7 /internal | |
| parent | f48b79d8860e4c4616821ceeeea091ab4b838197 (diff) | |
| download | lectio-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
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/bookmarks/bookmarks.go | 10 | ||||
| -rw-r--r-- | internal/bookmarks/place.go | 66 | ||||
| -rw-r--r-- | internal/bookmarks/place_test.go | 17 | ||||
| -rw-r--r-- | internal/config/config.go | 2 | ||||
| -rw-r--r-- | internal/i18n/i18n.go | 151 | ||||
| -rw-r--r-- | internal/tui/reader.go | 318 | ||||
| -rw-r--r-- | internal/tui/reader_test.go | 78 |
7 files changed, 551 insertions, 91 deletions
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()) + } +} |
