aboutsummaryrefslogtreecommitdiffstats
path: root/tui
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 22:14:30 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 22:14:30 -0800
commit47c43577ead8721008b858232511b2f65e0ed574 (patch)
treea15d1a4a4bb64b4b7d3de650b306835d8d7f63a5 /tui
parent76cb9c2a39d477a64824a985ade40507e3bbade1 (diff)
downloadneko-47c43577ead8721008b858232511b2f65e0ed574.tar.gz
neko-47c43577ead8721008b858232511b2f65e0ed574.tar.bz2
neko-47c43577ead8721008b858232511b2f65e0ed574.zip
Optimize asset packaging: move UI assets to root dist/ and decouple rice embedding
Diffstat (limited to 'tui')
-rw-r--r--tui/style.go52
-rw-r--r--tui/tui.go318
-rw-r--r--tui/tui_test.go184
3 files changed, 0 insertions, 554 deletions
diff --git a/tui/style.go b/tui/style.go
deleted file mode 100644
index 0fbfa5a..0000000
--- a/tui/style.go
+++ /dev/null
@@ -1,52 +0,0 @@
-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)
-
- // 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
deleted file mode 100644
index 4a6f2be..0000000
--- a/tui/tui.go
+++ /dev/null
@@ -1,318 +0,0 @@
-package tui
-
-import (
- "fmt"
-
- "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 sessionState int
-
-const (
- sidebarFocus sessionState = iota
- itemFocus
- contentFocus
-)
-
-// itemString implements list.Item
-type itemString string
-
-func (i itemString) FilterValue() string { return string(i) }
-
-type Model struct {
- state sessionState
- sidebar list.Model
- items list.Model
- content viewport.Model
-
- feedData []*feed.Feed
- itemData []*item.Item
-
- selectedFeed *feed.Feed
- selectedItem *item.Item
-
- width int
- height int
- err error
-
- ready bool
-}
-
-func NewModel() Model {
- // 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),
- }
-}
-
-func (m Model) Init() tea.Cmd {
- return loadFeeds
-}
-
-type (
- feedsMsg []*feed.Feed
- itemsMsg []*item.Item
- errMsg error
-)
-
-const (
- SpecialFeedAllId = -100
- SpecialFeedUnreadId = -101
- SpecialFeedStarredId = -102
-)
-
-func loadFeeds() tea.Msg {
- feeds, err := feed.All()
- if err != nil {
- return errMsg(err)
- }
- 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 {
- 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)
- }
- return itemsMsg(items)
- }
-}
-
-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.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.feedData = msg
- items := make([]list.Item, len(m.feedData))
- for i, f := range m.feedData {
- items[i] = itemString(f.Title)
- }
- m.sidebar.SetItems(items)
-
- case itemsMsg:
- 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
- }
-
- case errMsg:
- m.err = msg
- return m, tea.Quit
-
- case tea.KeyMsg:
- switch msg.String() {
- case "ctrl+c", "q":
- if m.state == contentFocus {
- m.state = itemFocus
- return m, nil
- }
- return m, tea.Quit
-
- case "tab":
- if m.state == sidebarFocus {
- m.state = itemFocus
- } else if m.state == itemFocus {
- m.state = sidebarFocus
- }
- 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 "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 == 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 == 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()
- }
-
- formattedContent := fmt.Sprintf("%s\n\n%s",
- HeaderStyle.Render(m.selectedItem.Title),
- m.selectedItem.Description)
- m.content.SetContent(formattedContent)
- m.content.YPosition = 0
- m.state = contentFocus
- }
- }
- }
- }
-
- // 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)
-}
-
-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
-
- // 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 lipgloss.JoinHorizontal(lipgloss.Top, sidebarView, mainView)
-}
-
-func Run() error {
- p := tea.NewProgram(NewModel(), tea.WithAltScreen())
- if _, err := p.Run(); err != nil {
- return err
- }
- return nil
-}
diff --git a/tui/tui_test.go b/tui/tui_test.go
deleted file mode 100644
index d2d2e5f..0000000
--- a/tui/tui_test.go
+++ /dev/null
@@ -1,184 +0,0 @@
-package tui
-
-import (
- "fmt"
- "path/filepath"
- "strings"
- "testing"
-
- "adammathes.com/neko/config"
- "adammathes.com/neko/models"
- "adammathes.com/neko/models/feed"
- "adammathes.com/neko/models/item"
- tea "github.com/charmbracelet/bubbletea"
-)
-
-func setupTestDB(t *testing.T) {
- t.Helper()
- config.Config.DBFile = filepath.Join(t.TempDir(), "test.db")
- models.InitDB()
- t.Cleanup(func() {
- if models.DB != nil {
- models.DB.Close()
- }
- })
-}
-
-func seedData(t *testing.T) {
- t.Helper()
- f := &feed.Feed{Url: "http://example.com", Title: "Test Feed", Category: "tech"}
- f.Create()
-
- i := &item.Item{
- Title: "Test Item",
- Url: "http://example.com/1",
- FeedId: f.Id,
- }
- i.Create()
-}
-
-func TestNewModel(t *testing.T) {
- m := NewModel()
- if m.state != sidebarFocus {
- t.Errorf("Expected initial state sidebarFocus, got %v", m.state)
- }
-}
-
-func TestUpdateWindowSizeNoPanic(t *testing.T) {
- m := NewModel()
- 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)
-
- // Feed loaded
- feeds := []*feed.Feed{{Id: 1, Title: "Test Feed"}}
- m2, _ := m.Update(feedsMsg(feeds))
- tm2 := m2.(Model)
- if len(tm2.feedData) != 1 {
- t.Fatal("Expected 1 feed")
- }
-
- // Items loaded
- items := []*item.Item{{Id: 1, Title: "Test Item", Description: "Desc"}}
- // 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)
-
- // 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")
- }
-
- // Manually switch focus to items (simulating Tab or logic)
- tm3.state = itemFocus
-
- // Test entering content view
- // 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.
-
- // Select item 0
- tm3.items.Select(0)
-
- 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 !strings.Contains(tm4m.content.View(), "Test Item") {
- t.Error("Expected content view to show item title")
- }
-
- // 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)
- }
-}
-
-func TestTuiCommands(t *testing.T) {
- setupTestDB(t)
- seedData(t)
-
- m := NewModel()
- cmd := m.Init()
- if cmd == nil {
- t.Fatal("Init should return a command")
- }
-
- msg := loadFeeds()
- feeds, ok := msg.(feedsMsg)
- if !ok || len(feeds) == 0 {
- t.Errorf("loadFeeds should return feedsMsg, got %T", msg)
- }
-
- msg2 := loadItems(feeds[0].Id)()
- _, ok = msg2.(itemsMsg)
- if !ok {
- t.Errorf("loadItems should return itemsMsg, got %T", msg2)
- }
-}
-
-func TestUpdateError(t *testing.T) {
- m := NewModel()
- msg := errMsg(fmt.Errorf("test error"))
- newModel, cmd := m.Update(msg)
- tm := newModel.(Model)
- if tm.err == nil {
- t.Error("Expected error to be set in model")
- }
- if cmd == nil {
- t.Error("Expected tea.Quit command (non-nil)")
- }
-}
-
-func TestSwitchFocus(t *testing.T) {
- m := NewModel()
- m.state = sidebarFocus
-
- // 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")
- }
-
- m2, _ := m1.Update(tea.KeyMsg{Type: tea.KeyTab})
- if m2.(Model).state != sidebarFocus {
- t.Error("Tab from itemFocus should go back to sidebarFocus")
- }
-}
-
-func TestView(t *testing.T) {
- m := NewModel()
- // Trigger WindowSizeMsg to make it ready and size components
- m1, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
- tm := m1.(Model)
-
- v := tm.View()
- // It should render "Feeds" and "Items" (titles of lists)
- if !strings.Contains(v, "Feeds") {
- t.Error("View should contain Feeds")
- }
- if !strings.Contains(v, "Items") {
- t.Error("View should contain Items")
- }
-}