package tui import ( "fmt" "sort" "strconv" "strings" tea "github.com/charmbracelet/bubbletea" "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" "github.com/lukaszkasprzak/lectio/internal/render" ) // readerMode is the reader's screen: the book picker, the chapter view, the // verse-picker (choosing which verse to bookmark), the note prompt, or the // bookmarks list. type readerMode int const ( 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 modeChapterJump // typing a chapter number to jump to ) // selStyle marks the picker's selected row (reverse video, legible on any theme). var selStyle = lipgloss.NewStyle().Reverse(true) // markStyle renders the red "*" that flags a verse carrying a bookmark. var markStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) // modalStyle / modalTitleStyle render the bookmark note box and the delete // confirmation as a prominent centered, bordered dialog (not a footer line). var ( modalStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 3) modalTitleStyle = lipgloss.NewStyle().Bold(true) ) // modal centres inner in a bordered box over the whole screen. func (m ReaderModel) modal(inner string) string { w, h := m.width, m.height if w <= 0 { w = 80 } if h <= 0 { h = 24 } return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, modalStyle.Render(inner)) } // ReaderModel is lectio-ui's --reader mode: a fuzzy book picker over the sigla // dialect's book names, then a scrolling view of a chapter from the embedded // corpora with chapter navigation and version cycling. It implements tea.Model // and reuses the package's styleBlock/styles. It never touches the network. type ReaderModel struct { cfg config.Config dialect string // sigla dialect / book-name language ("en"/"pl") books []bible.BookInfo // dialect books, scriptural order versions []string // corpus versions available (wuj/vul/grb/drb subset) verIdx int mode readerMode // picker query string matches []int // indices into books, filtered + ranked pickSel int // selected position within matches pickTop int // first visible match (list scroll) // reading bookIdx int // index into books chapters []int // sorted chapters for current book+version chapPos int // index into chapters verses []bible.Verse scroll int chapJumpBuf string // digits typed in modeChapterJump // bookmarks 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 markTags string // tags being typed in modeMark markField int // 0 = note field, 1 = tags field marks []bookmarks.Bookmark // loaded list for modeBookmarks (unfiltered) markSel int // selection into the VISIBLE (filtered) list markTop int // list scroll offset markFilter string // active tag filter (substring, case-insensitive) markFiltering bool // typing into the tag filter confirmDelete bool // bookmarks list is awaiting delete confirmation markedVerses map[int]bool // verse numbers in the current book+chapter that carry a bookmark 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. 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. func corpusVersions(cfg config.Config) []string { var out []string for _, v := range cfg.Versions { if config.ValidVersion(v) { out = append(out, v) } } if len(out) == 0 { out = []string{"wuj", "vul", "grb", "drb"} } return out } func (m ReaderModel) version() string { if m.verIdx < 0 || m.verIdx >= len(m.versions) { return "" } return m.versions[m.verIdx] } func (m ReaderModel) Init() tea.Cmd { return nil } // refilter rebuilds matches from query (fuzzy, ranked); empty query lists all // books in scriptural order. func (m *ReaderModel) refilter() { m.matches = m.matches[:0] q := strings.TrimSpace(m.query) if q == "" { for i := range m.books { m.matches = append(m.matches, i) } } else { type sc struct{ i, score int } var scored []sc for i, b := range m.books { if s, ok := fuzzyScore(q, b.Shortcut+" "+b.Name); ok { scored = append(scored, sc{i, s}) } } sort.SliceStable(scored, func(a, b int) bool { return scored[a].score > scored[b].score }) for _, s := range scored { m.matches = append(m.matches, s.i) } } if m.pickSel >= len(m.matches) { m.pickSel = len(m.matches) - 1 } if m.pickSel < 0 { m.pickSel = 0 } m.pickTop = 0 } // fuzzyScore ranks target against query: a substring hit (prefix best) beats a // subsequence hit; ok=false when query is not even a subsequence. func fuzzyScore(q, target string) (int, bool) { ql, tl := strings.ToLower(q), strings.ToLower(target) if i := strings.Index(tl, ql); i >= 0 { return 1000 - i, true } ti := 0 for _, qc := range ql { idx := strings.IndexRune(tl[ti:], qc) if idx < 0 { return 0, false } ti += idx + len(string(qc)) } return 100, true } func (m ReaderModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height return m, nil case tea.KeyMsg: switch m.mode { case modePick: return m.updatePick(msg) case modeMarkVerse: return m.updateMarkVerse(msg) case modeMark: return m.updateMark(msg) case modeBookmarks: return m.updateBookmarks(msg) case modeChapterJump: return m.updateChapterJump(msg) default: return m.updateRead(msg) } } return m, nil } func (m ReaderModel) updatePick(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyCtrlC: return m, tea.Quit case tea.KeyEsc: if m.query != "" { m.query = "" m.refilter() return m, nil } return m, tea.Quit case tea.KeyEnter: if len(m.matches) > 0 { m.bookIdx = m.matches[m.pickSel] m = m.openBook() m.mode = modeRead } return m, nil case tea.KeyUp: if m.pickSel > 0 { m.pickSel-- } return m, nil case tea.KeyDown: if m.pickSel < len(m.matches)-1 { m.pickSel++ } return m, nil case tea.KeyBackspace: if r := []rune(m.query); len(r) > 0 { m.query = string(r[:len(r)-1]) m.refilter() } return m, nil case tea.KeySpace: m.query += " " m.refilter() return m, nil case tea.KeyRunes: m.query += string(msg.Runes) m.refilter() return m, nil } return m, nil } 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": 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 { m.marks = marks } m.markSel = 0 m.markTop = 0 m.markFilter = "" m.markFiltering = false m.mode = modeBookmarks return m, nil case "tab": return m.cycleVersion(+1), nil case "shift+tab": return m.cycleVersion(-1), nil case "n", "]", "right", "l": return m.chapterStep(+1), nil case "p", "[", "left", "h": return m.chapterStep(-1), nil case "c": if len(m.chapters) > 1 { m.mode = modeChapterJump m.chapJumpBuf = "" } return m, nil case "j", "down": m.scroll = m.clampRead(m.scroll + 1) return m, nil case "k", "up": m.scroll = m.clampRead(m.scroll - 1) return m, nil case " ", "f": m.scroll = m.clampRead(m.scroll + m.readVisible()) return m, nil case "u": m.scroll = m.clampRead(m.scroll - m.readVisible()) return m, nil case "g": m.scroll = 0 return m, nil case "G": m.scroll = m.clampRead(1 << 30) return m, nil } return m, nil } // 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.markTags = "" m.markField = 0 m.mode = modeMark } return m, nil } return m, nil } // updateMark handles the note/tags box for the chosen verse: type into the // active field, Tab switches note<->tags, Enter saves (both optional), Esc // cancels. func (m ReaderModel) updateMark(msg tea.KeyMsg) (tea.Model, tea.Cmd) { field := &m.markNote if m.markField == 1 { field = &m.markTags } switch msg.Type { case tea.KeyCtrlC: return m, tea.Quit case tea.KeyEsc: m.mode = modeRead m.markNote, m.markTags = "", "" return m, nil case tea.KeyTab, tea.KeyShiftTab, tea.KeyDown, tea.KeyUp: m.markField = 1 - m.markField return m, nil case tea.KeyEnter: m = m.saveBookmark(m.markVerse, m.markNote, m.markTags) m.markNote, m.markTags = "", "" m.mode = modeRead return m, nil case tea.KeyBackspace: if r := []rune(*field); len(r) > 0 { *field = string(r[:len(r)-1]) } return m, nil case tea.KeySpace: *field += " " return m, nil case tea.KeyRunes: *field += string(msg.Runes) return m, nil } return m, nil } // visMarks is the bookmarks currently shown: all of them, or, when a tag filter // is set, those with a tag containing it (case-insensitive substring). markSel // indexes into this, so navigation/open/delete all act on the visible list. func (m ReaderModel) visMarks() []bookmarks.Bookmark { if m.markFilter == "" { return m.marks } q := strings.ToLower(m.markFilter) var out []bookmarks.Bookmark for _, bm := range m.marks { for _, t := range bm.Tags { if strings.Contains(strings.ToLower(t), q) { out = append(out, bm) break } } } return out } // updateBookmarks handles the saved-bookmarks list: navigate, open (jump to that // book+chapter+verse), delete, filter by tag (/), or go back. func (m ReaderModel) updateBookmarks(msg tea.KeyMsg) (tea.Model, tea.Cmd) { vis := m.visMarks() if m.confirmDelete { switch msg.String() { case "y": if m.markSel < len(vis) && m.store != nil { _ = m.store.Delete(vis[m.markSel].ID) if marks, err := m.store.List(""); err == nil { m.marks = marks } if v := m.visMarks(); m.markSel >= len(v) { m.markSel = len(v) - 1 } if m.markSel < 0 { m.markSel = 0 } } m.confirmDelete = false case "n", "esc": m.confirmDelete = false } return m, nil } // Tag-filter input: type to narrow live, Enter keeps it (then j/k navigate // the filtered list), Esc clears it. if m.markFiltering { switch msg.Type { case tea.KeyEnter: m.markFiltering = false case tea.KeyEsc: m.markFiltering, m.markFilter, m.markSel = false, "", 0 case tea.KeyBackspace: if r := []rune(m.markFilter); len(r) > 0 { m.markFilter, m.markSel = string(r[:len(r)-1]), 0 } case tea.KeySpace: m.markFilter, m.markSel = m.markFilter+" ", 0 case tea.KeyRunes: m.markFilter, m.markSel = m.markFilter+string(msg.Runes), 0 } return m, nil } switch msg.String() { case "q", "ctrl+c": m.savePlace() return m, tea.Quit case "esc", "b": if m.markFilter != "" { // first esc clears an active filter, then back m.markFilter, m.markSel = "", 0 return m, nil } m.mode = modeRead return m.withMarks(), nil case "/": m.markFiltering = true return m, nil case "j", "down": if m.markSel < len(vis)-1 { m.markSel++ } return m, nil case "k", "up": if m.markSel > 0 { m.markSel-- } return m, nil case "d": if len(vis) > 0 { m.confirmDelete = true } return m, nil case "enter": if m.markSel < len(vis) { bm := vis[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 } // updateChapterJump handles the go-to-chapter prompt: type digits, Enter jumps // (clamped to the book's chapter range), Esc cancels. Non-digit keys are // ignored so the buffer only ever holds a number. func (m ReaderModel) updateChapterJump(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyCtrlC: return m, tea.Quit case tea.KeyEsc: m.mode = modeRead m.chapJumpBuf = "" return m, nil case tea.KeyEnter: m = m.jumpToChapter(m.chapJumpBuf) m.chapJumpBuf = "" m.mode = modeRead return m, nil case tea.KeyBackspace: if r := []rune(m.chapJumpBuf); len(r) > 0 { m.chapJumpBuf = string(r[:len(r)-1]) } return m, nil case tea.KeyRunes: for _, r := range msg.Runes { if r >= '0' && r <= '9' && len([]rune(m.chapJumpBuf)) < 4 { m.chapJumpBuf += string(r) } } return m, nil } return m, nil } // jumpToChapter parses buf as a chapter number, clamps it to the book's // chapter range, and opens that chapter at its top. An empty or non-numeric // buf is a no-op (the prompt just closes). Because the corpora number chapters // contiguously, the clamped number always names an existing chapter; if a book // ever had a gap, the nearest existing chapter at or after it is used. func (m ReaderModel) jumpToChapter(buf string) ReaderModel { if len(m.chapters) == 0 { return m } n, err := strconv.Atoi(strings.TrimSpace(buf)) if err != nil { return m } lo, hi := m.chapters[0], m.chapters[len(m.chapters)-1] if n < lo { n = lo } if n > hi { n = hi } pos := len(m.chapters) - 1 for i, c := range m.chapters { if c >= n { pos = i break } } if pos == m.chapPos { return m } m.chapPos = pos m.scroll = 0 m = m.loadVerses() m.savePlace() return m } // 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 } // 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 } starts := m.verseLineStarts(m.innerW()) sel := 0 for i, st := range starts { if st <= m.scroll { sel = i } else { break } } 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 // <= 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 at the chosen // verse with an optional note, and sets a flash message. // withMarks recomputes the set of verse numbers in the current book+chapter that // carry a bookmark, so the reader can flag them with a red "*". One store read; // refreshed on chapter load, on save, and on returning from the bookmarks list. func (m ReaderModel) withMarks() ReaderModel { m.markedVerses = nil if m.store == nil || m.bookIdx < 0 || m.bookIdx >= len(m.books) { return m } all, err := m.store.List("") if err != nil { return m } canon := m.books[m.bookIdx].Canonical chap := m.currentChapter() set := map[int]bool{} for _, bm := range all { if bm.Book == canon && bm.Chapter == chap { set[bm.Verse] = true } } m.markedVerses = set return m } func (m ReaderModel) saveBookmark(verse int, note, tags string) ReaderModel { if m.store == nil || len(m.verses) == 0 { return m } book := m.books[m.bookIdx] chap := m.currentChapter() if verse <= 0 { verse = m.topVerse() } _, _ = m.store.Add(bookmarks.Bookmark{ Book: book.Canonical, Chapter: chap, Verse: verse, Note: strings.TrimSpace(note), Tags: bookmarks.ParseTags(tags), }) m.flash = fmt.Sprintf("bookmarked %s %d:%d", book.Name, chap, verse) return m.withMarks() } // 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 m = m.loadVerses() m.savePlace() return m } func (m ReaderModel) loadVerses() ReaderModel { if len(m.chapters) == 0 { m.verses = nil return m.withMarks() } if m.chapPos < 0 { m.chapPos = 0 } if m.chapPos >= len(m.chapters) { m.chapPos = len(m.chapters) - 1 } m.verses = bible.Verses(m.version(), m.books[m.bookIdx].Canonical, m.chapters[m.chapPos]) return m.withMarks() } func (m ReaderModel) chapterStep(d int) ReaderModel { if len(m.chapters) == 0 { return m } np := m.chapPos + d if np < 0 || np >= len(m.chapters) || np == m.chapPos { return m } m.chapPos = np m.scroll = 0 m = m.loadVerses() m.savePlace() return m } // cycleVersion moves the active version by d, wrapping. In reading mode it // recomputes the book's chapter list for the new version and keeps the same // chapter NUMBER when that version has it (else clamps). func (m ReaderModel) cycleVersion(d int) ReaderModel { n := len(m.versions) if n == 0 { return m } curChap := 0 if len(m.chapters) > 0 && m.chapPos < len(m.chapters) { curChap = m.chapters[m.chapPos] } m.verIdx = ((m.verIdx+d)%n + n) % n if m.mode == modeRead { m.chapters = bible.Chapters(m.version(), m.books[m.bookIdx].Canonical) m.chapPos = 0 for i, c := range m.chapters { if c == curChap { m.chapPos = i break } } m.scroll = 0 m = m.loadVerses() } return m } // readVisible is how many reading lines fit between the 1-line header and // 1-line footer, with a sane default before the first WindowSizeMsg. func (m ReaderModel) readVisible() int { chrome := 3 // header + footer + margin if m.height <= chrome { return 10 } return m.height - chrome } func (m ReaderModel) clampRead(s int) int { return clampScroll(s, len(m.readBody(m.innerW())), m.readVisible()) } func (m ReaderModel) innerW() int { w := m.width if w <= 0 { w = 80 } iw := w - 2 if iw < 20 { iw = 20 } return iw } // readBody returns the styled, wrapped verse lines for the current chapter. 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)} } 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) var lines []string for i, b := range blocks { var vlines []string if i == hl { for _, ln := range strings.Split(render.Wrap(b, w), "\n") { vlines = append(vlines, selStyle.Render(ln)) } } else { vlines = styleBlock(b, false, w, numW) } // Flag a bookmarked verse with a red "*" at the end of its last line. if m.markedVerses[m.verses[i].Verse] && len(vlines) > 0 { vlines[len(vlines)-1] += markStyle.Render("*") } lines = append(lines, vlines...) } return lines } func (m ReaderModel) View() string { switch m.mode { case modePick: return m.viewPick() case modeMark: return m.viewMark() case modeBookmarks: return m.viewBookmarks() case modeChapterJump: return m.viewChapterJump() default: // modeRead + modeMarkVerse (verse cursor highlighted in the reading) return m.viewRead() } } // viewChapterJump renders the go-to-chapter prompt as a prominent centered // dialog showing the book's valid chapter range and the digits typed so far. func (m ReaderModel) viewChapterJump() string { ui := i18n.Get(m.cfg.UILanguage) lo, hi := 0, 0 if len(m.chapters) > 0 { lo, hi = m.chapters[0], m.chapters[len(m.chapters)-1] } title := modalTitleStyle.Render(fmt.Sprintf("%s %s", m.books[m.bookIdx].Name, ui.ReaderJumpChapter)) prompt := fmt.Sprintf("%d-%d: %s▏", lo, hi, m.chapJumpBuf) inner := title + "\n\n" + prompt + "\n\n" + citationStyle.Render(ui.ReaderJumpChapterKeys) return m.modal(inner) } // viewMark renders the bookmark note/tags box as a prominent centered dialog. // The note and tags wrap (with a hanging indent) within a bounded width, so a // long note flows down inside the box instead of overrunning the border. func (m ReaderModel) viewMark() string { ui := i18n.Get(m.cfg.UILanguage) b := m.books[m.bookIdx] cw := m.width - 12 if cw > 56 { cw = 56 } if cw < 24 { cw = 24 } title := modalTitleStyle.Render(fmt.Sprintf("★ %s %d:%d", b.Name, m.currentChapter(), m.markVerse)) lines := wrapField(ui.ReaderMarkNote, m.markNote, cw, m.markField == 0) lines = append(lines, wrapField(ui.ReaderMarkTags, m.markTags, cw, m.markField == 1)...) inner := title + "\n\n" + strings.Join(lines, "\n") + "\n\n" + citationStyle.Render(ui.ReaderMarkHelp) return m.modal(inner) } // wrapField renders "label: value" wrapping value to width with a hanging // indent under the label; a cursor is appended when the field is active. func wrapField(label, value string, width int, active bool) []string { prefix := label + ": " pw := len([]rune(prefix)) text := value if active { text += "▏" } tw := width - pw if tw < 8 { tw = 8 } wrapped := hardWrap(text, tw) indent := strings.Repeat(" ", pw) out := make([]string, 0, len(wrapped)) for i, ln := range wrapped { if i == 0 { out = append(out, prefix+ln) } else { out = append(out, indent+ln) } } return out } // hardWrap wraps on spaces but, unlike render.Wrap, also hard-breaks a single // token longer than width (notes can contain arbitrary unbroken text). func hardWrap(s string, width int) []string { if width < 1 { width = 1 } var lines []string cur := "" for _, word := range strings.Fields(s) { for len([]rune(word)) > width { if cur != "" { lines = append(lines, cur) cur = "" } r := []rune(word) lines = append(lines, string(r[:width])) word = string(r[width:]) } switch { case cur == "": cur = word case len([]rune(cur))+1+len([]rune(word)) <= width: cur += " " + word default: lines = append(lines, cur) cur = word } } if cur != "" { lines = append(lines, cur) } if len(lines) == 0 { return []string{""} } return lines } // markLine renders one bookmark as "Book Chap:Verse — note #tag1 #tag2" // (note and tags shown only when present). func (m ReaderModel) markLine(bm bookmarks.Bookmark) string { name := bm.Book if idx := m.bookIndex(bm.Book); idx >= 0 { name = m.books[idx].Name } s := fmt.Sprintf("%s %d", name, bm.Chapter) if bm.Verse > 0 { s += fmt.Sprintf(":%d", bm.Verse) } if bm.Note != "" { s += " — " + bm.Note } if len(bm.Tags) > 0 { s += " #" + strings.Join(bm.Tags, " #") } return s } // viewBookmarks renders the saved-bookmarks list (with the tag filter, if any). func (m ReaderModel) viewBookmarks() string { w := m.width if w <= 0 { w = 80 } ui := i18n.Get(m.cfg.UILanguage) vis := m.visMarks() // Deleting -> a prominent centered confirmation dialog naming the bookmark. if m.confirmDelete && m.markSel >= 0 && m.markSel < len(vis) { inner := modalTitleStyle.Render(ui.ReaderConfirmDelete) + "\n\n " + m.markLine(vis[m.markSel]) + "\n\n" + citationStyle.Render(ui.ReaderConfirmKeys) return m.modal(inner) } title := ui.ReaderBookmarksTitle if m.markFilter != "" || m.markFiltering { cursor := "" if m.markFiltering { cursor = "_" } title += " /" + m.markFilter + cursor } header := headerStyle.Width(w).Render(title) footer := footerStyle.Width(w).Render(ui.ReaderBookmarksKeys) if len(m.marks) == 0 { return header + "\n" + citationStyle.Render(ui.ReaderNoBookmarks) + "\n" + footer } if len(vis) == 0 { // a tag filter that matches nothing -- the header shows it return header + "\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(vis) && i < top+visible; i++ { row := m.markLine(vis[i]) 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 { w := m.width if w <= 0 { w = 80 } ui := i18n.Get(m.cfg.UILanguage) header := headerStyle.Width(w).Render(ui.ReaderTitle) footer := footerStyle.Width(w).Render(ui.ReaderPickKeys) prompt := headingStyle.Render("› ") + m.query // visible list window (header + prompt + footer + margin = 4 chrome lines) visible := m.height - 4 if visible < 3 { visible = 3 } // keep selection in view top := m.pickTop if m.pickSel < top { top = m.pickSel } if m.pickSel >= top+visible { top = m.pickSel - visible + 1 } if top < 0 { top = 0 } col := shortcutCol(m.books) var rows []string if len(m.matches) == 0 { rows = append(rows, citationStyle.Render(ui.ReaderNoMatch)) } for i := top; i < len(m.matches) && i < top+visible; i++ { b := m.books[m.matches[i]] pad := col - len([]rune(b.Shortcut)) if pad < 1 { pad = 1 } row := b.Shortcut + strings.Repeat(" ", pad) + b.Name if i == m.pickSel { row = selStyle.Render("› " + row) } else { row = " " + row } rows = append(rows, row) } return header + "\n" + prompt + "\n" + strings.Join(rows, "\n") + "\n" + footer } // shortcutCol is the width of the shortcut column: widest shortcut + 2. func shortcutCol(books []bible.BookInfo) int { w := 0 for _, b := range books { if n := len([]rune(b.Shortcut)); n > w { w = n } } return w + 2 } func (m ReaderModel) viewRead() string { w := m.width if w <= 0 { w = 80 } ui := i18n.Get(m.cfg.UILanguage) b := m.books[m.bookIdx] chap := m.currentChapter() label := m.version() if l, ok := ui.Version[m.version()]; ok { label = l } 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 switch m.mode { case modeMarkVerse: footerText = ui.ReaderMarkVerseKeys case modeMark: footerText = ui.ReaderMarkPrompt + ": " + m.markNote default: if m.flash != "" { footerText = m.flash } } footer := footerStyle.Width(w).Render(footerText) body := m.readBodyHL(m.innerW(), hl) visible := m.readVisible() scroll := clampScroll(m.scroll, len(body), visible) end := scroll + visible if end > len(body) { end = len(body) } return header + "\n" + strings.Join(body[scroll:end], "\n") + "\n" + footer }