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 | |
| parent | 07bf5808039283ef7375b4af7703419f26b08b82 (diff) | |
| download | lectio-2e91d69ffb5989a167821979d4ed8d71a0f6ecbe.tar.gz lectio-2e91d69ffb5989a167821979d4ed8d71a0f6ecbe.zip | |
tui: colored reader + version switch
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/styles.go | 44 | ||||
| -rw-r--r-- | internal/tui/tui.go | 348 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 31 |
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") + } + } +} |
