From 6cd581385e3b8d37769984ae79c10bccd98a7e15 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 3 Apr 2026 12:19:03 +0200 Subject: [PATCH] feat(tui): Bubble Tea TUI with reading list, detail view, and keybindings --- go.mod | 16 +++ go.sum | 34 ++++++ internal/tui/keys.go | 4 + internal/tui/model.go | 263 ++++++++++++++++++++++++++++++++++++++++++ internal/tui/views.go | 181 +++++++++++++++++++++++++++++ 5 files changed, 498 insertions(+) create mode 100644 internal/tui/keys.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/views.go diff --git a/go.mod b/go.mod index 594cbad..0104836 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 648677e..f5f6c40 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..360e4f6 --- /dev/null +++ b/internal/tui/keys.go @@ -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. diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..e1fa9a1 --- /dev/null +++ b/internal/tui/model.go @@ -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 + } +} diff --git a/internal/tui/views.go b/internal/tui/views.go new file mode 100644 index 0000000..d08d4e7 --- /dev/null +++ b/internal/tui/views.go @@ -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)) + } +}