internal/tui/model.go (view raw)
1package tui
2
3import (
4 "fmt"
5 "strings"
6
7 tea "github.com/charmbracelet/bubbletea"
8 "github.com/charmbracelet/lipgloss"
9 bible "maxwelljensen/bibel/internal"
10)
11
12// Model represents the TUI application state
13type Model struct {
14 bibleData *bible.Bible
15 bookmark *bible.Bookmark
16 width int
17 height int
18 quitting bool
19 outputMode string
20 styles *Styles
21 formatter *bible.Formatter
22 config *bible.Config
23 easterProg *bible.EasterProgression
24}
25
26// Styles contains the lipgloss styles for the TUI
27type Styles struct {
28 box lipgloss.Style
29 header lipgloss.Style
30 content lipgloss.Style
31 numberStyle lipgloss.Style // For numbered verse output
32 quitMessage lipgloss.Style
33}
34
35// NewModel creates a new TUI model with the given Bible data and bookmark
36func NewModel(bibleData *bible.Bible, bookmark *bible.Bookmark, config *bible.Config) Model {
37 // Initialise Easter progression
38 easterProg := bible.NewEasterProgression(config.EasterType)
39
40 m := Model{
41 bibleData: bibleData,
42 bookmark: bookmark,
43 outputMode: config.OutputMode,
44 styles: createStyles(config),
45 formatter: bible.NewFormatterWithFullConfig(bibleData, config.Formatter.UseColours, config.Formatter.HeaderFormat, config.Formatter.Numbered, config.Formatter.Paragraphs),
46 config: config,
47 easterProg: easterProg,
48 }
49 return m
50}
51
52// createStyles creates the lipgloss styles based on configuration
53func createStyles(config *bible.Config) *Styles {
54 // Use ANSI colours (set by terminal emulator)
55 // Border colour - bright black (gray)
56 borderColour := lipgloss.Color("8") // ANSI bright black (gray)
57
58 // Header colour - green (matching formatter's green)
59 headerColour := lipgloss.Color("2") // ANSI green
60
61 // Text colour - default terminal text colour
62 textColour := lipgloss.Color("") // Default terminal colour
63
64 // Quit message colour - bright black (gray)
65 quitColour := lipgloss.Color("8") // ANSI bright black (gray)
66
67 // Create border style based on config
68 var border lipgloss.Border
69 switch config.TUI.BorderStyle {
70 case "double":
71 border = lipgloss.DoubleBorder()
72 case "single":
73 border = lipgloss.NormalBorder()
74 case "hidden":
75 border = lipgloss.HiddenBorder()
76 case "rounded":
77 fallthrough
78 default:
79 border = lipgloss.RoundedBorder()
80 }
81
82 return &Styles{
83 box: lipgloss.NewStyle().
84 Border(border).
85 BorderForeground(borderColour).
86 Padding(1, 2).
87 Margin(1, 0),
88
89 header: lipgloss.NewStyle().
90 Foreground(headerColour).
91 Bold(true).
92 MarginBottom(1),
93
94 content: lipgloss.NewStyle().
95 Foreground(textColour),
96
97 numberStyle: lipgloss.NewStyle().
98 Foreground(lipgloss.Color("8")). // ANSI bright black (gray)
99 Bold(false),
100
101 quitMessage: lipgloss.NewStyle().
102 Foreground(quitColour).
103 Italic(true).
104 MarginTop(1),
105 }
106}
107
108// Init initializes the model (required by bubbletea)
109func (m Model) Init() tea.Cmd {
110 // No initial command needed
111 return nil
112}
113
114// Update handles messages and updates the model
115func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
116 switch msg := msg.(type) {
117 case tea.KeyMsg:
118 switch msg.String() {
119 case "q", "Q", "ctrl+c", "esc":
120 m.quitting = true
121 return m, tea.Quit
122 }
123 case tea.WindowSizeMsg:
124 // Update window dimensions
125 m.width = msg.Width
126 m.height = msg.Height
127 }
128
129 return m, nil
130}
131
132// View renders the UI
133func (m Model) View() string {
134 if m.quitting {
135 return ""
136 }
137
138 // Get formatted content using the formatter
139 header := m.formatter.FormatHeader(m.bookmark)
140 content := m.formatter.ExtractAndFormat(m.bibleData, m.bookmark)
141
142 // For TUI mode, use special formatting
143 verses := m.bibleData.GetVerseRange(int(m.bookmark.Book), m.bookmark.Chapter,
144 m.bookmark.FirstVerse, m.bookmark.SecondVerse)
145
146 // In plain or formatted mode, just return the original formatted text
147 if m.outputMode == "plain" || m.outputMode == "formatted" {
148 var output strings.Builder
149 output.WriteString(header)
150 output.WriteString("\n")
151 output.WriteString(content)
152 return output.String()
153 }
154
155 // For TUI mode, we need to parse the header to extract the book/chapter and verse range
156 // The header from formatter has ANSI codes, but we want to style with lipgloss
157 // Let's extract just the text without ANSI codes
158 headerText := stripANSI(header)
159
160 // Build the box content
161 var boxContent strings.Builder
162
163 // Add header styled with lipgloss
164 boxContent.WriteString(m.styles.header.Render(headerText))
165 boxContent.WriteString("\n\n")
166
167 // Add verse content with wrapping based on available width
168 // Calculate available width for content
169 // Box has: Padding(1, 2) = 2 left + 2 right = 4
170 // Border = 1 left + 1 right = 2
171 // Total horizontal frame = 6
172 // Use window size with some margin
173 availableWidth := max(
174 // Leave some terminal margin
175 m.width-10,
176 // Minimum reasonable width
177 30)
178 contentWidth := availableWidth - 6 // Account for box frame
179
180 // Format verses with appropriate styling
181 var verseContent string
182 if m.config.Formatter.Numbered || m.config.Formatter.Paragraphs {
183 // Use TUI-aware formatting for numbered or paragraph mode
184 verseContent = m.formatter.FormatSnippetForTUI(verses,
185 m.styles.content,
186 m.styles.numberStyle,
187 contentWidth)
188 } else {
189 // Use regular formatting
190 wrappedContent := m.styles.content.Width(contentWidth).Render(content)
191 verseContent = wrappedContent
192 }
193 boxContent.WriteString(verseContent)
194
195 // Create the box with max width constraint
196 box := m.styles.box.MaxWidth(availableWidth).Render(boxContent.String())
197
198 // Create Easter progress bar
199 easterProgress := m.renderEasterProgressBar(availableWidth)
200
201 // Add quit message below the box if configured to show it
202 var fullOutput string
203 if m.config.TUI.ShowQuitMessage {
204 quitMsg := m.styles.quitMessage.Render("Press q to quit...")
205 fullOutput = lipgloss.JoinVertical(lipgloss.Center, box, easterProgress, quitMsg)
206 } else {
207 fullOutput = lipgloss.JoinVertical(lipgloss.Center, box, easterProgress)
208 }
209
210 return fullOutput
211}
212
213// renderEasterProgressBar renders a fancy progress bar showing time until Easter
214func (m Model) renderEasterProgressBar(width int) string {
215 if m.easterProg == nil {
216 return ""
217 }
218
219 // Calculate progress percentage
220 progress, err := m.easterProg.GetEasterProgressPercentage()
221 if err != nil {
222 // If we can't calculate Easter, show error message
223 errorStyle := lipgloss.NewStyle().
224 Foreground(lipgloss.Color("1")). // ANSI red
225 Italic(true)
226 return errorStyle.Width(width - 6).Render("Unable to calculate Easter date")
227 }
228
229 // Format time until Easter
230 timeStr, err := m.easterProg.FormatEasterProgress()
231 if err != nil {
232 timeStr = "Calculating..."
233 }
234
235 // Create progress bar
236 barWidth := max(
237 // Leave some padding
238 width-10,
239 // Minimum bar width
240 20)
241
242 // Calculate filled width
243 filledWidth := max(min(int(float64(barWidth)*progress), barWidth), 0)
244 emptyWidth := barWidth - filledWidth
245
246 // Define styles using ANSI colours
247 filledStyle := lipgloss.NewStyle().
248 Foreground(lipgloss.Color("15")). // Bright white
249 Background(lipgloss.Color("2")) // Green
250
251 emptyStyle := lipgloss.NewStyle().
252 Foreground(lipgloss.Color("8")). // Bright black (gray)
253 Background(lipgloss.Color("0")) // Black
254
255 // Create bar segments
256 filledBar := filledStyle.Render(strings.Repeat("█", filledWidth))
257 emptyBar := emptyStyle.Render(strings.Repeat("░", emptyWidth))
258 bar := filledBar + emptyBar
259
260 // Add percentage text
261 percentage := fmt.Sprintf("%.1f%%", progress*100)
262 percentageStyle := lipgloss.NewStyle().
263 Foreground(lipgloss.Color("8")). // ANSI bright black (gray)
264 Bold(true)
265
266 percentageText := percentageStyle.Render(percentage)
267
268 // Combine time text, bar, and percentage
269 timeStyle := lipgloss.NewStyle().
270 Foreground(lipgloss.Color("7")) // ANSI white (light gray)
271
272 timeText := timeStyle.Width(barWidth - len(percentage) - 2).Render(timeStr)
273
274 // Layout: time text on left, bar below, percentage on right of bar
275 topRow := lipgloss.JoinHorizontal(lipgloss.Left, timeText, " ", percentageText)
276 bottomRow := bar
277
278 // Container for the progress bar
279 containerStyle := lipgloss.NewStyle().
280 Padding(0, 1).
281 MarginTop(1)
282
283 return containerStyle.Render(lipgloss.JoinVertical(lipgloss.Left, topRow, bottomRow))
284}
285
286// CreateTUIProgram creates and returns a bubbletea program for the Bible verse
287func CreateTUIProgram(bible *bible.Bible, bookmark *bible.Bookmark, config *bible.Config) *tea.Program {
288 model := NewModel(bible, bookmark, config)
289 return tea.NewProgram(model)
290}
291
292// stripANSI removes ANSI escape codes from a string
293func stripANSI(str string) string {
294 var result strings.Builder
295 inEscape := false
296
297 for i := 0; i < len(str); i++ {
298 // Check for ESC [ sequence (ANSI escape)
299 if str[i] == 27 && i+1 < len(str) && str[i+1] == '[' {
300 inEscape = true
301 i++ // Skip the '['
302 continue
303 }
304
305 if inEscape {
306 // Look for the end of the escape sequence (letter m)
307 if str[i] >= 'A' && str[i] <= 'Z' || str[i] >= 'a' && str[i] <= 'z' {
308 inEscape = false
309 }
310 continue
311 }
312
313 result.WriteByte(str[i])
314 }
315
316 return result.String()
317}