diff options
| -rw-r--r-- | cmd/lectio-ui/main.go | 13 | ||||
| -rw-r--r-- | internal/bible/bible.go | 13 | ||||
| -rw-r--r-- | internal/bible/bible_test.go | 16 | ||||
| -rw-r--r-- | internal/config/config.go | 17 | ||||
| -rw-r--r-- | internal/i18n/i18n.go | 13 | ||||
| -rw-r--r-- | internal/tui/reader.go | 445 | ||||
| -rw-r--r-- | internal/tui/reader_test.go | 100 |
7 files changed, 614 insertions, 3 deletions
diff --git a/cmd/lectio-ui/main.go b/cmd/lectio-ui/main.go index 023cf66..d8e2f27 100644 --- a/cmd/lectio-ui/main.go +++ b/cmd/lectio-ui/main.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" + "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/tui" ) @@ -21,6 +22,7 @@ Usage: Flags: -b, --bible VER start on version: bt,wuj,vul,grb,drb + -R, --reader open the Bible reader (book picker) instead of the daily view -a, --all show all parts (override config) -o, --offline cache/sigla only, no network -l, --lectionary WHICH new|trad (trad -> traditional) @@ -64,7 +66,7 @@ func run(args []string, stdout, stderr io.Writer) int { return 1 } - var all, offline bool + var all, offline, reader bool var bibleVer, lectionary, lang string fs := flag.NewFlagSet("lectio-ui", flag.ContinueOnError) @@ -73,6 +75,8 @@ func run(args []string, stdout, stderr io.Writer) int { fs.StringVar(&bibleVer, "b", "", "start on version: bt,wuj,vul,grb,drb") fs.StringVar(&bibleVer, "bible", "", "start on version: bt,wuj,vul,grb,drb") + fs.BoolVar(&reader, "R", false, "open the Bible reader (fuzzy book picker) instead of the daily view") + fs.BoolVar(&reader, "reader", false, "open the Bible reader (fuzzy book picker) instead of the daily view") fs.BoolVar(&all, "a", cfg.All, "show all parts (override config)") fs.BoolVar(&all, "all", cfg.All, "show all parts (override config)") fs.BoolVar(&offline, "o", cfg.Offline, "cache/sigla only, no network") @@ -113,7 +117,12 @@ func run(args []string, stdout, stderr io.Writer) int { cfg.TraditionalLang = lang } - if _, err := tea.NewProgram(tui.New(cfg, date, bibleVer), tea.WithAltScreen()).Run(); err != nil { + var mdl tea.Model = tui.New(cfg, date, bibleVer) + if reader { + tbl, _ := bible.LoadBookTable(config.UserBooksTOML()) + mdl = tui.NewReader(cfg, tbl) + } + if _, err := tea.NewProgram(mdl, tea.WithAltScreen()).Run(); err != nil { fmt.Fprintln(stderr, err) return 1 } diff --git a/internal/bible/bible.go b/internal/bible/bible.go index 0254ceb..312daeb 100644 --- a/internal/bible/bible.go +++ b/internal/bible/bible.go @@ -2,6 +2,7 @@ package bible import ( "embed" + "sort" "strconv" "strings" "sync" @@ -58,3 +59,15 @@ func load(version string) *corpus { func Verses(version, book string, chap int) []Verse { return load(version).books[book][chap] } + +// Chapters returns the chapter numbers present for a book in a version, sorted +// ascending (empty if the version has no such book). +func Chapters(version, book string) []int { + chapMap := load(version).books[book] + chaps := make([]int, 0, len(chapMap)) + for ch := range chapMap { + chaps = append(chaps, ch) + } + sort.Ints(chaps) + return chaps +} diff --git a/internal/bible/bible_test.go b/internal/bible/bible_test.go index efd22d6..36223a3 100644 --- a/internal/bible/bible_test.go +++ b/internal/bible/bible_test.go @@ -28,3 +28,19 @@ func TestVerses(t *testing.T) { } func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p } + +func TestChapters(t *testing.T) { + ch := Chapters("wuj", "John") + if len(ch) == 0 || ch[0] != 1 { + t.Fatalf("John chapters = %v", ch) + } + for i := 1; i < len(ch); i++ { + if ch[i] <= ch[i-1] { + t.Errorf("chapters not sorted ascending: %v", ch) + break + } + } + if got := Chapters("wuj", "Nonesuch"); len(got) != 0 { + t.Errorf("missing book chapters = %v want empty", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8ee15e1..9a41adc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,7 +24,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.7.0" +const Version = "0.8.0" // validVersions are the five scripture versions lectio understands. var validVersions = map[string]bool{ @@ -203,6 +203,21 @@ func BooksPath() (string, error) { return filepath.Join(filepath.Dir(p), "books.toml"), nil } +// UserBooksTOML returns the bytes of the optional user books.toml (see +// BooksPath), or nil when it is absent or unreadable -- callers then fall back +// to bible's embedded default table. +func UserBooksTOML() []byte { + p, err := BooksPath() + if err != nil { + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return nil + } + return b +} + // seedIfMissing writes the embedded default config to path if nothing is // there yet. func seedIfMissing(path string) error { diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index a108323..f752e9b 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -24,6 +24,9 @@ type UI struct { // error string directly onto them, no separator added at the call site). FooterKeys, Loading, NoReadingsFor, ErrorPrefix, ErrorHint string + // Reader-mode (lectio-ui --reader) chrome. + ReaderTitle, ReaderPickKeys, ReaderReadKeys, ReaderNoText, ReaderNoMatch string + // CLI banner label words and connective: the banner is // "<word> <BannerConnective> <date>" (e.g. "Gospel for 2026-07-22" / // "Ewangelia na 2026-07-22"). @@ -82,6 +85,11 @@ var enUI = UI{ NoReadingsFor: "no readings for ", ErrorPrefix: "error: ", ErrorHint: "change date (←/→) or refresh (r)", + ReaderTitle: "reader — pick a book", + ReaderPickKeys: "type to filter ↑/↓ move enter open esc quit", + ReaderReadKeys: "n/p chapter tab/⇧tab version j/k scroll space page g/G ends esc books q quit", + ReaderNoText: "(no text in this version)", + ReaderNoMatch: "(no matching books)", BannerGospel: "Gospel", BannerReadings: "Readings", BannerConnective: "for", @@ -125,6 +133,11 @@ var plUI = UI{ NoReadingsFor: "brak czytań na ", ErrorPrefix: "błąd: ", ErrorHint: "zmień datę (←/→) lub odśwież (r)", + ReaderTitle: "czytnik — wybierz księgę", + ReaderPickKeys: "wpisz, by filtrować ↑/↓ ruch enter otwórz esc wyjście", + ReaderReadKeys: "n/p rozdział tab/⇧tab wersja j/k przewiń spacja strona g/G końce esc księgi q wyjście", + ReaderNoText: "(brak tekstu w tej wersji)", + ReaderNoMatch: "(brak pasujących ksiąg)", BannerGospel: "Ewangelia", BannerReadings: "Czytania", BannerConnective: "na", diff --git a/internal/tui/reader.go b/internal/tui/reader.go new file mode 100644 index 0000000..8e33534 --- /dev/null +++ b/internal/tui/reader.go @@ -0,0 +1,445 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/lukaszkasprzak/lectio/internal/bible" + "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/i18n" +) + +// readerMode is the reader's screen: the book picker or the chapter view. +type readerMode int + +const ( + modePick readerMode = iota + modeRead +) + +// selStyle marks the picker's selected row (reverse video, legible on any theme). +var selStyle = lipgloss.NewStyle().Reverse(true) + +// 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 + + 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. +func NewReader(cfg config.Config, tbl *bible.BookTable) ReaderModel { + dialect := cfg.SiglaLang() + m := ReaderModel{ + cfg: cfg, + dialect: dialect, + books: tbl.Books(dialect), + versions: corpusVersions(cfg), + mode: modePick, + } + m.refilter() + return m +} + +// 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) && v != "bt" { + 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: + if m.mode == modePick { + return m.updatePick(msg) + } + 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) { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "esc", "backspace": + m.mode = modePick + 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 "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 +} + +// 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 + return m.loadVerses() +} + +func (m ReaderModel) loadVerses() ReaderModel { + if len(m.chapters) == 0 { + m.verses = nil + return m + } + 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 +} + +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 + return m.loadVerses() +} + +// 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 { + 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 _, b := range blocks { + lines = append(lines, styleBlock(b, false, w, numW)...) + } + return lines +} + +func (m ReaderModel) View() string { + if m.mode == modePick { + return m.viewPick() + } + return m.viewRead() +} + +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 := 0 + if len(m.chapters) > 0 && m.chapPos < len(m.chapters) { + chap = m.chapters[m.chapPos] + } + label := m.version() + if l, ok := ui.Version[m.version()]; ok { + label = l + } + header := headerStyle.Width(w).Render(fmt.Sprintf("%s %d [%s]", b.Name, chap, label)) + footer := footerStyle.Width(w).Render(ui.ReaderReadKeys) + + body := m.readBody(m.innerW()) + 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 +} diff --git a/internal/tui/reader_test.go b/internal/tui/reader_test.go new file mode 100644 index 0000000..167e546 --- /dev/null +++ b/internal/tui/reader_test.go @@ -0,0 +1,100 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/lukaszkasprzak/lectio/internal/bible" + "github.com/lukaszkasprzak/lectio/internal/config" +) + +func enReader(t *testing.T) ReaderModel { + t.Helper() + tbl, err := bible.LoadBookTable(nil) + if err != nil { + t.Fatal(err) + } + cfg := config.Default() + cfg.UILanguage = "en" + cfg.SiglaStyle = "english" + cfg.Versions = []string{"bt", "wuj", "vul", "grb", "drb"} + return NewReader(cfg, tbl) +} + +func key(m ReaderModel, k tea.KeyMsg) ReaderModel { + nm, _ := m.Update(k) + return nm.(ReaderModel) +} + +func runes(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } + +func TestReaderInit(t *testing.T) { + m := enReader(t) + if m.mode != modePick { + t.Errorf("mode = %v want modePick", m.mode) + } + if len(m.matches) != len(m.books) || len(m.books) != 73 { + t.Errorf("matches=%d books=%d", len(m.matches), len(m.books)) + } + if len(m.versions) == 0 || m.versions[0] == "bt" { + t.Errorf("versions = %v (bt must be excluded, non-empty)", m.versions) + } +} + +func TestReaderFilterAndOpen(t *testing.T) { + m := enReader(t) + nm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = nm.(ReaderModel) + m = key(m, runes("jn")) + if len(m.matches) == 0 || m.books[m.matches[0]].Canonical != "John" { + t.Fatalf("filter 'jn' top = %v", m.books[m.matches[m.pickSel]]) + } + m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.mode != modeRead { + t.Fatalf("did not enter read mode") + } + if m.books[m.bookIdx].Canonical != "John" || len(m.verses) == 0 { + t.Errorf("opened book=%q verses=%d", m.books[m.bookIdx].Canonical, len(m.verses)) + } + if !strings.Contains(m.View(), "1") { // chapter 1 header + t.Errorf("read view missing chapter:\n%s", m.View()) + } +} + +func TestReaderChapterAndVersion(t *testing.T) { + m := enReader(t) + nm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = nm.(ReaderModel) + m = key(m, runes("jn")) + m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) + firstChap := m.chapters[m.chapPos] + m = key(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) // next chapter + if m.chapters[m.chapPos] <= firstChap { + t.Errorf("chapter did not advance: %d -> %d", firstChap, m.chapters[m.chapPos]) + } + v0 := m.verIdx + m = key(m, tea.KeyMsg{Type: tea.KeyTab}) + if m.verIdx == v0 { + t.Errorf("version did not cycle") + } + // esc returns to picker + m = key(m, tea.KeyMsg{Type: tea.KeyEsc}) + if m.mode != modePick { + t.Errorf("esc did not return to picker") + } +} + +func TestReaderFuzzyScore(t *testing.T) { + if s, ok := fuzzyScore("gen", "Gen Genesis"); !ok || s < 500 { + t.Errorf("substring score = %d,%v", s, ok) + } + if _, ok := fuzzyScore("xyz", "Gen Genesis"); ok { + t.Error("xyz should not match Genesis") + } + // subsequence: g..s..s across the string + if _, ok := fuzzyScore("gss", "Gen Genesis"); !ok { + t.Error("gss should subsequence-match") + } +} |
