From 9aba8e8cbbf9d1e2a21562f6e5e4e4e5d2489050 Mon Sep 17 00:00:00 2001 From: thebanri Date: Mon, 5 Oct 2026 01:07:25 +0300 Subject: [PATCH] fix: next and prev in the article view follow the filtered list --- internal/commands/navigation_test.go | 137 +++++++++++++++++++++++++++ internal/commands/tui.go | 8 ++ internal/commands/viewport.go | 12 ++- 3 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 internal/commands/navigation_test.go diff --git a/internal/commands/navigation_test.go b/internal/commands/navigation_test.go new file mode 100644 index 0000000..e395710 --- /dev/null +++ b/internal/commands/navigation_test.go @@ -0,0 +1,137 @@ +package commands + +import ( + "testing" + "time" + + "github.com/charmbracelet/bubbles/cursor" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + "github.com/guyfedwards/nom/v2/internal/config" + "github.com/guyfedwards/nom/v2/internal/store" +) + +// navModel returns a model over three articles, set up the way Render does. +func navModel(t *testing.T, titles ...string) model { + t.Helper() + + s, err := store.NewInMemorySQLiteStore() + if err != nil { + t.Fatal(err) + } + for _, title := range titles { + if err := s.UpsertItem(&store.Item{Title: title, FeedURL: "https://example.com/feed", Link: "https://example.com/" + title, GUID: title}); err != nil { + t.Fatal(err) + } + } + items, err := s.GetAllItems("") + if err != nil { + t.Fatal(err) + } + + // Opening an article reloads the list, which drops items of feeds + // the config does not name. + cfg := &config.Config{Feeds: []config.Feed{{URL: "https://example.com/feed"}}} + l := list.New(convertItems(items), itemDelegate{theme: cfg.Theme}, 40, 20) + l.Filter = CustomFilter(*cfg) + ListKeyMap.SetOverrides(&l) + // Keep the commands the pump runs from sleeping: a blinking cursor + // waits half a second, a status message a second. + l.FilterInput.Cursor.SetMode(cursor.CursorStatic) + l.StatusMessageLifetime = 0 + + return model{ + cfg: cfg, + commands: New(cfg, s), + list: l, + help: help.New(), + viewport: viewport.New(78, 10), + } +} + +// press sends keys to the model as the program would, and runs the commands +// they return, feeding their messages back in, until none are left. +func press(t *testing.T, m model, keys ...tea.KeyMsg) model { + t.Helper() + for _, k := range keys { + next, cmd := m.Update(k) + m = pump(t, next.(model), cmd, 0) + } + return m +} + +func pump(t *testing.T, m model, cmd tea.Cmd, depth int) model { + t.Helper() + if cmd == nil || depth > 20 { + return m + } + done := make(chan tea.Msg, 1) + go func() { done <- cmd() }() + var msg tea.Msg + select { + case msg = <-done: + case <-time.After(2 * time.Second): + t.Fatal("a command did not return") + } + switch msg := msg.(type) { + case nil: + return m + case tea.BatchMsg: + for _, c := range msg { + m = pump(t, m, c, depth+1) + } + return m + } + next, cmd := m.Update(msg) + return pump(t, next.(model), cmd, depth+1) +} + +func keys(s string) []tea.KeyMsg { + var ks []tea.KeyMsg + for _, r := range s { + ks = append(ks, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + return ks +} + +var enter = tea.KeyMsg{Type: tea.KeyEnter} + +func openTitle(t *testing.T, m model) string { + t.Helper() + if m.selectedArticle == nil { + t.Fatal("no article open") + } + item, err := m.commands.store.GetItemByID(*m.selectedArticle) + if err != nil { + t.Fatal(err) + } + return item.Title +} + +func TestNextAndPrevFollowTheFilteredList(t *testing.T) { + m := navModel(t, "rust news", "golang one", "golang two") + + // Filter the way a user does, then open the first row. + m = press(t, m, keys("/golang")...) + m = press(t, m, enter) + if len(m.list.VisibleItems()) != 2 { + t.Fatalf("filter shows %d rows, want 2", len(m.list.VisibleItems())) + } + m = press(t, m, enter) + if got := openTitle(t, m); got != "golang one" { + t.Fatalf("opened %q, want the first filtered row, %q", got, "golang one") + } + + m = press(t, m, keys("l")...) + if got := openTitle(t, m); got != "golang two" { + t.Errorf("next opened %q, want %q", got, "golang two") + } + + m = press(t, m, keys("h")...) + if got := openTitle(t, m); got != "golang one" { + t.Errorf("prev opened %q, want %q", got, "golang one") + } +} diff --git a/internal/commands/tui.go b/internal/commands/tui.go index 19036b1..0b66996 100644 --- a/internal/commands/tui.go +++ b/internal/commands/tui.go @@ -74,6 +74,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.Height = msg.Height - footerHeight return m, nil + + case list.FilterMatchesMsg: + // Opening an article reloads the list, and a filtered list then + // refilters in the background. The matches belong to the list + // whichever view is showing, or next and prev find no rows. + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd } if m.selectedArticle != nil { diff --git a/internal/commands/viewport.go b/internal/commands/viewport.go index f84ced8..59f131b 100644 --- a/internal/commands/viewport.go +++ b/internal/commands/viewport.go @@ -31,7 +31,7 @@ func updateViewport(msg tea.Msg, m model) (tea.Model, tea.Cmd) { case key.Matches(msg, ViewportKeyMap.Escape): // reset cursor if last post is read and quit index := m.list.Index() - length := len(m.list.Items()) + length := len(m.list.VisibleItems()) if index >= length && length >= 1 { m.list.Select(index - 1) } @@ -75,7 +75,9 @@ func updateViewport(msg tea.Msg, m model) (tea.Model, tea.Cmd) { case key.Matches(msg, ViewportKeyMap.Prev): navIndex := m.getPrevIndex() - items := m.list.Items() + // Index counts the filtered rows when a filter is applied, so + // index the rows the list shows, not all of them. + items := m.list.VisibleItems() if m.isPrevOutOfBounds(navIndex) { return m, nil } @@ -99,7 +101,7 @@ func updateViewport(msg tea.Msg, m model) (tea.Model, tea.Cmd) { case key.Matches(msg, ViewportKeyMap.Next): navIndex := m.getNextIndex() - items := m.list.Items() + items := m.list.VisibleItems() if m.isNextOutOfBounds(navIndex, len(items)) { return m, nil } @@ -141,7 +143,7 @@ func updateViewport(msg tea.Msg, m model) (tea.Model, tea.Cmd) { } func (m *model) isPrevOutOfBounds(i int) bool { - if len(m.list.Items()) == 0 { + if len(m.list.VisibleItems()) == 0 { return true } return i < 0 @@ -180,7 +182,7 @@ func (m *model) getNextIndex() int { func (m *model) getPrevIndex() int { current := m.list.Index() - if m.commands.config.AutoRead && !m.commands.config.ShowRead && current < len(m.list.Items()) { + if m.commands.config.AutoRead && !m.commands.config.ShowRead && current < len(m.list.VisibleItems()) { return m.list.Index() }