diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-23 14:03:04 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-23 14:03:04 +0200 |
| commit | 2e91d69ffb5989a167821979d4ed8d71a0f6ecbe (patch) | |
| tree | 52bfee726eb3075d384f0256682bf4321cb65585 /internal/tui/tui.go | |
| parent | 07bf5808039283ef7375b4af7703419f26b08b82 (diff) | |
| download | lectio-2e91d69ffb5989a167821979d4ed8d71a0f6ecbe.tar.gz lectio-2e91d69ffb5989a167821979d4ed8d71a0f6ecbe.zip | |
tui: colored reader + version switch
Diffstat (limited to 'internal/tui/tui.go')
| -rw-r--r-- | internal/tui/tui.go | 348 |
1 files changed, 348 insertions, 0 deletions
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") +} |
