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 plainMode bool
19 styles *Styles
20 formatter *bible.Formatter
21}
22
23// Styles contains the lipgloss styles for the TUI
24type Styles struct {
25 box lipgloss.Style
26 header lipgloss.Style
27 content lipgloss.Style
28 quitMessage lipgloss.Style
29}
30
31// NewModel creates a new TUI model with the given Bible data and bookmark
32func NewModel(bibleData *bible.Bible, bookmark *bible.Bookmark, plainMode bool) Model {
33 m := Model{
34 bibleData: bibleData,
35 bookmark: bookmark,
36 plainMode: plainMode,
37 styles: createStyles(),
38 formatter: &bible.Formatter{},
39 }
40 return m
41}
42
43// createStyles creates the lipgloss styles, adapting to terminal background color
44func createStyles() *Styles {
45 // Detect if terminal has dark background
46 hasDarkBG := lipgloss.HasDarkBackground()
47
48 // Helper function for light/dark colors
49 lightDark := func(light, dark lipgloss.TerminalColor) lipgloss.TerminalColor {
50 if hasDarkBG {
51 return dark
52 }
53 return light
54 }
55
56 // Colors that match the existing formatter colors (green/yellow)
57 // but adapted for light/dark backgrounds
58 green := lightDark(lipgloss.Color("#00AA00"), lipgloss.Color("#00FF00"))
59 borderColor := lightDark(lipgloss.Color("#666666"), lipgloss.Color("#999999"))
60 textColor := lightDark(lipgloss.Color("#000000"), lipgloss.Color("#FFFFFF"))
61 quitColor := lightDark(lipgloss.Color("#555555"), lipgloss.Color("#AAAAAA"))
62
63 return &Styles{
64 box: lipgloss.NewStyle().
65 Border(lipgloss.RoundedBorder()).
66 BorderForeground(borderColor).
67 Padding(1, 2).
68 Margin(1, 0),
69
70 header: lipgloss.NewStyle().
71 Foreground(green).
72 Bold(true).
73 MarginBottom(1),
74
75 content: lipgloss.NewStyle().
76 Foreground(textColor),
77
78 quitMessage: lipgloss.NewStyle().
79 Foreground(quitColor).
80 Italic(true).
81 MarginTop(1),
82 }
83}
84
85// Init initializes the model (required by bubbletea)
86func (m Model) Init() tea.Cmd {
87 // No initial command needed
88 return nil
89}
90
91// Update handles messages and updates the model
92func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
93 switch msg := msg.(type) {
94 case tea.KeyMsg:
95 switch msg.String() {
96 case "q", "Q", "ctrl+c", "esc":
97 m.quitting = true
98 return m, tea.Quit
99 }
100 case tea.WindowSizeMsg:
101 // Update window dimensions
102 m.width = msg.Width
103 m.height = msg.Height
104 }
105
106 return m, nil
107}
108
109// View renders the UI
110func (m Model) View() string {
111 if m.quitting {
112 return ""
113 }
114
115 // Get formatted content using the formatter
116 header := m.formatter.FormatHeader(m.bookmark)
117 content := m.formatter.ExtractAndFormat(m.bibleData, m.bookmark)
118
119 // In plain mode, just return the original formatted text (with ANSI colors)
120 if m.plainMode {
121 var output strings.Builder
122 output.WriteString(header)
123 output.WriteString("\n")
124 output.WriteString(content)
125 return output.String()
126 }
127
128 // For TUI mode, we need to parse the header to extract the book/chapter and verse range
129 // The header from formatter has ANSI codes, but we want to style with lipgloss
130 // Let's extract just the text without ANSI codes
131 headerText := stripANSI(header)
132
133 // Build the box content
134 var boxContent strings.Builder
135
136 // Add header styled with lipgloss
137 boxContent.WriteString(m.styles.header.Render(headerText))
138 boxContent.WriteString("\n\n")
139
140 // Add verse content with wrapping based on available width
141 // Calculate available width for content
142 // Box has: Padding(1, 2) = 2 left + 2 right = 4
143 // Border = 1 left + 1 right = 2
144 // Total horizontal frame = 6
145 // Use window width with some margin
146 availableWidth := max(
147 // Leave some terminal margin
148 m.width-10,
149 // Minimum reasonable width
150 30)
151 contentWidth := availableWidth - 6 // Account for box frame
152
153 // Apply width constraint for text wrapping
154 wrappedContent := m.styles.content.Width(contentWidth).Render(content)
155 boxContent.WriteString(wrappedContent)
156
157 // Create the box with max width constraint
158 box := m.styles.box.MaxWidth(availableWidth).Render(boxContent.String())
159
160 // Add quit message below the box
161 quitMsg := m.styles.quitMessage.Render("Press q to quit...")
162
163 // Combine box and quit message
164 return lipgloss.JoinVertical(lipgloss.Center, box, quitMsg)
165}
166
167// CreateTUIProgram creates and returns a bubbletea program for the Bible verse
168func CreateTUIProgram(bible *bible.Bible, bookmark *bible.Bookmark, plainMode bool) *tea.Program {
169 model := NewModel(bible, bookmark, plainMode)
170 return tea.NewProgram(model)
171}
172
173// stripANSI removes ANSI escape codes from a string
174func stripANSI(str string) string {
175 var result strings.Builder
176 inEscape := false
177
178 for i := 0; i < len(str); i++ {
179 if str[i] == '\033' && i+1 < len(str) && str[i+1] == '[' {
180 inEscape = true
181 i++ // Skip the '['
182 continue
183 }
184
185 if inEscape {
186 // Look for the end of the escape sequence (letter m)
187 if str[i] >= 'A' && str[i] <= 'Z' || str[i] >= 'a' && str[i] <= 'z' {
188 inEscape = false
189 }
190 continue
191 }
192
193 result.WriteByte(str[i])
194 }
195
196 return result.String()
197}