feat(tui): Bubble Tea TUI with reading list, detail view, and keybindings

This commit is contained in:
2026-04-03 12:19:03 +02:00
parent 36620fcce7
commit 6cd581385e
5 changed files with 498 additions and 0 deletions

16
go.mod
View File

@@ -13,14 +13,30 @@ require (
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.42.0 // indirect

34
go.sum
View File

@@ -1,13 +1,29 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/VikingOwl91/mistral-go-sdk v1.2.1 h1:6OQMtOzJUFcvFUEtbX9VlglUPBn+dKOrQPnyoVKlpkA=
github.com/VikingOwl91/mistral-go-sdk v1.2.1/go.mod h1:f4emNtHUx2zSqY3V0LBz6lNI1jE6q/zh+SEU+/hJ0i4=
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/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-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -27,8 +43,20 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
@@ -37,6 +65,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -47,6 +78,8 @@ github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/vartanbeno/go-reddit/v2 v2.0.1 h1:P6ITpf5YHjdy7DHZIbUIDn/iNAoGcEoDQnMa+L4vutw=
github.com/vartanbeno/go-reddit/v2 v2.0.1/go.mod h1:758/S10hwZSLm43NPtwoNQdZFSg3sjB5745Mwjb0ANI=
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=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
@@ -72,6 +105,7 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

4
internal/tui/keys.go Normal file
View File

@@ -0,0 +1,4 @@
package tui
// Key bindings are handled via msg.String() matching in the Update function.
// No complex key binding struct needed for v1.

263
internal/tui/model.go Normal file
View File

@@ -0,0 +1,263 @@
package tui
import (
"context"
"fmt"
"os/exec"
tea "github.com/charmbracelet/bubbletea"
client "somegit.dev/vikingowl/reddit-reader/internal/grpc/client"
"somegit.dev/vikingowl/reddit-reader/internal/domain"
)
type view int
const (
viewReadingList view = iota
viewStarred
viewArchive
viewSettings
)
var viewNames = []string{"Reading List", "Starred", "Archive", "Settings"}
// postsMsg carries a loaded batch of posts.
type postsMsg []domain.Post
// streamMsg carries a single post from the live stream.
type streamMsg domain.Post
// errMsg carries an error to display.
type errMsg error
// Model is the root Bubble Tea model for the reading-list TUI.
type Model struct {
client *client.Client
posts []domain.Post
cursor int
expanded bool
view view
width int
height int
err error
}
// New constructs a Model with the given gRPC client.
func New(c *client.Client) Model {
return Model{client: c}
}
// Init starts the initial data load and stream subscription.
func (m Model) Init() tea.Cmd {
return tea.Batch(loadPosts(m.client), subscribeStream(m.client))
}
// Update handles all incoming messages and produces commands.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case postsMsg:
m.posts = []domain.Post(msg)
m.cursor = 0
m.err = nil
return m, nil
case streamMsg:
post := domain.Post(msg)
// Prepend so newest arrives at the top.
m.posts = append([]domain.Post{post}, m.posts...)
// Subscribe again to receive the next streamed post.
return m, subscribeStream(m.client)
case errMsg:
m.err = msg
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
visible := filterForView(m.posts, m.view)
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "j", "down":
if m.cursor < len(visible)-1 {
m.cursor++
}
m.expanded = false
case "k", "up":
if m.cursor > 0 {
m.cursor--
}
m.expanded = false
case "g":
m.cursor = 0
m.expanded = false
case "G":
if len(visible) > 0 {
m.cursor = len(visible) - 1
}
m.expanded = false
case "enter":
m.expanded = !m.expanded
case "tab":
m.view = (m.view + 1) % view(len(viewNames))
m.cursor = 0
m.expanded = false
case "s":
if post := selectedPost(visible, m.cursor); post != nil {
starred := !post.Starred
return m, toggleStar(m.client, *post, starred)
}
case "d":
if post := selectedPost(visible, m.cursor); post != nil {
return m, dismissPost(m.client, *post)
}
case "o":
if post := selectedPost(visible, m.cursor); post != nil {
openURL(post.URL)
}
case "+":
if post := selectedPost(visible, m.cursor); post != nil {
return m, voteFeedback(m.client, post.ID, 1)
}
case "-":
if post := selectedPost(visible, m.cursor); post != nil {
return m, voteFeedback(m.client, post.ID, -1)
}
}
return m, nil
}
// View delegates rendering to views.go.
func (m Model) View() string {
return renderView(m)
}
// Run creates and starts the Bubble Tea program in alt-screen mode.
func Run(c *client.Client) error {
p := tea.NewProgram(New(c), tea.WithAltScreen())
_, err := p.Run()
if err != nil {
return fmt.Errorf("tui run: %w", err)
}
return nil
}
// --- helper functions ---
func selectedPost(posts []domain.Post, cursor int) *domain.Post {
if len(posts) == 0 || cursor < 0 || cursor >= len(posts) {
return nil
}
p := posts[cursor]
return &p
}
func filterForView(posts []domain.Post, v view) []domain.Post {
var out []domain.Post
for _, p := range posts {
switch v {
case viewReadingList:
if !p.Dismissed {
out = append(out, p)
}
case viewStarred:
if p.Starred {
out = append(out, p)
}
case viewArchive:
if p.Dismissed || p.Read {
out = append(out, p)
}
case viewSettings:
// settings view has no posts
}
}
return out
}
func openURL(url string) {
// best-effort; ignore error
_ = exec.Command("xdg-open", url).Start()
}
// --- commands ---
func loadPosts(c *client.Client) tea.Cmd {
return func() tea.Msg {
posts, err := c.ListPosts(context.Background(), "", 200)
if err != nil {
return errMsg(err)
}
return postsMsg(posts)
}
}
func subscribeStream(c *client.Client) tea.Cmd {
return func() tea.Msg {
ch, err := c.StreamPosts(context.Background())
if err != nil {
return errMsg(err)
}
post, ok := <-ch
if !ok {
return nil
}
return streamMsg(post)
}
}
func toggleStar(c *client.Client, post domain.Post, starred bool) tea.Cmd {
return func() tea.Msg {
_, err := c.UpdatePost(context.Background(), post.ID, nil, &starred, nil)
if err != nil {
return errMsg(err)
}
return loadPosts(c)()
}
}
func dismissPost(c *client.Client, post domain.Post) tea.Cmd {
return func() tea.Msg {
dismissed := true
_, err := c.UpdatePost(context.Background(), post.ID, nil, nil, &dismissed)
if err != nil {
return errMsg(err)
}
return loadPosts(c)()
}
}
func voteFeedback(c *client.Client, postID string, vote int) tea.Cmd {
return func() tea.Msg {
err := c.SubmitFeedback(context.Background(), postID, vote)
if err != nil {
return errMsg(err)
}
return nil
}
}

181
internal/tui/views.go Normal file
View File

@@ -0,0 +1,181 @@
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))
}
}