diff --git a/internal/tui/model.go b/internal/tui/model.go index 6909df0..d5d04be 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -12,6 +12,7 @@ import ( ) const defaultViewWidth = 80 +const maxIRCMessageHistory = 50 type viewMode int @@ -20,6 +21,21 @@ const ( streamerView ) +type panelFocus int + +const ( + focusStreamers panelFocus = iota + focusInfo + focusChat +) + +type detailTab int + +const ( + infoTab detailTab = iota + ircTab +) + type modelRuntime interface { startAuth(chan<- AuthUpdate) startResolve(*auth.State, chan<- StreamerUpdate) @@ -40,13 +56,15 @@ type Model struct { width int height int selectedConfig string + focus panelFocus ircDetails map[string]ircDetail authViewport viewport.Model + ircViewport viewport.Model } type ircDetail struct { - joined bool - line string + joined bool + messages []string } // New returns a Bubble Tea model for auth and streamer display. @@ -65,13 +83,16 @@ func New(options Options) Model { ircDetails: make(map[string]ircDetail), width: defaultViewWidth, height: 24, + focus: focusStreamers, authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)), + ircViewport: newIRCViewport(detailViewportWidth(defaultViewWidth), detailViewportHeight(24)), } if options.AuthState != nil { model.mode = streamerView } model.ensureSelection() model.syncAuthViewport() + model.syncIRCViewport(true) return model } @@ -101,19 +122,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.resizeComponents() m.ensureSelection() + m.syncIRCViewport(false) return m, nil case tea.KeyMsg: switch msg.String() { + case "left": + if m.mode == streamerView { + m.moveFocusLeft() + } + return m, nil + case "right": + if m.mode == streamerView { + m.moveFocusRight() + } + return m, nil case "up", "k": - if m.mode == streamerView { + if m.mode == streamerView && m.focus == focusStreamers { m.moveSelection(-1) + return m, nil + } + if m.mode == streamerView && m.focus == focusInfo { + return m, nil } - return m, nil case "down", "j": - if m.mode == streamerView { + if m.mode == streamerView && m.focus == focusStreamers { m.moveSelection(1) + return m, nil + } + if m.mode == streamerView && m.focus == focusInfo { + return m, nil } - return m, nil case "ctrl+c", "esc", "q": return m, tea.Quit } @@ -124,6 +162,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.authViewport, cmd = m.authViewport.Update(msg) return m, cmd } + if m.focus == focusChat { + var cmd tea.Cmd + m.ircViewport, cmd = m.ircViewport.Update(msg) + return m, cmd + } return m, nil } @@ -147,8 +190,10 @@ func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) { m.streamers = loadingEntries(m.streamers) m.ircDetails = make(map[string]ircDetail) m.selectedConfig = "" + m.focus = focusStreamers m.ensureSelection() m.resizeComponents() + m.syncIRCViewport(true) return m, startStreamerResolution(m.runtime, m.authState) } return m, nil @@ -175,6 +220,7 @@ func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) { } m.ensureSelection() m.resizeComponents() + m.syncIRCViewport(false) if msg.Done { return m, nil } @@ -197,8 +243,11 @@ func (m *Model) applyIRCUpdate(update IRCUpdate) { case IRCPending, IRCDisconnected: detail.joined = false } - if update.Line != "" { - detail.line = update.Line + if message, ok := formatIRCChatLine(update.Line); ok { + detail.messages = append(detail.messages, message) + if len(detail.messages) > maxIRCMessageHistory { + detail.messages = append([]string(nil), detail.messages[len(detail.messages)-maxIRCMessageHistory:]...) + } } m.ircDetails[login] = detail } @@ -225,9 +274,11 @@ func (m *Model) moveSelection(delta int) { entries := m.orderedStreamers() if len(entries) == 0 { m.selectedConfig = "" + m.syncIRCViewport(true) return } + current := m.selectedConfig selected := m.selectedRowIndex(entries) + delta if selected < 0 { selected = 0 @@ -236,6 +287,27 @@ func (m *Model) moveSelection(delta int) { selected = len(entries) - 1 } m.selectedConfig = entries[selected].ConfigLogin + if m.selectedConfig != current { + m.syncIRCViewport(true) + } +} + +func (m *Model) moveFocusLeft() { + switch m.focus { + case focusInfo: + m.focus = focusStreamers + case focusChat: + m.focus = focusInfo + } +} + +func (m *Model) moveFocusRight() { + switch m.focus { + case focusStreamers: + m.focus = focusInfo + case focusInfo: + m.focus = focusChat + } } func (m Model) orderedStreamers() []twitch.StreamerEntry { @@ -269,6 +341,8 @@ func (m Model) selectedRowIndex(entries []twitch.StreamerEntry) int { func (m *Model) resizeComponents() { m.authViewport.Width = contentWidth(m.width) m.authViewport.Height = authViewportHeight(m.height) + m.ircViewport.Width = detailViewportWidth(m.width) + m.ircViewport.Height = detailViewportHeight(m.height) } func (m *Model) syncAuthViewport() { @@ -283,6 +357,61 @@ func (m Model) authLogContent() string { return strings.Join(m.authLogs, "\n") } +func (m *Model) syncIRCViewport(forceBottom bool) { + if m.ircViewport.Width <= 0 || m.ircViewport.Height <= 0 { + return + } + + stickToBottom := forceBottom || m.ircViewport.AtBottom() + m.ircViewport.SetContent(m.ircViewportContent()) + if stickToBottom { + m.ircViewport.GotoBottom() + } +} + +func (m Model) ircViewportContent() string { + entry, ok := m.selectedEntry() + if !ok { + return "No streamers configured" + } + if !isActive(entry) { + return "Chat becomes available when this streamer is live." + } + + detail := m.ircDetails[normalizeKey(entry.Login)] + if !detail.joined { + return "Connecting to Twitch IRC..." + } + if len(detail.messages) == 0 { + return "Waiting for chat messages..." + } + return strings.Join(detail.messages, "\n") +} + +func (m Model) selectedEntry() (twitch.StreamerEntry, bool) { + entries := m.orderedStreamers() + selected := m.selectedRowIndex(entries) + if selected < 0 || selected >= len(entries) { + return twitch.StreamerEntry{}, false + } + return entries[selected], true +} + +func (m Model) visibleDetailTab() detailTab { + if m.focus == focusChat { + return ircTab + } + return infoTab +} + +func (m Model) isStreamersFocused() bool { + return m.focus == focusStreamers +} + +func (m Model) isRightPanelFocused() bool { + return m.focus == focusInfo || m.focus == focusChat +} + func isActive(entry twitch.StreamerEntry) bool { return entry.Status == twitch.StreamerReady && entry.Live } @@ -298,6 +427,29 @@ func normalizeKey(value string) string { return strings.ToLower(strings.TrimSpace(value)) } +func formatIRCChatLine(line string) (string, bool) { + line = strings.TrimSpace(line) + if line == "" { + return "", false + } + + parts := strings.SplitN(line, " :", 2) + if len(parts) != 2 || !strings.Contains(parts[0], " PRIVMSG ") { + return "", false + } + + prefix := strings.TrimPrefix(parts[0], ":") + user, _, _ := strings.Cut(prefix, "!") + if user == "" { + user = "unknown" + } + message := strings.TrimSpace(parts[1]) + if message == "" { + return "", false + } + return user + ": " + message, true +} + func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry { logins := make([]string, 0, len(entries)) for _, entry := range entries { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 4cbcc9b..0a556f9 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "testing" @@ -34,12 +35,13 @@ func TestViewDisplaysDashboardWithSelectedStreamerDetails(t *testing.T) { assertContainsAll(t, model.View(), "Watching: alpha_live, beta_live", - "alpha_live", + "Info", + "IRC", "live | irc idle", "gamma", "loading", - "IRC Chat", - "not joined", + "Status: live", + "IRC: not joined", ) } @@ -182,7 +184,80 @@ func TestUpDownNavigationMovesSelectedStreamer(t *testing.T) { } } -func TestIRCUpdatesShowJoinedStatusOnly(t *testing.T) { +func TestFocusNavigationMovesBetweenPanels(t *testing.T) { + model := dashboardModel(twitch.StreamerEntry{ + ConfigLogin: "alpha", + Login: "alpha_live", + Live: true, + Status: twitch.StreamerReady, + }) + + if model.focus != focusStreamers { + t.Fatalf("initial focus = %v, want %v", model.focus, focusStreamers) + } + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRight}) + next := updated.(Model) + if next.focus != focusInfo || next.visibleDetailTab() != infoTab { + t.Fatalf("focus after first right = %v with tab %v, want %v and %v", next.focus, next.visibleDetailTab(), focusInfo, infoTab) + } + + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight}) + next = updated.(Model) + if next.focus != focusChat || next.visibleDetailTab() != ircTab { + t.Fatalf("focus after second right = %v with tab %v, want %v and %v", next.focus, next.visibleDetailTab(), focusChat, ircTab) + } + + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight}) + next = updated.(Model) + if next.focus != focusChat { + t.Fatalf("focus after right on chat = %v, want %v", next.focus, focusChat) + } + + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft}) + next = updated.(Model) + if next.focus != focusInfo { + t.Fatalf("focus after left from chat = %v, want %v", next.focus, focusInfo) + } + + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft}) + next = updated.(Model) + if next.focus != focusStreamers { + t.Fatalf("focus after left from info = %v, want %v", next.focus, focusStreamers) + } + + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft}) + next = updated.(Model) + if next.focus != focusStreamers { + t.Fatalf("focus after left on streamers = %v, want %v", next.focus, focusStreamers) + } +} + +func TestInfoFocusMakesUpDownNoop(t *testing.T) { + model := dashboardModel( + twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady}, + twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady}, + ) + model.focus = focusInfo + model.ircViewport.SetYOffset(0) + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown}) + next := updated.(Model) + if next.selectedConfig != "alpha" { + t.Fatalf("selectedConfig after down in info = %q, want alpha", next.selectedConfig) + } + if next.ircViewport.YOffset != 0 { + t.Fatalf("irc viewport offset after down in info = %d, want 0", next.ircViewport.YOffset) + } + + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyUp}) + next = updated.(Model) + if next.selectedConfig != "alpha" { + t.Fatalf("selectedConfig after up in info = %q, want alpha", next.selectedConfig) + } +} + +func TestIRCUpdatesShowJoinedStatusAndFormattedMessages(t *testing.T) { updated, _ := dashboardModel(twitch.StreamerEntry{ ConfigLogin: "alpha", Login: "alpha_live", @@ -194,7 +269,147 @@ func TestIRCUpdatesShowJoinedStatusOnly(t *testing.T) { if !next.ircDetails["alpha_live"].joined { t.Fatal("expected joined detail") } - assertContainsAll(t, next.View(), "joined") + + updated, _ = next.Update(StreamerUpdate{IRC: &IRCUpdate{ + Login: "alpha_live", + Line: ":someone!someone@someone.tmi.twitch.tv PRIVMSG #alpha_live :hello there", + }}) + next = updated.(Model) + next.focus = focusChat + next.syncIRCViewport(true) + + assertContainsAll(t, next.View(), "someone: hello there") +} + +func TestChatFocusUsesUpDownForViewportScroll(t *testing.T) { + model := dashboardModel( + twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady}, + twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady}, + ) + model.focus = focusChat + model.ircDetails["alpha_live"] = ircDetail{ + joined: true, + messages: numberedMessages(20), + } + model.syncIRCViewport(true) + model.ircViewport.GotoTop() + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown}) + next := updated.(Model) + if next.selectedConfig != "alpha" { + t.Fatalf("selectedConfig after down in chat = %q, want alpha", next.selectedConfig) + } + if next.ircViewport.YOffset == 0 { + t.Fatal("expected chat viewport to scroll down") + } + + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyUp}) + next = updated.(Model) + if next.ircViewport.YOffset != 0 { + t.Fatalf("irc viewport offset after up in chat = %d, want 0", next.ircViewport.YOffset) + } +} + +func TestIRCUpdatesKeepOnlyLast50ChatMessages(t *testing.T) { + model := dashboardModel(twitch.StreamerEntry{ + ConfigLogin: "alpha", + Login: "alpha_live", + Live: true, + Status: twitch.StreamerReady, + }) + + updated, _ := model.Update(StreamerUpdate{IRC: &IRCUpdate{Login: "alpha_live", State: IRCJoined}}) + next := updated.(Model) + for i := 1; i <= maxIRCMessageHistory+5; i++ { + line := fmt.Sprintf(":user%d!user%d@user%d.tmi.twitch.tv PRIVMSG #alpha_live :message %d", i, i, i, i) + updated, _ = next.Update(StreamerUpdate{IRC: &IRCUpdate{Login: "alpha_live", Line: line}}) + next = updated.(Model) + } + + detail := next.ircDetails["alpha_live"] + if len(detail.messages) != maxIRCMessageHistory { + t.Fatalf("message count = %d, want %d", len(detail.messages), maxIRCMessageHistory) + } + if detail.messages[0] != "user6: message 6" { + t.Fatalf("oldest retained message = %q, want %q", detail.messages[0], "user6: message 6") + } + if detail.messages[len(detail.messages)-1] != "user55: message 55" { + t.Fatalf("newest retained message = %q, want %q", detail.messages[len(detail.messages)-1], "user55: message 55") + } +} + +func TestIRCUpdatesIgnoreNonChatProtocolLines(t *testing.T) { + model := dashboardModel(twitch.StreamerEntry{ + ConfigLogin: "alpha", + Login: "alpha_live", + Live: true, + Status: twitch.StreamerReady, + }) + + updated, _ := model.Update(StreamerUpdate{IRC: &IRCUpdate{ + Login: "alpha_live", + Line: "Joined #alpha_live as viewer", + }}) + next := updated.(Model) + if len(next.ircDetails["alpha_live"].messages) != 0 { + t.Fatalf("messages = %#v, want empty", next.ircDetails["alpha_live"].messages) + } +} + +func TestIRCViewportAutoScrollsAtBottom(t *testing.T) { + model := dashboardModel(twitch.StreamerEntry{ + ConfigLogin: "alpha", + Login: "alpha_live", + Live: true, + Status: twitch.StreamerReady, + }) + model.focus = focusChat + model.ircDetails["alpha_live"] = ircDetail{ + joined: true, + messages: numberedMessages(20), + } + model.syncIRCViewport(true) + + if !model.ircViewport.AtBottom() { + t.Fatal("expected viewport to start at bottom") + } + + updated, _ := model.Update(StreamerUpdate{IRC: &IRCUpdate{ + Login: "alpha_live", + Line: ":late!late@late.tmi.twitch.tv PRIVMSG #alpha_live :newest", + }}) + next := updated.(Model) + if !next.ircViewport.AtBottom() { + t.Fatal("expected viewport to stay at bottom after new message") + } +} + +func TestIRCViewportPreservesManualScrollPosition(t *testing.T) { + model := dashboardModel(twitch.StreamerEntry{ + ConfigLogin: "alpha", + Login: "alpha_live", + Live: true, + Status: twitch.StreamerReady, + }) + model.focus = focusChat + model.ircDetails["alpha_live"] = ircDetail{ + joined: true, + messages: numberedMessages(20), + } + model.syncIRCViewport(true) + model.ircViewport.GotoTop() + + updated, _ := model.Update(StreamerUpdate{IRC: &IRCUpdate{ + Login: "alpha_live", + Line: ":late!late@late.tmi.twitch.tv PRIVMSG #alpha_live :newest", + }}) + next := updated.(Model) + if next.ircViewport.YOffset != 0 { + t.Fatalf("viewport YOffset = %d, want 0", next.ircViewport.YOffset) + } + if next.ircViewport.AtBottom() { + t.Fatal("expected viewport to remain off bottom after manual scroll") + } } func TestWindowSizeKeepsSelectionVisible(t *testing.T) { @@ -216,9 +431,19 @@ func dashboardModel(entries ...twitch.StreamerEntry) Model { model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"} model.width = 100 model.height = 28 + model.resizeComponents() + model.syncIRCViewport(true) return model } +func numberedMessages(count int) []string { + lines := make([]string, 0, count) + for i := 1; i <= count; i++ { + lines = append(lines, fmt.Sprintf("user%d: message %d", i, i)) + } + return lines +} + func assertContainsAll(t *testing.T, got string, wants ...string) { t.Helper() for _, want := range wants { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index f601d59..9321bfa 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -23,9 +23,18 @@ var ( Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("#30363D")). Padding(1, 2) + focusedPanelStyle = panelStyle. + BorderForeground(lipgloss.Color("#7CE38B")) labelStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#8B949E")). Bold(true) + activeTabStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#0D1117")). + Background(lipgloss.Color("#7CE38B")) + inactiveTabStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B949E")). + Background(lipgloss.Color("#1B1F24")) statusLiveStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#7CE38B")) statusIdleStyle = lipgloss.NewStyle(). @@ -52,3 +61,9 @@ func newAuthViewport(width, height int) viewport.Model { vp.Style = lipgloss.NewStyle() return vp } + +func newIRCViewport(width, height int) viewport.Model { + vp := viewport.New(width, height) + vp.Style = lipgloss.NewStyle() + return vp +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 3dc17e0..9a48b9d 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -32,11 +32,20 @@ func (m Model) renderAuthView() string { func (m Model) renderStreamerView() string { header := m.renderDashboardHeader() - left := panelStyle. + leftStyle := panelStyle + rightStyle := panelStyle + if m.isStreamersFocused() { + leftStyle = focusedPanelStyle + } + if m.isRightPanelFocused() { + rightStyle = focusedPanelStyle + } + + left := leftStyle. Width(streamerListWidth(m.width)). Height(panelHeight(m.height)). Render(labelStyle.Render("Streamers") + "\n" + m.renderStreamerRows()) - right := panelStyle. + right := rightStyle. Width(detailWidth(m.width)). Height(panelHeight(m.height)). Render(m.renderDetailPanel()) @@ -66,40 +75,63 @@ func (m Model) renderDashboardHeader() string { } func (m Model) renderDetailPanel() string { - entries := m.orderedStreamers() - selected := m.selectedRowIndex(entries) - if selected < 0 || selected >= len(entries) { - return labelStyle.Render("IRC Chat") + "\n\n" + mutedStyle.Render("No streamers configured") + header := m.renderDetailTabs() + entry, ok := m.selectedEntry() + if !ok { + return header + "\n\n" + mutedStyle.Render("No streamers configured") } - entry := entries[selected] + var body string + switch m.visibleDetailTab() { + case ircTab: + body = m.renderIRCTab() + default: + body = m.renderInfoTab(entry) + } + return header + "\n\n" + body +} + +func (m Model) renderDetailTabs() string { + tabs := []string{ + m.renderDetailTabButton(infoTab, "Info"), + m.renderDetailTabButton(ircTab, "IRC"), + } + return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) +} + +func (m Model) renderDetailTabButton(tab detailTab, label string) string { + if m.visibleDetailTab() == tab { + return activeTabStyle.Render(" " + label + " ") + } + return inactiveTabStyle.Render(" " + label + " ") +} + +func (m Model) renderInfoTab(entry twitch.StreamerEntry) string { lines := []string{ - labelStyle.Render("IRC Chat"), - "", - titleStyle.Render(streamerName(entry)), "Status: " + statusText(entry), } - if entry.Error != "" { - lines = append(lines, "Error: "+errorStyle.Render(entry.Error)) + if entry.ChannelID != "" { + lines = append(lines, "Channel ID: "+entry.ChannelID) } + detail := m.ircDetails[normalizeKey(entry.Login)] if !isActive(entry) { lines = append(lines, "IRC: "+mutedStyle.Render("inactive")) return strings.Join(lines, "\n") } - detail := m.ircDetails[normalizeKey(entry.Login)] if detail.joined { lines = append(lines, "IRC: "+accentStyle.Render("joined")) } else { lines = append(lines, "IRC: "+mutedStyle.Render("not joined")) } - if detail.line != "" { - lines = append(lines, "", mutedStyle.Render(detail.line)) - } return strings.Join(lines, "\n") } +func (m Model) renderIRCTab() string { + return m.ircViewport.View() +} + func (m Model) renderStreamerRows() string { entries := m.visibleStreamers() if len(entries) == 0 { @@ -224,6 +256,10 @@ func detailWidth(width int) int { return max(24, width-streamerListWidth(width)-11) } +func detailViewportWidth(width int) int { + return max(16, detailWidth(width)-4) +} + func streamerListHeight(height int) int { if height <= 0 { return 14 @@ -231,6 +267,13 @@ func streamerListHeight(height int) int { return max(4, height-10) } +func detailViewportHeight(height int) int { + if height <= 0 { + return 9 + } + return max(4, panelHeight(height)-6) +} + func panelHeight(height int) int { if height <= 0 { return 16