From 8f9c66ba463486af34cb3d6507d443cf9b20e028 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Wed, 28 Jan 2026 20:13:09 -0500 Subject: [PATCH] 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 --- .gitignore | 1 + README.md | 66 ++++++++ filetree/model.go | 189 +++++++++++++++++++++++ filetree/view.go | 206 +++++++++++++++++++++++++ go.mod | 40 +++++ go.sum | 80 ++++++++++ keys.go | 70 +++++++++ main.go | 114 ++++++++++++++ model.go | 384 ++++++++++++++++++++++++++++++++++++++++++++++ styles.go | 32 ++++ theme.go | 196 +++++++++++++++++++++++ viewer/model.go | 280 +++++++++++++++++++++++++++++++++ 12 files changed, 1658 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 filetree/model.go create mode 100644 filetree/view.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 keys.go create mode 100644 main.go create mode 100644 model.go create mode 100644 styles.go create mode 100644 theme.go create mode 100644 viewer/model.go 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 == "" +}