From 1b4134d883544beba3fc51704322cef2a875a7d2 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 24 Jul 2026 22:13:06 +0200 Subject: lectio-ui reader: go-to-chapter jump (c) Inside a book, 'c' opens a centered prompt showing the book's chapter range (e.g. "1-21: ") and accepts a chapter number to jump straight there, instead of scrolling (handy for Psalm 119 etc). Digits only, backspace edits, Enter clamps to the valid range and opens that chapter at its top, Esc cancels. New modeChapterJump + updateChapterJump/jumpToChapter/ viewChapterJump; i18n ReaderJumpChapter/ReaderJumpChapterKeys (en/pl) and 'c' added to the reading keybar. Only offered when the book has >1 chapter. --- internal/tui/reader.go | 116 ++++++++++++++++++++++++++++++++++++++++---- internal/tui/reader_test.go | 58 ++++++++++++++++++++++ 2 files changed, 164 insertions(+), 10 deletions(-) (limited to 'internal/tui') 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) -- cgit v1.3