264 lines
5.2 KiB
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
|
|
}
|
|
}
|