Files
reddit-reader/internal/tui/model.go

264 lines
5.2 KiB
Go

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