aboutsummaryrefslogtreecommitdiffstats
path: root/tui
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-12 21:06:15 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-12 21:06:15 -0800
commit8a8f516ebd1115eed6256cd1b60be6393fd42c26 (patch)
treee4fa77e29897d5f22f425464a9173bd3506449d3 /tui
parentcfd583e9166330f053fa7cc28258b4467c48459c (diff)
downloadneko-8a8f516ebd1115eed6256cd1b60be6393fd42c26.tar.gz
neko-8a8f516ebd1115eed6256cd1b60be6393fd42c26.tar.bz2
neko-8a8f516ebd1115eed6256cd1b60be6393fd42c26.zip
Fix TUI content view navigation and interaction
- Integrated viewport.Model for scrollable content view - Fixed 'q' and 'esc' navigation from content view - Added unit tests for content state transitions and rendering - Cleaned up unused TUI delegate code - Increased TUI package coverage to ~70%
Diffstat (limited to 'tui')
-rw-r--r--tui/tui.go44
-rw-r--r--tui/tui_test.go34
2 files changed, 55 insertions, 23 deletions
diff --git a/tui/tui.go b/tui/tui.go
index 7597292..c27a68f 100644
--- a/tui/tui.go
+++ b/tui/tui.go
@@ -7,6 +7,7 @@ import (
"adammathes.com/neko/models/feed"
"adammathes.com/neko/models/item"
"github.com/charmbracelet/bubbles/list"
+ "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
@@ -18,24 +19,6 @@ const (
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) }
@@ -44,6 +27,7 @@ type Model struct {
state viewState
feedList list.Model
itemList list.Model
+ contentView viewport.Model
feeds []*feed.Feed
items []*item.Item
selectedFeed *feed.Feed
@@ -57,9 +41,10 @@ func NewModel() Model {
m := Model{
state: viewFeeds,
}
- // Initialize lists with empty items to avoid nil dereference in SetSize
+ // Initialize lists and viewport with empty items to avoid nil dereference
m.feedList = list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
m.itemList = list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
+ m.contentView = viewport.New(0, 0)
return m
}
@@ -100,6 +85,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
m.feedList.SetSize(msg.Width, msg.Height-4)
m.itemList.SetSize(msg.Width, msg.Height-4)
+ m.contentView.Width = msg.Width - 4
+ m.contentView.Height = msg.Height - 8
case feedsMsg:
m.feeds = msg
@@ -130,7 +117,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch msg.String() {
- case "ctrl+c", "q":
+ case "ctrl+c":
+ return m, tea.Quit
+
+ case "q", "esc":
if m.state == viewFeeds {
return m, tea.Quit
}
@@ -151,6 +141,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
idx := m.itemList.Index()
if idx >= 0 && idx < len(m.items) {
m.selectedItem = m.items[idx]
+
+ content := fmt.Sprintf("%s\n\n%s",
+ HeaderStyle.Render(m.selectedItem.Title),
+ m.selectedItem.Description)
+
+ m.contentView.SetContent(content)
+ m.contentView.YPosition = 0
m.state = viewContent
}
}
@@ -161,6 +158,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.feedList, cmd = m.feedList.Update(msg)
} else if m.state == viewItems {
m.itemList, cmd = m.itemList.Update(msg)
+ } else if m.state == viewContent {
+ m.contentView, cmd = m.contentView.Update(msg)
}
return m, cmd
@@ -181,9 +180,8 @@ func (m Model) View() string {
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"))
+ s.WriteString(m.contentView.View() + "\n")
+ s.WriteString(StatusStyle.Render("Press 'q' or 'esc' to go back | j/k or arrows to scroll"))
}
}
diff --git a/tui/tui_test.go b/tui/tui_test.go
index 6ae8d73..de9caf0 100644
--- a/tui/tui_test.go
+++ b/tui/tui_test.go
@@ -1,6 +1,7 @@
package tui
import (
+ "strings"
"testing"
"adammathes.com/neko/models/feed"
@@ -28,6 +29,8 @@ func TestUpdateWindowSizeNoPanic(t *testing.T) {
func TestStateTransitions(t *testing.T) {
m := NewModel()
+ m1, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ m = m1.(Model)
// Feed loaded
feeds := []*feed.Feed{{Id: 1, Title: "Test Feed"}}
@@ -55,4 +58,35 @@ func TestStateTransitions(t *testing.T) {
if tm4.state != viewFeeds {
t.Errorf("Expected back to viewFeeds, got %v", tm4.state)
}
+
+ // Test entering content view
+ tm5, _ := tm3.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ tm5m := tm5.(Model)
+ if tm5m.state != viewContent {
+ t.Errorf("Expected state viewContent, got %v", tm5m.state)
+ }
+ if !strings.Contains(tm5m.contentView.View(), "Test Item") {
+ t.Error("Expected content view to show item title")
+ }
+
+ // Back from content to items
+ tm6, _ := tm5.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
+ tm6m := tm6.(Model)
+ if tm6m.state != viewItems {
+ t.Errorf("Expected back to viewItems, got %v", tm6m.state)
+ }
+
+ // Test View for all states
+ if v := m.View(); !strings.Contains(v, "NEKO TUI") {
+ t.Error("View should contain title")
+ }
+ if v := tm2.View(); !strings.Contains(v, "Feeds") {
+ t.Error("View should contain Feeds list")
+ }
+ if v := tm3.View(); !strings.Contains(v, "Items") && !strings.Contains(v, "Test Feed") {
+ t.Error("View should contain Items list or Feed title")
+ }
+ if v := tm5m.View(); !strings.Contains(v, "Test Item") {
+ t.Error("View should contain Item title in content view")
+ }
}