all repos — bibel @ 63474ea3c260b3efb9d8af56effc2a24b2a3568f

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

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}