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>
385 lines
8.2 KiB
Go
385 lines
8.2 KiB
Go
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")
|
|
}
|