diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-13 22:14:30 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-13 22:14:30 -0800 |
| commit | 47c43577ead8721008b858232511b2f65e0ed574 (patch) | |
| tree | a15d1a4a4bb64b4b7d3de650b306835d8d7f63a5 /tui | |
| parent | 76cb9c2a39d477a64824a985ade40507e3bbade1 (diff) | |
| download | neko-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.go | 52 | ||||
| -rw-r--r-- | tui/tui.go | 318 | ||||
| -rw-r--r-- | tui/tui_test.go | 184 |
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") - } -} |
