diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..c56773b --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,245 @@ +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() +} \ No newline at end of file