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:
Chris Davies
2026-01-28 20:13:09 -05:00
commit 8f9c66ba46
12 changed files with 1658 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
reader

66
README.md Normal file
View File

@@ -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

189
filetree/model.go Normal file
View File

@@ -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))
}

206
filetree/view.go Normal file
View File

@@ -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 "…"
}

40
go.mod Normal file
View File

@@ -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
)

80
go.sum Normal file
View File

@@ -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=

70
keys.go Normal file
View File

@@ -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",
}
)

114
main.go Normal file
View File

@@ -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)
}

384
model.go Normal file
View File

@@ -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")
}

32
styles.go Normal file
View File

@@ -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 = "○"
)

196
theme.go Normal file
View File

@@ -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)
}

280
viewer/model.go Normal file
View 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 == ""
}