GemReader/internal/tui/tui.go

245 lines
5.6 KiB
Go
Raw Permalink Normal View History

2025-09-30 09:19:32 -05:00
package tui
import (
"fmt"
"strings"
"gemreader/internal/config"
"github.com/charmbracelet/bubbles/viewport"
markdown "github.com/MichaelMure/go-term-markdown"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type pane int
const (
tocPane pane = iota
contentPane
)
type model struct {
content string
rawLines []string
toc []tocEntry
selectedTocIndex int
tocViewport viewport.Model
contentViewport viewport.Model
activePane pane
helpVisible bool
config config.Config
windowWidth int
windowHeight int
}
type tocEntry struct {
level int
text string
line int
}
func NewModel(content string, cfg config.Config) model {
rawLines := strings.Split(content, "\n")
toc := generateTOC(rawLines)
// Create viewports for the TOC and content
tocVP := viewport.New(0, 0)
contentVP := viewport.New(0, 0)
m := model{
content: content,
rawLines: rawLines,
toc: toc,
tocViewport: tocVP,
contentViewport: contentVP,
activePane: contentPane,
helpVisible: true,
config: cfg,
}
return m
}
func generateTOC(lines []string) []tocEntry {
var toc []tocEntry
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") {
level := 0
for _, char := range trimmed {
if char == '#' {
level++
} else {
break
}
}
headerText := strings.TrimSpace(trimmed[level:])
if headerText != "" {
toc = append(toc, tocEntry{
level: level,
text: headerText,
line: i,
})
}
}
}
return toc
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
cmd tea.Cmd
cmds []tea.Cmd
)
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.windowWidth = msg.Width
m.windowHeight = msg.Height
// Calculate pane widths
tocWidth := int(float64(m.windowWidth) * 0.3)
contentWidth := m.windowWidth - tocWidth
// Set viewport dimensions
m.tocViewport.Width = tocWidth - 2 // Adjust for border
m.tocViewport.Height = m.windowHeight - 2
m.contentViewport.Width = contentWidth - 2
m.contentViewport.Height = m.windowHeight - 2
// Set content for viewports
m.tocViewport.SetContent(m.renderTOC())
renderedContent := string(markdown.Render(m.content, contentWidth-4, 6))
m.contentViewport.SetContent(renderedContent)
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "h", "?":
m.helpVisible = !m.helpVisible
case "tab":
if m.activePane == tocPane {
m.activePane = contentPane
} else {
m.activePane = tocPane
}
case "enter":
if m.activePane == tocPane && len(m.toc) > 0 {
// Jump to selected TOC entry
tocEntry := m.toc[m.selectedTocIndex]
// Find the line in the rendered content
renderedLines := strings.Split(m.contentViewport.View(), "\n")
for i, line := range renderedLines {
if strings.Contains(strings.ToLower(line), strings.ToLower(tocEntry.text)) {
m.contentViewport.SetYOffset(i)
break
}
}
m.activePane = contentPane // Switch to content view after jumping
}
case "up", "k":
if m.activePane == tocPane {
if m.selectedTocIndex > 0 {
m.selectedTocIndex--
m.tocViewport.SetContent(m.renderTOC())
}
} else {
m.contentViewport.LineUp(1)
}
case "down", "j":
if m.activePane == tocPane {
if m.selectedTocIndex < len(m.toc)-1 {
m.selectedTocIndex++
m.tocViewport.SetContent(m.renderTOC())
}
} else {
m.contentViewport.LineDown(1)
}
case "pgup", "ctrl+u":
if m.activePane == contentPane {
m.contentViewport.ViewUp()
}
case "pgdn", "ctrl+d":
if m.activePane == contentPane {
m.contentViewport.ViewDown()
}
case "g", "home":
if m.activePane == contentPane {
m.contentViewport.GotoTop()
}
case "G", "end":
if m.activePane == contentPane {
m.contentViewport.GotoBottom()
}
}
}
// Update viewports
m.tocViewport, cmd = m.tocViewport.Update(msg)
cmds = append(cmds, cmd)
m.contentViewport, cmd = m.contentViewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m model) View() string {
// Styles
tocStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")) // Default border color
contentStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")) // Default border color
// Highlight active pane
if m.activePane == tocPane {
tocStyle = tocStyle.BorderForeground(lipgloss.Color("226")) // Yellow
} else {
contentStyle = contentStyle.BorderForeground(lipgloss.Color("226")) // Yellow
}
// Render panes
tocView := tocStyle.Render(m.tocViewport.View())
contentView := contentStyle.Render(m.contentViewport.View())
// Join panes horizontally
mainView := lipgloss.JoinHorizontal(lipgloss.Top, tocView, contentView)
// Help text
var helpText string
if m.helpVisible {
s := "Navigate: ↑/↓ j/k | PgUp/PgDn | g/G | Tab (switch panes) | h/? (help) | q (quit)"
helpText = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(s)
}
return mainView + helpText
}
func (m model) renderTOC() string {
if len(m.toc) == 0 {
return "No table of contents found."
}
var content strings.Builder
content.WriteString("Table of Contents:\n\n")
for i, entry := range m.toc {
indent := strings.Repeat(" ", entry.level-1)
if i == m.selectedTocIndex {
content.WriteString(fmt.Sprintf("%s> %s\n", indent, entry.text))
} else {
content.WriteString(fmt.Sprintf("%s %s\n", indent, entry.text))
}
}
return content.String()
}