all repos — bibel @ 24fb7ab0d94eef6e9a9dab5d2553b861096d5889

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	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}