feat: add focused tui navigation and irc scrolling
This commit is contained in:
+160
-8
@@ -12,6 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const defaultViewWidth = 80
|
const defaultViewWidth = 80
|
||||||
|
const maxIRCMessageHistory = 50
|
||||||
|
|
||||||
type viewMode int
|
type viewMode int
|
||||||
|
|
||||||
@@ -20,6 +21,21 @@ const (
|
|||||||
streamerView
|
streamerView
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type panelFocus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
focusStreamers panelFocus = iota
|
||||||
|
focusInfo
|
||||||
|
focusChat
|
||||||
|
)
|
||||||
|
|
||||||
|
type detailTab int
|
||||||
|
|
||||||
|
const (
|
||||||
|
infoTab detailTab = iota
|
||||||
|
ircTab
|
||||||
|
)
|
||||||
|
|
||||||
type modelRuntime interface {
|
type modelRuntime interface {
|
||||||
startAuth(chan<- AuthUpdate)
|
startAuth(chan<- AuthUpdate)
|
||||||
startResolve(*auth.State, chan<- StreamerUpdate)
|
startResolve(*auth.State, chan<- StreamerUpdate)
|
||||||
@@ -40,13 +56,15 @@ type Model struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
selectedConfig string
|
selectedConfig string
|
||||||
|
focus panelFocus
|
||||||
ircDetails map[string]ircDetail
|
ircDetails map[string]ircDetail
|
||||||
authViewport viewport.Model
|
authViewport viewport.Model
|
||||||
|
ircViewport viewport.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
type ircDetail struct {
|
type ircDetail struct {
|
||||||
joined bool
|
joined bool
|
||||||
line string
|
messages []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Bubble Tea model for auth and streamer display.
|
// 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),
|
ircDetails: make(map[string]ircDetail),
|
||||||
width: defaultViewWidth,
|
width: defaultViewWidth,
|
||||||
height: 24,
|
height: 24,
|
||||||
|
focus: focusStreamers,
|
||||||
authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)),
|
authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)),
|
||||||
|
ircViewport: newIRCViewport(detailViewportWidth(defaultViewWidth), detailViewportHeight(24)),
|
||||||
}
|
}
|
||||||
if options.AuthState != nil {
|
if options.AuthState != nil {
|
||||||
model.mode = streamerView
|
model.mode = streamerView
|
||||||
}
|
}
|
||||||
model.ensureSelection()
|
model.ensureSelection()
|
||||||
model.syncAuthViewport()
|
model.syncAuthViewport()
|
||||||
|
model.syncIRCViewport(true)
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,19 +122,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
m.resizeComponents()
|
m.resizeComponents()
|
||||||
m.ensureSelection()
|
m.ensureSelection()
|
||||||
|
m.syncIRCViewport(false)
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
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":
|
case "up", "k":
|
||||||
if m.mode == streamerView {
|
if m.mode == streamerView && m.focus == focusStreamers {
|
||||||
m.moveSelection(-1)
|
m.moveSelection(-1)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.mode == streamerView && m.focus == focusInfo {
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, nil
|
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
if m.mode == streamerView {
|
if m.mode == streamerView && m.focus == focusStreamers {
|
||||||
m.moveSelection(1)
|
m.moveSelection(1)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.mode == streamerView && m.focus == focusInfo {
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, nil
|
|
||||||
case "ctrl+c", "esc", "q":
|
case "ctrl+c", "esc", "q":
|
||||||
return m, tea.Quit
|
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)
|
m.authViewport, cmd = m.authViewport.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
if m.focus == focusChat {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.ircViewport, cmd = m.ircViewport.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +190,10 @@ func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) {
|
|||||||
m.streamers = loadingEntries(m.streamers)
|
m.streamers = loadingEntries(m.streamers)
|
||||||
m.ircDetails = make(map[string]ircDetail)
|
m.ircDetails = make(map[string]ircDetail)
|
||||||
m.selectedConfig = ""
|
m.selectedConfig = ""
|
||||||
|
m.focus = focusStreamers
|
||||||
m.ensureSelection()
|
m.ensureSelection()
|
||||||
m.resizeComponents()
|
m.resizeComponents()
|
||||||
|
m.syncIRCViewport(true)
|
||||||
return m, startStreamerResolution(m.runtime, m.authState)
|
return m, startStreamerResolution(m.runtime, m.authState)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -175,6 +220,7 @@ func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.ensureSelection()
|
m.ensureSelection()
|
||||||
m.resizeComponents()
|
m.resizeComponents()
|
||||||
|
m.syncIRCViewport(false)
|
||||||
if msg.Done {
|
if msg.Done {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -197,8 +243,11 @@ func (m *Model) applyIRCUpdate(update IRCUpdate) {
|
|||||||
case IRCPending, IRCDisconnected:
|
case IRCPending, IRCDisconnected:
|
||||||
detail.joined = false
|
detail.joined = false
|
||||||
}
|
}
|
||||||
if update.Line != "" {
|
if message, ok := formatIRCChatLine(update.Line); ok {
|
||||||
detail.line = update.Line
|
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
|
m.ircDetails[login] = detail
|
||||||
}
|
}
|
||||||
@@ -225,9 +274,11 @@ func (m *Model) moveSelection(delta int) {
|
|||||||
entries := m.orderedStreamers()
|
entries := m.orderedStreamers()
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
m.selectedConfig = ""
|
m.selectedConfig = ""
|
||||||
|
m.syncIRCViewport(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
current := m.selectedConfig
|
||||||
selected := m.selectedRowIndex(entries) + delta
|
selected := m.selectedRowIndex(entries) + delta
|
||||||
if selected < 0 {
|
if selected < 0 {
|
||||||
selected = 0
|
selected = 0
|
||||||
@@ -236,6 +287,27 @@ func (m *Model) moveSelection(delta int) {
|
|||||||
selected = len(entries) - 1
|
selected = len(entries) - 1
|
||||||
}
|
}
|
||||||
m.selectedConfig = entries[selected].ConfigLogin
|
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 {
|
func (m Model) orderedStreamers() []twitch.StreamerEntry {
|
||||||
@@ -269,6 +341,8 @@ func (m Model) selectedRowIndex(entries []twitch.StreamerEntry) int {
|
|||||||
func (m *Model) resizeComponents() {
|
func (m *Model) resizeComponents() {
|
||||||
m.authViewport.Width = contentWidth(m.width)
|
m.authViewport.Width = contentWidth(m.width)
|
||||||
m.authViewport.Height = authViewportHeight(m.height)
|
m.authViewport.Height = authViewportHeight(m.height)
|
||||||
|
m.ircViewport.Width = detailViewportWidth(m.width)
|
||||||
|
m.ircViewport.Height = detailViewportHeight(m.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) syncAuthViewport() {
|
func (m *Model) syncAuthViewport() {
|
||||||
@@ -283,6 +357,61 @@ func (m Model) authLogContent() string {
|
|||||||
return strings.Join(m.authLogs, "\n")
|
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 {
|
func isActive(entry twitch.StreamerEntry) bool {
|
||||||
return entry.Status == twitch.StreamerReady && entry.Live
|
return entry.Status == twitch.StreamerReady && entry.Live
|
||||||
}
|
}
|
||||||
@@ -298,6 +427,29 @@ func normalizeKey(value string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(value))
|
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 {
|
func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry {
|
||||||
logins := make([]string, 0, len(entries))
|
logins := make([]string, 0, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
|||||||
+230
-5
@@ -1,6 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -34,12 +35,13 @@ func TestViewDisplaysDashboardWithSelectedStreamerDetails(t *testing.T) {
|
|||||||
|
|
||||||
assertContainsAll(t, model.View(),
|
assertContainsAll(t, model.View(),
|
||||||
"Watching: alpha_live, beta_live",
|
"Watching: alpha_live, beta_live",
|
||||||
"alpha_live",
|
"Info",
|
||||||
|
"IRC",
|
||||||
"live | irc idle",
|
"live | irc idle",
|
||||||
"gamma",
|
"gamma",
|
||||||
"loading",
|
"loading",
|
||||||
"IRC Chat",
|
"Status: live",
|
||||||
"not joined",
|
"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{
|
updated, _ := dashboardModel(twitch.StreamerEntry{
|
||||||
ConfigLogin: "alpha",
|
ConfigLogin: "alpha",
|
||||||
Login: "alpha_live",
|
Login: "alpha_live",
|
||||||
@@ -194,7 +269,147 @@ func TestIRCUpdatesShowJoinedStatusOnly(t *testing.T) {
|
|||||||
if !next.ircDetails["alpha_live"].joined {
|
if !next.ircDetails["alpha_live"].joined {
|
||||||
t.Fatal("expected joined detail")
|
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) {
|
func TestWindowSizeKeepsSelectionVisible(t *testing.T) {
|
||||||
@@ -216,9 +431,19 @@ func dashboardModel(entries ...twitch.StreamerEntry) Model {
|
|||||||
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
|
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
|
||||||
model.width = 100
|
model.width = 100
|
||||||
model.height = 28
|
model.height = 28
|
||||||
|
model.resizeComponents()
|
||||||
|
model.syncIRCViewport(true)
|
||||||
return model
|
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) {
|
func assertContainsAll(t *testing.T, got string, wants ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, want := range wants {
|
for _, want := range wants {
|
||||||
|
|||||||
@@ -23,9 +23,18 @@ var (
|
|||||||
Border(lipgloss.NormalBorder()).
|
Border(lipgloss.NormalBorder()).
|
||||||
BorderForeground(lipgloss.Color("#30363D")).
|
BorderForeground(lipgloss.Color("#30363D")).
|
||||||
Padding(1, 2)
|
Padding(1, 2)
|
||||||
|
focusedPanelStyle = panelStyle.
|
||||||
|
BorderForeground(lipgloss.Color("#7CE38B"))
|
||||||
labelStyle = lipgloss.NewStyle().
|
labelStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#8B949E")).
|
Foreground(lipgloss.Color("#8B949E")).
|
||||||
Bold(true)
|
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().
|
statusLiveStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#7CE38B"))
|
Foreground(lipgloss.Color("#7CE38B"))
|
||||||
statusIdleStyle = lipgloss.NewStyle().
|
statusIdleStyle = lipgloss.NewStyle().
|
||||||
@@ -52,3 +61,9 @@ func newAuthViewport(width, height int) viewport.Model {
|
|||||||
vp.Style = lipgloss.NewStyle()
|
vp.Style = lipgloss.NewStyle()
|
||||||
return vp
|
return vp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newIRCViewport(width, height int) viewport.Model {
|
||||||
|
vp := viewport.New(width, height)
|
||||||
|
vp.Style = lipgloss.NewStyle()
|
||||||
|
return vp
|
||||||
|
}
|
||||||
|
|||||||
+59
-16
@@ -32,11 +32,20 @@ func (m Model) renderAuthView() string {
|
|||||||
|
|
||||||
func (m Model) renderStreamerView() string {
|
func (m Model) renderStreamerView() string {
|
||||||
header := m.renderDashboardHeader()
|
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)).
|
Width(streamerListWidth(m.width)).
|
||||||
Height(panelHeight(m.height)).
|
Height(panelHeight(m.height)).
|
||||||
Render(labelStyle.Render("Streamers") + "\n" + m.renderStreamerRows())
|
Render(labelStyle.Render("Streamers") + "\n" + m.renderStreamerRows())
|
||||||
right := panelStyle.
|
right := rightStyle.
|
||||||
Width(detailWidth(m.width)).
|
Width(detailWidth(m.width)).
|
||||||
Height(panelHeight(m.height)).
|
Height(panelHeight(m.height)).
|
||||||
Render(m.renderDetailPanel())
|
Render(m.renderDetailPanel())
|
||||||
@@ -66,40 +75,63 @@ func (m Model) renderDashboardHeader() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderDetailPanel() string {
|
func (m Model) renderDetailPanel() string {
|
||||||
entries := m.orderedStreamers()
|
header := m.renderDetailTabs()
|
||||||
selected := m.selectedRowIndex(entries)
|
entry, ok := m.selectedEntry()
|
||||||
if selected < 0 || selected >= len(entries) {
|
if !ok {
|
||||||
return labelStyle.Render("IRC Chat") + "\n\n" + mutedStyle.Render("No streamers configured")
|
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{
|
lines := []string{
|
||||||
labelStyle.Render("IRC Chat"),
|
|
||||||
"",
|
|
||||||
titleStyle.Render(streamerName(entry)),
|
|
||||||
"Status: " + statusText(entry),
|
"Status: " + statusText(entry),
|
||||||
}
|
}
|
||||||
if entry.Error != "" {
|
if entry.ChannelID != "" {
|
||||||
lines = append(lines, "Error: "+errorStyle.Render(entry.Error))
|
lines = append(lines, "Channel ID: "+entry.ChannelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
detail := m.ircDetails[normalizeKey(entry.Login)]
|
||||||
if !isActive(entry) {
|
if !isActive(entry) {
|
||||||
lines = append(lines, "IRC: "+mutedStyle.Render("inactive"))
|
lines = append(lines, "IRC: "+mutedStyle.Render("inactive"))
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
detail := m.ircDetails[normalizeKey(entry.Login)]
|
|
||||||
if detail.joined {
|
if detail.joined {
|
||||||
lines = append(lines, "IRC: "+accentStyle.Render("joined"))
|
lines = append(lines, "IRC: "+accentStyle.Render("joined"))
|
||||||
} else {
|
} else {
|
||||||
lines = append(lines, "IRC: "+mutedStyle.Render("not joined"))
|
lines = append(lines, "IRC: "+mutedStyle.Render("not joined"))
|
||||||
}
|
}
|
||||||
if detail.line != "" {
|
|
||||||
lines = append(lines, "", mutedStyle.Render(detail.line))
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) renderIRCTab() string {
|
||||||
|
return m.ircViewport.View()
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) renderStreamerRows() string {
|
func (m Model) renderStreamerRows() string {
|
||||||
entries := m.visibleStreamers()
|
entries := m.visibleStreamers()
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
@@ -224,6 +256,10 @@ func detailWidth(width int) int {
|
|||||||
return max(24, width-streamerListWidth(width)-11)
|
return max(24, width-streamerListWidth(width)-11)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detailViewportWidth(width int) int {
|
||||||
|
return max(16, detailWidth(width)-4)
|
||||||
|
}
|
||||||
|
|
||||||
func streamerListHeight(height int) int {
|
func streamerListHeight(height int) int {
|
||||||
if height <= 0 {
|
if height <= 0 {
|
||||||
return 14
|
return 14
|
||||||
@@ -231,6 +267,13 @@ func streamerListHeight(height int) int {
|
|||||||
return max(4, height-10)
|
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 {
|
func panelHeight(height int) int {
|
||||||
if height <= 0 {
|
if height <= 0 {
|
||||||
return 16
|
return 16
|
||||||
|
|||||||
Reference in New Issue
Block a user