Initial commit: terminal file browser & markdown viewer
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>
This commit is contained in:
280
viewer/model.go
Normal file
280
viewer/model.go
Normal file
@@ -0,0 +1,280 @@
|
||||
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 == ""
|
||||
}
|
||||
Reference in New Issue
Block a user