aboutsummaryrefslogtreecommitdiff
path: root/internal/tui
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 /internal/tui
parent07bf5808039283ef7375b4af7703419f26b08b82 (diff)
downloadlectio-2e91d69ffb5989a167821979d4ed8d71a0f6ecbe.tar.gz
lectio-2e91d69ffb5989a167821979d4ed8d71a0f6ecbe.zip
tui: colored reader + version switch
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/styles.go44
-rw-r--r--internal/tui/tui.go348
-rw-r--r--internal/tui/tui_test.go31
3 files changed, 423 insertions, 0 deletions
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")
+ }
+ }
+}