feat: add focused tui navigation and irc scrolling

This commit is contained in:
2026-04-29 23:12:32 +02:00
parent b631d201b0
commit 61e140fb03
4 changed files with 464 additions and 29 deletions
+159 -7
View File
@@ -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
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
}
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
}
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 {
+230 -5
View File
@@ -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 {
+15
View File
@@ -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
}
+59 -16
View File
@@ -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