aboutsummaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/reader.go116
-rw-r--r--internal/tui/reader_test.go58
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)