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/config/config.go | 2 +- internal/i18n/i18n.go | 283 +++++++++++++++++++++++--------------------- internal/tui/reader.go | 116 ++++++++++++++++-- internal/tui/reader_test.go | 58 +++++++++ 4 files changed, 310 insertions(+), 149 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index e2ea0d3..a7365bb 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.24.1" +const Version = "0.25.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 16d489a..36aa288 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -34,6 +34,9 @@ type UI struct { ReaderMarkPrompt, ReaderBookmarksTitle, ReaderBookmarksKeys, ReaderNoBookmarks string ReaderMarkVerseKeys, ReaderConfirmDelete, ReaderConfirmKeys string ReaderMarkNote, ReaderMarkTags, ReaderMarkHelp string + // Reader go-to-chapter prompt: the dialog label (rendered after the book + // name) and its enter/esc help line. + ReaderJumpChapter, ReaderJumpChapterKeys string // CLI banner label words and connective: the banner is // " " (e.g. "Gospel for 2026-07-22" / @@ -116,75 +119,77 @@ 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 version j/k scroll m mark b bookmarks esc books q quit", - ReaderNoText: "(no text in this version)", - ReaderNoMatch: "(no matching books)", - ReaderMarkPrompt: "note (enter to save, esc to cancel)", - ReaderBookmarksTitle: "bookmarks", - ReaderBookmarksKeys: "j/k move enter open d delete esc back q quit", - ReaderNoBookmarks: "no bookmarks yet — press m while reading", - ReaderMarkVerseKeys: "↑/↓ pick verse enter add note esc cancel", - ReaderConfirmDelete: "Delete this bookmark?", - ReaderConfirmKeys: "y = delete n = cancel", - ReaderMarkNote: "note", - ReaderMarkTags: "tags", - ReaderMarkHelp: "enter save · tab switch field · esc cancel", - BannerGospel: "Gospel", - BannerReadings: "Readings", - BannerConnective: "for", - Months: [12]string{"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}, - 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", - NavHome: "home", - NavSettings: "settings", - NavBookmarks: "bookmarks", - WebVerseOptional: "verse (optional)", - WebTagsPlaceholder: "tags, comma-separated", - WebAddBookmark: "bookmark", - WebAllTags: "all", - WebNoBookmarks: "No bookmarks yet.", - WebDelete: "delete", - WebSaved: "Saved.", - WebTradLang: "traditional language", - WebUILang: "UI language", - WebSiglaStyle: "sigla style", - WebDefaultVersion: "default version", - WebVersions: "versions", - WebWebVersions: "web versions", - WebOffline: "offline", - WebWidth: "width", - WebPort: "web port", - WebPager: "pager", - WebSave: "Save", - Export: "download", - Calendar: "month calendar", - 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 c go-to 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 (enter to save, esc to cancel)", + ReaderBookmarksTitle: "bookmarks", + ReaderBookmarksKeys: "j/k move enter open d delete esc back q quit", + ReaderNoBookmarks: "no bookmarks yet — press m while reading", + ReaderMarkVerseKeys: "↑/↓ pick verse enter add note esc cancel", + ReaderConfirmDelete: "Delete this bookmark?", + ReaderConfirmKeys: "y = delete n = cancel", + ReaderMarkNote: "note", + ReaderMarkTags: "tags", + ReaderMarkHelp: "enter save · tab switch field · esc cancel", + ReaderJumpChapter: "go to chapter", + ReaderJumpChapterKeys: "enter go · esc cancel", + BannerGospel: "Gospel", + BannerReadings: "Readings", + BannerConnective: "for", + Months: [12]string{"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}, + 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", + NavHome: "home", + NavSettings: "settings", + NavBookmarks: "bookmarks", + WebVerseOptional: "verse (optional)", + WebTagsPlaceholder: "tags, comma-separated", + WebAddBookmark: "bookmark", + WebAllTags: "all", + WebNoBookmarks: "No bookmarks yet.", + WebDelete: "delete", + WebSaved: "Saved.", + WebTradLang: "traditional language", + WebUILang: "UI language", + WebSiglaStyle: "sigla style", + WebDefaultVersion: "default version", + WebVersions: "versions", + WebWebVersions: "web versions", + WebOffline: "offline", + WebWidth: "width", + WebPort: "web port", + WebPager: "pager", + WebSave: "Save", + Export: "download", + Calendar: "month calendar", + 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{ @@ -202,73 +207,75 @@ 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 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 (enter zapisz, esc anuluj)", - 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", - ReaderMarkVerseKeys: "↑/↓ wybierz werset enter notatka esc anuluj", - ReaderConfirmDelete: "Usunąć zakładkę?", - ReaderConfirmKeys: "y = usuń n = anuluj", - ReaderMarkNote: "notatka", - ReaderMarkTags: "tagi", - ReaderMarkHelp: "enter zapisz · tab pole · esc anuluj", - BannerGospel: "Ewangelia", - BannerReadings: "Czytania", - BannerConnective: "na", - Months: [12]string{"Styczeń", "Luty", "Marzec", "Kwiecień", "Maj", "Czerwiec", "Lipiec", "Sierpień", "Wrzesień", "Październik", "Listopad", "Grudzień"}, - 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ł", - NavHome: "strona główna", - NavSettings: "ustawienia", - NavBookmarks: "zakładki", - WebVerseOptional: "werset (opcjonalnie)", - WebTagsPlaceholder: "tagi, oddzielone przecinkami", - WebAddBookmark: "zakładka", - WebAllTags: "wszystkie", - WebNoBookmarks: "Brak zakładek.", - WebDelete: "usuń", - WebSaved: "Zapisano.", - WebTradLang: "język tradycyjny", - WebUILang: "język interfejsu", - WebSiglaStyle: "styl sigli", - WebDefaultVersion: "wersja domyślna", - WebVersions: "wersje", - WebWebVersions: "wersje web", - WebOffline: "offline", - WebWidth: "szerokość", - WebPort: "port web", - WebPager: "pager", - WebSave: "Zapisz", - Export: "pobierz", - Calendar: "kalendarz miesiąca", - 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ł c skok 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 (enter zapisz, esc anuluj)", + 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", + ReaderMarkVerseKeys: "↑/↓ wybierz werset enter notatka esc anuluj", + ReaderConfirmDelete: "Usunąć zakładkę?", + ReaderConfirmKeys: "y = usuń n = anuluj", + ReaderMarkNote: "notatka", + ReaderMarkTags: "tagi", + ReaderMarkHelp: "enter zapisz · tab pole · esc anuluj", + ReaderJumpChapter: "przejdź do rozdziału", + ReaderJumpChapterKeys: "enter przejdź · esc anuluj", + BannerGospel: "Ewangelia", + BannerReadings: "Czytania", + BannerConnective: "na", + Months: [12]string{"Styczeń", "Luty", "Marzec", "Kwiecień", "Maj", "Czerwiec", "Lipiec", "Sierpień", "Wrzesień", "Październik", "Listopad", "Grudzień"}, + 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ł", + NavHome: "strona główna", + NavSettings: "ustawienia", + NavBookmarks: "zakładki", + WebVerseOptional: "werset (opcjonalnie)", + WebTagsPlaceholder: "tagi, oddzielone przecinkami", + WebAddBookmark: "zakładka", + WebAllTags: "wszystkie", + WebNoBookmarks: "Brak zakładek.", + WebDelete: "usuń", + WebSaved: "Zapisano.", + WebTradLang: "język tradycyjny", + WebUILang: "język interfejsu", + WebSiglaStyle: "styl sigli", + WebDefaultVersion: "wersja domyślna", + WebVersions: "wersje", + WebWebVersions: "wersje web", + WebOffline: "offline", + WebWidth: "szerokość", + WebPort: "port web", + WebPager: "pager", + WebSave: "Zapisz", + Export: "pobierz", + Calendar: "kalendarz miesiąca", + 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 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