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 // Detect if terminal has dark background
55 hasDarkBG := lipgloss.HasDarkBackground()
56
57 // Helper function for light/dark colors
58 lightDark := func(light, dark lipgloss.TerminalColor) lipgloss.TerminalColor {
59 if hasDarkBG {
60 return dark
61 }
62 return light
63 }
64
65 // Get colours from config, or use adaptive defaults
66 var borderColour, headerColour, textColour, quitColour lipgloss.TerminalColor
67
68 // Border colour
69 if config.TUI.BorderColour != "" {
70 borderColour = lipgloss.Color(config.TUI.BorderColour)
71 } else {
72 borderColour = lightDark(lipgloss.Color("#666666"), lipgloss.Color("#999999"))
73 }
74
75 // Header colour (matching formatter's green)
76 if config.TUI.HeaderColour != "" {
77 headerColour = lipgloss.Color(config.TUI.HeaderColour)
78 } else {
79 headerColour = lightDark(lipgloss.Color("#00AA00"), lipgloss.Color("#00FF00"))
80 }
81
82 // Text colour
83 if config.TUI.TextColour != "" {
84 textColour = lipgloss.Color(config.TUI.TextColour)
85 } else {
86 textColour = lightDark(lipgloss.Color("#000000"), lipgloss.Color("#FFFFFF"))
87 }
88
89 // Quit message colour
90 if config.TUI.QuitColour != "" {
91 quitColour = lipgloss.Color(config.TUI.QuitColour)
92 } else {
93 quitColour = lightDark(lipgloss.Color("#555555"), lipgloss.Color("#AAAAAA"))
94 }
95
96 // Create border style based on config
97 var border lipgloss.Border
98 switch config.TUI.BorderStyle {
99 case "double":
100 border = lipgloss.DoubleBorder()
101 case "single":
102 border = lipgloss.NormalBorder()
103 case "hidden":
104 border = lipgloss.HiddenBorder()
105 case "rounded":
106 fallthrough
107 default:
108 border = lipgloss.RoundedBorder()
109 }
110
111 return &Styles{
112 box: lipgloss.NewStyle().
113 Border(border).
114 BorderForeground(borderColour).
115 Padding(1, 2).
116 Margin(1, 0),
117
118 header: lipgloss.NewStyle().
119 Foreground(headerColour).
120 Bold(true).
121 MarginBottom(1),
122
123 content: lipgloss.NewStyle().
124 Foreground(textColour),
125
126 numberStyle: lipgloss.NewStyle().
127 Foreground(lightDark(lipgloss.Color("#444444"), lipgloss.Color("#AAAAAA"))).
128 Bold(false),
129
130 quitMessage: lipgloss.NewStyle().
131 Foreground(quitColour).
132 Italic(true).
133 MarginTop(1),
134 }
135}
136
137// Init initializes the model (required by bubbletea)
138func (m Model) Init() tea.Cmd {
139 // No initial command needed
140 return nil
141}
142
143// Update handles messages and updates the model
144func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145 switch msg := msg.(type) {
146 case tea.KeyMsg:
147 switch msg.String() {
148 case "q", "Q", "ctrl+c", "esc":
149 m.quitting = true
150 return m, tea.Quit
151 }
152 case tea.WindowSizeMsg:
153 // Update window dimensions
154 m.width = msg.Width
155 m.height = msg.Height
156 }
157
158 return m, nil
159}
160
161// View renders the UI
162func (m Model) View() string {
163 if m.quitting {
164 return ""
165 }
166
167 // Get formatted content using the formatter
168 header := m.formatter.FormatHeader(m.bookmark)
169 content := m.formatter.ExtractAndFormat(m.bibleData, m.bookmark)
170
171 // For TUI mode, use special formatting
172 verses := m.bibleData.GetVerseRange(int(m.bookmark.Book), m.bookmark.Chapter,
173 m.bookmark.FirstVerse, m.bookmark.SecondVerse)
174
175 // In plain or formatted mode, just return the original formatted text
176 if m.outputMode == "plain" || m.outputMode == "formatted" {
177 var output strings.Builder
178 output.WriteString(header)
179 output.WriteString("\n")
180 output.WriteString(content)
181 return output.String()
182 }
183
184 // For TUI mode, we need to parse the header to extract the book/chapter and verse range
185 // The header from formatter has ANSI codes, but we want to style with lipgloss
186 // Let's extract just the text without ANSI codes
187 headerText := stripANSI(header)
188
189 // Build the box content
190 var boxContent strings.Builder
191
192 // Add header styled with lipgloss
193 boxContent.WriteString(m.styles.header.Render(headerText))
194 boxContent.WriteString("\n\n")
195
196 // Add verse content with wrapping based on available width
197 // Calculate available width for content
198 // Box has: Padding(1, 2) = 2 left + 2 right = 4
199 // Border = 1 left + 1 right = 2
200 // Total horizontal frame = 6
201 // Use window size with some margin
202 availableWidth := max(
203 // Leave some terminal margin
204 m.width-10,
205 // Minimum reasonable width
206 30)
207 contentWidth := availableWidth - 6 // Account for box frame
208
209 // Format verses with appropriate styling
210 var verseContent string
211 if m.config.Formatter.Numbered || m.config.Formatter.Paragraphs {
212 // Use TUI-aware formatting for numbered or paragraph mode
213 verseContent = m.formatter.FormatSnippetForTUI(verses,
214 m.styles.content,
215 m.styles.numberStyle,
216 contentWidth)
217 } else {
218 // Use regular formatting
219 wrappedContent := m.styles.content.Width(contentWidth).Render(content)
220 verseContent = wrappedContent
221 }
222 boxContent.WriteString(verseContent)
223
224 // Create the box with max width constraint
225 box := m.styles.box.MaxWidth(availableWidth).Render(boxContent.String())
226
227 // Create Easter progress bar
228 easterProgress := m.renderEasterProgressBar(availableWidth)
229
230 // Add quit message below the box if configured to show it
231 var fullOutput string
232 if m.config.TUI.ShowQuitMessage {
233 quitMsg := m.styles.quitMessage.Render("Press q to quit...")
234 fullOutput = lipgloss.JoinVertical(lipgloss.Center, box, easterProgress, quitMsg)
235 } else {
236 fullOutput = lipgloss.JoinVertical(lipgloss.Center, box, easterProgress)
237 }
238
239 return fullOutput
240}
241
242// renderEasterProgressBar renders a fancy progress bar showing time until Easter
243func (m Model) renderEasterProgressBar(width int) string {
244 if m.easterProg == nil {
245 return ""
246 }
247
248 // Calculate progress percentage
249 progress, err := m.easterProg.GetEasterProgressPercentage()
250 if err != nil {
251 // If we can't calculate Easter, show error message
252 errorStyle := lipgloss.NewStyle().
253 Foreground(lipgloss.Color("#FF4444")).
254 Italic(true)
255 return errorStyle.Width(width - 6).Render("Unable to calculate Easter date")
256 }
257
258 // Format time until Easter
259 timeStr, err := m.easterProg.FormatEasterProgress()
260 if err != nil {
261 timeStr = "Calculating..."
262 }
263
264 // Create progress bar
265 barWidth := max(
266 // Leave some padding
267 width-10,
268 // Minimum bar width
269 20)
270
271 // Calculate filled width
272 filledWidth := max(min(int(float64(barWidth)*progress), barWidth), 0)
273 emptyWidth := barWidth - filledWidth
274
275 // Define styles
276 filledStyle := lipgloss.NewStyle().
277 Foreground(lipgloss.Color("#FFFFFF")).
278 Background(lipgloss.Color("#00AA00"))
279
280 emptyStyle := lipgloss.NewStyle().
281 Foreground(lipgloss.Color("#888888")).
282 Background(lipgloss.Color("#222222"))
283
284 // Create bar segments
285 filledBar := filledStyle.Render(strings.Repeat("█", filledWidth))
286 emptyBar := emptyStyle.Render(strings.Repeat("░", emptyWidth))
287 bar := filledBar + emptyBar
288
289 // Add percentage text
290 percentage := fmt.Sprintf("%.1f%%", progress*100)
291 percentageStyle := lipgloss.NewStyle().
292 Foreground(lipgloss.Color("#AAAAAA")).
293 Bold(true)
294
295 percentageText := percentageStyle.Render(percentage)
296
297 // Combine time text, bar, and percentage
298 timeStyle := lipgloss.NewStyle().
299 Foreground(lipgloss.Color("#CCCCCC"))
300
301 timeText := timeStyle.Width(barWidth - len(percentage) - 2).Render(timeStr)
302
303 // Layout: time text on left, bar below, percentage on right of bar
304 topRow := lipgloss.JoinHorizontal(lipgloss.Left, timeText, " ", percentageText)
305 bottomRow := bar
306
307 // Container for the progress bar
308 containerStyle := lipgloss.NewStyle().
309 Padding(0, 1).
310 MarginTop(1)
311
312 return containerStyle.Render(lipgloss.JoinVertical(lipgloss.Left, topRow, bottomRow))
313}
314
315// CreateTUIProgram creates and returns a bubbletea program for the Bible verse
316func CreateTUIProgram(bible *bible.Bible, bookmark *bible.Bookmark, config *bible.Config) *tea.Program {
317 model := NewModel(bible, bookmark, config)
318 return tea.NewProgram(model)
319}
320
321// stripANSI removes ANSI escape codes from a string
322func stripANSI(str string) string {
323 var result strings.Builder
324 inEscape := false
325
326 for i := 0; i < len(str); i++ {
327 // Check for ESC [ sequence (ANSI escape)
328 if str[i] == 27 && i+1 < len(str) && str[i+1] == '[' {
329 inEscape = true
330 i++ // Skip the '['
331 continue
332 }
333
334 if inEscape {
335 // Look for the end of the escape sequence (letter m)
336 if str[i] >= 'A' && str[i] <= 'Z' || str[i] >= 'a' && str[i] <= 'z' {
337 inEscape = false
338 }
339 continue
340 }
341
342 result.WriteByte(str[i])
343 }
344
345 return result.String()
346}