internal/tui/model.go (view raw)
1package tui
2
3import (
4 "strings"
5
6 tea "github.com/charmbracelet/bubbletea"
7 "github.com/charmbracelet/lipgloss"
8 bible "maxwelljensen/bibel/internal"
9)
10
11// Model represents the TUI application state
12type Model struct {
13 bibleData *bible.Bible
14 bookmark *bible.Bookmark
15 width int
16 height int
17 quitting bool
18 outputMode string
19 styles *Styles
20 formatter *bible.Formatter
21 config *bible.Config
22}
23
24// Styles contains the lipgloss styles for the TUI
25type Styles struct {
26 box lipgloss.Style
27 header lipgloss.Style
28 content lipgloss.Style
29 numberStyle lipgloss.Style // For numbered verse output
30 quitMessage lipgloss.Style
31}
32
33// NewModel creates a new TUI model with the given Bible data and bookmark
34func NewModel(bibleData *bible.Bible, bookmark *bible.Bookmark, config *bible.Config) Model {
35 m := Model{
36 bibleData: bibleData,
37 bookmark: bookmark,
38 outputMode: config.OutputMode,
39 styles: createStyles(config),
40 formatter: bible.NewFormatterWithFullConfig(bibleData, config.Formatter.UseColours, config.Formatter.HeaderFormat, config.Formatter.Numbered, config.Formatter.Paragraphs),
41 config: config,
42 }
43 return m
44}
45
46// createStyles creates the lipgloss styles based on configuration
47func createStyles(config *bible.Config) *Styles {
48 // Detect if terminal has dark background
49 hasDarkBG := lipgloss.HasDarkBackground()
50
51 // Helper function for light/dark colors
52 lightDark := func(light, dark lipgloss.TerminalColor) lipgloss.TerminalColor {
53 if hasDarkBG {
54 return dark
55 }
56 return light
57 }
58
59 // Get colours from config, or use adaptive defaults
60 var borderColour, headerColour, textColour, quitColour lipgloss.TerminalColor
61
62 // Border colour
63 if config.TUI.BorderColour != "" {
64 borderColour = lipgloss.Color(config.TUI.BorderColour)
65 } else {
66 borderColour = lightDark(lipgloss.Color("#666666"), lipgloss.Color("#999999"))
67 }
68
69 // Header colour (matching formatter's green)
70 if config.TUI.HeaderColour != "" {
71 headerColour = lipgloss.Color(config.TUI.HeaderColour)
72 } else {
73 headerColour = lightDark(lipgloss.Color("#00AA00"), lipgloss.Color("#00FF00"))
74 }
75
76 // Text colour
77 if config.TUI.TextColour != "" {
78 textColour = lipgloss.Color(config.TUI.TextColour)
79 } else {
80 textColour = lightDark(lipgloss.Color("#000000"), lipgloss.Color("#FFFFFF"))
81 }
82
83 // Quit message colour
84 if config.TUI.QuitColour != "" {
85 quitColour = lipgloss.Color(config.TUI.QuitColour)
86 } else {
87 quitColour = lightDark(lipgloss.Color("#555555"), lipgloss.Color("#AAAAAA"))
88 }
89
90 // Create border style based on config
91 var border lipgloss.Border
92 switch config.TUI.BorderStyle {
93 case "double":
94 border = lipgloss.DoubleBorder()
95 case "single":
96 border = lipgloss.NormalBorder()
97 case "hidden":
98 border = lipgloss.HiddenBorder()
99 case "rounded":
100 fallthrough
101 default:
102 border = lipgloss.RoundedBorder()
103 }
104
105 return &Styles{
106 box: lipgloss.NewStyle().
107 Border(border).
108 BorderForeground(borderColour).
109 Padding(1, 2).
110 Margin(1, 0),
111
112 header: lipgloss.NewStyle().
113 Foreground(headerColour).
114 Bold(true).
115 MarginBottom(1),
116
117 content: lipgloss.NewStyle().
118 Foreground(textColour),
119
120 numberStyle: lipgloss.NewStyle().
121 Foreground(lightDark(lipgloss.Color("#444444"), lipgloss.Color("#AAAAAA"))).
122 Bold(false),
123
124 quitMessage: lipgloss.NewStyle().
125 Foreground(quitColour).
126 Italic(true).
127 MarginTop(1),
128 }
129}
130
131// Init initializes the model (required by bubbletea)
132func (m Model) Init() tea.Cmd {
133 // No initial command needed
134 return nil
135}
136
137// Update handles messages and updates the model
138func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
139 switch msg := msg.(type) {
140 case tea.KeyMsg:
141 switch msg.String() {
142 case "q", "Q", "ctrl+c", "esc":
143 m.quitting = true
144 return m, tea.Quit
145 }
146 case tea.WindowSizeMsg:
147 // Update window dimensions
148 m.width = msg.Width
149 m.height = msg.Height
150 }
151
152 return m, nil
153}
154
155// View renders the UI
156func (m Model) View() string {
157 if m.quitting {
158 return ""
159 }
160
161 // Get formatted content using the formatter
162 header := m.formatter.FormatHeader(m.bookmark)
163 content := m.formatter.ExtractAndFormat(m.bibleData, m.bookmark)
164
165 // For TUI mode, use special formatting
166 verses := m.bibleData.GetVerseRange(int(m.bookmark.Book), m.bookmark.Chapter,
167 m.bookmark.FirstVerse, m.bookmark.SecondVerse)
168
169 // In plain or formatted mode, just return the original formatted text
170 if m.outputMode == "plain" || m.outputMode == "formatted" {
171 var output strings.Builder
172 output.WriteString(header)
173 output.WriteString("\n")
174 output.WriteString(content)
175 return output.String()
176 }
177
178 // For TUI mode, we need to parse the header to extract the book/chapter and verse range
179 // The header from formatter has ANSI codes, but we want to style with lipgloss
180 // Let's extract just the text without ANSI codes
181 headerText := stripANSI(header)
182
183 // Build the box content
184 var boxContent strings.Builder
185
186 // Add header styled with lipgloss
187 boxContent.WriteString(m.styles.header.Render(headerText))
188 boxContent.WriteString("\n\n")
189
190 // Add verse content with wrapping based on available width
191 // Calculate available width for content
192 // Box has: Padding(1, 2) = 2 left + 2 right = 4
193 // Border = 1 left + 1 right = 2
194 // Total horizontal frame = 6
195 // Use window width with some margin
196 availableWidth := max(
197 // Leave some terminal margin
198 m.width-10,
199 // Minimum reasonable width
200 30)
201 contentWidth := availableWidth - 6 // Account for box frame
202
203 // Format verses with appropriate styling
204 var verseContent string
205 if m.config.Formatter.Numbered || m.config.Formatter.Paragraphs {
206 // Use TUI-aware formatting for numbered or paragraph mode
207 verseContent = m.formatter.FormatSnippetForTUI(verses,
208 m.styles.content,
209 m.styles.numberStyle,
210 contentWidth)
211 } else {
212 // Use regular formatting
213 wrappedContent := m.styles.content.Width(contentWidth).Render(content)
214 verseContent = wrappedContent
215 }
216 boxContent.WriteString(verseContent)
217
218 // Create the box with max width constraint
219 box := m.styles.box.MaxWidth(availableWidth).Render(boxContent.String())
220
221 // Add quit message below the box if configured to show it
222 var fullOutput string
223 if m.config.TUI.ShowQuitMessage {
224 quitMsg := m.styles.quitMessage.Render("Press q to quit...")
225 fullOutput = lipgloss.JoinVertical(lipgloss.Center, box, quitMsg)
226 } else {
227 fullOutput = lipgloss.JoinVertical(lipgloss.Center, box)
228 }
229
230 return fullOutput
231}
232
233// CreateTUIProgram creates and returns a bubbletea program for the Bible verse
234func CreateTUIProgram(bible *bible.Bible, bookmark *bible.Bookmark, config *bible.Config) *tea.Program {
235 model := NewModel(bible, bookmark, config)
236 return tea.NewProgram(model)
237}
238
239// stripANSI removes ANSI escape codes from a string
240func stripANSI(str string) string {
241 var result strings.Builder
242 inEscape := false
243
244 for i := 0; i < len(str); i++ {
245 if str[i] == '\033' && i+1 < len(str) && str[i+1] == '[' {
246 inEscape = true
247 i++ // Skip the '['
248 continue
249 }
250
251 if inEscape {
252 // Look for the end of the escape sequence (letter m)
253 if str[i] >= 'A' && str[i] <= 'Z' || str[i] >= 'a' && str[i] <= 'z' {
254 inEscape = false
255 }
256 continue
257 }
258
259 result.WriteByte(str[i])
260 }
261
262 return result.String()
263}
264
265// max returns the larger of two integers
266func max(a, b int) int {
267 if a > b {
268 return a
269 }
270 return b
271}