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