all repos — bibel @ 4d989dbd3374640c4671c3a77cc588c4fb078be0

Unnamed repository; edit this file 'description' to name the repository.

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}