diff options
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/reader.go | 116 | ||||
| -rw-r--r-- | internal/tui/reader_test.go | 58 |
2 files changed, 164 insertions, 10 deletions
diff --git a/internal/tui/reader.go b/internal/tui/reader.go index df92349..7f23f66 100644 --- a/internal/tui/reader.go +++ b/internal/tui/reader.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "sort" + "strconv" "strings" tea "github.com/charmbracelet/bubbletea" @@ -21,11 +22,12 @@ import ( 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 + 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). @@ -70,11 +72,12 @@ type ReaderModel struct { 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 + 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 @@ -218,6 +221,8 @@ func (m ReaderModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateMark(msg) case modeBookmarks: return m.updateBookmarks(msg) + case modeChapterJump: + return m.updateChapterJump(msg) default: return m.updateRead(msg) } @@ -302,6 +307,12 @@ func (m ReaderModel) updateRead(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 @@ -457,6 +468,75 @@ func (m ReaderModel) updateBookmarks(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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) { @@ -735,11 +815,27 @@ func (m ReaderModel) View() string { 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. diff --git a/internal/tui/reader_test.go b/internal/tui/reader_test.go index 904836e..9d324ba 100644 --- a/internal/tui/reader_test.go +++ b/internal/tui/reader_test.go @@ -165,6 +165,64 @@ func TestReaderBookmarkFlow(t *testing.T) { } } +func TestReaderChapterJump(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 (21 chapters) + if m.mode != modeRead { + t.Fatal("did not enter reading") + } + // c opens the jump prompt + m = key(m, runes("c")) + if m.mode != modeChapterJump { + t.Fatalf("c did not open the chapter-jump prompt (mode=%v)", m.mode) + } + if hi := m.chapters[len(m.chapters)-1]; hi != 21 { + t.Fatalf("John should have 21 chapters, got %d", hi) + } + if v := m.View(); !strings.Contains(v, "1-21") || !strings.Contains(v, "go to chapter") { + t.Errorf("jump prompt missing range/label:\n%s", v) + } + // non-digit keys are ignored; only digits accumulate + m = key(m, runes("x1")) + m = key(m, runes("4")) + if m.chapJumpBuf != "14" { + t.Fatalf("buffer = %q want 14 (non-digits filtered)", m.chapJumpBuf) + } + m = key(m, tea.KeyMsg{Type: tea.KeyBackspace}) + m = key(m, runes("2")) // -> "12" + m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.mode != modeRead { + t.Fatalf("enter did not return to reading (mode=%v)", m.mode) + } + if m.currentChapter() != 12 { + t.Errorf("jumped to chapter %d want 12", m.currentChapter()) + } + if m.scroll != 0 { + t.Errorf("jump should reset scroll, got %d", m.scroll) + } + + // out-of-range clamps to the last chapter + m = key(m, runes("c")) + m = key(m, runes("999")) + m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.currentChapter() != 21 { + t.Errorf("999 should clamp to 21, got %d", m.currentChapter()) + } + + // esc cancels without moving + m = key(m, runes("c")) + m = key(m, runes("3")) + m = key(m, tea.KeyMsg{Type: tea.KeyEsc}) + if m.mode != modeRead { + t.Fatalf("esc did not return to reading (mode=%v)", m.mode) + } + if m.currentChapter() != 21 { + t.Errorf("esc changed chapter to %d want 21", m.currentChapter()) + } +} + func TestReaderRemembersPlace(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_DATA_HOME", dir) |
