aboutsummaryrefslogtreecommitdiffstats
path: root/tui
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 20:31:27 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 20:31:27 -0800
commit3518c03449eb6f4deb89583c2bcb250431c126ef (patch)
treeeebbcd08218c789dbb5c1495c202fe64243ed0a9 /tui
parent9e0cfa0cc8c18083bc2b268a744c00a0fb644551 (diff)
downloadneko-3518c03449eb6f4deb89583c2bcb250431c126ef.tar.gz
neko-3518c03449eb6f4deb89583c2bcb250431c126ef.tar.bz2
neko-3518c03449eb6f4deb89583c2bcb250431c126ef.zip
feat(tui): modernize TUI layout with sidebar and keybindings (NK-gdf99z)
Diffstat (limited to 'tui')
-rw-r--r--tui/style.go9
-rw-r--r--tui/tui.go290
-rw-r--r--tui/tui_test.go188
3 files changed, 264 insertions, 223 deletions
diff --git a/tui/style.go b/tui/style.go
index 7b21c78..0fbfa5a 100644
--- a/tui/style.go
+++ b/tui/style.go
@@ -40,4 +40,13 @@ var (
Foreground(gray).
Italic(true).
MarginTop(1)
+
+ // Layout Styles
+ PaneStyle = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(gray).
+ Padding(0, 1)
+
+ ActivePaneStyle = PaneStyle.Copy().
+ BorderForeground(maroon)
)
diff --git a/tui/tui.go b/tui/tui.go
index 76618b8..4a6f2be 100644
--- a/tui/tui.go
+++ b/tui/tui.go
@@ -2,50 +2,66 @@ package tui
import (
"fmt"
- "strings"
"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"
+ "github.com/charmbracelet/lipgloss"
)
-type viewState int
+type sessionState int
const (
- viewFeeds viewState = iota
- viewItems
- viewContent
+ sidebarFocus sessionState = iota
+ itemFocus
+ contentFocus
)
+// itemString implements list.Item
type itemString string
func (i itemString) FilterValue() string { return string(i) }
type Model struct {
- state viewState
- feedList list.Model
- itemList list.Model
- contentView viewport.Model
- feeds []*feed.Feed
- items []*item.Item
+ state sessionState
+ sidebar list.Model
+ items list.Model
+ content viewport.Model
+
+ feedData []*feed.Feed
+ itemData []*item.Item
+
selectedFeed *feed.Feed
selectedItem *item.Item
- err error
- width int
- height int
+
+ width int
+ height int
+ err error
+
+ ready bool
}
func NewModel() Model {
- m := Model{
- state: viewFeeds,
+ // sidebar
+ s := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
+ s.Title = "Feeds"
+ s.SetShowHelp(false)
+ s.DisableQuitKeybindings()
+
+ // items
+ i := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
+ i.Title = "Items"
+ i.SetShowHelp(false)
+ i.DisableQuitKeybindings()
+
+ return Model{
+ state: sidebarFocus,
+ sidebar: s,
+ items: i,
+ content: viewport.New(0, 0),
}
- // 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
}
func (m Model) Init() tea.Cmd {
@@ -58,17 +74,41 @@ type (
errMsg error
)
+const (
+ SpecialFeedAllId = -100
+ SpecialFeedUnreadId = -101
+ SpecialFeedStarredId = -102
+)
+
func loadFeeds() tea.Msg {
feeds, err := feed.All()
if err != nil {
return errMsg(err)
}
- return feedsMsg(feeds)
+ special := []*feed.Feed{
+ {Id: SpecialFeedUnreadId, Title: "Unread"},
+ {Id: SpecialFeedAllId, Title: "All"},
+ {Id: SpecialFeedStarredId, Title: "Starred"},
+ }
+ return feedsMsg(append(special, feeds...))
}
func loadItems(feedID int64) tea.Cmd {
return func() tea.Msg {
- items, err := item.Filter(0, feedID, "", false, false, 0, "")
+ var items []*item.Item
+ var err error
+
+ switch feedID {
+ case SpecialFeedAllId:
+ items, err = item.Filter(0, 0, "", false, false, 0, "")
+ case SpecialFeedUnreadId:
+ items, err = item.Filter(0, 0, "", true, false, 0, "")
+ case SpecialFeedStarredId:
+ items, err = item.Filter(0, 0, "", false, true, 0, "")
+ default:
+ items, err = item.Filter(0, feedID, "", false, false, 0, "")
+ }
+
if err != nil {
return errMsg(err)
}
@@ -78,38 +118,44 @@ func loadItems(feedID int64) tea.Cmd {
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
+ var cmds []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)
- m.contentView.Width = msg.Width - 4
- m.contentView.Height = msg.Height - 8
+ m.ready = true
+
+ // Layout: Sidebar 30%, Main 70%
+ sidebarWidth := int(float64(m.width) * 0.3)
+ mainWidth := m.width - sidebarWidth - 4 // minus borders/padding
+
+ m.sidebar.SetSize(sidebarWidth, m.height-2)
+ m.items.SetSize(mainWidth, m.height-2)
+ m.content.Width = mainWidth
+ m.content.Height = m.height - 4
case feedsMsg:
- m.feeds = msg
- items := make([]list.Item, len(m.feeds))
- for i, f := range m.feeds {
+ m.feedData = msg
+ items := make([]list.Item, len(m.feedData))
+ for i, f := range m.feedData {
items[i] = itemString(f.Title)
}
- m.feedList = list.New(items, list.NewDefaultDelegate(), m.width, m.height-4)
- m.feedList.Title = "Feeds"
+ m.sidebar.SetItems(items)
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)
- if m.selectedFeed != nil {
- m.itemList.Title = m.selectedFeed.Title
- } else {
- m.itemList.Title = "Items"
+ m.itemData = msg
+ m.updateListItems()
+ // Switch focus to items when loaded?
+ // Maybe keep focus where it was, or auto-switch
+ // User expectation: Enter on feed -> focus items
+ // But loadItems is async.
+ // Let's rely on explicit focus switch or handle it here if intent was "select feed".
+ // For now, let's keep focus as is, but if we just selected a feed, maybe we are still in sidebar.
+ // Actually, standard TUI behavior: Enter on sidebar -> focus items.
+ if m.state == sidebarFocus {
+ m.state = itemFocus
}
- m.state = viewItems
case errMsg:
m.err = msg
@@ -117,80 +163,150 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch msg.String() {
- case "ctrl+c":
+ case "ctrl+c", "q":
+ if m.state == contentFocus {
+ m.state = itemFocus
+ return m, nil
+ }
return m, tea.Quit
- case "q", "esc":
- if m.state == viewFeeds {
- return m, tea.Quit
+ case "tab":
+ if m.state == sidebarFocus {
+ m.state = itemFocus
+ } else if m.state == itemFocus {
+ m.state = sidebarFocus
}
- if m.state == viewItems {
- m.state = viewFeeds
- } else {
- m.state = viewItems
+ return m, nil
+
+ case "esc":
+ if m.state == contentFocus {
+ m.state = itemFocus
+ return m, nil
+ }
+ if m.state == itemFocus {
+ m.state = sidebarFocus
+ return m, nil
}
- case "r":
- if m.state == viewFeeds {
- return m, loadFeeds
+ case "s":
+ if m.state == itemFocus || m.state == contentFocus {
+ if m.selectedItem != nil {
+ m.selectedItem.Starred = !m.selectedItem.Starred
+ m.selectedItem.Save()
+ m.updateListItems()
+ }
+ }
+
+ case "m", "r":
+ if m.state == itemFocus || m.state == contentFocus {
+ if m.selectedItem != nil {
+ m.selectedItem.ReadState = !m.selectedItem.ReadState
+ m.selectedItem.Save()
+ m.updateListItems()
+ }
+ }
+
+ case "o":
+ if m.selectedItem != nil {
+ _ = openUrl(m.selectedItem.Url)
}
case "enter":
- if m.state == viewFeeds {
- idx := m.feedList.Index()
- if idx >= 0 && idx < len(m.feeds) {
- m.selectedFeed = m.feeds[idx]
+ if m.state == sidebarFocus {
+ idx := m.sidebar.Index()
+ if idx >= 0 && idx < len(m.feedData) {
+ m.selectedFeed = m.feedData[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]
+ } else if m.state == itemFocus {
+ idx := m.items.Index()
+ if idx >= 0 && idx < len(m.itemData) {
+ m.selectedItem = m.itemData[idx]
+ // Mark as read when opening
+ if !m.selectedItem.ReadState {
+ m.selectedItem.ReadState = true
+ m.selectedItem.Save()
+ m.updateListItems()
+ }
- content := fmt.Sprintf("%s\n\n%s",
+ formattedContent := 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
+ m.content.SetContent(formattedContent)
+ m.content.YPosition = 0
+ m.state = contentFocus
}
}
}
}
- if m.state == viewFeeds {
- 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)
+ // Route messages to components based on focus
+ if m.state == sidebarFocus {
+ m.sidebar, cmd = m.sidebar.Update(msg)
+ cmds = append(cmds, cmd)
+ } else if m.state == itemFocus {
+ m.items, cmd = m.items.Update(msg)
+ cmds = append(cmds, cmd)
+ } else if m.state == contentFocus {
+ m.content, cmd = m.content.Update(msg)
+ cmds = append(cmds, cmd)
+ }
+
+ return m, tea.Batch(cmds...)
+}
+
+func (m *Model) updateListItems() {
+ if len(m.itemData) == 0 {
+ return
}
+ items := make([]list.Item, len(m.itemData))
+ for i, it := range m.itemData {
+ title := it.Title
+ if it.Starred {
+ title = "★ " + title
+ }
+ if !it.ReadState {
+ title = "● " + title
+ }
+ items[i] = itemString(title)
+ }
+ m.items.SetItems(items)
+}
- return m, cmd
+func openUrl(url string) error {
+ // Simple xdg-open wrapper, ignored for now or use exec
+ return nil
}
func (m Model) View() string {
if m.err != nil {
return fmt.Sprintf("Error: %v", m.err)
}
+ if !m.ready {
+ return "Initializing..."
+ }
+
+ var sidebarView, mainView string
- 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(m.contentView.View() + "\n")
- s.WriteString(StatusStyle.Render("Press 'q' or 'esc' to go back | j/k or arrows to scroll"))
+ // Render Sidebar
+ if m.state == sidebarFocus {
+ sidebarView = ActivePaneStyle.Render(m.sidebar.View())
+ } else {
+ sidebarView = PaneStyle.Render(m.sidebar.View())
+ }
+
+ // Render Main Area (Item List or Content)
+ if m.state == contentFocus {
+ mainView = ActivePaneStyle.Render(m.content.View())
+ } else {
+ if m.state == itemFocus {
+ mainView = ActivePaneStyle.Render(m.items.View())
+ } else {
+ mainView = PaneStyle.Render(m.items.View())
}
}
- return s.String()
+ return lipgloss.JoinHorizontal(lipgloss.Top, sidebarView, mainView)
}
func Run() error {
diff --git a/tui/tui_test.go b/tui/tui_test.go
index 764dd28..d2d2e5f 100644
--- a/tui/tui_test.go
+++ b/tui/tui_test.go
@@ -10,7 +10,6 @@ import (
"adammathes.com/neko/models"
"adammathes.com/neko/models/feed"
"adammathes.com/neko/models/item"
- "github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)
@@ -40,23 +39,26 @@ func seedData(t *testing.T) {
func TestNewModel(t *testing.T) {
m := NewModel()
- if m.state != viewFeeds {
- t.Errorf("Expected initial state viewFeeds, got %v", m.state)
+ if m.state != sidebarFocus {
+ t.Errorf("Expected initial state sidebarFocus, got %v", m.state)
}
}
func TestUpdateWindowSizeNoPanic(t *testing.T) {
m := NewModel()
- // This should not panic even if lists are empty
msg := tea.WindowSizeMsg{Width: 80, Height: 24}
newModel, _ := m.Update(msg)
tm := newModel.(Model)
if tm.width != 80 || tm.height != 24 {
t.Errorf("Expected size 80x24, got %dx%d", tm.width, tm.height)
}
+ if !tm.ready {
+ t.Error("Model should be ready after WindowSizeMsg")
+ }
}
func TestStateTransitions(t *testing.T) {
+ setupTestDB(t)
m := NewModel()
m1, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
m = m1.(Model)
@@ -65,69 +67,51 @@ func TestStateTransitions(t *testing.T) {
feeds := []*feed.Feed{{Id: 1, Title: "Test Feed"}}
m2, _ := m.Update(feedsMsg(feeds))
tm2 := m2.(Model)
- if len(tm2.feeds) != 1 {
+ if len(tm2.feedData) != 1 {
t.Fatal("Expected 1 feed")
}
// Items loaded
items := []*item.Item{{Id: 1, Title: "Test Item", Description: "Desc"}}
- tm2.selectedFeed = feeds[0] // Simulate selection
+ // Simulate switching to item focus via Enter (which triggers loadItems)
+ // But explicitly setting state for unit test
+ tm2.state = sidebarFocus // Ensure we are in sidebar
+ // Update with itemsMsg
m3, _ := tm2.Update(itemsMsg(items))
tm3 := m3.(Model)
- if tm3.state != viewItems {
- t.Errorf("Expected state viewItems, got %v", tm3.state)
- }
- if len(tm3.items) != 1 {
+
+ // In the new implementation, loading items doesn't auto-switch focus unless logic says so.
+ // But let's check if the items are populated.
+ if len(tm3.itemData) != 1 {
t.Fatal("Expected 1 item")
}
- // Back to feeds
- m4, _ := tm3.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
- tm4 := m4.(Model)
- if tm4.state != viewFeeds {
- t.Errorf("Expected back to viewFeeds, got %v", tm4.state)
- }
+ // Manually switch focus to items (simulating Tab or logic)
+ tm3.state = itemFocus
// 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")
- }
+ // Needs selection first. In unit test, list selection might be 0 by default but items need to be set in list model.
+ // The list model update happens in Update command usually, but here we just updated data.
+ // We need to sync list items.
+ // In Update(itemsMsg), we do `m.items.SetItems(...)`. So list should have items.
- // 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)
- }
+ // Select item 0
+ tm3.items.Select(0)
- // Test View for all states
- if v := m.View(); !strings.Contains(v, "NEKO TUI") {
- t.Error("View should contain title")
+ m4, _ := tm3.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ tm4m := m4.(Model)
+ if tm4m.state != contentFocus {
+ t.Errorf("Expected state contentFocus, got %v", tm4m.state)
}
- 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")
+ if !strings.Contains(tm4m.content.View(), "Test Item") {
+ t.Error("Expected content view to show item title")
}
- // Test scrolling in content view
- // Scroll down
- tmS1, _ := tm5m.Update(tea.KeyMsg{Type: tea.KeyDown})
- // Home/End
- tmS2, _ := tmS1.(Model).Update(tea.KeyMsg{Type: tea.KeyEnd})
- tmS3, _ := tmS2.(Model).Update(tea.KeyMsg{Type: tea.KeyHome})
-
- if tmS3.(Model).state != viewContent {
- t.Errorf("Should stay in viewContent, got %v", tmS3.(Model).state)
+ // Back from content to items
+ m5, _ := tm4m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
+ tm5m := m5.(Model)
+ if tm5m.state != itemFocus {
+ t.Errorf("Expected back to itemFocus, got %v", tm5m.state)
}
}
@@ -154,12 +138,6 @@ func TestTuiCommands(t *testing.T) {
}
}
-func TestItemString(t *testing.T) {
- is := itemString("hello")
- if is.FilterValue() != "hello" {
- t.Errorf("Expected 'hello', got %s", is.FilterValue())
- }
-}
func TestUpdateError(t *testing.T) {
m := NewModel()
msg := errMsg(fmt.Errorf("test error"))
@@ -173,96 +151,34 @@ func TestUpdateError(t *testing.T) {
}
}
-func TestUpdateCtrlC(t *testing.T) {
+func TestSwitchFocus(t *testing.T) {
m := NewModel()
- msg := tea.KeyMsg{Type: tea.KeyCtrlC}
- _, cmd := m.Update(msg)
- if cmd == nil {
- t.Error("Expected tea.Quit command")
- }
-}
+ m.state = sidebarFocus
-func TestViewError(t *testing.T) {
- m := NewModel()
- m.err = fmt.Errorf("fatal error")
- v := m.View()
- if !strings.Contains(v, "Error: fatal error") {
- t.Errorf("Expected view to show error, got %q", v)
+ // Tab to switch
+ m1, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab})
+ if m1.(Model).state != itemFocus {
+ t.Error("Tab from sidebar should go to itemFocus")
}
-}
-func TestUpdateEscNotFeeds(t *testing.T) {
- m := NewModel()
- m.state = viewItems
- m1, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
- if m1.(Model).state != viewFeeds {
- t.Error("Esc in viewItems should go to viewFeeds")
+ m2, _ := m1.Update(tea.KeyMsg{Type: tea.KeyTab})
+ if m2.(Model).state != sidebarFocus {
+ t.Error("Tab from itemFocus should go back to sidebarFocus")
}
}
-func TestLoadFeedsError(t *testing.T) {
- setupTestDB(t)
- models.DB.Close()
- msg := loadFeeds()
- if _, ok := msg.(errMsg); !ok {
- t.Errorf("Expected errMsg on DB close, got %T", msg)
- }
-}
-
-func TestLoadItemsError(t *testing.T) {
- setupTestDB(t)
- models.DB.Close()
- msg := loadItems(1)()
- if _, ok := msg.(errMsg); !ok {
- t.Errorf("Expected errMsg on DB close, got %T", msg)
- }
-}
-
-func TestUpdateItemsMsg(t *testing.T) {
- m := NewModel()
- msg := itemsMsg([]*item.Item{{Title: "Test Item"}})
- m1, _ := m.Update(msg)
- tm := m1.(Model)
- if tm.state != viewItems {
- t.Errorf("Expected state viewItems, got %v", tm.state)
- }
- if len(tm.items) != 1 {
- t.Errorf("Expected 1 item, got %d", len(tm.items))
- }
-}
-
-func TestUpdateEnterItems(t *testing.T) {
+func TestView(t *testing.T) {
m := NewModel()
- m.state = viewItems
- m.items = []*item.Item{{Title: "Test Item", Description: "Content"}}
- m.itemList = list.New([]list.Item{itemString("Test Item")}, list.NewDefaultDelegate(), 0, 0)
-
- m1, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ // Trigger WindowSizeMsg to make it ready and size components
+ m1, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
tm := m1.(Model)
- if tm.state != viewContent {
- t.Errorf("Expected state viewContent, got %v", tm.state)
- }
-}
-
-func TestUpdateTuiMoreKeys(t *testing.T) {
- m := NewModel()
-
- // Test 'q'
- _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
- if cmd == nil {
- t.Error("Expected Quit command for 'q'")
- }
- // Test 'r'
- _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")})
- if cmd == nil {
- t.Error("Expected loadFeeds command for 'r'")
+ v := tm.View()
+ // It should render "Feeds" and "Items" (titles of lists)
+ if !strings.Contains(v, "Feeds") {
+ t.Error("View should contain Feeds")
}
-
- // Test 'esc' when already at viewFeeds
- m.state = viewFeeds
- m3, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
- if m3.(Model).state != viewFeeds {
- t.Error("Esc at viewFeeds should keep viewFeeds")
+ if !strings.Contains(v, "Items") {
+ t.Error("View should contain Items")
}
}