summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 14:03:04 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 14:03:04 +0200
commit2e91d69ffb5989a167821979d4ed8d71a0f6ecbe (patch)
tree52bfee726eb3075d384f0256682bf4321cb65585
parent07bf5808039283ef7375b4af7703419f26b08b82 (diff)
downloadlectio-2e91d69ffb5989a167821979d4ed8d71a0f6ecbe.tar.gz
lectio-2e91d69ffb5989a167821979d4ed8d71a0f6ecbe.zip
tui: colored reader + version switch
-rw-r--r--cmd/lectio-ui/main.go22
-rw-r--r--go.mod26
-rw-r--r--go.sum43
-rw-r--r--internal/tui/styles.go44
-rw-r--r--internal/tui/tui.go348
-rw-r--r--internal/tui/tui_test.go31
6 files changed, 511 insertions, 3 deletions
diff --git a/cmd/lectio-ui/main.go b/cmd/lectio-ui/main.go
index eced3b5..13e3551 100644
--- a/cmd/lectio-ui/main.go
+++ b/cmd/lectio-ui/main.go
@@ -1,5 +1,23 @@
package main
-import "fmt"
+import (
+ "fmt"
+ "os"
-func main() { fmt.Println("lectio-ui: not yet implemented") }
+ tea "github.com/charmbracelet/bubbletea"
+
+ "github.com/lukaszkasprzak/lectio/internal/config"
+ "github.com/lukaszkasprzak/lectio/internal/tui"
+)
+
+func main() {
+ cfg, err := config.Load()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ if _, err := tea.NewProgram(tui.New(cfg), tea.WithAltScreen()).Run(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/go.mod b/go.mod
index 9852557..94f5ab2 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,28 @@ module github.com/lukaszkasprzak/lectio
go 1.24.0
-require github.com/pelletier/go-toml/v2 v2.4.2
+require (
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/lipgloss v1.1.0
+ github.com/pelletier/go-toml/v2 v2.4.2
+)
+
+require (
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+ github.com/charmbracelet/x/ansi v0.10.1 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sys v0.36.0 // indirect
+ golang.org/x/text v0.3.8 // indirect
+)
diff --git a/go.sum b/go.sum
index df426f0..cd63509 100644
--- a/go.sum
+++ b/go.sum
@@ -1,2 +1,45 @@
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
+github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
diff --git a/internal/tui/styles.go b/internal/tui/styles.go
new file mode 100644
index 0000000..94b591e
--- /dev/null
+++ b/internal/tui/styles.go
@@ -0,0 +1,44 @@
+package tui
+
+import "github.com/charmbracelet/lipgloss"
+
+// Color roles for the reader. lipgloss.AdaptiveColor picks the Light or Dark
+// value based on the terminal's detected background, and the default
+// renderer's color profile already honors NO_COLOR (muesli/termenv's
+// EnvColorProfile falls back to Ascii, so styled text degrades to plain
+// text automatically -- no extra handling needed here).
+var (
+ accentColor = lipgloss.AdaptiveColor{Light: "#8839ef", Dark: "#cba6f7"} // heading
+ dimColor = lipgloss.AdaptiveColor{Light: "#6c6f85", Dark: "#a6adc8"} // citation
+ mutedColor = lipgloss.AdaptiveColor{Light: "#9ca0b0", Dark: "#6c7086"} // verse numbers
+ textColor = lipgloss.AdaptiveColor{Light: "#4c4f69", Dark: "#cdd6f4"} // verse text (default)
+ barColor = lipgloss.AdaptiveColor{Light: "#eff1f5", Dark: "#313244"} // header/footer background
+ barTextColor = lipgloss.AdaptiveColor{Light: "#4c4f69", Dark: "#cdd6f4"}
+ errColor = lipgloss.AdaptiveColor{Light: "#d20f39", Dark: "#f38ba8"}
+)
+
+var (
+ // headingStyle renders a reading section's heading (e.g. "Ewangelia").
+ headingStyle = lipgloss.NewStyle().Bold(true).Foreground(accentColor)
+
+ // citationStyle renders a section's subtitle/citation line.
+ citationStyle = lipgloss.NewStyle().Faint(true).Foreground(dimColor)
+
+ // verseNumStyle renders a bible verse's "chapter:verse" prefix.
+ verseNumStyle = lipgloss.NewStyle().Foreground(mutedColor)
+
+ // verseTextStyle renders verse/paragraph body text.
+ verseTextStyle = lipgloss.NewStyle().Foreground(textColor)
+
+ // refrainStyle renders a responsorial psalm's repeated refrain.
+ refrainStyle = lipgloss.NewStyle().Italic(true).Foreground(textColor)
+
+ // headerStyle renders the top bar (date + active version).
+ headerStyle = lipgloss.NewStyle().Bold(true).Foreground(barTextColor).Background(barColor).Padding(0, 1)
+
+ // footerStyle renders the bottom keybar.
+ footerStyle = lipgloss.NewStyle().Faint(true).Foreground(barTextColor).Background(barColor).Padding(0, 1)
+
+ // errStyle renders the error line.
+ errStyle = lipgloss.NewStyle().Bold(true).Foreground(errColor)
+)
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
new file mode 100644
index 0000000..1e3b948
--- /dev/null
+++ b/internal/tui/tui.go
@@ -0,0 +1,348 @@
+// Package tui is lectio's interactive Bubble Tea reader: a colored,
+// scrollable full-day view of the liturgy with version cycling and date
+// navigation. It re-styles the same render.GatherVersion data the CLI
+// prints as plain text.
+package tui
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "github.com/lukaszkasprzak/lectio/internal/config"
+ "github.com/lukaszkasprzak/lectio/internal/liturgy"
+ "github.com/lukaszkasprzak/lectio/internal/readings"
+ "github.com/lukaszkasprzak/lectio/internal/render"
+)
+
+// Model is the TUI's state: it implements tea.Model.
+type Model struct {
+ cfg config.Config
+ versions []string
+ verIdx int
+ date string
+ sections []liturgy.Section
+ scroll int
+ width int
+ height int
+ loading bool
+ err error
+}
+
+// readingsMsg carries a successful fetch's sections back to Update.
+type readingsMsg struct {
+ sections []liturgy.Section
+}
+
+// errMsg carries a failed fetch's error back to Update.
+type errMsg struct {
+ err error
+}
+
+const footerKeys = "tab/⇧tab wersja ←/→ dzień j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście"
+
+// New builds the initial model: cfg.Offline drops "pl" from the version
+// list (render.OfflineVersions), the active version starts at
+// cfg.DefaultVersion (falling back to the first version if not found, or
+// "" if there are none), and the date starts at today. The first fetch is
+// issued by Init, not here.
+func New(cfg config.Config) Model {
+ versions := append([]string(nil), cfg.Versions...)
+ if cfg.Offline {
+ versions = render.OfflineVersions(versions)
+ }
+
+ idx := indexOf(versions, cfg.DefaultVersion)
+ if idx < 0 {
+ idx = 0
+ }
+ idx = clampIndex(idx, len(versions))
+
+ return Model{
+ cfg: cfg,
+ versions: versions,
+ verIdx: idx,
+ date: time.Now().Format("2006-01-02"),
+ loading: true,
+ }
+}
+
+// version returns the active version code, or "" if there are none.
+func (m Model) version() string {
+ if m.verIdx < 0 || m.verIdx >= len(m.versions) {
+ return ""
+ }
+ return m.versions[m.verIdx]
+}
+
+// cycleVersion moves the active version by d steps, wrapping around the
+// list (tab = +1, shift+tab = -1). A pure, unit-tested helper.
+func (m Model) cycleVersion(d int) Model {
+ n := len(m.versions)
+ if n == 0 {
+ return m
+ }
+ m.verIdx = ((m.verIdx+d)%n + n) % n
+ return m
+}
+
+// indexOf returns the index of v in list, or -1 if not present.
+func indexOf(list []string, v string) int {
+ for i, s := range list {
+ if s == v {
+ return i
+ }
+ }
+ return -1
+}
+
+// clampIndex clamps i into [0, n-1], returning 0 when n == 0.
+func clampIndex(i, n int) int {
+ if n == 0 {
+ return 0
+ }
+ if i < 0 {
+ return 0
+ }
+ if i >= n {
+ return n - 1
+ }
+ return i
+}
+
+// shiftDate adds days to date (YYYY-MM-DD); an unparsable date is returned
+// unchanged.
+func shiftDate(date string, days int) string {
+ t, err := time.Parse("2006-01-02", date)
+ if err != nil {
+ return date
+ }
+ return t.AddDate(0, 0, days).Format("2006-01-02")
+}
+
+// fetchCmd issues the readings.Load fetch for the model's current date as a
+// tea.Cmd, resolving to readingsMsg or errMsg. It is a full-day reader, so
+// All is always true; Offline follows cfg.Offline.
+func (m Model) fetchCmd() tea.Cmd {
+ cfg := m.cfg
+ date := m.date
+ return func() tea.Msg {
+ secs, err := readings.Load(cfg, readings.Options{
+ Date: date,
+ Offline: cfg.Offline,
+ All: true,
+ })
+ if err != nil {
+ return errMsg{err}
+ }
+ return readingsMsg{secs}
+ }
+}
+
+// Init issues the first load.
+func (m Model) Init() tea.Cmd {
+ return m.fetchCmd()
+}
+
+// Update handles key input and fetch results.
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ return m, nil
+
+ case readingsMsg:
+ m.loading = false
+ m.err = nil
+ m.sections = msg.sections
+ m.scroll = 0
+ return m, nil
+
+ case errMsg:
+ m.loading = false
+ m.err = msg.err
+ return m, nil
+
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "q", "ctrl+c":
+ return m, tea.Quit
+ case "tab":
+ return m.cycleVersion(+1), nil
+ case "shift+tab":
+ return m.cycleVersion(-1), nil
+ case "left":
+ m.date = shiftDate(m.date, -1)
+ m.loading = true
+ m.err = nil
+ return m, m.fetchCmd()
+ case "right":
+ m.date = shiftDate(m.date, +1)
+ m.loading = true
+ m.err = nil
+ return m, m.fetchCmd()
+ case "r":
+ m.loading = true
+ m.err = nil
+ return m, m.fetchCmd()
+ case "j", "down":
+ m.scroll++
+ return m, nil
+ case "k", "up":
+ if m.scroll > 0 {
+ m.scroll--
+ }
+ return m, nil
+ case " ":
+ m.scroll += pageSize(m.height)
+ return m, nil
+ case "b":
+ m.scroll -= pageSize(m.height)
+ if m.scroll < 0 {
+ m.scroll = 0
+ }
+ return m, nil
+ case "g":
+ m.scroll = 0
+ return m, nil
+ case "G":
+ m.scroll = 1 << 30 // clamped to the last page in View
+ return m, nil
+ }
+ }
+ return m, nil
+}
+
+// pageSize is how many reading lines fit between the header and footer bars
+// for a given terminal height; it falls back to a sane default before the
+// first tea.WindowSizeMsg arrives (height == 0).
+func pageSize(height int) int {
+ const chrome = 4 // header + blank + footer + margin
+ if height <= chrome {
+ return 10
+ }
+ return height - chrome
+}
+
+// clampScroll keeps scroll within [0, total-visible] (never negative).
+func clampScroll(scroll, total, visible int) int {
+ max := total - visible
+ if max < 0 {
+ max = 0
+ }
+ if scroll > max {
+ scroll = max
+ }
+ if scroll < 0 {
+ scroll = 0
+ }
+ return scroll
+}
+
+// View renders the header (date + active version label), the scrolling
+// reading, and the footer keybar.
+func (m Model) View() string {
+ w := m.width
+ if w <= 0 {
+ w = 80
+ }
+ innerW := w - 2
+ if innerW < 20 {
+ innerW = 20
+ }
+
+ header := headerStyle.Width(w).Render(m.headerText())
+ footer := footerStyle.Width(w).Render(footerKeys)
+
+ bodyLines := m.bodyLines(innerW)
+
+ visible := pageSize(m.height)
+ scroll := clampScroll(m.scroll, len(bodyLines), visible)
+ end := scroll + visible
+ if end > len(bodyLines) {
+ end = len(bodyLines)
+ }
+
+ return header + "\n" + strings.Join(bodyLines[scroll:end], "\n") + "\n" + footer
+}
+
+// headerText is "lectio DATE [version label]"; the label comes from the
+// active section's render.GatherVersion when sections are loaded, else the
+// bare version code.
+func (m Model) headerText() string {
+ label := m.version()
+ if len(m.sections) > 0 {
+ if l, _ := render.GatherVersion(m.version(), m.sections[0], m.cfg.Lectionary); l != "" {
+ label = l
+ }
+ }
+ return fmt.Sprintf("lectio %s [%s]", m.date, label)
+}
+
+// bodyLines returns the styled, wrapped lines the reading pane scrolls
+// through: a loading/error/empty notice, or each section's heading +
+// render.GatherVersion blocks for the active version.
+func (m Model) bodyLines(w int) []string {
+ switch {
+ case m.err != nil:
+ return []string{
+ errStyle.Render("błąd: " + m.err.Error()),
+ "",
+ citationStyle.Render("zmień datę (←/→) lub odśwież (r)"),
+ }
+ case m.loading:
+ return []string{citationStyle.Render("ładowanie…")}
+ case len(m.sections) == 0:
+ return []string{citationStyle.Render("brak czytań na " + m.date)}
+ }
+
+ ver := m.version()
+ var lines []string
+ for i, sec := range m.sections {
+ if i > 0 {
+ lines = append(lines, "")
+ }
+ lines = append(lines, headingStyle.Render(sec.Heading))
+ if sec.Subtitle != "" {
+ lines = append(lines, citationStyle.Render(sec.Subtitle))
+ }
+ lines = append(lines, "")
+
+ _, blocks := render.GatherVersion(ver, sec, m.cfg.Lectionary)
+ isPsalm := sec.PartID == "psalm"
+ for bi, b := range blocks {
+ refrain := isPsalm && bi == 0
+ lines = append(lines, styleBlock(b, refrain, w)...)
+ lines = append(lines, "")
+ }
+ }
+ return lines
+}
+
+// verseNumRe matches a bible verse block's "chapter:verse " prefix, as
+// produced by render.GatherVersion (e.g. "3:16 Tak bowiem...").
+var verseNumRe = regexp.MustCompile(`^(\d+:\d+) (.*)$`)
+
+// styleBlock wraps and colors one render.GatherVersion block: a verse
+// block gets its "chapter:verse" prefix in the muted verse-number style and
+// its text in the default verse style; a psalm's first (refrain) block
+// renders italic; everything else renders in the default verse style.
+func styleBlock(b string, refrain bool, w int) []string {
+ if g := verseNumRe.FindStringSubmatch(b); g != nil {
+ num, text := g[1], g[2]
+ wrapped := verseTextStyle.Width(w).Render(text)
+ wlines := strings.Split(wrapped, "\n")
+ wlines[0] = verseNumStyle.Render(num+" ") + wlines[0]
+ return wlines
+ }
+
+ style := verseTextStyle
+ if refrain {
+ style = refrainStyle
+ }
+ return strings.Split(style.Width(w).Render(b), "\n")
+}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
new file mode 100644
index 0000000..2aa7b84
--- /dev/null
+++ b/internal/tui/tui_test.go
@@ -0,0 +1,31 @@
+package tui
+
+import (
+ "testing"
+
+ "github.com/lukaszkasprzak/lectio/internal/config"
+)
+
+func TestVersionCycle(t *testing.T) {
+ m := New(config.Config{Versions: []string{"pl", "wuj", "vul"}, DefaultVersion: "pl"})
+ if m.version() != "pl" {
+ t.Fatalf("start = %q", m.version())
+ }
+ m = m.cycleVersion(+1)
+ if m.version() != "wuj" {
+ t.Errorf("after tab = %q", m.version())
+ }
+ m = m.cycleVersion(-1)
+ if m.version() != "pl" {
+ t.Errorf("after shift-tab = %q", m.version())
+ }
+}
+
+func TestOfflineDropsPL(t *testing.T) {
+ m := New(config.Config{Versions: []string{"pl", "wuj", "vul"}, DefaultVersion: "pl", Offline: true})
+ for _, v := range m.versions {
+ if v == "pl" {
+ t.Error("offline model kept pl")
+ }
+ }
+}