all repos — bibel @ e61fc65a61631125f782ff5d302389fe7c4a9228

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