commit 8f9c66ba463486af34cb3d6507d443cf9b20e028 Author: Chris Davies Date: Wed Jan 28 20:13:09 2026 -0500 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7da14f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +reader diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5421ef --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Reader + +A beautiful terminal file browser and markdown viewer built with Go and the Charm ecosystem. + +## Features + +- **File Tree Navigation**: Browse directories with expand/collapse functionality +- **Markdown Rendering**: Beautiful markdown display using Glamour +- **Syntax Highlighting**: Code files rendered with Chroma +- **Vim-like Keybindings**: Navigate efficiently with familiar keys +- **Responsive Layout**: Adapts to terminal size + +## Installation + +```bash +go build -o reader +``` + +## Usage + +```bash +# Open current directory +./reader + +# Open a specific directory +./reader ~/Documents + +# Show help +./reader --help +``` + +## Keyboard Shortcuts + +### File Browser +| Key | Action | +|-----|--------| +| `↑` / `↓` | Move up/down | +| `Enter` | Open file or folder | +| `←` | Collapse folder / go to parent | +| `→` | Expand folder / open file | + +### Reading a File +| Key | Action | +|-----|--------| +| `↑` / `↓` | Scroll up/down | +| `PgUp` / `PgDn` | Page up/down | +| `Home` / `End` | Jump to top/bottom | +| `Esc` | Back to file browser | + +### General +| Key | Action | +|-----|--------| +| `?` | Show help | +| `q` | Quit | + +## Built With + +- [Bubbletea](https://github.com/charmbracelet/bubbletea) - TUI framework +- [Lipgloss](https://github.com/charmbracelet/lipgloss) - Styling +- [Glamour](https://github.com/charmbracelet/glamour) - Markdown rendering +- [Chroma](https://github.com/alecthomas/chroma) - Syntax highlighting +- [Bubbles](https://github.com/charmbracelet/bubbles) - UI components + +## License + +MIT diff --git a/filetree/model.go b/filetree/model.go new file mode 100644 index 0000000..b1975ad --- /dev/null +++ b/filetree/model.go @@ -0,0 +1,189 @@ +package filetree + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +// Node represents a file or directory in the tree +type Node struct { + Name string + Path string + IsDir bool + IsOpen bool + Level int + Parent *Node + Children []*Node +} + +// Model represents the file tree state +type Model struct { + Root *Node + Nodes []*Node // Flattened visible nodes for navigation + Cursor int + RootPath string +} + +// New creates a new file tree model +func New(rootPath string) Model { + absPath, err := filepath.Abs(rootPath) + if err != nil { + absPath = rootPath + } + + root := &Node{ + Name: filepath.Base(absPath), + Path: absPath, + IsDir: true, + IsOpen: true, + Level: 0, + } + + m := Model{ + Root: root, + RootPath: absPath, + Cursor: 0, + } + + m.loadChildren(root) + m.updateVisibleNodes() + + return m +} + +// loadChildren loads the children of a directory node +func (m *Model) loadChildren(node *Node) { + if !node.IsDir { + return + } + + entries, err := os.ReadDir(node.Path) + if err != nil { + return + } + + // Sort: directories first, then alphabetically + sort.Slice(entries, func(i, j int) bool { + iDir := entries[i].IsDir() + jDir := entries[j].IsDir() + if iDir != jDir { + return iDir + } + return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name()) + }) + + node.Children = make([]*Node, 0, len(entries)) + for _, entry := range entries { + // Skip hidden files + if strings.HasPrefix(entry.Name(), ".") { + continue + } + + child := &Node{ + Name: entry.Name(), + Path: filepath.Join(node.Path, entry.Name()), + IsDir: entry.IsDir(), + Level: node.Level + 1, + Parent: node, + } + node.Children = append(node.Children, child) + } +} + +// updateVisibleNodes updates the flattened list of visible nodes +func (m *Model) updateVisibleNodes() { + m.Nodes = make([]*Node, 0) + m.flattenNode(m.Root) +} + +func (m *Model) flattenNode(node *Node) { + m.Nodes = append(m.Nodes, node) + if node.IsDir && node.IsOpen { + for _, child := range node.Children { + m.flattenNode(child) + } + } +} + +// SelectedNode returns the currently selected node +func (m *Model) SelectedNode() *Node { + if m.Cursor < 0 || m.Cursor >= len(m.Nodes) { + return nil + } + return m.Nodes[m.Cursor] +} + +// Toggle expands or collapses the selected directory +func (m *Model) Toggle() { + node := m.SelectedNode() + if node == nil || !node.IsDir { + return + } + + node.IsOpen = !node.IsOpen + if node.IsOpen && len(node.Children) == 0 { + m.loadChildren(node) + } + m.updateVisibleNodes() +} + +// MoveUp moves the cursor up +func (m *Model) MoveUp() { + if m.Cursor > 0 { + m.Cursor-- + } +} + +// MoveDown moves the cursor down +func (m *Model) MoveDown() { + if m.Cursor < len(m.Nodes)-1 { + m.Cursor++ + } +} + +// Collapse collapses the current directory or moves to parent +func (m *Model) Collapse() { + node := m.SelectedNode() + if node == nil { + return + } + + if node.IsDir && node.IsOpen { + node.IsOpen = false + m.updateVisibleNodes() + } else if node.Parent != nil { + // Find parent in visible nodes + for i, n := range m.Nodes { + if n == node.Parent { + m.Cursor = i + break + } + } + } +} + +// Expand expands the current directory +func (m *Model) Expand() { + node := m.SelectedNode() + if node == nil || !node.IsDir { + return + } + + if !node.IsOpen { + node.IsOpen = true + if len(node.Children) == 0 { + m.loadChildren(node) + } + m.updateVisibleNodes() + } +} + +// Ext returns the file extension of a node +func (n *Node) Ext() string { + if n.IsDir { + return "" + } + return strings.ToLower(filepath.Ext(n.Name)) +} diff --git a/filetree/view.go b/filetree/view.go new file mode 100644 index 0000000..65ff5a7 --- /dev/null +++ b/filetree/view.go @@ -0,0 +1,206 @@ +package filetree + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Styles (will be set from parent) +var ( + DirectoryStyle = lipgloss.NewStyle() + FileStyle = lipgloss.NewStyle() + SelectedStyle = lipgloss.NewStyle() +) + +// View renders the file tree +func (m Model) View(width, height int, focused bool) string { + var b strings.Builder + + // Calculate visible range for scrolling + visibleHeight := height - 2 // Account for potential padding + if visibleHeight < 1 { + visibleHeight = 1 + } + + startIdx := 0 + if m.Cursor >= visibleHeight { + startIdx = m.Cursor - visibleHeight + 1 + } + + endIdx := startIdx + visibleHeight + if endIdx > len(m.Nodes) { + endIdx = len(m.Nodes) + } + + for i := startIdx; i < endIdx; i++ { + node := m.Nodes[i] + line := m.renderNode(node, i == m.Cursor, focused) + + // Truncate line if too long + if lipgloss.Width(line) > width-2 { + line = truncateString(line, width-2) + } + + // Pad to width + padding := width - 2 - lipgloss.Width(line) + if padding > 0 { + line += strings.Repeat(" ", padding) + } + + b.WriteString(line) + if i < endIdx-1 { + b.WriteString("\n") + } + } + + return b.String() +} + +func (m Model) renderNode(node *Node, selected, focused bool) string { + // Build the prefix with tree characters + prefix := m.buildPrefix(node) + + // Get icon + icon := getIcon(node) + + // Format name + name := node.Name + if node.IsDir { + name += "/" + } + + line := fmt.Sprintf("%s%s %s", prefix, icon, name) + + // Apply style + if selected && focused { + return SelectedStyle.Render(line) + } else if selected { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render(line) + } else if node.IsDir { + return DirectoryStyle.Render(line) + } + return FileStyle.Render(line) +} + +func (m Model) buildPrefix(node *Node) string { + if node.Level == 0 { + return "" + } + + // Build prefix by traversing parents + prefixParts := make([]string, node.Level) + + current := node + for i := node.Level - 1; i >= 0; i-- { + parent := current.Parent + if parent == nil { + prefixParts[i] = " " + continue + } + + isLast := true + for j, sibling := range parent.Children { + if sibling == current { + isLast = j == len(parent.Children)-1 + break + } + } + + if i == node.Level-1 { + if isLast { + prefixParts[i] = "└─" + } else { + prefixParts[i] = "├─" + } + } else { + if isLast { + prefixParts[i] = " " + } else { + prefixParts[i] = "│ " + } + } + + current = parent + } + + return strings.Join(prefixParts, "") +} + +func getIcon(node *Node) string { + if node.IsDir { + if node.IsOpen { + return "▼" + } + return "▶" + } + + // Minimal icons using Unicode block/geometric characters + // Grouped by file type category + ext := node.Ext() + + // Documents + docs := map[string]bool{".md": true, ".markdown": true, ".txt": true, ".pdf": true, ".doc": true, ".docx": true} + if docs[ext] { + return "◔" + } + + // Code files + code := 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, ".swift": true, ".kt": true, + ".scala": true, ".ex": true, ".exs": true, ".clj": true, ".zig": true, + ".nim": true, ".lua": true, ".vim": true, ".el": true, + } + if code[ext] { + return "◇" + } + + // Shell/scripts + shell := map[string]bool{".sh": true, ".bash": true, ".zsh": true, ".fish": true} + if shell[ext] { + return "▷" + } + + // Data/config + data := map[string]bool{ + ".json": true, ".yaml": true, ".yml": true, ".toml": true, + ".xml": true, ".csv": true, ".sql": true, ".env": true, + } + if data[ext] { + return "◫" + } + + // Web + web := map[string]bool{".html": true, ".css": true, ".scss": true, ".sass": true, ".less": true} + if web[ext] { + return "◈" + } + + // Images + images := map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true} + if images[ext] { + return "◐" + } + + // Default + return "○" +} + +func truncateString(s string, maxWidth int) string { + if lipgloss.Width(s) <= maxWidth { + return s + } + + // Simple truncation - could be improved for unicode + runes := []rune(s) + for i := len(runes) - 1; i >= 0; i-- { + truncated := string(runes[:i]) + "…" + if lipgloss.Width(truncated) <= maxWidth { + return truncated + } + } + return "…" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c5025c6 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module reader + +go 1.25.6 + +require ( + github.com/alecthomas/chroma/v2 v2.23.1 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v0.9.1 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8456c71 --- /dev/null +++ b/go.sum @@ -0,0 +1,80 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM= +github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= diff --git a/keys.go b/keys.go new file mode 100644 index 0000000..e41c559 --- /dev/null +++ b/keys.go @@ -0,0 +1,70 @@ +package main + +import "github.com/charmbracelet/bubbletea" + +// Key represents a key binding +type Key struct { + Keys []string + Help string +} + +func (k Key) Matches(msg tea.KeyMsg) bool { + for _, key := range k.Keys { + if msg.String() == key { + return true + } + } + return false +} + +// Keybindings for the application +var ( + keyUp = Key{ + Keys: []string{"up"}, + Help: "move up", + } + keyDown = Key{ + Keys: []string{"down"}, + Help: "move down", + } + keyLeft = Key{ + Keys: []string{"left"}, + Help: "collapse/parent", + } + keyRight = Key{ + Keys: []string{"right"}, + Help: "expand/open", + } + keyEnter = Key{ + Keys: []string{"enter"}, + Help: "open/toggle", + } + keyEscape = Key{ + Keys: []string{"esc"}, + Help: "back to files", + } + keyPageDown = Key{ + Keys: []string{"pgdown"}, + Help: "page down", + } + keyPageUp = Key{ + Keys: []string{"pgup"}, + Help: "page up", + } + keyHome = Key{ + Keys: []string{"home"}, + Help: "go to top", + } + keyEnd = Key{ + Keys: []string{"end"}, + Help: "go to bottom", + } + keyHelp = Key{ + Keys: []string{"?"}, + Help: "toggle help", + } + keyQuit = Key{ + Keys: []string{"q", "ctrl+c"}, + Help: "quit", + } +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..6244835 --- /dev/null +++ b/main.go @@ -0,0 +1,114 @@ +package main + +import ( + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +const version = "0.1.0" + +func main() { + // Parse command line arguments + rootPath := "." + themeOverride := "" + + for i := 1; i < len(os.Args); i++ { + arg := os.Args[i] + switch { + case arg == "-h" || arg == "--help": + printHelp() + os.Exit(0) + case arg == "-v" || arg == "--version": + fmt.Printf("reader v%s\n", version) + os.Exit(0) + case arg == "--light": + themeOverride = "light" + case arg == "--dark": + themeOverride = "dark" + case strings.HasPrefix(arg, "--theme="): + themeOverride = strings.TrimPrefix(arg, "--theme=") + case arg == "--save-theme": + // Save current detected theme + theme := DetectTheme() + if err := SaveThemePreference(theme.Name); err != nil { + fmt.Fprintf(os.Stderr, "Error saving theme: %v\n", err) + os.Exit(1) + } + fmt.Printf("Saved theme preference: %s\n", theme.Name) + os.Exit(0) + case !strings.HasPrefix(arg, "-"): + rootPath = arg + } + } + + // Detect or set theme + var theme Theme + switch themeOverride { + case "light": + theme = LightTheme + case "dark": + theme = DarkTheme + default: + theme = DetectTheme() + } + SetTheme(theme) + + // Verify path exists + info, err := os.Stat(rootPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // If it's a file, use its parent directory + if !info.IsDir() { + rootPath = "." + } + + // Create and run the program + p := tea.NewProgram( + NewModel(rootPath), + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func printHelp() { + help := `reader - A beautiful terminal file browser & markdown viewer + +Usage: + reader [path] Open at path (default: current directory) + reader --light Force light theme + reader --dark Force dark theme + reader --save-theme Save detected theme as default + +Options: + -h, --help Show this help + -v, --version Show version + --light Use light theme + --dark Use dark theme + --save-theme Detect and save theme preference + +Keys: + ↑/↓ Navigate / scroll + Enter Open file or folder + ←/→ Collapse/expand folders + Esc Back to file browser + ? Show help + q Quit + +Theme is auto-detected from your terminal. Override with flags +or save a preference with --save-theme. + +Config: ~/.config/reader/theme` + + fmt.Println(help) +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..384619f --- /dev/null +++ b/model.go @@ -0,0 +1,384 @@ +package main + +import ( + "fmt" + "reader/filetree" + "reader/viewer" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Pane represents which pane is focused +type Pane int + +const ( + TreePane Pane = iota + ViewerPane +) + +// Model is the main application model +type Model struct { + tree filetree.Model + viewer viewer.Model + focused Pane + showHelp bool + width int + height int + treeWidth int + ready bool +} + +// NewModel creates a new application model +func NewModel(rootPath string) Model { + // Initialize file tree styles from current theme + filetree.DirectoryStyle = directoryStyle + filetree.FileStyle = fileStyle + filetree.SelectedStyle = selectedStyle + + // Create viewer with theme config + v := viewer.New() + v.SetTheme(viewer.ThemeConfig{ + ChromaStyle: CurrentTheme.ChromaStyle, + GlamourStyle: CurrentTheme.GlamourStyle, + LineNumColor: CurrentTheme.LineNumColor, + TextColor: string(CurrentTheme.Text), + }) + + return Model{ + tree: filetree.New(rootPath), + viewer: v, + focused: TreePane, + treeWidth: 30, + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + // Handle help toggle + if keyHelp.Matches(msg) { + m.showHelp = !m.showHelp + return m, nil + } + + // If help is shown, any key closes it + if m.showHelp { + m.showHelp = false + return m, nil + } + + // Handle quit + if keyQuit.Matches(msg) { + return m, tea.Quit + } + + // Handle escape to go back to file tree + if keyEscape.Matches(msg) { + m.focused = TreePane + return m, nil + } + + // Handle pane-specific keys + if m.focused == TreePane { + return m.updateTree(msg) + } else { + return m.updateViewer(msg) + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.ready = true + + // Calculate pane widths + m.treeWidth = m.width / 3 + if m.treeWidth < 25 { + m.treeWidth = 25 + } + if m.treeWidth > 50 { + m.treeWidth = 50 + } + + viewerWidth := m.width - m.treeWidth - 4 // Account for borders + viewerHeight := m.height - 4 // Account for status bar and borders + + m.viewer.SetSize(viewerWidth, viewerHeight) + return m, nil + } + + return m, nil +} + +func (m Model) updateTree(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case keyUp.Matches(msg): + m.tree.MoveUp() + case keyDown.Matches(msg): + m.tree.MoveDown() + case keyLeft.Matches(msg): + m.tree.Collapse() + case keyRight.Matches(msg): + node := m.tree.SelectedNode() + if node != nil && !node.IsDir { + m.openSelectedFile() + } else { + m.tree.Expand() + } + case keyEnter.Matches(msg): + node := m.tree.SelectedNode() + if node != nil { + if node.IsDir { + m.tree.Toggle() + } else { + m.openSelectedFile() + } + } + } + return m, nil +} + +func (m *Model) openSelectedFile() { + node := m.tree.SelectedNode() + if node == nil || node.IsDir { + return + } + m.viewer.LoadFile(node.Path) + m.focused = ViewerPane // Automatically switch to viewer +} + +func (m Model) updateViewer(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case keyUp.Matches(msg): + m.viewer.LineUp() + case keyDown.Matches(msg): + m.viewer.LineDown() + case keyPageDown.Matches(msg): + m.viewer.PageDown() + case keyPageUp.Matches(msg): + m.viewer.PageUp() + case keyHome.Matches(msg): + m.viewer.GotoTop() + case keyEnd.Matches(msg): + m.viewer.GotoBottom() + } + return m, nil +} + +func (m Model) View() string { + if !m.ready { + return "Loading..." + } + + // Render file tree pane + treeContent := m.tree.View(m.treeWidth-2, m.height-4, m.focused == TreePane) + var treePane string + if m.focused == TreePane { + treePane = activeBorderStyle. + Width(m.treeWidth - 2). + Height(m.height - 4). + Render(treeContent) + } else { + treePane = inactiveBorderStyle. + Width(m.treeWidth - 2). + Height(m.height - 4). + Render(treeContent) + } + + // Render viewer pane + viewerWidth := m.width - m.treeWidth - 4 + viewerContent := m.renderViewer(viewerWidth, m.height-4) + var viewerPane string + if m.focused == ViewerPane { + viewerPane = activeBorderStyle. + Width(viewerWidth). + Height(m.height - 4). + Render(viewerContent) + } else { + viewerPane = inactiveBorderStyle. + Width(viewerWidth). + Height(m.height - 4). + Render(viewerContent) + } + + // Join panes horizontally + content := lipgloss.JoinHorizontal(lipgloss.Top, treePane, viewerPane) + + // Add status bar + statusBar := m.renderStatusBar() + content = lipgloss.JoinVertical(lipgloss.Left, content, statusBar) + + // Overlay help if shown + if m.showHelp { + content = m.renderHelpOverlay(content) + } + + return content +} + +func (m Model) renderViewer(width, height int) string { + if m.viewer.Empty() { + // Show welcome message + welcome := lipgloss.NewStyle(). + Foreground(CurrentTheme.Subtext). + Italic(true). + Render("Select a file to view its contents\n\nPress ? for help") + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, welcome) + } + return m.viewer.View() +} + +func (m Model) renderStatusBar() string { + // Left side: current file or directory + var left string + if m.viewer.Empty() { + left = statusBarStyle.Render(fmt.Sprintf(" %s", m.tree.RootPath)) + } else { + left = statusBarStyle.Render(fmt.Sprintf(" %s", m.viewer.FileName)) + } + + // Right side: scroll position and help hint + var right string + if !m.viewer.Empty() { + percent := int(m.viewer.ScrollPercent() * 100) + right = statusBarStyle.Render(fmt.Sprintf("%d%% ", percent)) + } + right += statusKeyStyle.Render("? help") + + // Calculate gap + gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 0 { + gap = 0 + } + + return left + strings.Repeat(" ", gap) + right +} + +func (m Model) renderHelpOverlay(base string) string { + help := m.buildHelpContent() + + // Center the help overlay + helpRendered := helpStyle.Render(help) + + // Calculate position + helpWidth := lipgloss.Width(helpRendered) + helpHeight := lipgloss.Height(helpRendered) + + x := (m.width - helpWidth) / 2 + y := (m.height - helpHeight) / 2 + + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + + // Overlay help on base content + return placeOverlay(x, y, helpRendered, base) +} + +func (m Model) buildHelpContent() string { + title := helpTitleStyle.Render("Keyboard Shortcuts") + + sections := []struct { + header string + keys []struct{ key, desc string } + }{ + { + header: "File Browser", + keys: []struct{ key, desc string }{ + {"↑/↓", "Move up/down"}, + {"Enter", "Open file/folder"}, + {"←", "Collapse/go back"}, + {"→", "Expand/open"}, + }, + }, + { + header: "Reading a File", + keys: []struct{ key, desc string }{ + {"↑/↓", "Scroll up/down"}, + {"PgUp/PgDn", "Page up/down"}, + {"Home/End", "Top/bottom"}, + {"Esc", "Back to files"}, + }, + }, + { + header: "General", + keys: []struct{ key, desc string }{ + {"?", "Toggle help"}, + {"q", "Quit"}, + }, + }, + } + + var content strings.Builder + content.WriteString(title) + content.WriteString("\n\n") + + for i, section := range sections { + header := lipgloss.NewStyle().Foreground(CurrentTheme.Teal).Bold(true).Render(section.header) + content.WriteString(header) + content.WriteString("\n") + + for _, binding := range section.keys { + key := helpKeyStyle.Render(binding.key) + desc := helpDescStyle.Render(binding.desc) + content.WriteString(fmt.Sprintf(" %s %s\n", key, desc)) + } + + if i < len(sections)-1 { + content.WriteString("\n") + } + } + + return content.String() +} + +// placeOverlay places overlay on top of background at position x, y +func placeOverlay(x, y int, overlay, background string) string { + bgLines := strings.Split(background, "\n") + overlayLines := strings.Split(overlay, "\n") + + for i, overlayLine := range overlayLines { + bgY := y + i + if bgY < 0 || bgY >= len(bgLines) { + continue + } + + bgLine := bgLines[bgY] + bgRunes := []rune(bgLine) + + // Build new line + var newLine strings.Builder + + // Add background before overlay + for j := 0; j < x && j < len(bgRunes); j++ { + newLine.WriteRune(bgRunes[j]) + } + + // Pad if needed + for j := len(bgRunes); j < x; j++ { + newLine.WriteRune(' ') + } + + // Add overlay + newLine.WriteString(overlayLine) + + // Add background after overlay + overlayWidth := lipgloss.Width(overlayLine) + afterStart := x + overlayWidth + for j := afterStart; j < len(bgRunes); j++ { + newLine.WriteRune(bgRunes[j]) + } + + bgLines[bgY] = newLine.String() + } + + return strings.Join(bgLines, "\n") +} diff --git a/styles.go b/styles.go new file mode 100644 index 0000000..191dbb9 --- /dev/null +++ b/styles.go @@ -0,0 +1,32 @@ +package main + +import "github.com/charmbracelet/lipgloss" + +// Styles - these are initialized by SetTheme() in theme.go +var ( + activeBorderStyle lipgloss.Style + inactiveBorderStyle lipgloss.Style + directoryStyle lipgloss.Style + fileStyle lipgloss.Style + selectedStyle lipgloss.Style + statusBarStyle lipgloss.Style + statusKeyStyle lipgloss.Style + helpStyle lipgloss.Style + helpTitleStyle lipgloss.Style + helpKeyStyle lipgloss.Style + helpDescStyle lipgloss.Style + titleStyle lipgloss.Style +) + +// Icon styles - using Unicode geometric shapes for a clean look +var ( + iconDir = "▶" + iconDirOpen = "▼" + iconDoc = "◔" + iconCode = "◇" + iconShell = "▷" + iconData = "◫" + iconWeb = "◈" + iconImage = "◐" + iconDefault = "○" +) diff --git a/theme.go b/theme.go new file mode 100644 index 0000000..1a67075 --- /dev/null +++ b/theme.go @@ -0,0 +1,196 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// Theme represents a color theme +type Theme struct { + Name string + + // UI colors + Base lipgloss.Color + Surface lipgloss.Color + Overlay lipgloss.Color + Text lipgloss.Color + Subtext lipgloss.Color + Accent lipgloss.Color + Blue lipgloss.Color + Teal lipgloss.Color + Green lipgloss.Color + Yellow lipgloss.Color + Orange lipgloss.Color + Red lipgloss.Color + Purple lipgloss.Color + + // For code highlighting + ChromaStyle string + GlamourStyle string + LineNumColor string +} + +var ( + // LightTheme for light terminal backgrounds + LightTheme = Theme{ + Name: "light", + Base: lipgloss.Color("#f5f5f5"), + Surface: lipgloss.Color("#e8e8e8"), + Overlay: lipgloss.Color("#c0c0c0"), + Text: lipgloss.Color("#1a1a1a"), + Subtext: lipgloss.Color("#505050"), + Accent: lipgloss.Color("#6366f1"), + Blue: lipgloss.Color("#2563eb"), + Teal: lipgloss.Color("#0d9488"), + Green: lipgloss.Color("#16a34a"), + Yellow: lipgloss.Color("#ca8a04"), + Orange: lipgloss.Color("#ea580c"), + Red: lipgloss.Color("#dc2626"), + Purple: lipgloss.Color("#9333ea"), + + ChromaStyle: "github", + GlamourStyle: "light", + LineNumColor: "#888888", + } + + // DarkTheme for dark terminal backgrounds + DarkTheme = Theme{ + Name: "dark", + Base: lipgloss.Color("#1e1e2e"), + Surface: lipgloss.Color("#313244"), + Overlay: lipgloss.Color("#45475a"), + Text: lipgloss.Color("#cdd6f4"), + Subtext: lipgloss.Color("#a6adc8"), + Accent: lipgloss.Color("#b4befe"), + Blue: lipgloss.Color("#89b4fa"), + Teal: lipgloss.Color("#94e2d5"), + Green: lipgloss.Color("#a6e3a1"), + Yellow: lipgloss.Color("#f9e2af"), + Orange: lipgloss.Color("#fab387"), + Red: lipgloss.Color("#f38ba8"), + Purple: lipgloss.Color("#cba6f7"), + + ChromaStyle: "catppuccin-mocha", + GlamourStyle: "dark", + LineNumColor: "#6c7086", + } + + // CurrentTheme is the active theme + CurrentTheme = LightTheme +) + +// DetectTheme tries to detect if terminal has dark or light background +func DetectTheme() Theme { + // Check for config file override first + if theme := loadThemePreference(); theme != "" { + if theme == "dark" { + return DarkTheme + } + return LightTheme + } + + // Auto-detect from terminal + if termenv.NewOutput(os.Stdout).HasDarkBackground() { + return DarkTheme + } + return LightTheme +} + +// SetTheme sets the current theme and updates all styles +func SetTheme(t Theme) { + CurrentTheme = t + updateStyles() +} + +// loadThemePreference loads theme from config file +func loadThemePreference() string { + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + + configPath := filepath.Join(configDir, "reader", "theme") + data, err := os.ReadFile(configPath) + if err != nil { + return "" + } + + return strings.TrimSpace(string(data)) +} + +// SaveThemePreference saves theme preference to config file +func SaveThemePreference(theme string) error { + configDir, err := os.UserConfigDir() + if err != nil { + return err + } + + readerDir := filepath.Join(configDir, "reader") + if err := os.MkdirAll(readerDir, 0755); err != nil { + return err + } + + configPath := filepath.Join(readerDir, "theme") + return os.WriteFile(configPath, []byte(theme), 0644) +} + +// updateStyles updates all the lipgloss styles based on current theme +func updateStyles() { + t := CurrentTheme + + activeBorderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Accent) + + inactiveBorderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Overlay) + + directoryStyle = lipgloss.NewStyle(). + Foreground(t.Blue). + Bold(true) + + fileStyle = lipgloss.NewStyle(). + Foreground(t.Text) + + selectedStyle = lipgloss.NewStyle(). + Background(t.Surface). + Foreground(t.Accent). + Bold(true) + + statusBarStyle = lipgloss.NewStyle(). + Background(t.Surface). + Foreground(t.Subtext). + Padding(0, 1) + + statusKeyStyle = lipgloss.NewStyle(). + Background(t.Overlay). + Foreground(t.Text). + Padding(0, 1) + + helpStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Purple). + Padding(1, 2) + + helpTitleStyle = lipgloss.NewStyle(). + Foreground(t.Purple). + Bold(true). + MarginBottom(1) + + helpKeyStyle = lipgloss.NewStyle(). + Foreground(t.Yellow). + Width(12) + + helpDescStyle = lipgloss.NewStyle(). + Foreground(t.Subtext) + + titleStyle = lipgloss.NewStyle(). + Foreground(t.Accent). + Bold(true). + Padding(0, 1) +} diff --git a/viewer/model.go b/viewer/model.go new file mode 100644 index 0000000..4bb63ed --- /dev/null +++ b/viewer/model.go @@ -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 == "" +}