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:
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")
|
||||
}
|
||||
Reference in New Issue
Block a user