// 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 dayInfo liturgy.DayInfo scroll int width int height int loading bool err error jumping bool // date-entry ("d") mode is active jumpBuf string // the date being typed in jump mode } // readingsMsg carries a successful fetch's sections and DayInfo back to // Update. type readingsMsg struct { sections []liturgy.Section dayInfo liturgy.DayInfo } // errMsg carries a failed fetch's error back to Update. type errMsg struct { err error } // New builds the initial model: cfg.Offline drops "bt" from the version // list (render.OfflineVersions). startVersion selects the active version // (falling back to cfg.DefaultVersion when ""), still resolved through // EffectiveVersions/indexOf so an unavailable version falls back to index 0. // startDate selects the starting date (falling back to today when ""). The // first fetch is issued by Init, not here. func New(cfg config.Config, startDate, startVersion string) Model { versions := render.EffectiveVersions(append([]string(nil), cfg.Versions...), cfg.Lectionary, cfg.Offline) if startVersion == "" { startVersion = cfg.DefaultVersion } idx := indexOf(versions, startVersion) if idx < 0 { idx = 0 } idx = clampIndex(idx, len(versions)) date := startDate if date == "" { date = time.Now().Format("2006-01-02") } return Model{ cfg: cfg, versions: versions, verIdx: idx, date: date, 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. All follows cfg.All (config's // gospel-only vs every-part choice); Offline follows cfg.Offline; refresh // bypasses the cache (the "r" key), matching the CLI's --refresh. func (m Model) fetchCmd(refresh bool) tea.Cmd { cfg := m.cfg date := m.date return func() tea.Msg { secs, info, err := readings.Load(cfg, readings.Options{ Date: date, Refresh: refresh, Offline: cfg.Offline, All: cfg.All, }) if err != nil { return errMsg{err} } return readingsMsg{secs, info} } } // Init issues the first load. func (m Model) Init() tea.Cmd { return m.fetchCmd(false) } // 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.dayInfo = msg.dayInfo m.scroll = 0 return m, nil case errMsg: m.loading = false m.err = msg.err return m, nil case tea.KeyMsg: if m.jumping { return m.updateJump(msg) } 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(false) case "right": m.date = shiftDate(m.date, +1) m.loading = true m.err = nil return m, m.fetchCmd(false) case "r": m.loading = true m.err = nil return m, m.fetchCmd(true) case "d": m.jumping = true m.jumpBuf = "" return m, nil case "j", "down": m.scroll = m.scrollTo(m.scroll + 1) return m, nil case "k", "up": m.scroll = m.scrollTo(m.scroll - 1) return m, nil case " ": m.scroll = m.scrollTo(m.scroll + m.pageSize()) return m, nil case "b": m.scroll = m.scrollTo(m.scroll - m.pageSize()) return m, nil case "g": m.scroll = 0 return m, nil case "G": m.scroll = m.scrollTo(1 << 30) return m, nil } } return m, nil } // updateJump handles keys while the "d" date-jump prompt is active: digits and // "-" build the buffer, Enter parses YYYY-MM-DD and navigates (invalid input // just cancels), Esc cancels, Backspace edits, Ctrl+C quits. Any other key is // ignored so the prompt stays modal. func (m Model) updateJump(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyCtrlC: return m, tea.Quit case tea.KeyEsc: m.jumping = false m.jumpBuf = "" return m, nil case tea.KeyEnter: buf := m.jumpBuf m.jumping = false m.jumpBuf = "" if t, err := time.Parse("2006-01-02", buf); err == nil { m.date = t.Format("2006-01-02") m.loading = true m.err = nil return m, m.fetchCmd(false) } return m, nil case tea.KeyBackspace: if r := []rune(m.jumpBuf); len(r) > 0 { m.jumpBuf = string(r[:len(r)-1]) } return m, nil case tea.KeyRunes: for _, c := range msg.Runes { if (c >= '0' && c <= '9') || c == '-' { m.jumpBuf += string(c) } } return m, nil } return m, nil } // headerLines is how many lines the top header block renders as: 1 (just // the "lectio DATE [version]" bar) or 2 when a day-info line (the // celebration name, optionally with its temporal Season) is shown beneath // it -- see dayInfoLine. func (m Model) headerLines() int { if m.dayInfo.Name == "" { return 1 } return 2 } // pageSize is how many reading lines fit between the header block and // footer bar for the model's current terminal height; it falls back to a // sane default before the first tea.WindowSizeMsg arrives (height == 0). func (m Model) pageSize() int { chrome := m.headerLines() + 3 // header block + blank + footer + margin if m.height <= chrome { return 10 } return m.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 } // innerWidth is the reading pane's wrap width: the terminal width minus a // small margin, with an 80-column fallback before the first WindowSizeMsg. func (m Model) innerWidth() int { w := m.width if w <= 0 { w = 80 } iw := w - 2 if iw < 20 { iw = 20 } return iw } // scrollTo clamps a target scroll offset to the reading's real length, so the // view can't scroll past the end -- keeping m.scroll bounded in Update, not // merely clamped for display in View. func (m Model) scrollTo(s int) int { return clampScroll(s, len(m.bodyLines(m.innerWidth())), m.pageSize()) } // View renders the header (date + active version label, plus a day-info // line when the source carries one), the scrolling reading, and the // footer keybar. func (m Model) View() string { w := m.width if w <= 0 { w = 80 } innerW := m.innerWidth() header := headerStyle.Width(w).Render(m.headerText()) if line := m.dayInfoLine(); line != "" { header += "\n" + line } footerText := i18n.Get(m.cfg.UILanguage).FooterKeys if m.jumping { footerText = i18n.Get(m.cfg.UILanguage).JumpPrompt + ": " + m.jumpBuf } footer := footerStyle.Width(w).Render(footerText) bodyLines := m.bodyLines(innerW) visible := m.pageSize() 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) } // dayInfoLine renders the day's celebration name (heading/accent style, // source-language, never translated -- like the readings/citations // themselves; see liturgy.DayInfo) with its temporal Season, if any, // appended in the dim citation style, as the header block's second line. // Empty when the active source yielded no DayInfo (m.dayInfo.Name == ""), // which is never an error -- the header is simply omitted. func (m Model) dayInfoLine() string { if m.dayInfo.Name == "" { return "" } line := headingStyle.Render(m.dayInfo.Name) if m.dayInfo.Season != "" { line += " " + citationStyle.Render(m.dayInfo.Season) } return line } // 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.HeadingWithRef(sec, m.cfg.UILanguage) lines = append(lines, headingStyle.Render(heading)) lines = append(lines, "") _, blocks := render.GatherVersion(ver, sec, m.cfg.Lectionary, m.cfg.UILanguage) numW := maxNumWidth(blocks) // The refrain-italic only applies to the bt responsorial-psalm block // (its first, deduped paragraph); bible versions have no refrain block. isPsalm := sec.PartID == "psalm" && ver == "bt" for bi, b := range blocks { refrain := isPsalm && bi == 0 lines = append(lines, styleBlock(b, refrain, w, numW)...) 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, numW int) []string { if w < 1 { w = 1 } if g := verseNumRe.FindStringSubmatch(b); g != nil { num, text := g[1], g[2] col := numW + 2 // verse-number column: widest "chapter:verse" + 2 spaces indent := strings.Repeat(" ", col) tw := w - col if tw < 1 { tw = 1 } lines := strings.Split(render.Wrap(text, tw), "\n") out := make([]string, 0, len(lines)) for i, ln := range lines { if i == 0 { pad := strings.Repeat(" ", col-len([]rune(num))) out = append(out, verseNumStyle.Render(num)+pad+verseTextStyle.Render(ln)) } else { out = append(out, indent+verseTextStyle.Render(ln)) } } return out } style := verseTextStyle if refrain { style = refrainStyle } lines := strings.Split(render.Wrap(b, w), "\n") out := make([]string, 0, len(lines)) for _, ln := range lines { out = append(out, style.Render(ln)) } return out } // maxNumWidth returns the widest "chapter:verse" prefix rune-width among the // verse blocks, so styleBlock can align every verse's text to one column. func maxNumWidth(blocks []string) int { m := 0 for _, b := range blocks { if g := verseNumRe.FindStringSubmatch(b); g != nil { if n := len([]rune(g[1])); n > m { m = n } } } return m }