// 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/i18n" "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 } // 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(i18n.Get(m.cfg.UILanguage).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, m.cfg.UILanguage); 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 { ui := i18n.Get(m.cfg.UILanguage) switch { case m.err != nil: return []string{ errStyle.Render(ui.ErrorPrefix + m.err.Error()), "", citationStyle.Render(ui.ErrorHint), } case m.loading: return []string{citationStyle.Render(ui.Loading)} case len(m.sections) == 0: return []string{citationStyle.Render(ui.NoReadingsFor + m.date)} } ver := m.version() var lines []string for i, sec := range m.sections { if i > 0 { lines = append(lines, "") } heading := render.LocalizeHeading(sec.Heading, sec.PartID, m.cfg.UILanguage) lines = append(lines, headingStyle.Render(heading)) if sec.Subtitle != "" { lines = append(lines, citationStyle.Render(sec.Subtitle)) } lines = append(lines, "") _, blocks := render.GatherVersion(ver, sec, m.cfg.Lectionary, m.cfg.UILanguage) 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") }