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