182 lines
4.8 KiB
Go
182 lines
4.8 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
"somegit.dev/vikingowl/reddit-reader/internal/domain"
|
|
)
|
|
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
|
|
tabStyle = lipgloss.NewStyle().Padding(0, 2)
|
|
activeTab = tabStyle.Copy().Foreground(lipgloss.Color("39")).Bold(true).Underline(true)
|
|
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
|
scoreStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
|
|
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
|
|
summaryStyle = lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("252"))
|
|
helpStyle = dimStyle.Copy().Padding(1, 0)
|
|
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
|
)
|
|
|
|
const (
|
|
headerLines = 2 // tab bar + blank line
|
|
helpLines = 2 // blank line + help bar
|
|
detailLines = 8 // approximate lines for expanded detail pane
|
|
)
|
|
|
|
// renderView is the top-level renderer called by Model.View().
|
|
func renderView(m Model) string {
|
|
if m.err != nil {
|
|
return errorStyle.Render(fmt.Sprintf("error: %v", m.err)) + "\n" +
|
|
helpStyle.Render("q quit")
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
b.WriteString(renderTabs(m.view))
|
|
b.WriteString("\n")
|
|
|
|
if m.view == viewSettings {
|
|
b.WriteString(summaryStyle.Render("Settings — edit ~/.config/reddit-reader/config.toml"))
|
|
b.WriteString("\n")
|
|
b.WriteString(helpStyle.Render("tab next view • q quit"))
|
|
return b.String()
|
|
}
|
|
|
|
visible := filterForView(m.posts, m.view)
|
|
|
|
if len(visible) == 0 {
|
|
b.WriteString(dimStyle.Render(" (empty)"))
|
|
b.WriteString("\n")
|
|
} else {
|
|
// Calculate how many list rows fit given terminal height.
|
|
listHeight := m.height - headerLines - helpLines
|
|
if m.expanded {
|
|
listHeight -= detailLines
|
|
}
|
|
if listHeight < 1 {
|
|
listHeight = 1
|
|
}
|
|
|
|
// Scroll window: keep cursor visible.
|
|
start := 0
|
|
if m.cursor >= listHeight {
|
|
start = m.cursor - listHeight + 1
|
|
}
|
|
end := start + listHeight
|
|
if end > len(visible) {
|
|
end = len(visible)
|
|
}
|
|
|
|
for i := start; i < end; i++ {
|
|
selected := i == m.cursor
|
|
b.WriteString(renderPostLine(visible[i], selected))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Expanded detail pane for the selected post.
|
|
if m.expanded && m.cursor < len(visible) {
|
|
b.WriteString(renderDetail(visible[m.cursor]))
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
b.WriteString(helpStyle.Render(
|
|
"j/k navigate • enter expand • s star • d dismiss • o open • +/- vote • tab view • q quit",
|
|
))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// renderTabs renders the tab bar.
|
|
func renderTabs(current view) string {
|
|
var parts []string
|
|
for i, name := range viewNames {
|
|
if view(i) == current {
|
|
parts = append(parts, activeTab.Render(name))
|
|
} else {
|
|
parts = append(parts, tabStyle.Render(name))
|
|
}
|
|
}
|
|
return titleStyle.Render("reddit-reader") + " " + strings.Join(parts, dimStyle.Render("│"))
|
|
}
|
|
|
|
// renderPostLine renders a single post as a compact one-liner.
|
|
// Format: ● r/golang 0.87 2m ago Title here
|
|
func renderPostLine(p domain.Post, selected bool) string {
|
|
bullet := dimStyle.Render(" ·")
|
|
if selected {
|
|
bullet = cursorStyle.Render(" ●")
|
|
}
|
|
|
|
subreddit := dimStyle.Render(fmt.Sprintf("r/%-12s", p.Subreddit))
|
|
|
|
var relevance string
|
|
if p.Relevance != nil {
|
|
relevance = scoreStyle.Render(fmt.Sprintf("%.2f", *p.Relevance))
|
|
} else {
|
|
relevance = dimStyle.Render(" ")
|
|
}
|
|
|
|
age := dimStyle.Render(fmt.Sprintf("%-6s", relativeTime(p.CreatedUTC)))
|
|
|
|
title := p.Title
|
|
if p.Starred {
|
|
title = "★ " + title
|
|
}
|
|
if selected {
|
|
title = cursorStyle.Render(title)
|
|
}
|
|
|
|
return fmt.Sprintf("%s %s %s %s %s", bullet, subreddit, relevance, age, title)
|
|
}
|
|
|
|
// renderDetail renders the expanded detail pane for a post.
|
|
func renderDetail(p domain.Post) string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString(summaryStyle.Render(strings.Repeat("─", 60)))
|
|
b.WriteString("\n")
|
|
b.WriteString(summaryStyle.Render(p.Title))
|
|
b.WriteString("\n")
|
|
b.WriteString(summaryStyle.Render(
|
|
fmt.Sprintf("r/%s • by u/%s • score %d • %s", p.Subreddit, p.Author, p.Score, relativeTime(p.CreatedUTC)),
|
|
))
|
|
b.WriteString("\n")
|
|
b.WriteString(summaryStyle.Render(p.URL))
|
|
b.WriteString("\n")
|
|
|
|
if p.Summary != nil && *p.Summary != "" {
|
|
b.WriteString("\n")
|
|
for _, line := range strings.Split(*p.Summary, "\n") {
|
|
b.WriteString(summaryStyle.Render(" " + line))
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
b.WriteString(summaryStyle.Render(strings.Repeat("─", 60)))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// relativeTime formats a time as a short human-readable age string.
|
|
func relativeTime(t time.Time) string {
|
|
if t.IsZero() {
|
|
return "?"
|
|
}
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
|
case d < 24*time.Hour:
|
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
|
default:
|
|
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
|
}
|
|
}
|