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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
reader
|
||||
66
README.md
Normal file
66
README.md
Normal 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
189
filetree/model.go
Normal 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
206
filetree/view.go
Normal 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
40
go.mod
Normal 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
80
go.sum
Normal 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
70
keys.go
Normal 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
114
main.go
Normal 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
384
model.go
Normal 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
32
styles.go
Normal 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
196
theme.go
Normal 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
280
viewer/model.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package viewer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/alecthomas/chroma/v2/formatters"
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ThemeConfig holds theme settings for rendering
|
||||
type ThemeConfig struct {
|
||||
ChromaStyle string
|
||||
GlamourStyle string
|
||||
LineNumColor string
|
||||
TextColor string
|
||||
}
|
||||
|
||||
// Model represents the content viewer
|
||||
type Model struct {
|
||||
viewport viewport.Model
|
||||
FilePath string
|
||||
FileName string
|
||||
content string
|
||||
contentType string
|
||||
ready bool
|
||||
width int
|
||||
height int
|
||||
theme ThemeConfig
|
||||
}
|
||||
|
||||
// New creates a new viewer model
|
||||
func New() Model {
|
||||
return Model{
|
||||
theme: ThemeConfig{
|
||||
ChromaStyle: "github",
|
||||
GlamourStyle: "light",
|
||||
LineNumColor: "#888888",
|
||||
TextColor: "#1a1a1a",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetTheme updates the viewer's theme settings
|
||||
func (m *Model) SetTheme(config ThemeConfig) {
|
||||
m.theme = config
|
||||
// Re-render if we have content
|
||||
if m.FilePath != "" {
|
||||
m.renderContent()
|
||||
}
|
||||
}
|
||||
|
||||
// SetSize sets the viewer dimensions
|
||||
func (m *Model) SetSize(width, height int) {
|
||||
m.width = width
|
||||
m.height = height
|
||||
|
||||
if !m.ready {
|
||||
m.viewport = viewport.New(width, height)
|
||||
m.ready = true
|
||||
} else {
|
||||
m.viewport.Width = width
|
||||
m.viewport.Height = height
|
||||
}
|
||||
|
||||
// Re-render content if we have a file
|
||||
if m.FilePath != "" {
|
||||
m.renderContent()
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFile loads and renders a file
|
||||
func (m *Model) LoadFile(path string) error {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.FilePath = path
|
||||
m.FileName = filepath.Base(path)
|
||||
m.content = string(content)
|
||||
m.contentType = detectContentType(path)
|
||||
m.renderContent()
|
||||
m.viewport.GotoTop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderContent renders the content based on file type
|
||||
func (m *Model) renderContent() {
|
||||
if m.width <= 0 || m.height <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var rendered string
|
||||
|
||||
switch m.contentType {
|
||||
case "markdown":
|
||||
rendered = m.renderMarkdown()
|
||||
case "code":
|
||||
rendered = m.renderCode()
|
||||
default:
|
||||
rendered = m.renderPlainText()
|
||||
}
|
||||
|
||||
m.viewport.SetContent(rendered)
|
||||
}
|
||||
|
||||
func (m *Model) renderMarkdown() string {
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithStylePath(m.theme.GlamourStyle),
|
||||
glamour.WithWordWrap(m.width-4),
|
||||
)
|
||||
if err != nil {
|
||||
return m.content
|
||||
}
|
||||
|
||||
out, err := r.Render(m.content)
|
||||
if err != nil {
|
||||
return m.content
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Model) renderCode() string {
|
||||
lexer := lexers.Match(m.FilePath)
|
||||
if lexer == nil {
|
||||
lexer = lexers.Fallback
|
||||
}
|
||||
lexer = chroma.Coalesce(lexer)
|
||||
|
||||
style := styles.Get(m.theme.ChromaStyle)
|
||||
if style == nil {
|
||||
style = styles.Fallback
|
||||
}
|
||||
|
||||
formatter := formatters.Get("terminal256")
|
||||
if formatter == nil {
|
||||
formatter = formatters.Fallback
|
||||
}
|
||||
|
||||
iterator, err := lexer.Tokenise(nil, m.content)
|
||||
if err != nil {
|
||||
return m.renderPlainText()
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
err = formatter.Format(&buf, style, iterator)
|
||||
if err != nil {
|
||||
return m.renderPlainText()
|
||||
}
|
||||
|
||||
// Add line numbers
|
||||
lines := strings.Split(buf.String(), "\n")
|
||||
lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.theme.LineNumColor))
|
||||
|
||||
var result strings.Builder
|
||||
for i, line := range lines {
|
||||
lineNum := fmt.Sprintf("%4d │ ", i+1)
|
||||
result.WriteString(lineNumStyle.Render(lineNum))
|
||||
result.WriteString(line)
|
||||
if i < len(lines)-1 {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func (m *Model) renderPlainText() string {
|
||||
lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.theme.LineNumColor))
|
||||
textStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.theme.TextColor))
|
||||
|
||||
lines := strings.Split(m.content, "\n")
|
||||
var result strings.Builder
|
||||
|
||||
for i, line := range lines {
|
||||
lineNum := fmt.Sprintf("%4d │ ", i+1)
|
||||
result.WriteString(lineNumStyle.Render(lineNum))
|
||||
result.WriteString(textStyle.Render(line))
|
||||
if i < len(lines)-1 {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func detectContentType(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
|
||||
markdownExts := map[string]bool{
|
||||
".md": true,
|
||||
".markdown": true,
|
||||
".mdown": true,
|
||||
".mkd": true,
|
||||
}
|
||||
|
||||
codeExts := map[string]bool{
|
||||
".go": true, ".py": true, ".js": true, ".ts": true,
|
||||
".jsx": true, ".tsx": true, ".rs": true, ".c": true,
|
||||
".cpp": true, ".h": true, ".hpp": true, ".java": true,
|
||||
".rb": true, ".php": true, ".sh": true, ".bash": true,
|
||||
".zsh": true, ".json": true, ".yaml": true, ".yml": true,
|
||||
".toml": true, ".xml": true, ".html": true, ".css": true,
|
||||
".scss": true, ".sass": true, ".sql": true, ".lua": true,
|
||||
".vim": true, ".swift": true, ".kt": true, ".kts": true,
|
||||
".zig": true, ".nim": true, ".ex": true, ".exs": true,
|
||||
".erl": true, ".hrl": true, ".clj": true, ".scala": true,
|
||||
}
|
||||
|
||||
if markdownExts[ext] {
|
||||
return "markdown"
|
||||
}
|
||||
if codeExts[ext] {
|
||||
return "code"
|
||||
}
|
||||
return "text"
|
||||
}
|
||||
|
||||
// View renders the viewer
|
||||
func (m Model) View() string {
|
||||
if !m.ready {
|
||||
return ""
|
||||
}
|
||||
return m.viewport.View()
|
||||
}
|
||||
|
||||
// Viewport returns the viewport for external control
|
||||
func (m *Model) Viewport() *viewport.Model {
|
||||
return &m.viewport
|
||||
}
|
||||
|
||||
// ScrollPercent returns the current scroll percentage
|
||||
func (m Model) ScrollPercent() float64 {
|
||||
return m.viewport.ScrollPercent()
|
||||
}
|
||||
|
||||
// LineDown scrolls down one line
|
||||
func (m *Model) LineDown() {
|
||||
m.viewport.LineDown(1)
|
||||
}
|
||||
|
||||
// LineUp scrolls up one line
|
||||
func (m *Model) LineUp() {
|
||||
m.viewport.LineUp(1)
|
||||
}
|
||||
|
||||
// PageDown scrolls a full page down
|
||||
func (m *Model) PageDown() {
|
||||
m.viewport.ViewDown()
|
||||
}
|
||||
|
||||
// PageUp scrolls a full page up
|
||||
func (m *Model) PageUp() {
|
||||
m.viewport.ViewUp()
|
||||
}
|
||||
|
||||
// GotoTop scrolls to the top
|
||||
func (m *Model) GotoTop() {
|
||||
m.viewport.GotoTop()
|
||||
}
|
||||
|
||||
// GotoBottom scrolls to the bottom
|
||||
func (m *Model) GotoBottom() {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
|
||||
// Empty returns true if no file is loaded
|
||||
func (m Model) Empty() bool {
|
||||
return m.FilePath == ""
|
||||
}
|
||||
Reference in New Issue
Block a user