aboutsummaryrefslogtreecommitdiffstats
path: root/tui
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-12 20:42:09 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-12 20:42:09 -0800
commitfa037e748bb2ba65f75ba104e18d460a8fbcd49b (patch)
tree9a6c33024475ff6fd08013e39aee9717296cc638 /tui
parent27d14514ab3c3c11e822fc2c1220072298e85c02 (diff)
downloadneko-fa037e748bb2ba65f75ba104e18d460a8fbcd49b.tar.gz
neko-fa037e748bb2ba65f75ba104e18d460a8fbcd49b.tar.bz2
neko-fa037e748bb2ba65f75ba104e18d460a8fbcd49b.zip
Implement Bubble Tea Terminal UI (TUI)
- Added Bubble Tea, Lipgloss, and Bubbles dependencies - Implemented TUI package in tui/ for browsing feeds and items - Added --tui flag to main command - Verified build and existing tests
Diffstat (limited to 'tui')
-rw-r--r--tui/style.go43
-rw-r--r--tui/tui.go191
2 files changed, 234 insertions, 0 deletions
diff --git a/tui/style.go b/tui/style.go
new file mode 100644
index 0000000..7b21c78
--- /dev/null
+++ b/tui/style.go
@@ -0,0 +1,43 @@
+package tui
+
+import "github.com/charmbracelet/lipgloss"
+
+var (
+ // Colors
+ maroon = lipgloss.Color("#800000")
+ lavender = lipgloss.Color("#E6E6FA")
+ gray = lipgloss.Color("#808080")
+ darkGray = lipgloss.Color("#404040")
+
+ // Styles
+ TitleStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lavender).
+ Background(maroon).
+ Padding(0, 1)
+
+ ListStyle = lipgloss.NewStyle().
+ Padding(1, 2)
+
+ SelectedItemStyle = lipgloss.NewStyle().
+ PaddingLeft(2).
+ Foreground(lavender).
+ Background(darkGray).
+ Bold(true)
+
+ ItemStyle = lipgloss.NewStyle().
+ PaddingLeft(2)
+
+ HeaderStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(maroon).
+ MarginBottom(1)
+
+ ContentStyle = lipgloss.NewStyle().
+ Padding(1, 4)
+
+ StatusStyle = lipgloss.NewStyle().
+ Foreground(gray).
+ Italic(true).
+ MarginTop(1)
+)
diff --git a/tui/tui.go b/tui/tui.go
new file mode 100644
index 0000000..54e654b
--- /dev/null
+++ b/tui/tui.go
@@ -0,0 +1,191 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ "adammathes.com/neko/models/feed"
+ "adammathes.com/neko/models/item"
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type viewState int
+
+const (
+ viewFeeds viewState = iota
+ viewItems
+ viewContent
+)
+
+type itemDelegate struct{}
+
+func (d itemDelegate) Height() int { return 1 }
+func (d itemDelegate) Spacing() int { return 0 }
+func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+func (d itemDelegate) Render(w strings.Builder, m list.Model, index int, listItem list.Item) {
+ str, ok := listItem.(itemString)
+ if !ok {
+ return
+ }
+
+ if index == m.Index() {
+ fmt.Fprint(&w, SelectedItemStyle.Render(string(str)))
+ } else {
+ fmt.Fprint(&w, ItemStyle.Render(string(str)))
+ }
+}
+
+type itemString string
+
+func (i itemString) FilterValue() string { return string(i) }
+
+type Model struct {
+ state viewState
+ feedList list.Model
+ itemList list.Model
+ feeds []*feed.Feed
+ items []*item.Item
+ selectedFeed *feed.Feed
+ selectedItem *item.Item
+ err error
+ width int
+ height int
+}
+
+func NewModel() Model {
+ return Model{
+ state: viewFeeds,
+ }
+}
+
+func (m Model) Init() tea.Cmd {
+ return loadFeeds
+}
+
+type (
+ feedsMsg []*feed.Feed
+ itemsMsg []*item.Item
+ errMsg error
+)
+
+func loadFeeds() tea.Msg {
+ feeds, err := feed.All()
+ if err != nil {
+ return errMsg(err)
+ }
+ return feedsMsg(feeds)
+}
+
+func loadItems(feedID int64) tea.Cmd {
+ return func() tea.Msg {
+ items, err := item.Filter(0, feedID, "", false, false, 0, "")
+ if err != nil {
+ return errMsg(err)
+ }
+ return itemsMsg(items)
+ }
+}
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ m.feedList.SetSize(msg.Width, msg.Height-4)
+ m.itemList.SetSize(msg.Width, msg.Height-4)
+
+ case feedsMsg:
+ m.feeds = msg
+ items := make([]list.Item, len(m.feeds))
+ for i, f := range m.feeds {
+ items[i] = itemString(f.Title)
+ }
+ m.feedList = list.New(items, list.NewDefaultDelegate(), m.width, m.height-4)
+ m.feedList.Title = "Feeds"
+
+ case itemsMsg:
+ m.items = msg
+ items := make([]list.Item, len(m.items))
+ for i, it := range m.items {
+ items[i] = itemString(it.Title)
+ }
+ m.itemList = list.New(items, list.NewDefaultDelegate(), m.width, m.height-4)
+ m.itemList.Title = m.selectedFeed.Title
+ m.state = viewItems
+
+ case errMsg:
+ m.err = msg
+ return m, tea.Quit
+
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "ctrl+c", "q":
+ if m.state == viewFeeds {
+ return m, tea.Quit
+ }
+ if m.state == viewItems {
+ m.state = viewFeeds
+ } else {
+ m.state = viewItems
+ }
+
+ case "enter":
+ if m.state == viewFeeds {
+ idx := m.feedList.Index()
+ if idx >= 0 && idx < len(m.feeds) {
+ m.selectedFeed = m.feeds[idx]
+ return m, loadItems(m.selectedFeed.Id)
+ }
+ } else if m.state == viewItems {
+ idx := m.itemList.Index()
+ if idx >= 0 && idx < len(m.items) {
+ m.selectedItem = m.items[idx]
+ m.state = viewContent
+ }
+ }
+ }
+ }
+
+ if m.state == viewFeeds {
+ m.feedList, cmd = m.feedList.Update(msg)
+ } else if m.state == viewItems {
+ m.itemList, cmd = m.itemList.Update(msg)
+ }
+
+ return m, cmd
+}
+
+func (m Model) View() string {
+ if m.err != nil {
+ return fmt.Sprintf("Error: %v", m.err)
+ }
+
+ var s strings.Builder
+ s.WriteString(TitleStyle.Render("NEKO TUI") + "\n\n")
+
+ switch m.state {
+ case viewFeeds:
+ s.WriteString(m.feedList.View())
+ case viewItems:
+ s.WriteString(m.itemList.View())
+ case viewContent:
+ if m.selectedItem != nil {
+ s.WriteString(HeaderStyle.Render(m.selectedItem.Title) + "\n")
+ s.WriteString(ContentStyle.Render(m.selectedItem.Description) + "\n\n")
+ s.WriteString(StatusStyle.Render("Press 'q' or 'esc' to go back"))
+ }
+ }
+
+ return s.String()
+}
+
+func Run() error {
+ p := tea.NewProgram(NewModel(), tea.WithAltScreen())
+ if _, err := p.Run(); err != nil {
+ return err
+ }
+ return nil
+}