A beautiful TUI for browsing files and reading markdown/code: - Two-pane layout with file tree and content viewer - Markdown rendering via Glamour - Syntax highlighting via Chroma - Auto-detects light/dark terminal theme - Arrow key navigation, Esc to go back - Page Up/Down, Home/End for scrolling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
281 lines
5.8 KiB
Go
281 lines
5.8 KiB
Go
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 == ""
|
|
}
|