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