aboutsummaryrefslogtreecommitdiffstats
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
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%
-rw-r--r--.thicket/tickets.jsonl4
-rw-r--r--test_feeds.txt72
-rw-r--r--tui/tui.go44
-rw-r--r--tui/tui_test.go34
4 files changed, 131 insertions, 23 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl
index 6f62686..8df7045 100644
--- a/.thicket/tickets.jsonl
+++ b/.thicket/tickets.jsonl
@@ -1,5 +1,7 @@
{"id":"NK-1phdpf","title":"refactor backend to have a clean API","description":"create a nice clean API for the backend GO code that is more independent of the frontend\n\nensure that it is working with good tests","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T01:52:49.8322638Z","updated":"2026-02-13T04:26:47.517515371Z"}
+{"id":"NK-27or4b","title":"Increase Test Coverage to \u003e80%","description":"Project-wide test coverage is currently ~63%. Key gaps are in the new and packages, as well as some core model logic. Increase coverage to at least 80% to ensure stability.","type":"task","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-13T05:03:09.677147894Z","updated":"2026-02-13T05:03:09.677147894Z"}
{"id":"NK-6q9nyg","title":"Refactor HTTP-dependent functions for testability","description":"Several functions use http.Get or external libraries directly (GetFullContent uses goose, ResolveFeedURL uses http.Get + goquery, imageProxyHandler uses http.Client). Refactor these to accept interfaces for HTTP fetching so they can be unit tested with mocks. This is the primary blocker for reaching 90% coverage.","type":"task","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:37.630148644Z","updated":"2026-02-13T03:54:37.630148644Z"}
+{"id":"NK-7tzbql","title":"Fix TUI Content View Navigation and Interaction","description":"The TUI content view (reading a single item) is currently non-functional or severely limited. Users cannot easily navigate back, scroll, or interact with the content. This task involves improving the 'viewContent' state in the TUI.","type":"bug","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T05:02:57.382793121Z","updated":"2026-02-13T05:06:15.144485446Z"}
{"id":"NK-bsdwqz","title":"terminal UI","description":"once there is good test coverage and a clean backend API, work on a nice efficient TUI with https://github.com/charmbracelet/bubbletea","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T01:54:02.285738454Z","updated":"2026-02-13T04:42:09.824268427Z"}
{"id":"NK-ed1iah","title":"Make feed crawling async in API","description":"Currently, POST /api/feed triggers an immediate crawl which blocks the response (or at least keeps the goroutine alive). Refactor the crawling architecture to be truly async with a job queue or status updates, improving API responsiveness and reliability.","type":"task","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-13T04:26:55.908243985Z","updated":"2026-02-13T04:26:55.908243985Z"}
{"id":"NK-gqkh96","title":"Remaining test coverage gaps","description":"Cross-package test coverage is at 81.2%. The remaining untested functions are: GetFullContent (goose HTTP extraction), indexHandler/serveBoxedFile (rice.MustFindBox), Serve (starts HTTP server), main, util.init. To reach 90%, consider: (1) refactoring GetFullContent to accept an interface for HTTP fetching, (2) refactoring Serve to extract route setup into a testable function, (3) mocking rice.MustFindBox, (4) using feeds from https://trenchant.org/feeds.txt as static test fixtures for integration tests.","type":"task","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:30.298141982Z","updated":"2026-02-13T03:54:30.298141982Z"}
@@ -7,6 +9,8 @@
{"id":"NK-t0nmbj","title":"new web frontend","description":"The current frontend uses an old version of backbone and jquery. Let's \"deprecate\" it -- keep it arouond so we can test against it and use it, but let's be able to also serve and use a nice shiny new frontend written in either simiple, highly efficient vanilla javascript, or put together something in react or similar. Needs to feel fast and low latency!","type":"feature","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-13T02:01:37.2107893Z","updated":"2026-02-13T02:01:37.2107893Z"}
{"id":"NK-x924bu","title":"test coverage","description":"assume the code works properly (it mostly does)\nget to 90% test coverage on the go code","type":"task","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-13T01:52:01.042476226Z","updated":"2026-02-13T03:54:21.526519915Z"}
{"id":"NK-d0ghccy","from_ticket_id":"NK-ric1zs","to_ticket_id":"NK-1phdpf","type":"created_from","created":"2026-02-13T04:26:55.875394997Z"}
+{"id":"NK-d1uyy71","from_ticket_id":"NK-27or4b","to_ticket_id":"NK-bsdwqz","type":"created_from","created":"2026-02-13T05:03:09.689282214Z"}
{"id":"NK-d86tgcs","from_ticket_id":"NK-ed1iah","to_ticket_id":"NK-1phdpf","type":"created_from","created":"2026-02-13T04:26:55.917754798Z"}
{"id":"NK-dgfppki","from_ticket_id":"NK-gqkh96","to_ticket_id":"NK-x924bu","type":"created_from","created":"2026-02-13T03:54:30.303602703Z"}
+{"id":"NK-dlvmiyc","from_ticket_id":"NK-7tzbql","to_ticket_id":"NK-bsdwqz","type":"created_from","created":"2026-02-13T05:02:57.392616851Z"}
{"id":"NK-dwav3hh","from_ticket_id":"NK-6q9nyg","to_ticket_id":"NK-x924bu","type":"created_from","created":"2026-02-13T03:54:37.639569082Z"}
diff --git a/test_feeds.txt b/test_feeds.txt
new file mode 100644
index 0000000..942a12c
--- /dev/null
+++ b/test_feeds.txt
@@ -0,0 +1,72 @@
+ttps://a.wholelottanothing.org/feed/
+http://feeds.feedburner.com/eod_full
+https://www.analogue.co/feed/all
+http://torrez.org/feed.xml
+https://annas-blog.org/rss.xml
+http://ascii.textfiles.com/feed/atom
+https://begriffs.com/atom.xml
+http://ilovebenbrown.com/rss
+http://bitcannon.net/index.xml
+http://feeds.feedburner.com/bricoleur
+https://www.bump.net/feed/
+http://www.bytecellar.com/feed/
+http://www.antipope.org/charlie/blog-static/atom.xml
+https://computer.rip/rss.xml
+http://www.coryarcangel.com/feed/atom/
+http://forums.court-records.net/news/rss.php
+https://craigmod.com/index.xml
+https://danluu.com/atom.xml
+https://lemire.me/blog/feed/
+http://www.spinellis.gr/blog/dds-blog-rss.xml
+http://dreamandfriends.com/feed/
+http://sircmpwn.github.io/feed.xml
+http://everybodyslibraries.com/feed/
+https://benbrown.com/feed.xml
+http://www.tedunangst.com/flak/rss
+https://fronkonstin.com/feed/
+http://www.ftrain.com/xml/feed/rss.xml
+http://www.hardcoregaming101.net/feed/
+https://ln.hixie.ch/rss/html
+http://www.cringely.com/feed/
+https://writing.markchristian.org/feed.xml
+http://idlewords.com/index.xml
+http://imperialviolet.org/iv-rss.xml
+http://insertcredit.com/feed/
+http://interconnected.org/home/;rss2
+http://www.jessyoko.com/blog/feed/
+http://www.jnd.org/index.xml
+http://www.jonas-kyratzes.net/feed/
+https://jcs.org/rss/notaweblog/
+https://www.jwz.org/blog/feed/
+http://feeds.kottke.org/main
+http://www.timemachinego.com/linkmachinego/feed/
+http://lostlevels.org/wordpress/?feed=rss2
+http://motd.co/feed/
+https://nadia.xyz/feed.xml
+http://nerdlypleasures.blogspot.com/feeds/posts/default
+http://www.wadjeteyegames.com/category/news/feed
+http://nullprogram.com/feed/
+https://www.tbray.org/ongoing/ongoing.atom
+http://undeadly.org/cgi?action=rss
+http://blogs.law.harvard.edu/philg/xml/rss.xml
+https://ploum.net/atom_en.xml
+https://randomfoo.net/feed
+https://www.raphkoster.com/feed/
+http://ribbonblack.blogspot.com/rss.xml
+https://www.schneier.com/blog/atom.xml
+http://joeyh.name/blog/index.rss
+http://blog.fogus.me/feed/
+https://simonwillison.net/atom/everything/
+https://www.smoothterminal.com/feed
+https://www.sonyaellenmann.com/feed
+http://feeds2.feedburner.com/stevelosh
+https://www.filfre.net/feed/rss/
+https://obscuritory.com/feed/
+http://dtrace.org/blogs/bmc/feed/rss/
+http://www.trenchant.org/rss.xml
+http://chneukirchen.org/trivium/index.atom
+https://gamehistory.org/feed/
+https://virtuallyfun.com/feed/
+http://waxy.org/links/index.xml
+http://negativesmart.com/wp-rss2.php
+https://zed.dev/blog.rss
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")
+ }
}