package viewer import ( "fmt" "os" "path/filepath" "strings" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/formatters" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" ) // ThemeConfig holds theme settings for rendering type ThemeConfig struct { ChromaStyle string GlamourStyle string LineNumColor string TextColor string } // Model represents the content viewer type Model struct { viewport viewport.Model FilePath string FileName string content string contentType string ready bool width int height int theme ThemeConfig } // New creates a new viewer model func New() Model { return Model{ theme: ThemeConfig{ ChromaStyle: "github", GlamourStyle: "light", LineNumColor: "#888888", TextColor: "#1a1a1a", }, } } // SetTheme updates the viewer's theme settings func (m *Model) SetTheme(config ThemeConfig) { m.theme = config // Re-render if we have content if m.FilePath != "" { m.renderContent() } } // SetSize sets the viewer dimensions func (m *Model) SetSize(width, height int) { m.width = width m.height = height if !m.ready { m.viewport = viewport.New(width, height) m.ready = true } else { m.viewport.Width = width m.viewport.Height = height } // Re-render content if we have a file if m.FilePath != "" { m.renderContent() } } // LoadFile loads and renders a file func (m *Model) LoadFile(path string) error { content, err := os.ReadFile(path) if err != nil { return err } m.FilePath = path m.FileName = filepath.Base(path) m.content = string(content) m.contentType = detectContentType(path) m.renderContent() m.viewport.GotoTop() return nil } // renderContent renders the content based on file type func (m *Model) renderContent() { if m.width <= 0 || m.height <= 0 { return } var rendered string switch m.contentType { case "markdown": rendered = m.renderMarkdown() case "code": rendered = m.renderCode() default: rendered = m.renderPlainText() } m.viewport.SetContent(rendered) } func (m *Model) renderMarkdown() string { r, err := glamour.NewTermRenderer( glamour.WithStylePath(m.theme.GlamourStyle), glamour.WithWordWrap(m.width-4), ) if err != nil { return m.content } out, err := r.Render(m.content) if err != nil { return m.content } return out } func (m *Model) renderCode() string { lexer := lexers.Match(m.FilePath) if lexer == nil { lexer = lexers.Fallback } lexer = chroma.Coalesce(lexer) style := styles.Get(m.theme.ChromaStyle) if style == nil { style = styles.Fallback } formatter := formatters.Get("terminal256") if formatter == nil { formatter = formatters.Fallback } iterator, err := lexer.Tokenise(nil, m.content) if err != nil { return m.renderPlainText() } var buf strings.Builder err = formatter.Format(&buf, style, iterator) if err != nil { return m.renderPlainText() } // Add line numbers lines := strings.Split(buf.String(), "\n") lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.theme.LineNumColor)) var result strings.Builder for i, line := range lines { lineNum := fmt.Sprintf("%4d │ ", i+1) result.WriteString(lineNumStyle.Render(lineNum)) result.WriteString(line) if i < len(lines)-1 { result.WriteString("\n") } } return result.String() } func (m *Model) renderPlainText() string { lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.theme.LineNumColor)) textStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.theme.TextColor)) lines := strings.Split(m.content, "\n") var result strings.Builder for i, line := range lines { lineNum := fmt.Sprintf("%4d │ ", i+1) result.WriteString(lineNumStyle.Render(lineNum)) result.WriteString(textStyle.Render(line)) if i < len(lines)-1 { result.WriteString("\n") } } return result.String() } func detectContentType(path string) string { ext := strings.ToLower(filepath.Ext(path)) markdownExts := map[string]bool{ ".md": true, ".markdown": true, ".mdown": true, ".mkd": true, } codeExts := map[string]bool{ ".go": true, ".py": true, ".js": true, ".ts": true, ".jsx": true, ".tsx": true, ".rs": true, ".c": true, ".cpp": true, ".h": true, ".hpp": true, ".java": true, ".rb": true, ".php": true, ".sh": true, ".bash": true, ".zsh": true, ".json": true, ".yaml": true, ".yml": true, ".toml": true, ".xml": true, ".html": true, ".css": true, ".scss": true, ".sass": true, ".sql": true, ".lua": true, ".vim": true, ".swift": true, ".kt": true, ".kts": true, ".zig": true, ".nim": true, ".ex": true, ".exs": true, ".erl": true, ".hrl": true, ".clj": true, ".scala": true, } if markdownExts[ext] { return "markdown" } if codeExts[ext] { return "code" } return "text" } // View renders the viewer func (m Model) View() string { if !m.ready { return "" } return m.viewport.View() } // Viewport returns the viewport for external control func (m *Model) Viewport() *viewport.Model { return &m.viewport } // ScrollPercent returns the current scroll percentage func (m Model) ScrollPercent() float64 { return m.viewport.ScrollPercent() } // LineDown scrolls down one line func (m *Model) LineDown() { m.viewport.LineDown(1) } // LineUp scrolls up one line func (m *Model) LineUp() { m.viewport.LineUp(1) } // PageDown scrolls a full page down func (m *Model) PageDown() { m.viewport.ViewDown() } // PageUp scrolls a full page up func (m *Model) PageUp() { m.viewport.ViewUp() } // GotoTop scrolls to the top func (m *Model) GotoTop() { m.viewport.GotoTop() } // GotoBottom scrolls to the bottom func (m *Model) GotoBottom() { m.viewport.GotoBottom() } // Empty returns true if no file is loaded func (m Model) Empty() bool { return m.FilePath == "" }