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