feat(tui): Bubble Tea TUI with reading list, detail view, and keybindings
This commit is contained in:
16
go.mod
16
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
|
||||
|
||||
34
go.sum
34
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=
|
||||
|
||||
4
internal/tui/keys.go
Normal file
4
internal/tui/keys.go
Normal 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
263
internal/tui/model.go
Normal 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
181
internal/tui/views.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user